Compare commits

...

4 Commits

54 changed files with 2190 additions and 932 deletions
-291
View File
@@ -1,291 +0,0 @@
name: 'API: Bump Version'
on:
release:
types:
- 'published'
concurrency:
group: ${{ github.workflow }}-${{ github.event.release.tag_name }}
cancel-in-progress: false
env:
PROWLER_VERSION: ${{ github.event.release.tag_name }}
BASE_BRANCH: master
permissions: {}
jobs:
detect-release-type:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
is_minor: ${{ steps.detect.outputs.is_minor }}
is_patch: ${{ steps.detect.outputs.is_patch }}
major_version: ${{ steps.detect.outputs.major_version }}
minor_version: ${{ steps.detect.outputs.minor_version }}
patch_version: ${{ steps.detect.outputs.patch_version }}
current_api_version: ${{ steps.get_api_version.outputs.current_api_version }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Get current API version
id: get_api_version
run: |
CURRENT_API_VERSION=$(grep -oP '^version = "\K[^"]+' api/pyproject.toml)
echo "current_api_version=${CURRENT_API_VERSION}" >> "${GITHUB_OUTPUT}"
echo "Current API version: $CURRENT_API_VERSION"
- name: Detect release type and parse version
id: detect
run: |
if [[ $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR_VERSION=${BASH_REMATCH[1]}
MINOR_VERSION=${BASH_REMATCH[2]}
PATCH_VERSION=${BASH_REMATCH[3]}
echo "major_version=${MAJOR_VERSION}" >> "${GITHUB_OUTPUT}"
echo "minor_version=${MINOR_VERSION}" >> "${GITHUB_OUTPUT}"
echo "patch_version=${PATCH_VERSION}" >> "${GITHUB_OUTPUT}"
if (( MAJOR_VERSION != 5 )); then
echo "::error::Releasing another Prowler major version, aborting..."
exit 1
fi
if (( PATCH_VERSION == 0 )); then
echo "is_minor=true" >> "${GITHUB_OUTPUT}"
echo "is_patch=false" >> "${GITHUB_OUTPUT}"
echo "✓ Minor release detected: $PROWLER_VERSION"
else
echo "is_minor=false" >> "${GITHUB_OUTPUT}"
echo "is_patch=true" >> "${GITHUB_OUTPUT}"
echo "✓ Patch release detected: $PROWLER_VERSION"
fi
else
echo "::error::Invalid version syntax: '$PROWLER_VERSION' (must be X.Y.Z)"
exit 1
fi
bump-minor-version:
needs: detect-release-type
if: needs.detect-release-type.outputs.is_minor == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Calculate next API minor version
run: |
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
CURRENT_API_VERSION="${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_API_VERSION}"
# API version follows Prowler minor + 1
# For Prowler 5.17.0 -> API 1.18.0
# For next master (Prowler 5.18.0) -> API 1.19.0
NEXT_API_VERSION=1.$((MINOR_VERSION + 2)).0
echo "CURRENT_API_VERSION=${CURRENT_API_VERSION}" >> "${GITHUB_ENV}"
echo "NEXT_API_VERSION=${NEXT_API_VERSION}" >> "${GITHUB_ENV}"
echo "Prowler release version: ${MAJOR_VERSION}.${MINOR_VERSION}.0"
echo "Current API version: $CURRENT_API_VERSION"
echo "Next API minor version (for master): $NEXT_API_VERSION"
env:
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_API_VERSION: ${{ needs.detect-release-type.outputs.current_api_version }}
- name: Bump API versions in files for master
run: |
set -e
sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${NEXT_API_VERSION}\"|" api/pyproject.toml
sed -i "s|spectacular_settings.VERSION = \"${CURRENT_API_VERSION}\"|spectacular_settings.VERSION = \"${NEXT_API_VERSION}\"|" api/src/backend/api/v1/views.py
sed -i "s| version: ${CURRENT_API_VERSION}| version: ${NEXT_API_VERSION}|" api/src/backend/api/specs/v1.yaml
echo "Files modified:"
git --no-pager diff
- name: Create PR for next API minor version to master
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: master
commit-message: 'chore(api): Bump version to v${{ env.NEXT_API_VERSION }}'
branch: api-version-bump-to-v${{ env.NEXT_API_VERSION }}
title: 'chore(api): Bump version to v${{ env.NEXT_API_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
Bump Prowler API version to v${{ env.NEXT_API_VERSION }} after releasing Prowler v${{ env.PROWLER_VERSION }}.
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
- name: Checkout version branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }}
persist-credentials: false
- name: Calculate first API patch version
run: |
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
CURRENT_API_VERSION="${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_API_VERSION}"
VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION}
# API version follows Prowler minor + 1
# For Prowler 5.17.0 release -> version branch v5.17 should have API 1.18.1
FIRST_API_PATCH_VERSION=1.$((MINOR_VERSION + 1)).1
echo "CURRENT_API_VERSION=${CURRENT_API_VERSION}" >> "${GITHUB_ENV}"
echo "FIRST_API_PATCH_VERSION=${FIRST_API_PATCH_VERSION}" >> "${GITHUB_ENV}"
echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}"
echo "Prowler release version: ${MAJOR_VERSION}.${MINOR_VERSION}.0"
echo "First API patch version (for ${VERSION_BRANCH}): $FIRST_API_PATCH_VERSION"
echo "Version branch: $VERSION_BRANCH"
env:
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_API_VERSION: ${{ needs.detect-release-type.outputs.current_api_version }}
- name: Bump API versions in files for version branch
run: |
set -e
sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${FIRST_API_PATCH_VERSION}\"|" api/pyproject.toml
sed -i "s|spectacular_settings.VERSION = \"${CURRENT_API_VERSION}\"|spectacular_settings.VERSION = \"${FIRST_API_PATCH_VERSION}\"|" api/src/backend/api/v1/views.py
sed -i "s| version: ${CURRENT_API_VERSION}| version: ${FIRST_API_PATCH_VERSION}|" api/src/backend/api/specs/v1.yaml
echo "Files modified:"
git --no-pager diff
- name: Create PR for first API patch version to version branch
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: ${{ env.VERSION_BRANCH }}
commit-message: 'chore(api): Bump version to v${{ env.FIRST_API_PATCH_VERSION }}'
branch: api-version-bump-to-v${{ env.FIRST_API_PATCH_VERSION }}
title: 'chore(api): Bump version to v${{ env.FIRST_API_PATCH_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
Bump Prowler API version to v${{ env.FIRST_API_PATCH_VERSION }} in version branch after releasing Prowler v${{ env.PROWLER_VERSION }}.
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
bump-patch-version:
needs: detect-release-type
if: needs.detect-release-type.outputs.is_patch == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Calculate next API patch version
run: |
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
PATCH_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_PATCH_VERSION}
CURRENT_API_VERSION="${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_API_VERSION}"
VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION}
# Extract current API patch to increment it
if [[ $CURRENT_API_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
API_PATCH=${BASH_REMATCH[3]}
# API version follows Prowler minor + 1
# Keep same API minor (based on Prowler minor), increment patch
NEXT_API_PATCH_VERSION=1.$((MINOR_VERSION + 1)).$((API_PATCH + 1))
echo "CURRENT_API_VERSION=${CURRENT_API_VERSION}" >> "${GITHUB_ENV}"
echo "NEXT_API_PATCH_VERSION=${NEXT_API_PATCH_VERSION}" >> "${GITHUB_ENV}"
echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}"
echo "Prowler release version: ${MAJOR_VERSION}.${MINOR_VERSION}.${PATCH_VERSION}"
echo "Current API version: $CURRENT_API_VERSION"
echo "Next API patch version: $NEXT_API_PATCH_VERSION"
echo "Target branch: $VERSION_BRANCH"
else
echo "::error::Invalid API version format: $CURRENT_API_VERSION"
exit 1
fi
env:
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_PATCH_VERSION: ${{ needs.detect-release-type.outputs.patch_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_API_VERSION: ${{ needs.detect-release-type.outputs.current_api_version }}
- name: Bump API versions in files for version branch
run: |
set -e
sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${NEXT_API_PATCH_VERSION}\"|" api/pyproject.toml
sed -i "s|spectacular_settings.VERSION = \"${CURRENT_API_VERSION}\"|spectacular_settings.VERSION = \"${NEXT_API_PATCH_VERSION}\"|" api/src/backend/api/v1/views.py
sed -i "s| version: ${CURRENT_API_VERSION}| version: ${NEXT_API_PATCH_VERSION}|" api/src/backend/api/specs/v1.yaml
echo "Files modified:"
git --no-pager diff
- name: Create PR for next API patch version to version branch
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: ${{ env.VERSION_BRANCH }}
commit-message: 'chore(api): Bump version to v${{ env.NEXT_API_PATCH_VERSION }}'
branch: api-version-bump-to-v${{ env.NEXT_API_PATCH_VERSION }}
title: 'chore(api): Bump version to v${{ env.NEXT_API_PATCH_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
Bump Prowler API version to v${{ env.NEXT_API_PATCH_VERSION }} after releasing Prowler v${{ env.PROWLER_VERSION }}.
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
+376
View File
@@ -0,0 +1,376 @@
name: 'Release: Bump Versions'
on:
release:
types:
- 'published'
concurrency:
group: release-bump-versions-${{ github.event.release.tag_name }}
cancel-in-progress: false
env:
PROWLER_VERSION: ${{ github.event.release.tag_name }}
DOCS_FILE: docs/getting-started/installation/prowler-app.mdx
permissions: {}
jobs:
detect-release-type:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
is_minor: ${{ steps.detect.outputs.is_minor }}
is_patch: ${{ steps.detect.outputs.is_patch }}
major_version: ${{ steps.detect.outputs.major_version }}
minor_version: ${{ steps.detect.outputs.minor_version }}
patch_version: ${{ steps.detect.outputs.patch_version }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Detect release type and parse version
id: detect
run: |
if [[ $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR_VERSION=${BASH_REMATCH[1]}
MINOR_VERSION=${BASH_REMATCH[2]}
PATCH_VERSION=${BASH_REMATCH[3]}
echo "major_version=${MAJOR_VERSION}" >> "${GITHUB_OUTPUT}"
echo "minor_version=${MINOR_VERSION}" >> "${GITHUB_OUTPUT}"
echo "patch_version=${PATCH_VERSION}" >> "${GITHUB_OUTPUT}"
if (( MAJOR_VERSION != 5 )); then
echo "::error::Releasing another Prowler major version, aborting..."
exit 1
fi
if (( PATCH_VERSION == 0 )); then
echo "is_minor=true" >> "${GITHUB_OUTPUT}"
echo "is_patch=false" >> "${GITHUB_OUTPUT}"
echo "✓ Minor release detected: $PROWLER_VERSION"
else
echo "is_minor=false" >> "${GITHUB_OUTPUT}"
echo "is_patch=true" >> "${GITHUB_OUTPUT}"
echo "✓ Patch release detected: $PROWLER_VERSION"
fi
else
echo "::error::Invalid version syntax: '$PROWLER_VERSION' (must be X.Y.Z)"
exit 1
fi
bump-minor-master:
name: Bump versions on master (minor release)
needs: detect-release-type
if: needs.detect-release-type.outputs.is_minor == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout master
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
persist-credentials: false
- name: Compute next versions for master
run: |
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
# SDK / UI / docs mirror the Prowler version directly.
NEXT_SDK_VERSION=${MAJOR_VERSION}.$((MINOR_VERSION + 1)).0
# API is an independent stream: 1.<prowler_minor + 1>.X
# After Prowler 5.M.0 release, master moves on to next API minor: 1.(M+2).0
NEXT_API_VERSION=1.$((MINOR_VERSION + 2)).0
# Read current versions to drive sed replacements.
CURRENT_API_VERSION=$(grep -oP '^version = "\K[^"]+' api/pyproject.toml)
CURRENT_DOCS_VERSION=$(grep -oP 'PROWLER_UI_VERSION="\K[^"]+' "${DOCS_FILE}")
echo "NEXT_SDK_VERSION=${NEXT_SDK_VERSION}" >> "${GITHUB_ENV}"
echo "NEXT_API_VERSION=${NEXT_API_VERSION}" >> "${GITHUB_ENV}"
echo "CURRENT_API_VERSION=${CURRENT_API_VERSION}" >> "${GITHUB_ENV}"
echo "CURRENT_DOCS_VERSION=${CURRENT_DOCS_VERSION}" >> "${GITHUB_ENV}"
echo "Released Prowler version: $PROWLER_VERSION"
echo "Next SDK/UI version (master): $NEXT_SDK_VERSION"
echo "Next API version (master): $NEXT_API_VERSION (current: $CURRENT_API_VERSION)"
echo "Docs target version: $PROWLER_VERSION (current: $CURRENT_DOCS_VERSION)"
env:
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
- name: Decide whether to bump docs on master
id: docs_decision
run: |
# Skip docs bump if master is already at or ahead of the release version
# (re-run, or patch shipped against an older minor line).
HIGHEST=$(printf '%s\n%s\n' "${CURRENT_DOCS_VERSION}" "${PROWLER_VERSION}" | sort -V | tail -n1)
if [[ "${CURRENT_DOCS_VERSION}" == "${PROWLER_VERSION}" || "${HIGHEST}" != "${PROWLER_VERSION}" ]]; then
echo "skip=true" >> "${GITHUB_OUTPUT}"
echo "Skipping docs bump: current ($CURRENT_DOCS_VERSION) >= release ($PROWLER_VERSION)"
else
echo "skip=false" >> "${GITHUB_OUTPUT}"
fi
- name: Bump SDK version (pyproject.toml, config.py)
run: |
set -e
sed -i "s|version = \"${PROWLER_VERSION}\"|version = \"${NEXT_SDK_VERSION}\"|" pyproject.toml
sed -i "s|prowler_version = \"${PROWLER_VERSION}\"|prowler_version = \"${NEXT_SDK_VERSION}\"|" prowler/config/config.py
- name: Bump API version (api/pyproject.toml, specs/v1.yaml)
run: |
set -e
sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${NEXT_API_VERSION}\"|" api/pyproject.toml
sed -i "s| version: ${CURRENT_API_VERSION}| version: ${NEXT_API_VERSION}|" api/src/backend/api/specs/v1.yaml
- name: Bump UI version (.env)
run: |
set -e
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_SDK_VERSION}|" .env
- name: Bump docs versions (prowler-app.mdx)
if: steps.docs_decision.outputs.skip == 'false'
run: |
set -e
sed -i "s|PROWLER_UI_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_UI_VERSION=\"${PROWLER_VERSION}\"|" "${DOCS_FILE}"
sed -i "s|PROWLER_API_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_API_VERSION=\"${PROWLER_VERSION}\"|" "${DOCS_FILE}"
- name: Show consolidated diff
run: git --no-pager diff
- name: Create PR for next versions to master
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: master
commit-message: 'chore(release): Bump versions to v${{ env.NEXT_SDK_VERSION }}'
branch: release-version-bump-to-v${{ env.NEXT_SDK_VERSION }}
title: 'chore(release): Bump versions to v${{ env.NEXT_SDK_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
Bump Prowler versions on master after releasing Prowler v${{ env.PROWLER_VERSION }}.
| Area | File(s) | New version |
| --- | --- | --- |
| SDK | `pyproject.toml`, `prowler/config/config.py` | v${{ env.NEXT_SDK_VERSION }} |
| API | `api/pyproject.toml`, `api/src/backend/api/specs/v1.yaml` | v${{ env.NEXT_API_VERSION }} |
| UI | `.env` (`NEXT_PUBLIC_PROWLER_RELEASE_VERSION`) | v${{ env.NEXT_SDK_VERSION }} |
| Docs | `docs/getting-started/installation/prowler-app.mdx` (`PROWLER_UI_VERSION`, `PROWLER_API_VERSION`) | v${{ env.PROWLER_VERSION }} (skipped if already at or ahead) |
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
bump-minor-version-branch:
name: Bump versions on version branch (minor release)
needs: detect-release-type
if: needs.detect-release-type.outputs.is_minor == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout version branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }}
persist-credentials: false
- name: Compute first patch versions for version branch
run: |
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION}
# SDK / UI first patch mirrors Prowler version directly.
FIRST_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.1
# API on this branch stays on the 1.<MINOR+1>.X stream, starting at .1
FIRST_API_PATCH_VERSION=1.$((MINOR_VERSION + 1)).1
CURRENT_API_VERSION=$(grep -oP '^version = "\K[^"]+' api/pyproject.toml)
echo "FIRST_PATCH_VERSION=${FIRST_PATCH_VERSION}" >> "${GITHUB_ENV}"
echo "FIRST_API_PATCH_VERSION=${FIRST_API_PATCH_VERSION}" >> "${GITHUB_ENV}"
echo "CURRENT_API_VERSION=${CURRENT_API_VERSION}" >> "${GITHUB_ENV}"
echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}"
echo "Released Prowler version: $PROWLER_VERSION"
echo "Version branch: $VERSION_BRANCH"
echo "First SDK/UI patch: $FIRST_PATCH_VERSION"
echo "First API patch: $FIRST_API_PATCH_VERSION (current: $CURRENT_API_VERSION)"
env:
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
- name: Bump SDK version (pyproject.toml, config.py)
run: |
set -e
sed -i "s|version = \"${PROWLER_VERSION}\"|version = \"${FIRST_PATCH_VERSION}\"|" pyproject.toml
sed -i "s|prowler_version = \"${PROWLER_VERSION}\"|prowler_version = \"${FIRST_PATCH_VERSION}\"|" prowler/config/config.py
- name: Bump API version (api/pyproject.toml, specs/v1.yaml)
run: |
set -e
sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${FIRST_API_PATCH_VERSION}\"|" api/pyproject.toml
sed -i "s| version: ${CURRENT_API_VERSION}| version: ${FIRST_API_PATCH_VERSION}|" api/src/backend/api/specs/v1.yaml
- name: Bump UI version (.env)
run: |
set -e
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${FIRST_PATCH_VERSION}|" .env
- name: Show consolidated diff
run: git --no-pager diff
- name: Create PR for first patch versions to version branch
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: ${{ env.VERSION_BRANCH }}
commit-message: 'chore(release): Bump versions to v${{ env.FIRST_PATCH_VERSION }}'
branch: release-version-bump-to-v${{ env.FIRST_PATCH_VERSION }}
title: 'chore(release): Bump versions to v${{ env.FIRST_PATCH_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
Bump Prowler versions on `${{ env.VERSION_BRANCH }}` after releasing Prowler v${{ env.PROWLER_VERSION }}.
| Area | File(s) | New version |
| --- | --- | --- |
| SDK | `pyproject.toml`, `prowler/config/config.py` | v${{ env.FIRST_PATCH_VERSION }} |
| API | `api/pyproject.toml`, `api/src/backend/api/specs/v1.yaml` | v${{ env.FIRST_API_PATCH_VERSION }} |
| UI | `.env` (`NEXT_PUBLIC_PROWLER_RELEASE_VERSION`) | v${{ env.FIRST_PATCH_VERSION }} |
| Docs | (not touched on version branches) | — |
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
bump-patch-version-branch:
name: Bump versions on version branch (patch release)
needs: detect-release-type
if: needs.detect-release-type.outputs.is_patch == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Compute next patch versions
run: |
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
PATCH_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_PATCH_VERSION}
VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION}
# SDK / UI patch mirrors Prowler version directly.
NEXT_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.$((PATCH_VERSION + 1))
CURRENT_API_VERSION=$(grep -oP '^version = "\K[^"]+' api/pyproject.toml)
# API on this branch stays on 1.<MINOR+1>.X; bump its patch component.
if [[ $CURRENT_API_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
API_PATCH=${BASH_REMATCH[3]}
NEXT_API_PATCH_VERSION=1.$((MINOR_VERSION + 1)).$((API_PATCH + 1))
else
echo "::error::Invalid API version format: $CURRENT_API_VERSION"
exit 1
fi
echo "NEXT_PATCH_VERSION=${NEXT_PATCH_VERSION}" >> "${GITHUB_ENV}"
echo "NEXT_API_PATCH_VERSION=${NEXT_API_PATCH_VERSION}" >> "${GITHUB_ENV}"
echo "CURRENT_API_VERSION=${CURRENT_API_VERSION}" >> "${GITHUB_ENV}"
echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}"
echo "Released Prowler version: $PROWLER_VERSION"
echo "Version branch: $VERSION_BRANCH"
echo "Next SDK/UI patch: $NEXT_PATCH_VERSION"
echo "Next API patch: $NEXT_API_PATCH_VERSION (current: $CURRENT_API_VERSION)"
env:
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_PATCH_VERSION: ${{ needs.detect-release-type.outputs.patch_version }}
- name: Bump SDK version (pyproject.toml, config.py)
run: |
set -e
sed -i "s|version = \"${PROWLER_VERSION}\"|version = \"${NEXT_PATCH_VERSION}\"|" pyproject.toml
sed -i "s|prowler_version = \"${PROWLER_VERSION}\"|prowler_version = \"${NEXT_PATCH_VERSION}\"|" prowler/config/config.py
- name: Bump API version (api/pyproject.toml, specs/v1.yaml)
run: |
set -e
sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${NEXT_API_PATCH_VERSION}\"|" api/pyproject.toml
sed -i "s| version: ${CURRENT_API_VERSION}| version: ${NEXT_API_PATCH_VERSION}|" api/src/backend/api/specs/v1.yaml
- name: Bump UI version (.env)
run: |
set -e
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_PATCH_VERSION}|" .env
- name: Show consolidated diff
run: git --no-pager diff
- name: Create PR for next patch versions to version branch
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: ${{ env.VERSION_BRANCH }}
commit-message: 'chore(release): Bump versions to v${{ env.NEXT_PATCH_VERSION }}'
branch: release-version-bump-to-v${{ env.NEXT_PATCH_VERSION }}
title: 'chore(release): Bump versions to v${{ env.NEXT_PATCH_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
Bump Prowler versions on `${{ env.VERSION_BRANCH }}` after releasing Prowler v${{ env.PROWLER_VERSION }}.
| Area | File(s) | New version |
| --- | --- | --- |
| SDK | `pyproject.toml`, `prowler/config/config.py` | v${{ env.NEXT_PATCH_VERSION }} |
| API | `api/pyproject.toml`, `api/src/backend/api/specs/v1.yaml` | v${{ env.NEXT_API_PATCH_VERSION }} |
| UI | `.env` (`NEXT_PUBLIC_PROWLER_RELEASE_VERSION`) | v${{ env.NEXT_PATCH_VERSION }} |
| Docs | (not touched on version branches) | — |
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
-97
View File
@@ -1,97 +0,0 @@
name: 'Docs: Bump Version'
on:
release:
types:
- 'published'
concurrency:
group: ${{ github.workflow }}-${{ github.event.release.tag_name }}
cancel-in-progress: false
env:
PROWLER_VERSION: ${{ github.event.release.tag_name }}
BASE_BRANCH: master
DOCS_FILE: docs/getting-started/installation/prowler-app.mdx
permissions: {}
jobs:
bump-version:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Validate release version
run: |
if [[ ! $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "::error::Invalid version syntax: '$PROWLER_VERSION' (must be X.Y.Z)"
exit 1
fi
if (( ${BASH_REMATCH[1]} != 5 )); then
echo "::error::Releasing another Prowler major version, aborting..."
exit 1
fi
- name: Checkout master branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ env.BASE_BRANCH }}
persist-credentials: false
- name: Read current docs version on master
id: docs_version
run: |
CURRENT_DOCS_VERSION=$(grep -oP 'PROWLER_UI_VERSION="\K[^"]+' "${DOCS_FILE}")
echo "CURRENT_DOCS_VERSION=${CURRENT_DOCS_VERSION}" >> "${GITHUB_ENV}"
echo "Current docs version on master: $CURRENT_DOCS_VERSION"
echo "Target release version: $PROWLER_VERSION"
# Skip if master is already at or ahead of the release version
# (re-run, or patch shipped against an older minor line)
HIGHEST=$(printf '%s\n%s\n' "${CURRENT_DOCS_VERSION}" "${PROWLER_VERSION}" | sort -V | tail -n1)
if [[ "${CURRENT_DOCS_VERSION}" == "${PROWLER_VERSION}" || "${HIGHEST}" != "${PROWLER_VERSION}" ]]; then
echo "skip=true" >> "${GITHUB_OUTPUT}"
echo "Skipping bump: current ($CURRENT_DOCS_VERSION) >= release ($PROWLER_VERSION)"
else
echo "skip=false" >> "${GITHUB_OUTPUT}"
fi
- name: Bump versions in documentation
if: steps.docs_version.outputs.skip == 'false'
run: |
set -e
sed -i "s|PROWLER_UI_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_UI_VERSION=\"${PROWLER_VERSION}\"|" "${DOCS_FILE}"
sed -i "s|PROWLER_API_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_API_VERSION=\"${PROWLER_VERSION}\"|" "${DOCS_FILE}"
echo "Files modified:"
git --no-pager diff
- name: Create PR for documentation update to master
if: steps.docs_version.outputs.skip == 'false'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: ${{ env.BASE_BRANCH }}
commit-message: 'chore(docs): Bump version to v${{ env.PROWLER_VERSION }}'
branch: docs-version-bump-to-v${{ env.PROWLER_VERSION }}
title: 'chore(docs): Bump version to v${{ env.PROWLER_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
Update Prowler documentation version references to v${{ env.PROWLER_VERSION }} after releasing Prowler v${{ env.PROWLER_VERSION }}.
### Files Updated
- `docs/getting-started/installation/prowler-app.mdx`: `PROWLER_UI_VERSION` and `PROWLER_API_VERSION`
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
+2 -1
View File
@@ -88,7 +88,8 @@ jobs:
# The MCP server version (mcp_server/pyproject.toml) is decoupled from the Prowler release
# version: it only changes when MCP code changes. mcp-bump-version.yml normally keeps it in
# sync with mcp_server/CHANGELOG.md, but this publish workflow still runs on every release.
# sync with mcp_server/CHANGELOG.md (separate from the release bump-version.yml), but this
# publish workflow still runs on every release.
# Pre-flight PyPI check covers the legitimate "no MCP changes for this release" case (and any
# workflow_dispatch re-runs) without failing with HTTP 400 (version exists).
- name: Check if prowler-mcp version already exists on PyPI
+1 -12
View File
@@ -53,7 +53,7 @@ jobs:
- name: Parse version and determine branch
run: |
# Validate version format (reusing pattern from sdk-bump-version.yml)
# Validate version format (reusing pattern from bump-version.yml)
if [[ $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR_VERSION=${BASH_REMATCH[1]}
MINOR_VERSION=${BASH_REMATCH[2]}
@@ -299,17 +299,6 @@ jobs:
fi
echo "✓ api/pyproject.toml prowler dependency: $CURRENT_PROWLER_REF"
- name: Verify API version in api/src/backend/api/v1/views.py
if: ${{ env.HAS_API_CHANGES == 'true' }}
run: |
CURRENT_API_VERSION=$(grep 'spectacular_settings.VERSION = ' api/src/backend/api/v1/views.py | sed -E 's/.*spectacular_settings.VERSION = "([^"]+)".*/\1/' | tr -d '[:space:]')
API_VERSION_TRIMMED=$(echo "$API_VERSION" | tr -d '[:space:]')
if [ "$CURRENT_API_VERSION" != "$API_VERSION_TRIMMED" ]; then
echo "ERROR: API version mismatch in views.py (expected: '$API_VERSION_TRIMMED', found: '$CURRENT_API_VERSION')"
exit 1
fi
echo "✓ api/src/backend/api/v1/views.py version: $CURRENT_API_VERSION"
- name: Verify API version in api/src/backend/api/specs/v1.yaml
if: ${{ env.HAS_API_CHANGES == 'true' }}
run: |
-247
View File
@@ -1,247 +0,0 @@
name: 'SDK: Bump Version'
on:
release:
types:
- 'published'
concurrency:
group: ${{ github.workflow }}-${{ github.event.release.tag_name }}
cancel-in-progress: false
env:
PROWLER_VERSION: ${{ github.event.release.tag_name }}
BASE_BRANCH: master
permissions: {}
jobs:
detect-release-type:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
is_minor: ${{ steps.detect.outputs.is_minor }}
is_patch: ${{ steps.detect.outputs.is_patch }}
major_version: ${{ steps.detect.outputs.major_version }}
minor_version: ${{ steps.detect.outputs.minor_version }}
patch_version: ${{ steps.detect.outputs.patch_version }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Detect release type and parse version
id: detect
run: |
if [[ $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR_VERSION=${BASH_REMATCH[1]}
MINOR_VERSION=${BASH_REMATCH[2]}
PATCH_VERSION=${BASH_REMATCH[3]}
echo "major_version=${MAJOR_VERSION}" >> "${GITHUB_OUTPUT}"
echo "minor_version=${MINOR_VERSION}" >> "${GITHUB_OUTPUT}"
echo "patch_version=${PATCH_VERSION}" >> "${GITHUB_OUTPUT}"
if (( MAJOR_VERSION != 5 )); then
echo "::error::Releasing another Prowler major version, aborting..."
exit 1
fi
if (( PATCH_VERSION == 0 )); then
echo "is_minor=true" >> "${GITHUB_OUTPUT}"
echo "is_patch=false" >> "${GITHUB_OUTPUT}"
echo "✓ Minor release detected: $PROWLER_VERSION"
else
echo "is_minor=false" >> "${GITHUB_OUTPUT}"
echo "is_patch=true" >> "${GITHUB_OUTPUT}"
echo "✓ Patch release detected: $PROWLER_VERSION"
fi
else
echo "::error::Invalid version syntax: '$PROWLER_VERSION' (must be X.Y.Z)"
exit 1
fi
bump-minor-version:
needs: detect-release-type
if: needs.detect-release-type.outputs.is_minor == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Calculate next minor version
run: |
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
NEXT_MINOR_VERSION=${MAJOR_VERSION}.$((MINOR_VERSION + 1)).0
echo "NEXT_MINOR_VERSION=${NEXT_MINOR_VERSION}" >> "${GITHUB_ENV}"
echo "Current version: $PROWLER_VERSION"
echo "Next minor version: $NEXT_MINOR_VERSION"
env:
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
- name: Bump versions in files for master
run: |
set -e
sed -i "s|version = \"${PROWLER_VERSION}\"|version = \"${NEXT_MINOR_VERSION}\"|" pyproject.toml
sed -i "s|prowler_version = \"${PROWLER_VERSION}\"|prowler_version = \"${NEXT_MINOR_VERSION}\"|" prowler/config/config.py
echo "Files modified:"
git --no-pager diff
- name: Create PR for next minor version to master
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: master
commit-message: 'chore(sdk): Bump version to v${{ env.NEXT_MINOR_VERSION }}'
branch: sdk-version-bump-to-v${{ env.NEXT_MINOR_VERSION }}
title: 'chore(sdk): Bump version to v${{ env.NEXT_MINOR_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
Bump Prowler version to v${{ env.NEXT_MINOR_VERSION }} after releasing v${{ env.PROWLER_VERSION }}.
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
- name: Checkout version branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }}
persist-credentials: false
- name: Calculate first patch version
run: |
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
FIRST_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.1
VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION}
echo "FIRST_PATCH_VERSION=${FIRST_PATCH_VERSION}" >> "${GITHUB_ENV}"
echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}"
echo "First patch version: $FIRST_PATCH_VERSION"
echo "Version branch: $VERSION_BRANCH"
env:
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
- name: Bump versions in files for version branch
run: |
set -e
sed -i "s|version = \"${PROWLER_VERSION}\"|version = \"${FIRST_PATCH_VERSION}\"|" pyproject.toml
sed -i "s|prowler_version = \"${PROWLER_VERSION}\"|prowler_version = \"${FIRST_PATCH_VERSION}\"|" prowler/config/config.py
echo "Files modified:"
git --no-pager diff
- name: Create PR for first patch version to version branch
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: ${{ env.VERSION_BRANCH }}
commit-message: 'chore(sdk): Bump version to v${{ env.FIRST_PATCH_VERSION }}'
branch: sdk-version-bump-to-v${{ env.FIRST_PATCH_VERSION }}
title: 'chore(sdk): Bump version to v${{ env.FIRST_PATCH_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
Bump Prowler version to v${{ env.FIRST_PATCH_VERSION }} in version branch after releasing v${{ env.PROWLER_VERSION }}.
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
bump-patch-version:
needs: detect-release-type
if: needs.detect-release-type.outputs.is_patch == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Calculate next patch version
run: |
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
PATCH_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_PATCH_VERSION}
NEXT_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.$((PATCH_VERSION + 1))
VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION}
echo "NEXT_PATCH_VERSION=${NEXT_PATCH_VERSION}" >> "${GITHUB_ENV}"
echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}"
echo "Current version: $PROWLER_VERSION"
echo "Next patch version: $NEXT_PATCH_VERSION"
echo "Target branch: $VERSION_BRANCH"
env:
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_PATCH_VERSION: ${{ needs.detect-release-type.outputs.patch_version }}
- name: Bump versions in files for version branch
run: |
set -e
sed -i "s|version = \"${PROWLER_VERSION}\"|version = \"${NEXT_PATCH_VERSION}\"|" pyproject.toml
sed -i "s|prowler_version = \"${PROWLER_VERSION}\"|prowler_version = \"${NEXT_PATCH_VERSION}\"|" prowler/config/config.py
echo "Files modified:"
git --no-pager diff
- name: Create PR for next patch version to version branch
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: ${{ env.VERSION_BRANCH }}
commit-message: 'chore(sdk): Bump version to v${{ env.NEXT_PATCH_VERSION }}'
branch: sdk-version-bump-to-v${{ env.NEXT_PATCH_VERSION }}
title: 'chore(sdk): Bump version to v${{ env.NEXT_PATCH_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
Bump Prowler version to v${{ env.NEXT_PATCH_VERSION }} after releasing v${{ env.PROWLER_VERSION }}.
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
-253
View File
@@ -1,253 +0,0 @@
name: 'UI: Bump Version'
on:
release:
types:
- 'published'
concurrency:
group: ${{ github.workflow }}-${{ github.event.release.tag_name }}
cancel-in-progress: false
env:
PROWLER_VERSION: ${{ github.event.release.tag_name }}
BASE_BRANCH: master
permissions: {}
jobs:
detect-release-type:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
is_minor: ${{ steps.detect.outputs.is_minor }}
is_patch: ${{ steps.detect.outputs.is_patch }}
major_version: ${{ steps.detect.outputs.major_version }}
minor_version: ${{ steps.detect.outputs.minor_version }}
patch_version: ${{ steps.detect.outputs.patch_version }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Detect release type and parse version
id: detect
run: |
if [[ $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR_VERSION=${BASH_REMATCH[1]}
MINOR_VERSION=${BASH_REMATCH[2]}
PATCH_VERSION=${BASH_REMATCH[3]}
echo "major_version=${MAJOR_VERSION}" >> "${GITHUB_OUTPUT}"
echo "minor_version=${MINOR_VERSION}" >> "${GITHUB_OUTPUT}"
echo "patch_version=${PATCH_VERSION}" >> "${GITHUB_OUTPUT}"
if (( MAJOR_VERSION != 5 )); then
echo "::error::Releasing another Prowler major version, aborting..."
exit 1
fi
if (( PATCH_VERSION == 0 )); then
echo "is_minor=true" >> "${GITHUB_OUTPUT}"
echo "is_patch=false" >> "${GITHUB_OUTPUT}"
echo "✓ Minor release detected: $PROWLER_VERSION"
else
echo "is_minor=false" >> "${GITHUB_OUTPUT}"
echo "is_patch=true" >> "${GITHUB_OUTPUT}"
echo "✓ Patch release detected: $PROWLER_VERSION"
fi
else
echo "::error::Invalid version syntax: '$PROWLER_VERSION' (must be X.Y.Z)"
exit 1
fi
bump-minor-version:
needs: detect-release-type
if: needs.detect-release-type.outputs.is_minor == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Calculate next minor version
run: |
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
NEXT_MINOR_VERSION=${MAJOR_VERSION}.$((MINOR_VERSION + 1)).0
echo "NEXT_MINOR_VERSION=${NEXT_MINOR_VERSION}" >> "${GITHUB_ENV}"
echo "Current version: $PROWLER_VERSION"
echo "Next minor version: $NEXT_MINOR_VERSION"
env:
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
- name: Bump UI version in .env for master
run: |
set -e
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_MINOR_VERSION}|" .env
echo "Files modified:"
git --no-pager diff
- name: Create PR for next minor version to master
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: master
commit-message: 'chore(ui): Bump version to v${{ env.NEXT_MINOR_VERSION }}'
branch: ui-version-bump-to-v${{ env.NEXT_MINOR_VERSION }}
title: 'chore(ui): Bump version to v${{ env.NEXT_MINOR_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
Bump Prowler UI version to v${{ env.NEXT_MINOR_VERSION }} after releasing Prowler v${{ env.PROWLER_VERSION }}.
### Files Updated
- `.env`: `NEXT_PUBLIC_PROWLER_RELEASE_VERSION`
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
- name: Checkout version branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }}
persist-credentials: false
- name: Calculate first patch version
run: |
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
FIRST_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.1
VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION}
echo "FIRST_PATCH_VERSION=${FIRST_PATCH_VERSION}" >> "${GITHUB_ENV}"
echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}"
echo "First patch version: $FIRST_PATCH_VERSION"
echo "Version branch: $VERSION_BRANCH"
env:
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
- name: Bump UI version in .env for version branch
run: |
set -e
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${FIRST_PATCH_VERSION}|" .env
echo "Files modified:"
git --no-pager diff
- name: Create PR for first patch version to version branch
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: ${{ env.VERSION_BRANCH }}
commit-message: 'chore(ui): Bump version to v${{ env.FIRST_PATCH_VERSION }}'
branch: ui-version-bump-to-v${{ env.FIRST_PATCH_VERSION }}
title: 'chore(ui): Bump version to v${{ env.FIRST_PATCH_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
Bump Prowler UI version to v${{ env.FIRST_PATCH_VERSION }} in version branch after releasing Prowler v${{ env.PROWLER_VERSION }}.
### Files Updated
- `.env`: `NEXT_PUBLIC_PROWLER_RELEASE_VERSION`
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
bump-patch-version:
needs: detect-release-type
if: needs.detect-release-type.outputs.is_patch == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Calculate next patch version
run: |
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
PATCH_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_PATCH_VERSION}
NEXT_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.$((PATCH_VERSION + 1))
VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION}
echo "NEXT_PATCH_VERSION=${NEXT_PATCH_VERSION}" >> "${GITHUB_ENV}"
echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}"
echo "Current version: $PROWLER_VERSION"
echo "Next patch version: $NEXT_PATCH_VERSION"
echo "Target branch: $VERSION_BRANCH"
env:
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_PATCH_VERSION: ${{ needs.detect-release-type.outputs.patch_version }}
- name: Bump UI version in .env for version branch
run: |
set -e
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_PATCH_VERSION}|" .env
echo "Files modified:"
git --no-pager diff
- name: Create PR for next patch version to version branch
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: ${{ env.VERSION_BRANCH }}
commit-message: 'chore(ui): Bump version to v${{ env.NEXT_PATCH_VERSION }}'
branch: ui-version-bump-to-v${{ env.NEXT_PATCH_VERSION }}
title: 'chore(ui): Bump version to v${{ env.NEXT_PATCH_VERSION }}'
labels: no-changelog,skip-sync
body: |
### Description
Bump Prowler UI version to v${{ env.NEXT_PATCH_VERSION }} after releasing Prowler v${{ env.PROWLER_VERSION }}.
### Files Updated
- `.env`: `NEXT_PUBLIC_PROWLER_RELEASE_VERSION`
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
+1 -4
View File
@@ -1,22 +1,19 @@
rules:
secrets-outside-env:
ignore:
- api-bump-version.yml
- api-container-build-push.yml
- api-tests.yml
- backport.yml
- docs-bump-version.yml
- bump-version.yml
- issue-triage.lock.yml
- mcp-container-build-push.yml
- nightly-arm64-container-builds.yml
- pr-merged.yml
- prepare-release.yml
- sdk-bump-version.yml
- sdk-container-build-push.yml
- sdk-refresh-aws-services-regions.yml
- sdk-refresh-oci-regions.yml
- sdk-tests.yml
- ui-bump-version.yml
- ui-container-build-push.yml
- ui-e2e-tests-v2.yml
superfluous-actions:
+1
View File
@@ -121,6 +121,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| OpenStack | 34 | 5 | 0 | 9 | Official | UI, API, CLI |
| Vercel | 26 | 6 | 0 | 5 | Official | UI, API, CLI |
| Okta | 1 | 1 | 0 | 1 | Official | CLI |
| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 0 | 1 | Unofficial | CLI |
| NHN | 6 | 2 | 1 | 0 | Unofficial | CLI |
> [!Note]
+7
View File
@@ -326,6 +326,13 @@
"user-guide/providers/openstack/authentication"
]
},
{
"group": "Scaleway",
"pages": [
"user-guide/providers/scaleway/getting-started-scaleway",
"user-guide/providers/scaleway/authentication"
]
},
{
"group": "Vercel",
"pages": [
+1
View File
@@ -35,6 +35,7 @@ Prowler supports a wide range of providers organized by category:
| **NHN** | Unofficial | Tenants | CLI |
| [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | UI, API, CLI |
| [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI |
| [Scaleway](/user-guide/providers/scaleway/getting-started-scaleway) [Contact us](https://prowler.com/contact) | Unofficial | Organizations | CLI |
### Infrastructure as Code Providers
@@ -0,0 +1,37 @@
---
title: 'Scaleway Authentication in Prowler'
---
Prowler authenticates to Scaleway using a **Scaleway API key** (access key + secret key). The integration is read-only and only needs permission to list IAM users and API keys in the audited organization.
## Prerequisites
1. A Scaleway organization with IAM access.
2. A Scaleway API key with at least the `IAMReadOnly` policy bound to a dedicated IAM user (do not use the account root user).
3. Your organization ID (visible at the top right of the Scaleway console).
## Authentication Method
Prowler reads credentials **exclusively** from the standard Scaleway environment variables. There are no credential CLI flags, so secrets are never exposed in shell history or process listings.
| Variable | Purpose |
|---|---|
| `SCW_ACCESS_KEY` | API key access key |
| `SCW_SECRET_KEY` | API key secret key |
| `SCW_DEFAULT_ORGANIZATION_ID` | Optional, required when the key bearer is an application |
| `SCW_DEFAULT_PROJECT_ID` | Optional, default project for project-scoped resources |
| `SCW_DEFAULT_REGION` | Optional, defaults to `fr-par` |
The scope variables can also be passed as CLI flags (`--organization-id`, `--project-id`, `--region`), which override the corresponding environment variables.
```bash
export SCW_ACCESS_KEY="SCW..."
export SCW_SECRET_KEY="..."
export SCW_DEFAULT_ORGANIZATION_ID="..."
prowler scaleway
```
## Required Scaleway Permissions
The API key bearer needs read access to the IAM API in order to list users and API keys. The `IAMReadOnly` policy is sufficient. Refer to the [Scaleway IAM policy reference](https://www.scaleway.com/en/docs/identity-and-access-management/iam/reference-content/permission-sets/) for the full list of permissions.
@@ -0,0 +1,37 @@
---
title: "Getting Started With Scaleway on Prowler"
---
Prowler for Scaleway scans IAM resources in your Scaleway organization for security misconfigurations. The current release ships one check that flags API keys still owned by the account root user.
## Prerequisites
1. A Scaleway organization with IAM access.
2. A Scaleway API key with at least the `IAMReadOnly` policy bound to a dedicated IAM user (do not use the account root user).
3. Your organization ID (visible at the top right of the Scaleway console).
## Authentication
Prowler authenticates to Scaleway with a Scaleway API key. See [Scaleway Authentication in Prowler](./authentication) for the full setup, environment variables, CLI flags, and required permissions.
## Run a scan
```bash
export SCW_ACCESS_KEY="SCW..."
export SCW_SECRET_KEY="..."
export SCW_DEFAULT_ORGANIZATION_ID="..."
prowler scaleway
```
To run only the IAM root-key check:
```bash
prowler scaleway --check iam_api_keys_no_root_owned
```
## Checks shipped
| Check ID | Severity | Description |
|---|---|---|
| `iam_api_keys_no_root_owned` | Critical | Fails when any Scaleway IAM API key is still owned by the account root user. |
+1
View File
@@ -12,6 +12,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `cloudtrail_bedrock_logging_enabled` check for AWS provider [(#10858)](https://github.com/prowler-cloud/prowler/pull/10858)
- Okta provider with OAuth 2.0 authentication and `signon_global_session_idle_timeout_15min` check [(#11079)](https://github.com/prowler-cloud/prowler/pull/11079)
- `sagemaker_domain_sso_configured` check for AWS provider [(#11094)](https://github.com/prowler-cloud/prowler/pull/11094)
- Scaleway provider with `iam_api_keys_no_root_owned` check [(#11166)](https://github.com/prowler-cloud/prowler/pull/11166)
### 🔄 Changed
+5
View File
@@ -157,6 +157,7 @@ from prowler.providers.nhn.models import NHNOutputOptions
from prowler.providers.okta.models import OktaOutputOptions
from prowler.providers.openstack.models import OpenStackOutputOptions
from prowler.providers.oraclecloud.models import OCIOutputOptions
from prowler.providers.scaleway.models import ScalewayOutputOptions
from prowler.providers.vercel.models import VercelOutputOptions
@@ -431,6 +432,10 @@ def prowler():
output_options = OktaOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
elif provider == "scaleway":
output_options = ScalewayOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
# Run the quick inventory for the provider if available
if hasattr(args, "quick_inventory") and args.quick_inventory:
+1
View File
@@ -75,6 +75,7 @@ class Provider(str, Enum):
ALIBABACLOUD = "alibabacloud"
OPENSTACK = "openstack"
IMAGE = "image"
SCALEWAY = "scaleway"
VERCEL = "vercel"
OKTA = "okta"
+4
View File
@@ -741,6 +741,10 @@ def execute(
is_finding_muted_args["team_id"] = (
team.id if team else global_provider.identity.user_id
)
elif global_provider.type == "scaleway":
is_finding_muted_args["organization_id"] = (
global_provider.identity.organization_id
)
elif global_provider.type == "oraclecloud":
is_finding_muted_args["tenancy_id"] = (
global_provider.identity.tenancy_id
+48
View File
@@ -1318,6 +1318,54 @@ class CheckReportVercel(Check_Report):
return "global"
@dataclass
class CheckReportScaleway(Check_Report):
"""Contains the Scaleway Check's finding information.
Scaleway scans run at the organization level. Most IAM/account-level
resources are global; regional resources expose a ``region`` attribute
on the underlying object, which we surface as the report ``region``.
"""
resource_name: str
resource_id: str
organization_id: str
def __init__(
self,
metadata: Dict,
resource: Any,
resource_name: str = None,
resource_id: str = None,
organization_id: str = None,
) -> None:
"""Initialize the Scaleway Check's finding information.
Args:
metadata: Check metadata dictionary.
resource: The Scaleway resource being checked.
resource_name: Override for resource name.
resource_id: Override for resource ID.
organization_id: Override for the organization ID.
"""
super().__init__(metadata, resource)
self.resource_name = resource_name or getattr(
resource, "name", getattr(resource, "resource_name", "")
)
self.resource_id = resource_id or getattr(
resource, "id", getattr(resource, "resource_id", "")
)
self.organization_id = organization_id or getattr(
resource, "organization_id", ""
)
self._region = getattr(resource, "region", None) or "global"
@property
def region(self) -> str:
"""Scaleway regional resources expose their own region; IAM is global."""
return self._region
# Testing Pending
def load_check_metadata(metadata_file: str) -> CheckMetadata:
"""
+3 -2
View File
@@ -29,10 +29,10 @@ class ProwlerArgumentParser:
self.parser = argparse.ArgumentParser(
prog="prowler",
formatter_class=RawTextHelpFormatter,
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image,llm} ...",
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,vercel,dashboard,iac,image,llm} ...",
epilog="""
Available Cloud Providers:
{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel}
{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,vercel}
aws AWS Provider
azure Azure Provider
gcp GCP Provider
@@ -50,6 +50,7 @@ Available Cloud Providers:
image Container Image Provider
nhn NHN Provider (Unofficial)
mongodbatlas MongoDB Atlas Provider
scaleway Scaleway Provider
vercel Vercel Provider
Available components:
+12
View File
@@ -442,6 +442,18 @@ class Finding(BaseModel):
output_data["resource_uid"] = check_output.resource_id
output_data["region"] = "global"
elif provider.type == "scaleway":
output_data["auth_method"] = "api_key"
output_data["account_uid"] = get_nested_attribute(
provider, "identity.organization_id"
)
output_data["account_name"] = get_nested_attribute(
provider, "identity.bearer_email"
) or get_nested_attribute(provider, "identity.organization_id")
output_data["resource_name"] = check_output.resource_name
output_data["resource_uid"] = check_output.resource_id
output_data["region"] = check_output.region
elif provider.type == "alibabacloud":
output_data["auth_method"] = get_nested_attribute(
provider, "identity.identity_arn"
+71
View File
@@ -1450,6 +1450,77 @@ class HTML(Output):
)
return ""
@staticmethod
def get_scaleway_assessment_summary(provider: Provider) -> str:
"""
get_scaleway_assessment_summary gets the HTML assessment summary for the Scaleway provider
Args:
provider (Provider): the Scaleway provider object
Returns:
str: HTML assessment summary for the Scaleway provider
"""
try:
assessment_items = f"""
<li class="list-group-item">
<b>Organization ID:</b> {provider.identity.organization_id}
</li>"""
credentials_items = """
<li class="list-group-item">
<b>Authentication:</b> API Key
</li>"""
access_key = getattr(provider.session, "access_key", None)
if access_key:
credentials_items += f"""
<li class="list-group-item">
<b>Access Key:</b> {access_key}
</li>"""
bearer_type = getattr(provider.identity, "bearer_type", None)
bearer_email = getattr(provider.identity, "bearer_email", None)
bearer_id = getattr(provider.identity, "bearer_id", None)
if bearer_type:
bearer_label = bearer_email or bearer_id or "-"
credentials_items += f"""
<li class="list-group-item">
<b>Bearer:</b> {bearer_type} ({bearer_label})
</li>"""
region = getattr(provider.session, "default_region", None)
if region:
credentials_items += f"""
<li class="list-group-item">
<b>Default Region:</b> {region}
</li>"""
return f"""
<div class="col-md-2">
<div class="card">
<div class="card-header">
Scaleway Assessment Summary
</div>
<ul class="list-group list-group-flush">{assessment_items}
</ul>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
Scaleway Credentials
</div>
<ul class="list-group list-group-flush">{credentials_items}
</ul>
</div>
</div>"""
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
)
return ""
@staticmethod
def get_assessment_summary(provider: Provider) -> str:
"""
+2
View File
@@ -42,6 +42,8 @@ def stdout_report(finding, color, verbose, status, fix):
details = finding.region
if finding.check_metadata.Provider == "okta":
details = finding.region
if finding.check_metadata.Provider == "scaleway":
details = finding.region
if (verbose or fix) and (not status or finding.status in status):
if finding.muted:
+3
View File
@@ -111,6 +111,9 @@ def display_summary_table(
elif provider.type == "okta":
entity_type = "Okta Org"
audited_entities = provider.identity.org_domain
elif provider.type == "scaleway":
entity_type = "Organization"
audited_entities = provider.identity.organization_id
# Check if there are findings and that they are not all MANUAL
if findings and not all(finding.status == "MANUAL" for finding in findings):
+12
View File
@@ -416,6 +416,18 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "scaleway" in provider_class_name.lower():
# Credentials are read from the SCW_ACCESS_KEY /
# SCW_SECRET_KEY env vars by the provider itself; there
# are no credential CLI flags to avoid leaking secrets.
provider_class(
organization_id=getattr(arguments, "organization_id", None),
project_id=getattr(arguments, "project_id", None),
region=getattr(arguments, "region", None),
config_path=arguments.config_file,
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
except TypeError as error:
logger.critical(
@@ -0,0 +1,99 @@
# Exceptions codes from 15000 to 15999 are reserved for Scaleway exceptions
from prowler.exceptions.exceptions import ProwlerException
class ScalewayBaseException(ProwlerException):
"""Base exception for Scaleway provider errors."""
SCALEWAY_ERROR_CODES = {
(15000, "ScalewayCredentialsError"): {
"message": "Scaleway credentials not found or invalid.",
"remediation": (
"Set the SCW_ACCESS_KEY and SCW_SECRET_KEY environment variables "
"with a valid Scaleway API key. Generate one at "
"https://console.scaleway.com/iam/api-keys."
),
},
(15001, "ScalewayAuthenticationError"): {
"message": "Authentication to the Scaleway API failed.",
"remediation": (
"Verify your Scaleway API key is valid, has not expired, and that "
"the bearer has IAM read permissions on the target organization."
),
},
(15002, "ScalewaySessionError"): {
"message": "Failed to create a Scaleway API session.",
"remediation": (
"Check network connectivity and ensure the Scaleway API is "
"reachable at https://api.scaleway.com."
),
},
(15003, "ScalewayIdentityError"): {
"message": "Failed to retrieve Scaleway identity information.",
"remediation": (
"Ensure the API key has permissions to read IAM users and the "
"owning organization metadata."
),
},
(15004, "ScalewayAPIError"): {
"message": "An error occurred while calling the Scaleway API.",
"remediation": (
"Check the Scaleway API status at https://status.scaleway.com "
"and retry. Run with --log-level DEBUG for the full traceback."
),
},
}
def __init__(self, code, file=None, original_exception=None, message=None):
provider = "Scaleway"
error_info = self.SCALEWAY_ERROR_CODES.get((code, self.__class__.__name__))
if error_info is None:
error_info = {
"message": message or "Unknown Scaleway error.",
"remediation": "Check the Scaleway API documentation for more details.",
}
elif message:
error_info = error_info.copy()
error_info["message"] = message
super().__init__(
code=code,
source=provider,
file=file,
original_exception=original_exception,
error_info=error_info,
)
class ScalewayCredentialsError(ScalewayBaseException):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
15000, file=file, original_exception=original_exception, message=message
)
class ScalewayAuthenticationError(ScalewayBaseException):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
15001, file=file, original_exception=original_exception, message=message
)
class ScalewaySessionError(ScalewayBaseException):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
15002, file=file, original_exception=original_exception, message=message
)
class ScalewayIdentityError(ScalewayBaseException):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
15003, file=file, original_exception=original_exception, message=message
)
class ScalewayAPIError(ScalewayBaseException):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
15004, file=file, original_exception=original_exception, message=message
)
@@ -0,0 +1,36 @@
def init_parser(self):
"""Init the Scaleway provider CLI parser."""
scaleway_parser = self.subparsers.add_parser(
"scaleway",
parents=[self.common_providers_parser],
help="Scaleway Provider",
)
# Authentication
# Credentials are read exclusively from the standard Scaleway environment
# variables (SCW_ACCESS_KEY / SCW_SECRET_KEY) to avoid leaking secrets into
# shell history and process listings. There are no credential CLI flags.
# Scope
scope_subparser = scaleway_parser.add_argument_group("Scope")
scope_subparser.add_argument(
"--organization-id",
nargs="?",
default=None,
metavar="SCW_DEFAULT_ORGANIZATION_ID",
help="Scaleway organization ID to scope the audit.",
)
scope_subparser.add_argument(
"--project-id",
nargs="?",
default=None,
metavar="SCW_DEFAULT_PROJECT_ID",
help="Default Scaleway project ID for project-scoped resources.",
)
scope_subparser.add_argument(
"--region",
nargs="?",
default=None,
metavar="SCW_DEFAULT_REGION",
help="Default Scaleway region (fr-par, nl-ams, pl-waw).",
)
@@ -0,0 +1,20 @@
from prowler.lib.check.models import CheckReportScaleway
from prowler.lib.mutelist.mutelist import Mutelist
from prowler.lib.outputs.utils import unroll_dict, unroll_tags
class ScalewayMutelist(Mutelist):
"""Scaleway-specific mutelist helper."""
def is_finding_muted(
self,
finding: CheckReportScaleway,
organization_id: str,
) -> bool:
return self.is_muted(
organization_id,
finding.check_metadata.CheckID,
finding.region or "global",
finding.resource_id or finding.resource_name,
unroll_dict(unroll_tags(finding.resource_tags)),
)
@@ -0,0 +1,44 @@
from prowler.lib.logger import logger
from prowler.providers.scaleway.exceptions.exceptions import ScalewayAPIError
class ScalewayService:
"""Base class for Scaleway services.
Centralizes the provider context (audit/fixer configuration, the
scoping organization, the authenticated ``scaleway.Client``) so each
service only worries about which Scaleway API to call.
"""
def __init__(self, service: str, provider):
self.provider = provider
self.audit_config = provider.audit_config
self.fixer_config = provider.fixer_config
self.service = service.lower() if not service.islower() else service
# Shared authenticated client and the organization in scope
self.client = provider.session.client
self.organization_id = provider.identity.organization_id
def _safe_call(self, label: str, fn, *args, **kwargs):
"""Run a Scaleway SDK call and surface failures as ScalewayAPIError.
Args:
label: Human-readable label for the call (used in logs).
fn: SDK function to invoke.
Returns:
The SDK function result, or ``None`` if the call failed.
"""
try:
return fn(*args, **kwargs)
except Exception as error:
logger.error(
f"{self.service} - {label} failed: "
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
raise ScalewayAPIError(
file=__file__,
original_exception=error,
message=f"Scaleway API call '{label}' failed.",
)
+55
View File
@@ -0,0 +1,55 @@
from typing import Any, Literal, Optional
from pydantic.v1 import BaseModel, Field
from prowler.config.config import output_file_timestamp
from prowler.providers.common.models import ProviderOutputOptions
ScalewayBearerType = Literal["user", "application"]
class ScalewaySession(BaseModel):
"""Scaleway API session information.
Stores the credentials and the underlying ``scaleway.Client`` so every
service can reuse the same authenticated client.
"""
access_key: str
# Excluded from serialization and repr: the whole authentication relies
# on this secret, so it must never leak through .dict()/.json()/logs.
secret_key: str = Field(exclude=True, repr=False)
organization_id: Optional[str] = None
default_project_id: Optional[str] = None
default_region: Optional[str] = None
client: Any = Field(default=None, exclude=True)
class Config:
arbitrary_types_allowed = True
class ScalewayIdentityInfo(BaseModel):
"""Scaleway identity and scoping information."""
organization_id: str
bearer_id: Optional[str] = None
bearer_type: Optional[ScalewayBearerType] = None
bearer_email: Optional[str] = None
account_root_user_id: Optional[str] = None
class ScalewayOutputOptions(ProviderOutputOptions):
"""Customize output filenames for Scaleway scans."""
def __init__(self, arguments, bulk_checks_metadata, identity: ScalewayIdentityInfo):
super().__init__(arguments, bulk_checks_metadata)
if (
not hasattr(arguments, "output_filename")
or arguments.output_filename is None
):
account_fragment = identity.organization_id or "scaleway"
self.output_filename = (
f"prowler-output-{account_fragment}-{output_file_timestamp}"
)
else:
self.output_filename = arguments.output_filename
@@ -0,0 +1,378 @@
import os
from colorama import Fore, Style
from scaleway import Client
from scaleway.iam.v1alpha1 import IamV1Alpha1API
from prowler.config.config import (
default_config_file_path,
get_default_mute_file_path,
load_and_validate_config_file,
)
from prowler.lib.logger import logger
from prowler.lib.utils.utils import print_boxes
from prowler.providers.common.models import Audit_Metadata, Connection
from prowler.providers.common.provider import Provider
from prowler.providers.scaleway.exceptions.exceptions import (
ScalewayAuthenticationError,
ScalewayCredentialsError,
ScalewayIdentityError,
ScalewaySessionError,
)
from prowler.providers.scaleway.lib.mutelist.mutelist import ScalewayMutelist
from prowler.providers.scaleway.models import (
ScalewayIdentityInfo,
ScalewaySession,
)
class ScalewayProvider(Provider):
"""Scaleway provider.
Authenticates against the Scaleway API using an API key (access key +
secret key) and exposes a single global session that every service
reuses. Scaleway scopes everything to an organization, so the
organization ID is the audit identity.
"""
_type: str = "scaleway"
_session: ScalewaySession
_identity: ScalewayIdentityInfo
_audit_config: dict
_fixer_config: dict
_mutelist: ScalewayMutelist
audit_metadata: Audit_Metadata
def __init__(
self,
# Authentication credentials
access_key: str = None,
secret_key: str = None,
organization_id: str = None,
project_id: str = None,
region: str = None,
# Provider configuration
config_path: str = None,
config_content: dict | None = None,
fixer_config: dict = {},
mutelist_path: str = None,
mutelist_content: dict = None,
):
logger.info("Instantiating Scaleway provider...")
if config_content:
self._audit_config = config_content
else:
if not config_path:
config_path = default_config_file_path
self._audit_config = load_and_validate_config_file(self._type, config_path)
self._session = ScalewayProvider.setup_session(
access_key=access_key,
secret_key=secret_key,
organization_id=organization_id,
project_id=project_id,
region=region,
)
self._identity = ScalewayProvider.setup_identity(self._session)
self._fixer_config = fixer_config
if mutelist_content:
self._mutelist = ScalewayMutelist(mutelist_content=mutelist_content)
else:
if not mutelist_path:
mutelist_path = get_default_mute_file_path(self.type)
self._mutelist = ScalewayMutelist(mutelist_path=mutelist_path)
Provider.set_global_provider(self)
@property
def type(self):
return self._type
@property
def session(self):
return self._session
@property
def identity(self):
return self._identity
@property
def audit_config(self):
return self._audit_config
@property
def fixer_config(self):
return self._fixer_config
@property
def mutelist(self) -> ScalewayMutelist:
return self._mutelist
@staticmethod
def setup_session(
access_key: str = None,
secret_key: str = None,
organization_id: str = None,
project_id: str = None,
region: str = None,
) -> ScalewaySession:
"""Initialize the Scaleway API session.
Credentials can be provided as arguments (for API/SDK use) or read
from the official Scaleway environment variables:
- ``SCW_ACCESS_KEY``
- ``SCW_SECRET_KEY``
- ``SCW_DEFAULT_ORGANIZATION_ID``
- ``SCW_DEFAULT_PROJECT_ID``
- ``SCW_DEFAULT_REGION``
Args:
access_key: Scaleway API access key.
secret_key: Scaleway API secret key.
organization_id: Default organization ID to scope the audit.
project_id: Default project ID for project-scoped resources.
region: Default region.
Returns:
ScalewaySession: The initialized session, holding the
authenticated ``scaleway.Client``.
Raises:
ScalewayCredentialsError: Access or secret key missing.
ScalewaySessionError: Client instantiation failed.
"""
access = access_key or os.environ.get("SCW_ACCESS_KEY", "")
secret = secret_key or os.environ.get("SCW_SECRET_KEY", "")
org = organization_id or os.environ.get("SCW_DEFAULT_ORGANIZATION_ID") or None
project = project_id or os.environ.get("SCW_DEFAULT_PROJECT_ID") or None
default_region = region or os.environ.get("SCW_DEFAULT_REGION") or "fr-par"
if not access or not secret:
raise ScalewayCredentialsError(
file=os.path.basename(__file__),
message=(
"Scaleway credentials not found. Provide access_key and "
"secret_key or set the SCW_ACCESS_KEY and SCW_SECRET_KEY "
"environment variables."
),
)
try:
client = Client(
access_key=access,
secret_key=secret,
default_organization_id=org,
default_project_id=project,
default_region=default_region,
)
return ScalewaySession(
access_key=access,
secret_key=secret,
organization_id=org,
default_project_id=project,
default_region=default_region,
client=client,
)
except Exception as error:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
)
raise ScalewaySessionError(
file=os.path.basename(__file__),
original_exception=error,
)
@staticmethod
def setup_identity(session: ScalewaySession) -> ScalewayIdentityInfo:
"""Resolve the audit identity by calling Scaleway IAM.
Uses ``iam.get_api_key`` on the current access key to discover the
bearer (user vs application). When the bearer is a user, the
owning organization is read from the user record; otherwise we
require ``SCW_DEFAULT_ORGANIZATION_ID``.
"""
try:
iam = IamV1Alpha1API(session.client)
current_key = iam.get_api_key(access_key=session.access_key)
bearer_id = current_key.user_id or current_key.application_id
bearer_type = (
"user"
if current_key.user_id
else ("application" if current_key.application_id else None)
)
organization_id = session.organization_id
bearer_email = None
account_root_user_id = None
# If the bearer is a user, resolve the org from the user record
# and surface the email + root user id for the credentials banner.
if current_key.user_id:
user = iam.get_user(user_id=current_key.user_id)
organization_id = organization_id or user.organization_id
bearer_email = user.email
account_root_user_id = user.account_root_user_id
elif current_key.application_id and not organization_id:
# Application keys do not expose the org directly without an
# extra call. The default org from env is preferred.
logger.warning(
"Scaleway application-scoped API key without "
"SCW_DEFAULT_ORGANIZATION_ID. Resource discovery may fail."
)
# NOTE: application-scoped keys never resolve account_root_user_id
# here (the IAM API does not expose it for an application bearer).
# The IAM service falls back to the org's user list to recover it;
# if that is unavailable, iam_api_keys_no_root_owned degrades to
# MANUAL rather than silently PASSing root-owned keys.
if not organization_id:
raise ScalewayIdentityError(
file=os.path.basename(__file__),
message=(
"Could not determine the Scaleway organization ID. "
"Set SCW_DEFAULT_ORGANIZATION_ID or use a user-scoped "
"API key."
),
)
return ScalewayIdentityInfo(
organization_id=organization_id,
bearer_id=bearer_id,
bearer_type=bearer_type,
bearer_email=bearer_email,
account_root_user_id=account_root_user_id,
)
except ScalewayIdentityError:
raise
except Exception as error:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
)
raise ScalewayIdentityError(
file=os.path.basename(__file__),
original_exception=error,
)
@staticmethod
def validate_credentials(session: ScalewaySession) -> None:
"""Smoke-test credentials by resolving the current API key.
Uses ``iam.get_api_key`` because it does not require any prior
knowledge of the bearer or the owning organization.
Args:
session: The Scaleway session to validate.
Raises:
ScalewayAuthenticationError: Authentication or authorization
failed against the Scaleway IAM API.
"""
try:
iam = IamV1Alpha1API(session.client)
iam.get_api_key(access_key=session.access_key)
except Exception as error:
raise ScalewayAuthenticationError(
file=os.path.basename(__file__),
original_exception=error,
)
def print_credentials(self) -> None:
report_title = (
f"{Style.BRIGHT}Using the Scaleway credentials below:{Style.RESET_ALL}"
)
report_lines = [
f"Authentication: {Fore.YELLOW}API Key{Style.RESET_ALL}",
f"Access Key: {Fore.YELLOW}{self._session.access_key}{Style.RESET_ALL}",
f"Organization ID: {Fore.YELLOW}{self._identity.organization_id}{Style.RESET_ALL}",
]
if self._identity.bearer_type:
report_lines.append(
f"Bearer: {Fore.YELLOW}{self._identity.bearer_type}"
f" ({self._identity.bearer_email or self._identity.bearer_id})"
f"{Style.RESET_ALL}"
)
if self._session.default_region:
report_lines.append(
f"Default Region: {Fore.YELLOW}{self._session.default_region}{Style.RESET_ALL}"
)
print_boxes(report_lines, report_title)
@staticmethod
def test_connection(
access_key: str = None,
secret_key: str = None,
organization_id: str = None,
raise_on_exception: bool = True,
provider_id: str = None,
) -> Connection:
"""Test connection to Scaleway.
Args:
access_key: Scaleway access key (falls back to SCW_ACCESS_KEY).
secret_key: Scaleway secret key (falls back to SCW_SECRET_KEY).
organization_id: Organization ID to scope the audit.
raise_on_exception: Whether to raise or return errors.
provider_id: Expected Scaleway organization ID. When provided,
the resolved identity must match it; otherwise the test
fails with ``ScalewayAuthenticationError``.
Returns:
Connection: Connection object with is_connected status.
"""
try:
session = ScalewayProvider.setup_session(
access_key=access_key,
secret_key=secret_key,
organization_id=organization_id,
)
ScalewayProvider.validate_credentials(session)
# Guard for API callers that already know the expected
# organization: the credentials must point to that exact org.
if provider_id:
identity = ScalewayProvider.setup_identity(session)
if identity.organization_id != provider_id:
raise ScalewayAuthenticationError(
file=os.path.basename(__file__),
message=(
"The provided credentials do not have access to "
f"the Scaleway organization with ID: {provider_id}"
),
)
return Connection(is_connected=True)
except (
ScalewayCredentialsError,
ScalewaySessionError,
ScalewayAuthenticationError,
ScalewayIdentityError,
) as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
if raise_on_exception:
raise error
return Connection(is_connected=False, error=error)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
formatted_error = ScalewayAuthenticationError(
file=os.path.basename(__file__),
original_exception=error,
)
if raise_on_exception:
raise formatted_error
return Connection(is_connected=False, error=formatted_error)
def validate_arguments(self) -> None:
return None
@@ -0,0 +1,37 @@
{
"Provider": "scaleway",
"CheckID": "iam_api_keys_no_root_owned",
"CheckTitle": "Scaleway IAM API keys must not be owned by the account root user",
"CheckType": [],
"ServiceName": "iam",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "**Scaleway API keys** are checked to ensure none is bound to the **account root user**. The account root user is the original Scaleway account owner; its credentials bypass IAM policies and grant unrestricted access to the entire organization.",
"Risk": "API keys owned by the **account root user** cannot be scoped down with IAM policies. Leaking one of these keys yields immediate full control over every project, resource and billing setting in the organization, and rotating them disrupts every automation depending on root credentials.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://www.scaleway.com/en/docs/identity-and-access-management/iam/concepts/#root-account",
"https://www.scaleway.com/en/docs/identity-and-access-management/iam/how-to/create-api-keys/"
],
"Remediation": {
"Code": {
"CLI": "scw iam api-key delete <ACCESS_KEY>",
"NativeIaC": "",
"Other": "1. Sign in to the Scaleway console as a user with IAM admin permissions.\n2. Create a dedicated IAM user or application scoped with the minimum required policy.\n3. Generate a new API key for that bearer and roll it out to the workloads currently using the root key.\n4. Delete the API key owned by the account root user from the IAM > API keys page.",
"Terraform": ""
},
"Recommendation": {
"Text": "Never use API keys owned by the **account root user** for automation. Create scoped **IAM users** or **applications**, attach **least-privilege policies**, and rotate any existing root API keys to that new bearer.",
"Url": "https://hub.prowler.com/check/iam_api_keys_no_root_owned"
}
},
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,94 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportScaleway
from prowler.providers.scaleway.services.iam.iam_client import iam_client
from prowler.providers.scaleway.services.iam.iam_service import (
ScalewayIAMDataUnavailable,
)
class iam_api_keys_no_root_owned(Check):
"""Ensure no Scaleway IAM API key is owned by the account root user.
The account root user is the original Scaleway account owner. API keys
bound to that bearer bypass IAM policies and grant unrestricted access
to the entire organization; rotating or losing them is a critical
incident. Day-to-day automation should rely on IAM users or
applications scoped through policies instead.
"""
def execute(self) -> List[CheckReportScaleway]:
"""Iterate over the API keys cached by the IAM service.
The check degrades to ``MANUAL`` when the IAM service could not
load the prerequisite data (users or API keys) emitting ``PASS``
in those cases would silently mask the very condition the check
exists to detect.
Returns:
One ``CheckReportScaleway`` per discovered API key. ``FAIL``
when the bearer is the account root user, ``PASS`` otherwise.
A single ``MANUAL`` report is emitted when underlying IAM data
is unavailable.
"""
findings: List[CheckReportScaleway] = []
# If we could not even load the users we cannot tell who the root
# bearer is, so every API key would falsely PASS. Surface MANUAL
# explicitly so the operator investigates.
if not iam_client.users_loaded or not iam_client.api_keys_loaded:
placeholder = ScalewayIAMDataUnavailable(
organization_id=iam_client.organization_id
)
report = CheckReportScaleway(metadata=self.metadata(), resource=placeholder)
report.status = "MANUAL"
report.status_extended = (
"Could not retrieve Scaleway IAM users or API keys for "
f"organization {iam_client.organization_id}. Verify the "
"API key has the IAMReadOnly policy and rerun."
)
findings.append(report)
return findings
root_user_id = iam_client.account_root_user_id
# The account root user could not be resolved (typically an
# application-scoped API key with no IAM users visible). Without it
# every key would fall through to PASS, masking root-owned keys, so
# surface MANUAL instead of a silent clean result.
if not root_user_id:
placeholder = ScalewayIAMDataUnavailable(
organization_id=iam_client.organization_id
)
report = CheckReportScaleway(metadata=self.metadata(), resource=placeholder)
report.status = "MANUAL"
report.status_extended = (
"Could not determine the Scaleway account root user for "
f"organization {iam_client.organization_id}. This typically "
"happens with application-scoped API keys when no IAM users "
"are visible. Verify the API key has the IAMReadOnly policy "
"and rerun."
)
findings.append(report)
return findings
for api_key in iam_client.api_keys:
report = CheckReportScaleway(metadata=self.metadata(), resource=api_key)
if api_key.user_id == root_user_id:
report.status = "FAIL"
report.status_extended = (
f"Scaleway API key {api_key.access_key} is owned by the "
f"account root user ({root_user_id}). Replace it with an "
f"API key bound to a dedicated IAM user or application."
)
else:
report.status = "PASS"
report.status_extended = (
f"Scaleway API key {api_key.access_key} is not owned by "
f"the account root user."
)
findings.append(report)
return findings
@@ -0,0 +1,4 @@
from prowler.providers.common.provider import Provider
from prowler.providers.scaleway.services.iam.iam_service import IAM
iam_client = IAM(Provider.get_global_provider())
@@ -0,0 +1,166 @@
from typing import Optional
from pydantic.v1 import BaseModel
from scaleway.iam.v1alpha1 import IamV1Alpha1API
from prowler.lib.logger import logger
from prowler.providers.scaleway.lib.service.service import ScalewayService
class IAM(ScalewayService):
"""Scaleway IAM service.
Loads the users in scope plus every API key tied to the current
organization. Checks consume the materialized lists; nothing in this
class is lazy. Each load operation tracks success/failure separately
so checks can degrade to ``MANUAL`` when data is incomplete instead of
falsely passing.
"""
def __init__(self, provider):
super().__init__("iam", provider)
self._api = IamV1Alpha1API(self.client)
# Cached state — populated eagerly during construction
self.users: list[ScalewayUser] = []
self.api_keys: list[ScalewayAPIKey] = []
# Load status flags — checks consult these to surface MANUAL when
# the underlying API call failed rather than reporting empty lists
# as a clean PASS.
self.users_loaded: bool = False
self.api_keys_loaded: bool = False
self._load_users()
self._load_api_keys()
# Prefer the root user id resolved at authentication time from the
# audit identity. Application-scoped API keys do not expose it on
# the identity, so fall back to the loaded user list (every user
# record carries the org's account_root_user_id). When neither is
# available the root-key check degrades to MANUAL instead of
# silently PASSing root-owned keys.
self.account_root_user_id: Optional[str] = (
provider.identity.account_root_user_id
or next(
(u.account_root_user_id for u in self.users if u.account_root_user_id),
None,
)
)
def _load_users(self) -> None:
"""List every IAM user in the audited organization."""
try:
users = self._api.list_users_all(organization_id=self.organization_id)
for user in users:
self.users.append(
ScalewayUser(
id=user.id,
email=user.email,
username=user.username,
organization_id=user.organization_id,
account_root_user_id=user.account_root_user_id,
mfa=bool(getattr(user, "mfa", False)),
type_=(
str(user.type_) if getattr(user, "type_", None) else None
),
status=(
str(user.status) if getattr(user, "status", None) else None
),
)
)
self.users_loaded = True
except Exception as error:
logger.error(
f"{self.service} - Error listing users: "
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _load_api_keys(self) -> None:
"""List every API key in the audited organization."""
try:
api_keys = self._api.list_api_keys_all(organization_id=self.organization_id)
for key in api_keys:
self.api_keys.append(
ScalewayAPIKey(
access_key=key.access_key,
description=key.description,
user_id=key.user_id,
application_id=key.application_id,
default_project_id=key.default_project_id,
editable=bool(key.editable),
managed=bool(getattr(key, "managed", False)),
creation_ip=key.creation_ip,
created_at=str(key.created_at) if key.created_at else None,
updated_at=str(key.updated_at) if key.updated_at else None,
expires_at=str(key.expires_at) if key.expires_at else None,
)
)
self.api_keys_loaded = True
except Exception as error:
logger.error(
f"{self.service} - Error listing API keys: "
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
class ScalewayUser(BaseModel):
"""Subset of a Scaleway IAM user surface that the checks need."""
id: str
email: Optional[str] = None
username: Optional[str] = None
organization_id: Optional[str] = None
account_root_user_id: Optional[str] = None
mfa: bool = False
type_: Optional[str] = None
status: Optional[str] = None
# Provide name/id for CheckReportScaleway
name: str = ""
def __init__(self, **data):
super().__init__(**data)
self.name = self.email or self.username or self.id
class ScalewayAPIKey(BaseModel):
"""Subset of a Scaleway IAM API key surface that the checks need."""
access_key: str
description: Optional[str] = None
user_id: Optional[str] = None
application_id: Optional[str] = None
default_project_id: Optional[str] = None
editable: bool = False
managed: bool = False
creation_ip: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
expires_at: Optional[str] = None
# Provide name/id for CheckReportScaleway
name: str = ""
id: str = ""
def __init__(self, **data):
super().__init__(**data)
self.id = self.access_key
self.name = self.description or self.access_key
class ScalewayIAMDataUnavailable(BaseModel):
"""Stand-in resource used when the IAM service failed to load.
Lets checks materialize a ``MANUAL`` finding (instead of a silent
``PASS``) when users or API keys could not be retrieved.
``CheckReportScaleway`` reads ``name``/``id``/``organization_id``/
``region`` off the resource, so exposing those is enough.
"""
organization_id: str
name: str = "iam-data-unavailable"
id: str = "iam-data-unavailable"
region: str = "global"
+2 -1
View File
@@ -111,7 +111,8 @@ dependencies = [
"alibabacloud_actiontrail20200706==2.4.1",
"alibabacloud_cs20151215==6.1.0",
"alibabacloud-rds20140815==12.0.0",
"alibabacloud-sls20201230==5.9.0"
"alibabacloud-sls20201230==5.9.0",
"scaleway==2.10.3"
]
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"
+1 -1
View File
@@ -17,7 +17,7 @@ prowler_command = "prowler"
# capsys
# https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html
prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image,llm} ..."
prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,vercel,dashboard,iac,image,llm} ..."
def mock_get_available_providers():
@@ -0,0 +1,96 @@
from unittest.mock import MagicMock
from prowler.providers.scaleway.models import (
ScalewayIdentityInfo,
ScalewaySession,
)
from prowler.providers.scaleway.services.iam.iam_service import (
ScalewayAPIKey,
ScalewayUser,
)
# Scaleway Identity
ORGANIZATION_ID = "b4ce0bfc-38fc-4c53-8757-548be64add26"
ROOT_USER_ID = "00000000-0000-0000-0000-000000000001"
MEMBER_USER_ID = "00000000-0000-0000-0000-000000000002"
APPLICATION_ID = "00000000-0000-0000-0000-000000000003"
BEARER_EMAIL = "pedro@prowler.com"
# Scaleway Credentials
ACCESS_KEY = "SCWAE000000000000000"
SECRET_KEY = "00000000-0000-0000-0000-000000000000"
# API Key Constants
ROOT_API_KEY = "SCWROOT00000000000000"
USER_API_KEY = "SCWUSER00000000000000"
APP_API_KEY = "SCWAPP000000000000000"
def set_mocked_scaleway_provider(
access_key: str = ACCESS_KEY,
secret_key: str = SECRET_KEY,
identity: ScalewayIdentityInfo = None,
audit_config: dict = None,
):
"""Create a mocked ScalewayProvider for testing."""
provider = MagicMock()
provider.type = "scaleway"
provider.session = ScalewaySession(
access_key=access_key,
secret_key=secret_key,
organization_id=ORGANIZATION_ID,
default_project_id=None,
default_region="fr-par",
client=MagicMock(),
)
provider.identity = identity or ScalewayIdentityInfo(
organization_id=ORGANIZATION_ID,
bearer_id=ROOT_USER_ID,
bearer_type="user",
bearer_email=BEARER_EMAIL,
account_root_user_id=ROOT_USER_ID,
)
provider.audit_config = audit_config or {}
provider.fixer_config = {}
return provider
def make_user(
user_id: str = ROOT_USER_ID,
email: str = BEARER_EMAIL,
account_root_user_id: str = ROOT_USER_ID,
mfa: bool = True,
) -> ScalewayUser:
return ScalewayUser(
id=user_id,
email=email,
username=email.split("@")[0] if email else None,
organization_id=ORGANIZATION_ID,
account_root_user_id=account_root_user_id,
mfa=mfa,
type_="owner" if user_id == account_root_user_id else "member",
status="activated",
)
def make_api_key(
access_key: str = USER_API_KEY,
user_id: str = MEMBER_USER_ID,
application_id: str = None,
description: str = "test key",
expires_at: str = None,
) -> ScalewayAPIKey:
return ScalewayAPIKey(
access_key=access_key,
description=description,
user_id=user_id,
application_id=application_id,
default_project_id=None,
editable=True,
managed=False,
creation_ip=None,
created_at="2026-01-01T00:00:00Z",
updated_at="2026-01-01T00:00:00Z",
expires_at=expires_at,
)
@@ -0,0 +1,106 @@
import os
from unittest import mock
import pytest
from prowler.providers.scaleway.exceptions.exceptions import (
ScalewayAuthenticationError,
ScalewayCredentialsError,
ScalewayIdentityError,
)
from prowler.providers.scaleway.models import ScalewaySession
from prowler.providers.scaleway.scaleway_provider import ScalewayProvider
from tests.providers.scaleway.scaleway_fixtures import (
ACCESS_KEY,
BEARER_EMAIL,
ORGANIZATION_ID,
ROOT_USER_ID,
SECRET_KEY,
)
class Test_ScalewayProvider_setup_session:
def test_missing_access_key_raises_credentials_error(self):
with mock.patch.dict(
os.environ, {"SCW_ACCESS_KEY": "", "SCW_SECRET_KEY": ""}, clear=False
):
os.environ.pop("SCW_ACCESS_KEY", None)
os.environ.pop("SCW_SECRET_KEY", None)
with pytest.raises(ScalewayCredentialsError):
ScalewayProvider.setup_session()
def test_returns_session_with_credentials(self):
session = ScalewayProvider.setup_session(
access_key=ACCESS_KEY,
secret_key=SECRET_KEY,
organization_id=ORGANIZATION_ID,
)
assert isinstance(session, ScalewaySession)
assert session.access_key == ACCESS_KEY
assert session.organization_id == ORGANIZATION_ID
assert session.default_region == "fr-par"
class Test_ScalewayProvider_setup_identity:
def _build_session(self):
return ScalewaySession(
access_key=ACCESS_KEY,
secret_key=SECRET_KEY,
organization_id=ORGANIZATION_ID,
default_region="fr-par",
client=mock.MagicMock(),
)
def test_resolves_user_bearer_identity(self):
session = self._build_session()
api_key = mock.MagicMock(user_id=ROOT_USER_ID, application_id=None)
user = mock.MagicMock(
email=BEARER_EMAIL,
organization_id=ORGANIZATION_ID,
account_root_user_id=ROOT_USER_ID,
)
with mock.patch(
"prowler.providers.scaleway.scaleway_provider.IamV1Alpha1API"
) as iam_cls:
iam = iam_cls.return_value
iam.get_api_key.return_value = api_key
iam.get_user.return_value = user
identity = ScalewayProvider.setup_identity(session)
assert identity.organization_id == ORGANIZATION_ID
assert identity.bearer_type == "user"
assert identity.bearer_id == ROOT_USER_ID
assert identity.bearer_email == BEARER_EMAIL
assert identity.account_root_user_id == ROOT_USER_ID
def test_missing_organization_raises_identity_error(self):
session = self._build_session()
session.organization_id = None
api_key = mock.MagicMock(user_id=None, application_id="app-id")
with mock.patch(
"prowler.providers.scaleway.scaleway_provider.IamV1Alpha1API"
) as iam_cls:
iam = iam_cls.return_value
iam.get_api_key.return_value = api_key
with pytest.raises(ScalewayIdentityError):
ScalewayProvider.setup_identity(session)
class Test_ScalewayProvider_validate_credentials:
def test_invalid_credentials_raise_authentication_error(self):
session = ScalewaySession(
access_key=ACCESS_KEY,
secret_key=SECRET_KEY,
organization_id=ORGANIZATION_ID,
client=mock.MagicMock(),
)
with mock.patch(
"prowler.providers.scaleway.scaleway_provider.IamV1Alpha1API"
) as iam_cls:
iam_cls.return_value.get_api_key.side_effect = Exception("expired")
with pytest.raises(ScalewayAuthenticationError):
ScalewayProvider.validate_credentials(session)
@@ -0,0 +1,204 @@
from unittest import mock
from tests.providers.scaleway.scaleway_fixtures import (
APP_API_KEY,
APPLICATION_ID,
MEMBER_USER_ID,
ORGANIZATION_ID,
ROOT_API_KEY,
ROOT_USER_ID,
USER_API_KEY,
make_api_key,
set_mocked_scaleway_provider,
)
def _patch_clients(iam_client_mock):
"""Patch both the provider and the iam_client singleton."""
return [
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_scaleway_provider(),
),
mock.patch(
"prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client",
new=iam_client_mock,
),
]
class Test_iam_api_keys_no_root_owned:
def test_no_api_keys_returns_empty_findings(self):
iam_client = mock.MagicMock()
iam_client.users_loaded = True
iam_client.api_keys_loaded = True
iam_client.account_root_user_id = ROOT_USER_ID
iam_client.api_keys = []
iam_client.organization_id = ORGANIZATION_ID
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_scaleway_provider(),
),
mock.patch(
"prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client",
new=iam_client,
),
):
from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import (
iam_api_keys_no_root_owned,
)
result = iam_api_keys_no_root_owned().execute()
assert result == []
def test_root_api_key_fails(self):
iam_client = mock.MagicMock()
iam_client.users_loaded = True
iam_client.api_keys_loaded = True
iam_client.account_root_user_id = ROOT_USER_ID
iam_client.api_keys = [
make_api_key(access_key=ROOT_API_KEY, user_id=ROOT_USER_ID)
]
iam_client.organization_id = ORGANIZATION_ID
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_scaleway_provider(),
),
mock.patch(
"prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client",
new=iam_client,
),
):
from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import (
iam_api_keys_no_root_owned,
)
result = iam_api_keys_no_root_owned().execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].resource_id == ROOT_API_KEY
assert ROOT_USER_ID in result[0].status_extended
def test_user_api_key_passes(self):
iam_client = mock.MagicMock()
iam_client.users_loaded = True
iam_client.api_keys_loaded = True
iam_client.account_root_user_id = ROOT_USER_ID
iam_client.api_keys = [
make_api_key(access_key=USER_API_KEY, user_id=MEMBER_USER_ID)
]
iam_client.organization_id = ORGANIZATION_ID
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_scaleway_provider(),
),
mock.patch(
"prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client",
new=iam_client,
),
):
from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import (
iam_api_keys_no_root_owned,
)
result = iam_api_keys_no_root_owned().execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_id == USER_API_KEY
def test_application_api_key_passes(self):
iam_client = mock.MagicMock()
iam_client.users_loaded = True
iam_client.api_keys_loaded = True
iam_client.account_root_user_id = ROOT_USER_ID
iam_client.api_keys = [
make_api_key(
access_key=APP_API_KEY, user_id=None, application_id=APPLICATION_ID
)
]
iam_client.organization_id = ORGANIZATION_ID
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_scaleway_provider(),
),
mock.patch(
"prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client",
new=iam_client,
),
):
from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import (
iam_api_keys_no_root_owned,
)
result = iam_api_keys_no_root_owned().execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_users_load_failure_returns_manual(self):
iam_client = mock.MagicMock()
iam_client.users_loaded = False
iam_client.api_keys_loaded = True
iam_client.account_root_user_id = None
iam_client.api_keys = [
make_api_key(access_key=ROOT_API_KEY, user_id=ROOT_USER_ID)
]
iam_client.organization_id = ORGANIZATION_ID
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_scaleway_provider(),
),
mock.patch(
"prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client",
new=iam_client,
),
):
from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import (
iam_api_keys_no_root_owned,
)
result = iam_api_keys_no_root_owned().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "Could not retrieve" in result[0].status_extended
def test_root_user_unresolved_returns_manual(self):
# Data loaded fine but account_root_user_id could not be resolved
# (e.g. application-scoped key). A root-owned key must NOT slip
# through as PASS — the check degrades to MANUAL instead.
iam_client = mock.MagicMock()
iam_client.users_loaded = True
iam_client.api_keys_loaded = True
iam_client.account_root_user_id = None
iam_client.api_keys = [
make_api_key(access_key=ROOT_API_KEY, user_id=ROOT_USER_ID)
]
iam_client.organization_id = ORGANIZATION_ID
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_scaleway_provider(),
),
mock.patch(
"prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned.iam_client",
new=iam_client,
),
):
from prowler.providers.scaleway.services.iam.iam_api_keys_no_root_owned.iam_api_keys_no_root_owned import (
iam_api_keys_no_root_owned,
)
result = iam_api_keys_no_root_owned().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "account root user" in result[0].status_extended
@@ -0,0 +1,138 @@
from unittest import mock
from prowler.providers.scaleway.models import ScalewayIdentityInfo
from prowler.providers.scaleway.services.iam.iam_service import IAM
from tests.providers.scaleway.scaleway_fixtures import (
APPLICATION_ID,
MEMBER_USER_ID,
ORGANIZATION_ID,
ROOT_USER_ID,
USER_API_KEY,
set_mocked_scaleway_provider,
)
def _application_identity() -> ScalewayIdentityInfo:
"""Identity produced by an application-scoped API key: the IAM API
never exposes account_root_user_id for an application bearer."""
return ScalewayIdentityInfo(
organization_id=ORGANIZATION_ID,
bearer_id=APPLICATION_ID,
bearer_type="application",
bearer_email=None,
account_root_user_id=None,
)
def _mock_user(
user_id: str, account_root_user_id: str = ROOT_USER_ID, email: str = "u@example.com"
):
user = mock.MagicMock()
user.id = user_id
user.email = email
user.username = email.split("@")[0]
user.organization_id = ORGANIZATION_ID
user.account_root_user_id = account_root_user_id
user.mfa = True
user.type_ = "owner" if user_id == account_root_user_id else "member"
user.status = "activated"
return user
def _mock_api_key(access_key: str, user_id: str = None, application_id: str = None):
key = mock.MagicMock()
key.access_key = access_key
key.description = "test"
key.user_id = user_id
key.application_id = application_id
key.default_project_id = None
key.editable = True
key.managed = False
key.creation_ip = None
key.created_at = None
key.updated_at = None
key.expires_at = None
return key
class Test_IAM_service:
def test_loads_users_and_api_keys(self):
provider = set_mocked_scaleway_provider()
with mock.patch(
"prowler.providers.scaleway.services.iam.iam_service.IamV1Alpha1API"
) as iam_cls:
api = iam_cls.return_value
api.list_users_all.return_value = [
_mock_user(ROOT_USER_ID),
_mock_user(MEMBER_USER_ID, email="m@example.com"),
]
api.list_api_keys_all.return_value = [
_mock_api_key(USER_API_KEY, user_id=MEMBER_USER_ID),
_mock_api_key("SCWAPP", application_id=APPLICATION_ID),
]
iam = IAM(provider)
assert iam.users_loaded is True
assert iam.api_keys_loaded is True
assert iam.account_root_user_id == ROOT_USER_ID
assert len(iam.users) == 2
assert len(iam.api_keys) == 2
def test_marks_users_unloaded_on_error(self):
provider = set_mocked_scaleway_provider()
with mock.patch(
"prowler.providers.scaleway.services.iam.iam_service.IamV1Alpha1API"
) as iam_cls:
api = iam_cls.return_value
api.list_users_all.side_effect = Exception("denied")
api.list_api_keys_all.return_value = []
iam = IAM(provider)
assert iam.users_loaded is False
assert iam.api_keys_loaded is True
# account_root_user_id comes from the audit identity, not the user
# list, so a failed user listing must not blind the root-key check.
assert iam.account_root_user_id == ROOT_USER_ID
def test_application_key_resolves_root_user_from_user_list(self):
# Application-scoped API key: identity.account_root_user_id is None,
# so it must be recovered from the loaded user list. Otherwise the
# root-key check would silently PASS root-owned keys.
provider = set_mocked_scaleway_provider(identity=_application_identity())
with mock.patch(
"prowler.providers.scaleway.services.iam.iam_service.IamV1Alpha1API"
) as iam_cls:
api = iam_cls.return_value
api.list_users_all.return_value = [
_mock_user(ROOT_USER_ID),
_mock_user(MEMBER_USER_ID, email="m@example.com"),
]
api.list_api_keys_all.return_value = []
iam = IAM(provider)
assert iam.account_root_user_id == ROOT_USER_ID
def test_account_root_user_id_none_when_unresolvable(self):
# Application key + no user record exposes account_root_user_id:
# nothing to fall back to, so it stays None and the root-key check
# will degrade to MANUAL downstream.
provider = set_mocked_scaleway_provider(identity=_application_identity())
with mock.patch(
"prowler.providers.scaleway.services.iam.iam_service.IamV1Alpha1API"
) as iam_cls:
api = iam_cls.return_value
api.list_users_all.return_value = [
_mock_user(MEMBER_USER_ID, account_root_user_id=None)
]
api.list_api_keys_all.return_value = []
iam = IAM(provider)
assert iam.account_root_user_id is None
+1
View File
@@ -16,6 +16,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Lighthouse now accepts Prowler App Finding Groups MCP tools [(#11140)](https://github.com/prowler-cloud/prowler/pull/11140)
- Attack Paths graph now uses React Flow with improved layout, interactions, export, minimap, and browser test coverage [(#10686)](https://github.com/prowler-cloud/prowler/pull/10686)
- SAML ACS URL is only shown if the email domain is configured [(#11144)](https://github.com/prowler-cloud/prowler/pull/11144)
- "View Resource" action in the finding resource detail drawer is now an icon-only link rendered next to the resource name (instead of a text button in the UID row), keeping the "View in AWS Console" link unchanged [(#11193)](https://github.com/prowler-cloud/prowler/pull/11193)
### 🐞 Fixed
@@ -46,10 +46,12 @@ vi.mock("next/link", () => ({
default: ({
children,
href,
prefetch: _prefetch,
...props
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
children: ReactNode;
href: string;
prefetch?: boolean;
}) => (
<a href={href} {...props}>
{children}
@@ -288,8 +290,21 @@ vi.mock("@/components/ui/entities/date-with-time", () => ({
}));
vi.mock("@/components/ui/entities/entity-info", () => ({
EntityInfo: ({ idAction }: { idAction?: ReactNode }) =>
idAction ? <span data-testid="entity-id-action">{idAction}</span> : null,
EntityInfo: ({
nameAction,
idAction,
}: {
nameAction?: ReactNode;
idAction?: ReactNode;
}) =>
nameAction || idAction ? (
<span>
{nameAction && (
<span data-testid="entity-name-action">{nameAction}</span>
)}
{idAction && <span data-testid="entity-id-action">{idAction}</span>}
</span>
) : null,
}));
vi.mock("@/components/ui/table", () => ({
@@ -428,7 +443,7 @@ const mockFinding: ResourceDrawerFinding = {
};
describe("ResourceDetailDrawerContent — resource navigation", () => {
it("should render a View Resource link inline next to the resource UID", () => {
it("should render an icon-only View Resource link next to the resource name", () => {
// Given
render(
<ResourceDetailDrawerContent
@@ -457,6 +472,12 @@ describe("ResourceDetailDrawerContent — resource navigation", () => {
);
expect(viewResourceLink).toHaveAttribute("target", "_blank");
expect(viewResourceLink).toHaveAttribute("rel", "noopener noreferrer");
// Icon-only: accessible name comes from an sr-only span, not from an
// aria-label attribute, so the text lives in the DOM (more semantic).
expect(viewResourceLink).toHaveAccessibleName("View Resource");
expect(viewResourceLink).not.toHaveAttribute("aria-label");
const srOnlyLabel = viewResourceLink.querySelector(".sr-only");
expect(srOnlyLabel).toHaveTextContent("View Resource");
});
});
const mockResourceRow: FindingResourceRow = {
@@ -441,8 +441,7 @@ export function ResourceDetailDrawerContent({
findingUid: f?.uid,
region: resourceRegion,
});
const hasIdAction =
Boolean(resourceDetailHref) || Boolean(externalResourceTarget);
const hasIdAction = Boolean(externalResourceTarget);
const findingRecommendationUrl = f?.remediation.recommendation.url;
const checkRecommendationUrl = checkMeta.remediation.recommendation.url;
const recommendationUrl = isNonEmptyString(findingRecommendationUrl)
@@ -712,32 +711,41 @@ export function ResourceDetailDrawerContent({
entityAlias={resourceName}
entityId={resourceUid}
idLabel="UID"
idAction={
hasIdAction ? (
<span className="inline-flex items-center gap-2">
{resourceDetailHref && (
nameAction={
resourceDetailHref ? (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="link" size="link-sm" asChild>
<Link
href={resourceDetailHref}
target="_blank"
rel="noopener noreferrer"
prefetch={false}
>
View Resource
<ExternalLink className="size-3" />
<span className="sr-only">View Resource</span>
<ExternalLink
className="size-3"
aria-hidden="true"
/>
</Link>
</Button>
)}
{externalResourceTarget && (
<ExternalResourceLink
providerType={providerType}
resourceUid={resourceUid}
providerUid={providerUid}
resourceName={resourceName}
findingUid={f?.uid}
region={resourceRegion}
/>
)}
</span>
</TooltipTrigger>
<TooltipContent side="top">
View Resource
</TooltipContent>
</Tooltip>
) : undefined
}
idAction={
hasIdAction ? (
<ExternalResourceLink
providerType={providerType}
resourceUid={resourceUid}
providerUid={providerUid}
resourceName={resourceName}
findingUid={f?.uid}
region={resourceRegion}
/>
) : undefined
}
/>
@@ -17,6 +17,8 @@ interface EntityInfoProps {
icon?: ReactNode;
/** Small icon rendered inline before the entity alias text */
nameIcon?: ReactNode;
/** Inline element rendered after the entity alias (e.g. action link). */
nameAction?: ReactNode;
entityAlias?: string;
entityId?: string;
badge?: string;
@@ -37,6 +39,7 @@ export const EntityInfo = ({
cloudProvider,
icon,
nameIcon,
nameAction,
entityAlias,
entityId,
badge,
@@ -74,6 +77,7 @@ export const EntityInfo = ({
({badge})
</span>
)}
{nameAction && <span className="shrink-0">{nameAction}</span>}
</div>
{entityId && (
<div className="flex min-w-0 items-center gap-2">
Generated
+28
View File
@@ -3315,6 +3315,7 @@ dependencies = [
{ name = "pygithub" },
{ name = "python-dateutil" },
{ name = "pytz" },
{ name = "scaleway" },
{ name = "schema" },
{ name = "shodan" },
{ name = "slack-sdk" },
@@ -3419,6 +3420,7 @@ requires-dist = [
{ name = "pygithub", specifier = "==2.8.0" },
{ name = "python-dateutil", specifier = "==2.9.0.post0" },
{ name = "pytz", specifier = "==2025.1" },
{ name = "scaleway", specifier = "==2.10.3" },
{ name = "schema", specifier = "==0.7.5" },
{ name = "shodan", specifier = "==1.31.0" },
{ name = "slack-sdk", specifier = "==3.39.0" },
@@ -4167,6 +4169,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" },
]
[[package]]
name = "scaleway"
version = "2.10.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "scaleway-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/ce/774eea10c35fb0f028d0ae2e45d404bc16d83ac5cdd12f410d348e6dd6a4/scaleway-2.10.3.tar.gz", hash = "sha256:b1f9dd1b1450767205234c6f5a345e5e25dc039c780253d698893b5c344ce594", size = 717421, upload-time = "2025-11-04T10:03:13.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/d6/2b41dfb4cac8bf51cc100050f70129aa5284d04344ceec1391176467b1cb/scaleway-2.10.3-py3-none-any.whl", hash = "sha256:dbf381440d6caf37c878cf16445a63f4969a4aac2257c9b72c744d10ff223a0c", size = 877950, upload-time = "2025-11-04T10:03:12.128Z" },
]
[[package]]
name = "scaleway-core"
version = "2.10.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
{ name = "pyyaml" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/6a/9f53cc92ad2332f96b48bd4975e53e15b8099a148a4e7386d64d393538d5/scaleway_core-2.10.3.tar.gz", hash = "sha256:56432f755d694669429de51d51c1d0b3361b28dc2f939b28e4cb954610ee76be", size = 27282, upload-time = "2025-11-04T10:01:41.021Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/d0/b6b133a27a94fc272d316f7bc8722be701130b93ee12c60c0c2413c16199/scaleway_core-2.10.3-py3-none-any.whl", hash = "sha256:fd4112144554d6adae22ff737555eeb0e38cb1063250b3e88c9aebc1b957793b", size = 33523, upload-time = "2025-11-04T10:01:40.106Z" },
]
[[package]]
name = "schema"
version = "0.7.5"