Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84a8b53ab2 | ||
|
|
ad90b776d3 | ||
|
|
6dc3a2d68f | ||
|
|
1f62a5347b | ||
|
|
472448cf36 | ||
|
|
21e3ba5257 | ||
|
|
565207fcfc | ||
|
|
de2d9bfb57 | ||
|
|
e149d56c4f | ||
|
|
72269feb15 | ||
|
|
0f5bb2363d | ||
|
|
067281107b | ||
|
|
7272211b4c | ||
|
|
c619e3505b | ||
|
|
7b8261d803 | ||
|
|
86372d97ac | ||
|
|
e30371e1e9 | ||
|
|
482eab8c08 | ||
|
|
1b07d84097 | ||
|
|
1437f35531 | ||
|
|
25d71e96e5 | ||
|
|
e0f4c691fa | ||
|
|
645608593b | ||
|
|
b4e5df2730 | ||
|
|
364a7bc0a0 | ||
|
|
dae844fb5c | ||
|
|
d2a6be5f42 | ||
|
|
2317319f22 | ||
|
|
f47d7765d9 | ||
|
|
6b104778b3 | ||
|
|
b87885313b | ||
|
|
aa547ca299 | ||
|
|
b6c8053e04 | ||
|
|
7d2f4476ec | ||
|
|
029a5707c0 | ||
|
|
3a73c86437 | ||
|
|
5038711c5e | ||
|
|
f1d843d797 | ||
|
|
aa9785620f | ||
|
|
7763dbecba | ||
|
|
61e8b52838 | ||
|
|
c94760744e | ||
|
|
4cacd7e647 | ||
|
|
e3b7d97ece | ||
|
|
3ff71a49f7 | ||
|
|
3bb8e1c9b5 | ||
|
|
d4dc8acc4e | ||
|
|
43a8d62b11 | ||
|
|
5fb4014842 | ||
|
|
f4b9dd6eb5 | ||
|
|
0ec94b4759 | ||
|
|
014234d262 | ||
|
|
1e36cb322e | ||
|
|
e5f6250070 | ||
|
|
23e3d0da88 | ||
|
|
e72f81b0ce | ||
|
|
063f991cb5 | ||
|
|
8533de96a5 | ||
|
|
6baf78af00 | ||
|
|
87bbbfea8a | ||
|
|
e2251ad2af | ||
|
|
d83cec328b | ||
|
|
912f6efdcb | ||
|
|
17b812e649 | ||
|
|
e9a88ba99e | ||
|
|
de29f2994a | ||
|
|
2eff780d95 | ||
|
|
3dfbc35c49 | ||
|
|
cff28c1f7f | ||
|
|
639753d0e0 | ||
|
|
30c898be87 | ||
|
|
60c8f3cc64 | ||
|
|
eaa856152d | ||
|
|
0b9791ffdc | ||
|
|
f6defefb58 | ||
|
|
f3224d0988 | ||
|
|
450e6ba553 | ||
|
|
2cd93fe119 | ||
|
|
f807b22ea6 | ||
|
|
b6e9967da6 | ||
|
|
16e62f7514 | ||
|
|
13ce9436b3 | ||
|
|
d3ced63397 | ||
|
|
758b696ca5 | ||
|
|
0d3ce45374 | ||
|
|
f35666ff0a | ||
|
|
0758c3585d | ||
|
|
dd882c70e7 | ||
|
|
d05c9fbb31 | ||
|
|
7bde42ffb9 | ||
|
|
0d3df0fd0b | ||
|
|
ab996417e6 |
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.39.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.40.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -64,7 +64,7 @@ runs:
|
||||
scanners: 'vuln'
|
||||
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
|
||||
timeout: '5m'
|
||||
version: 'v0.72.0'
|
||||
version: 'v0.74.0'
|
||||
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
|
||||
env:
|
||||
TRIVY_IGNOREFILE: '.trivyignore.yaml'
|
||||
@@ -81,7 +81,7 @@ runs:
|
||||
scanners: 'vuln'
|
||||
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
|
||||
timeout: '5m'
|
||||
version: 'v0.72.0'
|
||||
version: 'v0.74.0'
|
||||
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
|
||||
env:
|
||||
TRIVY_IGNOREFILE: '.trivyignore.yaml'
|
||||
|
||||
@@ -199,7 +199,7 @@ You MUST structure your response using this EXACT format. Do NOT include anythin
|
||||
|
||||
### For Check Logic Bug
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Check Logic Bug
|
||||
|
||||
**Component**: {component from issue template}
|
||||
@@ -297,7 +297,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
|
||||
|
||||
### For Bug (non-check)
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Bug
|
||||
|
||||
**Component**: {CLI/SDK | API | UI | Dashboard | MCP Server | Other}
|
||||
@@ -378,7 +378,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
|
||||
|
||||
### For Already Fixed
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Already Fixed
|
||||
|
||||
**Component**: {component}
|
||||
@@ -401,7 +401,7 @@ Upgrade to the latest version. Close the issue as resolved.
|
||||
|
||||
### For Feature Request
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Feature Request
|
||||
|
||||
**Component**: {component}
|
||||
@@ -419,7 +419,7 @@ Upgrade to the latest version. Close the issue as resolved.
|
||||
|
||||
### For Not a Bug
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Not a Bug
|
||||
|
||||
**Component**: {component}
|
||||
@@ -440,7 +440,7 @@ Upgrade to the latest version. Close the issue as resolved.
|
||||
|
||||
### For Needs More Information
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Needs More Information
|
||||
|
||||
**Component**: {component or "Unknown"}
|
||||
|
||||
@@ -52,6 +52,16 @@ provider/alibabacloud:
|
||||
- any-glob-to-any-file: "prowler/providers/alibabacloud/**"
|
||||
- any-glob-to-any-file: "tests/providers/alibabacloud/**"
|
||||
|
||||
provider/huaweicloud:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/huaweicloud/**"
|
||||
- any-glob-to-any-file: "tests/providers/huaweicloud/**"
|
||||
|
||||
provider/image:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/image/**"
|
||||
- any-glob-to-any-file: "tests/providers/image/**"
|
||||
|
||||
provider/cloudflare:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/cloudflare/**"
|
||||
@@ -82,6 +92,11 @@ provider/linode:
|
||||
- any-glob-to-any-file: "prowler/providers/linode/**"
|
||||
- any-glob-to-any-file: "tests/providers/linode/**"
|
||||
|
||||
provider/stackit:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/stackit/**"
|
||||
- any-glob-to-any-file: "tests/providers/stackit/**"
|
||||
|
||||
github_actions:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ".github/workflows/*"
|
||||
|
||||
@@ -8,11 +8,11 @@ These JSON templates are used with the `slackapi/slack-github-action` using the
|
||||
|
||||
### Available Templates
|
||||
|
||||
**Container Releases**
|
||||
#### Container Releases
|
||||
- `container-release-started.json`: Simple one-line notification when container push starts
|
||||
- `container-release-completed.json`: Simple one-line notification when container release completes
|
||||
|
||||
**Deployments**
|
||||
#### Deployments
|
||||
- `deployment-started.json`: Deployment start notification with Block Kit formatting
|
||||
- `deployment-completed.json`: Deployment completion notification (updates the start message)
|
||||
|
||||
@@ -416,17 +416,17 @@ For deployments that start with one message and update it with the final status:
|
||||
### Container Release (Simple One-Line)
|
||||
|
||||
**Start message:**
|
||||
```
|
||||
```text
|
||||
API container release 4.5.0 push started... View run
|
||||
```
|
||||
|
||||
**Completion message (success):**
|
||||
```
|
||||
```text
|
||||
[✓] API container release 4.5.0 push completed successfully! View run
|
||||
```
|
||||
|
||||
**Completion message (failure):**
|
||||
```
|
||||
```text
|
||||
[✗] API container release 4.5.0 push failed View run
|
||||
```
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ jobs:
|
||||
# Pin must match .pre-commit-config.yaml so prek and CI behave identically.
|
||||
# pnpm dlx doesn't accept --ignore-scripts as a flag; the env var
|
||||
# disables postinstall scripts on transitives the same way.
|
||||
#
|
||||
# Files come from `git ls-files` because markdownlint doesn't traverse
|
||||
# dot-directories, so `.github/**/*.md` went unlinted.
|
||||
# `.markdownlintignore` still applies to the listed paths.
|
||||
env:
|
||||
pnpm_config_ignore_scripts: 'true'
|
||||
run: pnpm dlx markdownlint-cli@0.45.0 '**/*.md'
|
||||
run: git ls-files -z '*.md' | xargs -0 -r pnpm dlx markdownlint-cli@0.45.0 --
|
||||
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
|
||||
- name: Publish prowler-mcp package to PyPI
|
||||
if: steps.pypi-check.outputs.skip != 'true'
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
packages-dir: ${{ env.WORKING_DIRECTORY }}/dist/
|
||||
print-hash: true
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
name: 'SDK: Package Checks'
|
||||
|
||||
# Rehearses the PyPI release on every packaging change and once a week, from the
|
||||
# consumer's side. Two incidents this guards against:
|
||||
#
|
||||
# - 5.38.0 shipped an unsatisfiable Requires-Dist (cryptography==50.0.0 while
|
||||
# alibabacloud-tea-openapi and pyopenssl cap it below 49). A [tool.uv] override hid
|
||||
# the conflict inside the repo; pip could not install the wheel and silently
|
||||
# resolved `pip install prowler` to 5.37.1 for a week.
|
||||
# - 5.39.0 never published: an unpinned build backend started emitting core metadata
|
||||
# 2.5 and the twine bundled in the publish action rejected it.
|
||||
#
|
||||
# Both were only detectable at release time because nothing built and installed the
|
||||
# artifact earlier. The weekly run also catches releases yanked from PyPI after we
|
||||
# pinned them (zstd 1.5.7.3, "buggy - not thread safe", sat in uv.lock for months).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
schedule:
|
||||
# Monday 06:00 UTC. Yanks and upstream releases happen without a commit here.
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
env:
|
||||
# Must equal the twine bundled in the pypa/gh-action-pypi-publish pin used by
|
||||
# sdk-pypi-release.yml (requirements/runtime.txt in that repo at the pinned tag).
|
||||
# A metadata check that passes here must pass there.
|
||||
TWINE_VERSION: '7.0.0'
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
# Scheduled and manual runs always execute; pushes and PRs only when a packaging
|
||||
# input changed. Jobs skipped this way still report success to branch protection.
|
||||
run: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || steps.filter.outputs.any_changed == 'true' }}
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
api.github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Detect packaging changes
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
id: filter
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
README.md
|
||||
util/replicate_pypi_package.py
|
||||
util/check_yanked_pins.py
|
||||
api/pyproject.toml
|
||||
api/uv.lock
|
||||
mcp_server/pyproject.toml
|
||||
mcp_server/uv.lock
|
||||
.github/workflows/sdk-package-checks.yml
|
||||
.github/workflows/sdk-pypi-release.yml
|
||||
.github/actions/setup-python-uv/**
|
||||
|
||||
install-from-wheel:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version:
|
||||
- '3.10'
|
||||
- '3.11'
|
||||
- '3.12'
|
||||
- '3.13'
|
||||
package:
|
||||
- 'prowler'
|
||||
include:
|
||||
# prowler-cloud is the same tree renamed by util/replicate_pypi_package.py;
|
||||
# one Python is enough to prove the rename and its build still work.
|
||||
- python-version: '3.12'
|
||||
package: 'prowler-cloud'
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
api.github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python with uv
|
||||
uses: ./.github/actions/setup-python-uv
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
install-dependencies: 'false'
|
||||
|
||||
- name: Rename package to prowler-cloud
|
||||
if: matrix.package == 'prowler-cloud'
|
||||
run: |
|
||||
pip install --no-cache-dir toml
|
||||
python util/replicate_pypi_package.py
|
||||
|
||||
- name: Build sdist and wheel
|
||||
run: uv build
|
||||
|
||||
- name: Check metadata with the release workflow's twine
|
||||
run: uvx --from "twine==${TWINE_VERSION}" twine check --strict dist/*
|
||||
|
||||
- name: Install the wheel with pip into a clean virtualenv
|
||||
# Plain pip, --isolated, from outside the repo: consumers never see [tool.uv]
|
||||
# override-dependencies or constraint-dependencies, so neither does this step.
|
||||
run: |
|
||||
python -m venv "${RUNNER_TEMP}/consumer"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
|
||||
cd "${RUNNER_TEMP}"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir "${GITHUB_WORKSPACE}"/dist/*.whl
|
||||
|
||||
- name: Smoke test the installed CLI
|
||||
run: |
|
||||
cd "${RUNNER_TEMP}"
|
||||
"${RUNNER_TEMP}/consumer/bin/prowler" --version
|
||||
# Loads every AWS check module from the installed wheel: catches files missing
|
||||
# from the package. grep fails the step if the summary line never appears.
|
||||
"${RUNNER_TEMP}/consumer/bin/prowler" aws --list-checks | grep 'available checks'
|
||||
|
||||
pinned-releases-not-yanked:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
api.github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Check every pinned and locked release against PyPI
|
||||
run: python util/check_yanked_pins.py . api mcp_server
|
||||
@@ -84,8 +84,18 @@ jobs:
|
||||
- name: Build Prowler package
|
||||
run: uv build
|
||||
|
||||
- name: Verify the wheel installs with pip
|
||||
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
|
||||
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
|
||||
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
|
||||
run: |
|
||||
python -m venv "${RUNNER_TEMP}/consumer"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
|
||||
cd "${RUNNER_TEMP}"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
|
||||
|
||||
- name: Publish Prowler package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
print-hash: true
|
||||
|
||||
@@ -128,7 +138,17 @@ jobs:
|
||||
- name: Build prowler-cloud package
|
||||
run: uv build
|
||||
|
||||
- name: Verify the wheel installs with pip
|
||||
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
|
||||
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
|
||||
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
|
||||
run: |
|
||||
python -m venv "${RUNNER_TEMP}/consumer"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
|
||||
cd "${RUNNER_TEMP}"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
|
||||
|
||||
- name: Publish prowler-cloud package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
print-hash: true
|
||||
|
||||
@@ -135,8 +135,8 @@ vulnerabilities:
|
||||
# Modules compiled into the Trivy binary the images ship. The binary is pinned by version
|
||||
# and verified by checksum in the Dockerfile; only a rebuild by its vendor moves these.
|
||||
# CVE-2026-71556 affects go-git worktree operations that can follow symlinks outside a
|
||||
# cloned repository. Trivy 0.72.0 contains go-git 5.19.1, and even the latest published
|
||||
# Trivy release, 0.73.0, still pins that vulnerable version:
|
||||
# cloned repository. Trivy 0.73.0, the latest published release and the version the
|
||||
# images ship, still pins that vulnerable version:
|
||||
# https://github.com/aquasecurity/trivy/blob/v0.73.0/go.mod#L46
|
||||
# Trivy main already contains the 5.19.2 fix, but no published release includes it yet:
|
||||
# https://github.com/aquasecurity/trivy/commit/a2edba9a03987ba0d2ebc8212c1a9a1e6979497b
|
||||
@@ -164,7 +164,3 @@ vulnerabilities:
|
||||
purls:
|
||||
- "pkg:golang/oras.land/oras-go/v2"
|
||||
expired_at: 2026-12-31
|
||||
- id: CVE-2026-39822
|
||||
purls:
|
||||
- "pkg:golang/stdlib"
|
||||
expired_at: 2026-12-31
|
||||
|
||||
@@ -8,15 +8,15 @@ ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
|
||||
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
|
||||
ENV POWERSHELL_TELEMETRY_OPTOUT=1
|
||||
|
||||
ARG TRIVY_VERSION=0.72.0
|
||||
ARG TRIVY_VERSION=0.74.0
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
ARG ZIZMOR_VERSION=1.24.1
|
||||
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
|
||||
|
||||
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
|
||||
ARG TRIVY_SHA256_AMD64=bbb64b9695866ce4a7a8f5c9592002c5961cab378577fa3f8a040df362b9b2ea
|
||||
ARG TRIVY_SHA256_ARM64=2ca2c023109c2db6b2b77366b6717291452d4531167377d95c79547f0c8e3467
|
||||
ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a
|
||||
ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5
|
||||
ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0
|
||||
ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56
|
||||
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
|
||||
@@ -26,6 +26,7 @@ ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed9
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget libicu76 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
|
||||
build-essential pkg-config libzstd-dev zlib1g-dev \
|
||||
&& apt-get install -y --no-install-recommends --only-upgrade util-linux \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PowerShell
|
||||
|
||||
@@ -4,6 +4,23 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [1.40.1] (Prowler v5.39.1)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Bump alibabacloud-tea-openapi to 0.4.6, oci to 2.184.1 and pyopenssl to 26.4.0 to match the SDK; the cryptography override now names its actual blockers (azure-cli-core pins msal below 1.37, workos 8.3.0 requires cryptography 48) [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Trivy from v0.72.0 to v0.73.0 in the container image, fixing HIGH CVE-2026-46600 in the bundled `golang.org/x/net` [(#12445)](https://github.com/prowler-cloud/prowler/pull/12445)
|
||||
- Trivy v0.74.0 and Debian util-linux 2.41.5-0+deb13u1 in the API container image, patching Go standard library vulnerabilities and CVE-2026-53615 [(#12470)](https://github.com/prowler-cloud/prowler/pull/12470)
|
||||
|
||||
---
|
||||
|
||||
## [1.40.0] (Prowler v5.39.0)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
@@ -7,15 +7,15 @@ ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
|
||||
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
|
||||
ENV POWERSHELL_TELEMETRY_OPTOUT=1
|
||||
|
||||
ARG TRIVY_VERSION=0.72.0
|
||||
ARG TRIVY_VERSION=0.74.0
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
ARG ZIZMOR_VERSION=1.24.1
|
||||
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
|
||||
|
||||
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
|
||||
ARG TRIVY_SHA256_AMD64=bbb64b9695866ce4a7a8f5c9592002c5961cab378577fa3f8a040df362b9b2ea
|
||||
ARG TRIVY_SHA256_ARM64=2ca2c023109c2db6b2b77366b6717291452d4531167377d95c79547f0c8e3467
|
||||
ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a
|
||||
ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5
|
||||
ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0
|
||||
ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56
|
||||
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
|
||||
@@ -36,6 +36,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libtool \
|
||||
libxslt1-dev \
|
||||
python3-dev \
|
||||
&& apt-get install -y --no-install-recommends --only-upgrade util-linux \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PowerShell
|
||||
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.40.0"
|
||||
version = "1.41.0"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
@@ -92,8 +92,7 @@ extend-select = [
|
||||
|
||||
[tool.uv]
|
||||
# Transitive pins matching master to avoid silent drift; bump deliberately.
|
||||
# workos and pyopenssl run ahead of master: the versions master pins cap cryptography
|
||||
# below 48, so both were bumped to versions that allow it (PROWLER-2310).
|
||||
# workos is api-only; pyopenssl matches master (PROWLER-2310).
|
||||
constraint-dependencies = [
|
||||
"about-time==4.2.1",
|
||||
"adal==1.2.7",
|
||||
@@ -130,7 +129,7 @@ constraint-dependencies = [
|
||||
"alibabacloud-sls20201230==5.9.0",
|
||||
"alibabacloud-sts20150401==1.1.6",
|
||||
"alibabacloud-tea==0.4.3",
|
||||
"alibabacloud-tea-openapi==0.4.5",
|
||||
"alibabacloud-tea-openapi==0.4.6",
|
||||
"alibabacloud-tea-util==0.3.14",
|
||||
"alibabacloud-tea-xml==0.0.3",
|
||||
"alibabacloud-vpc20160428==6.13.0",
|
||||
@@ -339,7 +338,7 @@ constraint-dependencies = [
|
||||
"nltk==3.9.4",
|
||||
"numpy==2.2.6",
|
||||
"oauthlib==3.3.1",
|
||||
"oci==2.183.0",
|
||||
"oci==2.184.1",
|
||||
"openai==1.109.1",
|
||||
"openstacksdk==4.2.0",
|
||||
"opentelemetry-api==1.39.1",
|
||||
@@ -380,7 +379,7 @@ constraint-dependencies = [
|
||||
"pylint==3.2.5",
|
||||
"pymsalruntime==0.18.1",
|
||||
"pynacl==1.6.2",
|
||||
"pyopenssl==26.2.0",
|
||||
"pyopenssl==26.4.0",
|
||||
"pyparsing==3.3.2",
|
||||
"pyreadline3==3.5.4",
|
||||
"pysocks==1.7.1",
|
||||
@@ -458,7 +457,7 @@ constraint-dependencies = [
|
||||
"zipp==3.23.0",
|
||||
"zope-event==6.1",
|
||||
"zope-interface==8.2",
|
||||
"zstd==1.5.7.3"
|
||||
"zstd==1.5.7.2"
|
||||
]
|
||||
# prowler@master needs okta==3.4.2, but cartography 0.138.1 requires okta<1.0.0.
|
||||
# Attack Paths does not ingest Okta today, so override the Cartography
|
||||
@@ -485,7 +484,12 @@ constraint-dependencies = [
|
||||
# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive.
|
||||
override-dependencies = [
|
||||
"okta==3.4.2",
|
||||
# alibabacloud-tea-openapi 0.4.5 caps cryptography below 49 and is the latest release.
|
||||
# prowler requires cryptography==50.0.0. Two api-only dependencies still cap it below
|
||||
# 49 and cannot move yet: msal, pinned exactly by azure-cli-core (2.83.0 -> 1.35.0b1,
|
||||
# 2.89.1 -> 1.36.0, both <49; cartography needs azure-cli-core), and workos 8.3.0
|
||||
# (~=48.0; workos 10.1.1+ needs ~=50.0 and is a separate SDK upgrade). This api is
|
||||
# deployed from this lock with `uv sync --locked`, so the override applies to what runs.
|
||||
# Remove when azure-cli-core pins msal>=1.37.0 and workos is on 10.x.
|
||||
"cryptography==50.0.0",
|
||||
"azure-mgmt-containerservice==34.1.0",
|
||||
"microsoft-kiota-abstractions==1.9.10",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.40.0
|
||||
version: 1.41.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ constraints = [
|
||||
{ name = "alibabacloud-sls20201230", specifier = "==5.9.0" },
|
||||
{ name = "alibabacloud-sts20150401", specifier = "==1.1.6" },
|
||||
{ name = "alibabacloud-tea", specifier = "==0.4.3" },
|
||||
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.5" },
|
||||
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.6" },
|
||||
{ name = "alibabacloud-tea-util", specifier = "==0.3.14" },
|
||||
{ name = "alibabacloud-tea-xml", specifier = "==0.0.3" },
|
||||
{ name = "alibabacloud-vpc20160428", specifier = "==6.13.0" },
|
||||
@@ -254,7 +254,7 @@ constraints = [
|
||||
{ name = "nltk", specifier = "==3.9.4" },
|
||||
{ name = "numpy", specifier = "==2.2.6" },
|
||||
{ name = "oauthlib", specifier = "==3.3.1" },
|
||||
{ name = "oci", specifier = "==2.183.0" },
|
||||
{ name = "oci", specifier = "==2.184.1" },
|
||||
{ name = "openai", specifier = "==1.109.1" },
|
||||
{ name = "openstacksdk", specifier = "==4.2.0" },
|
||||
{ name = "opentelemetry-api", specifier = "==1.39.1" },
|
||||
@@ -295,7 +295,7 @@ constraints = [
|
||||
{ name = "pylint", specifier = "==3.2.5" },
|
||||
{ name = "pymsalruntime", specifier = "==0.18.1" },
|
||||
{ name = "pynacl", specifier = "==1.6.2" },
|
||||
{ name = "pyopenssl", specifier = "==26.2.0" },
|
||||
{ name = "pyopenssl", specifier = "==26.4.0" },
|
||||
{ name = "pyparsing", specifier = "==3.3.2" },
|
||||
{ name = "pyreadline3", specifier = "==3.5.4" },
|
||||
{ name = "pysocks", specifier = "==1.7.1" },
|
||||
@@ -373,7 +373,7 @@ constraints = [
|
||||
{ name = "zipp", specifier = "==3.23.0" },
|
||||
{ name = "zope-event", specifier = "==6.1" },
|
||||
{ name = "zope-interface", specifier = "==8.2" },
|
||||
{ name = "zstd", specifier = "==1.5.7.3" },
|
||||
{ name = "zstd", specifier = "==1.5.7.2" },
|
||||
]
|
||||
overrides = [
|
||||
{ name = "azure-mgmt-containerservice", specifier = "==34.1.0" },
|
||||
@@ -860,7 +860,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-tea-openapi"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -869,9 +869,9 @@ dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "darabonba-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/73/fb0c4d44759791ecdf269fc715c1e810fa1aba3981bfaaf8a01f61899296/alibabacloud_tea_openapi-0.4.5.tar.gz", hash = "sha256:75fa1f4360a46e41f5bf5f8d4917e52efb6f64885839bc1328c35590670c97b9", size = 26616, upload-time = "2026-07-14T13:15:39.364Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/34/1918a2d780676494365c7f945bfab397ecddb988054d78025bd26f438977/alibabacloud_tea_openapi-0.4.6.tar.gz", hash = "sha256:dafc32401712f5b21c12dc3d05ba887a91ad156d9b49a7662279f9fd90526fb2", size = 26742, upload-time = "2026-08-17T08:34:11.55Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/ec/6b368a10e9c2e8b1b394c69b96ac213ae66e8c4895e0baa1ffaf7178fd32/alibabacloud_tea_openapi-0.4.5-py3-none-any.whl", hash = "sha256:338979095c7beda80a5b413c31262892cafdc12069dde4ce4fc2e4f7ce0fc609", size = 33333, upload-time = "2026-07-14T13:15:38.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/00/2f534f5884e5f299d9cb3a1e8be2def8071bc6a6e2a192ba4ff2a8cd5e02/alibabacloud_tea_openapi-0.4.6-py3-none-any.whl", hash = "sha256:c9e1727b9fb2936f487d050fc3590c99f9f2065256dc3a927e5b61f414674ed6", size = 33448, upload-time = "2026-08-17T08:34:10.472Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4426,7 +4426,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "oci"
|
||||
version = "2.183.0"
|
||||
version = "2.184.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -4439,9 +4439,9 @@ dependencies = [
|
||||
{ name = "pytz" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/2a/77bd6cbf1c69b2f368fe3d6462d84369b0cba15e37ce713cdc08d459b95a/oci-2.183.0.tar.gz", hash = "sha256:ff572ef5f2030a788796bb509d257e6a41c6510ef9b4b6a75a079efd06e533ce", size = 17759723, upload-time = "2026-07-28T06:02:29.76Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/2d/fa5368cfabb868f4111c6978e8b5f66aa3a55076c40c1a59ac3081b0227b/oci-2.184.1.tar.gz", hash = "sha256:617dad69caf8dd6e521d224dbc3e8a8bc289906943a0214fd2c3419094e26435", size = 17990631, upload-time = "2026-08-11T11:01:26.194Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/de/8574b3e527996a099d196e87794a4652d91a0c3185fcc7fdbb5649b75a8a/oci-2.183.0-py3-none-any.whl", hash = "sha256:bd789c98a94d7c5ea08c20d11dcf68c9cd1ad479b134727d80a930b84387070b", size = 36133501, upload-time = "2026-07-28T06:02:18.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/63/5ae22e42aaf96a5da74dc2b9de449c78b4d7418cce621d5da723b3e49f32/oci-2.184.1-py3-none-any.whl", hash = "sha256:bd814e38a70da2190e721937455a08689ab13c0750bd2ef8dd0c98b2dc5a38ea", size = 36628063, upload-time = "2026-08-11T11:01:18.178Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4835,8 +4835,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.38.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b3d174d0c1eb202ed7cb9a9daf0500683f4443be" }
|
||||
version = "5.40.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b6e9967da6bebd6c7b8b237317a2a95e2e0c65bc" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4935,7 +4935,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.40.0"
|
||||
version = "1.41.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -5426,15 +5426,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyopenssl"
|
||||
version = "26.2.0"
|
||||
version = "26.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6623,39 +6623,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "1.5.7.3"
|
||||
version = "1.5.7.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/62/b9c075ad664e7c4cbb3d8d2be7c246506abe1bc7f778eb58d260ef9538c8/zstd-1.5.7.3.tar.gz", hash = "sha256:403e5205f4ac04b92e6b0cda654be2f51de268228a0db0067bc087faacf2f495", size = 672559, upload-time = "2026-01-08T16:24:43.361Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/78/9a476e09c825304df47b98be80d1ffe223733b03550af71325415028f615/zstd-1.5.7.2.tar.gz", hash = "sha256:6d8684c69009be49e1b18ec251a5eb0d7e24f93624990a8a124a1da66a92fc8a", size = 670481, upload-time = "2025-06-23T12:36:08.131Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/0d/8c89c0d010b58c21a7865a239790bb1c6822029c053b1ded858d6b573e3a/zstd-1.5.7.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a3c1781a24e2ced2c0ddee11d45b1f04018b03615eeb622a62eca4d56d3358a", size = 267641, upload-time = "2026-01-08T16:30:50.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/6d/155d8c344d96eca2a5a003a5ddd63373a5f13591fd5cf2b9490250d6805a/zstd-1.5.7.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6c7c81056362b60a04baa34632e713d596662a860ec34efd8e9b109c10e6ec7", size = 230962, upload-time = "2026-01-08T16:30:49.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/c7/ab93916a26eb58cd501ad701974c31b4bc67a7f6abd6c24bef8fe4d7649b/zstd-1.5.7.3-cp311-cp311-manylinux_2_14_x86_64.whl", hash = "sha256:e564f34a55effc7d654eb293468edc80b64d476b0f899f82760ecd8323223ff5", size = 304166, upload-time = "2026-01-10T11:17:45.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/54/27a7040a360019a4602343e3c98c0c0a140f382186002c01e1992fd21837/zstd-1.5.7.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:fbc49a57188184931d5e3c9f1133cad7eea5a370a9e9418fb8122d58c14340a5", size = 1540288, upload-time = "2026-01-08T17:50:26.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/93/4a4d4edd1b2e809e0ebbb16000404bdcc9a09743c04ee1661442c9581b75/zstd-1.5.7.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d121d3e63722819e1fe5effbcd9628d8a7cfea0cddabcc5bb37ea861a6a83424", size = 1619134, upload-time = "2026-01-08T17:50:32.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/6b/cd6f0a7f4f0d98e4110aa77763cf3e85f594d983ea9ca3d64cc0cee10684/zstd-1.5.7.3-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:621f2e7ca8e9eb52a83eb9c91ec3cd283d87591bf75cc658de486b65f44742c7", size = 300166, upload-time = "2026-01-10T11:12:27.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/3f/c717e0d15127d04b7fa58ba9b4c56e8b88b803048b9766cd9d158dbb22ea/zstd-1.5.7.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:c1950fcae690ba32d0f31702b335c548fb42547821565925e48576afdad774a5", size = 1525776, upload-time = "2026-01-08T17:50:35.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/a2/1813cd787d1a2f9ab8e8a90d28dcbc8e8098997dd04de38897ea8e75dd08/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bac4f0d03da69115878bedbfa03c4a3f64364e8396b432028c4ce0f05141a0fb", size = 2096057, upload-time = "2026-01-08T17:50:33.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/ce/f5a3c7c12de458dd9ce15c484d627fe5412b60c155da23dacb5fcf08d9d5/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:da0ab134b7fd28023dedf013751ca850de300a090eb11f689d2a1c178c87d9dc", size = 2132659, upload-time = "2026-01-08T17:50:29.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/66/151f9546498bfd8971a0b6ad67d87c26d7a0df17d57f724da674f3778666/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b9923175842ee8f7602ec9cc578f5fc396896f0e8460d3ac9a5adc3cea77244e", size = 2124811, upload-time = "2026-01-08T17:50:37.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/34/4d2dbb36cb2373d3f115c047cb901b64f89de0703d10779da39de9453812/zstd-1.5.7.3-cp311-cp311-win32.whl", hash = "sha256:0612b604948d7b58aecc6788c7ceb53c5f21d94a155bb6ea9bd0f54ffa43725d", size = 150363, upload-time = "2026-01-08T17:11:02.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/de/f53687e0dd8c0d0ebfaed9ae88f6a96a1a0388ae7424b469e74bb17ac57d/zstd-1.5.7.3-cp311-cp311-win_amd64.whl", hash = "sha256:5b7f8c81b2bd3b62c0345242247d484cafa4b518d59d18619813d9225af5c5c3", size = 167577, upload-time = "2026-01-08T17:11:03.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/58/d4a6a902e229e953ed273fe9b78587ed31f57567aa68d3e34af6056e42af/zstd-1.5.7.3-cp311-cp311-win_arm64.whl", hash = "sha256:ea112e3acd9e1765adca35df7b54ac75b36194290f64ea03a3a59664209c8527", size = 157238, upload-time = "2026-01-08T16:36:06.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ed/5a3bf2e29dc56d4cc7619929bb51f0c758de6d02967cc73c5d8755a862c0/zstd-1.5.7.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01a39efb0eeab7cc45cb308618233b624b0840d5e16dcf85456b6cca0592f203", size = 268124, upload-time = "2026-01-08T16:29:57.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/1d/efc2074ac90af938e78f2ed4004639fe24f294d9086c5280f8d9a02b9897/zstd-1.5.7.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a8e8838cf35fa3987bfe1958584cc22e1797efce8e155a63544b4144fc671f8", size = 230988, upload-time = "2026-01-08T16:29:55.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/52/178393b8d70e23fba67f42dfce4663e4e8a30867110168beb490a36d4639/zstd-1.5.7.3-cp312-cp312-manylinux_2_14_i686.whl", hash = "sha256:f3920ac1d1cc7e9f252f3e29f217fe3cd36f2191bb3dbcae826c29e189b7ad54", size = 300207, upload-time = "2026-01-10T11:26:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/7a/8dcd86a2efb2ed3f9dae39545a05d3c7ed26c7678330786ce4a44cd8b099/zstd-1.5.7.3-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:143f9062953fb5590cbd47c1040d357336742c79696bf90b6d5b835279a68304", size = 304154, upload-time = "2026-01-10T11:17:40.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/ce/0c96905ab01ffe0e53a3cec8132123b82db26bd583a71608029bcc789ebc/zstd-1.5.7.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d1fd8647e47e1f21b345e192f1a279e925678c23dad8236b547d04456cd699", size = 2162222, upload-time = "2026-01-08T18:02:22.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c4/db4807d6a68b4628c74fd379de7e3c67ec34f19a2a80ac246b3837cde6cb/zstd-1.5.7.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1538db419afa62773cf534fc7f3009ff59ecf55ecee4e889587ac2ef0010ed8", size = 2201732, upload-time = "2026-01-08T18:02:20.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/99/c19a3c0f5580ff9c33a74f06d98d6060ed1fa6bd09b55aed9be852ec191f/zstd-1.5.7.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5efd16adb092e2a547a7d51cfdaf6fd5680528227684c5bafc7669ab4a55f41", size = 2096459, upload-time = "2026-01-08T18:02:25.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/fd/02eac30419475dbe50212c119043a2d0698a0cbc756da85fd3fd9abddf42/zstd-1.5.7.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:39b3438e64637d80a5b1860526903b92020acb9bae9ceb5adffd9838c1441328", size = 2125442, upload-time = "2026-01-08T18:02:17.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/43/3a16ff0a8c913bb9825379db1bd533c75c57c2d2f31dd9111aa9b53711f4/zstd-1.5.7.3-cp312-cp312-win32.whl", hash = "sha256:cbf48c53461e224ffc2490cfe5120a1ff40d14c84d2b512c6d6d99fc91685cf3", size = 150367, upload-time = "2026-01-08T17:03:40.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/83/b85875d7428e63dfa9247e41d17fac611443c774f7892f8643bd4164a6b2/zstd-1.5.7.3-cp312-cp312-win_amd64.whl", hash = "sha256:943a189910f2fea997462e3e4d7fbf727a06d231ef801ebee557b1c87568981c", size = 167604, upload-time = "2026-01-08T17:03:41.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/42/cf291e26804de2f55500cdac93f5e9fa6267cf315def8aa402529bae3a87/zstd-1.5.7.3-cp312-cp312-win_arm64.whl", hash = "sha256:85c4d508f8109afa7c51c4960626c3325af2cf1e442c6c36ebfea15d04757e3f", size = 157241, upload-time = "2026-01-08T16:47:34.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/b8/d13d584867d5eb1bc607877a870858e02a256d4706a4274e475413a000aa/zstd-1.5.7.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:76c49ea969bc08389ea59155cea7c5dea224522ffc62f443f3c0a915f5fd184d", size = 260025, upload-time = "2026-01-08T16:57:45.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/a1/1e5faf75bedfd2bfccfb83e18736b115bed6e348504bd21800cd8f30dcea/zstd-1.5.7.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b1a638ff3dfce8f4cb1203c662fb5606dd99b4a62c5ddc4c406d2d1326bcfdd", size = 221038, upload-time = "2026-01-08T17:16:32.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/2c/0fe74d8b2029eef8000bc71aac5b3e5b55d00581238711cf627814183ea3/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5e96a5cb100a0edc162935227f2d9784b1031ce4a8a83e96e66eae2673c10143", size = 326792, upload-time = "2026-01-08T16:57:35.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/e0/2c7f081f3524f872128ff31bea2acb6b21cb1dacccef920eb6a1a77a87c6/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bda0bbf3a9553720cd33f1f85940a259656c7ffba4be717ff82b7f062052188", size = 322283, upload-time = "2026-01-08T16:57:36.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/a7/3bebfcc18d66b90bc7b506a61b2ff4af5ee1b0b16e784ea644afa06241c5/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac36e4022422f6e49b3f07bdbb8a964fd348223d3dc9c82ad5398a4f0432a719", size = 311553, upload-time = "2026-01-08T16:57:38.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/75/8a791cae2c98e5e44a158e15db50d21b7ec0b37aeaffa68d151bc8ffb6d6/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:fa4d760a220541b18ce732a3a2cf7547ea05afc76d05b3b39edebfeb721f6079", size = 317071, upload-time = "2026-01-08T16:36:07.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/25/b6624e6b08d515242154436c9d06fb20b790d300ac82e84f3c4c133e25e1/zstd-1.5.7.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a69e60146bf8aaa6a0e6c9a94a7c5f3133d68091e2e5c5a3c5ababf71fd5ec7a", size = 167654, upload-time = "2026-01-08T17:00:56.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/2a/0885f6f1921ec1ef4a8f8ab29ab0a335cc867abe4c7aaa4e5031435a32a5/zstd-1.5.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f799c1e9900ad77e7a3d994b9b5146d7cfd1cbd1b61c3db53a697bf21ffcc57b", size = 269702, upload-time = "2025-06-23T12:50:11.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/e6/629cf6b77e47fc7149f5724fb4853c48edcdeb10d8c64e391d7026cb10e1/zstd-1.5.7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ff4c667f29101566a7b71f06bbd677a63192818396003354131f586383db042", size = 228145, upload-time = "2025-06-23T12:50:10.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b8/9ddefd4670bfe9328ca6657ad335eb8d9c657466247e234a579818b6b0b9/zstd-1.5.7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8526a32fa9f67b07fd09e62474e345f8ca1daf3e37a41137643d45bd1bc90773", size = 1536530, upload-time = "2025-06-23T13:51:38.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/6a/1bb836c18760dc1e28ca7a9706016e482ebdea633b980d8505dbb65e18f8/zstd-1.5.7.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2cec2472760d48a7a3445beaba509d3f7850e200fed65db15a1a66e315baec6a", size = 1616141, upload-time = "2025-06-23T13:51:34.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/7a/bb6c6e2cb2a066e347dc27d45d5205058b69d6c8b8d4ae2ee7d6b91c64a5/zstd-1.5.7.2-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:a200c479ee1bb661bc45518e016a1fdc215a1d8f7e4bf6c7de0af254976cfdf6", size = 322188, upload-time = "2025-06-23T13:01:48.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/4f/cf0669c8a89fdcc91814bf92bd05cc363d5d12a79b656418c0add6f2d266/zstd-1.5.7.2-cp311-cp311-manylinux_2_4_x86_64.whl", hash = "sha256:f5d159e57a13147aa8293c0f14803a75e9039fd8afdf6cf1c8c2289fb4d2333a", size = 302736, upload-time = "2025-06-23T13:05:33.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/bc/e5f8b7f61826323e39e099db1eb5c0e09b18315df1b1ff778f7ae9aadcac/zstd-1.5.7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:7206934a2bd390080e972a1fed5a897e184dfd71dbb54e978dc11c6b295e1806", size = 1522687, upload-time = "2025-06-23T13:51:35.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/8c/7660a949a020ac9d02b3166a25dd1c12144572d77b11ae92a31d341016da/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e0027b20f296d1c9a8e85b8436834cf46560240a29d623aa8eaa8911832eb58", size = 2098794, upload-time = "2025-06-23T13:51:37.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b2/730c811a78d670104d40c7f08cc8092577cdff870cba42b3158f20fceb57/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d6b17e5581dd1a13437079bd62838d2635db8eb8aca9c0e9251faa5d4d40a6d7", size = 2112266, upload-time = "2025-06-23T13:51:31.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/74/2c16e1632094db36c8920d4c13b8e2e843024d548ae26888c2d22af6a676/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b13285c99cc710f60dd270785ec75233018870a1831f5655d862745470a0ca29", size = 2109465, upload-time = "2025-06-23T13:51:32.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/6e/b9c9a834769d96cab2122da1be8c8c700d3f76be796d2b7516e85d2eca0e/zstd-1.5.7.2-cp311-cp311-win32.whl", hash = "sha256:cdb5ec80da299f63f8aeccec0bff3247e96252d4c8442876363ff1b438d8049b", size = 149448, upload-time = "2025-06-23T13:06:21.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/b7/fc22ad6292a32d7676ab815de3a23573beac3679e8abd9914288d1496ceb/zstd-1.5.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:4f6861c8edceb25fda37cdaf422fc5f15dcc88ced37c6a5b3c9011eda51aa218", size = 166592, upload-time = "2025-06-23T13:06:22.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/14/096bb77f3e5ef525b452cd6294da33de7f8a8c9647ba78293378fbb0a7ce/zstd-1.5.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ebe3e60dbace52525fa7aa604479e231dc3e4fcc76d0b4c54d8abce5e58734", size = 269408, upload-time = "2025-06-23T13:11:46.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/b8/2bc2590a34c733ea0570f366e6ad7d889d05c7825bd3ccab01f36ece71c6/zstd-1.5.7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef201b6f7d3a6751d85cc52f9e6198d4d870e83d490172016b64a6dd654a9583", size = 228188, upload-time = "2025-06-23T13:11:47.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/80/6252de3a70cfd7767718ad476893f1c7dc129f942cc7ed0322e3137c03d9/zstd-1.5.7.2-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:ac7bdfedda51b1fcdcf0ab69267d01256fc97ddf666ce894fde0fae9f3630eac", size = 302720, upload-time = "2025-06-23T12:40:11.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b6/af908387814b99172d3aea6aeb24b19583aadfa45f6021e5e2a0d6d8e99a/zstd-1.5.7.2-cp312-cp312-manylinux_2_4_i686.whl", hash = "sha256:b835405cc4080b378e45029f2fe500e408d1eaedfba7dd7402aba27af16955f9", size = 322237, upload-time = "2025-06-23T13:17:35.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/d7/ab9142e002a7eaa451cb4bb37a74c390c489ba8ae75ade543840496eda04/zstd-1.5.7.2-cp312-cp312-win32.whl", hash = "sha256:e4cf97bb97ed6dbb62d139d68fd42fa1af51fd26fd178c501f7b62040e897c50", size = 149453, upload-time = "2025-06-23T13:13:02.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/c7/c182ea7bc283f591e3f3c5f0f239e7a92c9bc1f626642ae2c4dfbe51d6f2/zstd-1.5.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:55e2edc4560a5cf8ee9908595e90a15b1f47536ea9aad4b2889f0e6165890a38", size = 166628, upload-time = "2025-06-23T13:13:03.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/c9/a6495a7bf168a78f0a0c01d61d830ebfb401315a64fd1ae8d725c458114c/zstd-1.5.7.2-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:5fb2ff5718fe89181223c23ce7308bd0b4a427239379e2566294da805d8df68a", size = 315542, upload-time = "2025-06-23T12:39:27.598Z" },
|
||||
]
|
||||
|
||||
@@ -4,6 +4,117 @@ description: "New features and improvements in each Prowler release"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<Update label="v5.39.0" description="August 13, 2026">
|
||||
### 🤖 Lighthouse AI — Finding Skills
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Lighthouse AI now embeds a Skills menu on every finding, answering the questions an analyst actually asks. **Contextual Fix** produces the fix for the finding, **Triage Decision** judges whether it is real and closes it out when it is not, and **Systemic Scope** determines whether the problem is a one-off or everywhere. A free-form "Ask Lighthouse anything" prompt sits in the same menu, and each run shows its progress and offers follow-up actions such as creating a Jira issue or muting the finding.
|
||||
|
||||

|
||||
|
||||
Read more in the [Lighthouse AI documentation](https://docs.prowler.com/getting-started/products/prowler-cloud-lighthouse).
|
||||
|
||||
### ☁️ Azure Management Group Onboarding
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Azure subscriptions no longer onboard one at a time. Choose "Add Multiple Subscriptions With Azure Management Group" in the add-provider wizard, enter the Microsoft Entra tenant ID, and authenticate once with a single tenant-wide service principal: Prowler discovers the entire management-group hierarchy under the tenant root, lets you select the subscriptions to onboard, and creates their providers with the management-group structure preserved. Azure now matches the one-step onboarding that AWS Organizations and GCP organizations already have.
|
||||
|
||||

|
||||
|
||||
Read more in the [Azure Management Groups documentation](https://docs.prowler.com/user-guide/tutorials/prowler-cloud-azure-management-groups).
|
||||
|
||||
### ✅ Findings Triage — Verify MANUAL Findings as PASS
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Checks that require human judgment report `MANUAL` findings. For these findings, and only for them, the triage status selector now offers **Resolved**: choosing it asks for the required written evidence and verifies the finding as passing. The finding then reports an effective `PASS` while preserving the raw `MANUAL` scan result, across findings, finding groups, compliance reports, and scans, with the attestation's author, evidence, and validity always visible. Attestations expire automatically after 90 days, or as soon as a new scan reports a real failure, returning the finding to the review queue.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Read more in the [Findings Triage documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-findings-triage#verify-a-manual-finding-as-pass).
|
||||
|
||||
### ☁️ Prowler Cloud MCP — Organizations Management and Grouped Jira Dispatch
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
The hosted Prowler Cloud MCP server adds eight organization tools, so an agent can onboard and manage entire cloud organizations end to end: create the organization, discover its accounts, subscriptions, and projects, apply the selection, and manage the resulting providers. The tools cover AWS Organizations, GCP organizations, and Azure tenant root management groups, and they are available to Lighthouse AI.
|
||||
|
||||
`prowler_send_findings_to_jira` also gains Cloud-only dispatch capabilities: select failed findings by check IDs against the latest completed scan, and send them in grouped mode, one Jira work item per check listing up to 50 affected resources, with per-group error reporting.
|
||||
|
||||
Read more in the [Prowler MCP tools documentation](https://docs.prowler.com/getting-started/basic-usage/prowler-mcp-tools) and its [Jira operations reference](https://docs.prowler.com/getting-started/basic-usage/prowler-mcp-tools#jira-operations).
|
||||
|
||||
### 🕸️ Attack Paths — Grouped Graph with Outcome Destinations
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
The Attack Paths graph now reads from source to destination. Resources of the same class collapse into a single expandable node with a count, clicking reveals its members, and every path terminates in an explicit outcome node naming the destination impact: code execution, privilege escalation, public exposure, or resource inventory. The per-account hub node is gone, and the clicked resource stays highlighted while its findings are expanded.
|
||||
|
||||

|
||||
|
||||
Explore the full Attack Paths query catalog at [Prowler Hub](https://hub.prowler.com/attack-paths).
|
||||
|
||||
Read more in the [Attack Paths documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-attack-paths).
|
||||
|
||||
### 📚 New Compliance Framework — CMMC 2.0
|
||||
|
||||
The Cybersecurity Maturity Model Certification (CMMC) is the certification the US Department of Defense requires from contractors and suppliers that handle federal contract data. Prowler now includes CMMC 2.0 as a universal framework with all 149 requirements defined by the CMMC Program rule (32 CFR Part 170), organized in its three levels:
|
||||
|
||||
- **Level 1 (Foundational):** 15 requirements for the basic safeguarding of Federal Contract Information, from FAR 52.204-21.
|
||||
- **Level 2 (Advanced):** 110 requirements from NIST SP 800-171 Rev 2, protecting Controlled Unclassified Information.
|
||||
- **Level 3 (Expert):** 24 enhanced requirements from NIST SP 800-172 for the most sensitive programs.
|
||||
|
||||
Requirements map to Prowler checks across AWS, Azure, GCP, Alibaba Cloud, Oracle Cloud, and Microsoft 365, so one framework reports the compliance posture of the whole estate.
|
||||
|
||||
Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance).
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
#### Microsoft 365
|
||||
|
||||
Twenty new Entra ID checks expand the coverage of CIS Microsoft 365 Foundations Benchmark v7.0.0:
|
||||
|
||||
- **Password protection:** custom banned password list, on-premises enforcement, and lockout threshold and duration.
|
||||
- **Default user permissions:** security group and Microsoft 365 group creation restricted, and guest invitations limited to allowed domains.
|
||||
- **Conditional Access:** high and medium sign-in risk blocked, authentication transfer blocked, untrusted locations blocked, trusted named locations defined, sign-in frequency enforced, and token protection enforced.
|
||||
- **Sessions and authentication methods:** idle session timeout configured, email one-time passcodes disabled, and Microsoft Authenticator context shown.
|
||||
- **PIM and access reviews:** approval required to activate the Global Administrator and Privileged Role Administrator roles, and access reviews configured for guest users and privileged roles.
|
||||
|
||||
Explore all Microsoft 365 checks at [Prowler Hub](https://hub.prowler.com/check?provider=m365).
|
||||
|
||||
#### AWS
|
||||
|
||||
Two new checks detect hardcoded secrets:
|
||||
|
||||
- `batch_job_definition_no_secrets` scans Batch job definition environment variables and command parameters. Thanks to @praneetrajv!
|
||||
- `awslambda_layer_no_secrets_in_content` scans Lambda layer package content. Thanks to @ganiganesh25!
|
||||
|
||||
Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @praneetrajv: AWS `batch_job_definition_no_secrets` check ([#12117](https://github.com/prowler-cloud/prowler/pull/12117))
|
||||
- @ganiganesh25: AWS `awslambda_layer_no_secrets_in_content` check ([#12233](https://github.com/prowler-cloud/prowler/pull/12233))
|
||||
- @andoniaf: GitHub `organization_repository_creation_limited` now reports low severity when repository creation is limited to private or internal visibility ([#12164](https://github.com/prowler-cloud/prowler/pull/12164))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.39.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.38.0" description="August 6, 2026">
|
||||
### 📌 Compliance Watchlist
|
||||
|
||||
|
||||
@@ -224,9 +224,9 @@ Each check **must** populate the report with a unique identifier for the audited
|
||||
- `resource_name`: Description of the configuration (e.g., "SharePoint Settings")
|
||||
- GitHub
|
||||
- Resource ID — `report.resource_id`.
|
||||
- The ID of the Github resource. This is a system-generated integer that uniquely identifies the resource within the Github platform.
|
||||
- The ID of the GitHub resource. This is a system-generated integer that uniquely identifies the resource within the GitHub platform.
|
||||
- Resource Name — `report.resource_name`.
|
||||
- The name of the Github resource. In the case of a repository, this is just the repository name. For full repository names use the resource `full_name`.
|
||||
- The name of the GitHub resource. In the case of a repository, this is just the repository name. For full repository names use the resource `full_name`.
|
||||
|
||||
### Configurable Checks in Prowler
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ Once you have decided the provider you want or need to add to Prowler, the next
|
||||
- **SDK Providers**: Low complexity. You have mature examples like AWS, Azure, GCP, Kubernetes, etc. that you can leverage to implement your provider.
|
||||
- **API Providers**: Medium complexity. You need to implement the authentication and session management, and the API calls to the provider. You now have NHN and MongoDB Atlas as example to follow.
|
||||
- **Tool/Wrapper Providers**: High complexity. You need to implement the argument/output mapping to the provider and handle problems that the tool/wrapper may have. You now have IAC and the PowerShell wrapper as example to follow.
|
||||
- **Hybrid Providers**: High complexity. You need to "customize" your provider, mixing the other types of providers in order to achieve the desired result. You have M365 (msgraph SDK + PowerShell wrapper) and Github (PyGithub SDK + graphql API requests) as examples.
|
||||
- **Hybrid Providers**: High complexity. You need to "customize" your provider, mixing the other types of providers in order to achieve the desired result. You have M365 (msgraph SDK + PowerShell wrapper) and GitHub (PyGithub SDK + graphql API requests) as examples.
|
||||
|
||||
### Determining Regional vs Non-Regional Architecture
|
||||
|
||||
@@ -814,7 +814,7 @@ class YourProviderMutelist(Mutelist):
|
||||
Region management is essential for cloud providers that operate across multiple geographic locations. This component handles region validation and provides region-specific functionality.
|
||||
|
||||
<Note>
|
||||
Regions are optional, only if the provider has regions, for example Github does not have regions, but AWS does.
|
||||
Regions are optional, only if the provider has regions, for example GitHub does not have regions, but AWS does.
|
||||
</Note>
|
||||
|
||||
**File:** `prowler/providers/<provider_name>/lib/regions/<provider_name>_regions.py`
|
||||
@@ -1773,7 +1773,7 @@ The implementation of the mutelist is the same as the [SDK providers](#step-5-im
|
||||
Region management is essential for cloud providers that operate across multiple geographic locations. This component handles region validation and provides region-specific functionality.
|
||||
|
||||
<Note>
|
||||
Regions are optional, only if the provider has regions, for example Github does not have regions, but AWS does.
|
||||
Regions are optional, only if the provider has regions, for example GitHub does not have regions, but AWS does.
|
||||
</Note>
|
||||
|
||||
**File:** `prowler/providers/<provider_name>/lib/regions/<provider_name>_regions.py`
|
||||
|
||||
@@ -182,7 +182,8 @@
|
||||
"pages": [
|
||||
"user-guide/tutorials/prowler-app-s3-integration",
|
||||
"user-guide/tutorials/prowler-app-security-hub-integration",
|
||||
"user-guide/tutorials/prowler-app-jira-integration"
|
||||
"user-guide/tutorials/prowler-app-jira-integration",
|
||||
"user-guide/tutorials/prowler-app-slack-integration"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -128,12 +128,12 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.38.0"
|
||||
PROWLER_API_VERSION="5.38.0"
|
||||
PROWLER_UI_VERSION="5.39.0"
|
||||
PROWLER_API_VERSION="5.39.0"
|
||||
```
|
||||
|
||||
<Note>
|
||||
You can find the latest versions of Prowler Local Server in the [Releases Github section](https://github.com/prowler-cloud/prowler/releases) or in the [Container Versions](#container-versions) section of this documentation.
|
||||
You can find the latest versions of Prowler Local Server in the [Releases GitHub section](https://github.com/prowler-cloud/prowler/releases) or in the [Container Versions](#container-versions) section of this documentation.
|
||||
</Note>
|
||||
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 563 KiB |
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 115 KiB |
@@ -92,6 +92,7 @@ li[id="/user-guide/tutorials/prowler-alerts"] a > div > div > span:first-child::
|
||||
li[id="/user-guide/tutorials/prowler-app-attack-paths-active-queries"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-app-findings-triage"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-app-scan-configuration"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-app-slack-integration"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-cloud-aws-organizations"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-cloud-azure-management-groups"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-cloud-gcp-organizations"] a > div > div > span:first-child::after,
|
||||
|
||||
@@ -495,7 +495,7 @@ aws:
|
||||
|
||||
# AWS CloudTrail Configuration
|
||||
# aws.cloudtrail_threat_detection_privilege_escalation
|
||||
threat_detection_privilege_escalation_threshold: 0.2 # Percentage of actions found to decide if it is an privilege_escalation attack event, by default is 0.2 (20%)
|
||||
threat_detection_privilege_escalation_threshold: 0.2 # Percentage of actions found to decide if it is a privilege_escalation attack event, by default is 0.2 (20%)
|
||||
threat_detection_privilege_escalation_minutes: 1440 # Past minutes to search from now for privilege_escalation attacks, by default is 1440 minutes (24 hours)
|
||||
threat_detection_privilege_escalation_actions:
|
||||
[
|
||||
|
||||
@@ -16,26 +16,24 @@ Attack Paths analyzes relationships between cloud resources, permissions, and se
|
||||
By mapping these relationships as a graph, Attack Paths reveals risks that individual security checks cannot detect on their own, such as an IAM role that can escalate its own permissions, or a chain of policies that grants unintended access to sensitive resources.
|
||||
|
||||
<Note>
|
||||
Attack Paths is currently available for **AWS** providers. Support for
|
||||
additional providers is planned.
|
||||
Attack Paths is currently available for **AWS** providers. Support for additional providers is planned.
|
||||
</Note>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The following prerequisites are required for Attack Paths:
|
||||
|
||||
- **An AWS provider is configured** with valid credentials in Prowler Cloud. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws).
|
||||
- **An AWS provider is configured** with valid credentials. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws).
|
||||
- **At least one scan has completed** on the configured AWS provider and produced graph data. Attack Paths scans run automatically alongside regular security scans, no separate configuration is required.
|
||||
|
||||
## How Attack Paths Scans Work
|
||||
|
||||
Attack Paths scans are generated automatically when a security scan runs on an AWS provider. Each completed scan produces graph data that maps relationships between IAM principals, policies, trust configurations, and other resources.
|
||||
Attack Paths scans are generated automatically when a security scan runs on an AWS provider. When a scan produces graph data, it maps relationships between IAM principals, policies, trust configurations, and other resources. A scan can complete without producing graph data.
|
||||
|
||||
Once the scan finishes and graph data is ready, the scan appears in the Attack Paths scan table with a **Completed** status and a check in the **Graph** column. Scans that are still queued or running remain visible, but they cannot be selected until graph data is ready.
|
||||
When graph data is ready, the scan appears in the Attack Paths scan table with a check in the **Graph** column and can be selected regardless of its current status. Scans without graph data remain visible but cannot be selected. If a new scan cycle starts after graph data is available, the previous cycle remains available while the new scan runs.
|
||||
|
||||
<Note>
|
||||
Since Prowler scans all configured providers every **24 hours** by default,
|
||||
Attack Paths data stays up to date automatically.
|
||||
Prowler Cloud and Prowler Private Cloud scan configured providers every **24 hours** by default, so Attack Paths data stays up to date automatically.
|
||||
</Note>
|
||||
|
||||
## Accessing Attack Paths
|
||||
@@ -66,7 +64,7 @@ The scans table displays all Attack Paths scans with the following columns:
|
||||
- **Graph:** Whether Attack Paths graph data is available for the scan.
|
||||
- **Duration:** Total scan time.
|
||||
|
||||
To select a scan for analysis, click the radio button on any row with a **Completed** status and available graph data.
|
||||
To select a scan for analysis, click any row with a check in the **Graph** column. A row can remain selectable while a new scan cycle runs because Attack Paths keeps the graph from the previous completed cycle available.
|
||||
|
||||
<img
|
||||
src="/images/prowler-app/attack-paths/scan-list-table.png"
|
||||
@@ -75,8 +73,7 @@ To select a scan for analysis, click the radio button on any row with a **Comple
|
||||
/>
|
||||
|
||||
<Note>
|
||||
Only scans with graph data can be selected. Disabled rows include a tooltip
|
||||
that explains why the graph is not available yet.
|
||||
Only scans with graph data can be selected. Disabled rows include a tooltip that explains why the graph is not available yet.
|
||||
</Note>
|
||||
|
||||
## Choosing a Query
|
||||
@@ -97,9 +94,7 @@ To choose a query, click the dropdown and select from the available options. Eac
|
||||
Once selected, a description panel appears below the dropdown with more context about the query.
|
||||
|
||||
<Note>
|
||||
In Prowler Cloud and Prowler Private Cloud, the query selector hides queries
|
||||
confirmed empty for the selected scan, so only queries that return data remain
|
||||
visible. See [Active Queries](/user-guide/tutorials/prowler-app-attack-paths-active-queries).
|
||||
In Prowler Cloud and Prowler Private Cloud, the query selector hides built-in queries confirmed empty for the selected scan. Built-in queries without a confirmed empty result and the **Custom openCypher query** remain visible. See [Active Queries](/user-guide/tutorials/prowler-app-attack-paths-active-queries).
|
||||
</Note>
|
||||
|
||||
## Configuring Query Parameters
|
||||
@@ -120,7 +115,7 @@ For example, **Internet-Exposed EC2 with Sensitive S3 Access** uses **Tag key**
|
||||
|
||||
## Writing Custom openCypher Queries
|
||||
|
||||
In addition to the built-in queries, Attack Paths supports custom read-only [openCypher](https://opencypher.org/) queries. Custom queries provide direct access to the underlying graph so security teams can answer ad-hoc questions, prototype detections, or extend coverage beyond the built-in catalogue.
|
||||
In addition to the built-in queries, Attack Paths supports custom read-only [openCypher](https://opencypher.org/) queries. Custom queries provide direct access to the underlying graph so security teams can answer ad-hoc questions, prototype detections, or extend coverage beyond the built-in catalog.
|
||||
|
||||
To write a custom query, select **Custom openCypher query** from the query dropdown. A code editor with syntax highlighting and line numbers appears, ready to receive the query.
|
||||
|
||||
@@ -192,11 +187,7 @@ Custom queries traverse the same Cartography graph the built-in queries use. Nod
|
||||
For the complete reference, including the graph model, list-typed and JSON-encoded properties, performance guidance, and openCypher compatibility rules, see [Attack Paths Queries](/developer-guide/attack-paths-queries) in the Developer Guide.
|
||||
|
||||
<Note>
|
||||
AI assistants connected through Prowler MCP Server can fetch the exact
|
||||
Cartography schema for the active scan via the
|
||||
`prowler_get_attack_paths_cartography_schema` tool. This guarantees that
|
||||
generated queries match the schema version pinned by the running Prowler
|
||||
release.
|
||||
AI assistants connected through Prowler MCP Server can fetch the exact Cartography schema for the active scan via the `prowler_get_attack_paths_cartography_schema` tool. This guarantees that generated queries match the schema version pinned by the running Prowler release.
|
||||
</Note>
|
||||
|
||||
## Executing a Query
|
||||
@@ -221,12 +212,22 @@ If the query returns no results, an informational message appears. Common reason
|
||||
|
||||
After a successful execution, the graph visualization renders below the query builder. The graph maps relationships between cloud resources, IAM entities, public exposure, and security findings.
|
||||
|
||||
### Grouped Graphs and Query Outcomes
|
||||
|
||||
<VersionBadge version="5.39.0" />
|
||||
|
||||
Prowler Cloud and Prowler Private Cloud group resources of the same class and graph level into expandable nodes. Built-in query graphs also end with a query outcome, which states the result that the path can lead to.
|
||||
|
||||
Prowler Local Server keeps the flat graph view, including the provider root. Custom openCypher queries do not have a catalog outcome, so their graphs do not include an outcome node.
|
||||
|
||||
### Node Types
|
||||
|
||||
- **Provider root nodes:** Represent the AWS account or provider root for the selected scan.
|
||||
- **Resource nodes:** Represent cloud resources such as IAM roles, policies, EC2 instances, security groups, and S3 buckets.
|
||||
- **Grouped resource nodes:** Represent multiple resources of the same class in Prowler Cloud and Prowler Private Cloud. A number in the upper-right corner shows how many resources the node contains. A red outline indicates that one or more resources in the group have findings.
|
||||
- **Resource nodes:** Represent individual cloud resources such as IAM roles, policies, EC2 instances, security groups, and S3 buckets. A class with one resource remains an individual node.
|
||||
- **Internet nodes:** Represent exposure from the public internet.
|
||||
- **Finding nodes:** Represent Prowler findings linked to resources. Finding colors indicate risk level, such as critical, high, medium, or low.
|
||||
- **Outcome nodes:** Mark the terminal result of a built-in query in Prowler Cloud and Prowler Private Cloud. The orange node displays outcomes such as **Code execution**, **Privilege escalation**, **Public exposure**, or **Resource inventory**. A dashed ring and the label **Latent outcome** indicate an inventory or another partial outcome.
|
||||
- **Provider root nodes:** Represent the AWS account or provider root in the Prowler Local Server flat graph.
|
||||
|
||||
### Edge Types
|
||||
|
||||
@@ -234,7 +235,7 @@ After a successful execution, the graph visualization renders below the query bu
|
||||
- **Finding edges:** Dashed relationships between resources and their associated findings.
|
||||
- **Highlighted paths:** Green edges that show the active path when you hover a node or focus a finding.
|
||||
|
||||
The standard graph view includes a minimap and a legend below the canvas. The legend shows the provider roots, visible node types, finding risk levels, node states, and edge types present in the current view.
|
||||
The standard graph view includes a minimap and a legend below the canvas. The legend shows the visible node types, finding risk levels, node states, and edge types present in the current view. Prowler Local Server also displays the provider root in the legend.
|
||||
|
||||
<img
|
||||
src="/images/prowler-app/attack-paths/graph-visualization.png"
|
||||
@@ -244,17 +245,32 @@ The standard graph view includes a minimap and a legend below the canvas. The le
|
||||
|
||||
## Interacting with the Graph
|
||||
|
||||
The graph banner describes the main interactions:
|
||||
The graph supports these interactions:
|
||||
|
||||
- Click a node with a number in the upper-right corner to expand its resource group.
|
||||
- Click a finding to focus its connected path.
|
||||
- Click a resource with findings to show or hide its related findings.
|
||||
- Hover a node to highlight its connected path.
|
||||
|
||||
### Expanding Resource Groups
|
||||
|
||||
In Prowler Cloud and Prowler Private Cloud, a node with a number in its upper-right corner represents a resource group. The number is the total number of resources in the group.
|
||||
|
||||
- Click the group node to display its resources. Multiple groups can remain expanded, and the graph automatically fits the visible nodes to the canvas.
|
||||
- Double-click any resource revealed from a group to collapse that group.
|
||||
- Click **Collapse all groups** in the graph toolbar to close every expanded group. This control appears only while at least one group is expanded.
|
||||
|
||||
<img
|
||||
src="/images/prowler-app/attack-paths/graph-visualization-expanded.png"
|
||||
alt="Attack Paths graph showing an expanded AWS Role group and remaining numbered resource groups"
|
||||
width="700"
|
||||
/>
|
||||
|
||||
### Showing Related Findings
|
||||
|
||||
Resource nodes with related findings are clickable. Click one of these resources to show its finding nodes. Click the resource again to hide them.
|
||||
|
||||
The graph automatically fits the selected resource and its related findings when the findings are shown.
|
||||
The selected resource is highlighted in green while its findings are visible. The graph automatically fits the selected resource and its related findings when the findings are shown.
|
||||
|
||||
### Focusing a Finding Path
|
||||
|
||||
@@ -279,12 +295,12 @@ The toolbar in the top-right corner of the graph provides:
|
||||
|
||||
- **Zoom in / Zoom out:** Adjust the zoom level
|
||||
- **Fit graph to view:** Reset the view to fit the visible graph
|
||||
- **Export graph:** Download the current graph as a PNG file
|
||||
- **Collapse all groups:** Close every expanded resource group. This control appears only when a group is expanded
|
||||
- **Export graph:** Download the current graph, including its grouped or expanded state and outcome, as a PNG file
|
||||
- **Fullscreen:** Open the graph in a full-size modal
|
||||
|
||||
<Note>
|
||||
Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within
|
||||
the graph area.
|
||||
Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within the graph area.
|
||||
</Note>
|
||||
|
||||
## Viewing Finding Details
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
title: "Slack Integration"
|
||||
sidebarTitle: 'Slack'
|
||||
description: 'Connect a Slack workspace to Prowler Cloud, choose the channel Prowler posts to, and verify delivery with a test message.'
|
||||
---
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
<VersionBadge version="5.40.0" />
|
||||
|
||||
<SubscriptionBanner />
|
||||
|
||||
Prowler Cloud connects to a Slack workspace so security updates arrive where teams already work. Connecting takes one approval in Slack — there is no bot token to create, copy, or store by hand — and Prowler records a single destination channel it posts to.
|
||||
|
||||
Integrating Prowler Cloud with Slack provides:
|
||||
|
||||
* **Approval-based setup:** Approve Prowler once in Slack instead of building a Slack app and pasting a token.
|
||||
* **A verified delivery path:** Send a test message and confirm the channel receives it before anything depends on it.
|
||||
* **Controlled reach:** Prowler posts only to the channel recorded on the integration, and private channels stay invisible until the Prowler app is invited to them.
|
||||
|
||||
<Note>
|
||||
This guide covers the Slack integration in Prowler Cloud. It is unrelated to the Prowler CLI `--slack` flag, which posts a scan summary from the command line using a self-created Slack app and the `SLACK_API_TOKEN` and `SLACK_CHANNEL_NAME` environment variables — see [CLI Integrations](/user-guide/cli/tutorials/integrations) for that feature.
|
||||
</Note>
|
||||
|
||||
## How the Slack Integration Works
|
||||
|
||||
When connected and configured:
|
||||
|
||||
1. A Slack workspace is approved once through Slack's app install flow, and Prowler stores the resulting credential encrypted.
|
||||
2. Prowler reads the channels it can post to: the workspace's public channels, plus the private channels the Prowler app has been invited to.
|
||||
3. One channel is recorded on the integration as the default destination.
|
||||
4. A test message proves the delivery path end to end.
|
||||
5. Disconnecting removes the integration from Prowler and attempts to revoke Prowler's access at Slack.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The Slack integration is available only in **Prowler Cloud** and **Prowler Private Cloud**. Prowler Local Server does not serve the Slack endpoints at all, so the Slack card does not appear on the Integrations page and the management page redirects away.
|
||||
|
||||
Configuring and using the Slack integration requires the **Manage Integrations** permission. The integration is tenant-wide, so it does not require **Unlimited Visibility** or any specific Provider Group.
|
||||
|
||||
One Slack workspace connects per tenant. Approving Prowler again in the same workspace refreshes the stored credential, while approving it in a *different* workspace is refused until the current workspace is disconnected — a workspace is never swapped out silently.
|
||||
|
||||
## Permissions Prowler Requests in Slack
|
||||
|
||||
Slack shows a consent screen listing everything the Prowler app asks for. Prowler requests exactly four bot scopes:
|
||||
|
||||
| Scope | Why Prowler Requests It |
|
||||
|-------|-------------------------|
|
||||
| `chat:write` | Post the test message, and any later message, to the recorded channel. |
|
||||
| `chat:write.public` | Post to a public channel without first inviting the Prowler app to it. |
|
||||
| `channels:read` | List public channels for the destination-channel picker and resolve the chosen one. |
|
||||
| `groups:read` | List the private channels the Prowler app has been invited to, so they appear in the picker. |
|
||||
|
||||
Two of these read more broadly than they behave, and both are worth understanding before approving the app.
|
||||
|
||||
### What `chat:write.public` Does Not Grant
|
||||
|
||||
On the consent screen, `chat:write.public` reads as permission to post in any public channel. Prowler never uses it that way: **Prowler only ever posts to the channel recorded on the integration.** The scope exists so that recording a public channel does not also require someone to invite the Prowler app to it first.
|
||||
|
||||
### Why a Private Channel Is Missing From the Picker
|
||||
|
||||
`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the picker only after someone invites `@Prowler` to it in Slack:
|
||||
|
||||
```text
|
||||
/invite @Prowler
|
||||
```
|
||||
|
||||
That invite is issued in Slack, by that channel's own members, and **the invite itself is the permission grant** — no scope bypasses it. Prowler ships no in-product flow to get the app invited, because the decision belongs to the channel's members. After inviting the app, click **Refresh channels** to re-read the list.
|
||||
|
||||
## Connecting a Slack Workspace
|
||||
|
||||
To connect a Slack workspace to Prowler Cloud:
|
||||
|
||||
1. Navigate to **Integrations** in Prowler Cloud.
|
||||
2. Locate the **Slack** card and click **Manage**.
|
||||
|
||||

|
||||
|
||||
3. Click **Add to Slack**.
|
||||
|
||||

|
||||
|
||||
4. In Slack, select the workspace to connect and approve the permissions listed on the consent screen.
|
||||
5. Slack returns to Prowler Cloud, which completes the install and shows the connected workspace.
|
||||
|
||||

|
||||
|
||||
The connected card reports the workspace name and a **Not checked yet** status: the connection is checked against the destination channel, so no check has run at this point. Choosing that channel is the next step. Once one is recorded, **Test connection** verifies that Prowler can still reach both the workspace and that channel.
|
||||
|
||||
<Note>
|
||||
Declining the consent screen creates nothing. Prowler reports that the workspace was not connected and offers to start again.
|
||||
</Note>
|
||||
|
||||
## Choosing the Default Channel
|
||||
|
||||
Prowler posts to one channel, recorded on the integration as its default destination.
|
||||
|
||||
1. Open the **Destination channel** picker. It lists the workspace's public channels, plus the private channels the Prowler app has been invited to, each marked **Private**.
|
||||
|
||||

|
||||
|
||||
2. Select a channel.
|
||||
3. Click **Save channel**.
|
||||
|
||||
Prowler validates the selection against Slack and derives the channel name itself, so the recorded name can never drift from the channel it belongs to. Once a channel is saved, the page reports where Prowler posts.
|
||||
|
||||
If the picker reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler` to a private one, then click **Refresh channels**.
|
||||
|
||||
A workspace can hold more channels than Prowler reads in one go. When that happens, the picker says so and lists what was read: every listed channel is usable, and a channel missing from a partial list is not necessarily one `@Prowler` has to be invited to.
|
||||
|
||||
## Sending a Test Message
|
||||
|
||||
Sending a test message is how the delivery path gets verified before anything depends on it.
|
||||
|
||||
1. Confirm a destination channel is saved. **Send test message** appears only once one is recorded.
|
||||
2. Click **Send test message**.
|
||||
3. Check the channel in Slack for the message.
|
||||
|
||||

|
||||
|
||||
Prowler reports the outcome on the page. When Slack refuses the message, the reason Slack gave is shown — an archived or deleted channel, or an app that has been removed from a private channel, all surface here rather than failing silently.
|
||||
|
||||
## Disconnecting a Slack Workspace
|
||||
|
||||
Disconnecting removes the integration from Prowler **and** attempts to revoke Prowler's access at Slack.
|
||||
|
||||
1. On the Slack management page, click **Disconnect**.
|
||||
2. Review the confirmation, then click **Disconnect workspace**.
|
||||
|
||||

|
||||
|
||||
The page returns to its unconnected state, ready for a new install.
|
||||
|
||||
### What Revocation Means
|
||||
|
||||
Revocation is attempted at Slack, and it is best-effort:
|
||||
|
||||
* **Revocation succeeded:** The stored credential no longer grants Prowler anything, and the integration is gone from Prowler.
|
||||
* **Revocation failed:** The integration and the stored credential are gone from Prowler either way, so there is nothing to retry. Slack did not confirm the revocation, which means the Prowler app may still be installed in the workspace. Remove it from that workspace's Slack app settings.
|
||||
* **Revocation unreported:** Slack's answer carried no outcome either way. The integration is gone from Prowler, and the disconnect is reported without any claim about revocation. When certainty matters, check the workspace's Slack app settings and remove the Prowler app if it is still installed.
|
||||
|
||||
Prowler reports the outcome it received: a failed revocation always names the manual cleanup step, and an unreported one is never presented as revoked.
|
||||
|
||||
<Warning>
|
||||
Disconnecting cannot be undone. Reconnecting means approving Prowler in Slack again, and the destination channel has to be chosen again.
|
||||
</Warning>
|
||||
|
||||
## Integration Status
|
||||
|
||||
The Slack management page reports the state of the connection and offers these actions:
|
||||
|
||||
| Button | Purpose | Notes |
|
||||
|--------|---------|-------|
|
||||
| **Test connection** | Verify Prowler can reach the workspace and the recorded channel | Checks both the credential and the channel, and updates the last-checked time |
|
||||
| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler` to a private channel |
|
||||
| **Save channel** | Record the selected channel as the default destination | Enabled once a channel other than the current default is selected |
|
||||
| **Send test message** | Post a test message to the recorded channel | Offered only once a destination channel is recorded |
|
||||
| **Disconnect** | Remove the integration and attempt to revoke access at Slack | ⚠️ **Cannot be undone** — confirm before disconnecting |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Slack Is Not Available in This Environment Yet
|
||||
|
||||
The Prowler Slack app is not configured for the deployment being used, so no workspace can be connected. This resolves without any action on the tenant's side — the page starts working as soon as the app is configured.
|
||||
|
||||
### A Private Channel Does Not Appear in the Picker
|
||||
|
||||
The Prowler app has not been invited to it. In Slack, run `/invite @Prowler` in that channel, then click **Refresh channels**. Membership is the permission: no scope reveals a private channel the app is not in.
|
||||
|
||||
### Connection Test Fails
|
||||
|
||||
* Confirm the recorded channel still exists and has not been archived.
|
||||
* For a private destination channel, confirm the Prowler app is still a member of it.
|
||||
* Confirm the Prowler app is still installed in the workspace.
|
||||
|
||||
### Prowler's Access Has Been Revoked
|
||||
|
||||
When Slack stops accepting the stored credential — because a workspace administrator revoked it, or the app was removed from the workspace — Prowler reports the workspace as disconnected and offers **Reconnect to Slack**. Approving Prowler in Slack again restores access.
|
||||
|
||||
### The Test Message Does Not Arrive
|
||||
|
||||
* Confirm the destination channel saved on the integration is the channel being watched in Slack.
|
||||
* Check the outcome reported on the page: when Slack refuses the message, the reason Slack gave is shown there.
|
||||
* For a private channel, confirm the Prowler app is still a member of it.
|
||||
@@ -31,6 +31,8 @@
|
||||
"ec2:GetInstanceMetadataDefaults",
|
||||
"ecr:Describe*",
|
||||
"ecr:GetRegistryScanningConfiguration",
|
||||
"ecr:BatchGetImage",
|
||||
"ecr:GetDownloadUrlForLayer",
|
||||
"elasticfilesystem:DescribeBackupPolicy",
|
||||
"glue:GetConnections",
|
||||
"glue:GetSecurityConfiguration*",
|
||||
@@ -42,6 +44,7 @@
|
||||
"lightsail:GetRelationalDatabases",
|
||||
"macie2:GetMacieSession",
|
||||
"macie2:GetAutomatedDiscoveryConfiguration",
|
||||
"rolesanywhere:ListProfiles",
|
||||
"rolesanywhere:ListTagsForResource",
|
||||
"rolesanywhere:ListTrustAnchors",
|
||||
"s3:GetAccountPublicAccessBlock",
|
||||
|
||||
@@ -203,6 +203,8 @@ Resources:
|
||||
- "ec2:GetInstanceMetadataDefaults"
|
||||
- "ecr:Describe*"
|
||||
- "ecr:GetRegistryScanningConfiguration"
|
||||
- "ecr:BatchGetImage"
|
||||
- "ecr:GetDownloadUrlForLayer"
|
||||
- "elasticfilesystem:DescribeBackupPolicy"
|
||||
- "glue:GetConnections"
|
||||
- "glue:GetSecurityConfiguration*"
|
||||
@@ -213,6 +215,7 @@ Resources:
|
||||
- "lightsail:GetRelationalDatabases"
|
||||
- "macie2:GetMacieSession"
|
||||
- "macie2:GetAutomatedDiscoveryConfiguration"
|
||||
- "rolesanywhere:ListProfiles"
|
||||
- "rolesanywhere:ListTagsForResource"
|
||||
- "rolesanywhere:ListTrustAnchors"
|
||||
- "s3:GetAccountPublicAccessBlock"
|
||||
@@ -469,6 +472,8 @@ Resources:
|
||||
- "ec2:GetInstanceMetadataDefaults"
|
||||
- "ecr:Describe*"
|
||||
- "ecr:GetRegistryScanningConfiguration"
|
||||
- "ecr:BatchGetImage"
|
||||
- "ecr:GetDownloadUrlForLayer"
|
||||
- "elasticfilesystem:DescribeBackupPolicy"
|
||||
- "glue:GetConnections"
|
||||
- "glue:GetSecurityConfiguration*"
|
||||
|
||||
@@ -4,6 +4,22 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [5.39.1] (Prowler v5.39.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Bump alibabacloud-tea-openapi to 0.4.6, oci to 2.184.1 and pyopenssl to 26.4.0 so the published wheel installs with cryptography 50.0.0; 5.38.0 declared cryptography 50.0.0 while those packages capped it below 50, so pip could not install it and `pip install prowler` silently fell back to 5.37.1 [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
|
||||
- Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
|
||||
- ECS task-definition checks no longer report PASS when `DescribeTaskDefinition` fails before container evidence is gathered [(#12478)](https://github.com/prowler-cloud/prowler/pull/12478)
|
||||
- `ses_identity_not_publicly_accessible` now evaluates every SES identity authorization policy and marks mixed public Allow and Deny statements for manual review [(#12480)](https://github.com/prowler-cloud/prowler/pull/12480)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Trivy from v0.72.0 to v0.73.0 in the container image, fixing HIGH CVE-2026-46600 in the bundled `golang.org/x/net` [(#12445)](https://github.com/prowler-cloud/prowler/pull/12445)
|
||||
- Trivy v0.74.0 and Debian util-linux 2.41.5-0+deb13u1 in the SDK container image, patching Go standard library vulnerabilities and CVE-2026-53615 [(#12470)](https://github.com/prowler-cloud/prowler/pull/12470)
|
||||
|
||||
---
|
||||
|
||||
## [5.39.0] (Prowler v5.39.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
`ecr_repository_image_no_secrets` check for AWS provider, scanning the latest ECR repository image's configuration and filesystem layers for hardcoded secrets
|
||||
@@ -0,0 +1 @@
|
||||
Add the `iam_workload_identity_pool_provider_attribute_condition` check to flag GCP Workload Identity Federation providers that trust a multi-tenant issuer without an attribute condition restricting which external identities can impersonate federated principals
|
||||
@@ -0,0 +1 @@
|
||||
Add the `rolesanywhere_profile_restricts_session_permissions` check to flag AWS IAM Roles Anywhere profiles that reference an administrative role without scoping down the vended session with a session policy or managed policies
|
||||
@@ -49,7 +49,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.39.0"
|
||||
prowler_version = "5.40.0"
|
||||
html_logo_url = "https://github.com/prowler-cloud/prowler/"
|
||||
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
|
||||
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "ecr_repository_image_no_secrets",
|
||||
"CheckTitle": "ECR repository image contains no hardcoded secrets",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Sensitive Data Identifications/Passwords",
|
||||
"Effects/Data Exposure"
|
||||
],
|
||||
"ServiceName": "ecr",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "AwsEcrRepository",
|
||||
"ResourceGroup": "container",
|
||||
"Description": "The **latest image** pushed to each **Amazon ECR repository** is analyzed for **embedded secrets**: environment variables and build history (Dockerfile instructions) recorded in the image configuration, plus the file contents of every filesystem layer. Findings reference the variable, build step, or file, never the secret value.",
|
||||
"Risk": "Anyone able to pull the image obtains any **credentials, tokens, or keys** embedded at build time via `ENV`, `ARG`, inline `RUN` commands, or files copied into the image (e.g. `COPY .env .`).\n\nLeaked credentials enable unauthorized access to databases, APIs, or cloud resources, and rotation is harder once secrets are baked into distributed image artifacts.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/AmazonECR/latest/userguide/security-best-practices.html",
|
||||
"https://docs.aws.amazon.com/secretsmanager/latest/userguide/best-practices.html",
|
||||
"https://docs.docker.com/build/building/secrets/"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws ecr batch-delete-image --repository-name <repository-name> --image-ids imageDigest=<image-digest>",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Remove the secret from the Dockerfile (ENV/ARG/RUN) or from any file copied into the build context, and rebuild the image without it.\n2. Provide the secret at runtime instead: reference AWS Secrets Manager or SSM Parameter Store from your ECS task definition, EKS pod (Secrets Store CSI driver), or application code.\n3. Push the rebuilt image and delete the compromised image versions from the repository.\n4. Rotate the exposed credential immediately.",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Never bake secrets into images with `ENV`, `ARG`, inline `RUN` commands, or copied files. Use **BuildKit build secrets** (`--mount=type=secret`) at build time and **AWS Secrets Manager**/Parameter Store at runtime. Add secret scanning to CI/CD before pushing images.",
|
||||
"Url": "https://hub.prowler.com/check/ecr_repository_image_no_secrets"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"secrets"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Only the most recently pushed image in each repository is scanned; older tagged images are not scanned. The latest scannable image is evaluated in every repository regardless of whether scan-on-push is enabled. The scanned image's configuration (environment variables and build history) plus every filesystem layer's file contents are analyzed. A multi-architecture image resolves to a single platform's manifest; other architectures in the same manifest list are not scanned. To bound cost, a single layer over 100 MB (compressed) is not downloaded, an individual file over 1 MB is not scanned, and scanning of an image stops after 5000 files or 500 MB (decompressed). When part of an image cannot be scanned this way, a clean result is reported as MANUAL (coverage was incomplete) rather than PASS, and a FAIL still discloses that some content was skipped. Requires the ecr:BatchGetImage and ecr:GetDownloadUrlForLayer permissions in addition to SecurityAudit."
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import re
|
||||
|
||||
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.ecr.ecr_client import ecr_client
|
||||
|
||||
_SAFE_ENVIRONMENT_VARIABLE_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
||||
|
||||
|
||||
class ecr_repository_image_no_secrets(Check):
|
||||
"""Ensure the latest ECR repository image embeds no hardcoded secrets.
|
||||
|
||||
The most recently pushed image in every ECR repository is resolved to a
|
||||
single scannable manifest (a multi-arch image resolves to one platform's
|
||||
manifest; other architectures in the same manifest list are not
|
||||
scanned) and scanned for plaintext secrets in its configuration
|
||||
(environment variables, build history) and every filesystem layer's
|
||||
file contents. Older tagged images are not scanned.
|
||||
- PASS: no secrets detected and the whole image was scanned.
|
||||
- FAIL: a potential secret was detected; the variable, build step, or
|
||||
file is reported, never the secret value.
|
||||
- MANUAL: the image could not be scanned in full, so a clean result would
|
||||
be misleading -- the manifest could not be retrieved or resolved, the
|
||||
scan itself failed, or part of the image exceeded configured size limits
|
||||
or could not be retrieved.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the check logic.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
secrets_ignore_patterns = ecr_client.audit_config.get(
|
||||
"secrets_ignore_patterns", []
|
||||
)
|
||||
validate = ecr_client.audit_config.get("secrets_validate", False)
|
||||
|
||||
# Phase 1: collect. The service yields (repository, image, scan_data)
|
||||
# lazily, downloading each image's manifest, config, and layers; each
|
||||
# image contributes an env/history/file payload per scannable unit so
|
||||
# a finding's key maps back to a variable, build step, or file.
|
||||
scanned = []
|
||||
|
||||
def image_payloads():
|
||||
"""Yield keyed scan payloads, recording each image into `scanned`."""
|
||||
for repository, image, scan_data in ecr_client._get_image_scan_data():
|
||||
index = len(scanned)
|
||||
scanned.append((repository, image, scan_data))
|
||||
if scan_data is None or isinstance(scan_data, Exception):
|
||||
continue
|
||||
for env_index, entry in enumerate(scan_data.env):
|
||||
yield (index, f"environment:{env_index}"), entry
|
||||
for history_index, entry in enumerate(scan_data.history):
|
||||
yield (index, f"history:{history_index}"), entry
|
||||
for file_index, scanned_file in enumerate(scan_data.files):
|
||||
yield (index, f"file:{file_index}"), scanned_file.content
|
||||
# Free the file's contents once handed to the scanner. The
|
||||
# report phase needs only its path and layer digest, so
|
||||
# retained memory stays flat instead of growing with the
|
||||
# number of repositories scanned.
|
||||
scanned_file.content = ""
|
||||
|
||||
# Phase 2: batch — one call, chunked Kingfisher subprocesses. This
|
||||
# must fully consume image_payloads() so every image is appended to
|
||||
# `scanned` before Phase 3 runs; detect_secrets_scan_batch does so
|
||||
# today, but a future short-circuit there would silently drop images
|
||||
# from the report loop.
|
||||
scan_error = None
|
||||
try:
|
||||
batch_results = detect_secrets_scan_batch(
|
||||
image_payloads(),
|
||||
excluded_secrets=secrets_ignore_patterns,
|
||||
validate=validate,
|
||||
)
|
||||
except SecretsScanError as error:
|
||||
batch_results = {}
|
||||
scan_error = error
|
||||
|
||||
if scan_error:
|
||||
# The scan failed and the payload generator may not have been
|
||||
# consumed, so build the MANUAL reports from the repositories
|
||||
# themselves rather than risk a false PASS or a missing finding.
|
||||
for registry in ecr_client.registries.values():
|
||||
for repository in registry.repositories:
|
||||
image = ecr_client._get_scan_target_image(repository)
|
||||
if isinstance(image, Exception):
|
||||
findings.append(
|
||||
self._build_scan_error_report(repository, image)
|
||||
)
|
||||
elif image is not None:
|
||||
report = self._build_report(repository, image)
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not scan image '{image.latest_tag}' "
|
||||
f"({image.latest_digest}) of ECR repository "
|
||||
f"{repository.name} for secrets: {scan_error}; "
|
||||
f"manual review is required."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
# Phase 3: report — one finding per scanned image.
|
||||
for index, (repository, image, scan_data) in enumerate(scanned):
|
||||
if isinstance(scan_data, Exception):
|
||||
findings.append(self._build_scan_error_report(repository, scan_data))
|
||||
continue
|
||||
report = self._build_report(repository, image)
|
||||
image_reference = (
|
||||
f"image '{image.latest_tag}' ({image.latest_digest}) of ECR "
|
||||
f"repository {repository.name}"
|
||||
)
|
||||
|
||||
if scan_data is None:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not resolve or retrieve the manifest of the "
|
||||
f"{image_reference} to scan it for secrets; manual "
|
||||
f"review is required."
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
env_findings_by_index = {
|
||||
int(key[1].split(":", 1)[1]): entry_secrets
|
||||
for key, entry_secrets in batch_results.items()
|
||||
if key[0] == index and key[1].startswith("environment:")
|
||||
}
|
||||
history_findings_by_index = {
|
||||
int(key[1].split(":", 1)[1]): entry_secrets
|
||||
for key, entry_secrets in batch_results.items()
|
||||
if key[0] == index and key[1].startswith("history:")
|
||||
}
|
||||
file_findings_by_index = {
|
||||
int(key[1].split(":", 1)[1]): file_secrets
|
||||
for key, file_secrets in batch_results.items()
|
||||
if key[0] == index and key[1].startswith("file:")
|
||||
}
|
||||
|
||||
if (
|
||||
env_findings_by_index
|
||||
or history_findings_by_index
|
||||
or file_findings_by_index
|
||||
):
|
||||
secrets_found = []
|
||||
all_secrets = []
|
||||
|
||||
for env_index, env_findings in env_findings_by_index.items():
|
||||
variable = None
|
||||
if 0 <= env_index < len(scan_data.env):
|
||||
entry = scan_data.env[env_index]
|
||||
# Only a well-formed "NAME=value" entry has a name safe
|
||||
# to report; an entry with no "=" may itself be the
|
||||
# secret, so it is never echoed back.
|
||||
if "=" in entry:
|
||||
candidate = entry.split("=", 1)[0]
|
||||
if _SAFE_ENVIRONMENT_VARIABLE_NAME.fullmatch(candidate):
|
||||
variable = candidate
|
||||
all_secrets.extend(env_findings)
|
||||
for secret in env_findings:
|
||||
if variable is not None:
|
||||
secrets_found.append(
|
||||
f"{secret['type']} in environment variable {variable}"
|
||||
)
|
||||
else:
|
||||
secrets_found.append(
|
||||
f"{secret['type']} in image environment variables"
|
||||
)
|
||||
for (
|
||||
history_index,
|
||||
history_findings,
|
||||
) in history_findings_by_index.items():
|
||||
all_secrets.extend(history_findings)
|
||||
for secret in history_findings:
|
||||
secrets_found.append(
|
||||
f"{secret['type']} in image history step {history_index + 1}"
|
||||
)
|
||||
for file_index, file_secrets in file_findings_by_index.items():
|
||||
scanned_file = scan_data.files[file_index]
|
||||
all_secrets.extend(file_secrets)
|
||||
for secret in file_secrets:
|
||||
secrets_found.append(
|
||||
f"{secret['type']} in file {scanned_file.path} "
|
||||
f"(layer {scanned_file.layer_digest})"
|
||||
)
|
||||
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Potential {'secrets' if len(secrets_found) > 1 else 'secret'} "
|
||||
f"found in the {image_reference} -> {', '.join(secrets_found)}."
|
||||
)
|
||||
if scan_data.truncated:
|
||||
report.status_extended += (
|
||||
" Some of the image could not be retrieved or exceeded "
|
||||
"configured size limits and was not scanned."
|
||||
)
|
||||
annotate_verified_secrets(report, all_secrets)
|
||||
elif scan_data.truncated:
|
||||
# No secrets in what was scanned, but coverage was incomplete
|
||||
# (size/count limits, or the config could not be retrieved), so
|
||||
# a clean result would be misleading.
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"No secrets were found in the scanned portion of the "
|
||||
f"{image_reference}, but part of it could not be retrieved "
|
||||
f"or exceeded configured size limits and was not scanned; "
|
||||
f"manual review is required."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"No secrets found in the {image_reference}."
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
|
||||
def _build_scan_error_report(self, repository, error) -> Check_Report_AWS:
|
||||
"""Build a repository-level report for a latest-image lookup failure."""
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=repository)
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not determine the latest image of ECR repository "
|
||||
f"{repository.name}: {error}; manual review is required."
|
||||
)
|
||||
return report
|
||||
|
||||
def _build_report(self, repository, image) -> Check_Report_AWS:
|
||||
"""Build a report scoped to a single image within a repository.
|
||||
|
||||
ECR images have no ARN of their own, so the repository's ARN is
|
||||
reused with the image digest appended as a synthetic suffix,
|
||||
mirroring how other sub-resource checks (e.g. CodeArtifact packages
|
||||
within a repository) identify per-item findings.
|
||||
"""
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=repository)
|
||||
digest_short = image.latest_digest.split(":")[-1][:12]
|
||||
report.resource_id = f"{repository.name}:{image.latest_tag}@{digest_short}"
|
||||
report.resource_arn = f"{repository.arn}/image/{digest_short}"
|
||||
return report
|
||||
@@ -1,3 +1,4 @@
|
||||
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
||||
from datetime import datetime
|
||||
from json import loads
|
||||
from typing import Optional
|
||||
@@ -8,10 +9,21 @@ from pydantic.v1 import BaseModel
|
||||
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
|
||||
from prowler.providers.aws.services.ecr.image_inspection import ImageInspector
|
||||
|
||||
# Concurrency for the image-scan pipeline (_get_image_scan_data). Kept smaller
|
||||
# than the shared MAX_WORKERS metadata pool because each task can retain up to
|
||||
# MAX_LAYER_DOWNLOAD_BYTES compressed plus MAX_TOTAL_BYTES_PER_IMAGE decompressed
|
||||
# content (see image_inspection), so a high worker count would multiply peak
|
||||
# memory into several GB.
|
||||
IMAGE_SCAN_MAX_WORKERS = 4
|
||||
|
||||
|
||||
class ECR(AWSService):
|
||||
"""AWS Elastic Container Registry service."""
|
||||
|
||||
def __init__(self, provider):
|
||||
"""Discover registries, repositories, policies, and image metadata."""
|
||||
# Call AWSService's __init__
|
||||
super().__init__(__class__.__name__, provider)
|
||||
self.registry_id = self.audited_account
|
||||
@@ -24,6 +36,7 @@ class ECR(AWSService):
|
||||
self.__threading_call__(self._list_tags_for_resource)
|
||||
|
||||
def _describe_registries_and_repositories(self, regional_client):
|
||||
"""Populate the registry and its repositories for one region."""
|
||||
logger.info("ECR - Describing registries and repositories...")
|
||||
regional_registry_repositories = []
|
||||
try:
|
||||
@@ -68,6 +81,7 @@ class ECR(AWSService):
|
||||
)
|
||||
|
||||
def _describe_repository_policies(self, regional_client):
|
||||
"""Fetch and attach each repository's resource policy, if any."""
|
||||
logger.info("ECR - Describing repository policies...")
|
||||
try:
|
||||
if regional_client.region in self.registries:
|
||||
@@ -96,6 +110,7 @@ class ECR(AWSService):
|
||||
)
|
||||
|
||||
def _get_repository_lifecycle_policy(self, regional_client):
|
||||
"""Fetch and attach each repository's lifecycle policy, if any."""
|
||||
logger.info("ECR - Getting repository lifecycle policy...")
|
||||
try:
|
||||
if regional_client.region in self.registries:
|
||||
@@ -124,6 +139,7 @@ class ECR(AWSService):
|
||||
)
|
||||
|
||||
def _get_image_details(self, regional_client):
|
||||
"""Populate each scan-on-push repository's scannable, tagged images."""
|
||||
logger.info("ECR - Getting images details...")
|
||||
try:
|
||||
if regional_client.region in self.registries:
|
||||
@@ -158,12 +174,7 @@ class ECR(AWSService):
|
||||
image_scan_findings_field_name = (
|
||||
"imageScanFindingsSummary"
|
||||
)
|
||||
if "docker" in artifact_media_type:
|
||||
type = "Docker"
|
||||
elif "oci" in artifact_media_type:
|
||||
type = "OCI"
|
||||
else:
|
||||
type = ""
|
||||
type = ECR._artifact_type(artifact_media_type)
|
||||
|
||||
# If imageScanStatus is not present or imageScanFindingsSummary is missing,
|
||||
# we need to call DescribeImageScanFindings because AWS' new version of
|
||||
@@ -252,6 +263,7 @@ class ECR(AWSService):
|
||||
)
|
||||
|
||||
def _list_tags_for_resource(self, regional_client):
|
||||
"""Fetch and attach each repository's resource tags."""
|
||||
logger.info("ECR - List Tags...")
|
||||
try:
|
||||
if regional_client.region in self.registries:
|
||||
@@ -280,6 +292,7 @@ class ECR(AWSService):
|
||||
)
|
||||
|
||||
def _get_registry_scanning_configuration(self, regional_client):
|
||||
"""Fetch and attach the registry's image-scanning configuration."""
|
||||
logger.info("ECR - Getting Registry Scanning Configuration...")
|
||||
try:
|
||||
if regional_client.region in self.registries:
|
||||
@@ -315,6 +328,155 @@ class ECR(AWSService):
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_image_scan_data(self):
|
||||
"""Lazily fetch manifest, config, and layer file contents for the latest image.
|
||||
|
||||
Only the most recently pushed scannable image in each repository is
|
||||
scanned (resolved via _get_scan_target_image, which also covers
|
||||
scan-on-push-disabled repositories) to bound cost on repositories
|
||||
with many tags.
|
||||
|
||||
Not called from __init__: this is only invoked by the
|
||||
ecr_repository_image_no_secrets check, since it downloads and
|
||||
decompresses image layers and is significantly more expensive than
|
||||
the metadata gathered above. A dedicated, smaller thread pool bounds
|
||||
the concurrency (and therefore the peak memory) of this heavy
|
||||
pipeline independently of the shared metadata pool.
|
||||
|
||||
Yields:
|
||||
Tuple of repository, optional image, and scan data. The third item
|
||||
is an exception when the authoritative image lookup failed.
|
||||
"""
|
||||
logger.info("ECR - Fetching image manifests, configs, and layers...")
|
||||
inspector = ImageInspector()
|
||||
|
||||
def images_to_fetch():
|
||||
for registry in self.registries.values():
|
||||
for repository in registry.repositories:
|
||||
image = self._get_scan_target_image(repository)
|
||||
if isinstance(image, Exception):
|
||||
yield repository, None, image
|
||||
elif image is not None:
|
||||
yield repository, image, None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=IMAGE_SCAN_MAX_WORKERS) as executor:
|
||||
pending = {}
|
||||
targets = iter(images_to_fetch())
|
||||
|
||||
def submit_next():
|
||||
try:
|
||||
repository, image, error = next(targets)
|
||||
except StopIteration:
|
||||
return False
|
||||
if error:
|
||||
future = Future()
|
||||
future.set_result(error)
|
||||
else:
|
||||
client = self.regional_clients[repository.region]
|
||||
registry_id = self.registries[repository.region].id
|
||||
future = executor.submit(
|
||||
inspector.fetch_image_scan_data,
|
||||
client,
|
||||
registry_id,
|
||||
repository.name,
|
||||
image.latest_digest,
|
||||
)
|
||||
pending[future] = (repository, image)
|
||||
return True
|
||||
|
||||
for _ in range(IMAGE_SCAN_MAX_WORKERS):
|
||||
if not submit_next():
|
||||
break
|
||||
|
||||
while pending:
|
||||
completed, _ = wait(pending, return_when=FIRST_COMPLETED)
|
||||
for future in completed:
|
||||
repository, image = pending.pop(future)
|
||||
scan_data = None
|
||||
try:
|
||||
scan_data = future.result()
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{repository.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
yield repository, image, scan_data
|
||||
submit_next()
|
||||
|
||||
@staticmethod
|
||||
def _artifact_type(artifact_media_type: Optional[str]) -> str:
|
||||
"""Map an image's artifact media type to a short image type label.
|
||||
|
||||
Returns:
|
||||
"Docker", "OCI", or "" for an unrecognized/absent media type.
|
||||
"""
|
||||
if artifact_media_type:
|
||||
if "docker" in artifact_media_type:
|
||||
return "Docker"
|
||||
if "oci" in artifact_media_type:
|
||||
return "OCI"
|
||||
return ""
|
||||
|
||||
def _get_scan_target_image(self, repository) -> Optional["ImageDetails"]:
|
||||
"""Resolve the latest scannable image to scan for secrets.
|
||||
|
||||
Secret scanning is independent of ECR's vulnerability scanning
|
||||
configuration, but `_get_image_details` only populates
|
||||
`images_details` for scan-on-push-enabled repositories. For a
|
||||
repository with scan-on-push disabled (empty `images_details`), this
|
||||
performs a dedicated `describe_images` lookup to find the most
|
||||
recently pushed scannable image, so those repositories are not
|
||||
silently skipped.
|
||||
|
||||
The synthesized ImageDetails is deliberately NOT appended to
|
||||
`repository.images_details`: other checks (e.g.
|
||||
ecr_repositories_scan_vulnerabilities_in_latest_image) treat any
|
||||
entry there as a scanned image and would FAIL scan-on-push-disabled
|
||||
repositories that currently produce no finding.
|
||||
|
||||
Returns:
|
||||
The latest scannable ImageDetails, or None if the repository has
|
||||
no scannable image; an exception if the lookup failed.
|
||||
"""
|
||||
latest = repository.images_details[-1] if repository.images_details else None
|
||||
try:
|
||||
client = self.regional_clients[repository.region]
|
||||
describe_images_paginator = client.get_paginator("describe_images")
|
||||
for page in describe_images_paginator.paginate(
|
||||
registryId=self.registries[repository.region].id,
|
||||
repositoryName=repository.name,
|
||||
PaginationConfig={"PageSize": 1000},
|
||||
):
|
||||
for image in page["imageDetails"]:
|
||||
if image is None:
|
||||
continue
|
||||
artifact_media_type = image.get("artifactMediaType", None)
|
||||
tags = image.get("imageTags", [])
|
||||
if not ECR._is_artifact_scannable(artifact_media_type, tags):
|
||||
continue
|
||||
image_pushed_at = image.get("imagePushedAt")
|
||||
if image_pushed_at is None:
|
||||
continue
|
||||
# Match _get_image_details' "sort ascending, take last"
|
||||
# selection: on equal push dates the later-listed image
|
||||
# wins, so `<` (not `<=`) is used to replace on ties.
|
||||
if latest is not None and image_pushed_at < latest.image_pushed_at:
|
||||
continue
|
||||
latest = ImageDetails(
|
||||
latest_tag=image.get("imageTags", ["None"])[0],
|
||||
image_pushed_at=image_pushed_at,
|
||||
latest_digest=image.get("imageDigest"),
|
||||
scan_findings_status=None,
|
||||
scan_findings_severity_count=None,
|
||||
artifact_media_type=artifact_media_type,
|
||||
type=ECR._artifact_type(artifact_media_type),
|
||||
)
|
||||
return latest
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{repository.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return error
|
||||
|
||||
@staticmethod
|
||||
def _is_artifact_scannable(artifact_media_type: str, tags: list[str] = []) -> bool:
|
||||
"""
|
||||
@@ -355,12 +517,16 @@ class ECR(AWSService):
|
||||
|
||||
|
||||
class FindingSeverityCounts(BaseModel):
|
||||
"""Count of an image's vulnerability scan findings by severity."""
|
||||
|
||||
critical: int
|
||||
high: int
|
||||
medium: int
|
||||
|
||||
|
||||
class ImageDetails(BaseModel):
|
||||
"""A single scannable, tagged image within an ECR repository."""
|
||||
|
||||
latest_tag: str
|
||||
latest_digest: str
|
||||
image_pushed_at: datetime
|
||||
@@ -371,6 +537,8 @@ class ImageDetails(BaseModel):
|
||||
|
||||
|
||||
class Repository(BaseModel):
|
||||
"""An ECR repository and its policies, images, and tags."""
|
||||
|
||||
name: str
|
||||
arn: str
|
||||
region: str
|
||||
@@ -384,11 +552,15 @@ class Repository(BaseModel):
|
||||
|
||||
|
||||
class ScanningRule(BaseModel):
|
||||
"""A registry-level image-scanning rule and its repository filters."""
|
||||
|
||||
scan_frequency: str
|
||||
scan_filters: list[dict]
|
||||
|
||||
|
||||
class Registry(BaseModel):
|
||||
"""An ECR registry: its repositories and scanning configuration."""
|
||||
|
||||
id: str
|
||||
arn: str
|
||||
region: str
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
import gzip
|
||||
import tarfile
|
||||
from contextlib import contextmanager
|
||||
from json import loads
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
import zstandard
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
|
||||
# Manifest media types that wrap several per-architecture manifests (a "fat
|
||||
# manifest") rather than a single scannable image.
|
||||
_MANIFEST_LIST_MEDIA_TYPES = {
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
}
|
||||
|
||||
# Compressed size of a single layer, checked against the manifest-declared
|
||||
# size before downloading, and re-checked against actual bytes received.
|
||||
MAX_LAYER_DOWNLOAD_BYTES = 100 * 1024 * 1024
|
||||
# Size of a single extracted file considered for scanning.
|
||||
MAX_FILE_BYTES = 1 * 1024 * 1024
|
||||
# Hard cap on the number of files scanned per image, across all its layers.
|
||||
MAX_FILES_PER_IMAGE = 5000
|
||||
# Hard cap on total decompressed bytes read per image, across all its layers.
|
||||
MAX_TOTAL_BYTES_PER_IMAGE = 500 * 1024 * 1024
|
||||
LAYER_DOWNLOAD_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
class _LayerTooLargeError(Exception):
|
||||
"""Raised when a streamed layer exceeds MAX_LAYER_DOWNLOAD_BYTES."""
|
||||
|
||||
|
||||
class _ImageTooLargeError(Exception):
|
||||
"""Raised when decompressed image streams exceed their shared budget."""
|
||||
|
||||
|
||||
class _CappedLayerReader:
|
||||
"""A minimal read-only file object that caps the bytes it will yield.
|
||||
|
||||
Wraps a streaming HTTP body (urllib3's ``response.raw``) so ``tarfile`` can
|
||||
read a gzip/uncompressed layer incrementally while enforcing an upper bound
|
||||
on the compressed bytes consumed. A manifest that under-declares a layer's
|
||||
size (the declared size is pre-checked separately) cannot make this buffer
|
||||
an unbounded amount of untrusted data: once ``max_bytes`` is exceeded the
|
||||
read raises ``_LayerTooLargeError`` instead of continuing.
|
||||
"""
|
||||
|
||||
def __init__(self, raw, max_bytes: int):
|
||||
"""Store the underlying raw stream and the remaining byte budget."""
|
||||
self._raw = raw
|
||||
self._remaining = max_bytes
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
"""Read up to ``size`` bytes, never exceeding the remaining budget.
|
||||
|
||||
A negative/None ``size`` (``read all``) is treated as "read what's left
|
||||
of the budget, plus one" so a lying stream can never pull an unbounded
|
||||
amount into memory and an over-cap layer is still detected.
|
||||
"""
|
||||
if size is None or size < 0:
|
||||
size = self._remaining + 1
|
||||
to_read = min(size, self._remaining + 1)
|
||||
chunk = self._raw.read(to_read)
|
||||
self._remaining -= len(chunk)
|
||||
if self._remaining < 0:
|
||||
raise _LayerTooLargeError()
|
||||
return chunk
|
||||
|
||||
|
||||
class _DecompressedByteBudget:
|
||||
"""Track every decompressed byte consumed across an image's tar streams."""
|
||||
|
||||
def __init__(self, max_bytes: int):
|
||||
"""Set the shared decompressed-byte allowance."""
|
||||
self.remaining = max_bytes
|
||||
|
||||
def wrap(self, raw):
|
||||
"""Return a reader that charges bytes consumed from ``raw``."""
|
||||
return _BudgetedReader(raw, self)
|
||||
|
||||
|
||||
class _BudgetedReader:
|
||||
"""Charge all stream reads against a shared decompressed-byte budget."""
|
||||
|
||||
def __init__(self, raw, budget: _DecompressedByteBudget):
|
||||
self._raw = raw
|
||||
self._budget = budget
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
"""Read without allowing the shared budget to be exceeded."""
|
||||
if size is None or size < 0:
|
||||
size = self._budget.remaining + 1
|
||||
chunk = self._raw.read(min(size, self._budget.remaining + 1))
|
||||
self._budget.remaining -= len(chunk)
|
||||
if self._budget.remaining < 0:
|
||||
raise _ImageTooLargeError()
|
||||
return chunk
|
||||
|
||||
|
||||
class ImageScanFile(BaseModel):
|
||||
"""A single file extracted from an image layer for secret scanning."""
|
||||
|
||||
path: str
|
||||
layer_digest: str
|
||||
content: str
|
||||
|
||||
|
||||
class ImageScanData(BaseModel):
|
||||
"""An image's scannable content: config env/history and layer files."""
|
||||
|
||||
env: list[str] = []
|
||||
history: list[str] = []
|
||||
files: list[ImageScanFile] = []
|
||||
# True when part of the image was not scanned -- a layer/file exceeded a
|
||||
# configured size or count limit, or the config blob could not be
|
||||
# retrieved/parsed -- so a clean result can be reported as MANUAL
|
||||
# (coverage incomplete) rather than a false PASS.
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
class ImageInspector:
|
||||
"""Bounded, opt-in extraction of an ECR image's scannable content.
|
||||
|
||||
Given a boto3 ECR client and an image digest, resolves the image's
|
||||
manifest (handling multi-arch manifest lists and skipping attestation
|
||||
manifests) and returns its configuration (environment variables, build
|
||||
history) and every filesystem layer's file contents, subject to this
|
||||
module's size and count limits.
|
||||
|
||||
This is deliberately isolated from the ECR service so a future check can
|
||||
reuse the bounded extraction without the service downloading and
|
||||
decompressing image layers by default: the service only pays this cost
|
||||
when a check explicitly drives the inspector.
|
||||
"""
|
||||
|
||||
def fetch_image_scan_data(
|
||||
self, client, registry_id, repository_name, image_digest
|
||||
) -> Optional[ImageScanData]:
|
||||
"""Resolve one image's manifest and return its scannable content.
|
||||
|
||||
Downloads the config blob (environment variables, build history)
|
||||
and every filesystem layer's file contents, bounded by the module's
|
||||
size/count limits.
|
||||
|
||||
Returns:
|
||||
An ImageScanData, or None if the manifest could not be resolved.
|
||||
"""
|
||||
manifest, truncated = self._resolve_image_manifest(
|
||||
client, registry_id, repository_name, image_digest
|
||||
)
|
||||
if manifest is None:
|
||||
return None
|
||||
|
||||
env = []
|
||||
history = []
|
||||
|
||||
config_digest = (manifest.get("config") or {}).get("digest")
|
||||
if config_digest:
|
||||
config_bytes = self._download_layer(
|
||||
client,
|
||||
registry_id,
|
||||
repository_name,
|
||||
config_digest,
|
||||
max_bytes=MAX_FILE_BYTES,
|
||||
)
|
||||
if config_bytes is None:
|
||||
# The config blob (env vars, build history) could not be
|
||||
# retrieved. Empty env/history would be indistinguishable
|
||||
# from a clean config, so mark coverage incomplete instead
|
||||
# of risking a false PASS.
|
||||
truncated = True
|
||||
else:
|
||||
try:
|
||||
config_json = loads(config_bytes)
|
||||
env = config_json.get("config", {}).get("Env", []) or []
|
||||
history = [
|
||||
step.get("created_by", "")
|
||||
for step in config_json.get("history", [])
|
||||
if step.get("created_by")
|
||||
]
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{repository_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
truncated = True
|
||||
|
||||
files = []
|
||||
decompressed_budget = _DecompressedByteBudget(MAX_TOTAL_BYTES_PER_IMAGE)
|
||||
for layer in manifest.get("layers", []):
|
||||
if len(files) >= MAX_FILES_PER_IMAGE or decompressed_budget.remaining <= 0:
|
||||
truncated = True
|
||||
break
|
||||
|
||||
layer_digest = layer.get("digest")
|
||||
layer_size = layer.get("size", 0)
|
||||
if layer_size and layer_size > MAX_LAYER_DOWNLOAD_BYTES:
|
||||
truncated = True
|
||||
continue
|
||||
|
||||
try:
|
||||
with self._open_layer_tar_stream(
|
||||
client,
|
||||
registry_id,
|
||||
repository_name,
|
||||
layer_digest,
|
||||
layer.get("mediaType", ""),
|
||||
decompressed_budget,
|
||||
) as tar_stream:
|
||||
if tar_stream is None:
|
||||
truncated = True
|
||||
continue
|
||||
for member in tar_stream:
|
||||
if len(files) >= MAX_FILES_PER_IMAGE:
|
||||
truncated = True
|
||||
break
|
||||
if not member.isfile():
|
||||
continue
|
||||
base_name = member.name.rsplit("/", 1)[-1]
|
||||
if base_name.startswith(".wh."):
|
||||
# Whiteout marker: a deletion recorded by the union
|
||||
# filesystem, not real file content.
|
||||
continue
|
||||
if member.size > MAX_FILE_BYTES:
|
||||
truncated = True
|
||||
continue
|
||||
try:
|
||||
content = (
|
||||
tar_stream.extractfile(member).read().decode("latin-1")
|
||||
)
|
||||
except _LayerTooLargeError:
|
||||
# Over-cap while reading this member: truncate the
|
||||
# whole layer rather than silently skipping one file.
|
||||
raise
|
||||
except Exception:
|
||||
continue
|
||||
files.append(
|
||||
ImageScanFile(
|
||||
path=member.name,
|
||||
layer_digest=layer_digest,
|
||||
content=content,
|
||||
)
|
||||
)
|
||||
except _LayerTooLargeError:
|
||||
# The layer streamed more bytes than MAX_LAYER_DOWNLOAD_BYTES
|
||||
# (a manifest under-declaring its size); skip it and disclose
|
||||
# the partial coverage rather than buffer unbounded data.
|
||||
truncated = True
|
||||
continue
|
||||
except _ImageTooLargeError:
|
||||
truncated = True
|
||||
break
|
||||
|
||||
return ImageScanData(env=env, history=history, files=files, truncated=truncated)
|
||||
|
||||
def _resolve_image_manifest(
|
||||
self, client, registry_id, repository_name, image_digest
|
||||
) -> tuple[Optional[dict], bool]:
|
||||
"""Resolve an image digest to a single scannable image manifest.
|
||||
|
||||
Multi-arch images are stored as a manifest list/image index pointing
|
||||
at one manifest per platform (plus, often, an attestation manifest
|
||||
that isn't a real image). This picks one real platform manifest to
|
||||
scan; the other architectures in the same list are not scanned.
|
||||
"""
|
||||
try:
|
||||
manifest, media_type = self._batch_get_manifest(
|
||||
client, registry_id, repository_name, image_digest
|
||||
)
|
||||
if manifest is None:
|
||||
return None, False
|
||||
|
||||
truncated = False
|
||||
if media_type in _MANIFEST_LIST_MEDIA_TYPES:
|
||||
truncated = True
|
||||
child_digest = self._select_child_manifest_digest(manifest)
|
||||
if not child_digest:
|
||||
return None, truncated
|
||||
manifest, _ = self._batch_get_manifest(
|
||||
client, registry_id, repository_name, child_digest
|
||||
)
|
||||
if manifest is not None and not (
|
||||
manifest.get("config") or manifest.get("layers")
|
||||
):
|
||||
# A resolved manifest with neither a config nor layers has
|
||||
# nothing to scan (e.g. a nested manifest list, or an
|
||||
# unsupported manifest shape) -- treat it as unresolvable so
|
||||
# the caller reports MANUAL instead of a false PASS.
|
||||
return None, truncated
|
||||
return manifest, truncated
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{client.meta.region_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return None, False
|
||||
|
||||
@staticmethod
|
||||
def _batch_get_manifest(client, registry_id, repository_name, image_digest):
|
||||
"""Fetch and parse the raw manifest JSON for a single image digest.
|
||||
|
||||
Returns:
|
||||
A (manifest, media_type) tuple, or (None, None) if not found.
|
||||
"""
|
||||
response = client.batch_get_image(
|
||||
registryId=registry_id,
|
||||
repositoryName=repository_name,
|
||||
imageIds=[{"imageDigest": image_digest}],
|
||||
)
|
||||
images = response.get("images", [])
|
||||
if not images:
|
||||
return None, None
|
||||
manifest = loads(images[0]["imageManifest"])
|
||||
media_type = manifest.get("mediaType") or images[0].get(
|
||||
"imageManifestMediaType"
|
||||
)
|
||||
return manifest, media_type
|
||||
|
||||
@staticmethod
|
||||
def _select_child_manifest_digest(manifest_list: dict) -> Optional[str]:
|
||||
"""Pick one real platform manifest's digest from a manifest list.
|
||||
|
||||
Prefers linux/amd64, falling back to the first remaining candidate
|
||||
once attestation manifests (platform "unknown/unknown", or
|
||||
annotated as an attestation manifest) are excluded.
|
||||
|
||||
Returns:
|
||||
The chosen manifest's digest, or None if no candidate remains.
|
||||
"""
|
||||
candidates = []
|
||||
for entry in manifest_list.get("manifests", []):
|
||||
platform = entry.get("platform", {}) or {}
|
||||
annotations = entry.get("annotations", {}) or {}
|
||||
if (
|
||||
platform.get("architecture") == "unknown"
|
||||
or platform.get("os") == "unknown"
|
||||
):
|
||||
# Attestation manifests (SBOMs, provenance, signatures) are
|
||||
# attached to the index as "unknown/unknown" platform entries.
|
||||
continue
|
||||
if annotations.get("vnd.docker.reference.type") == "attestation-manifest":
|
||||
continue
|
||||
candidates.append(entry)
|
||||
|
||||
for entry in candidates:
|
||||
platform = entry.get("platform", {}) or {}
|
||||
if (
|
||||
platform.get("architecture") == "amd64"
|
||||
and platform.get("os") == "linux"
|
||||
):
|
||||
return entry.get("digest")
|
||||
|
||||
return candidates[0].get("digest") if candidates else None
|
||||
|
||||
@staticmethod
|
||||
def _get_layer_download_url(
|
||||
client, registry_id, repository_name, layer_digest
|
||||
) -> Optional[str]:
|
||||
"""Resolve the presigned download URL for one layer or config blob.
|
||||
|
||||
Returns:
|
||||
The presigned URL, or None if ECR did not return one.
|
||||
"""
|
||||
response = client.get_download_url_for_layer(
|
||||
registryId=registry_id,
|
||||
repositoryName=repository_name,
|
||||
layerDigest=layer_digest,
|
||||
)
|
||||
return response.get("downloadUrl")
|
||||
|
||||
@staticmethod
|
||||
def _download_layer(
|
||||
client, registry_id, repository_name, layer_digest, max_bytes=None
|
||||
) -> Optional[bytes]:
|
||||
"""Download one layer or config blob via its presigned URL.
|
||||
|
||||
Streams the response, aborting once `max_bytes` is exceeded, so a
|
||||
lying or oversized blob is never buffered in full. Used for the config
|
||||
blob and for zstd layers (which cannot be streamed into tarfile);
|
||||
gzip/uncompressed layers are streamed by `_open_layer_tar_stream`.
|
||||
|
||||
Returns:
|
||||
The blob's bytes, or None if it could not be downloaded or
|
||||
exceeded `max_bytes`.
|
||||
"""
|
||||
try:
|
||||
download_url = ImageInspector._get_layer_download_url(
|
||||
client, registry_id, repository_name, layer_digest
|
||||
)
|
||||
if not download_url:
|
||||
return None
|
||||
|
||||
downloaded = bytearray()
|
||||
with requests.get(
|
||||
download_url,
|
||||
stream=True,
|
||||
timeout=LAYER_DOWNLOAD_TIMEOUT_SECONDS,
|
||||
allow_redirects=False,
|
||||
) as http_response:
|
||||
http_response.raise_for_status()
|
||||
for chunk in http_response.iter_content(chunk_size=1024 * 1024):
|
||||
downloaded.extend(chunk)
|
||||
if max_bytes and len(downloaded) > max_bytes:
|
||||
return None
|
||||
return bytes(downloaded)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{repository_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return None
|
||||
|
||||
@contextmanager
|
||||
def _open_layer_tar_stream(
|
||||
self,
|
||||
client,
|
||||
registry_id,
|
||||
repository_name,
|
||||
layer_digest,
|
||||
media_type: str,
|
||||
decompressed_budget: _DecompressedByteBudget,
|
||||
):
|
||||
"""Yield an open TarFile for one layer, streamed from the download.
|
||||
|
||||
gzip, zstd, and uncompressed tar layers are all streamed straight from
|
||||
the download into `tarfile` (streaming mode reads a file-like object
|
||||
sequentially), so neither the compressed blob nor a decompressed copy is
|
||||
ever buffered in full. A `_CappedLayerReader` enforces
|
||||
`MAX_LAYER_DOWNLOAD_BYTES` on the compressed bytes (guarding a manifest
|
||||
that under-declares the layer size); zstd is decompressed incrementally
|
||||
via `zstandard`'s streaming reader, so a crafted frame can no longer
|
||||
expand unbounded in memory, and the decompressed side is bounded by the
|
||||
caller's per-image budget as it iterates members.
|
||||
|
||||
Yields:
|
||||
An open TarFile, or None for an unrecognized media type or a
|
||||
download/decompression failure. Raises `_LayerTooLargeError` if a
|
||||
streamed layer's compressed bytes exceed `MAX_LAYER_DOWNLOAD_BYTES`.
|
||||
"""
|
||||
if media_type.endswith("gzip"):
|
||||
decompress = "gzip"
|
||||
elif media_type.endswith("zstd"):
|
||||
decompress = "zstd"
|
||||
elif media_type.endswith("tar"):
|
||||
decompress = None
|
||||
else:
|
||||
yield None
|
||||
return
|
||||
|
||||
# Only the setup (URL resolution, connection, tar-header parse) is
|
||||
# guarded here; a failure yields None. The `yield tar_stream` below is
|
||||
# kept out of this try so exceptions raised while the caller iterates
|
||||
# members (e.g. _LayerTooLargeError) propagate instead of triggering a
|
||||
# forbidden second yield.
|
||||
try:
|
||||
download_url = ImageInspector._get_layer_download_url(
|
||||
client, registry_id, repository_name, layer_digest
|
||||
)
|
||||
if not download_url:
|
||||
yield None
|
||||
return
|
||||
http_response = requests.get(
|
||||
download_url,
|
||||
stream=True,
|
||||
timeout=LAYER_DOWNLOAD_TIMEOUT_SECONDS,
|
||||
allow_redirects=False,
|
||||
)
|
||||
try:
|
||||
http_response.raise_for_status()
|
||||
# Cap the compressed bytes read from the network; for zstd,
|
||||
# decompress that capped stream incrementally so the decompressed
|
||||
# data is never materialized in full.
|
||||
source = _CappedLayerReader(http_response.raw, MAX_LAYER_DOWNLOAD_BYTES)
|
||||
if decompress == "gzip":
|
||||
source = gzip.GzipFile(fileobj=source)
|
||||
elif decompress == "zstd":
|
||||
source = zstandard.ZstdDecompressor().stream_reader(source)
|
||||
source = decompressed_budget.wrap(source)
|
||||
tar_stream = tarfile.open(fileobj=source, mode="r|")
|
||||
except (_LayerTooLargeError, _ImageTooLargeError):
|
||||
http_response.close()
|
||||
raise
|
||||
except Exception:
|
||||
http_response.close()
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{repository_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
yield None
|
||||
return
|
||||
|
||||
with http_response, tar_stream:
|
||||
yield tar_stream
|
||||
@@ -149,8 +149,8 @@ class ECS(AWSService):
|
||||
"TAGS",
|
||||
],
|
||||
)
|
||||
container_definitions = response["taskDefinition"]["containerDefinitions"]
|
||||
for container in container_definitions:
|
||||
container_definitions = []
|
||||
for container in response["taskDefinition"]["containerDefinitions"]:
|
||||
environment = []
|
||||
if "environment" in container:
|
||||
for env_var in container["environment"]:
|
||||
@@ -159,7 +159,7 @@ class ECS(AWSService):
|
||||
name=env_var["name"], value=env_var["value"]
|
||||
)
|
||||
)
|
||||
task_definition.container_definitions.append(
|
||||
container_definitions.append(
|
||||
ContainerDefinition(
|
||||
name=container["name"],
|
||||
privileged=container.get("privileged", False),
|
||||
@@ -176,14 +176,16 @@ class ECS(AWSService):
|
||||
.get("mode", ""),
|
||||
)
|
||||
)
|
||||
task_definition.pid_mode = response["taskDefinition"].get("pidMode", "")
|
||||
task_definition.registered_at = response["taskDefinition"].get(
|
||||
"registeredAt"
|
||||
)
|
||||
task_definition.tags = response.get("tags")
|
||||
task_definition.network_mode = response["taskDefinition"].get(
|
||||
"networkMode", "bridge"
|
||||
)
|
||||
pid_mode = response["taskDefinition"].get("pidMode", "")
|
||||
registered_at = response["taskDefinition"].get("registeredAt")
|
||||
tags = response.get("tags")
|
||||
network_mode = response["taskDefinition"].get("networkMode", "bridge")
|
||||
|
||||
task_definition.container_definitions = container_definitions
|
||||
task_definition.pid_mode = pid_mode
|
||||
task_definition.registered_at = registered_at
|
||||
task_definition.tags = tags
|
||||
task_definition.network_mode = network_mode
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
@@ -302,7 +304,7 @@ class TaskDefinition(BaseModel):
|
||||
arn: str
|
||||
revision: str
|
||||
region: str
|
||||
container_definitions: list[ContainerDefinition] = []
|
||||
container_definitions: Optional[list[ContainerDefinition]] = None
|
||||
pid_mode: Optional[str]
|
||||
registered_at: Optional[datetime] = None
|
||||
tags: Optional[list] = []
|
||||
|
||||
@@ -6,6 +6,8 @@ class ecs_task_definitions_containers_readonly_access(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ class ecs_task_definitions_host_namespace_not_shared(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ class ecs_task_definitions_host_networking_mode_users(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ class ecs_task_definitions_logging_block_mode(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ class ecs_task_definitions_logging_enabled(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
@@ -16,7 +16,11 @@ class ecs_task_definitions_no_environment_secrets(Check):
|
||||
"secrets_ignore_patterns", []
|
||||
)
|
||||
validate = ecs_client.audit_config.get("secrets_validate", False)
|
||||
task_definitions = list(ecs_client.task_definitions.values())
|
||||
task_definitions = [
|
||||
task_definition
|
||||
for task_definition in ecs_client.task_definitions.values()
|
||||
if task_definition.container_definitions is not None
|
||||
]
|
||||
|
||||
# Scan every (task definition, container) environment in batched
|
||||
# Kingfisher invocations instead of one subprocess per container.
|
||||
|
||||
@@ -6,6 +6,8 @@ class ecs_task_definitions_no_privileged_containers(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "rolesanywhere_profile_restricts_session_permissions",
|
||||
"CheckTitle": "IAM Roles Anywhere profiles scope down the vended session permissions",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
],
|
||||
"ServiceName": "rolesanywhere",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsRolesAnywhereProfile",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**IAM Roles Anywhere profiles** that reference an administrative role are assessed for **session scoping**. A profile defining neither an inline `sessionPolicy` nor `managedPolicyArns` vends credentials with the full permissions of its roles. It is flagged only when a referenced role is administrative, since an unscoped session on a least-privilege role is already constrained.",
|
||||
"Risk": "Roles Anywhere profiles bind X.509 certificates to IAM roles. When a profile references an administrative role and does not scope the session, vended credentials carry full administrative permissions. An attacker presenting a valid certificate - or planting a rogue trust anchor and profile - gains durable privileged access that rotating IAM keys does not revoke.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/rolesanywhere/latest/userguide/introduction.html",
|
||||
"https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_CreateProfile.html",
|
||||
"https://docs.aws.amazon.com/rolesanywhere/latest/userguide/security-best-practices.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws rolesanywhere update-profile --profile-id <profile_id> --session-policy '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"<least_privilege_actions>\"],\"Resource\":[\"<scoped_resources>\"]}]}'",
|
||||
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::RolesAnywhere::Profile\n Properties:\n Name: scoped-profile\n Enabled: true\n RoleArns:\n - <role_arn>\n SessionPolicy: '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"<least_privilege_actions>\"],\"Resource\":[\"<scoped_resources>\"]}]}' # FIX: scope down the vended session\n```",
|
||||
"Other": "1. Identify the least-privilege actions the workload actually needs\n2. Attach a sessionPolicy or managedPolicyArns to the Roles Anywhere profile that grants only those actions\n3. Prefer purpose-built roles per workload over broad roles referenced by many profiles\n4. Review trust anchors and profiles regularly for entries you did not create",
|
||||
"Terraform": "```hcl\nresource \"aws_rolesanywhere_profile\" \"<example_resource_name>\" {\n name = \"scoped-profile\"\n enabled = true\n role_arns = [<role_arn>]\n session_policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = [<least_privilege_actions>]\n Resource = [<scoped_resources>]\n }]\n }) # FIX: scope down the vended session\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Attach a session policy or managed policies to every enabled IAM Roles Anywhere profile so the vended credentials are scoped below the referenced role's permissions. Pair each profile with a purpose-built least-privilege role and audit trust anchors and profiles regularly for unexpected entries.",
|
||||
"Url": "https://hub.prowler.com/check/rolesanywhere_profile_restricts_session_permissions"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access",
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "A profile is failed only when it is enabled, its session is unscoped, and a referenced role effectively grants administrative (*:*) access. The session-policy set (inline sessionPolicy plus every managedPolicyArns entry, resolved to its policy document) is evaluated as a union: any member granting *:* leaves the session unrestricted. Role classification merges all attached and inline identity-policy documents so explicit denies negate allows across policies, excludes condition-guarded statements (not statically provable), and intersects the result with the role's permissions boundary: a role whose boundary does not grant *:* - or whose boundary document cannot be resolved - is not classified as administrative. Disabled profiles, scoped profiles, and profiles with no role identified as administrative are reported as PASS. Referenced roles absent from the IAM inventory (for example cross-account roles or denied ListRoles) and policy documents that could not be collected are treated as non-administrative to avoid false positives."
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import json
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.aws.services.iam.iam_client import iam_client
|
||||
from prowler.providers.aws.services.iam.lib.policy import check_full_service_access
|
||||
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_client import (
|
||||
rolesanywhere_client,
|
||||
)
|
||||
|
||||
# AWS-managed AdministratorAccess ARN suffix, partition-agnostic
|
||||
# (arn:aws:..., arn:aws-cn:..., arn:aws-us-gov:...).
|
||||
ADMIN_POLICY_ARN_SUFFIX = ":iam::aws:policy/AdministratorAccess"
|
||||
|
||||
# Synthetic statement equivalent to the AWS-managed AdministratorAccess
|
||||
# document, used when a policy is identified by that ARN but its document was
|
||||
# not collected by the IAM service.
|
||||
_ADMIN_STATEMENT = {"Effect": "Allow", "Action": "*", "Resource": "*"}
|
||||
|
||||
|
||||
def _grants_full_access(document) -> bool:
|
||||
"""Return True when a policy document grants administrative (``*:*``) access.
|
||||
|
||||
Args:
|
||||
document: Decoded IAM policy document, or None when unavailable.
|
||||
"""
|
||||
if not document:
|
||||
return False
|
||||
try:
|
||||
return check_full_service_access("*", document)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _full_access_status(documents) -> bool | None:
|
||||
"""Return whether a policy-document set grants ``*:*``, or None when unknown.
|
||||
|
||||
Merges every condition-free statement across the given documents into a
|
||||
single evaluation so an explicit deny in one document negates an allow in
|
||||
another (Deny > Allow via the shared policy-evaluation helpers).
|
||||
|
||||
Unresolved or malformed documents and context-dependent semantics cannot
|
||||
produce a definitive classification and propagate as None:
|
||||
|
||||
- a document that is missing or not a well-formed statement container;
|
||||
- a ``Condition``-guarded Deny that could negate an otherwise proven
|
||||
full-access grant;
|
||||
- a ``Condition``-guarded Allow that could grant full access not proven
|
||||
by the unconditional statements.
|
||||
|
||||
Args:
|
||||
documents: Iterable of decoded IAM policy documents (None members mark
|
||||
documents that could not be resolved).
|
||||
"""
|
||||
statements = []
|
||||
conditional_effects = set()
|
||||
|
||||
for document in documents:
|
||||
if not isinstance(document, dict) or "Statement" not in document:
|
||||
return None
|
||||
|
||||
document_statements = document.get("Statement", [])
|
||||
if not isinstance(document_statements, list):
|
||||
document_statements = [document_statements]
|
||||
|
||||
for statement in document_statements:
|
||||
if not isinstance(statement, dict):
|
||||
return None
|
||||
|
||||
effect = str(statement.get("Effect", "")).lower()
|
||||
if (
|
||||
effect not in {"allow", "deny"}
|
||||
or not ("Action" in statement or "NotAction" in statement)
|
||||
or not ("Resource" in statement or "NotResource" in statement)
|
||||
):
|
||||
return None
|
||||
|
||||
if statement.get("Condition"):
|
||||
conditional_effects.add(effect)
|
||||
else:
|
||||
statements.append(statement)
|
||||
|
||||
grants_full_access = _grants_full_access({"Statement": statements})
|
||||
if (grants_full_access and "deny" in conditional_effects) or (
|
||||
not grants_full_access and "allow" in conditional_effects
|
||||
):
|
||||
return None
|
||||
|
||||
return grants_full_access
|
||||
|
||||
|
||||
def _role_is_privileged(role, policies) -> bool | None:
|
||||
"""Return whether an IAM role is administrative, or None when unknown.
|
||||
|
||||
Effective permissions are the intersection of the role's identity policies
|
||||
(attached and inline, evaluated together) and its permissions boundary.
|
||||
Unresolved policy documents, malformed policies, and condition-guarded
|
||||
statements that could change the outcome propagate as None instead of
|
||||
being collapsed into a definitive classification.
|
||||
|
||||
Args:
|
||||
role: An ``iam_service.Role`` referenced by a Roles Anywhere profile.
|
||||
policies: Mapping of policy ARN to ``iam_service.Policy`` from iam_client.
|
||||
"""
|
||||
documents = []
|
||||
for attached in role.attached_policies:
|
||||
policy_arn = attached.get("PolicyArn", "")
|
||||
document = getattr(policies.get(policy_arn), "document", None)
|
||||
if policy_arn.endswith(ADMIN_POLICY_ARN_SUFFIX) and not document:
|
||||
documents.append({"Statement": [_ADMIN_STATEMENT]})
|
||||
else:
|
||||
documents.append(document)
|
||||
for inline_name in role.inline_policies:
|
||||
policy = policies.get(f"{role.arn}:policy/{inline_name}")
|
||||
documents.append(getattr(policy, "document", None))
|
||||
|
||||
identity_status = _full_access_status(documents)
|
||||
if identity_status is False:
|
||||
# Identity policies provably do not grant *:*; no boundary can widen them.
|
||||
return False
|
||||
|
||||
boundary = getattr(role, "permissions_boundary", None)
|
||||
if not boundary:
|
||||
return identity_status
|
||||
boundary_arn = (
|
||||
boundary.get("PermissionsBoundaryArn", "") if isinstance(boundary, dict) else ""
|
||||
)
|
||||
if boundary_arn.endswith(ADMIN_POLICY_ARN_SUFFIX):
|
||||
# An AdministratorAccess boundary restricts nothing.
|
||||
return identity_status
|
||||
|
||||
boundary_status = _full_access_status(
|
||||
[getattr(policies.get(boundary_arn), "document", None)]
|
||||
)
|
||||
if boundary_status is False:
|
||||
# The boundary provably does not grant *:*: the intersection cannot be
|
||||
# administrative regardless of the identity policies.
|
||||
return False
|
||||
if boundary_status is None:
|
||||
return None
|
||||
return identity_status
|
||||
|
||||
|
||||
def _session_is_scoped(profile, policies) -> bool | None:
|
||||
"""Return whether session policies restrict permissions, or None when unknown.
|
||||
|
||||
AWS evaluates the inline ``sessionPolicy`` and every ``managedPolicyArns``
|
||||
entry together as a single session-policy category, so the complete set is
|
||||
merged into one evaluation: the session is scoped only when at least one
|
||||
session policy exists and the set does not grant ``*:*``. Managed entries
|
||||
are resolved through the collected IAM policies. An invalid inline policy
|
||||
or an unresolved managed policy does not prove that the session is
|
||||
restricted and propagates as None.
|
||||
|
||||
Args:
|
||||
profile: A ``rolesanywhere_service.Profile``.
|
||||
policies: Mapping of policy ARN to ``iam_service.Policy`` from iam_client.
|
||||
"""
|
||||
if not profile.session_policy and not profile.managed_policy_arns:
|
||||
return False
|
||||
|
||||
documents = []
|
||||
if profile.session_policy:
|
||||
try:
|
||||
documents.append(json.loads(profile.session_policy))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
for arn in profile.managed_policy_arns or []:
|
||||
if arn.endswith(ADMIN_POLICY_ARN_SUFFIX):
|
||||
documents.append({"Statement": [_ADMIN_STATEMENT]})
|
||||
else:
|
||||
documents.append(getattr(policies.get(arn), "document", None))
|
||||
|
||||
grants_full_access = _full_access_status(documents)
|
||||
return None if grants_full_access is None else not grants_full_access
|
||||
|
||||
|
||||
class rolesanywhere_profile_restricts_session_permissions(Check):
|
||||
"""Flag Roles Anywhere profiles that vend an unscoped session on a privileged role.
|
||||
|
||||
A Roles Anywhere profile that does not restrict the session with an inline
|
||||
``sessionPolicy`` or ``managedPolicyArns`` vends temporary credentials
|
||||
carrying the full permissions of every role it references. This is only a
|
||||
real risk when a referenced role is itself administrative: any certificate
|
||||
accepted by the trust anchor then wields administrative permissions, turning
|
||||
the profile into a durable privileged-access path that surviving key rotation
|
||||
does not remove. Profiles that scope the session, whose referenced roles were
|
||||
proven not administrative, or that are disabled are reported as PASS. When
|
||||
session scoping or role permissions cannot be evaluated (unresolved or
|
||||
invalid policy documents, condition-guarded grants, unknown roles), the
|
||||
report is MANUAL rather than a proven outcome.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Evaluate session-permission scoping for Roles Anywhere profiles.
|
||||
|
||||
Returns:
|
||||
list[Check_Report_AWS]: One report per Roles Anywhere profile. FAIL
|
||||
for enabled, unscoped profiles that reference a proven administrative
|
||||
role; MANUAL when session scoping or role permissions could not be
|
||||
evaluated; PASS for scoped profiles, profiles whose roles were proven
|
||||
not administrative, and disabled profiles.
|
||||
"""
|
||||
findings = []
|
||||
roles_by_arn = {role.arn: role for role in iam_client.roles}
|
||||
for profile in rolesanywhere_client.profiles.values():
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=profile)
|
||||
role_statuses = {
|
||||
arn: (
|
||||
_role_is_privileged(roles_by_arn[arn], iam_client.policies)
|
||||
if arn in roles_by_arn
|
||||
else None
|
||||
)
|
||||
for arn in profile.role_arns
|
||||
}
|
||||
privileged_role_arns = [
|
||||
arn for arn, status in role_statuses.items() if status is True
|
||||
]
|
||||
unknown_role_arns = [
|
||||
arn for arn, status in role_statuses.items() if status is None
|
||||
]
|
||||
session_scoped = _session_is_scoped(profile, iam_client.policies)
|
||||
|
||||
if not profile.enabled:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} is disabled and "
|
||||
"cannot vend session credentials."
|
||||
)
|
||||
elif session_scoped is True:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} restricts vended "
|
||||
"session permissions with a session policy or managed policies."
|
||||
)
|
||||
elif session_scoped is None:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} session scoping "
|
||||
"could not be evaluated because an inline or managed session "
|
||||
"policy was invalid or unresolved."
|
||||
)
|
||||
elif privileged_role_arns:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} does not scope down "
|
||||
"sessions and references administrative role(s) "
|
||||
f"{', '.join(privileged_role_arns)}; certificates authenticated "
|
||||
"through it inherit administrative permissions, enabling durable "
|
||||
"privileged access."
|
||||
)
|
||||
elif unknown_role_arns:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} does not scope down "
|
||||
"sessions, and the effective permissions of referenced role(s) "
|
||||
f"{', '.join(unknown_role_arns)} could not be evaluated."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} does not scope down "
|
||||
"sessions, but no referenced role was identified as "
|
||||
"administrative; scoping the session is recommended as "
|
||||
"defense-in-depth."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -11,7 +11,9 @@ class RolesAnywhere(AWSService):
|
||||
def __init__(self, provider):
|
||||
super().__init__(__class__.__name__, provider)
|
||||
self.trust_anchors = {}
|
||||
self.profiles = {}
|
||||
self.__threading_call__(self._list_trust_anchors)
|
||||
self.__threading_call__(self._list_profiles)
|
||||
|
||||
def _list_trust_anchors(self, regional_client):
|
||||
logger.info("RolesAnywhere - Listing Trust Anchors...")
|
||||
@@ -52,6 +54,53 @@ class RolesAnywhere(AWSService):
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _list_profiles(self, regional_client):
|
||||
"""List and cache IAM Roles Anywhere profiles for one AWS Region.
|
||||
|
||||
Args:
|
||||
regional_client: Roles Anywhere client for the audited Region.
|
||||
"""
|
||||
logger.info("RolesAnywhere - Listing Profiles...")
|
||||
try:
|
||||
paginator = regional_client.get_paginator("list_profiles")
|
||||
for page in paginator.paginate():
|
||||
for profile in page.get("profiles", []):
|
||||
arn = profile.get("profileArn", "")
|
||||
if not arn:
|
||||
continue
|
||||
if self.audit_resources and not is_resource_filtered(
|
||||
arn, self.audit_resources
|
||||
):
|
||||
continue
|
||||
tags = []
|
||||
try:
|
||||
tags = regional_client.list_tags_for_resource(
|
||||
resourceArn=arn
|
||||
).get("tags", [])
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
self.profiles[arn] = Profile(
|
||||
arn=arn,
|
||||
id=profile.get("profileId", ""),
|
||||
name=profile.get("name", ""),
|
||||
region=regional_client.region,
|
||||
enabled=profile.get("enabled", False),
|
||||
role_arns=profile.get("roleArns", []) or [],
|
||||
session_policy=profile.get("sessionPolicy", "") or "",
|
||||
managed_policy_arns=profile.get("managedPolicyArns", []) or [],
|
||||
duration_seconds=profile.get("durationSeconds", 0) or 0,
|
||||
accept_role_session_name=profile.get(
|
||||
"acceptRoleSessionName", False
|
||||
),
|
||||
tags=tags,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
|
||||
class TrustAnchor(BaseModel):
|
||||
arn: str
|
||||
@@ -62,3 +111,19 @@ class TrustAnchor(BaseModel):
|
||||
source_type: str = ""
|
||||
acm_pca_arn: str = ""
|
||||
tags: List[Dict[str, str]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Profile(BaseModel):
|
||||
"""Represent an IAM Roles Anywhere profile."""
|
||||
|
||||
arn: str
|
||||
id: str
|
||||
name: str
|
||||
region: str
|
||||
enabled: bool = False
|
||||
role_arns: List[str] = Field(default_factory=list)
|
||||
session_policy: str = ""
|
||||
managed_policy_arns: List[str] = Field(default_factory=list)
|
||||
duration_seconds: int = 0
|
||||
accept_role_session_name: bool = False
|
||||
tags: List[Dict[str, str]] = Field(default_factory=list)
|
||||
|
||||
@@ -1,25 +1,58 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.iam.lib.policy import is_policy_public
|
||||
from prowler.providers.aws.services.ses.ses_client import ses_client
|
||||
|
||||
|
||||
def _normalize_policy_statements(policy: dict) -> dict:
|
||||
statements = policy.get("Statement", [])
|
||||
if isinstance(statements, dict):
|
||||
return {**policy, "Statement": [statements]}
|
||||
return policy
|
||||
|
||||
|
||||
def _has_explicit_deny(policy: dict) -> bool:
|
||||
return any(
|
||||
isinstance(statement, dict) and statement.get("Effect") == "Deny"
|
||||
for statement in _normalize_policy_statements(policy).get("Statement", [])
|
||||
)
|
||||
|
||||
|
||||
class ses_identity_not_publicly_accessible(Check):
|
||||
def execute(self):
|
||||
"""Ensure SES identities are not publicly accessible through authorization policies."""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Evaluate every authorization policy attached to each SES identity.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the public-access result for each identity.
|
||||
"""
|
||||
findings = []
|
||||
for identity in ses_client.email_identities.values():
|
||||
if identity.policy is None:
|
||||
if not identity.policies:
|
||||
continue
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=identity)
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"SES identity {identity.name} is not publicly accessible."
|
||||
)
|
||||
if is_policy_public(
|
||||
identity.policy,
|
||||
ses_client.audited_account,
|
||||
):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"SES identity {identity.name} is publicly accessible due to its resource policy."
|
||||
has_public_allow = any(
|
||||
is_policy_public(
|
||||
_normalize_policy_statements(deepcopy(policy)),
|
||||
ses_client.audited_account,
|
||||
)
|
||||
for policy in identity.policies.values()
|
||||
)
|
||||
if has_public_allow:
|
||||
if any(
|
||||
_has_explicit_deny(policy) for policy in identity.policies.values()
|
||||
):
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"SES identity {identity.name} has public Allow and explicit Deny statements in its resource policies. Effective public access requires manual review."
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"SES identity {identity.name} is publicly accessible due to its resource policies."
|
||||
|
||||
findings.append(report)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from json import loads
|
||||
from typing import Optional
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
from pydantic.v1 import BaseModel, Field
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.scan_filters.scan_filters import is_resource_filtered
|
||||
@@ -46,8 +46,11 @@ class SES(AWSService):
|
||||
identity_attributes = regional_client.get_email_identity(
|
||||
EmailIdentity=identity.name
|
||||
)
|
||||
for _, content in identity_attributes.get("Policies", {}).items():
|
||||
identity.policy = loads(content)
|
||||
identity.policies = {
|
||||
name: loads(content)
|
||||
for name, content in identity_attributes.get("Policies", {}).items()
|
||||
}
|
||||
identity.policy = next(reversed(identity.policies.values()), None)
|
||||
identity.tags = identity_attributes.get("Tags", [])
|
||||
dkim_attrs = identity_attributes.get("DkimAttributes", {}) or {}
|
||||
identity.dkim_status = dkim_attrs.get("Status")
|
||||
@@ -72,6 +75,7 @@ class Identity(BaseModel):
|
||||
region: str
|
||||
type: Optional[str]
|
||||
policy: Optional[dict] = None
|
||||
policies: dict[str, dict] = Field(default_factory=dict)
|
||||
tags: Optional[list] = []
|
||||
dkim_status: Optional[str] = None
|
||||
dkim_signing_attributes_origin: Optional[str] = None
|
||||
|
||||
@@ -17,6 +17,8 @@ class IAM(GCPService):
|
||||
self.service_accounts = []
|
||||
self._get_service_accounts()
|
||||
self._get_service_accounts_keys()
|
||||
self.workload_identity_pool_providers = []
|
||||
self._get_workload_identity_pool_providers()
|
||||
|
||||
def _get_service_accounts(self):
|
||||
for project_id in self.project_ids:
|
||||
@@ -87,6 +89,94 @@ class IAM(GCPService):
|
||||
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_workload_identity_pool_providers(self):
|
||||
for project_id in self.project_ids:
|
||||
try:
|
||||
pools_request = (
|
||||
self.client.projects()
|
||||
.locations()
|
||||
.workloadIdentityPools()
|
||||
.list(parent=f"projects/{project_id}/locations/global")
|
||||
)
|
||||
while pools_request is not None:
|
||||
pools_response = pools_request.execute(
|
||||
num_retries=DEFAULT_RETRY_ATTEMPTS
|
||||
)
|
||||
for pool in pools_response.get("workloadIdentityPools", []):
|
||||
self._get_providers_for_pool(project_id, pool)
|
||||
pools_request = (
|
||||
self.client.projects()
|
||||
.locations()
|
||||
.workloadIdentityPools()
|
||||
.list_next(
|
||||
previous_request=pools_request,
|
||||
previous_response=pools_response,
|
||||
)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_providers_for_pool(self, project_id, pool):
|
||||
try:
|
||||
pool_name = pool.get("name", "")
|
||||
pool_id = pool_name.split("/")[-1]
|
||||
# A provider can remain ACTIVE while its parent pool is disabled or
|
||||
# soft-deleted; a disabled pool cannot vend credentials, so the
|
||||
# pool's effective availability must travel with the provider.
|
||||
pool_disabled = (
|
||||
pool.get("disabled", False) or pool.get("state", "ACTIVE") != "ACTIVE"
|
||||
)
|
||||
request = (
|
||||
self.client.projects()
|
||||
.locations()
|
||||
.workloadIdentityPools()
|
||||
.providers()
|
||||
.list(parent=pool_name)
|
||||
)
|
||||
while request is not None:
|
||||
response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
|
||||
for provider in response.get("workloadIdentityPoolProviders", []):
|
||||
provider_type = next(
|
||||
(
|
||||
key
|
||||
for key in ("oidc", "aws", "saml", "x509")
|
||||
if key in provider
|
||||
),
|
||||
"",
|
||||
)
|
||||
self.workload_identity_pool_providers.append(
|
||||
WorkloadIdentityPoolProvider(
|
||||
name=provider.get("name", ""),
|
||||
id=provider.get("name", "").split("/")[-1],
|
||||
pool_id=pool_id,
|
||||
pool_disabled=pool_disabled,
|
||||
project_id=project_id,
|
||||
state=provider.get("state", ""),
|
||||
disabled=provider.get("disabled", False),
|
||||
attribute_condition=provider.get("attributeCondition", ""),
|
||||
attribute_mapping=provider.get("attributeMapping", {})
|
||||
or {},
|
||||
provider_type=provider_type,
|
||||
issuer_uri=(provider.get("oidc", {}) or {}).get(
|
||||
"issuerUri", ""
|
||||
),
|
||||
display_name=provider.get("displayName", ""),
|
||||
)
|
||||
)
|
||||
request = (
|
||||
self.client.projects()
|
||||
.locations()
|
||||
.workloadIdentityPools()
|
||||
.providers()
|
||||
.list_next(previous_request=request, previous_response=response)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
|
||||
class Key(BaseModel):
|
||||
name: str
|
||||
@@ -106,6 +196,25 @@ class ServiceAccount(BaseModel):
|
||||
disabled: bool = False
|
||||
|
||||
|
||||
class WorkloadIdentityPoolProvider(BaseModel):
|
||||
"""Represent a GCP Workload Identity Federation pool provider."""
|
||||
|
||||
name: str
|
||||
id: str
|
||||
pool_id: str
|
||||
# True when the parent pool is disabled or not ACTIVE; such a pool cannot
|
||||
# vend credentials regardless of the provider's own state.
|
||||
pool_disabled: bool = False
|
||||
project_id: str
|
||||
state: str = ""
|
||||
disabled: bool = False
|
||||
attribute_condition: str = ""
|
||||
attribute_mapping: dict = {}
|
||||
provider_type: str = ""
|
||||
issuer_uri: str = ""
|
||||
display_name: str = ""
|
||||
|
||||
|
||||
class AccessApproval(GCPService):
|
||||
def __init__(self, provider: GcpProvider):
|
||||
super().__init__(__class__.__name__, provider)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Provider": "gcp",
|
||||
"CheckID": "iam_workload_identity_pool_provider_attribute_condition",
|
||||
"CheckTitle": "Workload Identity Federation providers trusting a multi-tenant issuer enforce an attribute condition",
|
||||
"CheckType": [],
|
||||
"ServiceName": "iam",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "iam.googleapis.com/WorkloadIdentityPoolProvider",
|
||||
"Description": "**Workload Identity Federation providers** define an `attributeCondition` (CEL) restricting which external identities may impersonate Google Cloud principals. When a provider trusts a **multi-tenant issuer** (GitHub Actions, GitLab.com and other shared issuers), omitting the condition trusts every identity that issuer can mint. Providers trusting a dedicated single-tenant issuer are not flagged.",
|
||||
"Risk": "A provider trusting a multi-tenant issuer without an attribute condition accepts any external identity from that issuer - for example any GitHub repository when the issuer is GitHub Actions. An attacker controlling any tenant on that platform can authenticate through the provider and exchange tokens for federated credentials. Because no key is stored, this access survives credential rotation.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://cloud.google.com/iam/docs/workload-identity-federation",
|
||||
"https://cloud.google.com/iam/docs/workload-identity-federation#mapping",
|
||||
"https://cloud.google.com/iam/docs/best-practices-for-using-workload-identity-federation"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "gcloud iam workload-identity-pools providers update-<oidc|aws|saml|x509> <PROVIDER_ID> --location=global --workload-identity-pool=<POOL_ID> --attribute-condition=\"<CEL_condition>\" # use the update subcommand matching the provider type",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. In the Google Cloud console, go to IAM & Admin > Workload Identity Federation\n2. Open the affected pool and provider\n3. Set an attribute condition (CEL) restricting the allowed external identities, using an assertion that fits the provider type: OIDC by subject or claim (assertion.sub), SAML by NameID or attribute (assertion.subject or assertion.attributes[...]), AWS by account/role (assertion.account or assertion.arn), X.509 by certificate subject\n4. For OIDC providers, also restrict allowedAudiences to your own audience\n5. Review providers regularly for entries you did not create",
|
||||
"Terraform": "```hcl\nresource \"google_iam_workload_identity_pool_provider\" \"example\" {\n workload_identity_pool_id = \"my-pool\"\n workload_identity_pool_provider_id = \"my-provider\"\n attribute_condition = \"assertion.repository_owner == 'my-org'\" # FIX: restrict trusted identities\n\n # Declare exactly one of oidc {}, aws {}, saml {}, or x509 {} to match the provider type, e.g.:\n oidc {\n issuer_uri = \"https://token.actions.githubusercontent.com\"\n allowed_audiences = [\"https://my-audience.example.com\"]\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Set an attribute condition on any active Workload Identity Federation provider that trusts a multi-tenant issuer so only the intended external identities can impersonate Google Cloud principals, and pair OIDC providers with a restricted audience. Providers trusting a dedicated single-tenant issuer should still add a condition as defense-in-depth. Audit pools and providers regularly for unexpected entries.",
|
||||
"Url": "https://hub.prowler.com/check/iam_workload_identity_pool_provider_attribute_condition"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access",
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Only active providers that trust a known multi-tenant issuer (GitHub Actions, GitLab.com, Google, HCP Terraform) are failed when they omit an attribute condition; providers trusting a dedicated single-tenant issuer, AWS/SAML/X.509 providers, and disabled or non-ACTIVE providers are reported as PASS. This check verifies that an attribute condition is present; it does not evaluate whether the condition's expression is sufficiently restrictive."
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_GCP
|
||||
from prowler.providers.gcp.services.iam.iam_client import iam_client
|
||||
|
||||
# OIDC issuers whose tokens are minted for many independent tenants (any GitHub
|
||||
# repository, any GitLab project, any Google account, ...). A provider that
|
||||
# trusts one of these without an ``attributeCondition`` accepts identities
|
||||
# outside the operator's control, so omitting the condition genuinely expands
|
||||
# trust. A dedicated, single-tenant issuer only vends tokens to the operator's
|
||||
# own workloads, so an attribute condition there is defense-in-depth rather than
|
||||
# a requirement (see Google's guidance for GitHub and other shared issuers).
|
||||
MULTI_TENANT_OIDC_ISSUER_HOSTS = {
|
||||
"token.actions.githubusercontent.com", # GitHub Actions (any repository)
|
||||
"gitlab.com", # GitLab.com SaaS (any project)
|
||||
"accounts.google.com", # any Google account
|
||||
"app.terraform.io", # HCP Terraform (any organization)
|
||||
}
|
||||
|
||||
|
||||
def _is_multi_tenant_issuer(issuer_uri: str) -> bool:
|
||||
"""Return True when the OIDC issuer is a known multi-tenant/shared issuer."""
|
||||
if not issuer_uri:
|
||||
return False
|
||||
# hostname lowercases and strips port/userinfo (gitlab.com:443, user@host);
|
||||
# fall back to the raw string for bare hosts without a scheme.
|
||||
host = urlparse(issuer_uri).hostname or issuer_uri.lower()
|
||||
return host in MULTI_TENANT_OIDC_ISSUER_HOSTS
|
||||
|
||||
|
||||
class iam_workload_identity_pool_provider_attribute_condition(Check):
|
||||
"""Ensure WIF providers trusting a multi-tenant issuer enforce an attribute condition.
|
||||
|
||||
A workload identity pool provider that trusts a multi-tenant issuer (GitHub
|
||||
Actions, GitLab.com, ...) without an ``attributeCondition`` accepts every
|
||||
external identity that issuer can mint. An attacker controlling any tenant on
|
||||
that platform can then authenticate through the provider and exchange tokens
|
||||
for federated credentials, surviving credential rotation. Providers that
|
||||
enforce an attribute condition, that trust a dedicated single-tenant issuer,
|
||||
that are not OIDC-based, that are disabled/inactive, or whose parent pool is
|
||||
disabled are reported as PASS.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_GCP]:
|
||||
"""Evaluate the attribute condition of each Workload Identity provider.
|
||||
|
||||
Returns:
|
||||
list[Check_Report_GCP]: One report per workload identity pool
|
||||
provider. FAIL for active providers that trust a multi-tenant issuer
|
||||
without an attribute condition; PASS for providers that enforce one,
|
||||
trust a dedicated issuer, are not OIDC-based, or are
|
||||
disabled/inactive.
|
||||
"""
|
||||
findings = []
|
||||
for provider in iam_client.workload_identity_pool_providers:
|
||||
report = Check_Report_GCP(
|
||||
metadata=self.metadata(),
|
||||
resource=provider,
|
||||
resource_id=provider.name,
|
||||
resource_name=provider.display_name or provider.id,
|
||||
location="global",
|
||||
)
|
||||
if provider.pool_disabled:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} belongs "
|
||||
f"to the disabled pool {provider.pool_id}, which cannot vend "
|
||||
"credentials."
|
||||
)
|
||||
elif provider.disabled or provider.state != "ACTIVE":
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} in pool "
|
||||
f"{provider.pool_id} is not active and cannot vend credentials."
|
||||
)
|
||||
elif provider.attribute_condition:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} in pool "
|
||||
f"{provider.pool_id} enforces an attribute condition."
|
||||
)
|
||||
elif _is_multi_tenant_issuer(provider.issuer_uri):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} in pool "
|
||||
f"{provider.pool_id} trusts the multi-tenant issuer "
|
||||
f"{provider.issuer_uri} without an attribute condition, so any "
|
||||
"identity from that issuer can authenticate through this provider."
|
||||
)
|
||||
elif provider.provider_type != "oidc":
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} in pool "
|
||||
f"{provider.pool_id} is not an OIDC provider trusting a "
|
||||
"multi-tenant issuer; an attribute condition is recommended as "
|
||||
"defense-in-depth but not required."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} in pool "
|
||||
f"{provider.pool_id} trusts a dedicated issuer; an attribute "
|
||||
"condition is recommended as defense-in-depth but not required."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -1,6 +1,6 @@
|
||||
[build-system]
|
||||
build-backend = "hatchling.build"
|
||||
requires = ["hatchling"]
|
||||
requires = ["hatchling==1.32.0"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
@@ -68,6 +68,10 @@ dependencies = [
|
||||
"boto3==1.40.61",
|
||||
"botocore==1.40.61",
|
||||
"colorama==0.4.6",
|
||||
# cryptography 50 needs alibabacloud-tea-openapi>=0.4.6, oci>=2.184.1 and, in the
|
||||
# [tool.uv] pins, msal>=1.37.0 and pyopenssl>=26.4.0: earlier releases cap it below 49
|
||||
# or 50. Keep the five in step. Never widen a cap with [tool.uv] override-dependencies:
|
||||
# overrides do not ship in the wheel, and 5.38.0 was uninstallable with pip because of one.
|
||||
"cryptography==50.0.0",
|
||||
"dash==3.1.1",
|
||||
"dash-bootstrap-components==2.0.3",
|
||||
@@ -103,10 +107,10 @@ dependencies = [
|
||||
"uuid6==2024.7.10",
|
||||
"py-iam-expand==0.3.0",
|
||||
"h2==4.3.0",
|
||||
"oci==2.183.0",
|
||||
"oci==2.184.1",
|
||||
"alibabacloud_credentials==1.0.3",
|
||||
"alibabacloud_ram20150501==1.2.0",
|
||||
"alibabacloud_tea_openapi==0.4.5",
|
||||
"alibabacloud_tea_openapi==0.4.6",
|
||||
"alibabacloud_sts20150401==1.1.6",
|
||||
"alibabacloud_vpc20160428==6.13.0",
|
||||
"alibabacloud_ecs20140526==7.2.5",
|
||||
@@ -128,7 +132,8 @@ dependencies = [
|
||||
"huaweicloudsdkobs==3.1.204",
|
||||
"huaweicloudsdkrds==3.1.204",
|
||||
"huaweicloudsdkvpc==3.1.204",
|
||||
"huaweicloudsdkwaf==3.1.204"
|
||||
"huaweicloudsdkwaf==3.1.204",
|
||||
"zstandard==0.25.0"
|
||||
]
|
||||
description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks."
|
||||
license = "Apache-2.0"
|
||||
@@ -136,7 +141,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
|
||||
name = "prowler"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.14"
|
||||
version = "5.39.0"
|
||||
version = "5.40.0"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
@@ -199,7 +204,7 @@ constraint-dependencies = [
|
||||
"alibabacloud-sas20181203==6.1.0",
|
||||
"alibabacloud-sts20150401==1.1.6",
|
||||
"alibabacloud-tea==0.4.3",
|
||||
"alibabacloud-tea-openapi==0.4.5",
|
||||
"alibabacloud-tea-openapi==0.4.6",
|
||||
"alibabacloud-tea-util==0.3.14",
|
||||
"alibabacloud-tea-xml==0.0.3",
|
||||
"alibabacloud-vpc20160428==6.13.0",
|
||||
@@ -300,7 +305,7 @@ constraint-dependencies = [
|
||||
"mock==5.2.0",
|
||||
"moto==5.1.11",
|
||||
"mpmath==1.3.0",
|
||||
"msal==1.36.0",
|
||||
"msal==1.37.0",
|
||||
"msal-extensions==1.3.1",
|
||||
"msgraph-core==1.3.8",
|
||||
"msrest==0.7.1",
|
||||
@@ -343,7 +348,7 @@ constraint-dependencies = [
|
||||
"pyjwt==2.13.0",
|
||||
"pylint==3.3.4",
|
||||
"pynacl==1.6.2",
|
||||
"pyopenssl==26.2.0",
|
||||
"pyopenssl==26.4.0",
|
||||
"pyparsing==3.3.2",
|
||||
"pytest==9.0.3",
|
||||
"pytest-cov==6.0.0",
|
||||
@@ -387,13 +392,9 @@ constraint-dependencies = [
|
||||
"xmltodict==1.0.4",
|
||||
"yarl==1.23.0",
|
||||
"zipp==3.23.1",
|
||||
"zstd==1.5.7.3"
|
||||
]
|
||||
override-dependencies = [
|
||||
"okta==3.4.2",
|
||||
# alibabacloud-tea-openapi 0.4.5 caps cryptography below 49 and is the latest release.
|
||||
"cryptography==50.0.0",
|
||||
"zstd==1.5.7.2"
|
||||
]
|
||||
override-dependencies = ["okta==3.4.2"]
|
||||
|
||||
[tool.vulture]
|
||||
# Suppress known false positives. The CI command only passes --exclude and
|
||||
|
||||
@@ -0,0 +1,837 @@
|
||||
from datetime import datetime
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.aws.services.ecr.ecr_service import (
|
||||
ImageDetails,
|
||||
Registry,
|
||||
Repository,
|
||||
)
|
||||
from prowler.providers.aws.services.ecr.image_inspection import (
|
||||
ImageScanData,
|
||||
ImageScanFile,
|
||||
)
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_US_EAST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
# A real JWT: Kingfisher detects this regardless of the surrounding key name
|
||||
# or format (env-style KEY=value, Dockerfile RUN step, or source file).
|
||||
SECRET_VALUE = (
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
".eyJzdWIiOiIxMjM0NTY3ODkwIn0"
|
||||
".dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
|
||||
)
|
||||
|
||||
|
||||
def create_repository(name="test-repo", region=AWS_REGION_US_EAST_1) -> Repository:
|
||||
"""Build a minimal ECR Repository fixture."""
|
||||
return Repository(
|
||||
name=name,
|
||||
arn=f"arn:aws:ecr:{region}:{AWS_ACCOUNT_NUMBER}:repository/{name}",
|
||||
region=region,
|
||||
scan_on_push=True,
|
||||
images_details=[],
|
||||
)
|
||||
|
||||
|
||||
def create_image(tag="latest", digest=None) -> ImageDetails:
|
||||
"""Build a minimal ImageDetails fixture."""
|
||||
return ImageDetails(
|
||||
latest_tag=tag,
|
||||
latest_digest=digest or f"sha256:{'0' * 64}",
|
||||
image_pushed_at=datetime.now(),
|
||||
scan_findings_status=None,
|
||||
scan_findings_severity_count=None,
|
||||
artifact_media_type="application/vnd.docker.container.image.v1+json",
|
||||
type="Docker",
|
||||
)
|
||||
|
||||
|
||||
def mock_image_scan_data(pairs):
|
||||
"""Build a fake _get_image_scan_data generator yielding the given pairs."""
|
||||
|
||||
def _generator():
|
||||
"""Yield each (repository, image, scan_data) pair once."""
|
||||
for entry in pairs:
|
||||
yield entry
|
||||
|
||||
return _generator
|
||||
|
||||
|
||||
class Test_ecr_repository_image_no_secrets:
|
||||
"""Tests for the ecr_repository_image_no_secrets check."""
|
||||
|
||||
def test_no_repositories(self):
|
||||
"""No repositories yields no findings."""
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data([])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
def test_clean_image(self):
|
||||
"""An image with no secrets passes."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=["PATH=/usr/bin"],
|
||||
history=["RUN echo hello"],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
digest_short = image.latest_digest.split(":")[-1][:12]
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].status_extended == (
|
||||
f"No secrets found in the image '{image.latest_tag}' "
|
||||
f"({image.latest_digest}) of ECR repository {repository.name}."
|
||||
)
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert (
|
||||
result[0].resource_id
|
||||
== f"{repository.name}:{image.latest_tag}@{digest_short}"
|
||||
)
|
||||
assert result[0].resource_arn == f"{repository.arn}/image/{digest_short}"
|
||||
|
||||
def test_truncated_image_reports_manual(self):
|
||||
"""A clean but truncated image is MANUAL, since part was not scanned."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(env=[], history=[], files=[], truncated=True)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert (
|
||||
"part of it could not be retrieved or exceeded configured size "
|
||||
"limits and was not scanned" in result[0].status_extended
|
||||
)
|
||||
|
||||
def test_secret_in_environment_variable(self):
|
||||
"""A secret in an environment variable fails, naming the variable."""
|
||||
from prowler.lib.check.models import Severity
|
||||
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=["PATH=/usr/bin", f"DB_PASSWORD={SECRET_VALUE}"],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "environment variable DB_PASSWORD" in result[0].status_extended
|
||||
assert SECRET_VALUE not in result[0].status_extended
|
||||
assert result[0].check_metadata.Severity == Severity.high
|
||||
|
||||
def test_secret_in_malformed_env_entry_is_redacted(self):
|
||||
"""An env entry without '=' is reported generically, never echoed."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
# The entry has no "=" so no variable name can be split out; the entry
|
||||
# itself is the secret and must not appear in the finding.
|
||||
scan_data = ImageScanData(
|
||||
env=[SECRET_VALUE],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "in image environment variables" in result[0].status_extended
|
||||
assert SECRET_VALUE not in result[0].status_extended
|
||||
|
||||
def test_secret_in_unsafe_environment_name_is_redacted(self):
|
||||
"""An unsafe name before '=' is never included in report text."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=[f"{SECRET_VALUE}=safe-value"],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
result = ecr_repository_image_no_secrets().execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "in image environment variables" in result[0].status_extended
|
||||
assert SECRET_VALUE not in str(vars(result[0]))
|
||||
|
||||
def test_scanned_file_content_is_freed_after_execute(self):
|
||||
"""File contents are released after scanning so memory stays flat."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scanned_file = ImageScanFile(
|
||||
path="app/config.py",
|
||||
layer_digest=f"sha256:{'a' * 64}",
|
||||
content="nothing secret here",
|
||||
)
|
||||
scan_data = ImageScanData(
|
||||
env=[], history=[], files=[scanned_file], truncated=False
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
# The check empties each file's content once it is handed to the
|
||||
# scanner; only path and layer digest are needed thereafter.
|
||||
assert scanned_file.content == ""
|
||||
|
||||
def test_secrets_ignore_patterns_suppresses_finding(self):
|
||||
"""A secret matching an ignore pattern is suppressed."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=["PATH=/usr/bin", f"DB_PASSWORD={SECRET_VALUE}"],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [SECRET_VALUE],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_secret_in_build_history(self):
|
||||
"""A secret in a build history step fails, naming the step."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=[],
|
||||
history=["RUN apt-get update", f'RUN export TOKEN="{SECRET_VALUE}"'],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "image history step 2" in result[0].status_extended
|
||||
assert SECRET_VALUE not in result[0].status_extended
|
||||
|
||||
def test_multiline_environment_secret_keeps_entry_attribution(self):
|
||||
"""Embedded newlines do not shift an env finding to another entry."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=[f"MULTILINE=prefix\r\n{SECRET_VALUE}", "WRONG=value"],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
result = ecr_repository_image_no_secrets().execute()
|
||||
|
||||
assert "environment variable MULTILINE" in result[0].status_extended
|
||||
assert "environment variable WRONG" not in result[0].status_extended
|
||||
|
||||
def test_multiline_history_secret_keeps_step_attribution(self):
|
||||
"""Embedded newlines do not shift a history finding to another step."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=[],
|
||||
history=[f"RUN first\nexport TOKEN={SECRET_VALUE}", "RUN second"],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
result = ecr_repository_image_no_secrets().execute()
|
||||
|
||||
assert "image history step 1" in result[0].status_extended
|
||||
assert "image history step 2" not in result[0].status_extended
|
||||
|
||||
def test_secret_in_layer_file(self):
|
||||
"""A secret in a layer file fails, naming the file and layer."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
layer_digest = f"sha256:{'a' * 64}"
|
||||
scan_data = ImageScanData(
|
||||
env=[],
|
||||
history=[],
|
||||
files=[
|
||||
ImageScanFile(
|
||||
path="app/config.py",
|
||||
layer_digest=layer_digest,
|
||||
content=f'TOKEN = "{SECRET_VALUE}"',
|
||||
)
|
||||
],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "file app/config.py" in result[0].status_extended
|
||||
assert layer_digest in result[0].status_extended
|
||||
assert SECRET_VALUE not in result[0].status_extended
|
||||
|
||||
def test_manifest_unresolvable(self):
|
||||
"""An unresolvable manifest is reported as MANUAL."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, None)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert (
|
||||
"Could not resolve or retrieve the manifest"
|
||||
in result[0].status_extended
|
||||
)
|
||||
|
||||
def test_latest_image_lookup_error_reports_repository_manual(self):
|
||||
"""A failed authoritative image lookup is reported for the repository."""
|
||||
repository = create_repository()
|
||||
lookup_error = RuntimeError("authoritative lookup failed")
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, None, lookup_error)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
result = ecr_repository_image_no_secrets().execute()
|
||||
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "Could not determine the latest image" in result[0].status_extended
|
||||
|
||||
def test_scan_error_reports_manual_for_latest_image_per_repository(self):
|
||||
"""A scanner failure reports MANUAL once per repository's latest image."""
|
||||
from prowler.lib.utils.utils import SecretsScanError
|
||||
|
||||
# Each repository has multiple images; the scan-error fallback must
|
||||
# scope to the latest image per repository only, mirroring the
|
||||
# success-path scope, not emit one MANUAL per image.
|
||||
repo1 = create_repository(name="repo-1")
|
||||
repo1.images_details = [
|
||||
create_image(tag="v1", digest=f"sha256:{'1' * 64}"),
|
||||
create_image(tag="v2", digest=f"sha256:{'2' * 64}"),
|
||||
]
|
||||
repo2 = create_repository(name="repo-2")
|
||||
repo2.images_details = [
|
||||
create_image(tag="v1", digest=f"sha256:{'3' * 64}"),
|
||||
create_image(tag="v2", digest=f"sha256:{'4' * 64}"),
|
||||
]
|
||||
registry = Registry(
|
||||
id=AWS_ACCOUNT_NUMBER,
|
||||
arn=f"arn:aws:ecr:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:registry/{AWS_ACCOUNT_NUMBER}",
|
||||
region=AWS_REGION_US_EAST_1,
|
||||
repositories=[repo1, repo2],
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {AWS_REGION_US_EAST_1: registry}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
# Not consumed on this path, but must be a real generator to iterate.
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data([])
|
||||
# The error fallback resolves each repository's scan target via
|
||||
# _get_scan_target_image; mirror the real method's latest-image scope.
|
||||
ecr_client._get_scan_target_image.side_effect = lambda repository: (
|
||||
repository.images_details[-1] if repository.images_details else None
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.detect_secrets_scan_batch",
|
||||
side_effect=SecretsScanError("Scanner failure"),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
# One MANUAL per repository (its latest image), not one per image.
|
||||
assert len(result) == 2
|
||||
for report in result:
|
||||
assert report.status == "MANUAL"
|
||||
assert "Could not scan image" in report.status_extended
|
||||
assert "Scanner failure" in report.status_extended
|
||||
|
||||
digests_reported = {report.resource_id.split("@")[-1] for report in result}
|
||||
latest_digest_repo1 = repo1.images_details[-1].latest_digest.split(":")[-1][
|
||||
:12
|
||||
]
|
||||
latest_digest_repo2 = repo2.images_details[-1].latest_digest.split(":")[-1][
|
||||
:12
|
||||
]
|
||||
assert digests_reported == {latest_digest_repo1, latest_digest_repo2}
|
||||
assert "Scanner failure" in result[0].status_extended
|
||||
|
||||
def test_verified_secret_escalates_to_critical(self):
|
||||
"""A verified secret escalates severity to critical."""
|
||||
from prowler.lib.check.models import Severity
|
||||
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=[f"TOKEN={SECRET_VALUE}"], history=[], files=[], truncated=False
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": True,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
def fake_scan_batch(payloads, **kwargs):
|
||||
# The real detect_secrets_scan_batch consumes the lazily-yielded
|
||||
# payloads generator as a side effect (that's what populates the
|
||||
# check's `scanned` list); replicate that here while returning
|
||||
# a controlled, pre-verified finding.
|
||||
"""Drain the payload generator like the real scanner, then return canned findings."""
|
||||
list(payloads)
|
||||
return {
|
||||
(0, "environment:0"): [
|
||||
{
|
||||
"type": "JSON Web Token (base64url-encoded)",
|
||||
"line_number": 1,
|
||||
"is_verified": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.detect_secrets_scan_batch",
|
||||
side_effect=fake_scan_batch,
|
||||
) as mock_scan,
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert mock_scan.call_args.kwargs.get("validate") is True
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].check_metadata.Severity == Severity.critical
|
||||
assert "confirmed to be live" in result[0].status_extended
|
||||
|
||||
def test_multiple_repositories_and_images(self):
|
||||
"""Mixed pass/fail results are reported across multiple repositories."""
|
||||
repo1 = create_repository(name="repo-1")
|
||||
repo2 = create_repository(name="repo-2")
|
||||
image1 = create_image(tag="v1", digest=f"sha256:{'1' * 64}")
|
||||
image2 = create_image(tag="v2", digest=f"sha256:{'2' * 64}")
|
||||
|
||||
clean_scan = ImageScanData(env=[], history=[], files=[], truncated=False)
|
||||
fail_scan = ImageScanData(
|
||||
env=[f"DB_PASSWORD={SECRET_VALUE}"],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repo1, image1, clean_scan), (repo2, image2, fail_scan)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 2
|
||||
statuses_by_repo = {r.resource_id.split(":")[0]: r.status for r in result}
|
||||
assert statuses_by_repo["repo-1"] == "PASS"
|
||||
assert statuses_by_repo["repo-2"] == "FAIL"
|
||||
report_by_repo = {r.resource_id.split(":")[0]: r for r in result}
|
||||
assert SECRET_VALUE not in report_by_repo["repo-2"].status_extended
|
||||
@@ -1,11 +1,21 @@
|
||||
import json
|
||||
from concurrent.futures import Future
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import botocore
|
||||
import pytest
|
||||
from boto3 import client
|
||||
from moto import mock_aws
|
||||
|
||||
from prowler.providers.aws.services.ecr.ecr_service import ECR, ScanningRule
|
||||
from prowler.providers.aws.services.ecr.ecr_service import (
|
||||
ECR,
|
||||
ScanningRule,
|
||||
)
|
||||
from tests.providers.aws.services.ecr.image_scan_fixtures import (
|
||||
MANIFESTS_BY_DIGEST,
|
||||
reset_image_fixtures,
|
||||
)
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_EU_WEST_1,
|
||||
@@ -18,8 +28,20 @@ repo_name = "test-repo"
|
||||
# Mocking Access Analyzer Calls
|
||||
make_api_call = botocore.client.BaseClient._make_api_call
|
||||
|
||||
# BatchGetImage / GetDownloadUrlForLayer fixtures (which moto does not
|
||||
# implement) live in image_scan_fixtures and are served by mock_make_api_call.
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_image_fixtures():
|
||||
"""Isolate the BatchGetImage/GetDownloadUrlForLayer fixtures per test."""
|
||||
reset_image_fixtures()
|
||||
yield
|
||||
reset_image_fixtures()
|
||||
|
||||
|
||||
def mock_make_api_call(self, operation_name, kwarg):
|
||||
"""Fake botocore responses for the ECR operations this suite exercises."""
|
||||
if operation_name == "DescribeImages":
|
||||
return {
|
||||
"imageDetails": [
|
||||
@@ -150,10 +172,37 @@ def mock_make_api_call(self, operation_name, kwarg):
|
||||
},
|
||||
}
|
||||
|
||||
if operation_name == "BatchGetImage":
|
||||
digest = kwarg["imageIds"][0]["imageDigest"]
|
||||
manifest = MANIFESTS_BY_DIGEST.get(digest)
|
||||
if manifest is None:
|
||||
return {
|
||||
"images": [],
|
||||
"failures": [
|
||||
{
|
||||
"imageId": {"imageDigest": digest},
|
||||
"failureCode": "ImageNotFound",
|
||||
}
|
||||
],
|
||||
}
|
||||
return {
|
||||
"images": [
|
||||
{
|
||||
"imageManifest": json.dumps(manifest),
|
||||
"imageManifestMediaType": manifest.get("mediaType", ""),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if operation_name == "GetDownloadUrlForLayer":
|
||||
digest = kwarg["layerDigest"]
|
||||
return {"downloadUrl": f"https://layers.example.com/{digest}"}
|
||||
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def mock_generate_regional_clients(provider, service):
|
||||
"""Return a single regional client for every requested region."""
|
||||
regional_client = provider._session.current_session.client(
|
||||
service, region_name=AWS_REGION_EU_WEST_1
|
||||
)
|
||||
@@ -169,13 +218,17 @@ def mock_generate_regional_clients(provider, service):
|
||||
)
|
||||
class Test_ECR_Service:
|
||||
# Test ECR Service
|
||||
"""Tests for the ECR service."""
|
||||
|
||||
def test_service(self):
|
||||
"""The service name is set correctly."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
assert ecr.service == "ecr"
|
||||
|
||||
# Test ECR client
|
||||
def test_client(self):
|
||||
"""Each regional client is an ECR client."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
for regional_client in ecr.regional_clients.values():
|
||||
@@ -183,6 +236,7 @@ class Test_ECR_Service:
|
||||
|
||||
# Test ECR session
|
||||
def test_get_session(self):
|
||||
"""The session is set correctly."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
assert ecr.session.__class__.__name__ == "Session"
|
||||
@@ -190,6 +244,7 @@ class Test_ECR_Service:
|
||||
# Test describe ECR repositories
|
||||
@mock_aws
|
||||
def test_describe_registries_and_repositories(self):
|
||||
"""Registries and repositories are discovered."""
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client.create_repository(
|
||||
repositoryName=repo_name,
|
||||
@@ -220,6 +275,7 @@ class Test_ECR_Service:
|
||||
# Test describe ECR repository policies
|
||||
@mock_aws
|
||||
def test_describe_repository_policies(self):
|
||||
"""Repository policies are fetched and parsed."""
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client.create_repository(
|
||||
repositoryName=repo_name,
|
||||
@@ -249,6 +305,7 @@ class Test_ECR_Service:
|
||||
# Test describe ECR repository lifecycle policies
|
||||
@mock_aws
|
||||
def test_get_lifecycle_policies(self):
|
||||
"""Repository lifecycle policies are fetched."""
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client.create_repository(
|
||||
repositoryName=repo_name,
|
||||
@@ -268,6 +325,7 @@ class Test_ECR_Service:
|
||||
# Test get image details
|
||||
@mock_aws
|
||||
def test_get_image_details(self):
|
||||
"""Scannable, tagged images are collected and sorted by push date."""
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client.create_repository(
|
||||
repositoryName=repo_name,
|
||||
@@ -366,6 +424,7 @@ class Test_ECR_Service:
|
||||
# Test get ECR Registries Scanning Configuration
|
||||
@mock_aws
|
||||
def test_get_registry_scanning_configuration(self):
|
||||
"""The registry's scanning configuration is fetched."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
assert len(ecr.registries) == 1
|
||||
@@ -379,39 +438,188 @@ class Test_ECR_Service:
|
||||
]
|
||||
|
||||
def test_is_artifact_scannable_docker(self):
|
||||
"""A Docker image config is scannable."""
|
||||
assert ECR._is_artifact_scannable(
|
||||
"application/vnd.docker.container.image.v1+json"
|
||||
)
|
||||
|
||||
def test_is_artifact_scannable_layer_tar(self):
|
||||
"""An uncompressed Docker layer is scannable."""
|
||||
assert ECR._is_artifact_scannable(
|
||||
"application/vnd.docker.image.rootfs.diff.tar"
|
||||
)
|
||||
|
||||
def test_is_artifact_scannable_layer_gzip(self):
|
||||
"""A gzip-compressed Docker layer is scannable."""
|
||||
assert ECR._is_artifact_scannable(
|
||||
"application/vnd.docker.image.rootfs.diff.tar.gzip"
|
||||
)
|
||||
|
||||
def test_is_artifact_scannable_oci(self):
|
||||
"""An OCI image config is scannable."""
|
||||
assert ECR._is_artifact_scannable("application/vnd.oci.image.config.v1+json")
|
||||
|
||||
def test_is_artifact_scannable_oci_tar(self):
|
||||
"""An uncompressed OCI layer is scannable."""
|
||||
assert ECR._is_artifact_scannable("application/vnd.oci.image.layer.v1.tar")
|
||||
|
||||
def test_is_artifact_scannable_oci_compressed(self):
|
||||
"""A gzip-compressed OCI layer is scannable."""
|
||||
assert ECR._is_artifact_scannable("application/vnd.oci.image.layer.v1.tar+gzip")
|
||||
|
||||
def test_is_artifact_scannable_none(self):
|
||||
"""A missing media type is not scannable."""
|
||||
assert not ECR._is_artifact_scannable(None)
|
||||
|
||||
def test_is_artifact_scannable_empty(self):
|
||||
"""An empty media type is not scannable."""
|
||||
assert not ECR._is_artifact_scannable("")
|
||||
|
||||
def test_is_artifact_scannable_non_scannable_tags(self):
|
||||
"""A signature-tagged artifact is not scannable."""
|
||||
assert not ECR._is_artifact_scannable("", ["sha256-abcdefg123456.sig"])
|
||||
|
||||
def test_is_artifact_scannable_scannable_tags(self):
|
||||
"""A normally-tagged artifact is scannable."""
|
||||
assert ECR._is_artifact_scannable(
|
||||
"application/vnd.docker.container.image.v1+json", ["abcdefg123456"]
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_get_image_scan_data_selects_only_latest_image_per_repository(self):
|
||||
"""Only the latest image per repository is selected for scanning."""
|
||||
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client_boto.create_repository(
|
||||
repositoryName=repo_name,
|
||||
imageScanningConfiguration={"scanOnPush": True},
|
||||
)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
|
||||
repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0]
|
||||
# Sanity check: this repository has several scannable tagged images.
|
||||
assert len(repository.images_details) == 4
|
||||
|
||||
results = list(ecr._get_image_scan_data())
|
||||
|
||||
# Only the most recently pushed image is selected, not all four.
|
||||
assert len(results) == 1
|
||||
fetched_repository, fetched_image, _ = results[0]
|
||||
assert fetched_repository.name == repo_name
|
||||
assert fetched_image.latest_tag == "test-tag4"
|
||||
assert (
|
||||
fetched_image.latest_digest
|
||||
== "sha256:43251ac64627fc331584f6c498b3aba5badc01574e2c70b2499af3af16630eed"
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_get_image_scan_data_covers_scan_on_push_disabled_repository(self):
|
||||
"""A scan-on-push-disabled repo (empty images_details) is still scanned."""
|
||||
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client_boto.create_repository(
|
||||
repositoryName=repo_name,
|
||||
imageScanningConfiguration={"scanOnPush": False},
|
||||
)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
|
||||
repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0]
|
||||
# Scan-on-push disabled: the metadata pass leaves images_details empty...
|
||||
assert repository.scan_on_push is False
|
||||
assert repository.images_details == []
|
||||
|
||||
# ...yet the secret-scan path resolves the latest image via a dedicated
|
||||
# describe_images lookup, so the repository is not silently skipped.
|
||||
results = list(ecr._get_image_scan_data())
|
||||
|
||||
assert len(results) == 1
|
||||
fetched_repository, fetched_image, _ = results[0]
|
||||
assert fetched_repository.name == repo_name
|
||||
assert fetched_image.latest_tag == "test-tag4"
|
||||
# The dedicated lookup must NOT mutate the shared images_details, or
|
||||
# other checks would treat this repo as having a scanned image.
|
||||
assert repository.images_details == []
|
||||
|
||||
@mock_aws
|
||||
def test_get_image_scan_data_bounds_submitted_futures(self):
|
||||
"""Image fetches are submitted only as earlier results are consumed."""
|
||||
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
for index in range(10):
|
||||
ecr_client_boto.create_repository(
|
||||
repositoryName=f"{repo_name}-{index}",
|
||||
imageScanningConfiguration={"scanOnPush": True},
|
||||
)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
repositories = ecr.registries[AWS_REGION_EU_WEST_1].repositories
|
||||
|
||||
executor = MagicMock()
|
||||
executor.__enter__.return_value = executor
|
||||
futures = []
|
||||
|
||||
def submit(*_args):
|
||||
future = Future()
|
||||
futures.append(future)
|
||||
if len(futures) == 1:
|
||||
future.set_result(None)
|
||||
return future
|
||||
|
||||
executor.submit.side_effect = submit
|
||||
with patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_service.ThreadPoolExecutor",
|
||||
return_value=executor,
|
||||
):
|
||||
results = ecr._get_image_scan_data()
|
||||
first_result = next(results)
|
||||
|
||||
assert first_result[0] == repositories[0]
|
||||
assert (
|
||||
first_result[1].latest_digest
|
||||
== repositories[0].images_details[-1].latest_digest
|
||||
)
|
||||
assert executor.submit.call_count == 4
|
||||
|
||||
@mock_aws
|
||||
def test_get_scan_target_image_ignores_stale_scanned_image(self):
|
||||
"""Secret scanning selects a newer image absent from scan findings."""
|
||||
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client_boto.create_repository(
|
||||
repositoryName=repo_name,
|
||||
imageScanningConfiguration={"scanOnPush": True},
|
||||
)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0]
|
||||
older_scanned_image = repository.images_details[0]
|
||||
repository.images_details = [older_scanned_image]
|
||||
|
||||
target = ecr._get_scan_target_image(repository)
|
||||
|
||||
assert target.latest_tag == "test-tag4"
|
||||
assert target.image_pushed_at > older_scanned_image.image_pushed_at
|
||||
|
||||
@mock_aws
|
||||
def test_get_scan_target_image_lookup_failure_rejects_stale_image(self):
|
||||
"""A failed authoritative lookup does not select cached scan metadata."""
|
||||
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client_boto.create_repository(
|
||||
repositoryName=repo_name,
|
||||
imageScanningConfiguration={"scanOnPush": True},
|
||||
)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0]
|
||||
repository.images_details = [repository.images_details[0]]
|
||||
|
||||
with patch.object(
|
||||
ecr.regional_clients[AWS_REGION_EU_WEST_1],
|
||||
"get_paginator",
|
||||
side_effect=RuntimeError("authoritative lookup failed"),
|
||||
):
|
||||
target = ecr._get_scan_target_image(repository)
|
||||
scan_results = list(ecr._get_image_scan_data())
|
||||
|
||||
assert isinstance(target, RuntimeError)
|
||||
assert len(scan_results) == 1
|
||||
_, result_image, result_error = scan_results[0]
|
||||
assert result_image is None and isinstance(result_error, RuntimeError)
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
import gzip
|
||||
import json
|
||||
import random
|
||||
import tarfile
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
|
||||
import botocore
|
||||
import pytest
|
||||
import zstandard
|
||||
from boto3 import client
|
||||
from moto import mock_aws
|
||||
|
||||
from prowler.providers.aws.services.ecr.image_inspection import (
|
||||
MAX_FILE_BYTES,
|
||||
MAX_LAYER_DOWNLOAD_BYTES,
|
||||
ImageInspector,
|
||||
_CappedLayerReader,
|
||||
_LayerTooLargeError,
|
||||
)
|
||||
from tests.providers.aws.services.ecr.image_scan_fixtures import (
|
||||
BLOBS_BY_DIGEST,
|
||||
CHILD_AMD64_DIGEST,
|
||||
CHILD_ARM64_DIGEST,
|
||||
CONFIG_DIGEST,
|
||||
CONFIG_JSON,
|
||||
IMAGE_DIGEST,
|
||||
LAYER_DIGEST,
|
||||
MANIFESTS_BY_DIGEST,
|
||||
MULTI_ARCH_INDEX_DIGEST,
|
||||
MULTI_ARCH_MANIFEST_LIST,
|
||||
SIMPLE_MANIFEST,
|
||||
build_gzip_tar,
|
||||
build_tar,
|
||||
mock_requests_get,
|
||||
reset_image_fixtures,
|
||||
)
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_EU_WEST_1,
|
||||
)
|
||||
|
||||
REPO_NAME = "test-repo"
|
||||
|
||||
_original_make_api_call = botocore.client.BaseClient._make_api_call
|
||||
|
||||
_REQUESTS_GET = "prowler.providers.aws.services.ecr.image_inspection.requests.get"
|
||||
|
||||
|
||||
def mock_make_api_call(self, operation_name, kwarg):
|
||||
"""Serve BatchGetImage/GetDownloadUrlForLayer from fixtures; delegate the rest.
|
||||
|
||||
moto implements neither operation, so they are answered from the per-test
|
||||
``MANIFESTS_BY_DIGEST`` fixtures; every other call falls through to the real
|
||||
(moto-backed) implementation.
|
||||
"""
|
||||
if operation_name == "BatchGetImage":
|
||||
digest = kwarg["imageIds"][0]["imageDigest"]
|
||||
manifest = MANIFESTS_BY_DIGEST.get(digest)
|
||||
if manifest is None:
|
||||
return {
|
||||
"images": [],
|
||||
"failures": [
|
||||
{
|
||||
"imageId": {"imageDigest": digest},
|
||||
"failureCode": "ImageNotFound",
|
||||
}
|
||||
],
|
||||
}
|
||||
return {
|
||||
"images": [
|
||||
{
|
||||
"imageManifest": json.dumps(manifest),
|
||||
"imageManifestMediaType": manifest.get("mediaType", ""),
|
||||
}
|
||||
]
|
||||
}
|
||||
if operation_name == "GetDownloadUrlForLayer":
|
||||
digest = kwarg["layerDigest"]
|
||||
return {"downloadUrl": f"https://layers.example.com/{digest}"}
|
||||
return _original_make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
@patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
|
||||
class Test_ImageInspector:
|
||||
"""Tests for the bounded image-content extraction in image_inspection."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_image_fixtures(self):
|
||||
"""Isolate the BatchGetImage/GetDownloadUrlForLayer fixtures per test."""
|
||||
reset_image_fixtures()
|
||||
yield
|
||||
reset_image_fixtures()
|
||||
|
||||
@staticmethod
|
||||
def _fetch(digest=IMAGE_DIGEST):
|
||||
"""Fetch scan data for one image, with the layer download stubbed."""
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
with patch(_REQUESTS_GET, new=mock_requests_get):
|
||||
return ImageInspector().fetch_image_scan_data(
|
||||
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, digest
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_simple_image(self):
|
||||
"""A single-manifest image's config and layer file are scanned."""
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar({"app/config.py": "TOKEN = 'x'"})
|
||||
|
||||
scan_data = self._fetch()
|
||||
|
||||
assert scan_data is not None
|
||||
assert scan_data.env == ["PATH=/usr/bin", "TOKEN=super-secret-value"]
|
||||
assert scan_data.history == ["/bin/sh -c #(nop) ADD file", "RUN echo hi"]
|
||||
assert len(scan_data.files) == 1
|
||||
assert scan_data.files[0].path == "app/config.py"
|
||||
assert scan_data.files[0].layer_digest == LAYER_DIGEST
|
||||
assert scan_data.files[0].content == "TOKEN = 'x'"
|
||||
assert scan_data.truncated is False
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_resolves_multi_arch_manifest(self):
|
||||
"""A multi-arch scan is incomplete when only one child is inspected."""
|
||||
MANIFESTS_BY_DIGEST[MULTI_ARCH_INDEX_DIGEST] = MULTI_ARCH_MANIFEST_LIST
|
||||
MANIFESTS_BY_DIGEST[CHILD_AMD64_DIGEST] = SIMPLE_MANIFEST
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar({"app/config.py": "TOKEN = 'x'"})
|
||||
|
||||
scan_data = self._fetch(digest=MULTI_ARCH_INDEX_DIGEST)
|
||||
|
||||
# Only the amd64/linux child manifest is resolved and scanned; the
|
||||
# arm64 and attestation entries in the manifest list are ignored.
|
||||
assert scan_data is not None
|
||||
assert scan_data.env == ["PATH=/usr/bin", "TOKEN=super-secret-value"]
|
||||
assert len(scan_data.files) == 1
|
||||
assert scan_data.files[0].path == "app/config.py"
|
||||
assert scan_data.truncated is True
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_skips_oversized_layer(self):
|
||||
"""A layer over the size cap is skipped, not downloaded."""
|
||||
oversized_manifest = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"config": {"digest": CONFIG_DIGEST, "size": 10},
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
|
||||
"digest": LAYER_DIGEST,
|
||||
"size": MAX_LAYER_DOWNLOAD_BYTES + 1,
|
||||
}
|
||||
],
|
||||
}
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = oversized_manifest
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
|
||||
scan_data = self._fetch()
|
||||
|
||||
# The oversized layer is never downloaded (only the config blob is in
|
||||
# BLOBS_BY_DIGEST), yet the fetch completes without raising.
|
||||
assert scan_data is not None
|
||||
assert scan_data.files == []
|
||||
assert scan_data.truncated is True
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_manifest_not_found_returns_none(self):
|
||||
"""An unknown digest resolves to no scan data."""
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
|
||||
scan_data = ImageInspector().fetch_image_scan_data(
|
||||
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, f"sha256:{'f' * 64}"
|
||||
)
|
||||
|
||||
assert scan_data is None
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_config_download_failure_marks_truncated(self):
|
||||
"""A config blob that cannot be retrieved marks coverage incomplete.
|
||||
|
||||
Empty env/history would otherwise be indistinguishable from a clean
|
||||
config, so the fetch flags the result as truncated rather than risking
|
||||
a false PASS at the check level.
|
||||
"""
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
|
||||
# Register the layer but NOT the config blob, so the config download
|
||||
# fails while the layer is still scanned cleanly.
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar({"app/config.py": "clean"})
|
||||
|
||||
scan_data = self._fetch()
|
||||
|
||||
assert scan_data is not None
|
||||
assert scan_data.env == []
|
||||
assert scan_data.history == []
|
||||
assert scan_data.truncated is True
|
||||
assert [f.path for f in scan_data.files] == ["app/config.py"]
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_member_over_remaining_budget_is_truncated(self):
|
||||
"""A layer exceeding the remaining stream budget is truncated.
|
||||
|
||||
Tar headers and padding consume the authoritative decompressed-byte
|
||||
budget before member payloads are exposed for scanning.
|
||||
"""
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar(
|
||||
{"app/first.txt": "a" * 100, "app/second.txt": "b" * 5000}
|
||||
)
|
||||
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
with (
|
||||
patch(_REQUESTS_GET, new=mock_requests_get),
|
||||
patch(
|
||||
"prowler.providers.aws.services.ecr.image_inspection.MAX_TOTAL_BYTES_PER_IMAGE",
|
||||
1000,
|
||||
),
|
||||
):
|
||||
scan_data = ImageInspector().fetch_image_scan_data(
|
||||
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST
|
||||
)
|
||||
|
||||
assert scan_data is not None
|
||||
assert scan_data.files == []
|
||||
assert scan_data.truncated is True
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_oversized_members_count_toward_budget(self):
|
||||
"""Members skipped for size still count toward the per-image budget.
|
||||
|
||||
A streaming reader must decompress each member to advance past it, so
|
||||
oversized-and-skipped members must still consume budget; otherwise a
|
||||
layer of many just-over-limit files would decompress unbounded. The
|
||||
loop must stop before reaching a later scannable member.
|
||||
"""
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
# Ten 300-byte members (each over the patched 100-byte MAX_FILE_BYTES,
|
||||
# so each is skipped for content) followed by a small, scannable file.
|
||||
layer = {f"app/big{i}.bin": "x" * 300 for i in range(10)}
|
||||
layer["app/reachable.txt"] = "hello"
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar(layer)
|
||||
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
with (
|
||||
patch(_REQUESTS_GET, new=mock_requests_get),
|
||||
patch(
|
||||
"prowler.providers.aws.services.ecr.image_inspection.MAX_FILE_BYTES",
|
||||
100,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.aws.services.ecr.image_inspection.MAX_TOTAL_BYTES_PER_IMAGE",
|
||||
1000,
|
||||
),
|
||||
):
|
||||
scan_data = ImageInspector().fetch_image_scan_data(
|
||||
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST
|
||||
)
|
||||
|
||||
assert scan_data is not None
|
||||
# The oversized members exhaust the 1000-byte budget after ~3 of them,
|
||||
# so the loop stops before ever reaching app/reachable.txt. If skipped
|
||||
# members were not counted, reachable.txt would be scanned.
|
||||
assert scan_data.files == []
|
||||
assert scan_data.truncated is True
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_tar_over_stream_budget_is_truncated(self):
|
||||
"""Tar headers, padding, and non-files consume the image byte budget."""
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
layer = BytesIO()
|
||||
with tarfile.open(fileobj=layer, mode="w") as archive:
|
||||
for index in range(20):
|
||||
directory = tarfile.TarInfo(f"metadata-{index}/")
|
||||
directory.type = tarfile.DIRTYPE
|
||||
archive.addfile(directory)
|
||||
content = b"x"
|
||||
member = tarfile.TarInfo("app/reachable.txt")
|
||||
member.size = len(content)
|
||||
archive.addfile(member, BytesIO(content))
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = gzip.compress(layer.getvalue())
|
||||
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
with (
|
||||
patch(_REQUESTS_GET, new=mock_requests_get),
|
||||
patch(
|
||||
"prowler.providers.aws.services.ecr.image_inspection.MAX_TOTAL_BYTES_PER_IMAGE",
|
||||
10 * 1024 - 1,
|
||||
),
|
||||
):
|
||||
scan_data = ImageInspector().fetch_image_scan_data(
|
||||
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST
|
||||
)
|
||||
|
||||
assert scan_data is not None
|
||||
assert scan_data.files == []
|
||||
assert scan_data.truncated is True
|
||||
|
||||
def test_select_child_manifest_digest_falls_back_to_non_amd64(self):
|
||||
"""With no amd64/linux entry, the first non-attestation candidate is picked."""
|
||||
manifest_list = {
|
||||
"manifests": [
|
||||
{
|
||||
"digest": CHILD_ARM64_DIGEST,
|
||||
"platform": {"architecture": "arm64", "os": "linux"},
|
||||
},
|
||||
{
|
||||
"digest": f"sha256:{'5' * 64}",
|
||||
"platform": {"architecture": "unknown", "os": "unknown"},
|
||||
"annotations": {
|
||||
"vnd.docker.reference.type": "attestation-manifest"
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
digest = ImageInspector._select_child_manifest_digest(manifest_list)
|
||||
|
||||
assert digest == CHILD_ARM64_DIGEST
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_zstd_layer(self):
|
||||
"""A zstd-compressed layer is streamed, decompressed, and scanned."""
|
||||
zstd_manifest = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"config": {"digest": CONFIG_DIGEST, "size": 100},
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.layer.v1.tar+zstd",
|
||||
"digest": LAYER_DIGEST,
|
||||
"size": 200,
|
||||
}
|
||||
],
|
||||
}
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = zstd_manifest
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = zstandard.ZstdCompressor().compress(
|
||||
build_tar({"app/config.py": "TOKEN = 'x'"})
|
||||
)
|
||||
|
||||
scan_data = self._fetch()
|
||||
|
||||
assert scan_data is not None
|
||||
assert len(scan_data.files) == 1
|
||||
assert scan_data.files[0].content == "TOKEN = 'x'"
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_zstd_layer_over_compressed_cap_is_truncated(self):
|
||||
"""A zstd layer whose compressed bytes exceed the cap is truncated.
|
||||
|
||||
The streaming decompressor reads through a _CappedLayerReader, so a
|
||||
layer whose compressed size exceeds MAX_LAYER_DOWNLOAD_BYTES (patched
|
||||
here) is cut off and disclosed via truncated instead of being buffered
|
||||
or decompressed unbounded.
|
||||
"""
|
||||
zstd_manifest = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"config": {"digest": CONFIG_DIGEST, "size": 100},
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.layer.v1.tar+zstd",
|
||||
"digest": LAYER_DIGEST,
|
||||
# Declares zero size so it passes the pre-download check;
|
||||
# the actual compressed bytes exceed the (patched) cap.
|
||||
"size": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
# Incompressible payload (so the compressed frame stays large), split
|
||||
# across two members so the first is read before the cap trips while
|
||||
# the second is streamed.
|
||||
rng = random.Random(0)
|
||||
incompressible = bytes(rng.randrange(256) for _ in range(64 * 1024)).decode(
|
||||
"latin-1"
|
||||
)
|
||||
layer_blob = zstandard.ZstdCompressor().compress(
|
||||
build_tar({"app/config.py": "TOKEN = 'x'", "app/big.bin": incompressible})
|
||||
)
|
||||
assert len(layer_blob) > 10 * 1024
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = zstd_manifest
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = layer_blob
|
||||
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
with (
|
||||
patch(_REQUESTS_GET, new=mock_requests_get),
|
||||
patch(
|
||||
"prowler.providers.aws.services.ecr.image_inspection.MAX_LAYER_DOWNLOAD_BYTES",
|
||||
len(layer_blob) - 1,
|
||||
),
|
||||
):
|
||||
scan_data = ImageInspector().fetch_image_scan_data(
|
||||
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST
|
||||
)
|
||||
|
||||
assert scan_data is not None
|
||||
assert scan_data.truncated is True
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_uncompressed_tar_layer(self):
|
||||
"""An uncompressed tar layer is read and scanned directly."""
|
||||
tar_manifest = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"config": {"digest": CONFIG_DIGEST, "size": 100},
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.layer.v1.tar",
|
||||
"digest": LAYER_DIGEST,
|
||||
"size": 200,
|
||||
}
|
||||
],
|
||||
}
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = tar_manifest
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_tar({"app/config.py": "TOKEN = 'x'"})
|
||||
|
||||
scan_data = self._fetch()
|
||||
|
||||
assert scan_data is not None
|
||||
assert len(scan_data.files) == 1
|
||||
assert scan_data.files[0].content == "TOKEN = 'x'"
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_skips_whiteout_and_oversized_file(self):
|
||||
"""Whiteout markers and oversized files are skipped, not scanned."""
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar(
|
||||
{
|
||||
".wh.deleted": "should never appear",
|
||||
"app/config.py": "TOKEN = 'x'",
|
||||
"app/oversized.bin": "x" * (MAX_FILE_BYTES + 1),
|
||||
}
|
||||
)
|
||||
|
||||
scan_data = self._fetch()
|
||||
|
||||
assert scan_data is not None
|
||||
assert [f.path for f in scan_data.files] == ["app/config.py"]
|
||||
assert scan_data.truncated is True
|
||||
|
||||
def test_capped_layer_reader_allows_up_to_max(self):
|
||||
"""Reading exactly the byte budget succeeds and then reports EOF."""
|
||||
import io
|
||||
|
||||
reader = _CappedLayerReader(io.BytesIO(b"x" * 10), max_bytes=10)
|
||||
assert reader.read() == b"x" * 10
|
||||
assert reader.read() == b""
|
||||
|
||||
def test_capped_layer_reader_raises_when_exceeding_max(self):
|
||||
"""A stream longer than the byte budget raises _LayerTooLargeError."""
|
||||
import io
|
||||
|
||||
reader = _CappedLayerReader(io.BytesIO(b"x" * 100), max_bytes=10)
|
||||
with pytest.raises(_LayerTooLargeError):
|
||||
reader.read()
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_streamed_layer_over_cap_is_truncated(self):
|
||||
"""A layer streaming past MAX_LAYER_DOWNLOAD_BYTES is skipped, not buffered."""
|
||||
undersized_manifest = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"config": {"digest": CONFIG_DIGEST, "size": 100},
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
|
||||
"digest": LAYER_DIGEST,
|
||||
# Declares zero size so it passes the pre-download check;
|
||||
# the actual streamed bytes exceed the (patched) cap.
|
||||
"size": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
# Two-member layer: a small first file that is scanned, then a large
|
||||
# incompressible second file. The blob must exceed tarfile's internal
|
||||
# read buffer (~10 KB) so tarfile.open() consumes only part of it and
|
||||
# the cap (set one byte below the full layer) is instead exceeded while
|
||||
# the second member's content is read during archive iteration.
|
||||
rng = random.Random(0)
|
||||
incompressible = bytes(rng.randrange(256) for _ in range(64 * 1024)).decode(
|
||||
"latin-1"
|
||||
)
|
||||
layer_blob = build_gzip_tar(
|
||||
{"app/config.py": "TOKEN = 'x'", "app/big.bin": incompressible}
|
||||
)
|
||||
assert len(layer_blob) > 10 * 1024 # larger than tarfile's read buffer
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = undersized_manifest
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = layer_blob
|
||||
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
with (
|
||||
patch(_REQUESTS_GET, new=mock_requests_get),
|
||||
patch(
|
||||
"prowler.providers.aws.services.ecr.image_inspection.MAX_LAYER_DOWNLOAD_BYTES",
|
||||
len(layer_blob) - 1,
|
||||
),
|
||||
):
|
||||
scan_data = ImageInspector().fetch_image_scan_data(
|
||||
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST
|
||||
)
|
||||
|
||||
# The over-cap layer is disclosed via truncated, and the config-derived
|
||||
# env/history (fetched independently of the layer) are still returned.
|
||||
assert scan_data is not None
|
||||
assert scan_data.truncated is True
|
||||
assert scan_data.env == ["PATH=/usr/bin", "TOKEN=super-secret-value"]
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Shared fixtures for ECR image-scanning tests.
|
||||
|
||||
Used by both the ECR service tests (orchestration) and the image_inspection
|
||||
tests (bounded extraction), so the manifest/layer fixtures and the fake layer
|
||||
download live in one place. moto implements neither BatchGetImage nor
|
||||
GetDownloadUrlForLayer, so each test registers the manifests/blobs it needs in
|
||||
``MANIFESTS_BY_DIGEST``/``BLOBS_BY_DIGEST`` and a patched ``_make_api_call``
|
||||
serves them.
|
||||
"""
|
||||
|
||||
import io
|
||||
import tarfile
|
||||
|
||||
IMAGE_DIGEST = f"sha256:{'1' * 64}"
|
||||
CONFIG_DIGEST = f"sha256:{'c' * 64}"
|
||||
LAYER_DIGEST = f"sha256:{'d' * 64}"
|
||||
MULTI_ARCH_INDEX_DIGEST = f"sha256:{'2' * 64}"
|
||||
CHILD_AMD64_DIGEST = f"sha256:{'3' * 64}"
|
||||
CHILD_ARM64_DIGEST = f"sha256:{'4' * 64}"
|
||||
ATTESTATION_DIGEST = f"sha256:{'5' * 64}"
|
||||
|
||||
SIMPLE_MANIFEST = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"config": {
|
||||
"mediaType": "application/vnd.docker.container.image.v1+json",
|
||||
"digest": CONFIG_DIGEST,
|
||||
"size": 100,
|
||||
},
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
|
||||
"digest": LAYER_DIGEST,
|
||||
"size": 200,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
MULTI_ARCH_MANIFEST_LIST = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"manifests": [
|
||||
{
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"digest": CHILD_AMD64_DIGEST,
|
||||
"size": 10,
|
||||
"platform": {"architecture": "amd64", "os": "linux"},
|
||||
},
|
||||
{
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"digest": CHILD_ARM64_DIGEST,
|
||||
"size": 10,
|
||||
"platform": {"architecture": "arm64", "os": "linux"},
|
||||
},
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"digest": ATTESTATION_DIGEST,
|
||||
"size": 10,
|
||||
"platform": {"architecture": "unknown", "os": "unknown"},
|
||||
"annotations": {"vnd.docker.reference.type": "attestation-manifest"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
CONFIG_JSON = {
|
||||
"config": {"Env": ["PATH=/usr/bin", "TOKEN=super-secret-value"]},
|
||||
"history": [
|
||||
{"created_by": "/bin/sh -c #(nop) ADD file"},
|
||||
{"created_by": "RUN echo hi"},
|
||||
],
|
||||
}
|
||||
|
||||
# Per-test fixtures keyed by digest. moto implements neither BatchGetImage nor
|
||||
# GetDownloadUrlForLayer, so tests populate these and a patched _make_api_call /
|
||||
# requests.get serves them. Cleared between tests via reset_image_fixtures().
|
||||
MANIFESTS_BY_DIGEST = {}
|
||||
BLOBS_BY_DIGEST = {}
|
||||
|
||||
|
||||
def reset_image_fixtures():
|
||||
"""Clear the per-test manifest/blob fixtures."""
|
||||
MANIFESTS_BY_DIGEST.clear()
|
||||
BLOBS_BY_DIGEST.clear()
|
||||
|
||||
|
||||
def build_tar(files: dict) -> bytes:
|
||||
"""Build an uncompressed tar archive from the given files."""
|
||||
tar_buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
|
||||
for name, content in files.items():
|
||||
data = content.encode("latin-1")
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(data)
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
return tar_buffer.getvalue()
|
||||
|
||||
|
||||
def build_gzip_tar(files: dict) -> bytes:
|
||||
"""Build a gzip-compressed tar archive from the given files."""
|
||||
tar_buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar:
|
||||
for name, content in files.items():
|
||||
data = content.encode("latin-1")
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(data)
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
return tar_buffer.getvalue()
|
||||
|
||||
|
||||
class FakeLayerResponse:
|
||||
"""A minimal stand-in for a requests.Response over a layer download."""
|
||||
|
||||
def __init__(self, data: bytes):
|
||||
"""Store the fixture bytes to serve (via iter_content and .raw)."""
|
||||
self._data = data
|
||||
# Streaming gzip/tar layers read the compressed bytes straight from
|
||||
# response.raw; the buffered config/zstd path uses iter_content.
|
||||
self.raw = io.BytesIO(data)
|
||||
|
||||
def raise_for_status(self):
|
||||
"""No-op: fixture responses are always successful."""
|
||||
|
||||
def iter_content(self, chunk_size=1024 * 1024):
|
||||
"""Yield the fixture bytes in chunks."""
|
||||
for start in range(0, len(self._data), chunk_size):
|
||||
yield self._data[start : start + chunk_size]
|
||||
|
||||
def close(self):
|
||||
"""Close the backing raw stream, mirroring requests.Response.close."""
|
||||
self.raw.close()
|
||||
|
||||
def __enter__(self):
|
||||
"""Support use as a context manager."""
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
"""Close on exit, mirroring requests.Response context-manager use."""
|
||||
self.close()
|
||||
return False
|
||||
|
||||
|
||||
def mock_requests_get(url, **_):
|
||||
"""Return the fixture bytes registered for the requested layer's URL."""
|
||||
digest = url.rsplit("/", 1)[-1]
|
||||
return FakeLayerResponse(BLOBS_BY_DIGEST[digest])
|
||||
@@ -0,0 +1,164 @@
|
||||
from datetime import datetime, timezone
|
||||
from importlib import import_module
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import botocore
|
||||
import pytest
|
||||
|
||||
from prowler.providers.aws.services.ecs.ecs_service import ECS, TaskDefinition
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_US_EAST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
TASK_NAME = "test-task"
|
||||
TASK_REVISION = "1"
|
||||
TASK_ARN = (
|
||||
f"arn:aws:ecs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:"
|
||||
f"task-definition/{TASK_NAME}:{TASK_REVISION}"
|
||||
)
|
||||
make_api_call = botocore.client.BaseClient._make_api_call
|
||||
|
||||
|
||||
def _mock_ecs_api(describe_result):
|
||||
def mock_make_api_call(self, operation_name, kwargs):
|
||||
if operation_name == "ListTaskDefinitions":
|
||||
return {"taskDefinitionArns": [TASK_ARN]}
|
||||
if operation_name == "DescribeTaskDefinition":
|
||||
if isinstance(describe_result, Exception):
|
||||
raise describe_result
|
||||
return describe_result
|
||||
if operation_name == "ListClusters":
|
||||
return {"clusterArns": []}
|
||||
return make_api_call(self, operation_name, kwargs)
|
||||
|
||||
return mock_make_api_call
|
||||
|
||||
|
||||
def _collect_task_definition(describe_result):
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
with patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=_mock_ecs_api(describe_result),
|
||||
):
|
||||
return ECS(aws_provider).task_definitions[TASK_ARN]
|
||||
|
||||
|
||||
def _undescribed_ecs_client():
|
||||
task_definition = TaskDefinition(
|
||||
name=TASK_NAME,
|
||||
arn=TASK_ARN,
|
||||
revision=TASK_REVISION,
|
||||
region=AWS_REGION_US_EAST_1,
|
||||
environment_variables=[],
|
||||
)
|
||||
task_definition.container_definitions = None
|
||||
return SimpleNamespace(
|
||||
audit_config={},
|
||||
task_definitions={TASK_ARN: task_definition},
|
||||
)
|
||||
|
||||
|
||||
def test_failed_describe_leaves_task_definition_undescribed():
|
||||
error = botocore.exceptions.ClientError(
|
||||
{"Error": {"Code": "ThrottlingException", "Message": "rate exceeded"}},
|
||||
"DescribeTaskDefinition",
|
||||
)
|
||||
|
||||
task_definition = _collect_task_definition(error)
|
||||
|
||||
assert task_definition.container_definitions is None
|
||||
assert task_definition.pid_mode is None
|
||||
assert task_definition.network_mode is None
|
||||
|
||||
|
||||
def test_successful_describe_preserves_empty_container_definitions():
|
||||
task_definition = _collect_task_definition(
|
||||
{
|
||||
"taskDefinition": {
|
||||
"containerDefinitions": [],
|
||||
"pidMode": "task",
|
||||
"networkMode": "awsvpc",
|
||||
},
|
||||
"tags": [],
|
||||
}
|
||||
)
|
||||
|
||||
assert task_definition.container_definitions == []
|
||||
assert task_definition.pid_mode == "task"
|
||||
assert task_definition.network_mode == "awsvpc"
|
||||
|
||||
|
||||
def test_partial_parse_leaves_task_definition_undescribed():
|
||||
task_definition = _collect_task_definition(
|
||||
{
|
||||
"taskDefinition": {
|
||||
"containerDefinitions": [
|
||||
{"name": "valid-container"},
|
||||
{"privileged": False},
|
||||
],
|
||||
"pidMode": "host",
|
||||
"networkMode": "host",
|
||||
"registeredAt": datetime(2026, 8, 13, tzinfo=timezone.utc),
|
||||
},
|
||||
"tags": [{"key": "Environment", "value": "production"}],
|
||||
}
|
||||
)
|
||||
|
||||
assert task_definition.container_definitions is None
|
||||
assert task_definition.pid_mode is None
|
||||
assert task_definition.network_mode is None
|
||||
assert task_definition.registered_at is None
|
||||
assert task_definition.tags == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("check_package", "check_name"),
|
||||
[
|
||||
(
|
||||
"ecs_task_definitions_containers_readonly_access",
|
||||
"ecs_task_definitions_containers_readonly_access",
|
||||
),
|
||||
(
|
||||
"ecs_task_definitions_host_namespace_not_shared",
|
||||
"ecs_task_definitions_host_namespace_not_shared",
|
||||
),
|
||||
(
|
||||
"ecs_task_definitions_host_networking_mode_users",
|
||||
"ecs_task_definitions_host_networking_mode_users",
|
||||
),
|
||||
(
|
||||
"ecs_task_definitions_logging_block_mode",
|
||||
"ecs_task_definitions_logging_block_mode",
|
||||
),
|
||||
(
|
||||
"ecs_task_definitions_logging_enabled",
|
||||
"ecs_task_definitions_logging_enabled",
|
||||
),
|
||||
(
|
||||
"ecs_task_definitions_no_environment_secrets",
|
||||
"ecs_task_definitions_no_environment_secrets",
|
||||
),
|
||||
(
|
||||
"ecs_task_definitions_no_privileged_containers",
|
||||
"ecs_task_definitions_no_privileged_containers",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_undescribed_task_definitions_are_not_reported(
|
||||
check_package, check_name, monkeypatch
|
||||
):
|
||||
with patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider([AWS_REGION_US_EAST_1]),
|
||||
):
|
||||
module = import_module(
|
||||
f"prowler.providers.aws.services.ecs.{check_package}.{check_name}"
|
||||
)
|
||||
monkeypatch.setattr(module, "ecs_client", _undescribed_ecs_client())
|
||||
|
||||
check = getattr(module, check_name)()
|
||||
|
||||
assert check.execute() == []
|
||||
@@ -0,0 +1,762 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import Profile
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_US_EAST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
PROFILE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
PROFILE_NAME = "workload-profile"
|
||||
PROFILE_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:profile/{PROFILE_ID}"
|
||||
ADMIN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/admin-role"
|
||||
READONLY_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/workload-role"
|
||||
UNKNOWN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/cross-account-role"
|
||||
CUSTOM_ADMIN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/custom-admin-role"
|
||||
INLINE_ADMIN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/inline-admin-role"
|
||||
NAME_COLLISION_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/name-collision-role"
|
||||
UNRESOLVED_POLICY_ROLE_ARN = (
|
||||
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/unresolved-policy-role"
|
||||
)
|
||||
ADMIN_UNRESOLVED_POLICY_ROLE_ARN = (
|
||||
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/admin-unresolved-policy-role"
|
||||
)
|
||||
INVALID_POLICY_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/invalid-policy-role"
|
||||
DENY_OVERRIDE_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/deny-override-role"
|
||||
CONDITIONAL_DENY_ROLE_ARN = (
|
||||
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/conditional-deny-role"
|
||||
)
|
||||
CONDITIONAL_ADMIN_ROLE_ARN = (
|
||||
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/conditional-admin-role"
|
||||
)
|
||||
BOUNDED_ADMIN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/bounded-admin-role"
|
||||
UNRESOLVED_BOUNDARY_ROLE_ARN = (
|
||||
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/unresolved-boundary-role"
|
||||
)
|
||||
ADMIN_BOUNDED_ROLE_ARN = (
|
||||
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/admin-bounded-admin-role"
|
||||
)
|
||||
AWS_ADMIN_POLICY_ARN = "arn:aws:iam::aws:policy/AdministratorAccess"
|
||||
CUSTOMER_ADMIN_NAMED_POLICY_ARN = (
|
||||
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/AdministratorAccess"
|
||||
)
|
||||
CUSTOM_ADMIN_POLICY_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/custom-admin"
|
||||
MANAGED_POLICY_ARN = "arn:aws:iam::aws:policy/ReadOnlyAccess"
|
||||
CUSTOMER_FULL_ACCESS_POLICY_ARN = (
|
||||
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/full-access-session"
|
||||
)
|
||||
BOUNDARY_POLICY_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/scoped-boundary"
|
||||
UNRESOLVED_BOUNDARY_POLICY_ARN = (
|
||||
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/unresolved-boundary"
|
||||
)
|
||||
UNRESOLVED_SESSION_POLICY_ARN = (
|
||||
"arn:aws:iam::aws:policy/job-function/SupportUser" # not in iam_client.policies
|
||||
)
|
||||
|
||||
SESSION_POLICY = (
|
||||
'{"Version":"2012-10-17","Statement":[{"Effect":"Allow",'
|
||||
'"Action":["s3:GetObject"],"Resource":["*"]}]}'
|
||||
)
|
||||
FULL_ACCESS_SESSION_POLICY = (
|
||||
'{"Version":"2012-10-17","Statement":[{"Effect":"Allow",'
|
||||
'"Action":"*","Resource":"*"}]}'
|
||||
)
|
||||
|
||||
FULL_ACCESS_DOCUMENT = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"}],
|
||||
}
|
||||
READONLY_DOCUMENT = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{"Effect": "Allow", "Action": "s3:Get*", "Resource": "*"}],
|
||||
}
|
||||
DENY_ALL_DOCUMENT = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{"Effect": "Deny", "Action": "*", "Resource": "*"}],
|
||||
}
|
||||
CONDITIONAL_ADMIN_DOCUMENT = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "*",
|
||||
"Resource": "*",
|
||||
"Condition": {"Bool": {"aws:MultiFactorAuthPresent": "true"}},
|
||||
}
|
||||
],
|
||||
}
|
||||
CONDITIONAL_DENY_DOCUMENT = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Action": "*",
|
||||
"Resource": "*",
|
||||
"Condition": {"StringNotEquals": {"aws:PrincipalTag/team": "security"}},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _profile(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
session_policy: str = "",
|
||||
managed_policy_arns=None,
|
||||
role_arns=None,
|
||||
):
|
||||
return Profile(
|
||||
arn=PROFILE_ARN,
|
||||
id=PROFILE_ID,
|
||||
name=PROFILE_NAME,
|
||||
region=AWS_REGION_US_EAST_1,
|
||||
enabled=enabled,
|
||||
role_arns=role_arns if role_arns is not None else [READONLY_ROLE_ARN],
|
||||
session_policy=session_policy,
|
||||
managed_policy_arns=managed_policy_arns or [],
|
||||
)
|
||||
|
||||
|
||||
def _role(arn, attached_policies=None, inline_policies=None, permissions_boundary=None):
|
||||
return SimpleNamespace(
|
||||
arn=arn,
|
||||
attached_policies=attached_policies or [],
|
||||
inline_policies=inline_policies or [],
|
||||
permissions_boundary=permissions_boundary,
|
||||
)
|
||||
|
||||
|
||||
def _iam_client():
|
||||
"""IAM client stub mirroring iam_service models: roles with attached/inline
|
||||
policies and a policies dict keyed by ARN (inline keyed {role_arn}:policy/{name}).
|
||||
"""
|
||||
roles = [
|
||||
_role(
|
||||
ADMIN_ROLE_ARN,
|
||||
attached_policies=[
|
||||
{"PolicyName": "AdministratorAccess", "PolicyArn": AWS_ADMIN_POLICY_ARN}
|
||||
],
|
||||
),
|
||||
_role(
|
||||
READONLY_ROLE_ARN,
|
||||
attached_policies=[
|
||||
{"PolicyName": "ReadOnlyAccess", "PolicyArn": MANAGED_POLICY_ARN}
|
||||
],
|
||||
),
|
||||
_role(
|
||||
CUSTOM_ADMIN_ROLE_ARN,
|
||||
attached_policies=[
|
||||
{"PolicyName": "custom-admin", "PolicyArn": CUSTOM_ADMIN_POLICY_ARN}
|
||||
],
|
||||
),
|
||||
_role(INLINE_ADMIN_ROLE_ARN, inline_policies=["inline-admin"]),
|
||||
_role(
|
||||
NAME_COLLISION_ROLE_ARN,
|
||||
attached_policies=[
|
||||
{
|
||||
"PolicyName": "AdministratorAccess",
|
||||
"PolicyArn": CUSTOMER_ADMIN_NAMED_POLICY_ARN,
|
||||
}
|
||||
],
|
||||
),
|
||||
_role(
|
||||
UNRESOLVED_POLICY_ROLE_ARN,
|
||||
attached_policies=[
|
||||
{
|
||||
"PolicyName": "unresolved",
|
||||
"PolicyArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/unresolved",
|
||||
}
|
||||
],
|
||||
),
|
||||
# Proven admin attached policy combined with an unresolved one: the
|
||||
# unresolved document could contain a deny, so the outcome is unknown.
|
||||
_role(
|
||||
ADMIN_UNRESOLVED_POLICY_ROLE_ARN,
|
||||
attached_policies=[
|
||||
{
|
||||
"PolicyName": "AdministratorAccess",
|
||||
"PolicyArn": AWS_ADMIN_POLICY_ARN,
|
||||
},
|
||||
{
|
||||
"PolicyName": "unresolved",
|
||||
"PolicyArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/unresolved",
|
||||
},
|
||||
],
|
||||
),
|
||||
# Attached policy resolves to a malformed (non-dict) document.
|
||||
_role(
|
||||
INVALID_POLICY_ROLE_ARN,
|
||||
attached_policies=[
|
||||
{
|
||||
"PolicyName": "invalid",
|
||||
"PolicyArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/invalid",
|
||||
}
|
||||
],
|
||||
),
|
||||
# Allow *:* in the attached policy negated by an unconditional Deny *:*
|
||||
# in an inline policy: not effectively administrative.
|
||||
_role(
|
||||
DENY_OVERRIDE_ROLE_ARN,
|
||||
attached_policies=[
|
||||
{"PolicyName": "custom-admin", "PolicyArn": CUSTOM_ADMIN_POLICY_ARN}
|
||||
],
|
||||
inline_policies=["deny-all"],
|
||||
),
|
||||
# Allow *:* in the attached policy plus a Condition-guarded Deny: the
|
||||
# deny may or may not apply, so the outcome is unknown.
|
||||
_role(
|
||||
CONDITIONAL_DENY_ROLE_ARN,
|
||||
attached_policies=[
|
||||
{"PolicyName": "custom-admin", "PolicyArn": CUSTOM_ADMIN_POLICY_ARN}
|
||||
],
|
||||
inline_policies=["conditional-deny"],
|
||||
),
|
||||
# Allow *:* guarded by a Condition: not statically provable as admin.
|
||||
_role(
|
||||
CONDITIONAL_ADMIN_ROLE_ARN,
|
||||
attached_policies=[
|
||||
{
|
||||
"PolicyName": "conditional-admin",
|
||||
"PolicyArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/conditional-admin",
|
||||
}
|
||||
],
|
||||
),
|
||||
# Administrative identity policies constrained by a restrictive
|
||||
# permissions boundary: not effectively administrative.
|
||||
_role(
|
||||
BOUNDED_ADMIN_ROLE_ARN,
|
||||
attached_policies=[
|
||||
{"PolicyName": "AdministratorAccess", "PolicyArn": AWS_ADMIN_POLICY_ARN}
|
||||
],
|
||||
permissions_boundary={
|
||||
"PermissionsBoundaryType": "Policy",
|
||||
"PermissionsBoundaryArn": BOUNDARY_POLICY_ARN,
|
||||
},
|
||||
),
|
||||
# Boundary present but its document is not in the IAM inventory:
|
||||
# restrictions cannot be evaluated, so the role is not classified admin.
|
||||
_role(
|
||||
UNRESOLVED_BOUNDARY_ROLE_ARN,
|
||||
attached_policies=[
|
||||
{"PolicyName": "AdministratorAccess", "PolicyArn": AWS_ADMIN_POLICY_ARN}
|
||||
],
|
||||
permissions_boundary={
|
||||
"PermissionsBoundaryType": "Policy",
|
||||
"PermissionsBoundaryArn": UNRESOLVED_BOUNDARY_POLICY_ARN,
|
||||
},
|
||||
),
|
||||
# AdministratorAccess as the boundary does not restrict anything.
|
||||
_role(
|
||||
ADMIN_BOUNDED_ROLE_ARN,
|
||||
attached_policies=[
|
||||
{"PolicyName": "AdministratorAccess", "PolicyArn": AWS_ADMIN_POLICY_ARN}
|
||||
],
|
||||
permissions_boundary={
|
||||
"PermissionsBoundaryType": "Policy",
|
||||
"PermissionsBoundaryArn": AWS_ADMIN_POLICY_ARN,
|
||||
},
|
||||
),
|
||||
]
|
||||
policies = {
|
||||
AWS_ADMIN_POLICY_ARN: SimpleNamespace(document=FULL_ACCESS_DOCUMENT),
|
||||
CUSTOM_ADMIN_POLICY_ARN: SimpleNamespace(document=FULL_ACCESS_DOCUMENT),
|
||||
MANAGED_POLICY_ARN: SimpleNamespace(document=READONLY_DOCUMENT),
|
||||
# Customer-managed policy that merely shares the AdministratorAccess name.
|
||||
CUSTOMER_ADMIN_NAMED_POLICY_ARN: SimpleNamespace(document=READONLY_DOCUMENT),
|
||||
f"{INLINE_ADMIN_ROLE_ARN}:policy/inline-admin": SimpleNamespace(
|
||||
document=FULL_ACCESS_DOCUMENT
|
||||
),
|
||||
f"{DENY_OVERRIDE_ROLE_ARN}:policy/deny-all": SimpleNamespace(
|
||||
document=DENY_ALL_DOCUMENT
|
||||
),
|
||||
f"{CONDITIONAL_DENY_ROLE_ARN}:policy/conditional-deny": SimpleNamespace(
|
||||
document=CONDITIONAL_DENY_DOCUMENT
|
||||
),
|
||||
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/invalid": SimpleNamespace(
|
||||
document="invalid"
|
||||
),
|
||||
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/conditional-admin": SimpleNamespace(
|
||||
document=CONDITIONAL_ADMIN_DOCUMENT
|
||||
),
|
||||
BOUNDARY_POLICY_ARN: SimpleNamespace(document=READONLY_DOCUMENT),
|
||||
# Customer-managed session policy whose document grants *:*.
|
||||
CUSTOMER_FULL_ACCESS_POLICY_ARN: SimpleNamespace(document=FULL_ACCESS_DOCUMENT),
|
||||
}
|
||||
iam = mock.MagicMock()
|
||||
iam.roles = roles
|
||||
iam.policies = policies
|
||||
return iam
|
||||
|
||||
|
||||
def _build_client(profiles):
|
||||
ra_client = mock.MagicMock()
|
||||
ra_client.profiles = profiles
|
||||
return ra_client
|
||||
|
||||
|
||||
def _patched(ra_client):
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
check_module = "prowler.providers.aws.services.rolesanywhere.rolesanywhere_profile_restricts_session_permissions.rolesanywhere_profile_restricts_session_permissions"
|
||||
return [
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{check_module}.rolesanywhere_client", new=ra_client),
|
||||
mock.patch(f"{check_module}.iam_client", new=_iam_client()),
|
||||
]
|
||||
|
||||
|
||||
def _enter(patches):
|
||||
from contextlib import ExitStack
|
||||
|
||||
stack = ExitStack()
|
||||
for p in patches:
|
||||
stack.enter_context(p)
|
||||
return stack
|
||||
|
||||
|
||||
def _run():
|
||||
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_profile_restricts_session_permissions.rolesanywhere_profile_restricts_session_permissions import (
|
||||
rolesanywhere_profile_restricts_session_permissions,
|
||||
)
|
||||
|
||||
return rolesanywhere_profile_restricts_session_permissions().execute()
|
||||
|
||||
|
||||
class Test_rolesanywhere_profile_restricts_session_permissions:
|
||||
def test_no_profiles(self):
|
||||
with _enter(_patched(_build_client({}))):
|
||||
assert len(_run()) == 0
|
||||
|
||||
def test_unscoped_profile_with_admin_role_fails(self):
|
||||
with _enter(
|
||||
_patched(_build_client({PROFILE_ARN: _profile(role_arns=[ADMIN_ROLE_ARN])}))
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].resource_id == PROFILE_ID
|
||||
assert result[0].resource_arn == PROFILE_ARN
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert ADMIN_ROLE_ARN in result[0].status_extended
|
||||
assert "administrative" in result[0].status_extended
|
||||
|
||||
def test_unscoped_profile_with_custom_admin_policy_fails(self):
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{PROFILE_ARN: _profile(role_arns=[CUSTOM_ADMIN_ROLE_ARN])}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert CUSTOM_ADMIN_ROLE_ARN in result[0].status_extended
|
||||
|
||||
def test_unscoped_profile_with_inline_admin_policy_fails(self):
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{PROFILE_ARN: _profile(role_arns=[INLINE_ADMIN_ROLE_ARN])}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert INLINE_ADMIN_ROLE_ARN in result[0].status_extended
|
||||
|
||||
def test_mixed_roles_fail_lists_only_admin_role(self):
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{
|
||||
PROFILE_ARN: _profile(
|
||||
role_arns=[READONLY_ROLE_ARN, ADMIN_ROLE_ARN]
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert ADMIN_ROLE_ARN in result[0].status_extended
|
||||
assert READONLY_ROLE_ARN not in result[0].status_extended
|
||||
|
||||
def test_customer_policy_named_administratoraccess_passes(self):
|
||||
# Name collision: customer-managed policy called AdministratorAccess
|
||||
# whose document is read-only must not flag the role as administrative.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{PROFILE_ARN: _profile(role_arns=[NAME_COLLISION_ROLE_ARN])}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_unresolved_attached_policy_is_manual(self):
|
||||
# Attached policy ARN missing from iam_client.policies: a missing
|
||||
# document is unknown, not proof that the role is unprivileged.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{PROFILE_ARN: _profile(role_arns=[UNRESOLVED_POLICY_ROLE_ARN])}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "could not be evaluated" in result[0].status_extended
|
||||
|
||||
def test_invalid_policy_document_is_manual(self):
|
||||
# A malformed policy document cannot prove anything about the role.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{PROFILE_ARN: _profile(role_arns=[INVALID_POLICY_ROLE_ARN])}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
|
||||
def test_allow_all_with_unresolved_policy_is_manual(self):
|
||||
# Proven admin policy plus an unresolved one: the unresolved document
|
||||
# could contain a deny, so the classification is unknown.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{
|
||||
PROFILE_ARN: _profile(
|
||||
role_arns=[ADMIN_UNRESOLVED_POLICY_ROLE_ARN]
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
|
||||
def test_unscoped_profile_with_least_privilege_role_passes(self):
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client({PROFILE_ARN: _profile(role_arns=[READONLY_ROLE_ARN])})
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "defense-in-depth" in result[0].status_extended
|
||||
|
||||
def test_unscoped_profile_with_unknown_role_is_manual(self):
|
||||
# A referenced role missing from the IAM inventory is unknown, not
|
||||
# proof that no administrative role exists.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client({PROFILE_ARN: _profile(role_arns=[UNKNOWN_ROLE_ARN])})
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "could not be evaluated" in result[0].status_extended
|
||||
|
||||
def test_unscoped_profile_without_roles_passes(self):
|
||||
with _enter(_patched(_build_client({PROFILE_ARN: _profile(role_arns=[])}))):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_profile_with_session_policy_passes(self):
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{
|
||||
PROFILE_ARN: _profile(
|
||||
session_policy=SESSION_POLICY, role_arns=[ADMIN_ROLE_ARN]
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "session policy" in result[0].status_extended
|
||||
|
||||
def test_full_access_session_policy_does_not_scope(self):
|
||||
# A sessionPolicy granting *:* does not restrict anything.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{
|
||||
PROFILE_ARN: _profile(
|
||||
session_policy=FULL_ACCESS_SESSION_POLICY,
|
||||
role_arns=[ADMIN_ROLE_ARN],
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_profile_with_managed_policies_passes(self):
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{
|
||||
PROFILE_ARN: _profile(
|
||||
managed_policy_arns=[MANAGED_POLICY_ARN],
|
||||
role_arns=[ADMIN_ROLE_ARN],
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_admin_managed_session_policy_does_not_scope(self):
|
||||
# AdministratorAccess as the managed session policy restricts nothing.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{
|
||||
PROFILE_ARN: _profile(
|
||||
managed_policy_arns=[AWS_ADMIN_POLICY_ARN],
|
||||
role_arns=[ADMIN_ROLE_ARN],
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_restrictive_inline_with_admin_managed_policy_fails(self):
|
||||
# The session-policy set is evaluated as a union: AdministratorAccess as
|
||||
# a managed session policy makes the boundary unrestricted even though
|
||||
# the inline session policy is restrictive.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{
|
||||
PROFILE_ARN: _profile(
|
||||
session_policy=SESSION_POLICY,
|
||||
managed_policy_arns=[AWS_ADMIN_POLICY_ARN],
|
||||
role_arns=[ADMIN_ROLE_ARN],
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_full_access_inline_with_restrictive_managed_policy_fails(self):
|
||||
# Conversely, a *:* inline session policy leaves the union unrestricted
|
||||
# regardless of a restrictive managed session policy.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{
|
||||
PROFILE_ARN: _profile(
|
||||
session_policy=FULL_ACCESS_SESSION_POLICY,
|
||||
managed_policy_arns=[MANAGED_POLICY_ARN],
|
||||
role_arns=[ADMIN_ROLE_ARN],
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_restrictive_inline_and_restrictive_managed_policy_passes(self):
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{
|
||||
PROFILE_ARN: _profile(
|
||||
session_policy=SESSION_POLICY,
|
||||
managed_policy_arns=[MANAGED_POLICY_ARN],
|
||||
role_arns=[ADMIN_ROLE_ARN],
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_customer_managed_full_access_session_policy_fails(self):
|
||||
# A customer-managed session policy whose document grants *:* must be
|
||||
# resolved through iam_client.policies and treated as unscoped.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{
|
||||
PROFILE_ARN: _profile(
|
||||
managed_policy_arns=[CUSTOMER_FULL_ACCESS_POLICY_ARN],
|
||||
role_arns=[ADMIN_ROLE_ARN],
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_unresolved_managed_session_policy_is_manual(self):
|
||||
# A managed session policy whose document was not collected does not
|
||||
# prove that the session is restricted: the outcome is unknown.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{
|
||||
PROFILE_ARN: _profile(
|
||||
managed_policy_arns=[UNRESOLVED_SESSION_POLICY_ARN],
|
||||
role_arns=[ADMIN_ROLE_ARN],
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "session scoping could not be evaluated" in result[0].status_extended
|
||||
|
||||
def test_invalid_inline_session_policy_is_manual(self):
|
||||
# An inline session policy that fails to parse does not prove that the
|
||||
# session is restricted: the outcome is unknown.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{
|
||||
PROFILE_ARN: _profile(
|
||||
session_policy="not-json", role_arns=[ADMIN_ROLE_ARN]
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
|
||||
def test_allow_all_with_cross_policy_deny_all_passes(self):
|
||||
# Allow *:* in an attached policy plus an unconditional Deny *:* in an
|
||||
# inline policy: the merged evaluation must not classify the role admin.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{PROFILE_ARN: _profile(role_arns=[DENY_OVERRIDE_ROLE_ARN])}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_conditional_admin_allow_is_manual(self):
|
||||
# An Allow *:* guarded by a Condition is not statically provable in
|
||||
# either direction: the classification is unknown.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{PROFILE_ARN: _profile(role_arns=[CONDITIONAL_ADMIN_ROLE_ARN])}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
|
||||
def test_allow_all_with_conditional_deny_is_manual(self):
|
||||
# Unconditional Allow *:* plus a Condition-guarded Deny: the deny may
|
||||
# or may not negate the grant, so the classification is unknown.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{PROFILE_ARN: _profile(role_arns=[CONDITIONAL_DENY_ROLE_ARN])}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
|
||||
def test_admin_role_with_restrictive_boundary_passes(self):
|
||||
# Admin identity policies intersected with a read-only permissions
|
||||
# boundary are not effectively administrative.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{PROFILE_ARN: _profile(role_arns=[BOUNDED_ADMIN_ROLE_ARN])}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_admin_role_with_unresolved_boundary_is_manual(self):
|
||||
# When the boundary document cannot be resolved the restrictions are
|
||||
# unknown: neither administrative nor safe can be proven.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{PROFILE_ARN: _profile(role_arns=[UNRESOLVED_BOUNDARY_ROLE_ARN])}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
|
||||
def test_admin_role_with_admin_boundary_fails(self):
|
||||
# An AdministratorAccess boundary restricts nothing: still admin.
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{PROFILE_ARN: _profile(role_arns=[ADMIN_BOUNDED_ROLE_ARN])}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert ADMIN_BOUNDED_ROLE_ARN in result[0].status_extended
|
||||
|
||||
def test_disabled_profile_passes(self):
|
||||
with _enter(
|
||||
_patched(
|
||||
_build_client(
|
||||
{PROFILE_ARN: _profile(enabled=False, role_arns=[ADMIN_ROLE_ARN])}
|
||||
)
|
||||
)
|
||||
):
|
||||
result = _run()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "disabled" in result[0].status_extended
|
||||
@@ -4,6 +4,7 @@ import botocore
|
||||
from moto import mock_aws
|
||||
|
||||
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import (
|
||||
Profile,
|
||||
RolesAnywhere,
|
||||
TrustAnchor,
|
||||
)
|
||||
@@ -16,6 +17,9 @@ from tests.providers.aws.utils import (
|
||||
TA_ID = "11111111-2222-3333-4444-555555555555"
|
||||
TA_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:trust-anchor/{TA_ID}"
|
||||
PCA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/abc"
|
||||
PROFILE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
PROFILE_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:profile/{PROFILE_ID}"
|
||||
ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/workload-role"
|
||||
|
||||
make_api_call = botocore.client.BaseClient._make_api_call
|
||||
|
||||
@@ -36,6 +40,21 @@ def mock_make_api_call(self, operation_name, kwarg):
|
||||
}
|
||||
]
|
||||
}
|
||||
if operation_name == "ListProfiles":
|
||||
return {
|
||||
"profiles": [
|
||||
{
|
||||
"profileArn": PROFILE_ARN,
|
||||
"profileId": PROFILE_ID,
|
||||
"name": "workload-profile",
|
||||
"enabled": True,
|
||||
"roleArns": [ROLE_ARN],
|
||||
"sessionPolicy": '{"Version":"2012-10-17","Statement":[]}',
|
||||
"durationSeconds": 3600,
|
||||
"acceptRoleSessionName": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
if operation_name == "ListTagsForResource":
|
||||
return {"tags": [{"key": "Environment", "value": "test"}]}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
@@ -78,6 +97,25 @@ class Test_RolesAnywhere_Service:
|
||||
assert ta.region == AWS_REGION_US_EAST_1
|
||||
assert ta.tags == [{"key": "Environment", "value": "test"}]
|
||||
|
||||
@patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
|
||||
@mock_aws
|
||||
def test_list_profiles(self):
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
rolesanywhere = RolesAnywhere(aws_provider)
|
||||
assert len(rolesanywhere.profiles) == 1
|
||||
profile = rolesanywhere.profiles[PROFILE_ARN]
|
||||
assert isinstance(profile, Profile)
|
||||
assert profile.id == PROFILE_ID
|
||||
assert profile.name == "workload-profile"
|
||||
assert profile.enabled is True
|
||||
assert profile.role_arns == [ROLE_ARN]
|
||||
assert profile.session_policy == '{"Version":"2012-10-17","Statement":[]}'
|
||||
assert profile.managed_policy_arns == []
|
||||
assert profile.duration_seconds == 3600
|
||||
assert profile.accept_role_session_name is True
|
||||
assert profile.region == AWS_REGION_US_EAST_1
|
||||
assert profile.tags == [{"key": "Environment", "value": "test"}]
|
||||
|
||||
@patch(
|
||||
"botocore.client.BaseClient._make_api_call", new=mock_make_api_call_tags_failure
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from copy import deepcopy
|
||||
from unittest import mock
|
||||
|
||||
import botocore
|
||||
import pytest
|
||||
from boto3 import client
|
||||
from moto import mock_aws
|
||||
|
||||
@@ -54,6 +56,113 @@ def mock_make_api_call_v2(self, operation_name, kwarg):
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
PUBLIC_ALLOW_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}'
|
||||
PRIVATE_ALLOW_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"ses:SendEmail","Resource":"*"}]}'
|
||||
MATCHING_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}'
|
||||
UNRELATED_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"ses:SendRawEmail","Resource":"*"}]}'
|
||||
PUBLIC_ALLOW_AND_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"},{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}'
|
||||
PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"}}'
|
||||
PRIVATE_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"ses:SendEmail","Resource":"*"}}'
|
||||
MATCHING_DENY_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}}'
|
||||
CONDITIONAL_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*","Condition":{"StringEquals":{"AWS:SourceAccount":"123456789012"}}}}'
|
||||
|
||||
|
||||
def make_multiple_policies_api_mock(policies):
|
||||
def mock_api_call(self, operation_name, kwarg):
|
||||
if operation_name == "ListEmailIdentities":
|
||||
return {
|
||||
"EmailIdentities": [
|
||||
{
|
||||
"IdentityType": "DOMAIN",
|
||||
"IdentityName": "test-email-identity-multiple-policies",
|
||||
}
|
||||
],
|
||||
}
|
||||
elif operation_name == "GetEmailIdentity":
|
||||
return {"Policies": policies, "Tags": {}}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
return mock_api_call
|
||||
|
||||
|
||||
mock_make_api_call_multiple_policies = make_multiple_policies_api_mock(
|
||||
{
|
||||
"public-policy": PUBLIC_ALLOW_POLICY,
|
||||
"private-policy": PRIVATE_ALLOW_POLICY,
|
||||
}
|
||||
)
|
||||
mock_make_api_call_multiple_policies_reversed = make_multiple_policies_api_mock(
|
||||
{
|
||||
"private-policy": PRIVATE_ALLOW_POLICY,
|
||||
"public-policy": PUBLIC_ALLOW_POLICY,
|
||||
}
|
||||
)
|
||||
mock_make_api_call_public_allow_and_matching_deny = make_multiple_policies_api_mock(
|
||||
{
|
||||
"public-policy": PUBLIC_ALLOW_POLICY,
|
||||
"deny-policy": MATCHING_DENY_POLICY,
|
||||
}
|
||||
)
|
||||
mock_make_api_call_matching_deny_and_public_allow = make_multiple_policies_api_mock(
|
||||
{
|
||||
"deny-policy": MATCHING_DENY_POLICY,
|
||||
"public-policy": PUBLIC_ALLOW_POLICY,
|
||||
}
|
||||
)
|
||||
mock_make_api_call_public_allow_and_unrelated_deny = make_multiple_policies_api_mock(
|
||||
{
|
||||
"public-policy": PUBLIC_ALLOW_POLICY,
|
||||
"deny-policy": UNRELATED_DENY_POLICY,
|
||||
}
|
||||
)
|
||||
mock_make_api_call_same_policy_allow_and_deny = make_multiple_policies_api_mock(
|
||||
{"combined-policy": PUBLIC_ALLOW_AND_DENY_POLICY}
|
||||
)
|
||||
mock_make_api_call_multiple_private_policies = make_multiple_policies_api_mock(
|
||||
{
|
||||
"private-policy-1": PRIVATE_ALLOW_POLICY,
|
||||
"private-policy-2": PRIVATE_ALLOW_POLICY,
|
||||
}
|
||||
)
|
||||
mock_make_api_call_public_single_statement = make_multiple_policies_api_mock(
|
||||
{"public-policy": PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY}
|
||||
)
|
||||
mock_make_api_call_private_single_statement = make_multiple_policies_api_mock(
|
||||
{"private-policy": PRIVATE_ALLOW_SINGLE_STATEMENT_POLICY}
|
||||
)
|
||||
mock_make_api_call_public_and_deny_single_statements = make_multiple_policies_api_mock(
|
||||
{
|
||||
"public-policy": PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY,
|
||||
"deny-policy": MATCHING_DENY_SINGLE_STATEMENT_POLICY,
|
||||
}
|
||||
)
|
||||
mock_make_api_call_conditional_single_statement = make_multiple_policies_api_mock(
|
||||
{"conditional-policy": CONDITIONAL_ALLOW_SINGLE_STATEMENT_POLICY}
|
||||
)
|
||||
|
||||
|
||||
def execute_check_with_api_mock(api_call_mock):
|
||||
with mock.patch("botocore.client.BaseClient._make_api_call", new=api_call_mock):
|
||||
client("sesv2", region_name=AWS_REGION_EU_WEST_1)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible.ses_client",
|
||||
new=SES(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible import (
|
||||
ses_identity_not_publicly_accessible,
|
||||
)
|
||||
|
||||
return ses_identity_not_publicly_accessible().execute()
|
||||
|
||||
|
||||
class Test_ses_identities_not_publicly_accessible:
|
||||
@mock_aws
|
||||
def test_no_identities(self):
|
||||
@@ -114,6 +223,114 @@ class Test_ses_identities_not_publicly_accessible:
|
||||
assert result[0].resource_tags == {"tag1": "value1", "tag2": "value2"}
|
||||
assert result[0].region == AWS_REGION_EU_WEST_1
|
||||
|
||||
@mock_aws
|
||||
@pytest.mark.parametrize(
|
||||
"api_call_mock",
|
||||
[
|
||||
mock_make_api_call_multiple_policies,
|
||||
mock_make_api_call_multiple_policies_reversed,
|
||||
],
|
||||
ids=["public-policy-first", "public-policy-last"],
|
||||
)
|
||||
def test_email_identity_public_when_any_policy_is_public(self, api_call_mock):
|
||||
result = execute_check_with_api_mock(api_call_mock)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "SES identity test-email-identity-multiple-policies is publicly accessible due to its resource policies."
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
@pytest.mark.parametrize(
|
||||
"api_call_mock",
|
||||
[
|
||||
mock_make_api_call_public_allow_and_matching_deny,
|
||||
mock_make_api_call_matching_deny_and_public_allow,
|
||||
mock_make_api_call_public_allow_and_unrelated_deny,
|
||||
mock_make_api_call_same_policy_allow_and_deny,
|
||||
],
|
||||
ids=[
|
||||
"matching-deny-last",
|
||||
"matching-deny-first",
|
||||
"unrelated-deny",
|
||||
"same-policy-deny",
|
||||
],
|
||||
)
|
||||
def test_email_identity_public_allow_with_explicit_deny_is_manual(
|
||||
self, api_call_mock
|
||||
):
|
||||
result = execute_check_with_api_mock(api_call_mock)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "SES identity test-email-identity-multiple-policies has public Allow and explicit Deny statements in its resource policies. Effective public access requires manual review."
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_email_identity_multiple_private_policies(self):
|
||||
result = execute_check_with_api_mock(
|
||||
mock_make_api_call_multiple_private_policies
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "SES identity test-email-identity-multiple-policies is not publicly accessible."
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
@pytest.mark.parametrize(
|
||||
("api_call_mock", "expected_status"),
|
||||
[
|
||||
(mock_make_api_call_public_single_statement, "FAIL"),
|
||||
(mock_make_api_call_private_single_statement, "PASS"),
|
||||
(mock_make_api_call_public_and_deny_single_statements, "MANUAL"),
|
||||
],
|
||||
ids=["public", "private", "public-with-deny"],
|
||||
)
|
||||
def test_email_identity_single_statement_policy(
|
||||
self, api_call_mock, expected_status
|
||||
):
|
||||
result = execute_check_with_api_mock(api_call_mock)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == expected_status
|
||||
|
||||
@mock_aws
|
||||
def test_check_preserves_nested_policy_condition_keys(self):
|
||||
with mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=mock_make_api_call_conditional_single_statement,
|
||||
):
|
||||
client("sesv2", region_name=AWS_REGION_EU_WEST_1)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ses_client = SES(aws_provider)
|
||||
identity = next(iter(ses_client.email_identities.values()))
|
||||
policies_before_check = deepcopy(identity.policies)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible.ses_client",
|
||||
new=ses_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible import (
|
||||
ses_identity_not_publicly_accessible,
|
||||
)
|
||||
|
||||
ses_identity_not_publicly_accessible().execute()
|
||||
|
||||
assert identity.policies == policies_before_check
|
||||
|
||||
@mock_aws
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call_v2)
|
||||
def test_email_identity_public(self):
|
||||
@@ -140,7 +357,7 @@ class Test_ses_identities_not_publicly_accessible:
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "SES identity test-email-identity-public is publicly accessible due to its resource policy."
|
||||
== "SES identity test-email-identity-public is publicly accessible due to its resource policies."
|
||||
)
|
||||
assert result[0].resource_id == "test-email-identity-public"
|
||||
assert (
|
||||
|
||||
@@ -27,6 +27,7 @@ def mock_make_api_call(self, operation_name, kwarg):
|
||||
return {
|
||||
"Policies": {
|
||||
"policy1": '{"policy1": "value1"}',
|
||||
"policy2": '{"policy2": "value2"}',
|
||||
},
|
||||
"Tags": {"tag1": "value1", "tag2": "value2"},
|
||||
"DkimAttributes": {
|
||||
@@ -81,7 +82,11 @@ class Test_SES_Service:
|
||||
assert ses.email_identities[arn].type == "EMAIL_ADDRESS"
|
||||
assert ses.email_identities[arn].arn == arn
|
||||
assert ses.email_identities[arn].region == AWS_REGION_EU_WEST_1
|
||||
assert ses.email_identities[arn].policy == {"policy1": "value1"}
|
||||
assert ses.email_identities[arn].policy == {"policy2": "value2"}
|
||||
assert ses.email_identities[arn].policies == {
|
||||
"policy1": {"policy1": "value1"},
|
||||
"policy2": {"policy2": "value2"},
|
||||
}
|
||||
assert ses.email_identities[arn].tags == {"tag1": "value1", "tag2": "value2"}
|
||||
assert ses.email_identities[arn].dkim_status == "SUCCESS"
|
||||
assert ses.email_identities[arn].dkim_signing_attributes_origin == "AWS_SES"
|
||||
|
||||
@@ -490,6 +490,19 @@ def mock_api_projects_calls(client: MagicMock):
|
||||
}
|
||||
client.projects().serviceAccounts().list_next.return_value = None
|
||||
|
||||
# Workload Identity Federation pools/providers: return empty pages and stop
|
||||
# pagination so the discovery while-loops in the IAM service terminate.
|
||||
client.projects().locations().workloadIdentityPools().list().execute.return_value = {
|
||||
"workloadIdentityPools": []
|
||||
}
|
||||
client.projects().locations().workloadIdentityPools().list_next.return_value = None
|
||||
client.projects().locations().workloadIdentityPools().providers().list().execute.return_value = {
|
||||
"workloadIdentityPoolProviders": []
|
||||
}
|
||||
client.projects().locations().workloadIdentityPools().providers().list_next.return_value = (
|
||||
None
|
||||
)
|
||||
|
||||
def mock_list_service_accounts_keys(name):
|
||||
return_value = MagicMock()
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
from unittest import mock
|
||||
|
||||
from tests.providers.gcp.gcp_fixtures import (
|
||||
GCP_PROJECT_ID,
|
||||
GCP_US_CENTER1_LOCATION,
|
||||
set_mocked_gcp_provider,
|
||||
)
|
||||
|
||||
CHECK_MODULE = "prowler.providers.gcp.services.iam.iam_workload_identity_pool_provider_attribute_condition.iam_workload_identity_pool_provider_attribute_condition"
|
||||
|
||||
|
||||
def _run(provider_kwargs):
|
||||
iam_client = mock.MagicMock()
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_gcp_provider(),
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam_client),
|
||||
):
|
||||
from prowler.providers.gcp.services.iam.iam_service import (
|
||||
WorkloadIdentityPoolProvider,
|
||||
)
|
||||
from prowler.providers.gcp.services.iam.iam_workload_identity_pool_provider_attribute_condition.iam_workload_identity_pool_provider_attribute_condition import (
|
||||
iam_workload_identity_pool_provider_attribute_condition,
|
||||
)
|
||||
|
||||
providers = []
|
||||
for kwargs in provider_kwargs:
|
||||
provider_id = kwargs.get("provider_id", "my-provider")
|
||||
providers.append(
|
||||
WorkloadIdentityPoolProvider(
|
||||
name=(
|
||||
f"projects/{GCP_PROJECT_ID}/locations/global/"
|
||||
f"workloadIdentityPools/my-pool/providers/{provider_id}"
|
||||
),
|
||||
id=provider_id,
|
||||
pool_id="my-pool",
|
||||
pool_disabled=kwargs.get("pool_disabled", False),
|
||||
project_id=GCP_PROJECT_ID,
|
||||
state=kwargs.get("state", "ACTIVE"),
|
||||
disabled=kwargs.get("disabled", False),
|
||||
attribute_condition=kwargs.get("attribute_condition", ""),
|
||||
provider_type=kwargs.get("provider_type", "oidc"),
|
||||
issuer_uri=kwargs.get(
|
||||
"issuer_uri",
|
||||
"https://token.actions.githubusercontent.com",
|
||||
),
|
||||
display_name="My Provider",
|
||||
)
|
||||
)
|
||||
|
||||
iam_client.project_ids = [GCP_PROJECT_ID]
|
||||
iam_client.region = GCP_US_CENTER1_LOCATION
|
||||
iam_client.workload_identity_pool_providers = providers
|
||||
return iam_workload_identity_pool_provider_attribute_condition().execute()
|
||||
|
||||
|
||||
class Test_iam_workload_identity_pool_provider_attribute_condition:
|
||||
def test_no_providers(self):
|
||||
assert len(_run([])) == 0
|
||||
|
||||
def test_multi_tenant_issuer_without_attribute_condition_fails(self):
|
||||
result = _run(
|
||||
[
|
||||
{
|
||||
"attribute_condition": "",
|
||||
"issuer_uri": "https://token.actions.githubusercontent.com",
|
||||
}
|
||||
]
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].resource_id.endswith("my-provider")
|
||||
assert result[0].location == "global"
|
||||
assert "multi-tenant issuer" in result[0].status_extended
|
||||
|
||||
def test_dedicated_issuer_without_attribute_condition_passes(self):
|
||||
result = _run(
|
||||
[
|
||||
{
|
||||
"attribute_condition": "",
|
||||
"issuer_uri": "https://oidc.eks.eu-west-1.amazonaws.com/id/ABC123",
|
||||
}
|
||||
]
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "dedicated issuer" in result[0].status_extended
|
||||
|
||||
def test_non_oidc_provider_without_attribute_condition_passes(self):
|
||||
result = _run(
|
||||
[{"attribute_condition": "", "provider_type": "aws", "issuer_uri": ""}]
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "not an OIDC provider" in result[0].status_extended
|
||||
|
||||
def test_multi_tenant_issuer_with_port_and_uppercase_fails(self):
|
||||
result = _run(
|
||||
[{"attribute_condition": "", "issuer_uri": "https://GitLab.com:443"}]
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_multi_tenant_issuer_bare_host_fails(self):
|
||||
result = _run(
|
||||
[
|
||||
{
|
||||
"attribute_condition": "",
|
||||
"issuer_uri": "token.actions.githubusercontent.com",
|
||||
}
|
||||
]
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_multi_tenant_issuer_with_attribute_condition_passes(self):
|
||||
result = _run(
|
||||
[
|
||||
{
|
||||
"attribute_condition": "assertion.repository_owner == 'acme'",
|
||||
"issuer_uri": "https://token.actions.githubusercontent.com",
|
||||
}
|
||||
]
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "enforces an attribute condition" in result[0].status_extended
|
||||
|
||||
def test_disabled_provider_passes(self):
|
||||
result = _run([{"disabled": True}])
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "not active" in result[0].status_extended
|
||||
|
||||
def test_non_active_provider_passes(self):
|
||||
result = _run([{"state": "DELETED"}])
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_active_provider_in_disabled_pool_passes(self):
|
||||
# The provider itself is ACTIVE and unconditioned on a multi-tenant
|
||||
# issuer, but its parent pool is disabled and cannot vend credentials.
|
||||
result = _run([{"pool_disabled": True}])
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "disabled pool" in result[0].status_extended
|
||||
@@ -0,0 +1,215 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tests.providers.gcp.gcp_fixtures import (
|
||||
GCP_PROJECT_ID,
|
||||
mock_is_api_active,
|
||||
set_mocked_gcp_provider,
|
||||
)
|
||||
|
||||
PROJECT_A = GCP_PROJECT_ID
|
||||
PROJECT_B = "test-project-b"
|
||||
|
||||
|
||||
def _pool_name(project_id, pool_id="my-pool"):
|
||||
return f"projects/{project_id}/locations/global/workloadIdentityPools/{pool_id}"
|
||||
|
||||
|
||||
def _provider_payload(pool_name, provider_id="my-provider"):
|
||||
return {
|
||||
"name": f"{pool_name}/providers/{provider_id}",
|
||||
"state": "ACTIVE",
|
||||
"disabled": False,
|
||||
"attributeMapping": {"google.subject": "assertion.sub"},
|
||||
"oidc": {"issuerUri": "https://token.actions.githubusercontent.com"},
|
||||
"displayName": "gh",
|
||||
}
|
||||
|
||||
|
||||
def _empty_service_accounts(client):
|
||||
"""Stub the service-account calls used by the rest of the IAM __init__."""
|
||||
sa = client.projects.return_value.serviceAccounts.return_value
|
||||
sa.list.return_value.execute.return_value = {"accounts": []}
|
||||
sa.list_next.return_value = None
|
||||
|
||||
|
||||
def _wif_client(_GCPService, _service, _api_version, _credentials):
|
||||
"""Discovery client stub returning one pool with one provider."""
|
||||
client = MagicMock()
|
||||
|
||||
pool_name = _pool_name(GCP_PROJECT_ID)
|
||||
pools = (
|
||||
client.projects.return_value.locations.return_value.workloadIdentityPools.return_value
|
||||
)
|
||||
pools.list.return_value.execute.return_value = {
|
||||
"workloadIdentityPools": [{"name": pool_name, "state": "ACTIVE"}]
|
||||
}
|
||||
pools.list_next.return_value = None
|
||||
|
||||
providers = pools.providers.return_value
|
||||
providers.list.return_value.execute.return_value = {
|
||||
"workloadIdentityPoolProviders": [_provider_payload(pool_name)]
|
||||
}
|
||||
providers.list_next.return_value = None
|
||||
|
||||
_empty_service_accounts(client)
|
||||
return client
|
||||
|
||||
|
||||
def _disabled_pool_client(_GCPService, _service, _api_version, _credentials):
|
||||
"""Discovery client stub: a disabled pool containing an ACTIVE provider."""
|
||||
client = MagicMock()
|
||||
|
||||
pool_name = _pool_name(GCP_PROJECT_ID)
|
||||
pools = (
|
||||
client.projects.return_value.locations.return_value.workloadIdentityPools.return_value
|
||||
)
|
||||
pools.list.return_value.execute.return_value = {
|
||||
"workloadIdentityPools": [
|
||||
{"name": pool_name, "state": "ACTIVE", "disabled": True}
|
||||
]
|
||||
}
|
||||
pools.list_next.return_value = None
|
||||
|
||||
providers = pools.providers.return_value
|
||||
providers.list.return_value.execute.return_value = {
|
||||
"workloadIdentityPoolProviders": [_provider_payload(pool_name)]
|
||||
}
|
||||
providers.list_next.return_value = None
|
||||
|
||||
_empty_service_accounts(client)
|
||||
return client
|
||||
|
||||
|
||||
def _pool_list_failure_client(_GCPService, _service, _api_version, _credentials):
|
||||
"""Pool listing fails for PROJECT_A but succeeds for PROJECT_B."""
|
||||
client = MagicMock()
|
||||
pool_name_b = _pool_name(PROJECT_B)
|
||||
|
||||
pools = (
|
||||
client.projects.return_value.locations.return_value.workloadIdentityPools.return_value
|
||||
)
|
||||
|
||||
def pools_list(parent):
|
||||
request = MagicMock()
|
||||
if f"projects/{PROJECT_A}/" in parent:
|
||||
request.execute.side_effect = Exception("permission denied listing pools")
|
||||
else:
|
||||
request.execute.return_value = {
|
||||
"workloadIdentityPools": [{"name": pool_name_b, "state": "ACTIVE"}]
|
||||
}
|
||||
return request
|
||||
|
||||
pools.list.side_effect = pools_list
|
||||
pools.list_next.return_value = None
|
||||
|
||||
providers = pools.providers.return_value
|
||||
providers.list.return_value.execute.return_value = {
|
||||
"workloadIdentityPoolProviders": [_provider_payload(pool_name_b)]
|
||||
}
|
||||
providers.list_next.return_value = None
|
||||
|
||||
_empty_service_accounts(client)
|
||||
return client
|
||||
|
||||
|
||||
def _provider_list_failure_client(_GCPService, _service, _api_version, _credentials):
|
||||
"""Provider listing fails for pool-1 but succeeds for pool-2 in one project."""
|
||||
client = MagicMock()
|
||||
pool_1 = _pool_name(GCP_PROJECT_ID, "pool-1")
|
||||
pool_2 = _pool_name(GCP_PROJECT_ID, "pool-2")
|
||||
|
||||
pools = (
|
||||
client.projects.return_value.locations.return_value.workloadIdentityPools.return_value
|
||||
)
|
||||
pools.list.return_value.execute.return_value = {
|
||||
"workloadIdentityPools": [
|
||||
{"name": pool_1, "state": "ACTIVE"},
|
||||
{"name": pool_2, "state": "ACTIVE"},
|
||||
]
|
||||
}
|
||||
pools.list_next.return_value = None
|
||||
|
||||
providers = pools.providers.return_value
|
||||
|
||||
def providers_list(parent):
|
||||
request = MagicMock()
|
||||
if parent == pool_1:
|
||||
request.execute.side_effect = Exception(
|
||||
"permission denied listing providers"
|
||||
)
|
||||
else:
|
||||
request.execute.return_value = {
|
||||
"workloadIdentityPoolProviders": [
|
||||
_provider_payload(pool_2, provider_id="provider-2")
|
||||
]
|
||||
}
|
||||
return request
|
||||
|
||||
providers.list.side_effect = providers_list
|
||||
providers.list_next.return_value = None
|
||||
|
||||
_empty_service_accounts(client)
|
||||
return client
|
||||
|
||||
|
||||
def _run_service(client_factory, project_ids):
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_gcp_provider(project_ids=project_ids),
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__",
|
||||
new=mock_is_api_active,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
|
||||
new=client_factory,
|
||||
),
|
||||
):
|
||||
from prowler.providers.gcp.services.iam.iam_service import IAM
|
||||
|
||||
return IAM(set_mocked_gcp_provider(project_ids=project_ids))
|
||||
|
||||
|
||||
class TestIAMWorkloadIdentityService:
|
||||
def test_get_workload_identity_pool_providers(self):
|
||||
iam = _run_service(_wif_client, [GCP_PROJECT_ID])
|
||||
|
||||
assert len(iam.workload_identity_pool_providers) == 1
|
||||
provider = iam.workload_identity_pool_providers[0]
|
||||
assert provider.id == "my-provider"
|
||||
assert provider.pool_id == "my-pool"
|
||||
assert provider.project_id == GCP_PROJECT_ID
|
||||
assert provider.state == "ACTIVE"
|
||||
assert provider.disabled is False
|
||||
assert provider.pool_disabled is False
|
||||
assert provider.attribute_condition == ""
|
||||
assert provider.provider_type == "oidc"
|
||||
assert provider.issuer_uri == "https://token.actions.githubusercontent.com"
|
||||
|
||||
def test_disabled_pool_state_propagates_to_provider(self):
|
||||
iam = _run_service(_disabled_pool_client, [GCP_PROJECT_ID])
|
||||
|
||||
# The provider is ACTIVE, but its parent pool is disabled: the pool's
|
||||
# effective state must travel with the provider record.
|
||||
assert len(iam.workload_identity_pool_providers) == 1
|
||||
provider = iam.workload_identity_pool_providers[0]
|
||||
assert provider.state == "ACTIVE"
|
||||
assert provider.disabled is False
|
||||
assert provider.pool_disabled is True
|
||||
|
||||
def test_pool_list_failure_does_not_block_other_projects(self):
|
||||
iam = _run_service(_pool_list_failure_client, [PROJECT_A, PROJECT_B])
|
||||
|
||||
# PROJECT_A's pool listing failed, but PROJECT_B is still processed.
|
||||
assert len(iam.workload_identity_pool_providers) == 1
|
||||
assert iam.workload_identity_pool_providers[0].project_id == PROJECT_B
|
||||
|
||||
def test_provider_list_failure_only_skips_that_pool(self):
|
||||
iam = _run_service(_provider_list_failure_client, [GCP_PROJECT_ID])
|
||||
|
||||
# pool-1's provider listing failed, but pool-2's provider is still found.
|
||||
assert len(iam.workload_identity_pool_providers) == 1
|
||||
assert iam.workload_identity_pool_providers[0].pool_id == "pool-2"
|
||||
assert iam.workload_identity_pool_providers[0].id == "provider-2"
|
||||
@@ -0,0 +1,202 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from util.check_yanked_pins import (
|
||||
Pin,
|
||||
collect_pins,
|
||||
evaluate,
|
||||
main,
|
||||
normalize,
|
||||
pins_from_pyproject,
|
||||
pins_from_uv_lock,
|
||||
)
|
||||
|
||||
PYPROJECT = """
|
||||
[project]
|
||||
name = "demo"
|
||||
dependencies = [
|
||||
"cryptography==48.0.1",
|
||||
"alibabacloud_tea_openapi==0.4.5",
|
||||
"Requests[security]==2.34.2 ; python_version >= '3.10'",
|
||||
"boto3>=1.40",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
extra = ["okta==3.4.2"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest==9.0.3", {include-group = "lint"}]
|
||||
lint = ["flake8==7.1.2"]
|
||||
|
||||
[tool.uv]
|
||||
constraint-dependencies = ["zstd==1.5.7.3"]
|
||||
override-dependencies = ["okta==3.4.2"]
|
||||
"""
|
||||
|
||||
UV_LOCK = """
|
||||
version = 1
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "1.5.7.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
|
||||
[[package]]
|
||||
name = "Cryptography"
|
||||
version = "48.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.40.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#abc" }
|
||||
|
||||
[[package]]
|
||||
name = "demo"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
"""
|
||||
|
||||
|
||||
class TestNormalize:
|
||||
"""normalize() applies PEP 503 so spellings of one project compare equal."""
|
||||
|
||||
def test_pep503_equivalence(self):
|
||||
"""Underscores, dots and case collapse to the canonical dashed lowercase form."""
|
||||
assert normalize("alibabacloud_tea_openapi") == "alibabacloud-tea-openapi"
|
||||
assert normalize("Requests") == "requests"
|
||||
assert normalize("zope.interface") == "zope-interface"
|
||||
|
||||
|
||||
class TestPinsFromPyproject:
|
||||
"""pins_from_pyproject() reads exact pins from every dependency-bearing table."""
|
||||
|
||||
def test_collects_exact_pins_from_every_table(self):
|
||||
"""Dependencies, extras, dependency groups and both [tool.uv] lists are covered."""
|
||||
pins = pins_from_pyproject(PYPROJECT, "")
|
||||
assert {(p.name, p.version) for p in pins} == {
|
||||
("cryptography", "48.0.1"),
|
||||
("alibabacloud-tea-openapi", "0.4.5"),
|
||||
("requests", "2.34.2"),
|
||||
("okta", "3.4.2"),
|
||||
("pytest", "9.0.3"),
|
||||
("flake8", "7.1.2"),
|
||||
("zstd", "1.5.7.3"),
|
||||
}
|
||||
|
||||
def test_ignores_ranges_and_records_source_table(self):
|
||||
"""Non-exact specifiers are skipped and each pin remembers its table."""
|
||||
pins = pins_from_pyproject(PYPROJECT, "api/")
|
||||
names = {p.name for p in pins}
|
||||
assert "boto3" not in names
|
||||
zstd = next(p for p in pins if p.name == "zstd")
|
||||
assert zstd.source == "api/pyproject.toml [tool.uv.constraint-dependencies]"
|
||||
|
||||
def test_same_pin_in_two_tables_keeps_both_sources(self):
|
||||
"""The same version in two tables yields two pins, one per source."""
|
||||
okta = {
|
||||
p.source for p in pins_from_pyproject(PYPROJECT, "") if p.name == "okta"
|
||||
}
|
||||
assert okta == {
|
||||
"pyproject.toml [project.optional-dependencies.extra]",
|
||||
"pyproject.toml [tool.uv.override-dependencies]",
|
||||
}
|
||||
|
||||
|
||||
class TestPinsFromUvLock:
|
||||
"""pins_from_uv_lock() reads locked versions that live on a registry."""
|
||||
|
||||
def test_only_registry_packages(self):
|
||||
"""git, path and editable sources are not on PyPI and are skipped."""
|
||||
pins = pins_from_uv_lock(UV_LOCK, "")
|
||||
assert {(p.name, p.version) for p in pins} == {
|
||||
("zstd", "1.5.7.3"),
|
||||
("cryptography", "48.0.1"),
|
||||
}
|
||||
assert all(p.source == "uv.lock" for p in pins)
|
||||
|
||||
|
||||
class TestCollectPins:
|
||||
"""collect_pins() merges a project's pyproject.toml and uv.lock."""
|
||||
|
||||
def test_missing_files_raise(self, tmp_path: Path):
|
||||
"""A directory with neither file is a caller error, not an empty result."""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
collect_pins(tmp_path)
|
||||
|
||||
def test_merges_pyproject_and_lock(self, tmp_path: Path):
|
||||
"""Pins from both files are returned with the directory as source prefix."""
|
||||
(tmp_path / "pyproject.toml").write_text(PYPROJECT)
|
||||
(tmp_path / "uv.lock").write_text(UV_LOCK)
|
||||
sources = {p.source for p in collect_pins(tmp_path)}
|
||||
prefix = f"{tmp_path.as_posix()}/"
|
||||
assert f"{prefix}uv.lock" in sources
|
||||
assert f"{prefix}pyproject.toml [project.dependencies]" in sources
|
||||
|
||||
|
||||
class TestEvaluate:
|
||||
"""evaluate() queries PyPI once per release and reports per pin."""
|
||||
|
||||
def test_queries_each_release_once_and_fans_out_to_every_source(self):
|
||||
"""One fetch per (name, version); its verdict reaches every source of that pin."""
|
||||
calls = []
|
||||
|
||||
def fake_fetch(name, version):
|
||||
"""Stand-in for fetch_release() that records calls and returns fixed verdicts."""
|
||||
calls.append((name, version))
|
||||
if (name, version) == ("zstd", "1.5.7.3"):
|
||||
return "yanked", "buggy - not thread safe"
|
||||
if (name, version) == ("gone", "0.0.1"):
|
||||
return "missing", "not found on PyPI"
|
||||
return "ok", ""
|
||||
|
||||
pins = {
|
||||
Pin("zstd", "1.5.7.3", "pyproject.toml [tool.uv.constraint-dependencies]"),
|
||||
Pin("zstd", "1.5.7.3", "uv.lock"),
|
||||
Pin("cryptography", "48.0.1", "uv.lock"),
|
||||
Pin("gone", "0.0.1", "uv.lock"),
|
||||
}
|
||||
verdicts = evaluate(pins, fetch=fake_fetch, workers=2)
|
||||
|
||||
assert sorted(calls) == [
|
||||
("cryptography", "48.0.1"),
|
||||
("gone", "0.0.1"),
|
||||
("zstd", "1.5.7.3"),
|
||||
]
|
||||
by_status = {}
|
||||
for verdict in verdicts:
|
||||
by_status.setdefault(verdict.status, []).append(verdict.pin)
|
||||
assert len(by_status["yanked"]) == 2
|
||||
assert {p.source for p in by_status["yanked"]} == {
|
||||
"pyproject.toml [tool.uv.constraint-dependencies]",
|
||||
"uv.lock",
|
||||
}
|
||||
assert by_status["missing"] == [Pin("gone", "0.0.1", "uv.lock")]
|
||||
assert by_status["ok"] == [Pin("cryptography", "48.0.1", "uv.lock")]
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""main() turns verdicts into a process exit code and annotations."""
|
||||
|
||||
def test_exit_code_reflects_verdicts(self, tmp_path: Path, monkeypatch, capsys):
|
||||
"""0 when every pin is ok, 1 plus a ::error:: line when one is yanked."""
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[project]\ndependencies = ["zstd==1.5.7.3"]\n'
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"util.check_yanked_pins.fetch_release",
|
||||
lambda name, version, retries=3: ("ok", ""),
|
||||
)
|
||||
assert main([str(tmp_path)]) == 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
"util.check_yanked_pins.fetch_release",
|
||||
lambda name, version, retries=3: ("yanked", "buggy - not thread safe"),
|
||||
)
|
||||
assert main([str(tmp_path)]) == 1
|
||||
assert (
|
||||
"::error::zstd==1.5.7.3 is yanked (buggy - not thread safe)"
|
||||
in capsys.readouterr().out
|
||||
)
|
||||
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* Fixture data for the Slack handlers. Shapes follow the API contract in
|
||||
* `openspec/changes/add-slack-integration/design.md`.
|
||||
*/
|
||||
|
||||
export interface SlackWorkspaceFixture {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
botUserId: string;
|
||||
/**
|
||||
* Absent from the serialized configuration until a channel is chosen: the API
|
||||
* omits the keys rather than sending nulls.
|
||||
*/
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
}
|
||||
|
||||
export interface SlackInstallFixture {
|
||||
id: string;
|
||||
/** `null` until the first connection check runs. */
|
||||
connected: boolean | null;
|
||||
connectionLastCheckedAt: string | null;
|
||||
workspace: SlackWorkspaceFixture;
|
||||
}
|
||||
|
||||
export const SLACK_EXCHANGE_OUTCOME = {
|
||||
CREATED: "created",
|
||||
/** Same workspace re-installed: the existing row keeps its id. */
|
||||
REINSTALLED: "reinstalled",
|
||||
REFUSED_STATE: "refused-state",
|
||||
SLACK_REFUSED: "slack-refused",
|
||||
/** A `409` named by its `code`: one workspace per tenant. */
|
||||
DIFFERENT_WORKSPACE: "different-workspace",
|
||||
/**
|
||||
* The three below are `2xx`: the install happened, but the answer is
|
||||
* unreadable, so nothing on the failure path sees them.
|
||||
*/
|
||||
UNREADABLE_NO_CONTENT: "unreadable-no-content",
|
||||
UNREADABLE_HTML: "unreadable-html",
|
||||
UNREADABLE_NO_DATA: "unreadable-no-data",
|
||||
} as const;
|
||||
|
||||
export type SlackExchangeOutcome =
|
||||
(typeof SLACK_EXCHANGE_OUTCOME)[keyof typeof SLACK_EXCHANGE_OUTCOME];
|
||||
|
||||
export interface SlackConnectionFixture {
|
||||
connected: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** A channel the listing endpoint offers for the picker. */
|
||||
export interface SlackChannelFixture {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Private channels are listed only where `@Prowler` has been invited. */
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
export interface SlackTestMessageFixture {
|
||||
accepted: boolean;
|
||||
/**
|
||||
* Why it did not: the reason `code` would carry, or prose — the contract
|
||||
* leaves the task result's shape open.
|
||||
*/
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal as the API sends one: the machine-readable reason in `code`, human
|
||||
* copy in `detail`, and — for a `429` — the wait in `Retry-After`.
|
||||
*/
|
||||
export interface SlackRefusalFixture {
|
||||
status: number;
|
||||
/** Slack's stable reason. `null` for the failures classified by status. */
|
||||
code: string | null;
|
||||
detail: string;
|
||||
/** Seconds `Retry-After` asked for; only a `429` carries one. */
|
||||
retryAfterSeconds: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `DELETE /integrations/{id}` reports about revoking the token at Slack.
|
||||
* Revocation is best-effort: the row goes either way, and the outcome travels in
|
||||
* JSON:API `meta`.
|
||||
*
|
||||
* One boolean is the whole of it: the API sends no reason for a revocation that
|
||||
* did not happen, so modelling one would let a test prove copy the real
|
||||
* deployment can never produce.
|
||||
*/
|
||||
export interface SlackRevocationFixture {
|
||||
/**
|
||||
* Slack confirmed the token no longer grants Prowler anything. `null` when the
|
||||
* answer reports nothing at all — the plain `204` a deployment without a
|
||||
* `destroy` override sends, which is what the UI meets today.
|
||||
*/
|
||||
revoked: boolean | null;
|
||||
}
|
||||
|
||||
export interface SlackFixture {
|
||||
/**
|
||||
* The deployment has `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` /
|
||||
* `SLACK_REDIRECT_URI`. Without them every Slack OAuth call answers `503`.
|
||||
*/
|
||||
appConfigured: boolean;
|
||||
install: SlackInstallFixture | null;
|
||||
exchangeWorkspace: SlackWorkspaceFixture;
|
||||
exchangeOutcome: SlackExchangeOutcome;
|
||||
connection: SlackConnectionFixture;
|
||||
/** The Slack OAuth calls answer `429` with a `Retry-After`. */
|
||||
rateLimited: boolean;
|
||||
/**
|
||||
* The shared `GET /integrations` read answers `500`, which the UI's own
|
||||
* helper turns into a thrown error rather than a result.
|
||||
*/
|
||||
listServerError: boolean;
|
||||
/** The consent-URL call answers `200` with a proxy's HTML page, not JSON. */
|
||||
authorizeUrlUnreadable: boolean;
|
||||
/**
|
||||
* Both Slack OAuth calls answer `502`, the contract's status for upstream and
|
||||
* transport failures. Distinct from `appConfigured: false`, which is a `503`.
|
||||
*/
|
||||
oauthUpstreamError: boolean;
|
||||
channels: SlackChannelFixture[];
|
||||
/**
|
||||
* Small on purpose: the default workspace spans two pages, so a UI that
|
||||
* stopped at `data` instead of following `links.next` would lose channels.
|
||||
*/
|
||||
channelsPageSize: number;
|
||||
/** Slack refused the listing outright, with the reason named in `code`. */
|
||||
channelsRefusal: SlackRefusalFixture | null;
|
||||
/**
|
||||
* The cursor the refusal starts at. Absent, the whole read fails; a page
|
||||
* size serves the first page and refuses the second — the partial read.
|
||||
*/
|
||||
channelsRefusalFromCursor?: number;
|
||||
/**
|
||||
* Slack refused the chosen channel when the `PATCH` validated it — the
|
||||
* listing itself answered fine.
|
||||
*/
|
||||
channelSaveRefusal: SlackRefusalFixture | null;
|
||||
testMessage: SlackTestMessageFixture;
|
||||
revocation: SlackRevocationFixture;
|
||||
}
|
||||
|
||||
/**
|
||||
* A UUID, as the API's ids are: it travels in the URL of every Slack call and
|
||||
* the actions accept no other shape.
|
||||
*/
|
||||
export const SLACK_INTEGRATION_ID = "7c9e6a1b-2d3f-4e5a-8b6c-9d0e1f2a3b4c";
|
||||
|
||||
/** The scopes the channel picker and the posting need (design D2). */
|
||||
export const SLACK_BOT_SCOPES = [
|
||||
"chat:write",
|
||||
"chat:write.public",
|
||||
"channels:read",
|
||||
"groups:read",
|
||||
] as const;
|
||||
|
||||
export const SLACK_REDIRECT_URI =
|
||||
"https://cloud.prowler.com/integrations/slack/callback";
|
||||
|
||||
/** Server-minted, single-use, bound to the tenant and user (design D5). */
|
||||
export const SLACK_OAUTH_STATE = "st-2f1c9d7a";
|
||||
export const SLACK_OAUTH_CODE = "slack-code-1f4a";
|
||||
|
||||
export const SLACK_AUTHORIZE_URL =
|
||||
"https://slack.com/oauth/v2/authorize" +
|
||||
"?client_id=1234567890.0987654321" +
|
||||
`&scope=${encodeURIComponent(SLACK_BOT_SCOPES.join(","))}` +
|
||||
`&state=${SLACK_OAUTH_STATE}` +
|
||||
`&redirect_uri=${encodeURIComponent(SLACK_REDIRECT_URI)}`;
|
||||
|
||||
/**
|
||||
* The `detail` strings the implementation sends. Human copy; the
|
||||
* machine-readable reason travels in `code`, which is what the UI maps.
|
||||
*/
|
||||
export const SLACK_UNCONFIGURED_DETAIL =
|
||||
"Slack integration is not configured or temporarily unavailable.";
|
||||
export const SLACK_REFUSED_STATE_DETAIL =
|
||||
"OAuth state is invalid, expired, or already consumed.";
|
||||
export const SLACK_INVALID_CODE_DETAIL = "The Slack OAuth code is invalid.";
|
||||
export const SLACK_DIFFERENT_WORKSPACE_DETAIL =
|
||||
"This tenant is already connected to a different Slack workspace.";
|
||||
export const SLACK_UPSTREAM_DETAIL = "Slack is temporarily unavailable.";
|
||||
/**
|
||||
* The `code` on the contract's `502`. The UI maps no copy of its own to it, so
|
||||
* the `detail` is what reaches the user.
|
||||
*/
|
||||
export const SLACK_UPSTREAM_ERROR_CODE = "service_unavailable";
|
||||
/**
|
||||
* Raised as a `ValidationError({"channel_id": ...})` that still points at
|
||||
* `/data` rather than at the attribute.
|
||||
*/
|
||||
export const SLACK_NO_CHANNEL_DETAIL =
|
||||
"This Slack integration has no channel configured.";
|
||||
export const SLACK_RATE_LIMITED_DETAIL =
|
||||
"Slack is rate limiting requests from Prowler.";
|
||||
/**
|
||||
* What a `500` from the shared `GET /integrations` read carries. Nothing here
|
||||
* is for the user to act on, so the UI answers a server error in its own words.
|
||||
*/
|
||||
export const INTEGRATIONS_SERVER_ERROR_DETAIL = "A server error occurred.";
|
||||
export const SLACK_MISSING_SCOPE_DETAIL =
|
||||
"Slack refused the request: missing_scope.";
|
||||
/**
|
||||
* Names the raw reason, as the missing-scope wording does: what lets a test tell
|
||||
* copy the UI mapped from `code` apart from an echoed `detail`.
|
||||
*/
|
||||
export const SLACK_TOKEN_EXPIRED_DETAIL =
|
||||
"Slack refused the request: token_expired.";
|
||||
/**
|
||||
* The same sentence for "it is gone" and "the app was removed from it": only
|
||||
* `code` separates them, which is why a client must read `code`.
|
||||
*/
|
||||
export const SLACK_UNKNOWN_CHANNEL_DETAIL =
|
||||
"That channel is not one Prowler can post to.";
|
||||
export const SLACK_NO_DEFAULT_CHANNEL_DETAIL =
|
||||
"No default channel is recorded on this integration.";
|
||||
/** A task result that reports the refusal as prose instead of as a reason. */
|
||||
export const SLACK_TEST_MESSAGE_REFUSED_DETAIL =
|
||||
"Slack rejected the message: the channel is archived.";
|
||||
|
||||
/**
|
||||
* A `200` challenge page from a proxy or WAF that took the call instead of the
|
||||
* API. V8 truncates the parser message for this body before the word `html`, so
|
||||
* the UI's own detection (`HTML_ERROR_PATTERN`) cannot recognise it either.
|
||||
*/
|
||||
export const PROXY_CHALLENGE_PAGE = [
|
||||
"<!DOCTYPE html>",
|
||||
"<html><head><title>Attention Required</title></head>",
|
||||
"<body><h1>Checking your browser before you proceed.</h1></body></html>",
|
||||
].join("\n");
|
||||
|
||||
/**
|
||||
* The `code` values the refusals below are named by. Wire values, spelled out
|
||||
* rather than imported from the UI's own mapping: a rename on our side must
|
||||
* fail these tests, not quietly agree with itself.
|
||||
*/
|
||||
export const SLACK_WORKSPACE_CONFLICT_CODE = "slack_workspace_conflict";
|
||||
export const SLACK_MISSING_SCOPE_CODE = "missing_scope";
|
||||
export const SLACK_CHANNEL_NOT_FOUND_CODE = "channel_not_found";
|
||||
export const SLACK_NOT_IN_CHANNEL_CODE = "not_in_channel";
|
||||
/**
|
||||
* A reason Slack really sends that the UI's mapping does not cover — the set is
|
||||
* open-ended, so having no copy for one is the ordinary case.
|
||||
*/
|
||||
export const SLACK_UNMAPPED_REASON_CODE = "is_archived";
|
||||
/**
|
||||
* Two of the four dead-grant codes the contract lists. Whichever call surfaces
|
||||
* one, the integration is disconnected and the only way out is connecting the
|
||||
* workspace again (contract, Cross-cutting).
|
||||
*/
|
||||
export const SLACK_TOKEN_REVOKED_CODE = "token_revoked";
|
||||
export const SLACK_TOKEN_EXPIRED_CODE = "token_expired";
|
||||
|
||||
export const SLACK_RETRY_AFTER_SECONDS = 30;
|
||||
|
||||
/** The install never granted a scope the call needs: actionable, so a `400`. */
|
||||
export const SLACK_MISSING_SCOPE_REFUSAL: SlackRefusalFixture = {
|
||||
status: 400,
|
||||
code: SLACK_MISSING_SCOPE_CODE,
|
||||
detail: SLACK_MISSING_SCOPE_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Where this really happens is the channel listing: `conversations.list` is
|
||||
* tier 2 and paginated.
|
||||
*/
|
||||
export const SLACK_RATE_LIMITED_REFUSAL: SlackRefusalFixture = {
|
||||
status: 429,
|
||||
code: null,
|
||||
detail: SLACK_RATE_LIMITED_DETAIL,
|
||||
retryAfterSeconds: SLACK_RETRY_AFTER_SECONDS,
|
||||
};
|
||||
|
||||
/**
|
||||
* The stored grant is no longer usable: a `400` like any other actionable
|
||||
* refusal, deliberately not the `401` that would read as an expired Prowler
|
||||
* session (contract, Errors).
|
||||
*/
|
||||
export const SLACK_TOKEN_EXPIRED_REFUSAL: SlackRefusalFixture = {
|
||||
status: 400,
|
||||
code: SLACK_TOKEN_EXPIRED_CODE,
|
||||
detail: SLACK_TOKEN_EXPIRED_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/** Slack-side or transport failure — a `502` naming no reason at all. */
|
||||
export const SLACK_UPSTREAM_REFUSAL: SlackRefusalFixture = {
|
||||
status: 502,
|
||||
code: null,
|
||||
detail: SLACK_UPSTREAM_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/** The chosen channel is archived, deleted, or was never in the workspace. */
|
||||
export const SLACK_CHANNEL_NOT_FOUND_REFUSAL: SlackRefusalFixture = {
|
||||
status: 400,
|
||||
code: SLACK_CHANNEL_NOT_FOUND_CODE,
|
||||
detail: SLACK_UNKNOWN_CHANNEL_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* The channel is fine, the Prowler app is simply not in it — fixed with
|
||||
* `/invite @Prowler`. Identical `detail` to the refusal above, deliberately.
|
||||
*/
|
||||
export const SLACK_NOT_IN_CHANNEL_REFUSAL: SlackRefusalFixture = {
|
||||
status: 400,
|
||||
code: SLACK_NOT_IN_CHANNEL_CODE,
|
||||
detail: SLACK_UNKNOWN_CHANNEL_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Two public channels and one private the Prowler app was invited to, ordered
|
||||
* so the private one lands on the second cursor page.
|
||||
*/
|
||||
export const SLACK_PUBLIC_CHANNEL: SlackChannelFixture = {
|
||||
id: "C0123AB",
|
||||
name: "security",
|
||||
isPrivate: false,
|
||||
};
|
||||
|
||||
export const SLACK_SECOND_PUBLIC_CHANNEL: SlackChannelFixture = {
|
||||
id: "C0789EF",
|
||||
name: "platform",
|
||||
isPrivate: false,
|
||||
};
|
||||
|
||||
export const SLACK_PRIVATE_CHANNEL: SlackChannelFixture = {
|
||||
id: "C0456CD",
|
||||
name: "security-alerts",
|
||||
isPrivate: true,
|
||||
};
|
||||
|
||||
export const SLACK_CHANNELS: SlackChannelFixture[] = [
|
||||
SLACK_PUBLIC_CHANNEL,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL,
|
||||
SLACK_PRIVATE_CHANNEL,
|
||||
];
|
||||
|
||||
/** Two channels per page, so `SLACK_CHANNELS` spans exactly two pages. */
|
||||
export const SLACK_CHANNELS_PAGE_SIZE = 2;
|
||||
|
||||
/**
|
||||
* The first channel the picker offers, so an install seeded with it always
|
||||
* points at a channel the listing really has.
|
||||
*/
|
||||
export const SLACK_DEFAULT_CHANNEL = SLACK_PUBLIC_CHANNEL;
|
||||
|
||||
const PROWLER_HQ: SlackWorkspaceFixture = {
|
||||
teamId: "T01PROWLER",
|
||||
teamName: "Prowler HQ",
|
||||
botUserId: "U01PROWLERBOT",
|
||||
};
|
||||
|
||||
export const slackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture => ({
|
||||
appConfigured: true,
|
||||
install: null,
|
||||
exchangeWorkspace: { ...PROWLER_HQ },
|
||||
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.CREATED,
|
||||
connection: { connected: true, error: null },
|
||||
rateLimited: false,
|
||||
listServerError: false,
|
||||
authorizeUrlUnreadable: false,
|
||||
oauthUpstreamError: false,
|
||||
channels: SLACK_CHANNELS.map((channel) => ({ ...channel })),
|
||||
channelsPageSize: SLACK_CHANNELS_PAGE_SIZE,
|
||||
channelsRefusal: null,
|
||||
channelSaveRefusal: null,
|
||||
testMessage: { accepted: true, error: null },
|
||||
revocation: { revoked: true },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A workspace approved with no destination channel yet. `connected` is `null`,
|
||||
* not `true`: the check runs against the channel, so it has never run
|
||||
* (design.md, "Connection state, in order").
|
||||
*/
|
||||
export const connectedSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
slackFixture({
|
||||
install: {
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
connected: null,
|
||||
connectionLastCheckedAt: null,
|
||||
workspace: { ...PROWLER_HQ },
|
||||
},
|
||||
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REINSTALLED,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const configuredInstall = (
|
||||
channel: SlackChannelFixture = SLACK_DEFAULT_CHANNEL,
|
||||
): SlackInstallFixture => ({
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
connected: true,
|
||||
connectionLastCheckedAt: "2026-08-10T09:30:00Z",
|
||||
workspace: {
|
||||
...PROWLER_HQ,
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The same tenant with a destination channel already on record: the state a
|
||||
* second visit starts from.
|
||||
*/
|
||||
export const slackFixtureWithDefaultChannel = (
|
||||
channel: SlackChannelFixture = SLACK_PUBLIC_CHANNEL,
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
connectedSlackFixture({ install: configuredInstall(channel), ...overrides });
|
||||
|
||||
/**
|
||||
* The same finished setup, with a check time no parser can read: a zero date
|
||||
* from a bad write or a serializer change. The contract types the attribute as
|
||||
* a string and rules nothing else out.
|
||||
*/
|
||||
export const unreadableCheckTimeSlackFixture = (): SlackFixture =>
|
||||
connectedSlackFixture({
|
||||
install: {
|
||||
...configuredInstall(),
|
||||
connectionLastCheckedAt: "0000-00-00T00:00:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The first cursor page is served and Slack rate limits the second: what is
|
||||
* already read stays usable, the refusal only says why the list is short.
|
||||
*/
|
||||
export const partiallyReadSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
|
||||
channelsRefusal: SLACK_RATE_LIMITED_REFUSAL,
|
||||
channelsRefusalFromCursor: SLACK_CHANNELS_PAGE_SIZE,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A workspace connected *and* a channel on record. Anything the API refuses
|
||||
* until a channel exists (the connection check) needs this fixture.
|
||||
*/
|
||||
export const configuredSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
slackFixtureWithDefaultChannel(SLACK_DEFAULT_CHANNEL, overrides);
|
||||
|
||||
/**
|
||||
* A connected tenant whose disconnect removes the row but cannot revoke at
|
||||
* Slack — the outcome the user has to finish by hand in the workspace.
|
||||
*/
|
||||
export const revokeFailureSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
connectedSlackFixture({
|
||||
revocation: { revoked: false },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A connected tenant whose disconnect answers a plain `204` with no body: the
|
||||
* row is gone and the revocation is unreported.
|
||||
*/
|
||||
export const unreportedRevocationSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
connectedSlackFixture({
|
||||
revocation: { revoked: null },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A connected tenant whose token has been revoked at Slack: the row still says
|
||||
* connected until a check runs, and the check is what surfaces it.
|
||||
*/
|
||||
export const revokedTokenSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
configuredSlackFixture({
|
||||
connection: { connected: false, error: SLACK_TOKEN_REVOKED_CODE },
|
||||
...overrides,
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* MSW handlers for the Slack integration, derived from the API contract in
|
||||
* `openspec/changes/add-slack-integration/design.md` (the API itself lives in
|
||||
* the cloud repository). State is per-call: an exchange creates the install the
|
||||
* subsequent `GET /integrations` returns.
|
||||
*
|
||||
* Wire them per test via `worker.use(...handlersForSlack(fx))`.
|
||||
*/
|
||||
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
import {
|
||||
INTEGRATIONS_SERVER_ERROR_DETAIL,
|
||||
PROXY_CHALLENGE_PAGE,
|
||||
SLACK_AUTHORIZE_URL,
|
||||
SLACK_DIFFERENT_WORKSPACE_DETAIL,
|
||||
SLACK_EXCHANGE_OUTCOME,
|
||||
SLACK_INTEGRATION_ID,
|
||||
SLACK_INVALID_CODE_DETAIL,
|
||||
SLACK_NO_CHANNEL_DETAIL,
|
||||
SLACK_NO_DEFAULT_CHANNEL_DETAIL,
|
||||
SLACK_RATE_LIMITED_REFUSAL,
|
||||
SLACK_REFUSED_STATE_DETAIL,
|
||||
SLACK_UNCONFIGURED_DETAIL,
|
||||
SLACK_UNKNOWN_CHANNEL_DETAIL,
|
||||
SLACK_UPSTREAM_DETAIL,
|
||||
SLACK_UPSTREAM_ERROR_CODE,
|
||||
SLACK_WORKSPACE_CONFLICT_CODE,
|
||||
} from "./slack.fixtures";
|
||||
import type {
|
||||
SlackExchangeOutcome,
|
||||
SlackFixture,
|
||||
SlackInstallFixture,
|
||||
SlackRefusalFixture,
|
||||
} from "./slack.fixtures";
|
||||
|
||||
const API = process.env.UI_API_BASE_URL;
|
||||
const TS = "2026-08-10T09:00:00Z";
|
||||
|
||||
const CONNECTION_TASK_PREFIX = "slack-conn-task-";
|
||||
const TEST_MESSAGE_TASK_PREFIX = "slack-test-message-task-";
|
||||
|
||||
/** Opaque to the UI, which only ever follows `links.next` (design D6). */
|
||||
const CHANNEL_CURSOR_PARAM = "page[cursor]";
|
||||
|
||||
/**
|
||||
* `status` is a string, per the JSON:API spec. `source.pointer` is `/data` even
|
||||
* for a field-shaped `ValidationError`: the errors are about the request.
|
||||
*/
|
||||
const errorBody = (detail: string, status: number, code?: string) => ({
|
||||
errors: [
|
||||
{
|
||||
status: String(status),
|
||||
...(code ? { code } : {}),
|
||||
detail,
|
||||
source: { pointer: "/data" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Answer a fixture's refusal as the API would: its own status, its `code`
|
||||
* when it names one, and `Retry-After` only where the status carries a wait.
|
||||
*/
|
||||
const refuse = (refusal: SlackRefusalFixture) =>
|
||||
HttpResponse.json(
|
||||
errorBody(refusal.detail, refusal.status, refusal.code ?? undefined),
|
||||
{
|
||||
status: refusal.status,
|
||||
...(refusal.retryAfterSeconds === null
|
||||
? {}
|
||||
: { headers: { "Retry-After": String(refusal.retryAfterSeconds) } }),
|
||||
},
|
||||
);
|
||||
|
||||
const configuration = (workspace: SlackInstallFixture["workspace"]) => ({
|
||||
team_id: workspace.teamId,
|
||||
team_name: workspace.teamName,
|
||||
bot_user_id: workspace.botUserId,
|
||||
// The API omits these keys until a channel is chosen, never sending nulls.
|
||||
...(workspace.channelId ? { channel_id: workspace.channelId } : {}),
|
||||
...(workspace.channelName ? { channel_name: workspace.channelName } : {}),
|
||||
});
|
||||
|
||||
const integrationResource = (install: SlackInstallFixture) => ({
|
||||
id: install.id,
|
||||
type: "integrations",
|
||||
attributes: {
|
||||
inserted_at: TS,
|
||||
updated_at: TS,
|
||||
enabled: true,
|
||||
connected: install.connected,
|
||||
connection_last_checked_at: install.connectionLastCheckedAt,
|
||||
integration_type: "slack",
|
||||
// No credentials: the bot token is encrypted at rest and never serialized.
|
||||
configuration: configuration(install.workspace),
|
||||
},
|
||||
links: { self: `${API}/integrations/${install.id}` },
|
||||
});
|
||||
|
||||
const collection = (install: SlackInstallFixture | null) => ({
|
||||
data: install ? [integrationResource(install)] : [],
|
||||
meta: {
|
||||
version: "v1",
|
||||
pagination: {
|
||||
page: 1,
|
||||
pages: 1,
|
||||
count: install ? 1 : 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const taskResource = (id: string, state: string, result: unknown) => ({
|
||||
data: { id, type: "tasks", attributes: { state, result } },
|
||||
});
|
||||
|
||||
/**
|
||||
* All three are `2xx`: the first two make `response.json()` throw, the third
|
||||
* parses into a body that names no resource.
|
||||
*/
|
||||
const unreadableExchange = (outcome: SlackExchangeOutcome): Response => {
|
||||
switch (outcome) {
|
||||
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT:
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML:
|
||||
return HttpResponse.html(PROXY_CHALLENGE_PAGE);
|
||||
default:
|
||||
return HttpResponse.json({ meta: { version: "v1" } });
|
||||
}
|
||||
};
|
||||
|
||||
export const handlersForSlack = (fx: SlackFixture) => {
|
||||
// Mutable copy: the exchange must not write through to the caller's fixture.
|
||||
let install: SlackInstallFixture | null = fx.install
|
||||
? { ...fx.install, workspace: { ...fx.install.workspace } }
|
||||
: null;
|
||||
|
||||
const unconfigured = () =>
|
||||
HttpResponse.json(errorBody(SLACK_UNCONFIGURED_DETAIL, 503), {
|
||||
status: 503,
|
||||
});
|
||||
|
||||
const rateLimited = () => refuse(SLACK_RATE_LIMITED_REFUSAL);
|
||||
|
||||
/** A `502` per the contract's taxonomy: a server fault, not a Slack state. */
|
||||
const upstreamError = () =>
|
||||
HttpResponse.json(
|
||||
errorBody(SLACK_UPSTREAM_DETAIL, 502, SLACK_UPSTREAM_ERROR_CODE),
|
||||
{ status: 502, statusText: "Bad Gateway" },
|
||||
);
|
||||
|
||||
return [
|
||||
// --- OAuth ------------------------------------------------------------
|
||||
http.post(`${API}/integrations/slack/oauth/authorize-url`, () => {
|
||||
if (!fx.appConfigured) return unconfigured();
|
||||
if (fx.rateLimited) return rateLimited();
|
||||
if (fx.oauthUpstreamError) return upstreamError();
|
||||
if (fx.authorizeUrlUnreadable) {
|
||||
return HttpResponse.html(PROXY_CHALLENGE_PAGE);
|
||||
}
|
||||
// The URL travels in `meta`; the call creates nothing.
|
||||
return HttpResponse.json({
|
||||
meta: { authorize_url: SLACK_AUTHORIZE_URL },
|
||||
});
|
||||
}),
|
||||
|
||||
http.post(`${API}/integrations/slack/oauth/exchange`, () => {
|
||||
if (!fx.appConfigured) return unconfigured();
|
||||
if (fx.rateLimited) return rateLimited();
|
||||
if (fx.oauthUpstreamError) return upstreamError();
|
||||
|
||||
switch (fx.exchangeOutcome) {
|
||||
case SLACK_EXCHANGE_OUTCOME.REFUSED_STATE:
|
||||
return HttpResponse.json(errorBody(SLACK_REFUSED_STATE_DETAIL, 400), {
|
||||
status: 400,
|
||||
});
|
||||
case SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED:
|
||||
return HttpResponse.json(errorBody(SLACK_INVALID_CODE_DETAIL, 400), {
|
||||
status: 400,
|
||||
});
|
||||
case SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE:
|
||||
return HttpResponse.json(
|
||||
errorBody(
|
||||
SLACK_DIFFERENT_WORKSPACE_DETAIL,
|
||||
409,
|
||||
SLACK_WORKSPACE_CONFLICT_CODE,
|
||||
),
|
||||
{ status: 409 },
|
||||
);
|
||||
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT:
|
||||
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML:
|
||||
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA:
|
||||
// The install still happened: the API upserts before it answers.
|
||||
install = {
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
connected: null,
|
||||
connectionLastCheckedAt: null,
|
||||
workspace: { ...fx.exchangeWorkspace },
|
||||
};
|
||||
return unreadableExchange(fx.exchangeOutcome);
|
||||
case SLACK_EXCHANGE_OUTCOME.REINSTALLED:
|
||||
install = {
|
||||
id: install?.id ?? SLACK_INTEGRATION_ID,
|
||||
connected: null,
|
||||
connectionLastCheckedAt: null,
|
||||
workspace: { ...fx.exchangeWorkspace },
|
||||
};
|
||||
return HttpResponse.json({ data: integrationResource(install) });
|
||||
default:
|
||||
install = {
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
connected: null,
|
||||
connectionLastCheckedAt: null,
|
||||
workspace: { ...fx.exchangeWorkspace },
|
||||
};
|
||||
return HttpResponse.json(
|
||||
{ data: integrationResource(install) },
|
||||
{ status: 201 },
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// --- Generic integration endpoints the Slack UI reuses -----------------
|
||||
http.get(`${API}/integrations`, ({ request }) => {
|
||||
if (fx.listServerError) {
|
||||
return HttpResponse.json(
|
||||
errorBody(INTEGRATIONS_SERVER_ERROR_DETAIL, 500),
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const type = new URL(request.url).searchParams.get(
|
||||
"filter[integration_type]",
|
||||
);
|
||||
// An unfiltered read would pull every type into the Slack page.
|
||||
return HttpResponse.json(collection(type === "slack" ? install : null));
|
||||
}),
|
||||
|
||||
http.post<{ id: string }>(
|
||||
`${API}/integrations/:id/connection`,
|
||||
({ params }) => {
|
||||
// The check posts to the channel, so the API refuses until one exists.
|
||||
if (!install?.workspace.channelId) {
|
||||
return HttpResponse.json(errorBody(SLACK_NO_CHANNEL_DETAIL, 400), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json(
|
||||
taskResource(
|
||||
`${CONNECTION_TASK_PREFIX}${params.id}`,
|
||||
"executing",
|
||||
null,
|
||||
),
|
||||
{ status: 202 },
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
http.get<{ taskId: string }>(`${API}/tasks/:taskId`, ({ params }) => {
|
||||
// The test message settles as its own task (design D9).
|
||||
if (params.taskId.startsWith(TEST_MESSAGE_TASK_PREFIX)) {
|
||||
const { accepted, error } = fx.testMessage;
|
||||
return HttpResponse.json(
|
||||
taskResource(params.taskId, accepted ? "completed" : "failed", {
|
||||
error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const { connected, error } = fx.connection;
|
||||
if (install && params.taskId.startsWith(CONNECTION_TASK_PREFIX)) {
|
||||
install.connected = connected;
|
||||
install.connectionLastCheckedAt = TS;
|
||||
}
|
||||
return HttpResponse.json(
|
||||
taskResource(params.taskId, "completed", { connected, error }),
|
||||
);
|
||||
}),
|
||||
|
||||
// --- Channels ----------------------------------------------------------
|
||||
http.get<{ id: string }>(
|
||||
`${API}/integrations/:id/slack/channels`,
|
||||
({ params, request }) => {
|
||||
// The UI follows `links.next` opaquely, so the cursor's shape is this
|
||||
// fixture's business alone. Read first: the page decides the refusal.
|
||||
const cursor = Number(
|
||||
new URL(request.url).searchParams.get(CHANNEL_CURSOR_PARAM) ?? "0",
|
||||
);
|
||||
|
||||
// An endpoint-specific refusal wins over the blanket rate limiting,
|
||||
// and applies from the named cursor, so a partial read is expressible.
|
||||
if (
|
||||
fx.channelsRefusal &&
|
||||
cursor >= (fx.channelsRefusalFromCursor ?? 0)
|
||||
) {
|
||||
return refuse(fx.channelsRefusal);
|
||||
}
|
||||
if (fx.rateLimited) return rateLimited();
|
||||
|
||||
const nextCursor = cursor + fx.channelsPageSize;
|
||||
const page = fx.channels.slice(cursor, nextCursor);
|
||||
const hasMore = nextCursor < fx.channels.length;
|
||||
|
||||
return HttpResponse.json({
|
||||
data: page.map((channel) => ({
|
||||
type: "slack-channels",
|
||||
id: channel.id,
|
||||
attributes: { name: channel.name, is_private: channel.isPrivate },
|
||||
})),
|
||||
links: {
|
||||
next: hasMore
|
||||
? `${API}/integrations/${params.id}/slack/channels` +
|
||||
`?${CHANNEL_CURSOR_PARAM}=${nextCursor}`
|
||||
: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
/**
|
||||
* The generic PATCH. The UI submits only `channel_id`; the name is derived
|
||||
* from it here, as the API derives it from Slack (design D6).
|
||||
*/
|
||||
http.patch(`${API}/integrations/:id`, async ({ request }) => {
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
data?: { attributes?: Record<string, unknown> };
|
||||
} | null;
|
||||
const attributes = body?.data?.attributes ?? {};
|
||||
const configurationPatch = attributes.configuration as
|
||||
| { channel_id?: string }
|
||||
| undefined;
|
||||
const channelId = configurationPatch?.channel_id;
|
||||
const channel = fx.channels.find((c) => c.id === channelId);
|
||||
|
||||
if (!install) {
|
||||
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
|
||||
}
|
||||
// The API's write serializer names the attributes it will not take and
|
||||
// refuses the whole save, rather than quietly dropping the extra one:
|
||||
// sending `integration_type` here refused every channel save.
|
||||
const refusedAttributes = Object.keys(attributes).filter(
|
||||
(attribute) => attribute !== "configuration",
|
||||
);
|
||||
if (refusedAttributes.length > 0) {
|
||||
const named = refusedAttributes
|
||||
.map((attribute) => `'${attribute}'`)
|
||||
.join(", ");
|
||||
return HttpResponse.json(errorBody(`Invalid fields: {${named}}`, 400), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
// Checked before the id lookup: the picker did offer this channel, and
|
||||
// Slack refused it anyway when the API validated it.
|
||||
if (fx.channelSaveRefusal) return refuse(fx.channelSaveRefusal);
|
||||
if (!channel) {
|
||||
return HttpResponse.json(errorBody(SLACK_UNKNOWN_CHANNEL_DETAIL, 400), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
install.workspace.channelId = channel.id;
|
||||
install.workspace.channelName = channel.name;
|
||||
return HttpResponse.json({ data: integrationResource(install) });
|
||||
}),
|
||||
|
||||
// --- Test message ------------------------------------------------------
|
||||
http.post<{ id: string }>(
|
||||
`${API}/integrations/:id/slack/test-message`,
|
||||
({ params }) => {
|
||||
if (!install?.workspace.channelId) {
|
||||
return HttpResponse.json(
|
||||
errorBody(SLACK_NO_DEFAULT_CHANNEL_DETAIL, 400),
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return HttpResponse.json(
|
||||
taskResource(
|
||||
`${TEST_MESSAGE_TASK_PREFIX}${params.id}`,
|
||||
"available",
|
||||
null,
|
||||
),
|
||||
{ status: 202 },
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Disconnect. Revocation at Slack is best-effort: the row is removed either
|
||||
// way and the outcome travels in `meta` — or nowhere at all, in the plain
|
||||
// `204` a deployment with no `destroy` override sends.
|
||||
http.delete(`${API}/integrations/:id`, () => {
|
||||
install = null;
|
||||
if (fx.revocation.revoked === null) {
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}
|
||||
return HttpResponse.json({ meta: { revoked: fx.revocation.revoked } });
|
||||
}),
|
||||
];
|
||||
};
|
||||
@@ -341,6 +341,7 @@ export const testIntegrationConnection = async (
|
||||
revalidatePath("/integrations/amazon-s3");
|
||||
revalidatePath("/integrations/aws-security-hub");
|
||||
revalidatePath("/integrations/jira");
|
||||
revalidatePath("/integrations/slack");
|
||||
|
||||
if ("error" in pollResult) {
|
||||
return { success: false, error: pollResult.error };
|
||||
@@ -387,6 +388,7 @@ export const pollConnectionTestStatus = async (
|
||||
revalidatePath("/integrations/amazon-s3");
|
||||
revalidatePath("/integrations/aws-security-hub");
|
||||
revalidatePath("/integrations/jira");
|
||||
revalidatePath("/integrations/slack");
|
||||
|
||||
if ("error" in pollResult) {
|
||||
return { success: false, error: pollResult.error };
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
/**
|
||||
* What the Slack actions do off the DOM, which
|
||||
* `slack-page.integration.test.tsx` cannot cover: which failures reach Sentry,
|
||||
* and the URLs the channel listing's cursor pagination follows.
|
||||
*/
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SLACK_ERROR_CODE,
|
||||
SLACK_ERROR_MESSAGES,
|
||||
SLACK_GENERIC_ERROR_MESSAGE,
|
||||
SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
|
||||
SLACK_UNREADABLE_RESULT_MESSAGE,
|
||||
} from "@/lib/integrations/slack-errors";
|
||||
import { SentryErrorSource, SentryErrorType } from "@/sentry";
|
||||
|
||||
const { captureExceptionMock, captureMessageMock, fetchMock } = vi.hoisted(
|
||||
() => ({
|
||||
/**
|
||||
* The real SDK marks the exception `__sentry_captured__`, and
|
||||
* `handleApiError` reads that mark to avoid reporting the same throw twice.
|
||||
*/
|
||||
captureExceptionMock: vi.fn((exception: unknown, _options?: unknown) => {
|
||||
if (exception !== null && typeof exception === "object") {
|
||||
Object.defineProperty(exception, "__sentry_captured__", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
}),
|
||||
captureMessageMock: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: captureExceptionMock,
|
||||
captureMessage: captureMessageMock,
|
||||
// The task poll leaves breadcrumbs on every read it makes.
|
||||
addBreadcrumb: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: vi.fn(),
|
||||
}));
|
||||
|
||||
// The real `handleApiResponse` reads its copy from `lib/helper`, which reaches
|
||||
// next-auth through `@/auth.config`; stubbing the session lets that copy load.
|
||||
vi.mock("@/auth.config", () => ({
|
||||
auth: vi.fn(() => Promise.resolve({ accessToken: "test-access-token" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: vi.fn(() =>
|
||||
Promise.resolve({ Authorization: "Bearer test-token" }),
|
||||
),
|
||||
parseStringify: (value: unknown) => value,
|
||||
}));
|
||||
|
||||
import {
|
||||
disconnectSlackIntegration,
|
||||
exchangeSlackOAuthCode,
|
||||
getSlackAuthorizeUrl,
|
||||
getSlackChannels,
|
||||
sendSlackTestMessage,
|
||||
setSlackDefaultChannel,
|
||||
} from "./slack";
|
||||
|
||||
/** The status the contract reserves for an upstream Slack failure. */
|
||||
const UPSTREAM_STATUS = 502;
|
||||
const UPSTREAM_DETAIL = "Slack is temporarily unavailable.";
|
||||
const GENERIC_SERVER_ERROR_MESSAGE =
|
||||
"Server is temporarily unavailable. Please try again in a few minutes.";
|
||||
|
||||
const errorResponse = (status: number, detail: string, code?: string) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
errors: [
|
||||
{
|
||||
status: String(status),
|
||||
...(code ? { code } : {}),
|
||||
detail,
|
||||
source: { pointer: "/data" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status, headers: { "content-type": "application/vnd.api+json" } },
|
||||
);
|
||||
|
||||
const exchange = () =>
|
||||
exchangeSlackOAuthCode({ code: "slack-code-1f4a", state: "st-2f1c9d7a" });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ action: getSlackAuthorizeUrl, name: "getSlackAuthorizeUrl" },
|
||||
{ action: exchange, name: "exchangeSlackOAuthCode" },
|
||||
])("$name", ({ action }) => {
|
||||
it("reports an upstream Slack failure instead of only turning it into copy", async () => {
|
||||
// 502 covers `internal_error`, `fatal_error`, `service_unavailable` and
|
||||
// transport failures.
|
||||
fetchMock.mockResolvedValue(
|
||||
errorResponse(UPSTREAM_STATUS, UPSTREAM_DETAIL, "service_unavailable"),
|
||||
);
|
||||
|
||||
const result = await action();
|
||||
|
||||
// Once, not twice: `handleApiResponse` reports and throws, and the action's
|
||||
// catch sees the mark.
|
||||
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
|
||||
expect(captureExceptionMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
tags: {
|
||||
api_error: true,
|
||||
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
|
||||
error_type: SentryErrorType.SERVER_ERROR,
|
||||
status_code: String(UPSTREAM_STATUS),
|
||||
},
|
||||
});
|
||||
expect(captureMessageMock).not.toHaveBeenCalled();
|
||||
|
||||
// The throw lands in the action's catch, so the page gets a result to
|
||||
// render rather than a rejection that strands the callback on its spinner.
|
||||
expect(result).toEqual({ error: UPSTREAM_DETAIL });
|
||||
});
|
||||
|
||||
it("answers a 5xx the API described in HTML in Prowler's own words", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response("<html><body><h1>502 Bad Gateway</h1></body></html>", {
|
||||
status: UPSTREAM_STATUS,
|
||||
statusText: "Bad Gateway",
|
||||
headers: { "content-type": "text/html" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await action();
|
||||
|
||||
expect(result).toEqual({ error: GENERIC_SERVER_ERROR_MESSAGE });
|
||||
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([503, 404])(
|
||||
"reports nothing for a %s: that is the feature being dark, not a fault",
|
||||
async (status) => {
|
||||
// 503 means `SLACK_CLIENT_*` is unset; 404 means no Slack API is served
|
||||
// in this deployment at all.
|
||||
fetchMock.mockResolvedValue(
|
||||
errorResponse(status, "Slack integration is not configured."),
|
||||
);
|
||||
|
||||
const result = await action();
|
||||
|
||||
// Capturing this would report the deliberate ship-dark state from every
|
||||
// tenant on every page load.
|
||||
expect(result).toEqual({ unavailable: true });
|
||||
expect(captureExceptionMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("reports nothing when Slack is rate limiting: it is a wait, not a fault", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
errors: [{ status: "429", detail: "Slack is rate limiting." }],
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"content-type": "application/vnd.api+json",
|
||||
"Retry-After": "30",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await action();
|
||||
|
||||
expect(result).toMatchObject({ rateLimited: true, retryAfterSeconds: 30 });
|
||||
expect(captureExceptionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The URL is rendered as the `Add to Slack` link's `href`, so a value the API
|
||||
* got wrong must not become a redirect to somewhere that is not Slack.
|
||||
*/
|
||||
describe("getSlackAuthorizeUrl authorize URL", () => {
|
||||
const NO_AUTHORIZE_URL_MESSAGE = "Slack did not return an authorization URL.";
|
||||
const CONSENT_SCREEN_URL =
|
||||
"https://slack.com/oauth/v2/authorize" +
|
||||
"?client_id=1234567890.0987654321&state=st-2f1c9d7a";
|
||||
|
||||
const authorizeUrlResponse = (authorizeUrl: unknown) =>
|
||||
new Response(JSON.stringify({ meta: { authorize_url: authorizeUrl } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/vnd.api+json" },
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a hostile scheme", "javascript:alert(document.domain)"],
|
||||
["plain HTTP", "http://slack.com/oauth/v2/authorize?client_id=1"],
|
||||
["another origin", "https://evil.test/oauth/v2/authorize?client_id=1"],
|
||||
["a lookalike hostname", "https://slack.com.evil.test/oauth/v2/authorize"],
|
||||
[
|
||||
"another Slack path",
|
||||
"https://slack.com/redirect?to=https%3A%2F%2Fevil.test",
|
||||
],
|
||||
["a value that is not a URL", "oauth/v2/authorize"],
|
||||
])(
|
||||
"refuses %s instead of offering it as the install link",
|
||||
async (_label, authorizeUrl) => {
|
||||
// Given — a 2xx whose `meta.authorize_url` is not Slack's consent screen.
|
||||
fetchMock.mockResolvedValue(authorizeUrlResponse(authorizeUrl));
|
||||
|
||||
// When
|
||||
const result = await getSlackAuthorizeUrl();
|
||||
|
||||
// Then — the answer for no URL at all: nothing here is safe to link to.
|
||||
expect(result).toEqual({ error: NO_AUTHORIZE_URL_MESSAGE });
|
||||
},
|
||||
);
|
||||
|
||||
it("hands over Slack's consent screen with its query untouched", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(authorizeUrlResponse(CONSENT_SCREEN_URL));
|
||||
|
||||
// When / Then
|
||||
expect(await getSlackAuthorizeUrl()).toEqual({
|
||||
authorizeUrl: CONSENT_SCREEN_URL,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The callback names the workspace and redirects on `integration` alone, so a
|
||||
* `2xx` body it cannot read back as an integration must not reach it.
|
||||
*/
|
||||
describe("exchangeSlackOAuthCode result shape", () => {
|
||||
const INTEGRATION = {
|
||||
id: "9b1f4c22-5e7a-4c2e-8f0d-6a3b1c9d7e42",
|
||||
type: "integrations",
|
||||
attributes: {
|
||||
integration_type: "slack",
|
||||
configuration: { team_name: "Prowler HQ" },
|
||||
},
|
||||
};
|
||||
|
||||
const exchangeResponse = (data: unknown) =>
|
||||
new Response(JSON.stringify({ data }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/vnd.api+json" },
|
||||
});
|
||||
|
||||
it.each<[string, unknown]>([
|
||||
["an empty object", {}],
|
||||
["an array", []],
|
||||
["a bare string", "invalid"],
|
||||
["a resource with no id", { type: "integrations", attributes: {} }],
|
||||
["a resource with an empty id", { ...INTEGRATION, id: "" }],
|
||||
["a resource of another type", { ...INTEGRATION, type: "tasks" }],
|
||||
[
|
||||
"a resource with no attributes",
|
||||
{ id: INTEGRATION.id, type: "integrations" },
|
||||
],
|
||||
[
|
||||
"another kind of integration",
|
||||
{
|
||||
...INTEGRATION,
|
||||
attributes: { ...INTEGRATION.attributes, integration_type: "jira" },
|
||||
},
|
||||
],
|
||||
])("cannot confirm the install from %s", async (_label, data) => {
|
||||
// Given — a 2xx whose `data` is truthy but is not an integration resource.
|
||||
fetchMock.mockResolvedValue(exchangeResponse(data));
|
||||
|
||||
// When
|
||||
const result = await exchange();
|
||||
|
||||
// Then — the answer for a body with no `data`: the install happened, only
|
||||
// its result is unknown.
|
||||
expect(result).toEqual({
|
||||
unconfirmed: true,
|
||||
message: SLACK_UNREADABLE_RESULT_MESSAGE,
|
||||
});
|
||||
});
|
||||
|
||||
it("hands over the workspace the API upserted", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(exchangeResponse(INTEGRATION));
|
||||
|
||||
// When / Then
|
||||
expect(await exchange()).toEqual({ integration: INTEGRATION });
|
||||
});
|
||||
});
|
||||
|
||||
/** The shape the API's integration ids have, which is the only shape accepted. */
|
||||
const SLACK_INTEGRATION_ID = "b2c7fd0a-3e51-4d8f-9a6c-1f0e2d3c4b5a";
|
||||
|
||||
const CHANNELS_URL =
|
||||
`https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}` +
|
||||
"/slack/channels";
|
||||
|
||||
const FIRST_CHANNEL = { id: "C0123AB", name: "security" };
|
||||
const SECOND_CHANNEL = { id: "C0789EF", name: "platform" };
|
||||
|
||||
const channelPage = (
|
||||
channel: { id: string; name: string },
|
||||
next: string | null,
|
||||
) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
type: "slack-channels",
|
||||
id: channel.id,
|
||||
attributes: { name: channel.name, is_private: false },
|
||||
},
|
||||
],
|
||||
links: { next },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/vnd.api+json" } },
|
||||
);
|
||||
|
||||
const channelOption = (channel: { id: string; name: string }) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
is_private: false,
|
||||
});
|
||||
|
||||
const requestedUrls = (): string[] =>
|
||||
fetchMock.mock.calls.map(([url]) => String(url));
|
||||
|
||||
const sentBody = (callIndex = 0): unknown =>
|
||||
JSON.parse(String(fetchMock.mock.calls[callIndex]?.[1]?.body));
|
||||
|
||||
/**
|
||||
* `MAX_CHANNEL_PAGES` in the action, which a `"use server"` module cannot
|
||||
* export: only async functions may leave one.
|
||||
*/
|
||||
const MAX_CHANNEL_PAGES = 20;
|
||||
|
||||
const channelOptions = (count: number) =>
|
||||
Array.from({ length: count }, () => channelOption(FIRST_CHANNEL));
|
||||
|
||||
/** What a `429` carrying `Retry-After: 30` is turned into. */
|
||||
const RATE_LIMITED_MESSAGE =
|
||||
"Slack is rate limiting Prowler right now. Try again in about 30 seconds.";
|
||||
|
||||
/** A dead grant as the API reports it: reason in `code`, prose in `detail`. */
|
||||
const TOKEN_EXPIRED_CODE = "token_expired";
|
||||
const TOKEN_EXPIRED_DETAIL = "Slack refused the request: token_expired.";
|
||||
const TOKEN_EXPIRED_MESSAGE =
|
||||
"Prowler's Slack credential has expired. Connect the workspace again to restore access.";
|
||||
|
||||
describe("getSlackChannels", () => {
|
||||
it("follows a cursor-only `next` on the listing's own URL, not on the API root", async () => {
|
||||
// The link is opaque (design D6), so the API may answer with the cursor
|
||||
// alone; resolved against the API root it loses the listing's own path.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
|
||||
.mockResolvedValueOnce(channelPage(SECOND_CHANNEL, null));
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(requestedUrls()).toEqual([
|
||||
CHANNELS_URL,
|
||||
`${CHANNELS_URL}?page[cursor]=2`,
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
channels: [channelOption(FIRST_CHANNEL), channelOption(SECOND_CHANNEL)],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
shape: "an absolute",
|
||||
next: "https://evil.test/api/v1/integrations/x/slack/channels?cursor=2",
|
||||
},
|
||||
{ shape: "a protocol-relative", next: "//evil.test/api/v1/channels?c=2" },
|
||||
])(
|
||||
"stops at $shape off-origin `next` rather than sending the tenant's token to it",
|
||||
async ({ next }) => {
|
||||
// `fetch` strips the tenant's `Authorization` on a redirect that leaves
|
||||
// the origin, but not on a hop the UI makes itself.
|
||||
fetchMock.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, next));
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(requestedUrls()).toEqual([CHANNELS_URL]);
|
||||
expect(result).toEqual({
|
||||
channels: [channelOption(FIRST_CHANNEL)],
|
||||
incomplete: SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("answers an unreadable page as no channels rather than parser prose", async () => {
|
||||
fetchMock.mockResolvedValueOnce(unreadableOk(HTML_INTERSTITIAL));
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({ channels: [] });
|
||||
expectNoParserProse(result);
|
||||
});
|
||||
|
||||
it("says the list is short of the workspace when the page budget runs out", async () => {
|
||||
// The budget exists because `conversations.list` is tier 2 and a workspace
|
||||
// can outgrow it (design.md, Risks). A fresh `Response` per call: one
|
||||
// instance is already consumed on its second read.
|
||||
fetchMock.mockImplementation(() =>
|
||||
Promise.resolve(channelPage(FIRST_CHANNEL, "?page[cursor]=next")),
|
||||
);
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(MAX_CHANNEL_PAGES);
|
||||
expect(result).toEqual({
|
||||
channels: channelOptions(MAX_CHANNEL_PAGES),
|
||||
incomplete: SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
|
||||
});
|
||||
});
|
||||
|
||||
it("says nothing about a short list for a workspace that just fits the budget", async () => {
|
||||
let page = 0;
|
||||
fetchMock.mockImplementation(() => {
|
||||
page += 1;
|
||||
return Promise.resolve(
|
||||
channelPage(
|
||||
FIRST_CHANNEL,
|
||||
page < MAX_CHANNEL_PAGES ? `?page[cursor]=${page}` : null,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(MAX_CHANNEL_PAGES);
|
||||
expect(result).toEqual({ channels: channelOptions(MAX_CHANNEL_PAGES) });
|
||||
expect(result).not.toHaveProperty("incomplete");
|
||||
});
|
||||
|
||||
it("keeps the pages it read when a later one is refused, saying why the list stops", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
|
||||
.mockResolvedValueOnce(rateLimitedResponse());
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
// A rate limit says nothing about the grant, so the truncation names none.
|
||||
expect(result).toEqual({
|
||||
channels: [channelOption(FIRST_CHANNEL)],
|
||||
incomplete: RATE_LIMITED_MESSAGE,
|
||||
code: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("names the reason a later page was refused, not only the wording", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
|
||||
.mockResolvedValueOnce(
|
||||
errorResponse(400, TOKEN_EXPIRED_DETAIL, TOKEN_EXPIRED_CODE),
|
||||
);
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({
|
||||
channels: [channelOption(FIRST_CHANNEL)],
|
||||
incomplete: TOKEN_EXPIRED_MESSAGE,
|
||||
code: TOKEN_EXPIRED_CODE,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a refusal on the first page as a failure, having nothing to show", async () => {
|
||||
fetchMock.mockResolvedValueOnce(rateLimitedResponse());
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({ error: RATE_LIMITED_MESSAGE, code: null });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A `2xx` whose body is not JSON:API: an empty answer, or the HTML a proxy or
|
||||
* WAF puts in front of one. The raw `SyntaxError` survives
|
||||
* `sanitizeErrorMessage` (V8 truncates the snippet to ten characters, so its
|
||||
* `<!doctype html>` branch never matches) and would be shown verbatim.
|
||||
*/
|
||||
const HTML_INTERSTITIAL =
|
||||
"<!DOCTYPE html><html><body><h1>Checking your browser</h1></body></html>";
|
||||
|
||||
const unreadableOk = (body: string) =>
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": body ? "text/html" : "application/json" },
|
||||
});
|
||||
|
||||
/** V8's parser wording, which no user should ever be shown. */
|
||||
const PARSER_PROSE = /unexpected (token|end of json)|not valid json/i;
|
||||
|
||||
const expectNoParserProse = (result: unknown) => {
|
||||
const message = (result as { error?: string }).error ?? "";
|
||||
expect(message).not.toMatch(PARSER_PROSE);
|
||||
};
|
||||
|
||||
const INTEGRATION_URL = `https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}`;
|
||||
|
||||
const saveChannel = () =>
|
||||
setSlackDefaultChannel(SLACK_INTEGRATION_ID, FIRST_CHANNEL.id);
|
||||
|
||||
/** The save as the API answers it: the channel's name derived server-side. */
|
||||
const savedIntegration = () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
type: "integrations",
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
attributes: {
|
||||
integration_type: "slack",
|
||||
configuration: {
|
||||
channel_id: FIRST_CHANNEL.id,
|
||||
channel_name: FIRST_CHANNEL.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/vnd.api+json" },
|
||||
},
|
||||
);
|
||||
|
||||
const expectIntegrationsRevalidated = () => {
|
||||
expect(vi.mocked(revalidatePath).mock.calls).toEqual([
|
||||
["/integrations"],
|
||||
["/integrations/slack"],
|
||||
]);
|
||||
};
|
||||
|
||||
describe("setSlackDefaultChannel", () => {
|
||||
it("returns the saved integration and revalidates the pages listing it", async () => {
|
||||
fetchMock.mockResolvedValueOnce(savedIntegration());
|
||||
|
||||
const result = await saveChannel();
|
||||
|
||||
expect(requestedUrls()).toEqual([INTEGRATION_URL]);
|
||||
expect(result).toMatchObject({
|
||||
integration: {
|
||||
attributes: { configuration: { channel_name: FIRST_CHANNEL.name } },
|
||||
},
|
||||
});
|
||||
expectIntegrationsRevalidated();
|
||||
});
|
||||
|
||||
// The write serializer names whatever it will not take and refuses the whole
|
||||
// save, so a body that also carried the integration's own (immutable) type
|
||||
// came back as `Invalid fields: {'integration_type'}` and recorded nothing.
|
||||
it("submits the channel as the save's only attribute", async () => {
|
||||
fetchMock.mockResolvedValueOnce(savedIntegration());
|
||||
|
||||
await saveChannel();
|
||||
|
||||
expect(sentBody()).toEqual({
|
||||
data: {
|
||||
type: "integrations",
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
attributes: { configuration: { channel_id: FIRST_CHANNEL.id } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ shape: "empty", body: "" },
|
||||
{ shape: "an HTML interstitial", body: HTML_INTERSTITIAL },
|
||||
])(
|
||||
"answers a $shape `200` as an unread result, not as a failed save",
|
||||
async ({ body }) => {
|
||||
fetchMock.mockResolvedValueOnce(unreadableOk(body));
|
||||
|
||||
const result = await saveChannel();
|
||||
|
||||
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
|
||||
expectNoParserProse(result);
|
||||
// The API recorded the channel before answering, so both pages refresh.
|
||||
expectIntegrationsRevalidated();
|
||||
},
|
||||
);
|
||||
|
||||
// The caller reads `integration.attributes.configuration`, so a shallower
|
||||
// guard lets the miss surface later as the manager's generic catch.
|
||||
it.each([
|
||||
{ shape: "no `data`", body: {} },
|
||||
{ shape: "a null `data`", body: { data: null } },
|
||||
{ shape: "a `data` with no configuration", body: { data: {} } },
|
||||
])(
|
||||
"answers a `200` carrying $shape as an unread result",
|
||||
async ({ body }) => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/vnd.api+json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await saveChannel();
|
||||
|
||||
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
|
||||
expectNoParserProse(result);
|
||||
expectIntegrationsRevalidated();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/** The `202` that hands back the task the post is reported on (design D9). */
|
||||
const TEST_MESSAGE_TASK_ID = "5f8b1c2d-7e64-4a90-8c31-2b7d6e5f4a90";
|
||||
|
||||
const testMessageAccepted = () =>
|
||||
new Response(JSON.stringify({ data: { id: TEST_MESSAGE_TASK_ID } }), {
|
||||
status: 202,
|
||||
headers: { "content-type": "application/vnd.api+json" },
|
||||
});
|
||||
|
||||
/** The task read the poll makes, already settled on its first look. */
|
||||
const settledTask = (state: string, result: unknown) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
type: "tasks",
|
||||
id: TEST_MESSAGE_TASK_ID,
|
||||
attributes: { state, result },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/vnd.api+json" } },
|
||||
);
|
||||
|
||||
describe("sendSlackTestMessage", () => {
|
||||
it("answers an unreadable `202` as no task started, not as parser prose", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(HTML_INTERSTITIAL, {
|
||||
status: 202,
|
||||
headers: { "content-type": "text/html" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await sendSlackTestMessage(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({ error: "Slack did not start the test message." });
|
||||
expectNoParserProse(result);
|
||||
});
|
||||
|
||||
it("wraps a reason it has no copy for instead of answering with the bare token", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(testMessageAccepted())
|
||||
.mockResolvedValueOnce(settledTask("failed", { error: "is_archived" }));
|
||||
|
||||
const result = await sendSlackTestMessage(SLACK_INTEGRATION_ID);
|
||||
|
||||
const error = (result as { error?: string }).error ?? "";
|
||||
expect(error).toMatch(/Slack refused the message/);
|
||||
expect(error).toContain("is_archived");
|
||||
expect(error).not.toBe("is_archived");
|
||||
});
|
||||
|
||||
it("keeps Prowler's own wording for a reason the mapping covers", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(testMessageAccepted())
|
||||
.mockResolvedValueOnce(
|
||||
settledTask("failed", { error: "not_in_channel" }),
|
||||
);
|
||||
|
||||
const result = await sendSlackTestMessage(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({
|
||||
error: SLACK_ERROR_MESSAGES[SLACK_ERROR_CODE.NOT_IN_CHANNEL],
|
||||
code: SLACK_ERROR_CODE.NOT_IN_CHANNEL,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a reason the task worded itself as the prose it is", async () => {
|
||||
// Not token-shaped, so nothing is wrapped around it.
|
||||
const prose = "Slack rejected the message: the channel is archived.";
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(testMessageAccepted())
|
||||
.mockResolvedValueOnce(settledTask("failed", { error: prose }));
|
||||
|
||||
const result = await sendSlackTestMessage(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({ error: prose });
|
||||
});
|
||||
});
|
||||
|
||||
/** The calls whose only failure path is one line of copy. */
|
||||
const COPY_ONLY_ACTIONS = [
|
||||
{
|
||||
name: "getSlackChannels",
|
||||
call: (id: string) => getSlackChannels(id),
|
||||
},
|
||||
{
|
||||
name: "setSlackDefaultChannel",
|
||||
call: (id: string) => setSlackDefaultChannel(id, FIRST_CHANNEL.id),
|
||||
},
|
||||
{
|
||||
name: "sendSlackTestMessage",
|
||||
call: (id: string) => sendSlackTestMessage(id),
|
||||
},
|
||||
{
|
||||
name: "disconnectSlackIntegration",
|
||||
call: (id: string) => disconnectSlackIntegration(id),
|
||||
},
|
||||
];
|
||||
|
||||
const rateLimitedResponse = () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
errors: [{ status: "429", detail: "Slack is rate limiting." }],
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"content-type": "application/vnd.api+json",
|
||||
"Retry-After": "30",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
|
||||
it("reports an upstream Slack failure and still answers in the same words", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
errorResponse(UPSTREAM_STATUS, UPSTREAM_DETAIL),
|
||||
);
|
||||
|
||||
const result = await call(SLACK_INTEGRATION_ID);
|
||||
|
||||
// Once, not twice: `handleApiResponse` reports and throws, and the action's
|
||||
// catch sees the mark.
|
||||
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
|
||||
expect(captureExceptionMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
tags: {
|
||||
api_error: true,
|
||||
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
|
||||
error_type: SentryErrorType.SERVER_ERROR,
|
||||
status_code: String(UPSTREAM_STATUS),
|
||||
},
|
||||
});
|
||||
expect(captureMessageMock).not.toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual({ error: UPSTREAM_DETAIL });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
status: 503,
|
||||
why: "Slack being unavailable, not a fault",
|
||||
response: () => errorResponse(503, "Slack is unavailable."),
|
||||
expected: "Slack is unavailable.",
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
why: "a wait, not a fault",
|
||||
response: rateLimitedResponse,
|
||||
expected:
|
||||
"Slack is rate limiting Prowler right now. Try again in about 30 seconds.",
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
why: "a refusal the API meant to give",
|
||||
response: () => errorResponse(400, "No default channel is set."),
|
||||
expected: "No default channel is set.",
|
||||
},
|
||||
])("reports nothing for a $status: that is $why", async (refusal) => {
|
||||
fetchMock.mockResolvedValue(refusal.response());
|
||||
|
||||
const result = await call(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(captureExceptionMock).not.toHaveBeenCalled();
|
||||
expect(captureMessageMock).not.toHaveBeenCalled();
|
||||
// None of these refusals names a `code`.
|
||||
expect(result).toEqual({ error: refusal.expected, code: null });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The integration id is interpolated into every one of these URLs, so a
|
||||
* malformed one is refused before the request is built.
|
||||
*/
|
||||
describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
|
||||
it.each(["../../users", "not-a-uuid", ""])(
|
||||
"asks the API nothing when the integration id is %o",
|
||||
async (id) => {
|
||||
const result = await call(id);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ error: SLACK_GENERIC_ERROR_MESSAGE });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,653 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
import { pollTaskUntilSettled } from "@/actions/task/poll";
|
||||
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
|
||||
import {
|
||||
readSlackFailure,
|
||||
SLACK_GENERIC_ERROR_MESSAGE,
|
||||
SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
|
||||
SLACK_REASON_TOKEN,
|
||||
SLACK_UNREADABLE_RESULT_MESSAGE,
|
||||
slackErrorMessage,
|
||||
slackRateLimitMessage,
|
||||
slackUnknownReasonMessage,
|
||||
} from "@/lib/integrations/slack-errors";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
INTEGRATION_TYPE,
|
||||
type IntegrationProps,
|
||||
type SlackChannelOption,
|
||||
} from "@/types/integrations";
|
||||
|
||||
interface SlackUnavailable {
|
||||
unavailable: true;
|
||||
}
|
||||
|
||||
interface SlackRateLimited {
|
||||
rateLimited: true;
|
||||
retryAfterSeconds: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The API accepted the exchange (`2xx`) and the UI could not read the workspace
|
||||
* back: the install happened, only its result is unknown.
|
||||
*/
|
||||
interface SlackUnconfirmed {
|
||||
unconfirmed: true;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface SlackActionError {
|
||||
error: string;
|
||||
/**
|
||||
* The refusal's `code`, when it named one, alongside the copy. A caller reads
|
||||
* it to recognise a class of failure the wording cannot be pattern-matched
|
||||
* for — a Slack grant that has stopped working, which the contract allows
|
||||
* from any of these calls (Cross-cutting) and is recovered from by
|
||||
* reconnecting rather than by retrying.
|
||||
*/
|
||||
code?: string | null;
|
||||
}
|
||||
|
||||
interface SlackAuthorizeUrl {
|
||||
authorizeUrl: string;
|
||||
}
|
||||
|
||||
export type SlackAuthorizeUrlResult =
|
||||
| SlackAuthorizeUrl
|
||||
| SlackUnavailable
|
||||
| SlackRateLimited
|
||||
| SlackActionError;
|
||||
|
||||
interface SlackExchangeInput {
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
const slackExchangeInputSchema = z.object({
|
||||
code: z.string().min(1),
|
||||
state: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* SSRF guard: the integration id is interpolated into the request URL, so only
|
||||
* the shape the API's ids have reaches it.
|
||||
*/
|
||||
const integrationIdSchema = z.uuid();
|
||||
|
||||
const parseIntegrationId = (integrationId: string): string | null => {
|
||||
const parsed = integrationIdSchema.safeParse(integrationId);
|
||||
return parsed.success ? parsed.data : null;
|
||||
};
|
||||
|
||||
interface SlackExchangeSuccess {
|
||||
integration: IntegrationProps;
|
||||
}
|
||||
|
||||
export type SlackExchangeResult =
|
||||
| SlackExchangeSuccess
|
||||
| SlackUnavailable
|
||||
| SlackRateLimited
|
||||
| SlackUnconfirmed
|
||||
| SlackActionError;
|
||||
|
||||
/**
|
||||
* `503`: no Slack app configured in this deployment. `404`: no Slack API at
|
||||
* all. Both mean "not available here", unlike `429`/`502` which mean "not now".
|
||||
*/
|
||||
const isUnavailableStatus = (status: number): boolean =>
|
||||
status === 503 || status === 404;
|
||||
|
||||
const RATE_LIMITED_STATUS = 429;
|
||||
|
||||
const SLACK_AUTHORIZE_HOSTNAME = "slack.com";
|
||||
const SLACK_AUTHORIZE_PATHNAME = "/oauth/v2/authorize";
|
||||
const NO_AUTHORIZE_URL_MESSAGE = "Slack did not return an authorization URL.";
|
||||
|
||||
/**
|
||||
* The URL is rendered as the `Add to Slack` link's `href`, so anything that is
|
||||
* not Slack's consent screen is a redirect to an origin the user did not choose.
|
||||
*/
|
||||
const isSlackAuthorizeUrl = (value: string): boolean => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
url.protocol === "https:" &&
|
||||
url.hostname === SLACK_AUTHORIZE_HOSTNAME &&
|
||||
url.pathname === SLACK_AUTHORIZE_PATHNAME
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const INTEGRATIONS_RESOURCE_TYPE = "integrations";
|
||||
|
||||
/**
|
||||
* The callback names the workspace and redirects on this value alone, so a `2xx`
|
||||
* payload that is not a JSON:API resource (`{}`, `[]`, `"invalid"`) must read as
|
||||
* unreadable rather than as a connected workspace. Identity too: a resource
|
||||
* that is not a linkable Slack integration would be shown as the workspace
|
||||
* just installed.
|
||||
*/
|
||||
const isIntegrationResource = (value: unknown): boolean => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { id, type, attributes } = value as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
typeof id !== "string" ||
|
||||
id === "" ||
|
||||
type !== INTEGRATIONS_RESOURCE_TYPE ||
|
||||
typeof attributes !== "object" ||
|
||||
attributes === null ||
|
||||
Array.isArray(attributes)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(attributes as Record<string, unknown>).integration_type ===
|
||||
INTEGRATION_TYPE.SLACK
|
||||
);
|
||||
};
|
||||
|
||||
const failureFrom = async (
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<SlackUnavailable | SlackRateLimited | SlackActionError> => {
|
||||
if (isUnavailableStatus(response.status)) return { unavailable: true };
|
||||
|
||||
// A 5xx (including the `502` the contract reserves for "Slack upstream
|
||||
// broke") goes through the repo's 5xx handling, which reports to Sentry and
|
||||
// throws, so the caller's catch answers the user. Must run before
|
||||
// `readSlackFailure`: a body can only be read once.
|
||||
if (response.status >= 500) await handleApiResponse(response);
|
||||
|
||||
const failure = await readSlackFailure(response);
|
||||
|
||||
if (failure.status === RATE_LIMITED_STATUS) {
|
||||
return {
|
||||
rateLimited: true,
|
||||
retryAfterSeconds: failure.retryAfterSeconds,
|
||||
message: slackRateLimitMessage(failure.retryAfterSeconds),
|
||||
};
|
||||
}
|
||||
|
||||
return { error: slackErrorMessage(failure, fallback), code: failure.code };
|
||||
};
|
||||
|
||||
/**
|
||||
* `failureFrom` flattened to one refusal, for the calls whose only outcome is
|
||||
* "it did not work". Rate limiting keeps its own wording: `conversations.list`
|
||||
* is Slack tier 2, so a `429` shows up here (contract, Errors) and the wait it
|
||||
* names is the useful part.
|
||||
*/
|
||||
const refusalFrom = async (
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<SlackActionError> => {
|
||||
// Same 5xx handling as `failureFrom`, `503` excepted: here too it means Slack
|
||||
// is unavailable. Must run before `readSlackFailure`: a body can only be read
|
||||
// once.
|
||||
if (response.status >= 500 && response.status !== 503) {
|
||||
await handleApiResponse(response);
|
||||
}
|
||||
|
||||
const failure = await readSlackFailure(response);
|
||||
|
||||
return {
|
||||
error:
|
||||
failure.status === RATE_LIMITED_STATUS
|
||||
? slackRateLimitMessage(failure.retryAfterSeconds)
|
||||
: slackErrorMessage(failure, fallback),
|
||||
code: failure.code,
|
||||
};
|
||||
};
|
||||
|
||||
/** Mint an OAuth state and get the consent URL. Creates no integration. */
|
||||
export const getSlackAuthorizeUrl =
|
||||
async (): Promise<SlackAuthorizeUrlResult> => {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/integrations/slack/oauth/authorize-url`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { method: "POST", headers });
|
||||
|
||||
if (!response.ok) {
|
||||
// Awaited inside the `try`: a returned promise's rejection would skip
|
||||
// this `catch`, and a 5xx rejects.
|
||||
return await failureFrom(
|
||||
response,
|
||||
`Unable to start the Slack install: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The URL travels in JSON:API `meta`: the call creates no resource. A
|
||||
// non-JSON `2xx` reads as "no URL" instead of throwing a parser message
|
||||
// the user would be shown verbatim.
|
||||
const body = await response.json().catch(() => null);
|
||||
const authorizeUrl = body?.meta?.authorize_url;
|
||||
|
||||
// A URL that is not Slack's own is no more usable than a missing one.
|
||||
if (
|
||||
typeof authorizeUrl !== "string" ||
|
||||
!isSlackAuthorizeUrl(authorizeUrl)
|
||||
) {
|
||||
return { error: NO_AUTHORIZE_URL_MESSAGE };
|
||||
}
|
||||
|
||||
return { authorizeUrl };
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete the install with what Slack put in the callback URL. The API
|
||||
* consumes the `state`, exchanges the single-use `code`, and upserts the
|
||||
* tenant's Slack integration.
|
||||
*/
|
||||
export const exchangeSlackOAuthCode = async (
|
||||
input: SlackExchangeInput,
|
||||
): Promise<SlackExchangeResult> => {
|
||||
const parsed = slackExchangeInputSchema.safeParse(input);
|
||||
if (!parsed.success) return { error: SLACK_GENERIC_ERROR_MESSAGE };
|
||||
|
||||
const { code, state } = parsed.data;
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/integrations/slack/oauth/exchange`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "slack-oauth-exchanges",
|
||||
attributes: { code, state },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Awaited inside the `try`: unawaited, a 5xx's rejection would skip this
|
||||
// `catch` and leave the callback on its spinner.
|
||||
return await failureFrom(
|
||||
response,
|
||||
`Unable to connect the Slack workspace: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => null);
|
||||
|
||||
// Before the guard and on both paths: the API upserted the integration
|
||||
// before answering, so a cache filled when there was none would list the
|
||||
// connected workspace as missing.
|
||||
revalidatePath("/integrations");
|
||||
revalidatePath("/integrations/slack");
|
||||
|
||||
if (!isIntegrationResource(body?.data)) {
|
||||
return { unconfirmed: true, message: SLACK_UNREADABLE_RESULT_MESSAGE };
|
||||
}
|
||||
|
||||
return { integration: parseStringify(body.data) as IntegrationProps };
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
interface SlackChannelsSuccess {
|
||||
channels: SlackChannelOption[];
|
||||
/**
|
||||
* Present when these channels are only part of the workspace's, carrying the
|
||||
* sentence that says why: a partial read is a success, so the caller renders
|
||||
* the picker *and* the reason.
|
||||
*/
|
||||
incomplete?: string;
|
||||
/**
|
||||
* The `code` of the refusal that cut the read short, when it named one. A
|
||||
* grant that has stopped working refuses the second cursor page exactly as it
|
||||
* refuses the first, and a caller reading only the failure path would never
|
||||
* hear about it.
|
||||
*/
|
||||
code?: string | null;
|
||||
}
|
||||
|
||||
export type SlackChannelsResult = SlackChannelsSuccess | SlackActionError;
|
||||
|
||||
/**
|
||||
* Cursor pages followed before giving up: `conversations.list` is a tier-2,
|
||||
* rate-limited Slack call (design.md, Risks), so the aggregation is bounded
|
||||
* rather than open-ended.
|
||||
*/
|
||||
const MAX_CHANNEL_PAGES = 20;
|
||||
|
||||
/**
|
||||
* Every channel Prowler can post to in the connected workspace — the picker's
|
||||
* options.
|
||||
*
|
||||
* The durable primitive, not the channel stored on the integration (design D6):
|
||||
* a consumer needing a per-rule channel reads the same endpoint. `links.next`
|
||||
* is followed opaquely — the contract does not pin the cursor parameter naming,
|
||||
* so the UI never builds one of its own. An early stop that still read
|
||||
* something reports through `incomplete`, not as a failure.
|
||||
*/
|
||||
export const getSlackChannels = async (
|
||||
integrationId: string,
|
||||
): Promise<SlackChannelsResult> => {
|
||||
const id = parseIntegrationId(integrationId);
|
||||
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const channels: SlackChannelOption[] = [];
|
||||
|
||||
const listing = new URL(`${apiBaseUrl}/integrations/${id}/slack/channels`);
|
||||
let next: string | null = listing.toString();
|
||||
let incomplete: string | null = null;
|
||||
|
||||
try {
|
||||
for (let page = 0; next && page < MAX_CHANNEL_PAGES; page += 1) {
|
||||
const current: string = next;
|
||||
const response: Response = await fetch(current, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let refusal: SlackActionError;
|
||||
try {
|
||||
refusal = await refusalFrom(
|
||||
response,
|
||||
`Unable to read the workspace's channels: ${response.statusText}`,
|
||||
);
|
||||
} catch (error) {
|
||||
// `handleApiResponse` reported the 5xx and threw; a first-page
|
||||
// failure stays a failure, but later pages keep what was read.
|
||||
if (channels.length === 0) throw error;
|
||||
return { channels, incomplete: SLACK_GENERIC_ERROR_MESSAGE };
|
||||
}
|
||||
|
||||
return channels.length > 0
|
||||
? { channels, incomplete: refusal.error, code: refusal.code }
|
||||
: refusal;
|
||||
}
|
||||
|
||||
// A page that is not JSON reads as no channels, rather than throwing a
|
||||
// parser message the user would be shown verbatim.
|
||||
const body = await response.json().catch(() => null);
|
||||
|
||||
for (const resource of body?.data ?? []) {
|
||||
// Radix `Select.Item` refuses an empty value; one malformed resource
|
||||
// would break the whole picker.
|
||||
const channelId = resource?.id;
|
||||
if (typeof channelId !== "string" || channelId.length === 0) continue;
|
||||
channels.push({
|
||||
id: channelId,
|
||||
name: resource?.attributes?.name ?? "",
|
||||
is_private: Boolean(resource?.attributes?.is_private),
|
||||
});
|
||||
}
|
||||
|
||||
const rawNext = body?.links?.next;
|
||||
const candidate =
|
||||
typeof rawNext === "string" && rawNext.length > 0
|
||||
? new URL(rawNext, current)
|
||||
: null;
|
||||
// Resolved against the page it arrived on, so a cursor-only `next` keeps
|
||||
// this listing's path. Followed only while it stays on the listing's
|
||||
// origin: every page is fetched with the tenant's token, and an
|
||||
// off-origin hop made here would carry it along.
|
||||
if (candidate === null) {
|
||||
next = null;
|
||||
} else if (candidate.origin === listing.origin) {
|
||||
next = candidate.toString();
|
||||
} else {
|
||||
next = null;
|
||||
incomplete = SLACK_PARTIAL_CHANNEL_LIST_MESSAGE;
|
||||
}
|
||||
}
|
||||
|
||||
// A link still waiting when the budget ran out. Checked rather than assumed
|
||||
// from the page count: a workspace of exactly `MAX_CHANNEL_PAGES` pages was
|
||||
// read to the end.
|
||||
if (next) incomplete = SLACK_PARTIAL_CHANNEL_LIST_MESSAGE;
|
||||
|
||||
return incomplete === null ? { channels } : { channels, incomplete };
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
interface SlackDefaultChannelSuccess {
|
||||
integration: IntegrationProps;
|
||||
}
|
||||
|
||||
export type SlackDefaultChannelResult =
|
||||
| SlackDefaultChannelSuccess
|
||||
| SlackActionError;
|
||||
|
||||
/**
|
||||
* Record the channel Prowler posts to, on the generic integration endpoint.
|
||||
*
|
||||
* A Slack action despite the generic `PATCH`: `channel_not_found` and
|
||||
* `not_in_channel` carry the same `detail`, so only `code` tells them apart,
|
||||
* and the generic action reads `detail` alone. Only `channel_id` travels — the
|
||||
* API derives `channel_name` server-side (design D6).
|
||||
*/
|
||||
export const setSlackDefaultChannel = async (
|
||||
integrationId: string,
|
||||
channelId: string,
|
||||
): Promise<SlackDefaultChannelResult> => {
|
||||
const id = parseIntegrationId(integrationId);
|
||||
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "integrations",
|
||||
id,
|
||||
// `configuration` is the only attribute the save may carry: the write
|
||||
// serializer refuses whatever it does not accept, so naming the
|
||||
// integration's own (immutable) type is answered with a 400,
|
||||
// "Invalid fields: {'integration_type'}".
|
||||
attributes: { configuration: { channel_id: channelId } },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Awaited inside the `try`: unawaited, a 5xx's rejection would skip
|
||||
// this `catch`.
|
||||
return await refusalFrom(
|
||||
response,
|
||||
`Unable to save the destination channel: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => null);
|
||||
|
||||
// Before the guard and on both paths: the save happened, so a cache still
|
||||
// holding the previous channel would keep showing it.
|
||||
revalidatePath("/integrations");
|
||||
revalidatePath("/integrations/slack");
|
||||
|
||||
// Guarded as deep as the caller reads: it names the saved channel from
|
||||
// `attributes.configuration`.
|
||||
if (!body?.data?.attributes?.configuration) {
|
||||
return { error: SLACK_UNREADABLE_RESULT_MESSAGE };
|
||||
}
|
||||
|
||||
return { integration: parseStringify(body.data) as IntegrationProps };
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
interface SlackTestMessageSuccess {
|
||||
sent: true;
|
||||
}
|
||||
|
||||
export type SlackTestMessageResult = SlackTestMessageSuccess | SlackActionError;
|
||||
|
||||
interface SlackTestMessageTaskResult {
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
const TEST_MESSAGE_POLL = { maxAttempts: 20, delayMs: 3000 } as const;
|
||||
|
||||
/**
|
||||
* Post the test message to the integration's default channel.
|
||||
*
|
||||
* Async on the API's side — `202` plus a Task (design D9) — so this polls the
|
||||
* same task machinery the connection test uses. A `400` means no default
|
||||
* channel is recorded.
|
||||
*/
|
||||
export const sendSlackTestMessage = async (
|
||||
integrationId: string,
|
||||
): Promise<SlackTestMessageResult> => {
|
||||
const id = parseIntegrationId(integrationId);
|
||||
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/integrations/${id}/slack/test-message`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { method: "POST", headers });
|
||||
|
||||
if (!response.ok) {
|
||||
return await refusalFrom(
|
||||
response,
|
||||
`Unable to send the test message: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
// As above: an unreadable `202` is "no task to follow", not a parser
|
||||
// message.
|
||||
const body = await response.json().catch(() => null);
|
||||
const taskId = body?.data?.id;
|
||||
|
||||
if (!taskId) {
|
||||
return { error: "Slack did not start the test message." };
|
||||
}
|
||||
|
||||
const settled = await pollTaskUntilSettled<SlackTestMessageTaskResult>(
|
||||
taskId,
|
||||
TEST_MESSAGE_POLL,
|
||||
);
|
||||
|
||||
if (!settled.ok) {
|
||||
return { error: settled.error };
|
||||
}
|
||||
|
||||
// Slack's refusal travels in the task result, not in an HTTP error: the
|
||||
// post happens after the `202`. A known code gets Prowler's own wording, a
|
||||
// code-shaped reason is wrapped in one (contract, test-message), and prose
|
||||
// is shown as it arrived.
|
||||
const reason = settled.result?.error?.trim();
|
||||
if (reason) {
|
||||
return SLACK_REASON_TOKEN.test(reason)
|
||||
? {
|
||||
error: slackErrorMessage(
|
||||
{ code: reason },
|
||||
slackUnknownReasonMessage(reason),
|
||||
),
|
||||
// A dead grant can surface here too, so the reason travels on as
|
||||
// a `code`, not only as its sentence.
|
||||
code: reason,
|
||||
}
|
||||
: { error: reason };
|
||||
}
|
||||
if (settled.state !== "completed") {
|
||||
return { error: "Slack did not accept the test message." };
|
||||
}
|
||||
|
||||
return { sent: true };
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* What the API reports about revoking Prowler's token at Slack: one boolean in
|
||||
* `meta`, and nothing else — it sends no reason for a revocation that did not
|
||||
* happen, so there is no field here to hold one.
|
||||
*/
|
||||
export interface SlackRevocation {
|
||||
/**
|
||||
* Whether Slack confirmed the token no longer grants Prowler anything, or
|
||||
* `null` when the response carried no outcome. The contract says the outcome
|
||||
* is always reported, so `null` means the response is wrong, not the
|
||||
* revocation.
|
||||
*/
|
||||
revoked: boolean | null;
|
||||
}
|
||||
|
||||
interface SlackDisconnectSuccess {
|
||||
/** The integration is gone from Prowler, whatever Slack answered. */
|
||||
disconnected: true;
|
||||
revocation: SlackRevocation;
|
||||
}
|
||||
|
||||
export type SlackDisconnectResult = SlackDisconnectSuccess | SlackActionError;
|
||||
|
||||
/**
|
||||
* Disconnect the workspace: `DELETE /integrations/{id}`.
|
||||
*
|
||||
* The generic `deleteIntegration` cannot serve this: it discards the response
|
||||
* body, and the body is the whole point. Revocation at Slack is best-effort —
|
||||
* the row is removed either way and the outcome travels in JSON:API `meta` — so
|
||||
* a caller has to tell "gone and revoked" from "gone, but still installed in
|
||||
* Slack".
|
||||
*
|
||||
* A body without the field (an empty `204`, say) yields `null`, not `false`: an
|
||||
* unreported outcome must not send the user off to clean up Slack, nor be shown
|
||||
* as access revoked.
|
||||
*/
|
||||
export const disconnectSlackIntegration = async (
|
||||
integrationId: string,
|
||||
): Promise<SlackDisconnectResult> => {
|
||||
const id = parseIntegrationId(integrationId);
|
||||
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { method: "DELETE", headers });
|
||||
|
||||
if (!response.ok) {
|
||||
return await refusalFrom(
|
||||
response,
|
||||
`Unable to disconnect the Slack workspace: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => ({}));
|
||||
const meta = body?.meta ?? {};
|
||||
|
||||
revalidatePath("/integrations");
|
||||
revalidatePath("/integrations/slack");
|
||||
|
||||
return {
|
||||
disconnected: true,
|
||||
revocation: {
|
||||
revoked: typeof meta.revoked === "boolean" ? meta.revoked : null,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ApiKeyLinkCard } from "@/components/integrations/api-key/api-key-link-card";
|
||||
import { JiraIntegrationCard } from "@/components/integrations/jira/jira-integration-card";
|
||||
import { S3IntegrationCard } from "@/components/integrations/s3/s3-integration-card";
|
||||
import { SecurityHubIntegrationCard } from "@/components/integrations/security-hub/security-hub-integration-card";
|
||||
import { SlackIntegrationCard } from "@/components/integrations/slack/slack-integration-card";
|
||||
import { SsoLinkCard } from "@/components/integrations/sso/sso-link-card";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
/**
|
||||
* Split out of `page.tsx` for the browser-mode tests: `ContentLayout`'s navbar
|
||||
* streams async server children a client renderer can't resolve.
|
||||
*/
|
||||
export function IntegrationsContent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Connect external services to enhance your security workflow and
|
||||
automatically export your scan results.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{/* Amazon S3 Integration */}
|
||||
<S3IntegrationCard />
|
||||
|
||||
{/* AWS Security Hub Integration */}
|
||||
<SecurityHubIntegrationCard />
|
||||
|
||||
{/* Jira Integration */}
|
||||
<JiraIntegrationCard />
|
||||
|
||||
{/* Slack Integration - cloud-only API, nothing to manage self-hosted */}
|
||||
{isCloud() && <SlackIntegrationCard />}
|
||||
|
||||
{/* SSO Configuration - redirects to Profile */}
|
||||
<SsoLinkCard />
|
||||
|
||||
{/* API Keys - redirects to Profile */}
|
||||
<ApiKeyLinkCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Browser-mode tests for the Slack entry in the integrations catalogue
|
||||
* (`/integrations`), which is offered in Prowler Cloud only. Driven through
|
||||
* `SlackIntegrationHarness` against the MSW handlers.
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { it } from "@/__tests__/fixtures";
|
||||
import { slackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
|
||||
|
||||
import { SlackIntegrationHarness } from "./slack/slack-integration.harness";
|
||||
|
||||
describe("the integrations catalogue", () => {
|
||||
it("offers Slack in Prowler Cloud, with a way to manage it", async () => {
|
||||
// Given — a Prowler Cloud deployment (the fixtures' default island).
|
||||
const harness = new SlackIntegrationHarness(slackFixture());
|
||||
|
||||
harness.mountCatalogue();
|
||||
|
||||
expect(await harness.listedIntegrations()).toContain("Slack");
|
||||
expect(harness.offersSlackManagement()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("omits Slack in a deployment that is not Prowler Cloud", async ({
|
||||
seedRuntimeConfig,
|
||||
}) => {
|
||||
seedRuntimeConfig({ cloudEnabled: false });
|
||||
const harness = new SlackIntegrationHarness(slackFixture());
|
||||
|
||||
harness.mountCatalogue();
|
||||
|
||||
const listed = await harness.listedIntegrations();
|
||||
expect(listed).not.toContain("Slack");
|
||||
expect(harness.offersSlackManagement()).toBe(false);
|
||||
// Tripwire: the catalogue rendered, so the assertions above are Slack's
|
||||
// absence rather than the page failing to load.
|
||||
expect(listed).toContain("Jira");
|
||||
}, 30000);
|
||||
});
|
||||
@@ -1,40 +1,11 @@
|
||||
import {
|
||||
ApiKeyLinkCard,
|
||||
JiraIntegrationCard,
|
||||
S3IntegrationCard,
|
||||
SecurityHubIntegrationCard,
|
||||
SsoLinkCard,
|
||||
} from "@/components/integrations";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
|
||||
import { IntegrationsContent } from "./integrations-content";
|
||||
|
||||
export default async function Integrations() {
|
||||
return (
|
||||
<ContentLayout title="Integrations" icon="lucide:puzzle">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Connect external services to enhance your security workflow and
|
||||
automatically export your scan results.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{/* Amazon S3 Integration */}
|
||||
<S3IntegrationCard />
|
||||
|
||||
{/* AWS Security Hub Integration */}
|
||||
<SecurityHubIntegrationCard />
|
||||
|
||||
{/* Jira Integration */}
|
||||
<JiraIntegrationCard />
|
||||
|
||||
{/* SSO Configuration - redirects to Profile */}
|
||||
<SsoLinkCard />
|
||||
|
||||
{/* API Keys - redirects to Profile */}
|
||||
<ApiKeyLinkCard />
|
||||
</div>
|
||||
</div>
|
||||
<IntegrationsContent />
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { SlackCallback } from "@/components/integrations/slack/slack-callback";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
export default async function SlackCallbackPage() {
|
||||
if (!isCloud()) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentLayout title="Slack">
|
||||
{/* `SlackCallback` reads the query string, so it needs a boundary. */}
|
||||
<Suspense fallback={null}>
|
||||
<SlackCallback />
|
||||
</Suspense>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
import { SlackIntegrationContent } from "./slack-integration-content";
|
||||
|
||||
export default async function SlackIntegrationPage() {
|
||||
// The Slack API is cloud-only, so self-hosted has nothing behind this page.
|
||||
// Mirrors `/alerts`.
|
||||
if (!isCloud()) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentLayout title="Slack">
|
||||
<div className="flex flex-col gap-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Connect a Slack workspace so Prowler can post to one of its channels.
|
||||
</p>
|
||||
|
||||
<SlackIntegrationContent />
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Browser-mode tests for the Slack OAuth callback
|
||||
* (`/integrations/slack/callback`), driven through `SlackIntegrationHarness`.
|
||||
* MSW answers from handlers derived from the API contract in `design.md`.
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { it } from "@/__tests__/fixtures";
|
||||
import {
|
||||
SLACK_EXCHANGE_OUTCOME,
|
||||
SLACK_OAUTH_CODE,
|
||||
SLACK_OAUTH_STATE,
|
||||
slackFixture,
|
||||
} from "@/__tests__/msw/handlers/slack.fixtures";
|
||||
|
||||
import { SlackIntegrationHarness } from "./slack-integration.harness";
|
||||
|
||||
/** The workspace the fixtures connect. */
|
||||
const WORKSPACE_NAME = "Prowler HQ";
|
||||
|
||||
/**
|
||||
* Callback headlines, spelled out rather than imported so a rename fails here.
|
||||
* `FAILURE_TITLE` is for installs that connected nothing; `UNCONFIRMED_TITLE`
|
||||
* for answers that arrive after the API already upserted the integration.
|
||||
*/
|
||||
const FAILURE_TITLE = "Slack workspace not connected";
|
||||
const UNCONFIRMED_TITLE = "Slack install not confirmed";
|
||||
|
||||
describe("returning from Slack", () => {
|
||||
it("completes the install and shows the connected workspace", async () => {
|
||||
const harness = new SlackIntegrationHarness(slackFixture());
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
|
||||
expect(await harness.completedInstall()).toBe(true);
|
||||
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
|
||||
// The code is single-use and the exchange runs from a render (design D4):
|
||||
// without the once-guard, a second call burns it and reports a failure.
|
||||
expect(harness.exchangeCallCount).toBe(1);
|
||||
// A completed install invalidates the cached "none connected".
|
||||
expect(harness.revalidatedPaths).toEqual(
|
||||
expect.arrayContaining(["/integrations", "/integrations/slack"]),
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
it("does not report an install the API completed as failed when it answers no content", async () => {
|
||||
// Given — a `204`: the API consumed the code and upserted the integration,
|
||||
// then answered with no body. `response.ok` is true, so this is no refusal.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({
|
||||
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT,
|
||||
}),
|
||||
);
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
|
||||
const reason = await harness.installFailureReason();
|
||||
expect(reason).toMatch(/could not read the result of the install/);
|
||||
expect(reason).toMatch(/Slack integration page/);
|
||||
expect(reason).not.toMatch(/JSON/i);
|
||||
expect(harness.offersRetry()).toBe(true);
|
||||
// The `204` says the workspace is connected; the headline cannot deny it.
|
||||
expect(await harness.installFailureTitle()).toBe(UNCONFIRMED_TITLE);
|
||||
// The install exists, so the cached "none connected" has to go with it.
|
||||
expect(harness.revalidatedPaths).toEqual(
|
||||
expect.arrayContaining(["/integrations", "/integrations/slack"]),
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
it("shows Prowler's own wording when a proxy answers the completion with an HTML page", async () => {
|
||||
// Given — a proxy answering `200` with a challenge page instead of JSON.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML }),
|
||||
);
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
|
||||
// V8's parse message truncates before the word `html`, so the shared
|
||||
// HTML-shaped-error filter cannot catch this one.
|
||||
const reason = await harness.installFailureReason();
|
||||
expect(reason).toMatch(/could not read the result of the install/);
|
||||
expect(reason).not.toMatch(/DOCTYPE/i);
|
||||
expect(reason).not.toMatch(/not valid JSON/i);
|
||||
}, 30000);
|
||||
|
||||
it("says the result is unreadable, not that the workspace is unknown, when the answer names no resource", async () => {
|
||||
// Given — a `200` carrying well-formed JSON:API with no `data` member.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({
|
||||
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA,
|
||||
}),
|
||||
);
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
|
||||
const reason = await harness.installFailureReason();
|
||||
expect(reason).toMatch(/could not read the result of the install/);
|
||||
expect(reason).not.toMatch(/undefined/i);
|
||||
expect(await harness.completedInstall()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("connects nothing when the user declines in Slack, and offers to retry", async () => {
|
||||
const harness = new SlackIntegrationHarness(slackFixture());
|
||||
|
||||
await harness.mountCallback({ error: "access_denied" });
|
||||
|
||||
expect(await harness.installFailureReason()).toMatch(
|
||||
/not approved in Slack/,
|
||||
);
|
||||
expect(harness.offersRetry()).toBe(true);
|
||||
// A declined consent carries no code, so there was nothing to exchange.
|
||||
expect(harness.exchangeCallCount).toBe(0);
|
||||
}, 30000);
|
||||
|
||||
it("surfaces the reason when Slack refuses to complete the install", async () => {
|
||||
// Given — Slack rejects the code, and the API's own wording explains it.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED }),
|
||||
);
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
|
||||
// A refusal Prowler has no wording of its own for falls back to the API's
|
||||
// `detail`, not to a generic failure.
|
||||
expect(await harness.installFailureReason()).toMatch(
|
||||
/OAuth code is invalid/,
|
||||
);
|
||||
expect(harness.offersRetry()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("surfaces a completion the API refuses, and connects nothing", async () => {
|
||||
// Given — the state was minted for another session, or already consumed.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REFUSED_STATE }),
|
||||
);
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: "state-from-another-session",
|
||||
});
|
||||
|
||||
expect(await harness.installFailureReason()).toMatch(
|
||||
/state is invalid, expired, or already consumed/,
|
||||
);
|
||||
// The API refused before consuming anything, so nothing was created and the
|
||||
// headline states that plainly.
|
||||
expect(await harness.installFailureTitle()).toBe(FAILURE_TITLE);
|
||||
expect(await harness.completedInstall()).toBe(false);
|
||||
expect(harness.offersRetry()).toBe(true);
|
||||
// Refused once, not retried into a second burnt code.
|
||||
expect(harness.exchangeCallCount).toBe(1);
|
||||
}, 30000);
|
||||
|
||||
it("says how to resolve a workspace conflict, in Prowler's own words", async () => {
|
||||
// Given — this tenant already has a different workspace connected, which
|
||||
// the API refuses as a 409 naming the conflict in `code`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({
|
||||
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE,
|
||||
}),
|
||||
);
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
|
||||
// The copy comes from the error `code`: the API's `detail` states the
|
||||
// conflict but not the way out of it.
|
||||
const reason = await harness.installFailureReason();
|
||||
expect(reason).toMatch(/already connected to a different Slack workspace/);
|
||||
expect(reason).toMatch(/Disconnect it before connecting another/);
|
||||
expect(reason).not.toMatch(/tenant/);
|
||||
expect(await harness.completedInstall()).toBe(false);
|
||||
expect(harness.offersRetry()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("tells the user when to come back if Slack is rate limiting the install", async () => {
|
||||
// Given — Slack answers 429 with a Retry-After.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ rateLimited: true }),
|
||||
);
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
|
||||
const reason = await harness.installFailureReason();
|
||||
expect(reason).toMatch(/rate limiting/);
|
||||
expect(reason).toMatch(/about 30 seconds/);
|
||||
expect(reason).not.toMatch(/not available in this environment/);
|
||||
// A 429 refuses the exchange outright, so nothing was connected: the plain
|
||||
// headline, unlike the unreadable `2xx` that arrives after the upsert.
|
||||
expect(await harness.installFailureTitle()).toBe(FAILURE_TITLE);
|
||||
expect(harness.offersRetry()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("reports Slack being broken upstream, rather than leaving the callback spinning", async () => {
|
||||
// Given — the completion answers `502`, the contract's status for a Slack
|
||||
// upstream failure. The shared 5xx handling throws, so the callback only
|
||||
// renders this if the action answers that rejection itself.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ oauthUpstreamError: true }),
|
||||
);
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
|
||||
// The API refused, so nothing was created: not the "could not confirm" the
|
||||
// page falls back to when the action never answers at all.
|
||||
const reason = await harness.installFailureReason();
|
||||
expect(reason).toMatch(/temporarily unavailable/);
|
||||
expect(reason).not.toMatch(/could not confirm/);
|
||||
expect(await harness.completedInstall()).toBe(false);
|
||||
expect(harness.offersRetry()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("does not attempt an exchange when the completion carries no state", async () => {
|
||||
const harness = new SlackIntegrationHarness(slackFixture());
|
||||
|
||||
await harness.mountCallback({ code: SLACK_OAUTH_CODE });
|
||||
|
||||
// Refused before the API is ever asked, so no code is spent.
|
||||
expect(await harness.installFailureReason()).toMatch(/incomplete response/);
|
||||
expect(harness.exchangeCallCount).toBe(0);
|
||||
}, 30000);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { getIntegrations } from "@/actions/integrations/integrations";
|
||||
import { getSlackAuthorizeUrl } from "@/actions/integrations/slack";
|
||||
import { SlackIntegrationManager } from "@/components/integrations/slack/slack-integration-manager";
|
||||
import { GENERIC_SERVER_ERROR_MESSAGE } from "@/lib/helper";
|
||||
import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations";
|
||||
|
||||
/**
|
||||
* `getIntegrations` throws a `>= 500` answer past its own catch, which covers
|
||||
* only transport. Uncaught it trips the route's error boundary and replaces a
|
||||
* page that could still offer the install, so report it as `{ error }` and take
|
||||
* the page's one error path.
|
||||
*/
|
||||
const readSlackIntegrations = async (searchParams: URLSearchParams) => {
|
||||
try {
|
||||
return await getIntegrations(searchParams);
|
||||
} catch {
|
||||
// The thrown message can carry the server's own wording; `handleApiResponse`
|
||||
// already reported it to Sentry.
|
||||
return { error: GENERIC_SERVER_ERROR_MESSAGE };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Split out of `page.tsx` so the browser-mode tests can render it without the
|
||||
* surrounding `ContentLayout`.
|
||||
*/
|
||||
export async function SlackIntegrationContent() {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("filter[integration_type]", INTEGRATION_TYPE.SLACK);
|
||||
// One workspace per tenant, so one row is the whole result set.
|
||||
searchParams.set("page[size]", "1");
|
||||
|
||||
const integrations = await readSlackIntegrations(searchParams);
|
||||
const loadError =
|
||||
integrations && "error" in integrations
|
||||
? (integrations.error as string)
|
||||
: null;
|
||||
const integration: IntegrationProps | null =
|
||||
(integrations?.data?.[0] as IntegrationProps | undefined) ?? null;
|
||||
|
||||
const authorize = integration ? null : await getSlackAuthorizeUrl();
|
||||
|
||||
return (
|
||||
<SlackIntegrationManager
|
||||
integration={integration}
|
||||
authorizeUrl={
|
||||
authorize && "authorizeUrl" in authorize ? authorize.authorizeUrl : null
|
||||
}
|
||||
unavailable={Boolean(authorize && "unavailable" in authorize)}
|
||||
// Rate limited is not unavailable: the install is still on offer, it just
|
||||
// cannot be started yet.
|
||||
rateLimitMessage={
|
||||
authorize && "rateLimited" in authorize ? authorize.message : null
|
||||
}
|
||||
loadError={
|
||||
loadError ??
|
||||
(authorize && "error" in authorize ? authorize.error : null)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
/**
|
||||
* Page-level test harness for the Slack integration (Vitest Browser Mode).
|
||||
*
|
||||
* A client renderer cannot render an async server component, so the component is
|
||||
* called and the element it returns is what gets rendered.
|
||||
*/
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createElement } from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { BrowserHarness } from "@/__tests__/browser-harness";
|
||||
import { handlersForSlack } from "@/__tests__/msw/handlers/slack";
|
||||
import type { SlackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
|
||||
import { worker } from "@/__tests__/msw/worker";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import { setSlackDefaultChannel } from "@/actions/integrations/slack";
|
||||
import { SlackCallback } from "@/components/integrations/slack/slack-callback";
|
||||
|
||||
import { IntegrationsContent } from "../integrations-content";
|
||||
|
||||
import { SlackIntegrationContent } from "./slack-integration-content";
|
||||
|
||||
export const CONNECTION_OUTCOME = {
|
||||
SUCCESS: "success",
|
||||
FAILURE: "failure",
|
||||
} as const;
|
||||
|
||||
export type ConnectionOutcome =
|
||||
(typeof CONNECTION_OUTCOME)[keyof typeof CONNECTION_OUTCOME];
|
||||
|
||||
export const TEST_MESSAGE_OUTCOME = {
|
||||
SENT: "sent",
|
||||
FAILED: "failed",
|
||||
} as const;
|
||||
|
||||
export type TestMessageOutcome =
|
||||
(typeof TEST_MESSAGE_OUTCOME)[keyof typeof TEST_MESSAGE_OUTCOME];
|
||||
|
||||
/** Sentinel: the page settled on "no channel recorded", rather than not yet. */
|
||||
const NO_DEFAULT_CHANNEL = "<no channel recorded>";
|
||||
|
||||
export const REVOCATION_OUTCOME = {
|
||||
REVOKED: "revoked",
|
||||
NOT_REVOKED: "not-revoked",
|
||||
/** The answer said nothing either way, so the page claims neither. */
|
||||
UNREPORTED: "unreported",
|
||||
} as const;
|
||||
|
||||
export type RevocationOutcome =
|
||||
(typeof REVOCATION_OUTCOME)[keyof typeof REVOCATION_OUTCOME];
|
||||
|
||||
interface CallbackParams {
|
||||
code?: string;
|
||||
state?: string;
|
||||
/** Slack's own refusal code, e.g. `access_denied`. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** What a picker search leaves on offer. */
|
||||
interface ChannelSearch {
|
||||
/** Names still offered once the filter landed, in the order offered. */
|
||||
offered: string[];
|
||||
/** The picker's no-match note; null while any channel is still offered. */
|
||||
emptyNote: string | null;
|
||||
}
|
||||
|
||||
export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
||||
get exchangeCallCount(): number {
|
||||
return this.countRequests("POST", "/slack/oauth/exchange");
|
||||
}
|
||||
|
||||
get authorizeUrlCallCount(): number {
|
||||
return this.countRequests("POST", "/slack/oauth/authorize-url");
|
||||
}
|
||||
|
||||
/** Paths the actions asked Next to refresh (`next/cache` is stubbed in this lane). */
|
||||
get revalidatedPaths(): string[] {
|
||||
return vi.mocked(revalidatePath).mock.calls.map(([path]) => path);
|
||||
}
|
||||
|
||||
// --- Mounting -----------------------------------------------------------
|
||||
|
||||
private wireHandlers(): void {
|
||||
// The stub is module-level and shared, so clearing it here is what makes
|
||||
// `revalidatedPaths` mean "since this mount".
|
||||
vi.mocked(revalidatePath).mockClear();
|
||||
worker.use(...handlersForSlack(this.fixture));
|
||||
this.trackRequests(worker);
|
||||
}
|
||||
|
||||
async mount(): Promise<void> {
|
||||
window.history.replaceState(null, "", "/integrations/slack");
|
||||
this.wireHandlers();
|
||||
|
||||
const readsBefore = this.channelListCallCount;
|
||||
this.mounted = render(await SlackIntegrationContent());
|
||||
if (this.fixture.install) await this.waitForChannelsRead(readsBefore);
|
||||
}
|
||||
|
||||
private mounted: ReturnType<typeof render> | null = null;
|
||||
|
||||
/**
|
||||
* Open the management page again, the way a later visit does — the handlers in
|
||||
* place keep serving what the previous visit left behind. Unmounts the previous
|
||||
* render first: two live copies would make every assertion ambiguous.
|
||||
*/
|
||||
async revisit(): Promise<void> {
|
||||
(await this.mounted)?.unmount();
|
||||
const readsBefore = this.channelListCallCount;
|
||||
this.mounted = render(await SlackIntegrationContent());
|
||||
await this.mounted;
|
||||
if (this.fixture.install) await this.waitForChannelsRead(readsBefore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the page's server data under the open card, as `revalidatePath` does
|
||||
* after an action: new props, no unmount, so React state survives — unlike
|
||||
* `revisit()`, which re-seeds everything from scratch.
|
||||
*/
|
||||
async refreshPageData(): Promise<void> {
|
||||
const rendered = await this.mounted;
|
||||
if (!rendered) {
|
||||
throw new Error("refreshPageData: the page is not mounted");
|
||||
}
|
||||
await rendered.rerender(await SlackIntegrationContent());
|
||||
}
|
||||
|
||||
async mountCallback({ code, state, error }: CallbackParams): Promise<void> {
|
||||
const params = new URLSearchParams();
|
||||
if (code) params.set("code", code);
|
||||
if (state) params.set("state", state);
|
||||
if (error) params.set("error", error);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`/integrations/slack/callback?${params.toString()}`,
|
||||
);
|
||||
this.wireHandlers();
|
||||
|
||||
render(createElement(SlackCallback));
|
||||
}
|
||||
|
||||
/** Mount the integrations catalogue. No handlers: every card there is static. */
|
||||
mountCatalogue(): void {
|
||||
window.history.replaceState(null, "", "/integrations");
|
||||
|
||||
render(createElement(IntegrationsContent));
|
||||
}
|
||||
|
||||
// --- The integrations catalogue ------------------------------------------
|
||||
|
||||
async listedIntegrations(): Promise<string[]> {
|
||||
const headings = await this.waitFor(
|
||||
() => {
|
||||
const found = Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>("h4"),
|
||||
);
|
||||
return found.length > 0 ? found : null;
|
||||
},
|
||||
5000,
|
||||
"the integrations catalogue",
|
||||
);
|
||||
return headings.map((heading) => (heading.textContent ?? "").trim());
|
||||
}
|
||||
|
||||
offersSlackManagement(): boolean {
|
||||
return this.q('a[href="/integrations/slack"]') !== null;
|
||||
}
|
||||
|
||||
// --- Starting the install -----------------------------------------------
|
||||
|
||||
private connectLink(): HTMLAnchorElement | null {
|
||||
return (
|
||||
Array.from(this.container.querySelectorAll("a")).find((anchor) =>
|
||||
/Add to Slack/.test(anchor.textContent ?? ""),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async authorizeUrl(): Promise<string> {
|
||||
const link = await this.waitFor(
|
||||
() => this.connectLink(),
|
||||
5000,
|
||||
"the Add to Slack link",
|
||||
);
|
||||
return link.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the install affordance and reports where it points. The default
|
||||
* action is cancelled: following the link navigates the test frame off the app.
|
||||
*/
|
||||
async connect(): Promise<string> {
|
||||
const link = await this.waitFor(
|
||||
() => this.connectLink(),
|
||||
5000,
|
||||
"the Add to Slack link",
|
||||
);
|
||||
|
||||
let destination = "";
|
||||
const intercept = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
destination = link.href;
|
||||
};
|
||||
link.addEventListener("click", intercept);
|
||||
try {
|
||||
await this.clickElement(link, { fallbackToDomClick: true });
|
||||
} finally {
|
||||
link.removeEventListener("click", intercept);
|
||||
}
|
||||
|
||||
return destination;
|
||||
}
|
||||
|
||||
offersInstall(): boolean {
|
||||
return this.connectLink() !== null;
|
||||
}
|
||||
|
||||
async waitForUnavailable(): Promise<void> {
|
||||
await this.waitForText(/Slack is not available in this environment yet/);
|
||||
}
|
||||
|
||||
saysUnavailable(): boolean {
|
||||
return this.containsText(/Slack is not available in this environment yet/);
|
||||
}
|
||||
|
||||
saysLoadFailed(): boolean {
|
||||
return this.containsText(/Could not load your Slack integration/);
|
||||
}
|
||||
|
||||
async rateLimitNotice(): Promise<string> {
|
||||
await this.waitForText(/Slack is busy right now/, 10000);
|
||||
const description = await this.waitFor(
|
||||
() => this.q('[data-slot="alert-description"]'),
|
||||
5000,
|
||||
"the rate limit notice",
|
||||
);
|
||||
return (description.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
async loadErrorNotice(): Promise<string> {
|
||||
await this.waitForText(/Could not load your Slack integration/, 10000);
|
||||
const description = await this.waitFor(
|
||||
() => this.q('[data-slot="alert-description"]'),
|
||||
5000,
|
||||
"the load error notice",
|
||||
);
|
||||
return (description.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
// --- Connected state ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read from the heading element, not the page text: in `textContent`
|
||||
* "Connected to <workspace>" runs straight into the copy that follows it.
|
||||
*/
|
||||
async connectedWorkspaceName(): Promise<string> {
|
||||
const heading = await this.waitFor(
|
||||
() => this.deepestElementMatching(/^Connected to \S/),
|
||||
5000,
|
||||
"the connected workspace name",
|
||||
);
|
||||
return (heading.textContent ?? "").trim().replace(/^Connected to /, "");
|
||||
}
|
||||
|
||||
/** Last match in document order: every ancestor of a match matches too. */
|
||||
private deepestElementMatching(pattern: RegExp): HTMLElement | null {
|
||||
return (
|
||||
Array.from(this.container.querySelectorAll<HTMLElement>("*"))
|
||||
.reverse()
|
||||
.find((element) => pattern.test((element.textContent ?? "").trim())) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed on the badge's state attribute, not its copy: the heading beside it
|
||||
* also starts "Connected to …".
|
||||
*/
|
||||
async connectionBadge(): Promise<string> {
|
||||
const badge = await this.waitFor(
|
||||
() => this.q("[data-connection-status]"),
|
||||
5000,
|
||||
"the connection badge",
|
||||
);
|
||||
return (badge.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
async offersConnectionTest(): Promise<boolean> {
|
||||
const button = await this.waitFor(
|
||||
() => this.buttonByText(/Test connection/),
|
||||
5000,
|
||||
"the Test connection button",
|
||||
);
|
||||
return !button.disabled;
|
||||
}
|
||||
|
||||
saysChannelIsNextStep(): boolean {
|
||||
return this.containsText(/Choosing a destination channel is the next step/);
|
||||
}
|
||||
|
||||
/**
|
||||
* The "last checked" line as rendered, or null when the page shows none —
|
||||
* which is what a workspace whose connection was never checked shows.
|
||||
*/
|
||||
lastCheckedLine(): string | null {
|
||||
const line = Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>("p"),
|
||||
).find((p) => /^Last checked:/.test((p.textContent ?? "").trim()));
|
||||
return line ? (line.textContent ?? "").trim() : null;
|
||||
}
|
||||
|
||||
async testConnection(): Promise<ConnectionOutcome> {
|
||||
await this.clickButton(/Test connection/);
|
||||
|
||||
return this.waitFor(
|
||||
() => {
|
||||
if (this.containsText(/Connection test successful/)) {
|
||||
return CONNECTION_OUTCOME.SUCCESS;
|
||||
}
|
||||
if (this.containsText(/Connection test failed/)) {
|
||||
return CONNECTION_OUTCOME.FAILURE;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
15000,
|
||||
"the connection test outcome",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Returning from Slack -----------------------------------------------
|
||||
|
||||
/**
|
||||
* The one element every non-success outcome renders. Keyed on it rather than
|
||||
* the alert title, which is not the same claim on every outcome.
|
||||
*/
|
||||
private backLink(): HTMLAnchorElement | null {
|
||||
return (
|
||||
Array.from(this.container.querySelectorAll("a")).find(
|
||||
(anchor) =>
|
||||
anchor.getAttribute("href") === "/integrations/slack" &&
|
||||
/Back to Slack integration/.test(anchor.textContent ?? ""),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async completedInstall(): Promise<boolean> {
|
||||
const outcome = await this.waitFor(
|
||||
() => this.containsText(/Connected to /) || this.backLink() !== null,
|
||||
10000,
|
||||
"the callback outcome",
|
||||
);
|
||||
return outcome && this.containsText(/Connected to /);
|
||||
}
|
||||
|
||||
async installFailureReason(): Promise<string> {
|
||||
await this.waitFor(() => this.backLink(), 10000, "the failed callback");
|
||||
const description = await this.waitFor(
|
||||
() => this.q('[data-slot="alert-description"]'),
|
||||
5000,
|
||||
"the failure reason",
|
||||
);
|
||||
return (description.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
async installFailureTitle(): Promise<string> {
|
||||
await this.waitFor(() => this.backLink(), 10000, "the failed callback");
|
||||
const title = await this.waitFor(
|
||||
() => this.q('[data-slot="alert-title"]'),
|
||||
5000,
|
||||
"the failure title",
|
||||
);
|
||||
return (title.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
offersRetry(): boolean {
|
||||
return this.backLink() !== null || this.offersInstall();
|
||||
}
|
||||
|
||||
// --- Choosing a destination channel --------------------------------------
|
||||
|
||||
/** Channel reads issued — one per cursor page the UI followed. */
|
||||
get channelListCallCount(): number {
|
||||
return this.countRequests("GET", "/slack/channels");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the channel read every connected mount starts, counting from the
|
||||
* reads already issued: one still in flight when the test ends lands in the
|
||||
* middle of the next, against a harness that never asked for it.
|
||||
*/
|
||||
private async waitForChannelsRead(readsBefore: number): Promise<void> {
|
||||
await this.waitFor(
|
||||
() => {
|
||||
const refresh = this.buttonByText(/Refresh channels/);
|
||||
return this.channelListCallCount > readsBefore &&
|
||||
refresh !== null &&
|
||||
!refresh.disabled
|
||||
? true
|
||||
: null;
|
||||
},
|
||||
15000,
|
||||
"the workspace's channels to be read",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the picker and hand back its options. A re-render landing mid-gesture
|
||||
* makes Radix drop the open state, so re-open from the keyboard when nothing
|
||||
* mounted at all.
|
||||
*/
|
||||
private async openChannelPicker(): Promise<HTMLElement[]> {
|
||||
const mounted = (): HTMLElement[] | null => {
|
||||
const options = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
);
|
||||
return options.length > 0 ? options : null;
|
||||
};
|
||||
|
||||
const alreadyOpen = mounted();
|
||||
if (alreadyOpen) return alreadyOpen;
|
||||
|
||||
const trigger = await this.waitFor<HTMLElement>(
|
||||
() => this.q("#slack-channel"),
|
||||
10000,
|
||||
"the channel picker",
|
||||
);
|
||||
|
||||
await this.clickElement(trigger, { fallbackToDomClick: true });
|
||||
|
||||
let options = await this.waitForOrNull(
|
||||
mounted,
|
||||
2000,
|
||||
"the channel options",
|
||||
);
|
||||
if (!options) {
|
||||
await this.user.keyboard("{Enter}");
|
||||
options = await this.waitForOrNull(mounted, 8000, "the channel options");
|
||||
}
|
||||
|
||||
if (!options) {
|
||||
throw new Error("openChannelPicker: the channel picker offered nothing");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private async closeChannelPicker(): Promise<void> {
|
||||
await this.user.keyboard("{Escape}");
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the workspace's channels, the way a user does after inviting
|
||||
* `@Prowler` to one in Slack. Waits for the read to have settled, not for the
|
||||
* click alone.
|
||||
*/
|
||||
async refreshChannels(): Promise<void> {
|
||||
const readsBefore = this.channelListCallCount;
|
||||
await this.clickButton(/Refresh channels/);
|
||||
|
||||
await this.waitFor(
|
||||
() => {
|
||||
const button = this.buttonByText(/Refresh channels/);
|
||||
return (
|
||||
this.channelListCallCount > readsBefore &&
|
||||
button !== null &&
|
||||
!button.disabled
|
||||
);
|
||||
},
|
||||
15000,
|
||||
"the workspace's channels to be read again",
|
||||
);
|
||||
}
|
||||
|
||||
/** The channels the workspace offers, in the order the picker lists them. */
|
||||
async channelOptions(): Promise<string[]> {
|
||||
const options = await this.openChannelPicker();
|
||||
const names = options.map(
|
||||
(option) => option.getAttribute("data-channel") ?? "",
|
||||
);
|
||||
|
||||
await this.closeChannelPicker();
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the channel offered under `name` is presented as private — read
|
||||
* from the marker the user sees, not from how the option is wired up.
|
||||
*/
|
||||
async isChannelShownAsPrivate(name: string): Promise<boolean> {
|
||||
const options = await this.openChannelPicker();
|
||||
const option = options.find(
|
||||
(element) => element.getAttribute("data-channel") === name,
|
||||
);
|
||||
|
||||
await this.closeChannelPicker();
|
||||
|
||||
return /Private/.test(option?.textContent ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the picker, type `query` into its search, and hand back what the
|
||||
* filter leaves on offer. The search dies with the popover, so each call
|
||||
* starts from the full list.
|
||||
*/
|
||||
async searchChannels(query: string): Promise<ChannelSearch> {
|
||||
const all = await this.openChannelPicker();
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>("[cmdk-input]");
|
||||
if (!input) {
|
||||
throw new Error("searchChannels: the open picker has no search field");
|
||||
}
|
||||
await this.user.fill(input, query);
|
||||
|
||||
// The filter lands a render after the last keystroke: the offered set
|
||||
// shrinks, or the no-match note shows. A query that matches everything
|
||||
// would never settle — the tests only narrow.
|
||||
await this.waitFor(
|
||||
() =>
|
||||
document.querySelectorAll('[role="option"]').length !== all.length ||
|
||||
document.querySelector("[cmdk-empty]") !== null ||
|
||||
null,
|
||||
5000,
|
||||
"the search to narrow the channels",
|
||||
);
|
||||
|
||||
const offered = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
).map((option) => option.getAttribute("data-channel") ?? "");
|
||||
const emptyNote =
|
||||
document.querySelector<HTMLElement>("[cmdk-empty]")?.textContent ?? null;
|
||||
|
||||
await this.closeChannelPicker();
|
||||
|
||||
return { offered, emptyNote };
|
||||
}
|
||||
|
||||
private async pickAndSave(name: string): Promise<void> {
|
||||
const options = await this.openChannelPicker();
|
||||
const option = options.find(
|
||||
(element) => element.getAttribute("data-channel") === name,
|
||||
);
|
||||
|
||||
if (!option) {
|
||||
throw new Error(`pickAndSave: no channel named "${name}" is offered`);
|
||||
}
|
||||
|
||||
await this.user.click(option);
|
||||
await this.waitForTransition();
|
||||
await this.clickButton(/Save channel/);
|
||||
}
|
||||
|
||||
/** Pick a channel, save it, and wait for it to be recorded as the destination. */
|
||||
async chooseChannel(name: string): Promise<void> {
|
||||
await this.pickAndSave(name);
|
||||
await this.waitFor(
|
||||
() => this.defaultChannelName() === name,
|
||||
15000,
|
||||
`#${name} to be recorded as the destination`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a different destination away from this page — a second tab, or someone
|
||||
* else in the tenant. Goes through the same call the page makes, leaving this
|
||||
* page's own copy of it untouched.
|
||||
*/
|
||||
async channelRecordedElsewhere(name: string): Promise<void> {
|
||||
const channel = this.fixture.channels.find((c) => c.name === name);
|
||||
if (!channel) {
|
||||
throw new Error(
|
||||
`channelRecordedElsewhere: no channel named "${name}" is offered`,
|
||||
);
|
||||
}
|
||||
|
||||
const integrationId = this.fixture.install?.id;
|
||||
if (!integrationId) {
|
||||
throw new Error("channelRecordedElsewhere: no workspace is connected");
|
||||
}
|
||||
|
||||
const result = await setSlackDefaultChannel(integrationId, channel.id);
|
||||
if ("error" in result) {
|
||||
throw new Error(`channelRecordedElsewhere: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the picked channel can be saved — false when there is nothing new to save. */
|
||||
offersChannelSave(): boolean {
|
||||
const button = this.buttonByText(/Save channel/);
|
||||
return button !== null && !button.disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to save a channel the API refuses and hand back what the user is told. A
|
||||
* save that succeeds fails the test rather than timing out.
|
||||
*/
|
||||
async refusedChannelSave(name: string): Promise<string> {
|
||||
await this.pickAndSave(name);
|
||||
|
||||
return this.waitFor(
|
||||
() => {
|
||||
if (this.defaultChannelName() === name) {
|
||||
throw new Error(
|
||||
`refusedChannelSave: #${name} was recorded, not refused`,
|
||||
);
|
||||
}
|
||||
return this.toastText(/Could not save the destination channel/);
|
||||
},
|
||||
15000,
|
||||
"the refused channel save",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of the toast matching `pattern` — title and message together. Radix
|
||||
* portals each toast into its viewport as an `<li>`, outside the page's markup.
|
||||
*/
|
||||
private toastText(pattern: RegExp): string | null {
|
||||
const toast = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("ol li"),
|
||||
).find((element) => pattern.test(element.textContent ?? ""));
|
||||
return toast ? (toast.textContent ?? "").replace(/\s+/g, " ").trim() : null;
|
||||
}
|
||||
|
||||
private defaultChannelName(): string | null {
|
||||
return (
|
||||
/Prowler posts to #(\S+?)\./.exec(
|
||||
this.container.textContent ?? "",
|
||||
)?.[1] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** The channel recorded as the integration's destination, if any. */
|
||||
async defaultChannel(): Promise<string | null> {
|
||||
const settled = await this.waitFor(
|
||||
() =>
|
||||
this.defaultChannelName() ??
|
||||
(this.containsText(/No destination channel recorded yet/)
|
||||
? NO_DEFAULT_CHANNEL
|
||||
: null),
|
||||
10000,
|
||||
"the recorded destination channel",
|
||||
);
|
||||
return settled === NO_DEFAULT_CHANNEL ? null : settled;
|
||||
}
|
||||
|
||||
/** What the user is told when the workspace exposes no channel at all. */
|
||||
async channelPickerMessage(): Promise<string> {
|
||||
const alert = await this.waitFor(
|
||||
() =>
|
||||
Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
|
||||
).find((element) =>
|
||||
/No channels available yet|Could not read the workspace/.test(
|
||||
element.textContent ?? "",
|
||||
),
|
||||
),
|
||||
10000,
|
||||
"the channel picker's message",
|
||||
);
|
||||
return (alert.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* What the user is told about a list short of the workspace, shown beside a
|
||||
* picker that still works — unlike `channelPickerMessage()`, which replaces it.
|
||||
*/
|
||||
partialListNotice(): string | null {
|
||||
const notice = this.q("[data-channels-notice]");
|
||||
return notice
|
||||
? (notice.textContent ?? "").replace(/\s+/g, " ").trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Whether the picker was replaced by the "could not read them" alert. */
|
||||
saysChannelsUnreadable(): boolean {
|
||||
return this.containsText(/Could not read the workspace/);
|
||||
}
|
||||
|
||||
/** The invite copy that says how to make a private channel appear. */
|
||||
channelInviteHint(): string | null {
|
||||
const hint = Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>("p"),
|
||||
).find((element) => /invites? @Prowler/.test(element.textContent ?? ""));
|
||||
return hint ? (hint.textContent ?? "").trim() : null;
|
||||
}
|
||||
|
||||
// --- The test message ----------------------------------------------------
|
||||
|
||||
offersTestMessage(): boolean {
|
||||
return this.buttonByText(/Send test message/) !== null;
|
||||
}
|
||||
|
||||
private testMessageAlert(): HTMLElement | null {
|
||||
return (
|
||||
Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
|
||||
).find((element) =>
|
||||
/Test message (sent|failed)/.test(element.textContent ?? ""),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async sendTestMessage(): Promise<TestMessageOutcome> {
|
||||
await this.clickButton(/Send test message/);
|
||||
|
||||
return this.waitFor(
|
||||
() => {
|
||||
const alert = this.testMessageAlert();
|
||||
if (!alert) return null;
|
||||
return /Test message sent/.test(alert.textContent ?? "")
|
||||
? TEST_MESSAGE_OUTCOME.SENT
|
||||
: TEST_MESSAGE_OUTCOME.FAILED;
|
||||
},
|
||||
15000,
|
||||
"the test message outcome",
|
||||
);
|
||||
}
|
||||
|
||||
async lastTestMessageOutcome(): Promise<string> {
|
||||
const alert = await this.waitFor(
|
||||
() => this.testMessageAlert(),
|
||||
10000,
|
||||
"the test message outcome",
|
||||
);
|
||||
const description = alert.querySelector<HTMLElement>(
|
||||
'[data-slot="alert-description"]',
|
||||
);
|
||||
return (description?.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
// --- Disconnecting ------------------------------------------------------
|
||||
|
||||
get disconnectCallCount(): number {
|
||||
return this.countRequests("DELETE", "/integrations/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects the workspace, confirming the way a user has to, and reports
|
||||
* what the page says about the revocation. The outcomes are mutually
|
||||
* exclusive, so asking for one also checks the others are absent.
|
||||
*
|
||||
* The revoked and unreported outcomes share a toast title, so each is read
|
||||
* from its own description: a title match would agree with either.
|
||||
*/
|
||||
async disconnect(): Promise<RevocationOutcome> {
|
||||
// The dialog's own button carries the noun too, hence the exact match on
|
||||
// the card's action.
|
||||
await this.clickButton(/^\s*Disconnect\s*$/);
|
||||
await this.clickButton(/Disconnect workspace/);
|
||||
|
||||
return this.waitFor(
|
||||
() => {
|
||||
if (this.alertMatching(/revocation/i)) {
|
||||
return REVOCATION_OUTCOME.NOT_REVOKED;
|
||||
}
|
||||
if (this.containsText(/has been revoked/)) {
|
||||
return REVOCATION_OUTCOME.REVOKED;
|
||||
}
|
||||
if (this.containsText(/is no longer connected to Prowler/)) {
|
||||
return REVOCATION_OUTCOME.UNREPORTED;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
15000,
|
||||
"the disconnect outcome",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the page is back to offering an install with no workspace
|
||||
* connected. The consent URL is minted after the disconnect, so the install
|
||||
* affordance appears a beat after the copy does.
|
||||
*/
|
||||
async returnedToUnconnectedState(): Promise<boolean> {
|
||||
await this.waitForText(/No workspace connected/, 10000);
|
||||
return (
|
||||
(await this.waitForOrNull(
|
||||
() => this.offersInstall(),
|
||||
5000,
|
||||
"the install to be offered again",
|
||||
)) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the page is asking the user to remove the access in Slack. */
|
||||
showsRevocationNotice(): boolean {
|
||||
return this.alertMatching(/revocation/i) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the user is told when the row was removed but Slack never confirmed
|
||||
* the revocation.
|
||||
*/
|
||||
async revocationNotice(): Promise<string> {
|
||||
const notice = await this.waitFor(
|
||||
() => this.alertMatching(/revocation/i),
|
||||
10000,
|
||||
"the revocation notice",
|
||||
);
|
||||
return (notice.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
// --- A credential Slack no longer accepts --------------------------------
|
||||
|
||||
/** What the user is told when Slack has stopped accepting the credential. */
|
||||
async revokedCredentialNotice(): Promise<string> {
|
||||
const notice = await this.waitFor(
|
||||
() => this.alertMatching(/no longer accepts Prowler's access/),
|
||||
10000,
|
||||
"the revoked-credential notice",
|
||||
);
|
||||
return (notice.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Whether the page is saying Slack has stopped accepting the credential. */
|
||||
showsRevokedCredentialNotice(): boolean {
|
||||
return this.alertMatching(/no longer accepts Prowler's access/) !== null;
|
||||
}
|
||||
|
||||
private reconnectLink(): HTMLAnchorElement | null {
|
||||
return (
|
||||
Array.from(this.container.querySelectorAll("a")).find((anchor) =>
|
||||
/Reconnect to Slack/.test(anchor.textContent ?? ""),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the page offers to approve Prowler in the workspace again. */
|
||||
offersReconnect(): boolean {
|
||||
return this.reconnectLink() !== null;
|
||||
}
|
||||
|
||||
/** The consent URL the reconnect affordance points at, once it is offered. */
|
||||
async reconnectUrl(): Promise<string> {
|
||||
const link = await this.waitFor(
|
||||
() => this.reconnectLink(),
|
||||
10000,
|
||||
"the reconnect link",
|
||||
);
|
||||
return link.href;
|
||||
}
|
||||
|
||||
/** The alert whose text matches, of however many the page is showing. */
|
||||
private alertMatching(pattern: RegExp): HTMLElement | null {
|
||||
return (
|
||||
Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
|
||||
).find((alert) => pattern.test(alert.textContent ?? "")) ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
/**
|
||||
* Browser-mode tests for the Slack integration page (`/integrations/slack`),
|
||||
* driven through `SlackIntegrationHarness`. MSW answers from handlers derived
|
||||
* from the API contract in `design.md`. The OAuth callback is its own route,
|
||||
* covered in `slack-callback-page.integration.test.tsx`.
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { it } from "@/__tests__/fixtures";
|
||||
import {
|
||||
configuredSlackFixture,
|
||||
connectedSlackFixture,
|
||||
INTEGRATIONS_SERVER_ERROR_DETAIL,
|
||||
partiallyReadSlackFixture,
|
||||
revokedTokenSlackFixture,
|
||||
revokeFailureSlackFixture,
|
||||
SLACK_CHANNEL_NOT_FOUND_REFUSAL,
|
||||
SLACK_MISSING_SCOPE_CODE,
|
||||
SLACK_MISSING_SCOPE_REFUSAL,
|
||||
SLACK_NOT_IN_CHANNEL_CODE,
|
||||
SLACK_NOT_IN_CHANNEL_REFUSAL,
|
||||
SLACK_PRIVATE_CHANNEL,
|
||||
SLACK_PUBLIC_CHANNEL,
|
||||
SLACK_RATE_LIMITED_REFUSAL,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL,
|
||||
SLACK_TEST_MESSAGE_REFUSED_DETAIL,
|
||||
SLACK_TOKEN_EXPIRED_CODE,
|
||||
SLACK_TOKEN_EXPIRED_REFUSAL,
|
||||
SLACK_TOKEN_REVOKED_CODE,
|
||||
SLACK_UNKNOWN_CHANNEL_DETAIL,
|
||||
SLACK_UNMAPPED_REASON_CODE,
|
||||
SLACK_UPSTREAM_REFUSAL,
|
||||
slackFixture,
|
||||
slackFixtureWithDefaultChannel,
|
||||
unreadableCheckTimeSlackFixture,
|
||||
unreportedRevocationSlackFixture,
|
||||
} from "@/__tests__/msw/handlers/slack.fixtures";
|
||||
|
||||
import {
|
||||
CONNECTION_OUTCOME,
|
||||
REVOCATION_OUTCOME,
|
||||
SlackIntegrationHarness,
|
||||
TEST_MESSAGE_OUTCOME,
|
||||
} from "./slack-integration.harness";
|
||||
|
||||
/** The shape the channel save is asserted against — only the id travels. */
|
||||
interface PatchIntegrationBody {
|
||||
data: PatchIntegrationData;
|
||||
}
|
||||
|
||||
interface PatchIntegrationData {
|
||||
attributes: PatchIntegrationAttributes;
|
||||
}
|
||||
|
||||
interface PatchIntegrationAttributes {
|
||||
configuration: PatchChannelConfiguration;
|
||||
}
|
||||
|
||||
interface PatchChannelConfiguration {
|
||||
channel_id: string;
|
||||
}
|
||||
|
||||
/** The workspace the fixtures connect. */
|
||||
const WORKSPACE_NAME = "Prowler HQ";
|
||||
|
||||
/** The only scopes Prowler asks a workspace for (design D2). */
|
||||
const REQUIRED_SCOPES = [
|
||||
"chat:write",
|
||||
"chat:write.public",
|
||||
"channels:read",
|
||||
"groups:read",
|
||||
];
|
||||
|
||||
describe("starting the install", () => {
|
||||
it("sends the user to Slack's consent screen for the access Prowler needs", async () => {
|
||||
// Given — a tenant with no workspace connected yet.
|
||||
const harness = new SlackIntegrationHarness(slackFixture());
|
||||
await harness.mount();
|
||||
|
||||
const consentScreen = new URL(await harness.connect());
|
||||
|
||||
expect(`${consentScreen.origin}${consentScreen.pathname}`).toBe(
|
||||
"https://slack.com/oauth/v2/authorize",
|
||||
);
|
||||
const scopes = (consentScreen.searchParams.get("scope") ?? "").split(",");
|
||||
expect(scopes).toHaveLength(REQUIRED_SCOPES.length);
|
||||
expect(scopes).toEqual(expect.arrayContaining(REQUIRED_SCOPES));
|
||||
// The state is server-minted, binding this install to the session
|
||||
// (design D5).
|
||||
expect(consentScreen.searchParams.get("state")).toBeTruthy();
|
||||
}, 30000);
|
||||
|
||||
it("says so when the deployment has no Slack app, instead of offering an install", async () => {
|
||||
// Given — no SLACK_CLIENT_ID/SECRET/REDIRECT_URI, which the API answers
|
||||
// with a 503.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ appConfigured: false }),
|
||||
);
|
||||
|
||||
await harness.mount();
|
||||
|
||||
// The read itself succeeded: an empty collection is what a deployment with
|
||||
// no Slack app has, so nothing claims it failed.
|
||||
await harness.waitForUnavailable();
|
||||
expect(harness.offersInstall()).toBe(false);
|
||||
expect(harness.saysLoadFailed()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("still says the read failed when the deployment also has no Slack app", async () => {
|
||||
// Given — both states, which coincide during rollout and rollback
|
||||
// (design.md, Migration Plan §2-3 and §5).
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ appConfigured: false, listServerError: true }),
|
||||
);
|
||||
|
||||
await harness.mount();
|
||||
|
||||
// Both notices: the read's is the actionable half (a retry may still show a
|
||||
// workspace this tenant has connected).
|
||||
const notice = await harness.loadErrorNotice();
|
||||
expect(notice).toMatch(/temporarily unavailable/);
|
||||
expect(notice).not.toMatch(INTEGRATIONS_SERVER_ERROR_DETAIL);
|
||||
expect(harness.saysUnavailable()).toBe(true);
|
||||
// The install is still not on offer: there is no Slack app to install into.
|
||||
expect(harness.offersInstall()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("says Slack is busy, not that the deployment has no Slack app, when it is rate limiting", async () => {
|
||||
// Given — the app is configured; Slack rate limits (429) the call that
|
||||
// mints the consent URL.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ rateLimited: true }),
|
||||
);
|
||||
|
||||
await harness.mount();
|
||||
|
||||
expect(await harness.rateLimitNotice()).toMatch(/about 30 seconds/);
|
||||
expect(harness.saysUnavailable()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("keeps the page usable when reading the install fails on the server", async () => {
|
||||
// Given — the shared `GET /integrations` read answers 500. The action
|
||||
// throws instead of returning a result, so the page has to catch it:
|
||||
// uncaught, the route's error boundary replaces the Slack page.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ listServerError: true }),
|
||||
);
|
||||
|
||||
await harness.mount();
|
||||
|
||||
const notice = await harness.loadErrorNotice();
|
||||
expect(notice).toMatch(/temporarily unavailable/);
|
||||
expect(notice).not.toMatch(INTEGRATIONS_SERVER_ERROR_DETAIL);
|
||||
// The install stays on offer: one read failed, the Slack app is fine.
|
||||
expect(harness.offersInstall()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("keeps the page usable when Slack's own side is broken upstream", async () => {
|
||||
// Given — the `502` the contract reserves for a Slack upstream failure.
|
||||
// The UI's shared 5xx handling throws, so this is the page's other
|
||||
// rejection path.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ oauthUpstreamError: true }),
|
||||
);
|
||||
|
||||
await harness.mount();
|
||||
|
||||
const notice = await harness.loadErrorNotice();
|
||||
expect(notice).toMatch(/temporarily unavailable/);
|
||||
// 502 is not 503: the app is configured, Slack is down.
|
||||
expect(harness.saysUnavailable()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("names the missing consent URL when a proxy answers that call with an HTML page", async () => {
|
||||
// Given — a 200 carrying a challenge page instead of JSON. Nothing refused
|
||||
// the call, so the action reaches its success path with no URL.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ authorizeUrlUnreadable: true }),
|
||||
);
|
||||
|
||||
await harness.mount();
|
||||
|
||||
// V8 truncates the parse message to `"<!DOCTYPE "`, before the word `html`,
|
||||
// so the UI's HTML-shaped-error filter can never match it.
|
||||
const notice = await harness.loadErrorNotice();
|
||||
expect(notice).toMatch(/did not return an authorization URL/);
|
||||
expect(notice).not.toMatch(/DOCTYPE/i);
|
||||
expect(notice).not.toMatch(/not valid JSON/i);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("a connected workspace", () => {
|
||||
it("identifies the workspace and reports the connection as healthy", async () => {
|
||||
// Given — a finished setup: workspace approved and a destination channel
|
||||
// recorded, which the API requires before it will check a connection.
|
||||
const harness = new SlackIntegrationHarness(configuredSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
|
||||
expect(await harness.connectionBadge()).toBe("Connected");
|
||||
expect(await harness.offersConnectionTest()).toBe(true);
|
||||
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.SUCCESS);
|
||||
// One workspace per tenant (design D10): no second install on offer, and no
|
||||
// consent URL minted for a page that would never use it.
|
||||
expect(harness.offersInstall()).toBe(false);
|
||||
expect(harness.authorizeUrlCallCount).toBe(0);
|
||||
}, 30000);
|
||||
|
||||
it("still identifies the workspace before a destination channel is chosen", async () => {
|
||||
// Given — the state the OAuth exchange leaves behind.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
|
||||
await harness.mount();
|
||||
|
||||
// The configuration carries no channel keys at all, which is "nothing
|
||||
// chosen yet", not a broken install.
|
||||
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
|
||||
expect(harness.offersInstall()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("reports the connection as never checked, not as broken, before the first check", async () => {
|
||||
// Given — the state the OAuth exchange leaves behind: `connected` is null,
|
||||
// neither true nor false (design.md, "Connection state, in order").
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
|
||||
await harness.mount();
|
||||
|
||||
const badge = await harness.connectionBadge();
|
||||
expect(badge).toBe("Not checked yet");
|
||||
expect(badge).not.toMatch(/Disconnected/);
|
||||
}, 30000);
|
||||
|
||||
it("keeps the page usable when the recorded check time is one no parser can read", async () => {
|
||||
// Given — a finished setup whose `connection_last_checked_at` is a zero
|
||||
// date. `date-fns` throws a RangeError on it, which would replace the whole
|
||||
// page with the route's error boundary.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
unreadableCheckTimeSlackFixture(),
|
||||
);
|
||||
|
||||
await harness.mount();
|
||||
|
||||
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
|
||||
expect(await harness.connectionBadge()).toBe("Connected");
|
||||
// Nothing to show, so nothing is shown: the same line a workspace that was
|
||||
// never checked renders.
|
||||
expect(harness.lastCheckedLine()).toBeNull();
|
||||
}, 30000);
|
||||
|
||||
it("does not offer a connection check the API is bound to refuse", async () => {
|
||||
// Given — a workspace connected and no destination channel recorded.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
|
||||
await harness.mount();
|
||||
|
||||
// The check posts to the destination channel, so with none recorded the API
|
||||
// answers 400 rather than `connected: false`.
|
||||
expect(await harness.offersConnectionTest()).toBe(false);
|
||||
expect(harness.saysChannelIsNextStep()).toBe(true);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("choosing a destination channel", () => {
|
||||
it("offers the workspace's channels and remembers the one chosen", async () => {
|
||||
// Given — a connected tenant whose channels span two cursor pages.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// Then — every channel is offered, so the picker followed `links.next`
|
||||
// rather than stopping at the first page (design D6). Alphabetically: the
|
||||
// picker sorts, so the API's page order is not the offered order.
|
||||
expect(await harness.channelOptions()).toEqual([
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
]);
|
||||
expect(harness.channelListCallCount).toBe(2);
|
||||
|
||||
// When
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// Then — only the id is submitted: the API derives the name from it.
|
||||
const saved = await harness.lastRequestBody<PatchIntegrationBody>(
|
||||
"PATCH",
|
||||
"/integrations/",
|
||||
);
|
||||
expect(saved?.data.attributes.configuration).toEqual({
|
||||
channel_id: SLACK_PUBLIC_CHANNEL.id,
|
||||
});
|
||||
|
||||
// And — a later visit shows it, under the name the API derived from the id.
|
||||
await harness.revisit();
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
}, 60000);
|
||||
|
||||
it("narrows the offered channels as the user types", async () => {
|
||||
// Given — a connected workspace whose channels were read.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// When — the user types part of a name. Then — only the match stays on
|
||||
// offer, so a long workspace list stays navigable.
|
||||
const narrowed = await harness.searchChannels("plat");
|
||||
expect(narrowed.offered).toEqual([SLACK_SECOND_PUBLIC_CHANNEL.name]);
|
||||
expect(narrowed.emptyNote).toBeNull();
|
||||
|
||||
// And — a search matching nothing says so instead of listing channels.
|
||||
const none = await harness.searchChannels("no-such-channel");
|
||||
expect(none.offered).toEqual([]);
|
||||
expect(none.emptyNote).toMatch(/No channel matches/);
|
||||
}, 60000);
|
||||
|
||||
it("offers a private channel the app was invited to, marked as private, and saves it", async () => {
|
||||
// Given — `@Prowler` was invited to one private channel; `groups:read` is
|
||||
// membership-gated (D2).
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// Then
|
||||
expect(await harness.channelOptions()).toContain(
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
);
|
||||
expect(
|
||||
await harness.isChannelShownAsPrivate(SLACK_PRIVATE_CHANNEL.name),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await harness.isChannelShownAsPrivate(SLACK_PUBLIC_CHANNEL.name),
|
||||
).toBe(false);
|
||||
|
||||
// When
|
||||
await harness.chooseChannel(SLACK_PRIVATE_CHANNEL.name);
|
||||
|
||||
// Then
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PRIVATE_CHANNEL.name);
|
||||
}, 60000);
|
||||
|
||||
it("offers a private channel once @Prowler is invited to it and the list is refreshed", async () => {
|
||||
// Given — a workspace whose only channels are public: `groups:read` is
|
||||
// membership-gated (design D2).
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
channels: [
|
||||
{ ...SLACK_PUBLIC_CHANNEL },
|
||||
{ ...SLACK_SECOND_PUBLIC_CHANNEL },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
expect(await harness.channelOptions()).not.toContain(
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
);
|
||||
|
||||
// When — `@Prowler` is invited to a private channel, and the user refreshes
|
||||
// instead of reconnecting the workspace.
|
||||
harness.fixture.channels.push({ ...SLACK_PRIVATE_CHANNEL });
|
||||
await harness.refreshChannels();
|
||||
|
||||
// Then
|
||||
expect(await harness.channelOptions()).toContain(
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
);
|
||||
expect(
|
||||
await harness.isChannelShownAsPrivate(SLACK_PRIVATE_CHANNEL.name),
|
||||
).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
it("says what to do when the workspace exposes no channel Prowler can post to", async () => {
|
||||
// Given — a connected workspace exposing no channels at all.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({ channels: [] }),
|
||||
);
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then — the user is told what to do, not merely that the list is empty.
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/No channels available yet/);
|
||||
expect(message).toMatch(/invite @Prowler/);
|
||||
expect(await harness.defaultChannel()).toBeNull();
|
||||
expect(harness.offersTestMessage()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("offers the connection check as soon as the destination is saved, without a revisit", async () => {
|
||||
// Given — connected with nothing recorded: the check posts to the
|
||||
// destination, so it is not offered yet.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
expect(await harness.offersConnectionTest()).toBe(false);
|
||||
expect(harness.saysChannelIsNextStep()).toBe(true);
|
||||
|
||||
// When
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// Then — everything waiting on a destination moves with the save, in the
|
||||
// same paint: no reload to find the check on offer.
|
||||
expect(await harness.offersConnectionTest()).toBe(true);
|
||||
expect(harness.saysChannelIsNextStep()).toBe(false);
|
||||
// And — the check really runs.
|
||||
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.SUCCESS);
|
||||
}, 60000);
|
||||
|
||||
it("follows the destination recorded elsewhere when the page's data refreshes under it", async () => {
|
||||
// Given — a finished setup, open on screen.
|
||||
const harness = new SlackIntegrationHarness(configuredSlackFixture());
|
||||
await harness.mount();
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// When — the destination changes elsewhere (a second tab, another user) and
|
||||
// this page's server data refreshes under the open card, as
|
||||
// `revalidatePath` does after an action.
|
||||
await harness.channelRecordedElsewhere(SLACK_SECOND_PUBLIC_CHANNEL.name);
|
||||
await harness.refreshPageData();
|
||||
|
||||
// Then — the card reports what is on record, not the copy it took at mount.
|
||||
expect(await harness.defaultChannel()).toBe(
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
);
|
||||
expect(harness.offersTestMessage()).toBe(true);
|
||||
// And — the picker followed too: the superseded destination is not left one
|
||||
// click from being saved back.
|
||||
expect(harness.offersChannelSave()).toBe(false);
|
||||
}, 60000);
|
||||
|
||||
it("says which permission is missing when Slack refuses the channel listing, leaving the recorded channel alone", async () => {
|
||||
// Given — a recorded destination, and an install missing a scope the listing
|
||||
// needs. The API names it in `code` (contract, Errors), not in `detail`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
|
||||
channelsRefusal: SLACK_MISSING_SCOPE_REFUSAL,
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then — the reason, worded as a fix, with the invite copy still beside the
|
||||
// picker.
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/missing a permission it needs in Slack/);
|
||||
expect(message).toMatch(/Connect the workspace again and approve/);
|
||||
// Slack's reason is a protocol token: it travels in `code` and is never
|
||||
// shown.
|
||||
expect(message).not.toMatch(SLACK_MISSING_SCOPE_CODE);
|
||||
expect(harness.channelInviteHint()).toMatch(/invites @Prowler/);
|
||||
|
||||
// And — a listing Prowler could not read says nothing about the channel
|
||||
// already recorded.
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
expect(harness.offersTestMessage()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("names the wait Slack asked for when it rate limits the channel listing", async () => {
|
||||
// Given — `conversations.list` is Slack tier 2 and paginated (contract,
|
||||
// Errors); the `429` carries the wait in `Retry-After`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
|
||||
channelsRefusal: SLACK_RATE_LIMITED_REFUSAL,
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then — when to come back, not just that it was refused: the wait is
|
||||
// asserted, not only the wording.
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/rate limiting/);
|
||||
expect(message).toMatch(/about 30 seconds/);
|
||||
|
||||
// And — waiting is the fix, so nothing is said about permissions.
|
||||
expect(message).not.toMatch(/permission/);
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
}, 30000);
|
||||
|
||||
it("keeps the channels it did read on offer when Slack refuses a later page", async () => {
|
||||
// Given — a two-page workspace whose second page is rate limited
|
||||
// (`conversations.list` is tier 2, contract, Errors).
|
||||
const harness = new SlackIntegrationHarness(partiallyReadSlackFixture());
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then — the picker offers what was read rather than being replaced by the
|
||||
// refusal: every reload re-runs the same reads into the same limit.
|
||||
// Alphabetically, as the picker sorts what it offers.
|
||||
expect(await harness.channelOptions()).toEqual([
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
expect(harness.saysChannelsUnreadable()).toBe(false);
|
||||
|
||||
// And — the wait is still said, as the explanation for the short list.
|
||||
const notice = harness.partialListNotice();
|
||||
expect(notice).toMatch(/rate limiting/);
|
||||
expect(notice).toMatch(/about 30 seconds/);
|
||||
|
||||
// And — a partial read says nothing about the destination already recorded.
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
expect(harness.offersTestMessage()).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
it("says nothing about a short list when the whole workspace was read", async () => {
|
||||
// Given — the default workspace: two cursor pages, read to the end.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then
|
||||
expect(harness.partialListNotice()).toBeNull();
|
||||
}, 30000);
|
||||
|
||||
it("falls back to the API's wording when the listing fails upstream", async () => {
|
||||
// Given — a `502`, which names no `code` because there is nothing to act on
|
||||
// (contract, Errors).
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
|
||||
channelsRefusal: SLACK_UPSTREAM_REFUSAL,
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then — the API's own `detail`, and not a wait that was never promised.
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/Slack is temporarily unavailable/);
|
||||
expect(message).not.toMatch(/rate limiting/);
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
}, 30000);
|
||||
|
||||
it("says to invite @Prowler when Slack refuses the channel because the app is not in it", async () => {
|
||||
// Given — a private channel the app was removed from. The API validates the
|
||||
// channel against Slack on the way in and refuses with `not_in_channel`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
channelSaveRefusal: SLACK_NOT_IN_CHANNEL_REFUSAL,
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
const refusal = await harness.refusedChannelSave(
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
);
|
||||
|
||||
// Then — the one fix the user can carry out themselves, in Slack.
|
||||
expect(refusal).toMatch(/Prowler is not in that channel/);
|
||||
expect(refusal).toMatch(/Invite @Prowler to it in Slack/);
|
||||
expect(refusal).not.toMatch(SLACK_NOT_IN_CHANNEL_CODE);
|
||||
|
||||
// And — nothing was recorded, so nothing is offered to post with.
|
||||
expect(await harness.defaultChannel()).toBeNull();
|
||||
expect(harness.offersTestMessage()).toBe(false);
|
||||
}, 60000);
|
||||
|
||||
it("says the channel is gone, not that @Prowler needs inviting, when Slack no longer has it", async () => {
|
||||
// Given — a channel archived since the listing was read. The API's `detail`
|
||||
// is word-for-word the one for `not_in_channel`, so only `code` tells them
|
||||
// apart.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
channelSaveRefusal: SLACK_CHANNEL_NOT_FOUND_REFUSAL,
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
const refusal = await harness.refusedChannelSave(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// Then — a different problem, so different copy: nothing to invite to a
|
||||
// channel that no longer exists.
|
||||
expect(refusal).toMatch(/no longer exists in the workspace/);
|
||||
expect(refusal).toMatch(/Choose another one/);
|
||||
expect(refusal).not.toMatch(/Invite @Prowler/);
|
||||
expect(refusal).not.toMatch(SLACK_UNKNOWN_CHANNEL_DETAIL);
|
||||
expect(await harness.defaultChannel()).toBeNull();
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("sending a test message", () => {
|
||||
it("is not offered until a destination channel is recorded", async () => {
|
||||
// Given — connected, but no channel chosen yet.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then
|
||||
expect(await harness.defaultChannel()).toBeNull();
|
||||
expect(harness.offersTestMessage()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("sends a test message to the recorded channel and reports it delivered", async () => {
|
||||
// Given — a tenant that has recorded where Prowler should post.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// When
|
||||
const outcome = await harness.sendTestMessage();
|
||||
|
||||
// Then — sent, and the user reads which channel it went to.
|
||||
expect(outcome).toBe(TEST_MESSAGE_OUTCOME.SENT);
|
||||
expect(await harness.lastTestMessageOutcome()).toMatch(
|
||||
`#${SLACK_PUBLIC_CHANNEL.name}`,
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it("surfaces the reason when Slack refuses the test message", async () => {
|
||||
// Given — the post fails, which the API reports on the task it handed back
|
||||
// (design D9), not on the request that started it, using the same stable
|
||||
// reason the synchronous endpoints put in `code`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
testMessage: { accepted: false, error: SLACK_NOT_IN_CHANNEL_CODE },
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// When
|
||||
const outcome = await harness.sendTestMessage();
|
||||
|
||||
// Then — the same copy the synchronous refusals get, not the raw token.
|
||||
expect(outcome).toBe(TEST_MESSAGE_OUTCOME.FAILED);
|
||||
const reported = await harness.lastTestMessageOutcome();
|
||||
expect(reported).toMatch(/Prowler is not in that channel/);
|
||||
expect(reported).toMatch(/Invite @Prowler to it in Slack/);
|
||||
expect(reported).not.toMatch(SLACK_NOT_IN_CHANNEL_CODE);
|
||||
}, 60000);
|
||||
|
||||
it("reports a refusal the task words itself, rather than swallowing it", async () => {
|
||||
// Given — a task result carrying prose instead of a stable reason; its exact
|
||||
// shape is the cloud lane's to pin down (contract, test-message).
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
testMessage: {
|
||||
accepted: false,
|
||||
error: SLACK_TEST_MESSAGE_REFUSED_DETAIL,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// When
|
||||
const outcome = await harness.sendTestMessage();
|
||||
|
||||
// Then
|
||||
expect(outcome).toBe(TEST_MESSAGE_OUTCOME.FAILED);
|
||||
expect(await harness.lastTestMessageOutcome()).toMatch(
|
||||
SLACK_TEST_MESSAGE_REFUSED_DETAIL,
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it("keeps a reason it has no copy for inside its own sentence, not as the whole message", async () => {
|
||||
// Given — a real Slack reason this UI has no copy for; Slack's set is
|
||||
// open-ended, so this is the ordinary case.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
testMessage: { accepted: false, error: SLACK_UNMAPPED_REASON_CODE },
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// When
|
||||
const outcome = await harness.sendTestMessage();
|
||||
|
||||
// Then — Prowler's wording, with Slack's word for it kept for diagnosis.
|
||||
expect(outcome).toBe(TEST_MESSAGE_OUTCOME.FAILED);
|
||||
const reported = await harness.lastTestMessageOutcome();
|
||||
expect(reported).toMatch(/Slack refused the message/);
|
||||
expect(reported).toMatch(SLACK_UNMAPPED_REASON_CODE);
|
||||
expect(reported).not.toBe(SLACK_UNMAPPED_REASON_CODE);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("disconnecting a workspace", () => {
|
||||
it("removes the integration and returns the card to its unconnected state", async () => {
|
||||
// Given — a tenant with a workspace connected.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// When — the user disconnects and confirms; Slack confirms the revocation.
|
||||
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.REVOKED);
|
||||
|
||||
expect(harness.disconnectCallCount).toBe(1);
|
||||
expect(await harness.returnedToUnconnectedState()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("still removes the integration when the revocation fails, and says access may need removing by hand", async () => {
|
||||
// Given — Slack will not accept the revocation; the row goes either way.
|
||||
const harness = new SlackIntegrationHarness(revokeFailureSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.NOT_REVOKED);
|
||||
|
||||
// And — the disconnect revalidates, so the copy below is read from props
|
||||
// that no longer carry an integration at all.
|
||||
await harness.refreshPageData();
|
||||
|
||||
// Then — what is true of both sides: nothing is left in Prowler to retry,
|
||||
// and the app may still be installed at Slack.
|
||||
const notice = await harness.revocationNotice();
|
||||
expect(notice).toMatch(/gone from Prowler/);
|
||||
expect(notice).toMatch(/nothing to retry here/);
|
||||
expect(notice).toMatch(/may still be installed in Prowler HQ/);
|
||||
expect(notice).toMatch(
|
||||
/remove it from that workspace's Slack app settings/,
|
||||
);
|
||||
expect(await harness.returnedToUnconnectedState()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("says only that the workspace is no longer connected when nothing reports the revocation", async () => {
|
||||
// Given — the plain `204` a deployment that overrides nothing answers: no
|
||||
// body, so no `meta` to read the outcome from. The case users really meet.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
unreportedRevocationSlackFixture(),
|
||||
);
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.UNREPORTED);
|
||||
|
||||
// Then — nothing sends the user to Slack to finish a job no answer said
|
||||
// was unfinished.
|
||||
expect(harness.showsRevocationNotice()).toBe(false);
|
||||
expect(await harness.returnedToUnconnectedState()).toBe(true);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("a credential Slack no longer accepts", () => {
|
||||
it("says the connection check found a dead credential, and offers to connect the workspace again", async () => {
|
||||
// Given — the token was revoked at Slack, so the row still reads connected
|
||||
// until a check runs (contract, Cross-cutting).
|
||||
const harness = new SlackIntegrationHarness(revokedTokenSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.FAILURE);
|
||||
|
||||
// Then — a way forward rather than only an error: a revoked token is fixed
|
||||
// by approving Prowler again, not by checking a second time.
|
||||
const notice = await harness.revokedCredentialNotice();
|
||||
expect(notice).toMatch(/no longer accepts Prowler's access to Prowler HQ/);
|
||||
expect(notice).toMatch(/Prowler's access to Slack was revoked/);
|
||||
expect(notice).toMatch(/Connect the workspace again to restore access/);
|
||||
// Slack's reason is a protocol token: it is what the UI switched on, never
|
||||
// what it showed.
|
||||
expect(notice).not.toMatch(new RegExp(SLACK_TOKEN_REVOKED_CODE));
|
||||
|
||||
const consentScreen = new URL(await harness.reconnectUrl());
|
||||
expect(`${consentScreen.origin}${consentScreen.pathname}`).toBe(
|
||||
"https://slack.com/oauth/v2/authorize",
|
||||
);
|
||||
expect(harness.offersReconnect()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("offers the same recovery when the channel listing is what finds the credential dead", async () => {
|
||||
// Given — a finished setup whose credential expired. The listing runs on
|
||||
// arrival, so it meets Slack before any check does, and the contract says
|
||||
// any call can be the one that surfaces this.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
configuredSlackFixture({ channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL }),
|
||||
);
|
||||
|
||||
// When — nothing but opening the page.
|
||||
await harness.mount();
|
||||
|
||||
// Then — the same answer the connection check gives, worded for how this
|
||||
// credential died rather than left as a channel problem.
|
||||
const notice = await harness.revokedCredentialNotice();
|
||||
expect(notice).toMatch(/Prowler's Slack credential has expired/);
|
||||
expect(notice).toMatch(/Connect the workspace again to restore access/);
|
||||
expect(harness.offersReconnect()).toBe(true);
|
||||
|
||||
// And — the picker says the same, in the same words: `detail` names the raw
|
||||
// reason, and it is `code` the UI answered from.
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/Prowler's Slack credential has expired/);
|
||||
expect(message).not.toMatch(new RegExp(SLACK_TOKEN_EXPIRED_CODE));
|
||||
}, 30000);
|
||||
|
||||
it("offers it too when only a later cursor page is what Slack refuses", async () => {
|
||||
// Given — a two-page workspace whose second page is refused by a credential
|
||||
// Slack no longer accepts: the read stops short rather than failing.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
partiallyReadSlackFixture({
|
||||
channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL,
|
||||
}),
|
||||
);
|
||||
|
||||
// When — nothing but opening the page.
|
||||
await harness.mount();
|
||||
|
||||
// Then — what was read stays on offer, as it does for any short list.
|
||||
expect(await harness.channelOptions()).toEqual([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
|
||||
// And — the dead credential is reported all the same: a picker that still
|
||||
// works is no reason to leave the user without the one fix there is.
|
||||
const notice = await harness.revokedCredentialNotice();
|
||||
expect(notice).toMatch(/Prowler's Slack credential has expired/);
|
||||
expect(harness.offersReconnect()).toBe(true);
|
||||
expect(await harness.connectionBadge()).toBe("Disconnected");
|
||||
}, 60000);
|
||||
|
||||
it("keeps saying so when a later check fails without Slack naming a reason", async () => {
|
||||
// Given — the listing found the credential dead on arrival, and a later
|
||||
// check that fails naming no reason at all.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
configuredSlackFixture({
|
||||
channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL,
|
||||
connection: { connected: false, error: null },
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
expect(await harness.revokedCredentialNotice()).toMatch(
|
||||
/Prowler's Slack credential has expired/,
|
||||
);
|
||||
|
||||
// When
|
||||
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.FAILURE);
|
||||
|
||||
// Then — a failure Slack never answered is no evidence the grant works
|
||||
// again, so the dead credential is still what the page reports.
|
||||
expect(await harness.revokedCredentialNotice()).toMatch(
|
||||
/Prowler's Slack credential has expired/,
|
||||
);
|
||||
expect(harness.offersReconnect()).toBe(true);
|
||||
expect(await harness.connectionBadge()).toBe("Disconnected");
|
||||
}, 60000);
|
||||
|
||||
it("stops saying so once a save Slack validated goes through", async () => {
|
||||
// Given — a finished setup whose test message found the grant revoked.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
configuredSlackFixture({
|
||||
testMessage: { accepted: false, error: SLACK_TOKEN_REVOKED_CODE },
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
expect(await harness.connectionBadge()).toBe("Connected");
|
||||
expect(await harness.sendTestMessage()).toBe(TEST_MESSAGE_OUTCOME.FAILED);
|
||||
expect(harness.showsRevokedCredentialNotice()).toBe(true);
|
||||
expect(await harness.connectionBadge()).toBe("Disconnected");
|
||||
|
||||
// When — the access is approved again in Slack, away from this page, and
|
||||
// the user saves a destination here. The API validates the channel against
|
||||
// Slack, so the save is an answer about the credential.
|
||||
await harness.chooseChannel(SLACK_SECOND_PUBLIC_CHANNEL.name);
|
||||
|
||||
// Then — Slack answered, so the notice about a credential it no longer
|
||||
// accepts goes, and the card is back to what it reported on arrival.
|
||||
expect(harness.showsRevokedCredentialNotice()).toBe(false);
|
||||
expect(harness.offersReconnect()).toBe(false);
|
||||
expect(await harness.connectionBadge()).toBe("Connected");
|
||||
}, 60000);
|
||||
});
|
||||
@@ -32,13 +32,6 @@ export const ProvidersTabContent = async ({
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
}) => {
|
||||
// The React Compiler (`reactCompiler: true`) otherwise instruments this as a
|
||||
// client component and injects `useMemoCache`, which needs a React dispatcher.
|
||||
// An async server component renders once per request, so there is nothing to
|
||||
// memoize — and the injected hook makes it uncallable outside a render, which
|
||||
// is exactly how the browser-mode tests mount it.
|
||||
"use no memo";
|
||||
|
||||
const isCloudEnvironment = isCloud();
|
||||
const [providersView, scanConfigsState] = await Promise.all([
|
||||
loadProvidersAccountsViewData({
|
||||
|
||||