mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08c77bea75 | |||
| 16798e293d | |||
| 1194d34396 | |||
| 98277689f5 | |||
| 0ddd7fbd69 | |||
| 22b233f206 | |||
| aa759ab6b7 | |||
| f769b8b812 | |||
| 8213b46bd8 | |||
| 515fe1918d | |||
| 369d6cecc1 | |||
| 25c11eb6dd | |||
| 089f7e7d3c | |||
| a678a04850 | |||
| 8707b51b34 | |||
| 833882e67e | |||
| d23c2f3b53 |
@@ -5,10 +5,20 @@ on:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'api/**'
|
||||
- '.github/workflows/api-tests.yml'
|
||||
- '.github/workflows/api-code-quality.yml'
|
||||
- '.github/actions/setup-python-poetry/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'api/**'
|
||||
- '.github/workflows/api-tests.yml'
|
||||
- '.github/workflows/api-code-quality.yml'
|
||||
- '.github/actions/setup-python-poetry/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
tags: |
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
|
||||
|
||||
# Create and push multi-architecture manifest
|
||||
create-manifest:
|
||||
|
||||
@@ -5,10 +5,16 @@ on:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'api/**'
|
||||
- '.github/workflows/api-container-checks.yml'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'api/**'
|
||||
- '.github/workflows/api-container-checks.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -57,16 +63,7 @@ jobs:
|
||||
|
||||
api-container-build-and-scan:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -119,23 +116,22 @@ jobs:
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build container for ${{ matrix.arch }}
|
||||
- name: Build container
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: ${{ env.API_WORKING_DIR }}
|
||||
push: false
|
||||
load: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}-${{ matrix.arch }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }}
|
||||
|
||||
- name: Scan container with Trivy for ${{ matrix.arch }}
|
||||
- name: Scan container with Trivy
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/trivy-scan
|
||||
with:
|
||||
image-name: ${{ env.IMAGE_NAME }}
|
||||
image-tag: ${{ github.sha }}-${{ matrix.arch }}
|
||||
image-tag: ${{ github.sha }}
|
||||
fail-on-critical: 'false'
|
||||
severity: 'CRITICAL'
|
||||
|
||||
@@ -5,10 +5,20 @@ on:
|
||||
branches:
|
||||
- "master"
|
||||
- "v5.*"
|
||||
paths:
|
||||
- 'api/**'
|
||||
- '.github/workflows/api-tests.yml'
|
||||
- '.github/workflows/api-security.yml'
|
||||
- '.github/actions/setup-python-poetry/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- "master"
|
||||
- "v5.*"
|
||||
paths:
|
||||
- 'api/**'
|
||||
- '.github/workflows/api-tests.yml'
|
||||
- '.github/workflows/api-security.yml'
|
||||
- '.github/actions/setup-python-poetry/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -5,10 +5,18 @@ on:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'api/**'
|
||||
- '.github/workflows/api-tests.yml'
|
||||
- '.github/actions/setup-python-poetry/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'api/**'
|
||||
- '.github/workflows/api-tests.yml'
|
||||
- '.github/actions/setup-python-poetry/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -4,8 +4,6 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v3'
|
||||
- 'v4.*'
|
||||
- 'v5.*'
|
||||
types:
|
||||
- 'opened'
|
||||
|
||||
@@ -43,14 +43,11 @@ jobs:
|
||||
|
||||
echo "Processing release tag: $RELEASE_TAG"
|
||||
|
||||
# Remove 'v' prefix if present (e.g., v3.2.0 -> 3.2.0)
|
||||
VERSION_ONLY="${RELEASE_TAG#v}"
|
||||
|
||||
# Check if it's a minor version (X.Y.0)
|
||||
if [[ "$VERSION_ONLY" =~ ^([0-9]+)\.([0-9]+)\.0$ ]]; then
|
||||
echo "Release $RELEASE_TAG (version $VERSION_ONLY) is a minor version. Proceeding to create backport label."
|
||||
|
||||
# Extract X.Y from X.Y.0 (e.g., 5.6 from 5.6.0)
|
||||
MAJOR="${BASH_REMATCH[1]}"
|
||||
MINOR="${BASH_REMATCH[2]}"
|
||||
TWO_DIGIT_VERSION="${MAJOR}.${MINOR}"
|
||||
@@ -62,7 +59,6 @@ jobs:
|
||||
echo "Label name: $LABEL_NAME"
|
||||
echo "Label description: $LABEL_DESC"
|
||||
|
||||
# Check if label already exists
|
||||
if gh label list --repo ${{ github.repository }} --limit 1000 | grep -q "^${LABEL_NAME}[[:space:]]"; then
|
||||
echo "Label '$LABEL_NAME' already exists."
|
||||
else
|
||||
|
||||
@@ -37,10 +37,13 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# PRs only need the diff range; push to master/release walks the new range from event.before.
|
||||
# 50 is enough headroom for the longest realistic PR/push chain without paying for a full clone.
|
||||
fetch-depth: 50
|
||||
persist-credentials: false
|
||||
|
||||
- name: Scan for secrets with TruffleHog
|
||||
- name: Scan diff for secrets with TruffleHog
|
||||
# Action auto-injects --since-commit/--branch from event payload; passing them in extra_args produces duplicate flags.
|
||||
uses: trufflesecurity/trufflehog@ef6e76c3c4023279497fab4721ffa071a722fd05 # v3.92.4
|
||||
with:
|
||||
extra_args: '--results=verified,unknown'
|
||||
extra_args: --results=verified,unknown
|
||||
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
org.opencontainers.image.created=${{ github.event_name == 'release' && github.event.release.published_at || github.event.head_commit.timestamp }}
|
||||
${{ github.event_name == 'release' && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
|
||||
|
||||
# Create and push multi-architecture manifest
|
||||
create-manifest:
|
||||
|
||||
@@ -5,10 +5,16 @@ on:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'mcp_server/**'
|
||||
- '.github/workflows/mcp-container-checks.yml'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'mcp_server/**'
|
||||
- '.github/workflows/mcp-container-checks.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -56,16 +62,7 @@ jobs:
|
||||
|
||||
mcp-container-build-and-scan:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -112,23 +109,22 @@ jobs:
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build MCP container for ${{ matrix.arch }}
|
||||
- name: Build MCP container
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: ${{ env.MCP_WORKING_DIR }}
|
||||
push: false
|
||||
load: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}-${{ matrix.arch }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }}
|
||||
|
||||
- name: Scan MCP container with Trivy for ${{ matrix.arch }}
|
||||
- name: Scan MCP container with Trivy
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/trivy-scan
|
||||
with:
|
||||
image-name: ${{ env.IMAGE_NAME }}
|
||||
image-tag: ${{ github.sha }}-${{ matrix.arch }}
|
||||
image-tag: ${{ github.sha }}
|
||||
fail-on-critical: 'false'
|
||||
severity: 'CRITICAL'
|
||||
|
||||
@@ -86,11 +86,32 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
# 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.
|
||||
# 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
|
||||
id: pypi-check
|
||||
working-directory: ${{ env.WORKING_DIRECTORY }}
|
||||
run: |
|
||||
MCP_VERSION=$(grep '^version' pyproject.toml | head -1 | sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/')
|
||||
echo "mcp_version=${MCP_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
if curl -fsS "https://pypi.org/pypi/prowler-mcp/${MCP_VERSION}/json" >/dev/null 2>&1; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice title=Skipping prowler-mcp publish::Version ${MCP_VERSION} already exists on PyPI; bump mcp_server/pyproject.toml to publish a new release."
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice title=Publishing prowler-mcp::Version ${MCP_VERSION} not on PyPI yet; proceeding."
|
||||
fi
|
||||
|
||||
- name: Build prowler-mcp package
|
||||
if: steps.pypi-check.outputs.skip != 'true'
|
||||
working-directory: ${{ env.WORKING_DIRECTORY }}
|
||||
run: uv build
|
||||
|
||||
- name: Publish prowler-mcp package to PyPI
|
||||
if: steps.pypi-check.outputs.skip != 'true'
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
packages-dir: ${{ env.WORKING_DIRECTORY }}/dist/
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
name: 'Nightly: ARM64 Container Builds'
|
||||
|
||||
# Mitigation for amd64-only PR container-checks: build amd64+arm64 nightly against
|
||||
# master to keep arm-specific Dockerfile regressions caught quickly. Build only —
|
||||
# no push, no Trivy (weekly checks already cover that).
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 4 * * *'
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
build-arm64:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- component: sdk
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
image_name: prowler
|
||||
- component: api
|
||||
context: ./api
|
||||
dockerfile: ./api/Dockerfile
|
||||
image_name: prowler-api
|
||||
- component: ui
|
||||
context: ./ui
|
||||
dockerfile: ./ui/Dockerfile
|
||||
image_name: prowler-ui
|
||||
target: prod
|
||||
build_args: |
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51LwpXXXX
|
||||
- component: mcp
|
||||
context: ./mcp_server
|
||||
dockerfile: ./mcp_server/Dockerfile
|
||||
image_name: prowler-mcp
|
||||
|
||||
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: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build ${{ matrix.component }} container (linux/arm64)
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
target: ${{ matrix.target }}
|
||||
push: false
|
||||
load: false
|
||||
platforms: linux/arm64
|
||||
tags: ${{ matrix.image_name }}:nightly-arm64
|
||||
build-args: ${{ matrix.build_args }}
|
||||
cache-from: type=gha,scope=arm64
|
||||
cache-to: type=gha,mode=min,scope=arm64
|
||||
|
||||
notify-failure:
|
||||
needs: build-arm64
|
||||
if: failure() && github.event_name == 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Notify Slack on failure
|
||||
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
text: ":rotating_light: Nightly arm64 container build failed for prowler — <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|view run>"
|
||||
errors: true
|
||||
@@ -41,10 +41,15 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-depth: 1
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Fetch PR base ref for tj-actions/changed-files
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: git fetch --depth=1 origin "${BASE_REF}"
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||
|
||||
@@ -45,10 +45,15 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-depth: 1
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Fetch PR base ref for tj-actions/changed-files
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: git fetch --depth=1 origin "${BASE_REF}"
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||
|
||||
@@ -36,8 +36,14 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Fetch PR base ref for tj-actions/changed-files
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: git fetch --depth=1 origin "${BASE_REF}"
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
|
||||
@@ -5,6 +5,9 @@ on:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'tests/providers/**/*_test.py'
|
||||
- '.github/workflows/sdk-check-duplicate-test-names.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -5,10 +5,26 @@ on:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'prowler/**'
|
||||
- 'tests/**'
|
||||
- 'pyproject.toml'
|
||||
- 'poetry.lock'
|
||||
- '.github/workflows/sdk-tests.yml'
|
||||
- '.github/workflows/sdk-code-quality.yml'
|
||||
- '.github/actions/setup-python-poetry/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'prowler/**'
|
||||
- 'tests/**'
|
||||
- 'pyproject.toml'
|
||||
- 'poetry.lock'
|
||||
- '.github/workflows/sdk-tests.yml'
|
||||
- '.github/workflows/sdk-code-quality.yml'
|
||||
- '.github/actions/setup-python-poetry/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -3,9 +3,7 @@ name: 'SDK: Container Build and Push'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'v3' # For v3-latest
|
||||
- 'v4.6' # For v4-latest
|
||||
- 'master' # For latest
|
||||
- 'master'
|
||||
paths-ignore:
|
||||
- '.github/**'
|
||||
- '!.github/workflows/sdk-container-build-push.yml'
|
||||
@@ -56,7 +54,6 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
prowler_version: ${{ steps.get-prowler-version.outputs.prowler_version }}
|
||||
prowler_version_major: ${{ steps.get-prowler-version.outputs.prowler_version_major }}
|
||||
latest_tag: ${{ steps.get-prowler-version.outputs.latest_tag }}
|
||||
stable_tag: ${{ steps.get-prowler-version.outputs.stable_tag }}
|
||||
permissions:
|
||||
@@ -92,32 +89,13 @@ jobs:
|
||||
PROWLER_VERSION="$(poetry version -s 2>/dev/null)"
|
||||
echo "prowler_version=${PROWLER_VERSION}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Extract major version
|
||||
PROWLER_VERSION_MAJOR="${PROWLER_VERSION%%.*}"
|
||||
echo "prowler_version_major=${PROWLER_VERSION_MAJOR}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Set version-specific tags
|
||||
case ${PROWLER_VERSION_MAJOR} in
|
||||
3)
|
||||
echo "latest_tag=v3-latest" >> "${GITHUB_OUTPUT}"
|
||||
echo "stable_tag=v3-stable" >> "${GITHUB_OUTPUT}"
|
||||
echo "✓ Prowler v3 detected - tags: v3-latest, v3-stable"
|
||||
;;
|
||||
4)
|
||||
echo "latest_tag=v4-latest" >> "${GITHUB_OUTPUT}"
|
||||
echo "stable_tag=v4-stable" >> "${GITHUB_OUTPUT}"
|
||||
echo "✓ Prowler v4 detected - tags: v4-latest, v4-stable"
|
||||
;;
|
||||
5)
|
||||
echo "latest_tag=latest" >> "${GITHUB_OUTPUT}"
|
||||
echo "stable_tag=stable" >> "${GITHUB_OUTPUT}"
|
||||
echo "✓ Prowler v5 detected - tags: latest, stable"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unsupported Prowler major version: ${PROWLER_VERSION_MAJOR}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [[ "${PROWLER_VERSION_MAJOR}" != "5" ]]; then
|
||||
echo "::error::Unsupported Prowler major version: ${PROWLER_VERSION_MAJOR}"
|
||||
exit 1
|
||||
fi
|
||||
echo "latest_tag=latest" >> "${GITHUB_OUTPUT}"
|
||||
echo "stable_tag=stable" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
notify-release-started:
|
||||
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
@@ -228,7 +206,7 @@ jobs:
|
||||
tags: |
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-${{ matrix.arch }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
|
||||
|
||||
# Create and push multi-architecture manifest
|
||||
create-manifest:
|
||||
@@ -386,39 +364,3 @@ jobs:
|
||||
payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json"
|
||||
step-outcome: ${{ steps.outcome.outputs.outcome }}
|
||||
update-ts: ${{ needs.notify-release-started.outputs.message-ts }}
|
||||
|
||||
dispatch-v3-deployment:
|
||||
needs: [setup, container-build-push]
|
||||
if: always() && needs.setup.outputs.prowler_version_major == '3' && needs.setup.result == 'success' && needs.container-build-push.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Calculate short SHA
|
||||
id: short-sha
|
||||
run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Dispatch v3 deployment (latest)
|
||||
if: github.event_name == 'push'
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
with:
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
repository: ${{ secrets.DISPATCH_OWNER }}/${{ secrets.DISPATCH_REPO }}
|
||||
event-type: dispatch
|
||||
client-payload: '{"version":"v3-latest","tag":"${{ steps.short-sha.outputs.short_sha }}"}'
|
||||
|
||||
- name: Dispatch v3 deployment (release)
|
||||
if: github.event_name == 'release'
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
with:
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
repository: ${{ secrets.DISPATCH_OWNER }}/${{ secrets.DISPATCH_REPO }}
|
||||
event-type: dispatch
|
||||
client-payload: '{"version":"release","tag":"${{ needs.setup.outputs.prowler_version }}"}'
|
||||
|
||||
@@ -5,10 +5,22 @@ on:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'prowler/**'
|
||||
- 'Dockerfile*'
|
||||
- 'pyproject.toml'
|
||||
- 'poetry.lock'
|
||||
- '.github/workflows/sdk-container-checks.yml'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'prowler/**'
|
||||
- 'Dockerfile*'
|
||||
- 'pyproject.toml'
|
||||
- 'poetry.lock'
|
||||
- '.github/workflows/sdk-container-checks.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -56,16 +68,7 @@ jobs:
|
||||
|
||||
sdk-container-build-and-scan:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -132,23 +135,22 @@ jobs:
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build SDK container for ${{ matrix.arch }}
|
||||
- name: Build SDK container
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}-${{ matrix.arch }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }}
|
||||
|
||||
- name: Scan SDK container with Trivy for ${{ matrix.arch }}
|
||||
- name: Scan SDK container with Trivy
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/trivy-scan
|
||||
with:
|
||||
image-name: ${{ env.IMAGE_NAME }}
|
||||
image-tag: ${{ github.sha }}-${{ matrix.arch }}
|
||||
image-tag: ${{ github.sha }}
|
||||
fail-on-critical: 'false'
|
||||
severity: 'CRITICAL'
|
||||
|
||||
@@ -5,10 +5,26 @@ on:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'prowler/**'
|
||||
- 'tests/**'
|
||||
- 'pyproject.toml'
|
||||
- 'poetry.lock'
|
||||
- '.github/workflows/sdk-tests.yml'
|
||||
- '.github/workflows/sdk-security.yml'
|
||||
- '.github/actions/setup-python-poetry/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'prowler/**'
|
||||
- 'tests/**'
|
||||
- 'pyproject.toml'
|
||||
- 'poetry.lock'
|
||||
- '.github/workflows/sdk-tests.yml'
|
||||
- '.github/workflows/sdk-security.yml'
|
||||
- '.github/actions/setup-python-poetry/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -5,10 +5,24 @@ on:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'prowler/**'
|
||||
- 'tests/**'
|
||||
- 'pyproject.toml'
|
||||
- 'poetry.lock'
|
||||
- '.github/workflows/sdk-tests.yml'
|
||||
- '.github/actions/setup-python-poetry/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'prowler/**'
|
||||
- 'tests/**'
|
||||
- 'pyproject.toml'
|
||||
- 'poetry.lock'
|
||||
- '.github/workflows/sdk-tests.yml'
|
||||
- '.github/actions/setup-python-poetry/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
tags: |
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
|
||||
|
||||
# Create and push multi-architecture manifest
|
||||
create-manifest:
|
||||
|
||||
@@ -5,10 +5,16 @@ on:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'ui/**'
|
||||
- '.github/workflows/ui-container-checks.yml'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'ui/**'
|
||||
- '.github/workflows/ui-container-checks.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -57,16 +63,7 @@ jobs:
|
||||
|
||||
ui-container-build-and-scan:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -114,7 +111,7 @@ jobs:
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build UI container for ${{ matrix.arch }}
|
||||
- name: Build UI container
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
@@ -122,18 +119,17 @@ jobs:
|
||||
target: prod
|
||||
push: false
|
||||
load: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}-${{ matrix.arch }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51LwpXXXX
|
||||
|
||||
- name: Scan UI container with Trivy for ${{ matrix.arch }}
|
||||
- name: Scan UI container with Trivy
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/trivy-scan
|
||||
with:
|
||||
image-name: ${{ env.IMAGE_NAME }}
|
||||
image-tag: ${{ github.sha }}-${{ matrix.arch }}
|
||||
image-tag: ${{ github.sha }}
|
||||
fail-on-critical: 'false'
|
||||
severity: 'CRITICAL'
|
||||
|
||||
@@ -15,6 +15,10 @@ on:
|
||||
- 'ui/**'
|
||||
- 'api/**' # API changes can affect UI E2E
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
@@ -266,7 +270,7 @@ jobs:
|
||||
with:
|
||||
name: playwright-report
|
||||
path: ui/playwright-report/
|
||||
retention-days: 30
|
||||
retention-days: 7
|
||||
|
||||
- name: Cleanup services
|
||||
if: always()
|
||||
|
||||
@@ -5,10 +5,16 @@ on:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'ui/**'
|
||||
- '.github/workflows/ui-tests.yml'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'ui/**'
|
||||
- '.github/workflows/ui-tests.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -8,6 +8,7 @@ rules:
|
||||
- docs-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
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
|
||||
|
||||
ARG TRIVY_VERSION=0.69.2
|
||||
ARG TRIVY_VERSION=0.70.0
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
ARG ZIZMOR_VERSION=1.24.1
|
||||
|
||||
@@ -8,6 +8,10 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
- `scan-reset-ephemeral-resources` post-scan task zeroes `failed_findings_count` for resources missing from the latest full-scope scan, keeping ephemeral resources from polluting the Resources page sort [(#10929)](https://github.com/prowler-cloud/prowler/pull/10929)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- `trivy` binary from 0.69.2 to 0.70.0 and `cryptography` from 46.0.6 to 46.0.7 (transitive via prowler SDK) in the API image for CVE-2026-33186 and CVE-2026-39892 [(#10978)](https://github.com/prowler-cloud/prowler/pull/10978)
|
||||
|
||||
---
|
||||
|
||||
## [1.26.1] (Prowler v5.25.1)
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ LABEL maintainer="https://github.com/prowler-cloud/api"
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
|
||||
|
||||
ARG TRIVY_VERSION=0.69.2
|
||||
ARG TRIVY_VERSION=0.70.0
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
ARG ZIZMOR_VERSION=1.24.1
|
||||
|
||||
Generated
+64
-64
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "about-time"
|
||||
@@ -2504,61 +2504,61 @@ dev = ["bandit", "coverage", "flake8", "pydocstyle", "pylint", "pytest", "pytest
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.6"
|
||||
version = "46.0.7"
|
||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||
optional = false
|
||||
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e"},
|
||||
{file = "cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4"},
|
||||
{file = "cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2571,7 +2571,7 @@ nox = ["nox[uv] (>=2024.4.15)"]
|
||||
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
|
||||
sdist = ["build (>=1.0.0)"]
|
||||
ssh = ["bcrypt (>=3.1.5)"]
|
||||
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.6)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
|
||||
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.7)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
|
||||
test-randomorder = ["pytest-randomly"]
|
||||
|
||||
[[package]]
|
||||
@@ -6665,7 +6665,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.25.0"
|
||||
version = "5.26.0"
|
||||
description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks."
|
||||
optional = false
|
||||
python-versions = ">=3.10,<3.13"
|
||||
@@ -6720,7 +6720,7 @@ boto3 = "1.40.61"
|
||||
botocore = "1.40.61"
|
||||
cloudflare = "4.3.1"
|
||||
colorama = "0.4.6"
|
||||
cryptography = "46.0.6"
|
||||
cryptography = "46.0.7"
|
||||
dash = "3.1.1"
|
||||
dash-bootstrap-components = "2.0.3"
|
||||
defusedxml = "0.7.1"
|
||||
@@ -6754,8 +6754,8 @@ uuid6 = "2024.7.10"
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/prowler-cloud/prowler.git"
|
||||
reference = "master"
|
||||
resolved_reference = "ca29e354b622198ff6a70e2ea5eb04e4a44a0903"
|
||||
reference = "eb1b4190ab2d9c265b46c9ede0298b81bdcf35a8"
|
||||
resolved_reference = "eb1b4190ab2d9c265b46c9ede0298b81bdcf35a8"
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
@@ -7912,26 +7912,26 @@ shaping = ["uharfbuzz"]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
version = "2.33.1"
|
||||
description = "Python HTTP for Humans."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
|
||||
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
|
||||
{file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"},
|
||||
{file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=2017.4.17"
|
||||
certifi = ">=2023.5.7"
|
||||
charset_normalizer = ">=2,<4"
|
||||
idna = ">=2.5,<4"
|
||||
PySocks = {version = ">=1.5.6,<1.5.7 || >1.5.7", optional = true, markers = "extra == \"socks\""}
|
||||
urllib3 = ">=1.21.1,<3"
|
||||
urllib3 = ">=1.26,<3"
|
||||
|
||||
[package.extras]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
||||
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
||||
use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
|
||||
|
||||
[[package]]
|
||||
name = "requests-file"
|
||||
@@ -9424,4 +9424,4 @@ files = [
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11,<3.13"
|
||||
content-hash = "a3ab982d11a87d951ff15694d2ca7fd51f1f51a451abb0baa067ccf6966367a8"
|
||||
content-hash = "df8a20081fe91c40d071e508dbe19590c8b7ffb5dcc61e71cf30ed016bad5a34"
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ dependencies = [
|
||||
"defusedxml==0.7.1",
|
||||
"gunicorn==23.0.0",
|
||||
"lxml==5.3.2",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@eb1b4190ab2d9c265b46c9ede0298b81bdcf35a8",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
|
||||
@@ -202,8 +202,9 @@ def _get_attack_surface_mapping_from_provider(provider_type: str) -> dict:
|
||||
"iam_inline_policy_allows_privilege_escalation",
|
||||
},
|
||||
"ec2-imdsv1": {
|
||||
"ec2_instance_imdsv2_enabled"
|
||||
}, # AWS only - IMDSv1 enabled findings
|
||||
"ec2_instance_imdsv2_enabled",
|
||||
"ec2_instance_account_imdsv2_enabled",
|
||||
}, # AWS only - instance-level IMDSv1 exposure and account IMDS defaults
|
||||
}
|
||||
for category_name, check_ids in attack_surface_check_mappings.items():
|
||||
if check_ids is None:
|
||||
|
||||
@@ -3853,6 +3853,7 @@ class TestAggregateAttackSurface:
|
||||
in result["privilege-escalation"]
|
||||
)
|
||||
assert "ec2_instance_imdsv2_enabled" in result["ec2-imdsv1"]
|
||||
assert "ec2_instance_account_imdsv2_enabled" in result["ec2-imdsv1"]
|
||||
|
||||
@patch("tasks.jobs.scan.AttackSurfaceOverview.objects.bulk_create")
|
||||
@patch("tasks.jobs.scan.Finding.all_objects.filter")
|
||||
|
||||
@@ -73,6 +73,58 @@ The best reference to understand how to implement a new service is following the
|
||||
- AWS API calls are wrapped in try/except blocks, with specific handling for `ClientError` and generic exceptions, always logging errors.
|
||||
- If ARN is not present for some resource, it can be constructed using string interpolation, always including partition, service, region, account, and resource ID.
|
||||
- Tags and additional attributes that cannot be retrieved from the default call, should be collected and stored for each resource using dedicated methods and threading using the resource object list as iterator.
|
||||
- When accessing dictionary values from AWS API responses, always use `.get()` with a default value instead of direct dictionary access (e.g., `response.get("Policies", {})` instead of `response["Policies"]`). AWS API responses may not always include all keys, and direct access can cause `KeyError` exceptions that break the entire scan for that service.
|
||||
|
||||
### Extending an Existing Service with New Attributes
|
||||
|
||||
When adding a new check that requires data not yet collected by an existing service, you need to extend the service by adding new attributes to its resource models and updating the data collection methods. This is a common contributor task that follows a consistent pattern:
|
||||
|
||||
1. **Identify the missing data**: Determine which AWS API call provides the data you need and whether it's already being called by the service.
|
||||
|
||||
2. **Add new attributes to the resource model**: Extend the Pydantic `BaseModel` class for the resource with the new fields. Use `Optional` types with `None` as the default value to maintain backward compatibility with existing checks.
|
||||
|
||||
3. **Update the data collection method**: Modify the existing method that fetches resource details to also extract and store the new attributes. If no existing method fetches the data, add a new method and call it in the constructor using `self.__threading_call__` if possible.
|
||||
|
||||
4. **Use safe dictionary access**: When extracting values from API responses, always use `.get()` with appropriate defaults to prevent `KeyError` exceptions when the API doesn't return certain fields.
|
||||
|
||||
#### Example: Adding DKIM Status to SES Identities
|
||||
|
||||
```python
|
||||
# Step 1 & 2: Add new fields to the resource model
|
||||
class Identity(BaseModel):
|
||||
name: str
|
||||
arn: str
|
||||
region: str
|
||||
type: Optional[str]
|
||||
policy: Optional[dict] = None
|
||||
tags: Optional[list] = []
|
||||
# New attributes for DKIM check
|
||||
dkim_status: Optional[str] = None
|
||||
dkim_signing_attributes_origin: Optional[str] = None
|
||||
|
||||
# Step 3: Update the data collection method
|
||||
def _get_email_identities(self, identity):
|
||||
try:
|
||||
regional_client = self.regional_clients[identity.region]
|
||||
identity_attributes = regional_client.get_email_identity(
|
||||
EmailIdentity=identity.name
|
||||
)
|
||||
# Step 4: Use .get() for safe dictionary access
|
||||
for content_key, content_value in identity_attributes.get("Policies", {}).items():
|
||||
identity.policy = loads(content)
|
||||
identity.tags = identity_attributes.get("Tags", [])
|
||||
# Extract new DKIM attributes
|
||||
identity.dkim_status = identity_attributes.get("DkimStatus")
|
||||
identity.dkim_signing_attributes_origin = (
|
||||
identity_attributes.get("DkimSigningAttributesOrigin")
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
```
|
||||
|
||||
5. **Update the service tests**: Add the new attributes to the test mock data and assertions to verify correct data extraction.
|
||||
|
||||
## Specific Patterns in AWS Checks
|
||||
|
||||
|
||||
@@ -1003,7 +1003,7 @@ class ProwlerArgumentParser:
|
||||
formatter_class=RawTextHelpFormatter,
|
||||
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,dashboard,iac,your_provider} ...",
|
||||
epilog="""
|
||||
Available Cloud Providers:
|
||||
Available Providers:
|
||||
{aws,azure,gcp,kubernetes,m365,github,iac,nhn,your_provider}
|
||||
aws AWS Provider
|
||||
azure Azure Provider
|
||||
|
||||
@@ -32,11 +32,11 @@ Access Prowler App by logging in with **email and password**.
|
||||
|
||||
<img src="/images/log-in.png" alt="Log In" width="285" />
|
||||
|
||||
## Add Cloud Provider
|
||||
## Add Provider
|
||||
|
||||
Configure a cloud provider for scanning:
|
||||
Configure a provider for scanning:
|
||||
|
||||
1. Navigate to `Settings > Cloud Providers` and click `Add Account`.
|
||||
1. Navigate to `Settings > Providers` and click `Add Provider`.
|
||||
2. Select the cloud provider.
|
||||
3. Enter the provider's identifier (Optional: Add an alias):
|
||||
- **AWS**: Account ID
|
||||
|
||||
@@ -40,13 +40,13 @@ Before you begin, make sure you have:
|
||||
### Step 2: Access Prowler Cloud
|
||||
|
||||
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
|
||||
2. Go to "Configuration" > "Cloud Providers"
|
||||
2. Go to "Configuration" > "Providers"
|
||||
|
||||

|
||||

|
||||
|
||||
3. Click "Add Cloud Provider"
|
||||
3. Click "Add Provider"
|
||||
|
||||

|
||||

|
||||
|
||||
4. Select "Alibaba Cloud"
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@ title: 'Getting Started With AWS on Prowler'
|
||||
### Step 2: Access Prowler Cloud
|
||||
|
||||
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
|
||||
2. Go to "Configuration" > "Cloud Providers"
|
||||
2. Go to "Configuration" > "Providers"
|
||||
|
||||

|
||||

|
||||
|
||||
3. Click "Add Cloud Provider"
|
||||
3. Click "Add Provider"
|
||||
|
||||

|
||||

|
||||
|
||||
4. Select "Amazon Web Services"
|
||||
|
||||
|
||||
@@ -35,13 +35,13 @@ For detailed instructions on how to create the Service Principal and configure p
|
||||
### Step 2: Access Prowler Cloud
|
||||
|
||||
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
|
||||
2. Navigate to `Configuration` > `Cloud Providers`
|
||||
2. Navigate to `Configuration` > `Providers`
|
||||
|
||||

|
||||

|
||||
|
||||
3. Click on `Add Cloud Provider`
|
||||
3. Click on `Add Provider`
|
||||
|
||||

|
||||

|
||||
|
||||
4. Select `Microsoft Azure`
|
||||
|
||||
|
||||
@@ -42,13 +42,13 @@ The Account ID is a 32-character hexadecimal string (e.g., `372e67954025e0ba6aaa
|
||||
### Step 2: Open Prowler Cloud
|
||||
|
||||
1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app).
|
||||
2. Navigate to "Configuration" > "Cloud Providers".
|
||||
2. Navigate to "Configuration" > "Providers".
|
||||
|
||||

|
||||

|
||||
|
||||
3. Click "Add Cloud Provider".
|
||||
3. Click "Add Provider".
|
||||
|
||||

|
||||

|
||||
|
||||
4. Select "Cloudflare".
|
||||
|
||||
|
||||
@@ -14,13 +14,13 @@ title: 'Getting Started With GCP on Prowler'
|
||||
### Step 2: Access Prowler Cloud
|
||||
|
||||
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
|
||||
2. Go to "Configuration" > "Cloud Providers"
|
||||
2. Go to "Configuration" > "Providers"
|
||||
|
||||

|
||||

|
||||
|
||||
3. Click "Add Cloud Provider"
|
||||
3. Click "Add Provider"
|
||||
|
||||

|
||||

|
||||
|
||||
4. Select "Google Cloud Platform"
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ For step-by-step setup instructions for Prowler Cloud, see the [Getting Started
|
||||
|
||||
### Using Personal Access Token
|
||||
|
||||
1. In Prowler Cloud, navigate to **Configuration** > **Cloud Providers** > **Add Cloud Provider** > **GitHub**.
|
||||
1. In Prowler Cloud, navigate to **Configuration** > **Providers** > **Add Provider** > **GitHub**.
|
||||
|
||||
2. Enter your GitHub Account ID (username or organization name).
|
||||
|
||||
|
||||
@@ -49,13 +49,13 @@ Before adding GitHub to Prowler Cloud/App, ensure you have:
|
||||
### Step 1: Access Prowler Cloud/App
|
||||
|
||||
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
|
||||
2. Go to **Configuration** → **Cloud Providers**
|
||||
2. Go to **Configuration** → **Providers**
|
||||
|
||||

|
||||

|
||||
|
||||
3. Click **Add Cloud Provider**
|
||||
3. Click **Add Provider**
|
||||
|
||||

|
||||

|
||||
|
||||
4. Select **GitHub**
|
||||
|
||||
|
||||
@@ -43,13 +43,13 @@ The Customer ID starts with the letter "C" followed by alphanumeric characters (
|
||||
### Step 2: Open Prowler Cloud
|
||||
|
||||
1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app).
|
||||
2. Navigate to "Configuration" > "Cloud Providers".
|
||||
2. Navigate to "Configuration" > "Providers".
|
||||
|
||||

|
||||

|
||||
|
||||
3. Click "Add Cloud Provider".
|
||||
3. Click "Add Provider".
|
||||
|
||||

|
||||

|
||||
|
||||
4. Select "Google Workspace".
|
||||
|
||||
|
||||
@@ -42,13 +42,13 @@ Scanner selection is not configurable in Prowler App. Default scanners, misconfi
|
||||
### Step 1: Access Prowler Cloud/App
|
||||
|
||||
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
|
||||
2. Go to "Configuration" > "Cloud Providers"
|
||||
2. Go to "Configuration" > "Providers"
|
||||
|
||||

|
||||

|
||||
|
||||
3. Click "Add Cloud Provider"
|
||||
3. Click "Add Provider"
|
||||
|
||||

|
||||

|
||||
|
||||
4. Select "Infrastructure as Code"
|
||||
|
||||
|
||||
@@ -34,13 +34,13 @@ Prowler Cloud does not support scanner selection. The vulnerability, secret, and
|
||||
### Step 1: Access Prowler Cloud
|
||||
|
||||
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
|
||||
2. Navigate to "Configuration" > "Cloud Providers"
|
||||
2. Navigate to "Configuration" > "Providers"
|
||||
|
||||

|
||||

|
||||
|
||||
3. Click "Add Cloud Provider"
|
||||
3. Click "Add Provider"
|
||||
|
||||

|
||||

|
||||
|
||||
4. Select "Container Registry"
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ title: 'Getting Started with Kubernetes'
|
||||
### Step 1: Access Prowler Cloud/App
|
||||
|
||||
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
|
||||
2. Go to "Configuration" > "Cloud Providers"
|
||||
2. Go to "Configuration" > "Providers"
|
||||
|
||||

|
||||

|
||||
|
||||
3. Click "Add Cloud Provider"
|
||||
3. Click "Add Provider"
|
||||
|
||||

|
||||

|
||||
|
||||
4. Select "Kubernetes"
|
||||
|
||||
|
||||
@@ -42,13 +42,13 @@ Set up authentication for Microsoft 365 with the [Microsoft 365 Authentication](
|
||||
### Step 2: Open Prowler Cloud
|
||||
|
||||
1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app).
|
||||
2. Navigate to "Configuration" > "Cloud Providers".
|
||||
2. Navigate to "Configuration" > "Providers".
|
||||
|
||||

|
||||

|
||||
|
||||
3. Click "Add Cloud Provider".
|
||||
3. Click "Add Provider".
|
||||
|
||||

|
||||

|
||||
|
||||
4. Select "Microsoft 365".
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ If **Require IP Access List for the Atlas Administration API** is enabled in you
|
||||
|
||||
### Step 1: Add the provider
|
||||
|
||||
1. Navigate to **Cloud Providers** and click **Add Cloud Provider**.
|
||||
1. Navigate to **Providers** and click **Add Provider**.
|
||||

|
||||
2. Select **MongoDB Atlas** from the provider list.
|
||||
3. Enter your **Organization ID** (24 hex characters). This value is visible in the Atlas UI under **Organization Settings**.
|
||||
|
||||
@@ -16,8 +16,8 @@ The following steps apply to Prowler Cloud and the self-hosted Prowler App.
|
||||
|
||||
### Step 2: Access Prowler Cloud
|
||||
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app).
|
||||
2. Go to **Configuration** → **Cloud Providers** and click **Add Cloud Provider**.
|
||||

|
||||
2. Go to **Configuration** → **Providers** and click **Add Provider**.
|
||||

|
||||
3. Select **Oracle Cloud** and enter the **Tenancy OCID** and an optional alias, then choose **Next**.
|
||||

|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Before running Prowler with the OpenStack provider, ensure you have:
|
||||
|
||||
### Step 1: Add the Provider
|
||||
|
||||
1. Navigate to "Cloud Providers" and click "Add Cloud Provider".
|
||||
1. Navigate to "Providers" and click "Add Provider".
|
||||

|
||||
2. Select "OpenStack" from the provider list.
|
||||
3. Enter the "Project ID" from the OpenStack provider.
|
||||
|
||||
@@ -29,13 +29,13 @@ Set up authentication for Vercel with the [Vercel Authentication](/user-guide/pr
|
||||
### Step 1: Add the Provider
|
||||
|
||||
1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app).
|
||||
2. Navigate to "Configuration" > "Cloud Providers".
|
||||
2. Navigate to "Configuration" > "Providers".
|
||||
|
||||

|
||||

|
||||
|
||||
3. Click "Add Cloud Provider".
|
||||
3. Click "Add Provider".
|
||||
|
||||

|
||||

|
||||
|
||||
4. Select "Vercel".
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ The Roles section in Prowler is designed to facilitate the assignment of custom
|
||||
</Note>
|
||||
### Provider Groups
|
||||
|
||||
Provider Groups control visibility across specific providers. When creating a new role, you can assign specific groups to define their Cloud Provider visibility. This ensures that users with that role have access only to the Cloud Providers that are required.
|
||||
Provider Groups control visibility across specific providers. When creating a new role, you can assign specific groups to define their Provider visibility. This ensures that users with that role have access only to the Providers that are required.
|
||||
|
||||
By default, a new user role does not have visibility into any group.
|
||||
|
||||
@@ -223,7 +223,7 @@ Assign administrative permissions by selecting from the following options:
|
||||
| Invite and Manage Users | All | Invite new users and manage existing ones. |
|
||||
| Manage Account | All | Adjust account settings, delete users and read/manage users permissions. |
|
||||
| Manage Scans | All | Run and review scans. |
|
||||
| Manage Cloud Providers | All | Add or modify connected cloud providers. |
|
||||
| Manage Providers | All | Add or modify connected providers. |
|
||||
| Manage Integrations | All | Add or modify the Prowler Integrations. |
|
||||
| Manage Ingestions | Prowler Cloud | Allow or deny the ability to submit findings ingestion batches via the API. |
|
||||
| Manage Billing | Prowler Cloud | Access and manage billing settings and subscription information. |
|
||||
|
||||
@@ -320,7 +320,7 @@ Once the required permissions are set up, proceed to configure the S3 integratio
|
||||

|
||||
4. Complete the configuration form with the following details:
|
||||
|
||||
- **Cloud Providers:** Select the providers whose scan results should be exported to this S3 bucket
|
||||
- **Providers:** Select the providers whose scan results should be exported to this S3 bucket
|
||||
- **Bucket Name:** Enter the name of the target S3 bucket (e.g., `my-security-findings-bucket`)
|
||||
- **Output Directory:** Specify the directory path within the bucket (e.g., `/prowler-findings/`, defaults to `output`)
|
||||
|
||||
|
||||
@@ -72,8 +72,8 @@ To perform security scans, link a cloud provider account. Prowler supports the f
|
||||
|
||||
Steps to add a provider:
|
||||
|
||||
1. Navigate to `Settings > Cloud Providers`.
|
||||
2. Click `Add Account` to set up a new provider and provide your credentials.
|
||||
1. Navigate to `Settings > Providers`.
|
||||
2. Click `Add Provider` to set up a new provider and provide your credentials.
|
||||
|
||||
<img src="/images/add-provider.png" alt="Add Provider" width="700" />
|
||||
|
||||
|
||||
@@ -246,10 +246,10 @@ Now that both roles are deployed — the management account role (Step 1) and th
|
||||
|
||||
### Open the Wizard
|
||||
|
||||
1. Navigate to **Cloud Providers** and click **Add Cloud Provider**.
|
||||
1. Navigate to **Providers** and click **Add Provider**.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/cloud-providers-add.png" alt="Cloud Providers page showing the Add Cloud Provider button" />
|
||||
<img src="/images/organizations/cloud-providers-add.png" alt="Providers page showing the Add Provider button" />
|
||||
</Frame>
|
||||
|
||||
2. Select **Amazon Web Services** as the provider.
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
All notable changes to the **Prowler MCP Server** are documented in this file.
|
||||
|
||||
## [0.7.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- `cryptography` from 46.0.1 to 47.0.0 (transitive) for CVE-2026-39892 and CVE-2026-26007 / CVE-2026-34073 [(#10978)](https://github.com/prowler-cloud/prowler/pull/10978)
|
||||
|
||||
---
|
||||
|
||||
## [0.6.0] (Prowler v5.23.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
Generated
+44
-47
@@ -204,58 +204,55 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.1"
|
||||
version = "47.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/62/e3664e6ffd7743e1694b244dde70b43a394f6f7fbcacf7014a8ff5197c73/cryptography-46.0.1.tar.gz", hash = "sha256:ed570874e88f213437f5cf758f9ef26cbfc3f336d889b1e592ee11283bb8d1c7", size = 749198, upload-time = "2025-09-17T00:10:35.797Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/8c/44ee01267ec01e26e43ebfdae3f120ec2312aa72fa4c0507ebe41a26739f/cryptography-46.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:1cd6d50c1a8b79af1a6f703709d8973845f677c8e97b1268f5ff323d38ce8475", size = 7285044, upload-time = "2025-09-17T00:08:36.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/59/9ae689a25047e0601adfcb159ec4f83c0b4149fdb5c3030cc94cd218141d/cryptography-46.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ff483716be32690c14636e54a1f6e2e1b7bf8e22ca50b989f88fa1b2d287080", size = 4308182, upload-time = "2025-09-17T00:08:39.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ee/ca6cc9df7118f2fcd142c76b1da0f14340d77518c05b1ebfbbabca6b9e7d/cryptography-46.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9873bf7c1f2a6330bdfe8621e7ce64b725784f9f0c3a6a55c3047af5849f920e", size = 4572393, upload-time = "2025-09-17T00:08:41.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/a3/0f5296f63815d8e985922b05c31f77ce44787b3127a67c0b7f70f115c45f/cryptography-46.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0dfb7c88d4462a0cfdd0d87a3c245a7bc3feb59de101f6ff88194f740f72eda6", size = 4308400, upload-time = "2025-09-17T00:08:43.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/8c/74fcda3e4e01be1d32775d5b4dd841acaac3c1b8fa4d0774c7ac8d52463d/cryptography-46.0.1-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e22801b61613ebdebf7deb18b507919e107547a1d39a3b57f5f855032dd7cfb8", size = 4015786, upload-time = "2025-09-17T00:08:45.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/b8/85d23287baeef273b0834481a3dd55bbed3a53587e3b8d9f0898235b8f91/cryptography-46.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:757af4f6341ce7a1e47c326ca2a81f41d236070217e5fbbad61bbfe299d55d28", size = 4982606, upload-time = "2025-09-17T00:08:47.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d3/de61ad5b52433b389afca0bc70f02a7a1f074651221f599ce368da0fe437/cryptography-46.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f7a24ea78de345cfa7f6a8d3bde8b242c7fac27f2bd78fa23474ca38dfaeeab9", size = 4604234, upload-time = "2025-09-17T00:08:49.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/1f/dbd4d6570d84748439237a7478d124ee0134bf166ad129267b7ed8ea6d22/cryptography-46.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e8776dac9e660c22241b6587fae51a67b4b0147daa4d176b172c3ff768ad736", size = 4307669, upload-time = "2025-09-17T00:08:52.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/fd/ca0a14ce7f0bfe92fa727aacaf2217eb25eb7e4ed513b14d8e03b26e63ed/cryptography-46.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9f40642a140c0c8649987027867242b801486865277cbabc8c6059ddef16dc8b", size = 4947579, upload-time = "2025-09-17T00:08:54.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6b/09c30543bb93401f6f88fce556b3bdbb21e55ae14912c04b7bf355f5f96c/cryptography-46.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:449ef2b321bec7d97ef2c944173275ebdab78f3abdd005400cc409e27cd159ab", size = 4603669, upload-time = "2025-09-17T00:08:57.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/9a/38cb01cb09ce0adceda9fc627c9cf98eb890fc8d50cacbe79b011df20f8a/cryptography-46.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2dd339ba3345b908fa3141ddba4025568fa6fd398eabce3ef72a29ac2d73ad75", size = 4435828, upload-time = "2025-09-17T00:08:59.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/53/435b5c36a78d06ae0bef96d666209b0ecd8f8181bfe4dda46536705df59e/cryptography-46.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7411c910fb2a412053cf33cfad0153ee20d27e256c6c3f14d7d7d1d9fec59fd5", size = 4709553, upload-time = "2025-09-17T00:09:01.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c4/0da6e55595d9b9cd3b6eb5dc22f3a07ded7f116a3ea72629cab595abb804/cryptography-46.0.1-cp311-abi3-win32.whl", hash = "sha256:cbb8e769d4cac884bb28e3ff620ef1001b75588a5c83c9c9f1fdc9afbe7f29b0", size = 3058327, upload-time = "2025-09-17T00:09:03.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/0f/cd29a35e0d6e78a0ee61793564c8cff0929c38391cb0de27627bdc7525aa/cryptography-46.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:92e8cfe8bd7dd86eac0a677499894862cd5cc2fd74de917daa881d00871ac8e7", size = 3523893, upload-time = "2025-09-17T00:09:06.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/dd/eea390f3e78432bc3d2f53952375f8b37cb4d37783e626faa6a51e751719/cryptography-46.0.1-cp311-abi3-win_arm64.whl", hash = "sha256:db5597a4c7353b2e5fb05a8e6cb74b56a4658a2b7bf3cb6b1821ae7e7fd6eaa0", size = 2932145, upload-time = "2025-09-17T00:09:08.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/fb/c73588561afcd5e24b089952bd210b14676c0c5bf1213376350ae111945c/cryptography-46.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:4c49eda9a23019e11d32a0eb51a27b3e7ddedde91e099c0ac6373e3aacc0d2ee", size = 7193928, upload-time = "2025-09-17T00:09:10.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/34/0ff0bb2d2c79f25a2a63109f3b76b9108a906dd2a2eb5c1d460b9938adbb/cryptography-46.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9babb7818fdd71394e576cf26c5452df77a355eac1a27ddfa24096665a27f8fd", size = 4293515, upload-time = "2025-09-17T00:09:12.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b7/d4f848aee24ecd1be01db6c42c4a270069a4f02a105d9c57e143daf6cf0f/cryptography-46.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9f2c4cc63be3ef43c0221861177cee5d14b505cd4d4599a89e2cd273c4d3542a", size = 4545619, upload-time = "2025-09-17T00:09:15.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/a5/42fedefc754fd1901e2d95a69815ea4ec8a9eed31f4c4361fcab80288661/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:41c281a74df173876da1dc9a9b6953d387f06e3d3ed9284e3baae3ab3f40883a", size = 4299160, upload-time = "2025-09-17T00:09:17.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a1/cd21174f56e769c831fbbd6399a1b7519b0ff6280acec1b826d7b072640c/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0a17377fa52563d730248ba1f68185461fff36e8bc75d8787a7dd2e20a802b7a", size = 3994491, upload-time = "2025-09-17T00:09:18.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/2f/a8cbfa1c029987ddc746fd966711d4fa71efc891d37fbe9f030fe5ab4eec/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:0d1922d9280e08cde90b518a10cd66831f632960a8d08cb3418922d83fce6f12", size = 4960157, upload-time = "2025-09-17T00:09:20.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ae/63a84e6789e0d5a2502edf06b552bcb0fa9ff16147265d5c44a211942abe/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:af84e8e99f1a82cea149e253014ea9dc89f75b82c87bb6c7242203186f465129", size = 4577263, upload-time = "2025-09-17T00:09:23.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/8f/1b9fa8e92bd9cbcb3b7e1e593a5232f2c1e6f9bd72b919c1a6b37d315f92/cryptography-46.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ef648d2c690703501714588b2ba640facd50fd16548133b11b2859e8655a69da", size = 4298703, upload-time = "2025-09-17T00:09:25.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/af/bb95db070e73fea3fae31d8a69ac1463d89d1c084220f549b00dd01094a8/cryptography-46.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:e94eb5fa32a8a9f9bf991f424f002913e3dd7c699ef552db9b14ba6a76a6313b", size = 4926363, upload-time = "2025-09-17T00:09:27.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/3b/d8fb17ffeb3a83157a1cc0aa5c60691d062aceecba09c2e5e77ebfc1870c/cryptography-46.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:534b96c0831855e29fc3b069b085fd185aa5353033631a585d5cd4dd5d40d657", size = 4576958, upload-time = "2025-09-17T00:09:29.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/46/86bc3a05c10c8aa88c8ae7e953a8b4e407c57823ed201dbcba55c4d655f4/cryptography-46.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9b55038b5c6c47559aa33626d8ecd092f354e23de3c6975e4bb205df128a2a0", size = 4422507, upload-time = "2025-09-17T00:09:32.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/4e/387e5a21dfd2b4198e74968a541cfd6128f66f8ec94ed971776e15091ac3/cryptography-46.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ec13b7105117dbc9afd023300fb9954d72ca855c274fe563e72428ece10191c0", size = 4683964, upload-time = "2025-09-17T00:09:34.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/a3/f9f5907b166adb8f26762071474b38bbfcf89858a5282f032899075a38a1/cryptography-46.0.1-cp314-cp314t-win32.whl", hash = "sha256:504e464944f2c003a0785b81668fe23c06f3b037e9cb9f68a7c672246319f277", size = 3029705, upload-time = "2025-09-17T00:09:36.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/66/4d3a4f1850db2e71c2b1628d14b70b5e4c1684a1bd462f7fffb93c041c38/cryptography-46.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c52fded6383f7e20eaf70a60aeddd796b3677c3ad2922c801be330db62778e05", size = 3502175, upload-time = "2025-09-17T00:09:38.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/c7/9f10ad91435ef7d0d99a0b93c4360bea3df18050ff5b9038c489c31ac2f5/cryptography-46.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9495d78f52c804b5ec8878b5b8c7873aa8e63db9cd9ee387ff2db3fffe4df784", size = 2912354, upload-time = "2025-09-17T00:09:40.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e5/fbd632385542a3311915976f88e0dfcf09e62a3fc0aff86fb6762162a24d/cryptography-46.0.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:d84c40bdb8674c29fa192373498b6cb1e84f882889d21a471b45d1f868d8d44b", size = 7255677, upload-time = "2025-09-17T00:09:42.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/3e/13ce6eab9ad6eba1b15a7bd476f005a4c1b3f299f4c2f32b22408b0edccf/cryptography-46.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9ed64e5083fa806709e74fc5ea067dfef9090e5b7a2320a49be3c9df3583a2d8", size = 4301110, upload-time = "2025-09-17T00:09:45.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/67/65dc233c1ddd688073cf7b136b06ff4b84bf517ba5529607c9d79720fc67/cryptography-46.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:341fb7a26bc9d6093c1b124b9f13acc283d2d51da440b98b55ab3f79f2522ead", size = 4562369, upload-time = "2025-09-17T00:09:47.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/db/d64ae4c6f4e98c3dac5bf35dd4d103f4c7c345703e43560113e5e8e31b2b/cryptography-46.0.1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6ef1488967e729948d424d09c94753d0167ce59afba8d0f6c07a22b629c557b2", size = 4302126, upload-time = "2025-09-17T00:09:49.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/19/5f1eea17d4805ebdc2e685b7b02800c4f63f3dd46cfa8d4c18373fea46c8/cryptography-46.0.1-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7823bc7cdf0b747ecfb096d004cc41573c2f5c7e3a29861603a2871b43d3ef32", size = 4009431, upload-time = "2025-09-17T00:09:51.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/b5/229ba6088fe7abccbfe4c5edb96c7a5ad547fac5fdd0d40aa6ea540b2985/cryptography-46.0.1-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:f736ab8036796f5a119ff8211deda416f8c15ce03776db704a7a4e17381cb2ef", size = 4980739, upload-time = "2025-09-17T00:09:54.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/9c/50aa38907b201e74bc43c572f9603fa82b58e831bd13c245613a23cff736/cryptography-46.0.1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:e46710a240a41d594953012213ea8ca398cd2448fbc5d0f1be8160b5511104a0", size = 4592289, upload-time = "2025-09-17T00:09:56.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/33/229858f8a5bb22f82468bb285e9f4c44a31978d5f5830bb4ea1cf8a4e454/cryptography-46.0.1-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:84ef1f145de5aee82ea2447224dc23f065ff4cc5791bb3b506615957a6ba8128", size = 4301815, upload-time = "2025-09-17T00:09:58.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/cb/b76b2c87fbd6ed4a231884bea3ce073406ba8e2dae9defad910d33cbf408/cryptography-46.0.1-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9394c7d5a7565ac5f7d9ba38b2617448eba384d7b107b262d63890079fad77ca", size = 4943251, upload-time = "2025-09-17T00:10:00.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/0f/f66125ecf88e4cb5b8017ff43f3a87ede2d064cb54a1c5893f9da9d65093/cryptography-46.0.1-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ed957044e368ed295257ae3d212b95456bd9756df490e1ac4538857f67531fcc", size = 4591247, upload-time = "2025-09-17T00:10:02.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/22/9f3134ae436b63b463cfdf0ff506a0570da6873adb4bf8c19b8a5b4bac64/cryptography-46.0.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f7de12fa0eee6234de9a9ce0ffcfa6ce97361db7a50b09b65c63ac58e5f22fc7", size = 4428534, upload-time = "2025-09-17T00:10:04.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/39/e6042bcb2638650b0005c752c38ea830cbfbcbb1830e4d64d530000aa8dc/cryptography-46.0.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7fab1187b6c6b2f11a326f33b036f7168f5b996aedd0c059f9738915e4e8f53a", size = 4699541, upload-time = "2025-09-17T00:10:06.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/46/753d457492d15458c7b5a653fc9a84a1c9c7a83af6ebdc94c3fc373ca6e8/cryptography-46.0.1-cp38-abi3-win32.whl", hash = "sha256:45f790934ac1018adeba46a0f7289b2b8fe76ba774a88c7f1922213a56c98bc1", size = 3043779, upload-time = "2025-09-17T00:10:08.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/50/b6f3b540c2f6ee712feeb5fa780bb11fad76634e71334718568e7695cb55/cryptography-46.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:7176a5ab56fac98d706921f6416a05e5aff7df0e4b91516f450f8627cda22af3", size = 3517226, upload-time = "2025-09-17T00:10:10.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/e8/77d17d00981cdd27cc493e81e1749a0b8bbfb843780dbd841e30d7f50743/cryptography-46.0.1-cp38-abi3-win_arm64.whl", hash = "sha256:efc9e51c3e595267ff84adf56e9b357db89ab2279d7e375ffcaf8f678606f3d9", size = 2923149, upload-time = "2025-09-17T00:10:13.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+60
-60
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "about-time"
|
||||
@@ -2029,61 +2029,61 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.6"
|
||||
version = "46.0.7"
|
||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||
optional = false
|
||||
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e"},
|
||||
{file = "cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4"},
|
||||
{file = "cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2097,7 +2097,7 @@ nox = ["nox[uv] (>=2024.4.15)"]
|
||||
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
|
||||
sdist = ["build (>=1.0.0)"]
|
||||
ssh = ["bcrypt (>=3.1.5)"]
|
||||
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.6)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
|
||||
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.7)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
|
||||
test-randomorder = ["pytest-randomly"]
|
||||
|
||||
[[package]]
|
||||
@@ -5445,25 +5445,25 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.4"
|
||||
version = "2.33.1"
|
||||
description = "Python HTTP for Humans."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"},
|
||||
{file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"},
|
||||
{file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"},
|
||||
{file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=2017.4.17"
|
||||
certifi = ">=2023.5.7"
|
||||
charset_normalizer = ">=2,<4"
|
||||
idna = ">=2.5,<4"
|
||||
urllib3 = ">=1.21.1,<3"
|
||||
urllib3 = ">=1.26,<3"
|
||||
|
||||
[package.extras]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
||||
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
||||
use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
|
||||
|
||||
[[package]]
|
||||
name = "requests-file"
|
||||
@@ -6735,4 +6735,4 @@ files = [
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10,<3.13"
|
||||
content-hash = "09ce4507a464b318702ed8c6a738f3bb1bc4cc6ff5a50a9c2884f560af9ab034"
|
||||
content-hash = "d7e2ad41783a864bb845f63ccc10c88ae1e4ac36d61993ea106bbb4a5f58a843"
|
||||
|
||||
@@ -18,6 +18,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- Azure compliance entries for legacy Network Watcher flow log controls now use retirement-aware guidance and point new deployments to VNet flow logs [(#10937)](https://github.com/prowler-cloud/prowler/pull/10937)
|
||||
- AWS CodeBuild service now batches `BatchGetProjects` and `BatchGetBuilds` calls per region (up to 100 items per call) to reduce API call volume and prevent throttling-induced false positives in `codebuild_project_not_publicly_accessible` [(#10639)](https://github.com/prowler-cloud/prowler/pull/10639)
|
||||
- `display_compliance_table` dispatch switched from substring `in` checks to `startswith` to prevent false matches between similarly named frameworks (e.g. `cisa` vs `cis`) [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301)
|
||||
- Restore the `ec2-imdsv1` category for EC2 IMDS checks to keep Attack Surface and findings filters aligned [(#10998)](https://github.com/prowler-cloud/prowler/pull/10998)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
@@ -28,6 +29,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
### 🔐 Security
|
||||
|
||||
- Parser-mismatch SSRF in image provider registry auth where crafted bearer-token realms and pagination links could force requests to internal addresses and leak credentials cross-origin [(#10945)](https://github.com/prowler-cloud/prowler/pull/10945)
|
||||
- `cryptography` from 46.0.6 to 46.0.7 and `trivy` binary from 0.69.2 to 0.70.0 in the SDK image for CVE-2026-39892 and CVE-2026-33186 [(#10978)](https://github.com/prowler-cloud/prowler/pull/10978)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-1
@@ -34,7 +34,8 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"secrets"
|
||||
"secrets",
|
||||
"ec2-imdsv1"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
|
||||
+2
-1
@@ -36,7 +36,8 @@
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access",
|
||||
"secrets"
|
||||
"secrets",
|
||||
"ec2-imdsv1"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
||||
"boto3==1.40.61",
|
||||
"botocore==1.40.61",
|
||||
"colorama==0.4.6",
|
||||
"cryptography==46.0.6",
|
||||
"cryptography==46.0.7",
|
||||
"dash==3.1.1",
|
||||
"dash-bootstrap-components==2.0.3",
|
||||
"defusedxml==0.7.1",
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.26.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Standardized "Providers" wording across UI and documentation, replacing legacy "Cloud Providers" / "Accounts" / "Account Groups" copy [(#10971)](https://github.com/prowler-cloud/prowler/pull/10971)
|
||||
|
||||
---
|
||||
|
||||
## [1.25.2] (Prowler v5.25.2)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
@@ -23,7 +23,7 @@ export const getProviderGroups = async ({
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
if (isNaN(Number(page)) || page < 1)
|
||||
redirect("/providers?tab=account-groups");
|
||||
redirect("/providers?tab=provider-groups");
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/provider-groups`);
|
||||
|
||||
@@ -112,7 +112,7 @@ export const createProviderGroup = async (formData: FormData) => {
|
||||
body,
|
||||
});
|
||||
|
||||
return await handleApiResponse(response, "/providers?tab=account-groups");
|
||||
return await handleApiResponse(response, "/providers?tab=provider-groups");
|
||||
} catch (error) {
|
||||
handleApiError(error);
|
||||
}
|
||||
@@ -169,7 +169,7 @@ export const deleteProviderGroup = async (formData: FormData) => {
|
||||
|
||||
if (!providerGroupId) {
|
||||
return {
|
||||
errors: [{ detail: "Account Group ID is required." }],
|
||||
errors: [{ detail: "Provider Group ID is required." }],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigationMocks = vi.hoisted(() => ({
|
||||
notFound: vi.fn(() => {
|
||||
throw new Error("NEXT_NOT_FOUND");
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: navigationMocks.notFound,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(auth)/alerts/_components/alert-public-action", () => ({
|
||||
ALERT_PUBLIC_ACTIONS: {
|
||||
CONFIRM: "confirm",
|
||||
UNSUBSCRIBE: "unsubscribe",
|
||||
},
|
||||
AlertPublicAction: ({
|
||||
action,
|
||||
token,
|
||||
}: {
|
||||
action: string;
|
||||
token: string | null;
|
||||
}) => (
|
||||
<div>
|
||||
<span>Public alerts action</span>
|
||||
<span>{action}</span>
|
||||
<span>{token}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import AlertsConfirmPage from "../confirm/page";
|
||||
import AlertsUnsubscribePage from "../unsubscribe/page";
|
||||
|
||||
const unreadableSearchParams = {
|
||||
then: () => {
|
||||
throw new Error("search params should not be read");
|
||||
},
|
||||
} as unknown as Promise<{ token?: string }>;
|
||||
|
||||
describe("alerts public pages", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_IS_CLOUD_ENV;
|
||||
navigationMocks.notFound.mockClear();
|
||||
});
|
||||
|
||||
it("should not render the confirm page when Cloud is disabled", async () => {
|
||||
// Given / When / Then
|
||||
await expect(
|
||||
AlertsConfirmPage({ searchParams: unreadableSearchParams }),
|
||||
).rejects.toThrow("NEXT_NOT_FOUND");
|
||||
expect(navigationMocks.notFound).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should not render the unsubscribe page when Cloud is disabled", async () => {
|
||||
// Given / When / Then
|
||||
await expect(
|
||||
AlertsUnsubscribePage({ searchParams: unreadableSearchParams }),
|
||||
).rejects.toThrow("NEXT_NOT_FOUND");
|
||||
expect(navigationMocks.notFound).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should render the confirm action when Cloud is enabled", async () => {
|
||||
// Given
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
|
||||
|
||||
// When
|
||||
render(
|
||||
await AlertsConfirmPage({
|
||||
searchParams: Promise.resolve({ token: "confirm-token" }),
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Public alerts action")).toBeInTheDocument();
|
||||
expect(screen.getByText("confirm")).toBeInTheDocument();
|
||||
expect(screen.getByText("confirm-token")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the unsubscribe action when Cloud is enabled", async () => {
|
||||
// Given
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
|
||||
|
||||
// When
|
||||
render(
|
||||
await AlertsUnsubscribePage({
|
||||
searchParams: Promise.resolve({ token: "unsubscribe-token" }),
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Public alerts action")).toBeInTheDocument();
|
||||
expect(screen.getByText("unsubscribe")).toBeInTheDocument();
|
||||
expect(screen.getByText("unsubscribe-token")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, Loader2, XCircleIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
confirmRecipient,
|
||||
unsubscribeRecipient,
|
||||
} from "@/app/(prowler)/alerts/_actions";
|
||||
import type { AlertPublicResponse } from "@/app/(prowler)/alerts/_types";
|
||||
import { Button, Card, CardContent } from "@/components/shadcn";
|
||||
|
||||
// NOT FOR THE MVP: this UI supports public confirm/unsubscribe email links.
|
||||
// The MVP assumes recipients belong to the tenant and are already confirmed.
|
||||
export const ALERT_PUBLIC_ACTIONS = {
|
||||
CONFIRM: "confirm",
|
||||
UNSUBSCRIBE: "unsubscribe",
|
||||
} as const;
|
||||
export type AlertPublicActionKind =
|
||||
(typeof ALERT_PUBLIC_ACTIONS)[keyof typeof ALERT_PUBLIC_ACTIONS];
|
||||
|
||||
interface AlertPublicActionProps {
|
||||
action: AlertPublicActionKind;
|
||||
token: string | null;
|
||||
idleTitle: string;
|
||||
idleDescription: string;
|
||||
ctaLabel: string;
|
||||
}
|
||||
|
||||
interface AlertPublicResultProps {
|
||||
variant: "success" | "error";
|
||||
title: string;
|
||||
description: string;
|
||||
primaryHref?: string;
|
||||
primaryLabel?: string;
|
||||
supportHref?: string;
|
||||
}
|
||||
|
||||
const runners: Record<
|
||||
AlertPublicActionKind,
|
||||
(token: string) => Promise<AlertPublicResponse>
|
||||
> = {
|
||||
confirm: confirmRecipient,
|
||||
unsubscribe: unsubscribeRecipient,
|
||||
};
|
||||
|
||||
const AlertPublicResult = ({
|
||||
variant,
|
||||
title,
|
||||
description,
|
||||
primaryHref,
|
||||
primaryLabel,
|
||||
supportHref = "https://prowler.com/contact",
|
||||
}: AlertPublicResultProps) => (
|
||||
<main className="flex min-h-screen items-center justify-center p-6">
|
||||
<Card variant="base" padding="lg" className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-5 p-0 text-center">
|
||||
<div
|
||||
className={
|
||||
variant === "success"
|
||||
? "bg-prowler-green-medium/10 flex h-14 w-14 items-center justify-center rounded-full"
|
||||
: "flex h-14 w-14 items-center justify-center rounded-full bg-rose-500/10"
|
||||
}
|
||||
>
|
||||
{variant === "success" ? (
|
||||
<CheckCircle2 className="text-prowler-green-medium h-7 w-7" />
|
||||
) : (
|
||||
<XCircleIcon className="h-7 w-7 text-rose-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="max-w-sm text-sm text-gray-600 dark:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
{primaryHref && primaryLabel && (
|
||||
<Button asChild>
|
||||
<Link href={primaryHref}>{primaryLabel}</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild variant="outline">
|
||||
<Link href={supportHref} target="_blank" rel="noopener noreferrer">
|
||||
Contact support
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
|
||||
const renderResult = (
|
||||
action: AlertPublicActionKind,
|
||||
result: AlertPublicResponse,
|
||||
): AlertPublicResultProps => {
|
||||
switch (result.state) {
|
||||
case "confirmed":
|
||||
return {
|
||||
variant: "success",
|
||||
title: "You're confirmed",
|
||||
description:
|
||||
"This address now receives Prowler Cloud alerts based on your team's alerts.",
|
||||
primaryHref: "https://prowler.com",
|
||||
primaryLabel: "Open Prowler Cloud",
|
||||
};
|
||||
case "already_confirmed":
|
||||
return {
|
||||
variant: "success",
|
||||
title: "Already confirmed",
|
||||
description:
|
||||
"Nothing to do, this address is already subscribed to Prowler Cloud alerts.",
|
||||
};
|
||||
case "unsubscribed":
|
||||
return {
|
||||
variant: "success",
|
||||
title: "You're unsubscribed",
|
||||
description:
|
||||
"We won't send you any more alert digests at this address. Pending notifications have been cancelled.",
|
||||
};
|
||||
case "already_unsubscribed":
|
||||
return {
|
||||
variant: "success",
|
||||
title: "Already unsubscribed",
|
||||
description:
|
||||
"This address is already unsubscribed from Prowler Cloud alerts.",
|
||||
};
|
||||
case "cannot_confirm":
|
||||
return {
|
||||
variant: "error",
|
||||
title: "This address can't be confirmed",
|
||||
description:
|
||||
"Earlier this address unsubscribed or stopped receiving deliveries. Ask your team to re-add it from the Prowler Cloud admin or contact support.",
|
||||
};
|
||||
case "superseded":
|
||||
return {
|
||||
variant: "error",
|
||||
title: "Link superseded",
|
||||
description:
|
||||
"A newer confirmation email has been issued for this address. Open the most recent invitation and use that link instead.",
|
||||
};
|
||||
case "missing_token":
|
||||
return {
|
||||
variant: "error",
|
||||
title: "Link is missing the token",
|
||||
description:
|
||||
"Open the original link from your email so the URL includes the token issued by Prowler Cloud.",
|
||||
};
|
||||
case "invalid_token":
|
||||
return {
|
||||
variant: "error",
|
||||
title: "Link is invalid or expired",
|
||||
description: `This ${action} link is no longer valid. Ask your team to resend the email.`,
|
||||
};
|
||||
case "not_found":
|
||||
return {
|
||||
variant: "error",
|
||||
title: "Recipient not found",
|
||||
description:
|
||||
"We couldn't locate the recipient referenced by this link. It may have been removed.",
|
||||
};
|
||||
case "network_error":
|
||||
default:
|
||||
return {
|
||||
variant: "error",
|
||||
title: "We couldn't reach the server",
|
||||
description:
|
||||
result.message ||
|
||||
"Try again in a few seconds. If this keeps happening, contact support.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const AlertPublicAction = ({
|
||||
action,
|
||||
token,
|
||||
idleTitle,
|
||||
idleDescription,
|
||||
ctaLabel,
|
||||
}: AlertPublicActionProps) => {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [result, setResult] = useState<AlertPublicResponse | null>(null);
|
||||
|
||||
if (!token) {
|
||||
const view = renderResult(action, {
|
||||
state: "missing_token",
|
||||
message: "Token query parameter is missing.",
|
||||
});
|
||||
return <AlertPublicResult {...view} />;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
const view = renderResult(action, result);
|
||||
return <AlertPublicResult {...view} />;
|
||||
}
|
||||
|
||||
const handleClick = async () => {
|
||||
setPending(true);
|
||||
const next = await runners[action](token);
|
||||
setPending(false);
|
||||
setResult(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center p-6">
|
||||
<Card variant="base" padding="lg" className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-5 p-0 text-center">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{idleTitle}
|
||||
</h1>
|
||||
<p className="max-w-sm text-sm text-gray-600 dark:text-gray-300">
|
||||
{idleDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleClick} disabled={pending}>
|
||||
{pending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Working...
|
||||
</>
|
||||
) : (
|
||||
ctaLabel
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import {
|
||||
ALERT_PUBLIC_ACTIONS,
|
||||
AlertPublicAction,
|
||||
} from "@/app/(auth)/alerts/_components/alert-public-action";
|
||||
import { isAlertsEnabled } from "@/app/(prowler)/alerts/_lib/env";
|
||||
|
||||
// NOT FOR THE MVP: tenant-owned recipients are treated as already confirmed.
|
||||
// Keep only if we reintroduce public recipient consent links.
|
||||
interface AlertsConfirmPageProps {
|
||||
searchParams: Promise<{ token?: string }>;
|
||||
}
|
||||
|
||||
export default async function AlertsConfirmPage({
|
||||
searchParams,
|
||||
}: AlertsConfirmPageProps) {
|
||||
if (!isAlertsEnabled()) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { token } = await searchParams;
|
||||
return (
|
||||
<AlertPublicAction
|
||||
action={ALERT_PUBLIC_ACTIONS.CONFIRM}
|
||||
token={token ?? null}
|
||||
idleTitle="Confirm your Prowler Cloud alerts subscription"
|
||||
idleDescription="Click the button below to confirm this email address. After confirming, alert digests for the alerts your team picked you for will start arriving here."
|
||||
ctaLabel="Confirm subscription"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import {
|
||||
ALERT_PUBLIC_ACTIONS,
|
||||
AlertPublicAction,
|
||||
} from "@/app/(auth)/alerts/_components/alert-public-action";
|
||||
import { isAlertsEnabled } from "@/app/(prowler)/alerts/_lib/env";
|
||||
|
||||
// NOT FOR THE MVP: recipient changes are managed inside the tenant product.
|
||||
// Keep only if alert emails need public unsubscribe links.
|
||||
interface AlertsUnsubscribePageProps {
|
||||
searchParams: Promise<{ token?: string }>;
|
||||
}
|
||||
|
||||
export default async function AlertsUnsubscribePage({
|
||||
searchParams,
|
||||
}: AlertsUnsubscribePageProps) {
|
||||
if (!isAlertsEnabled()) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { token } = await searchParams;
|
||||
return (
|
||||
<AlertPublicAction
|
||||
action={ALERT_PUBLIC_ACTIONS.UNSUBSCRIBE}
|
||||
token={token ?? null}
|
||||
idleTitle="Unsubscribe from Prowler Cloud alerts"
|
||||
idleDescription="Click the button below to stop receiving alert digests at this email address. Pending notifications already in flight will be cancelled."
|
||||
ctaLabel="Unsubscribe"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -138,4 +138,13 @@ describe("AccountsSelector", () => {
|
||||
screen.getByText("Production AWS").closest("[data-value]"),
|
||||
).toHaveAttribute("data-keywords", expect.stringContaining("123456789012"));
|
||||
});
|
||||
|
||||
it("disables select all when every account is already shown", () => {
|
||||
render(<AccountsSelector providers={providers} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("option", { name: /select all accounts/i }),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText("All selected")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,7 +146,7 @@ export function AccountsSelector({
|
||||
const filterDescription =
|
||||
selectedProviderTypes && selectedProviderTypes.length > 0
|
||||
? `Accounts for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
|
||||
: "All connected cloud provider accounts";
|
||||
: "All connected provider accounts";
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -155,8 +155,8 @@ export function AccountsSelector({
|
||||
className="sr-only"
|
||||
id="accounts-label"
|
||||
>
|
||||
Filter by cloud provider account. {filterDescription}. Select one or
|
||||
more accounts to view findings.
|
||||
Filter by provider account. {filterDescription}. Select one or more
|
||||
accounts to view findings.
|
||||
</label>
|
||||
<MultiSelect values={selectedIds} onValuesChange={handleMultiValueChange}>
|
||||
<MultiSelectTrigger
|
||||
@@ -171,18 +171,23 @@ export function AccountsSelector({
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={selectedIds.length === 0}
|
||||
aria-disabled={selectedIds.length === 0}
|
||||
aria-label="Select all accounts (clears current selection to show all)"
|
||||
tabIndex={0}
|
||||
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 dark:hover:bg-slate-700/50"
|
||||
onClick={() => handleMultiValueChange([])}
|
||||
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
|
||||
onClick={() => {
|
||||
if (selectedIds.length === 0) return;
|
||||
handleMultiValueChange([]);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (selectedIds.length === 0) return;
|
||||
handleMultiValueChange([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Select All
|
||||
{selectedIds.length === 0 ? "All selected" : "Select All"}
|
||||
</div>
|
||||
{visibleProviders.map((p) => {
|
||||
const id = p.id;
|
||||
|
||||
@@ -135,4 +135,13 @@ describe("ProviderTypeSelector", () => {
|
||||
expect.stringContaining("Amazon Web Services"),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables select all when every provider is already shown", () => {
|
||||
render(<ProviderTypeSelector providers={providers} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("option", { name: /select all providers/i }),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText("All selected")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,8 +277,7 @@ export const ProviderTypeSelector = ({
|
||||
className="sr-only"
|
||||
id="provider-type-label"
|
||||
>
|
||||
Filter by cloud provider type. Select one or more providers to view
|
||||
findings.
|
||||
Filter by provider type. Select one or more providers to view findings.
|
||||
</label>
|
||||
<MultiSelect
|
||||
values={selectedTypes}
|
||||
@@ -296,18 +295,23 @@ export const ProviderTypeSelector = ({
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={selectedTypes.length === 0}
|
||||
aria-disabled={selectedTypes.length === 0}
|
||||
aria-label="Select all providers (clears current selection to show all)"
|
||||
tabIndex={0}
|
||||
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 dark:hover:bg-slate-700/50"
|
||||
onClick={() => handleMultiValueChange([])}
|
||||
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
|
||||
onClick={() => {
|
||||
if (selectedTypes.length === 0) return;
|
||||
handleMultiValueChange([]);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (selectedTypes.length === 0) return;
|
||||
handleMultiValueChange([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Select All
|
||||
{selectedTypes.length === 0 ? "All selected" : "Select All"}
|
||||
</div>
|
||||
{availableTypes.map((providerType) => (
|
||||
<MultiSelectItem
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"use server";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
|
||||
import { buildAlertsDisabledResult, isAlertsEnabled } from "../_lib/env";
|
||||
import {
|
||||
buildSuccessResult,
|
||||
buildUnexpectedError,
|
||||
mapJsonApiErrorToAction,
|
||||
} from "../_lib/error-mapping";
|
||||
import type { AlertsActionResult } from "../_types";
|
||||
|
||||
export interface AlertsRequestOptions {
|
||||
method?: "GET" | "POST" | "PATCH" | "DELETE" | "OPTIONS";
|
||||
query?: URLSearchParams | Record<string, string | string[] | undefined>;
|
||||
body?: unknown;
|
||||
contentType?: boolean;
|
||||
attachAuth?: boolean;
|
||||
cache?: RequestCache;
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Override the `Accept` / `Content-Type` headers. Useful for endpoints that
|
||||
* use plain `JSONRenderer`/`JSONParser` instead of JSON:API renderers (e.g.
|
||||
* the alerts dry-run endpoints `/preview` and `/{id}/test`).
|
||||
*/
|
||||
acceptOverride?: string;
|
||||
contentTypeOverride?: string;
|
||||
}
|
||||
|
||||
const isPairArray = (
|
||||
value: unknown,
|
||||
): value is ReadonlyArray<readonly [string, string]> =>
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(entry) =>
|
||||
Array.isArray(entry) && entry.length >= 2 && typeof entry[0] === "string",
|
||||
);
|
||||
|
||||
const buildUrl = (
|
||||
path: string,
|
||||
query: AlertsRequestOptions["query"],
|
||||
): string => {
|
||||
if (!apiBaseUrl) {
|
||||
throw new Error("NEXT_PUBLIC_API_BASE_URL is not configured.");
|
||||
}
|
||||
const url = new URL(`${apiBaseUrl}${path}`);
|
||||
if (!query) return url.toString();
|
||||
// Real URLSearchParams (RSC → action call within the same process).
|
||||
if (query instanceof URLSearchParams) {
|
||||
query.forEach((value, key) => url.searchParams.append(key, value));
|
||||
return url.toString();
|
||||
}
|
||||
// Serialized URLSearchParams shape (client → server action crosses the
|
||||
// boundary; Next.js converts URLSearchParams to its [[k, v], ...] form).
|
||||
if (isPairArray(query)) {
|
||||
for (const [key, value] of query) {
|
||||
url.searchParams.append(key, String(value ?? ""));
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined) continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) url.searchParams.append(key, v);
|
||||
continue;
|
||||
}
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const safeJson = async (response: Response): Promise<unknown> => {
|
||||
const text = await response.text().catch(() => "");
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const alertsRequest = async <T>(
|
||||
path: string,
|
||||
options: AlertsRequestOptions = {},
|
||||
): Promise<AlertsActionResult<T>> => {
|
||||
if (!isAlertsEnabled()) {
|
||||
return buildAlertsDisabledResult<T>();
|
||||
}
|
||||
|
||||
const {
|
||||
method = "GET",
|
||||
query,
|
||||
body,
|
||||
contentType = method !== "GET" && method !== "DELETE",
|
||||
attachAuth = true,
|
||||
cache,
|
||||
signal,
|
||||
acceptOverride,
|
||||
contentTypeOverride,
|
||||
} = options;
|
||||
try {
|
||||
const baseHeaders = attachAuth
|
||||
? await getAuthHeaders({ contentType })
|
||||
: ({
|
||||
Accept: "application/vnd.api+json",
|
||||
...(contentType
|
||||
? { "Content-Type": "application/vnd.api+json" }
|
||||
: {}),
|
||||
} as Record<string, string>);
|
||||
const headers: Record<string, string> = { ...baseHeaders };
|
||||
if (acceptOverride) headers.Accept = acceptOverride;
|
||||
if (contentTypeOverride) headers["Content-Type"] = contentTypeOverride;
|
||||
|
||||
const url = buildUrl(path, query);
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
cache: cache ?? "no-store",
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const parsedBody = (await safeJson(response)) as Parameters<
|
||||
typeof mapJsonApiErrorToAction
|
||||
>[1];
|
||||
const error = mapJsonApiErrorToAction(
|
||||
response.status,
|
||||
parsedBody,
|
||||
response.headers.get("retry-after"),
|
||||
);
|
||||
Sentry.addBreadcrumb({
|
||||
category: "alerts.request",
|
||||
message: `${method} ${path} failed`,
|
||||
level: "warning",
|
||||
data: {
|
||||
status: response.status,
|
||||
code: error.code,
|
||||
retry_after_seconds: error.retryAfterSeconds,
|
||||
},
|
||||
});
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return buildSuccessResult(undefined as T, null);
|
||||
}
|
||||
|
||||
const parsed = (await safeJson(response)) as Parameters<
|
||||
typeof mapJsonApiErrorToAction
|
||||
>[1] & { data?: unknown };
|
||||
return buildSuccessResult((parsed ?? null) as T, parsed);
|
||||
} catch (error) {
|
||||
Sentry.captureException(error, {
|
||||
tags: { error_source: "alerts.request", method },
|
||||
level: "error",
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
error: buildUnexpectedError(
|
||||
error instanceof Error ? error.message : "Unexpected error.",
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,295 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: vi.fn(async () => ({
|
||||
Accept: "application/vnd.api+json",
|
||||
Authorization: "Bearer test-token",
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: vi.fn(),
|
||||
unstable_cache: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
addBreadcrumb: vi.fn(),
|
||||
captureException: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
ALERT_AGGREGATE_OPS,
|
||||
ALERT_ERROR_CODES,
|
||||
ALERT_TRIGGER_KINDS,
|
||||
} from "../_types";
|
||||
import {
|
||||
createAlert,
|
||||
deleteAlert,
|
||||
disableAlert,
|
||||
enableAlert,
|
||||
listAlerts,
|
||||
previewAlertCondition,
|
||||
updateAlert,
|
||||
} from "./alerts";
|
||||
|
||||
const mockFetchOnce = (
|
||||
status: number,
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
) => {
|
||||
const response = new Response(body === null ? null : JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => response),
|
||||
);
|
||||
};
|
||||
|
||||
const captureFetchArgs = (status: number, body: unknown) => {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
const fetchMock = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
|
||||
calls.push({ url: url.toString(), init: init ?? {} });
|
||||
return new Response(body === null ? null : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/vnd.api+json" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return calls;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("listAlerts", () => {
|
||||
it("returns a controlled error without fetching when alerts are disabled", async () => {
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "false";
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await listAlerts();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe(ALERT_ERROR_CODES.FORBIDDEN);
|
||||
expect(result.error.status).toBe(403);
|
||||
}
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the parsed list payload on success", async () => {
|
||||
mockFetchOnce(200, { data: [], meta: { pagination: { count: 0 } } });
|
||||
const result = await listAlerts(
|
||||
new URLSearchParams("filter[enabled]=true"),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.data).toEqual([]);
|
||||
expect(result.data.meta?.pagination?.count).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards searchParams as query string", async () => {
|
||||
const calls = captureFetchArgs(200, { data: [] });
|
||||
await listAlerts(new URLSearchParams("filter[trigger]=daily"));
|
||||
expect(calls[0].url).toContain("filter%5Btrigger%5D=daily");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAlert", () => {
|
||||
it("posts a JSON:API envelope and returns the new alert", async () => {
|
||||
const calls = captureFetchArgs(201, {
|
||||
data: {
|
||||
id: "alert-1",
|
||||
type: "alert-rules",
|
||||
attributes: { name: "n", trigger: "after_scan" },
|
||||
},
|
||||
});
|
||||
const result = await createAlert({
|
||||
name: "Daily critical",
|
||||
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
|
||||
condition: {
|
||||
op: ALERT_AGGREGATE_OPS.ANY,
|
||||
filter: { severity: ["critical"] },
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls[0].init.method).toBe("POST");
|
||||
const body = JSON.parse((calls[0].init.body as string) ?? "{}");
|
||||
expect(body.data.type).toBe("alert-rules");
|
||||
expect(body.data.attributes.schema_version).toBe(1);
|
||||
});
|
||||
|
||||
it("surfaces JSON:API validation errors with the API code", async () => {
|
||||
mockFetchOnce(400, {
|
||||
errors: [
|
||||
{
|
||||
code: "unknown_filter_field",
|
||||
detail: "Unknown filter field 'foo'.",
|
||||
source: { pointer: "/data/attributes/condition/filter/foo" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await createAlert({
|
||||
name: "x",
|
||||
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
|
||||
condition: {
|
||||
op: ALERT_AGGREGATE_OPS.ANY,
|
||||
filter: { severity: ["high"] },
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe(ALERT_ERROR_CODES.UNKNOWN_FILTER_FIELD);
|
||||
}
|
||||
});
|
||||
|
||||
it("sends an empty recipient list when provided", async () => {
|
||||
const calls = captureFetchArgs(201, {
|
||||
data: {
|
||||
id: "alert-1",
|
||||
type: "alert-rules",
|
||||
attributes: { name: "n", trigger: "after_scan" },
|
||||
},
|
||||
});
|
||||
await createAlert({
|
||||
name: "No recipients yet",
|
||||
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
|
||||
condition: {
|
||||
op: ALERT_AGGREGATE_OPS.ANY,
|
||||
filter: { severity: ["critical"] },
|
||||
},
|
||||
recipientEmails: [],
|
||||
});
|
||||
|
||||
const body = JSON.parse((calls[0].init.body as string) ?? "{}");
|
||||
expect(body.data.attributes.recipient_emails).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateAlert", () => {
|
||||
it("PATCHes the alert with the id in the URL", async () => {
|
||||
const calls = captureFetchArgs(200, {
|
||||
data: { id: "alert-1", type: "alert-rules", attributes: {} },
|
||||
});
|
||||
const result = await updateAlert("alert-1", {
|
||||
name: "Updated",
|
||||
trigger: ALERT_TRIGGER_KINDS.DAILY,
|
||||
condition: {
|
||||
op: ALERT_AGGREGATE_OPS.ANY,
|
||||
filter: { severity: ["critical"] },
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls[0].url).toContain("/alerts/rules/alert-1");
|
||||
expect(calls[0].init.method).toBe("PATCH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteAlert", () => {
|
||||
it("returns ok on 204 without body", async () => {
|
||||
const calls = captureFetchArgs(204, null);
|
||||
const result = await deleteAlert("alert-1");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls[0].init.method).toBe("DELETE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("enable / disable", () => {
|
||||
it("PATCHes enabled true to the alert rule endpoint", async () => {
|
||||
const calls = captureFetchArgs(200, {
|
||||
data: { id: "alert-1", type: "alert-rules", attributes: {} },
|
||||
});
|
||||
await enableAlert("alert-1");
|
||||
expect(calls[0].url).toMatch(/\/alerts\/rules\/alert-1$/);
|
||||
expect(calls[0].init.method).toBe("PATCH");
|
||||
const body = JSON.parse((calls[0].init.body as string) ?? "{}");
|
||||
expect(body).toEqual({
|
||||
data: {
|
||||
type: "alert-rules",
|
||||
id: "alert-1",
|
||||
attributes: { enabled: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("PATCHes enabled false to the alert rule endpoint", async () => {
|
||||
const calls = captureFetchArgs(200, {
|
||||
data: { id: "alert-1", type: "alert-rules", attributes: {} },
|
||||
});
|
||||
await disableAlert("alert-1");
|
||||
expect(calls[0].url).toMatch(/\/alerts\/rules\/alert-1$/);
|
||||
expect(calls[0].init.method).toBe("PATCH");
|
||||
const body = JSON.parse((calls[0].init.body as string) ?? "{}");
|
||||
expect(body).toEqual({
|
||||
data: {
|
||||
type: "alert-rules",
|
||||
id: "alert-1",
|
||||
attributes: { enabled: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewAlertCondition", () => {
|
||||
it("posts to /preview and forwards trigger", async () => {
|
||||
const calls = captureFetchArgs(200, { data: { attributes: {} } });
|
||||
await previewAlertCondition({
|
||||
condition: {
|
||||
op: ALERT_AGGREGATE_OPS.ANY,
|
||||
filter: { severity: ["critical"] },
|
||||
},
|
||||
trigger: ALERT_TRIGGER_KINDS.DAILY,
|
||||
});
|
||||
expect(calls[0].url).toMatch(/\/alerts\/rules\/preview$/);
|
||||
expect(calls[0].init.method).toBe("POST");
|
||||
const body = JSON.parse((calls[0].init.body as string) ?? "{}");
|
||||
expect(body.trigger).toBe("daily");
|
||||
});
|
||||
|
||||
it("unwraps JSON:API preview attributes into the preview model", async () => {
|
||||
mockFetchOnce(200, {
|
||||
data: {
|
||||
type: "alert-rule-previews",
|
||||
id: "preview",
|
||||
attributes: {
|
||||
would_fire: true,
|
||||
summary: {
|
||||
finding_count_total: 7,
|
||||
top_severity: "critical",
|
||||
},
|
||||
sample_finding_ids: [],
|
||||
evaluation_failed: false,
|
||||
duration_ms: 42,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await previewAlertCondition({
|
||||
condition: {
|
||||
op: ALERT_AGGREGATE_OPS.ANY,
|
||||
filter: { severity: ["critical"] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.would_fire).toBe(true);
|
||||
expect(result.data.summary.finding_count_total).toBe(7);
|
||||
expect(result.data.duration_ms).toBe(42);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
"use server";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import {
|
||||
ALERT_SCHEMA_VERSION,
|
||||
type AlertCondition,
|
||||
type AlertPreviewResponse,
|
||||
type AlertRule,
|
||||
type AlertsActionResult,
|
||||
type AlertTriggerKind,
|
||||
} from "../_types";
|
||||
import { alertsRequest } from "./_request";
|
||||
|
||||
const ALERT_RULES_API_PATH = "/alerts/rules";
|
||||
const ALERTS_BASE_PATH = "/alerts";
|
||||
|
||||
const revalidateAlertsBase = () => {
|
||||
revalidatePath(ALERTS_BASE_PATH);
|
||||
};
|
||||
|
||||
const revalidateAlert = (alertId: string) => {
|
||||
revalidatePath(`${ALERTS_BASE_PATH}/${alertId}`);
|
||||
};
|
||||
|
||||
const breadcrumb = (
|
||||
category: string,
|
||||
message: string,
|
||||
data?: Record<string, unknown>,
|
||||
) => {
|
||||
Sentry.addBreadcrumb({ category, message, level: "info", data });
|
||||
};
|
||||
|
||||
export interface AlertsListResponse {
|
||||
data: AlertRule[];
|
||||
meta?: {
|
||||
pagination?: { count: number; pages: number; page: number };
|
||||
};
|
||||
}
|
||||
|
||||
export const listAlerts = async (
|
||||
searchParams?: URLSearchParams,
|
||||
): Promise<AlertsActionResult<AlertsListResponse>> =>
|
||||
alertsRequest<AlertsListResponse>(ALERT_RULES_API_PATH, {
|
||||
method: "GET",
|
||||
query: searchParams,
|
||||
});
|
||||
|
||||
export const getAlert = async (
|
||||
alertId: string,
|
||||
): Promise<AlertsActionResult<{ data: AlertRule }>> =>
|
||||
alertsRequest<{ data: AlertRule }>(`${ALERT_RULES_API_PATH}/${alertId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
export interface AlertPayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
trigger: AlertTriggerKind;
|
||||
condition: AlertCondition;
|
||||
/**
|
||||
* List of recipient email addresses. The API resolves them to existing
|
||||
* `AlertRecipient` rows or creates new pending ones with confirmation
|
||||
* emails. Recipient IDs are NOT used by the rule write path.
|
||||
*/
|
||||
recipientEmails?: string[];
|
||||
}
|
||||
|
||||
const buildRuleEnvelope = (payload: AlertPayload, alertId?: string) => ({
|
||||
data: {
|
||||
type: "alert-rules",
|
||||
...(alertId ? { id: alertId } : {}),
|
||||
attributes: {
|
||||
name: payload.name,
|
||||
description: payload.description ?? "",
|
||||
enabled: payload.enabled ?? true,
|
||||
trigger: payload.trigger,
|
||||
condition: payload.condition,
|
||||
schema_version: ALERT_SCHEMA_VERSION,
|
||||
...(payload.recipientEmails !== undefined
|
||||
? { recipient_emails: payload.recipientEmails }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const buildEnabledEnvelope = (alertId: string, enabled: boolean) => ({
|
||||
data: {
|
||||
type: "alert-rules",
|
||||
id: alertId,
|
||||
attributes: { enabled },
|
||||
},
|
||||
});
|
||||
|
||||
export const createAlert = async (
|
||||
payload: AlertPayload,
|
||||
): Promise<AlertsActionResult<{ data: AlertRule }>> => {
|
||||
const result = await alertsRequest<{ data: AlertRule }>(
|
||||
ALERT_RULES_API_PATH,
|
||||
{
|
||||
method: "POST",
|
||||
body: buildRuleEnvelope(payload),
|
||||
},
|
||||
);
|
||||
if (result.ok) {
|
||||
breadcrumb("alerts.create", "Created alert", {
|
||||
alertId: result.data?.data?.id,
|
||||
});
|
||||
revalidateAlertsBase();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const updateAlert = async (
|
||||
alertId: string,
|
||||
payload: AlertPayload,
|
||||
): Promise<AlertsActionResult<{ data: AlertRule }>> => {
|
||||
const result = await alertsRequest<{ data: AlertRule }>(
|
||||
`${ALERT_RULES_API_PATH}/${alertId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: buildRuleEnvelope(payload, alertId),
|
||||
},
|
||||
);
|
||||
if (result.ok) {
|
||||
breadcrumb("alerts.update", "Updated alert", { alertId });
|
||||
revalidateAlertsBase();
|
||||
revalidateAlert(alertId);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const deleteAlert = async (
|
||||
alertId: string,
|
||||
): Promise<AlertsActionResult<undefined>> => {
|
||||
const result = await alertsRequest<undefined>(
|
||||
`${ALERT_RULES_API_PATH}/${alertId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
if (result.ok) {
|
||||
breadcrumb("alerts.delete", "Deleted alert", { alertId });
|
||||
revalidateAlertsBase();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const enableAlert = async (
|
||||
alertId: string,
|
||||
): Promise<AlertsActionResult<{ data: AlertRule }>> => {
|
||||
const result = await alertsRequest<{ data: AlertRule }>(
|
||||
`${ALERT_RULES_API_PATH}/${alertId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: buildEnabledEnvelope(alertId, true),
|
||||
},
|
||||
);
|
||||
if (result.ok) {
|
||||
breadcrumb("alerts.enable", "Enabled alert", { alertId });
|
||||
revalidateAlertsBase();
|
||||
revalidateAlert(alertId);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const disableAlert = async (
|
||||
alertId: string,
|
||||
): Promise<AlertsActionResult<{ data: AlertRule }>> => {
|
||||
const result = await alertsRequest<{ data: AlertRule }>(
|
||||
`${ALERT_RULES_API_PATH}/${alertId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: buildEnabledEnvelope(alertId, false),
|
||||
},
|
||||
);
|
||||
if (result.ok) {
|
||||
breadcrumb("alerts.disable", "Disabled alert", { alertId });
|
||||
revalidateAlertsBase();
|
||||
revalidateAlert(alertId);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
interface AlertPreviewEnvelope {
|
||||
data?: {
|
||||
type?: "alert-rule-previews";
|
||||
id?: string;
|
||||
attributes?: Partial<AlertPreviewResponse>;
|
||||
};
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isAlertPreviewEnvelope = (
|
||||
value: AlertPreviewResponse | AlertPreviewEnvelope,
|
||||
): value is AlertPreviewEnvelope =>
|
||||
"data" in value &&
|
||||
typeof value.data === "object" &&
|
||||
value.data !== null &&
|
||||
"attributes" in value.data;
|
||||
|
||||
const normalizePreviewResponse = (
|
||||
value: AlertPreviewResponse | AlertPreviewEnvelope,
|
||||
): AlertPreviewResponse => {
|
||||
const attributes = isAlertPreviewEnvelope(value)
|
||||
? value.data?.attributes
|
||||
: value;
|
||||
|
||||
if (!attributes) {
|
||||
return {
|
||||
would_fire: false,
|
||||
summary: { finding_count_total: 0 },
|
||||
sample_finding_ids: [],
|
||||
evaluation_failed: true,
|
||||
last_error: "Preview response is missing attributes.",
|
||||
};
|
||||
}
|
||||
|
||||
const summary = attributes.summary ?? { finding_count_total: 0 };
|
||||
|
||||
return {
|
||||
would_fire: attributes.would_fire ?? false,
|
||||
summary,
|
||||
sample_finding_ids:
|
||||
attributes.sample_finding_ids ?? summary.top_findings ?? [],
|
||||
evaluation_failed: attributes.evaluation_failed ?? false,
|
||||
last_error: attributes.last_error,
|
||||
summary_fallback: attributes.summary_fallback,
|
||||
duration_ms: attributes.duration_ms,
|
||||
};
|
||||
};
|
||||
|
||||
export const previewAlertCondition = async (payload: {
|
||||
condition: AlertCondition;
|
||||
trigger?: AlertTriggerKind;
|
||||
}): Promise<AlertsActionResult<AlertPreviewResponse>> => {
|
||||
const result = await alertsRequest<
|
||||
AlertPreviewResponse | AlertPreviewEnvelope
|
||||
>(`${ALERT_RULES_API_PATH}/preview`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
condition: payload.condition,
|
||||
trigger: payload.trigger,
|
||||
},
|
||||
acceptOverride: "application/vnd.api+json, application/json",
|
||||
contentTypeOverride: "application/json",
|
||||
});
|
||||
breadcrumb(
|
||||
result.ok ? "alerts.preview" : "alerts.preview.failed",
|
||||
"Previewed alert condition",
|
||||
{ ok: result.ok },
|
||||
);
|
||||
if (!result.ok) return result;
|
||||
return { ...result, data: normalizePreviewResponse(result.data) };
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./alerts";
|
||||
export * from "./public";
|
||||
export * from "./recipients";
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: vi.fn(async () => ({
|
||||
Accept: "application/vnd.api+json",
|
||||
Authorization: "Bearer test-token",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
addBreadcrumb: vi.fn(),
|
||||
captureException: vi.fn(),
|
||||
}));
|
||||
|
||||
import { confirmRecipient, unsubscribeRecipient } from "./public";
|
||||
|
||||
const captureFetchArgs = (status: number, body: unknown) => {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
const fetchMock = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
|
||||
calls.push({ url: url.toString(), init: init ?? {} });
|
||||
return new Response(body === null ? null : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return calls;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("confirmRecipient", () => {
|
||||
it("returns a controlled response without fetching when alerts are disabled", async () => {
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "false";
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await confirmRecipient("token-123");
|
||||
|
||||
expect(result.state).toBe("network_error");
|
||||
expect(result.message).toMatch(/Prowler Cloud/i);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT attach Authorization header (public endpoint)", async () => {
|
||||
const calls = captureFetchArgs(200, {
|
||||
state: "confirmed",
|
||||
message: "Recipient confirmed.",
|
||||
});
|
||||
await confirmRecipient("token-123");
|
||||
const headers = (calls[0].init.headers ?? {}) as Record<string, string>;
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
expect(calls[0].url).toContain("/alerts/recipients/confirm");
|
||||
expect(calls[0].url).toContain("token=token-123");
|
||||
});
|
||||
|
||||
it("returns the API state on a 200 response", async () => {
|
||||
captureFetchArgs(200, {
|
||||
state: "already_confirmed",
|
||||
message: "Already confirmed.",
|
||||
});
|
||||
const result = await confirmRecipient("token-123");
|
||||
expect(result.state).toBe("already_confirmed");
|
||||
expect(result.message).toBe("Already confirmed.");
|
||||
});
|
||||
|
||||
it("surfaces invalid_token state from a 400 response", async () => {
|
||||
captureFetchArgs(400, {
|
||||
state: "invalid_token",
|
||||
message: "Token is malformed.",
|
||||
});
|
||||
const result = await confirmRecipient("bad-token");
|
||||
expect(result.state).toBe("invalid_token");
|
||||
});
|
||||
|
||||
it("folds malformed bodies into network_error", async () => {
|
||||
captureFetchArgs(500, "not-json");
|
||||
const result = await confirmRecipient("token-123");
|
||||
expect(result.state).toBe("network_error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsubscribeRecipient", () => {
|
||||
it("hits /unsubscribe with the token and returns the API state", async () => {
|
||||
const calls = captureFetchArgs(200, {
|
||||
state: "unsubscribed",
|
||||
message: "Unsubscribed.",
|
||||
});
|
||||
const result = await unsubscribeRecipient("token-xyz");
|
||||
expect(result.state).toBe("unsubscribed");
|
||||
expect(calls[0].url).toContain("/alerts/recipients/unsubscribe");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
|
||||
import {
|
||||
buildAlertsDisabledPublicResponse,
|
||||
isAlertsEnabled,
|
||||
} from "../_lib/env";
|
||||
import type { AlertPublicResponse } from "../_types";
|
||||
|
||||
// NOT FOR THE MVP: public confirm/unsubscribe endpoints are only needed for
|
||||
// recipient consent links. MVP tenant recipients are already confirmed.
|
||||
const PUBLIC_PATH = "/alerts/recipients";
|
||||
|
||||
const _call = async (
|
||||
endpoint: "confirm" | "unsubscribe",
|
||||
token: string,
|
||||
): Promise<AlertPublicResponse> => {
|
||||
if (!isAlertsEnabled()) {
|
||||
return buildAlertsDisabledPublicResponse();
|
||||
}
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
return {
|
||||
state: "network_error",
|
||||
message: "API base URL is not configured.",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const url = `${apiBaseUrl}${PUBLIC_PATH}/${endpoint}?token=${encodeURIComponent(token)}`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
const body = (await response
|
||||
.json()
|
||||
.catch(() => null)) as AlertPublicResponse | null;
|
||||
if (body && typeof body === "object" && "state" in body) {
|
||||
return body;
|
||||
}
|
||||
return {
|
||||
state: "network_error",
|
||||
message: `Unexpected response from server (HTTP ${response.status}).`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
state: "network_error",
|
||||
message:
|
||||
err instanceof Error ? err.message : "Could not reach the server.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export async function confirmRecipient(
|
||||
token: string,
|
||||
): Promise<AlertPublicResponse> {
|
||||
return _call("confirm", token);
|
||||
}
|
||||
|
||||
export async function unsubscribeRecipient(
|
||||
token: string,
|
||||
): Promise<AlertPublicResponse> {
|
||||
return _call("unsubscribe", token);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: vi.fn(async () => ({
|
||||
Accept: "application/vnd.api+json",
|
||||
Authorization: "Bearer test-token",
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
})),
|
||||
}));
|
||||
|
||||
import { listAlertRecipients } from "./recipients";
|
||||
|
||||
const captureFetchArgs = (status: number, body: unknown) => {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
const fetchMock = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
|
||||
calls.push({ url: url.toString(), init: init ?? {} });
|
||||
return new Response(body === null ? null : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/vnd.api+json" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return calls;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("listAlertRecipients", () => {
|
||||
it("returns a controlled error without fetching when alerts are disabled", async () => {
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "false";
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await listAlertRecipients();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the parsed list payload", async () => {
|
||||
const calls = captureFetchArgs(200, {
|
||||
data: [
|
||||
{
|
||||
id: "1",
|
||||
type: "alert-recipients",
|
||||
attributes: { email: "a@b.test", status: "pending" },
|
||||
},
|
||||
],
|
||||
meta: { pagination: { count: 1, page: 1, pages: 1 } },
|
||||
});
|
||||
const result = await listAlertRecipients(
|
||||
new URLSearchParams("filter[status]=pending"),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.data).toHaveLength(1);
|
||||
expect(result.data.data[0].attributes.email).toBe("a@b.test");
|
||||
}
|
||||
expect(calls[0].url).toContain("filter%5Bstatus%5D=pending");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
"use server";
|
||||
|
||||
import type { AlertRecipient, AlertsActionResult } from "../_types";
|
||||
import { alertsRequest } from "./_request";
|
||||
|
||||
const RECIPIENTS_PATH = "/alerts/recipients";
|
||||
|
||||
export interface AlertRecipientsListResponse {
|
||||
data: AlertRecipient[];
|
||||
meta?: {
|
||||
pagination?: { count: number; pages: number; page: number };
|
||||
};
|
||||
}
|
||||
|
||||
export const listAlertRecipients = async (
|
||||
searchParams?: URLSearchParams,
|
||||
): Promise<AlertsActionResult<AlertRecipientsListResponse>> =>
|
||||
alertsRequest<AlertRecipientsListResponse>(RECIPIENTS_PATH, {
|
||||
method: "GET",
|
||||
query: searchParams,
|
||||
});
|
||||
@@ -0,0 +1,662 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ALERT_AGGREGATE_OPS,
|
||||
ALERT_BOOLEAN_OPS,
|
||||
ALERT_RECIPIENT_STATUS,
|
||||
ALERT_TRIGGER_KINDS,
|
||||
type AlertCondition,
|
||||
type AlertRecipient,
|
||||
type AlertRule,
|
||||
} from "@/app/(prowler)/alerts/_types";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { AlertFormModal } from "../alert-form-modal";
|
||||
|
||||
const recipientsActionMocks = vi.hoisted(() => ({
|
||||
listAlertRecipients: vi.fn(),
|
||||
}));
|
||||
|
||||
const alertsActionMocks = vi.hoisted(() => ({
|
||||
previewAlertCondition: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/app/(prowler)/alerts/_actions/recipients",
|
||||
() => recipientsActionMocks,
|
||||
);
|
||||
|
||||
vi.mock("@/app/(prowler)/alerts/_actions", () => alertsActionMocks);
|
||||
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-header/compliance-scan-info",
|
||||
() => ({
|
||||
ComplianceScanInfo: () => <span>Scan</span>,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/components/ui/entities/entity-info", () => ({
|
||||
EntityInfo: ({
|
||||
entityAlias,
|
||||
entityId,
|
||||
}: {
|
||||
entityAlias?: string;
|
||||
entityId?: string;
|
||||
}) => <span>{entityAlias ?? entityId}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("next-auth/react", () => ({
|
||||
useSession: () => ({ data: null, status: "unauthenticated" }),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/alerts",
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn(), refresh: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/modal", () => ({
|
||||
Modal: ({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
onOpenAutoFocus,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
onOpenAutoFocus?: (event: Event) => void;
|
||||
children: ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
aria-description={description}
|
||||
className={className}
|
||||
data-allows-open-auto-focus={String(Boolean(onOpenAutoFocus))}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
|
||||
global.ResizeObserver = ResizeObserverMock;
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
const mockProviders: ProviderProps[] = [
|
||||
{
|
||||
id: "provider-aws-1",
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "aws",
|
||||
uid: "123456789012",
|
||||
alias: "Production AWS",
|
||||
status: "completed",
|
||||
resources: 42,
|
||||
connection: {
|
||||
connected: true,
|
||||
last_checked_at: "2026-04-30T00:00:00Z",
|
||||
},
|
||||
scanner_args: {
|
||||
only_logs: false,
|
||||
excluded_checks: [],
|
||||
aws_retries_max_attempts: 3,
|
||||
},
|
||||
inserted_at: "2026-04-30T00:00:00Z",
|
||||
updated_at: "2026-04-30T00:00:00Z",
|
||||
created_by: { object: "users", id: "user-1" },
|
||||
},
|
||||
relationships: {
|
||||
secret: { data: null },
|
||||
provider_groups: { meta: { count: 0 }, data: [] },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "provider-gcp-1",
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "gcp",
|
||||
uid: "prowler-prod-project",
|
||||
alias: "Production GCP",
|
||||
status: "completed",
|
||||
resources: 21,
|
||||
connection: {
|
||||
connected: true,
|
||||
last_checked_at: "2026-04-30T00:00:00Z",
|
||||
},
|
||||
scanner_args: {
|
||||
only_logs: false,
|
||||
excluded_checks: [],
|
||||
aws_retries_max_attempts: 3,
|
||||
},
|
||||
inserted_at: "2026-04-30T00:00:00Z",
|
||||
updated_at: "2026-04-30T00:00:00Z",
|
||||
created_by: { object: "users", id: "user-1" },
|
||||
},
|
||||
relationships: {
|
||||
secret: { data: null },
|
||||
provider_groups: { meta: { count: 0 }, data: [] },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const createRecipient = (
|
||||
id: string,
|
||||
email: string,
|
||||
status: AlertRecipient["attributes"]["status"],
|
||||
): AlertRecipient => ({
|
||||
id,
|
||||
type: "alert-recipients",
|
||||
attributes: {
|
||||
email,
|
||||
status,
|
||||
inserted_at: "2026-04-30T00:00:00Z",
|
||||
updated_at: "2026-04-30T00:00:00Z",
|
||||
},
|
||||
relationships: { rules: { data: [] } },
|
||||
});
|
||||
|
||||
const confirmedRecipient = createRecipient(
|
||||
"recipient-confirmed",
|
||||
"security@example.com",
|
||||
ALERT_RECIPIENT_STATUS.CONFIRMED,
|
||||
);
|
||||
|
||||
const pendingRecipient = createRecipient(
|
||||
"recipient-pending",
|
||||
"pending@example.com",
|
||||
ALERT_RECIPIENT_STATUS.PENDING,
|
||||
);
|
||||
|
||||
const createEditingAlert = (
|
||||
overrides: Partial<AlertRule["attributes"]> = {},
|
||||
): AlertRule => ({
|
||||
id: "alert-1",
|
||||
type: "alert-rules",
|
||||
attributes: {
|
||||
name: "Existing alert",
|
||||
description: "Existing description",
|
||||
enabled: true,
|
||||
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
|
||||
condition: {
|
||||
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
|
||||
filter: { severity: ["critical"] },
|
||||
value: 1,
|
||||
},
|
||||
schema_version: 1,
|
||||
recipient_emails: ["security@example.com"],
|
||||
inserted_at: "2026-04-30T00:00:00Z",
|
||||
updated_at: "2026-04-30T00:00:00Z",
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
const mockRecipientsList = () => {
|
||||
recipientsActionMocks.listAlertRecipients.mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
data: [confirmedRecipient, pendingRecipient],
|
||||
meta: { pagination: { page: 1, pages: 1, count: 2 } },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderCreateModal = (
|
||||
props: Partial<React.ComponentProps<typeof AlertFormModal>> = {},
|
||||
) =>
|
||||
render(
|
||||
<AlertFormModal
|
||||
open
|
||||
defaultFrequency={ALERT_TRIGGER_KINDS.AFTER_SCAN}
|
||||
onOpenChange={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
const getVisibleFilterTrigger = (label: string): HTMLButtonElement => {
|
||||
const trigger = screen
|
||||
.getAllByRole("combobox")
|
||||
.find(
|
||||
(element) =>
|
||||
element.textContent?.includes(label) &&
|
||||
!element.closest('[aria-hidden="true"]'),
|
||||
);
|
||||
|
||||
expect(trigger).toBeDefined();
|
||||
return trigger as HTMLButtonElement;
|
||||
};
|
||||
|
||||
describe("AlertFormModal", () => {
|
||||
beforeEach(() => {
|
||||
recipientsActionMocks.listAlertRecipients.mockReset();
|
||||
recipientsActionMocks.listAlertRecipients.mockReturnValue(
|
||||
new Promise(() => {}),
|
||||
);
|
||||
alertsActionMocks.previewAlertCondition.mockReset();
|
||||
});
|
||||
|
||||
it("should render the simplified alert form without preview, delivery settings, or nested recipient management", () => {
|
||||
// Given / When
|
||||
renderCreateModal({
|
||||
providers: mockProviders,
|
||||
uniqueRegions: ["us-east-1", "europe-west1"],
|
||||
uniqueServices: ["iam", "cloudsql"],
|
||||
uniqueCategories: ["identity-security"],
|
||||
uniqueGroups: ["prod"],
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("dialog", { name: "Create Alert" })).toBeVisible();
|
||||
expect(screen.getByLabelText(/^name$/i)).toBeVisible();
|
||||
expect(screen.getByLabelText(/^description$/i)).toBeVisible();
|
||||
expect(screen.getByLabelText(/^frequency$/i)).toBeVisible();
|
||||
expect(screen.getByLabelText(/^recipients$/i)).toBeVisible();
|
||||
expect(screen.getAllByRole("combobox")).toHaveLength(2);
|
||||
expect(screen.queryByText("Alert criteria")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/delivery settings/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByLabelText(/notification method/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/run preview/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /manage recipients/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Production AWS")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/resource type/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^date$/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should provide accessible dialog description and allow initial focus when editing", () => {
|
||||
// Given / When
|
||||
renderCreateModal({
|
||||
editingAlert: createEditingAlert(),
|
||||
});
|
||||
|
||||
// Then
|
||||
const dialog = screen.getByRole("dialog", { name: "Edit Alert" });
|
||||
expect(dialog).toHaveAccessibleDescription(
|
||||
"Update recipients, frequency, and finding filters for this alert.",
|
||||
);
|
||||
expect(dialog).toHaveAttribute("data-allows-open-auto-focus", "true");
|
||||
});
|
||||
|
||||
it("should show selected Findings filters as chips while keeping criteria controls hidden", () => {
|
||||
// Given / When
|
||||
renderCreateModal({
|
||||
initialFindingsFilters: {},
|
||||
selectedFindingsFilterChips: [
|
||||
{ key: "filter[status__in]", label: "Status", value: "FAIL" },
|
||||
{ key: "filter[muted]", label: "Muted", value: "false" },
|
||||
],
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("region", { name: /active filters/i }),
|
||||
).toHaveTextContent("Status: FAIL");
|
||||
expect(
|
||||
screen.getByRole("region", { name: /active filters/i }),
|
||||
).toHaveTextContent("Muted: false");
|
||||
expect(screen.queryByText("All Provider")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/run preview/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should list tenant recipients with status and submit selected emails", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, alertId: "alert-1" });
|
||||
mockRecipientsList();
|
||||
renderCreateModal({ onSubmit });
|
||||
|
||||
// When
|
||||
await user.type(screen.getByLabelText(/^name$/i), "Critical alerts");
|
||||
await user.click(getVisibleFilterTrigger("Select emails"));
|
||||
expect((await screen.findAllByText("Confirmed")).at(-1)).toBeVisible();
|
||||
expect(screen.getAllByText("Pending").at(-1)).toBeVisible();
|
||||
const recipientOptions = await screen.findAllByText("pending@example.com");
|
||||
const visibleRecipientOption = recipientOptions.at(-1);
|
||||
expect(visibleRecipientOption).toBeDefined();
|
||||
await user.click(visibleRecipientOption as HTMLElement);
|
||||
await user.click(screen.getByRole("button", { name: /^create$/i }));
|
||||
|
||||
// Then
|
||||
expect(screen.getAllByText("pending@example.com").at(-1)).toBeVisible();
|
||||
await waitFor(() =>
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
frequency: ALERT_TRIGGER_KINDS.AFTER_SCAN,
|
||||
recipientEmails: ["pending@example.com"],
|
||||
}),
|
||||
null,
|
||||
),
|
||||
);
|
||||
const recipientsParams = recipientsActionMocks.listAlertRecipients.mock
|
||||
.calls[0][0] as URLSearchParams;
|
||||
expect(recipientsParams.get("filter[status]")).toBeNull();
|
||||
expect(recipientsParams.get("page[size]")).toBe("100");
|
||||
});
|
||||
|
||||
it("should submit the configured alert frequency", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, alertId: "alert-1" });
|
||||
mockRecipientsList();
|
||||
renderCreateModal({
|
||||
defaultFrequency: ALERT_TRIGGER_KINDS.DAILY,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
// When
|
||||
await user.type(screen.getByLabelText(/^name$/i), "Daily alerts");
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: /frequency/i }),
|
||||
).toHaveTextContent("Daily digest");
|
||||
await user.click(getVisibleFilterTrigger("Select emails"));
|
||||
const recipientOptions = await screen.findAllByText("security@example.com");
|
||||
const visibleRecipientOption = recipientOptions.at(-1);
|
||||
expect(visibleRecipientOption).toBeDefined();
|
||||
await user.click(visibleRecipientOption as HTMLElement);
|
||||
await user.click(screen.getByRole("button", { name: /^create$/i }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
frequency: ALERT_TRIGGER_KINDS.DAILY,
|
||||
}),
|
||||
null,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow submitting without selected recipients", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, alertId: "alert-1" });
|
||||
mockRecipientsList();
|
||||
renderCreateModal({ onSubmit });
|
||||
|
||||
// When
|
||||
await user.type(screen.getByLabelText(/^name$/i), "Critical alerts");
|
||||
await user.click(screen.getByRole("button", { name: /^create$/i }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recipientEmails: [],
|
||||
}),
|
||||
null,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
screen.queryByText(/select at least one recipient/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should reset form defaults when opening a different alert", () => {
|
||||
// Given
|
||||
const { rerender } = render(
|
||||
<AlertFormModal
|
||||
open
|
||||
defaultFrequency={ALERT_TRIGGER_KINDS.AFTER_SCAN}
|
||||
editingAlert={createEditingAlert({ name: "First alert" })}
|
||||
onOpenChange={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
rerender(
|
||||
<AlertFormModal
|
||||
open
|
||||
defaultFrequency={ALERT_TRIGGER_KINDS.AFTER_SCAN}
|
||||
editingAlert={createEditingAlert({
|
||||
name: "Second alert",
|
||||
updated_at: "2026-05-01T00:00:00Z",
|
||||
})}
|
||||
onOpenChange={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText(/^name$/i)).toHaveValue("Second alert");
|
||||
});
|
||||
|
||||
it("should render the shared Findings batch filter controls for an existing alert", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
mockRecipientsList();
|
||||
renderCreateModal({
|
||||
editingAlert: createEditingAlert({
|
||||
condition: {
|
||||
op: ALERT_BOOLEAN_OPS.AND,
|
||||
children: [
|
||||
{
|
||||
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
|
||||
filter: { severity: ["critical"] },
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
|
||||
filter: { provider_type: ["aws"] },
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
providers: mockProviders,
|
||||
uniqueRegions: ["us-east-1", "europe-west1"],
|
||||
uniqueServices: ["iam", "cloudsql"],
|
||||
uniqueResourceTypes: ["AWS::IAM::User"],
|
||||
uniqueCategories: ["identity-security"],
|
||||
uniqueGroups: ["prod"],
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /more filters/i }));
|
||||
|
||||
// Then
|
||||
const recipientsTrigger = screen.getByLabelText(/^recipients$/i);
|
||||
const filtersHeading = screen.getByRole("heading", { name: /^filters$/i });
|
||||
|
||||
expect(filtersHeading).toBeVisible();
|
||||
expect(
|
||||
recipientsTrigger.compareDocumentPosition(filtersHeading) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(filtersHeading.closest('[data-slot="card"]')).toBeVisible();
|
||||
expect(screen.getAllByText("Amazon Web Services")[0]).toBeVisible();
|
||||
expect(screen.getByText("All accounts")).toBeVisible();
|
||||
expect(screen.getByText("All Status")).toBeVisible();
|
||||
expect(screen.getByText("All Delta")).toBeVisible();
|
||||
expect(screen.getByText("All Resource Type")).toBeVisible();
|
||||
expect(screen.queryByLabelText(/^severity$/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should save edited filters as a normalized simple condition", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, alertId: "alert-1" });
|
||||
mockRecipientsList();
|
||||
renderCreateModal({
|
||||
editingAlert: createEditingAlert(),
|
||||
providers: mockProviders,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /more filters/i }));
|
||||
await user.click(screen.getByLabelText(/provider type/i));
|
||||
const providerOptions = await screen.findAllByText("Google Cloud Platform");
|
||||
const visibleProviderOption = providerOptions.at(-1);
|
||||
expect(visibleProviderOption).toBeDefined();
|
||||
await user.click(visibleProviderOption as HTMLElement);
|
||||
await user.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filterGroup: expect.objectContaining({
|
||||
children: expect.arrayContaining([
|
||||
{ kind: "filter", field: "providers", values: ["gcp"] },
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
null,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should preview the edited alert using current unsaved filters", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
alertsActionMocks.previewAlertCondition.mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
would_fire: true,
|
||||
summary: {
|
||||
finding_count_total: 7,
|
||||
top_severity: "critical",
|
||||
},
|
||||
sample_finding_ids: [],
|
||||
evaluation_failed: false,
|
||||
duration_ms: 42,
|
||||
},
|
||||
});
|
||||
mockRecipientsList();
|
||||
renderCreateModal({
|
||||
editingAlert: createEditingAlert(),
|
||||
providers: mockProviders,
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /more filters/i }));
|
||||
await user.click(screen.getByLabelText(/provider type/i));
|
||||
const providerOptions = await screen.findAllByText("Google Cloud Platform");
|
||||
const visibleProviderOption = providerOptions.at(-1);
|
||||
expect(visibleProviderOption).toBeDefined();
|
||||
await user.click(visibleProviderOption as HTMLElement);
|
||||
await user.click(screen.getByRole("button", { name: /^test$/i }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(alertsActionMocks.previewAlertCondition).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
condition: expect.objectContaining({
|
||||
op: ALERT_BOOLEAN_OPS.AND,
|
||||
children: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filter: { provider_type: ["gcp"] },
|
||||
value: 1,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const previewStatus = await screen.findByText(/would fire/i);
|
||||
expect(previewStatus).toBeVisible();
|
||||
const previewCard = previewStatus.closest('[data-slot="card"]');
|
||||
expect(previewCard).toBeInTheDocument();
|
||||
const previewCardQueries = within(previewCard as HTMLElement);
|
||||
expect(previewCardQueries.getByText(/^findings$/i)).toBeVisible();
|
||||
expect(previewCardQueries.getByText("7")).toBeVisible();
|
||||
expect(previewCardQueries.getByText(/^top severity$/i)).toBeVisible();
|
||||
expect(previewCardQueries.getByText("Critical")).toBeVisible();
|
||||
expect(previewCardQueries.getByText(/^duration$/i)).toBeVisible();
|
||||
expect(previewCardQueries.getByText(/42 ms/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it("should render preview errors inline in edit mode", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
alertsActionMocks.previewAlertCondition.mockResolvedValue({
|
||||
ok: false,
|
||||
error: { detail: "Invalid condition" },
|
||||
});
|
||||
mockRecipientsList();
|
||||
renderCreateModal({ editingAlert: createEditingAlert() });
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /^test$/i }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByText(/invalid condition/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it("should hydrate advanced edit mode filters and normalize them on save", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const advancedCondition: AlertCondition = {
|
||||
op: ALERT_BOOLEAN_OPS.NOT,
|
||||
child: {
|
||||
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
|
||||
filter: { severity: ["critical"] },
|
||||
value: 1,
|
||||
},
|
||||
};
|
||||
const onSubmit = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, alertId: "alert-1" });
|
||||
mockRecipientsList();
|
||||
renderCreateModal({
|
||||
editingAlert: createEditingAlert({
|
||||
condition: advancedCondition,
|
||||
recipient_emails: ["security@example.com"],
|
||||
}),
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Existing alert",
|
||||
recipientEmails: ["security@example.com"],
|
||||
filterGroup: expect.objectContaining({
|
||||
children: [
|
||||
{
|
||||
kind: "filter",
|
||||
field: "checkSeverities",
|
||||
values: ["critical"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
null,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
screen.queryByText(/advanced condition preserved/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ALERT_AGGREGATE_OPS,
|
||||
ALERT_TRIGGER_KINDS,
|
||||
type AlertRule,
|
||||
} from "@/app/(prowler)/alerts/_types";
|
||||
|
||||
import { AlertsManager } from "../alerts-manager";
|
||||
|
||||
const actionMocks = vi.hoisted(() => ({
|
||||
deleteAlert: vi.fn(),
|
||||
disableAlert: vi.fn(),
|
||||
enableAlert: vi.fn(),
|
||||
updateAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
const routerMocks = vi.hoisted(() => ({
|
||||
refresh: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
push: vi.fn(),
|
||||
}));
|
||||
|
||||
const toastMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/app/(prowler)/alerts/_actions", () => actionMocks);
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
cn: (...classes: Array<string | false | null | undefined>) =>
|
||||
classes.filter(Boolean).join(" "),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/alerts",
|
||||
useRouter: () => routerMocks,
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
variant,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
variant?: string;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
data-variant={variant}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../alert-form-modal", () => ({
|
||||
AlertFormModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../alerts-empty-state", () => ({
|
||||
AlertsEmptyState: () => <div>No alerts</div>,
|
||||
}));
|
||||
|
||||
const makeAlert = (enabled: boolean): AlertRule => ({
|
||||
id: enabled ? "enabled-alert" : "disabled-alert",
|
||||
type: "alert-rules",
|
||||
attributes: {
|
||||
name: enabled ? "Enabled alert" : "Disabled alert",
|
||||
description: "",
|
||||
enabled,
|
||||
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
|
||||
condition: {
|
||||
op: ALERT_AGGREGATE_OPS.ANY,
|
||||
filter: { severity: ["critical"] },
|
||||
},
|
||||
schema_version: 1,
|
||||
recipient_emails: [],
|
||||
inserted_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
const renderManager = (alerts: AlertRule[]) =>
|
||||
render(
|
||||
<AlertsManager
|
||||
alerts={alerts}
|
||||
loadError={null}
|
||||
providers={[]}
|
||||
completedScanIds={[]}
|
||||
scanDetails={[]}
|
||||
uniqueRegions={[]}
|
||||
uniqueServices={[]}
|
||||
uniqueResourceTypes={[]}
|
||||
uniqueCategories={[]}
|
||||
uniqueGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("AlertsManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows a success toast after disabling an alert", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const alert = makeAlert(true);
|
||||
actionMocks.disableAlert.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { data: alert },
|
||||
});
|
||||
renderManager([alert]);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /actions for enabled alert/i }),
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: /disable/i }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
title: "Alert disabled",
|
||||
description: "Enabled alert",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a success toast after enabling an alert", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const alert = makeAlert(false);
|
||||
actionMocks.enableAlert.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { data: alert },
|
||||
});
|
||||
renderManager([alert]);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /actions for disabled alert/i }),
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: /enable/i }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
title: "Alert enabled",
|
||||
description: "Disabled alert",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ALERT_AGGREGATE_OPS,
|
||||
ALERT_TRIGGER_KINDS,
|
||||
type AlertRule,
|
||||
} from "@/app/(prowler)/alerts/_types";
|
||||
|
||||
import { AlertsTable } from "../alerts-table";
|
||||
|
||||
const navigationMocks = vi.hoisted(() => ({
|
||||
routerPush: vi.fn(),
|
||||
currentSearch: "",
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/alerts",
|
||||
useRouter: () => ({ push: navigationMocks.routerPush }),
|
||||
useSearchParams: () => new URLSearchParams(navigationMocks.currentSearch),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table/data-table", () => ({
|
||||
DataTable: ({
|
||||
columns,
|
||||
data,
|
||||
metadata,
|
||||
}: {
|
||||
columns: {
|
||||
id?: string;
|
||||
size?: number;
|
||||
minSize?: number;
|
||||
cell?: (context: { row: { original: AlertRule } }) => ReactNode;
|
||||
}[];
|
||||
data: AlertRule[];
|
||||
metadata?: { pagination?: { count?: number } };
|
||||
}) => (
|
||||
<div>
|
||||
{metadata?.pagination?.count !== undefined && (
|
||||
<span>{metadata.pagination.count} Total Entries</span>
|
||||
)}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.id}
|
||||
data-testid={`column-${column.id}`}
|
||||
data-size={column.size}
|
||||
data-min-size={column.minSize}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigationMocks.routerPush(`/alerts?sort=${column.id}`, {
|
||||
scroll: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
{column.id === "enabled" ? "Status" : column.id}
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((alert) => (
|
||||
<tr key={alert.id}>
|
||||
{columns.map((column) => (
|
||||
<td key={`${alert.id}-${column.id}`}>
|
||||
{column.cell?.({ row: { original: alert } })}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table/data-table-column-header", () => ({
|
||||
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
|
||||
}));
|
||||
|
||||
interface AlertRuleOverrides extends Partial<Omit<AlertRule, "attributes">> {
|
||||
attributes?: Partial<AlertRule["attributes"]>;
|
||||
}
|
||||
|
||||
const makeRule = (overrides: AlertRuleOverrides = {}): AlertRule => ({
|
||||
id: overrides.id ?? "alert-1",
|
||||
type: "alert-rules",
|
||||
attributes: {
|
||||
name: "Critical findings",
|
||||
description: "Notify security",
|
||||
enabled: true,
|
||||
trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN,
|
||||
condition: {
|
||||
op: ALERT_AGGREGATE_OPS.ANY,
|
||||
filter: { severity: ["critical"] },
|
||||
},
|
||||
schema_version: 1,
|
||||
recipient_emails: ["security@example.com"],
|
||||
inserted_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides.attributes,
|
||||
},
|
||||
});
|
||||
|
||||
describe("AlertsTable", () => {
|
||||
beforeEach(() => {
|
||||
navigationMocks.currentSearch = "";
|
||||
navigationMocks.routerPush.mockClear();
|
||||
});
|
||||
|
||||
it("should render alert rows with dropdown actions and shared pagination", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<AlertsTable
|
||||
alerts={[makeRule()]}
|
||||
meta={{ pagination: { page: 1, pages: 2, count: 12 }, version: "1" }}
|
||||
mutatingId={null}
|
||||
onEdit={vi.fn()}
|
||||
onToggleEnabled={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("cell", { name: /critical findings/i }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /actions for critical findings/i }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /edit critical findings/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/12 total entries/i)).toBeVisible();
|
||||
expect(screen.getByTestId("column-actions")).toHaveAttribute(
|
||||
"data-size",
|
||||
"72",
|
||||
);
|
||||
expect(screen.getByTestId("column-name")).toHaveAttribute(
|
||||
"data-size",
|
||||
"320",
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /run preview|test/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /critical findings/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should truncate long descriptions in the name column", () => {
|
||||
// Given
|
||||
const description =
|
||||
"This alert description is intentionally long enough to overflow the alerts table if it is not constrained by the cell renderer.";
|
||||
|
||||
// When
|
||||
render(
|
||||
<AlertsTable
|
||||
alerts={[makeRule({ attributes: { description } })]}
|
||||
mutatingId={null}
|
||||
onEdit={vi.fn()}
|
||||
onToggleEnabled={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText(description)).toHaveClass("truncate");
|
||||
expect(screen.getByText(description).parentElement).toHaveClass(
|
||||
"max-w-[320px]",
|
||||
);
|
||||
expect(screen.getByText(description)).toHaveAttribute("title", description);
|
||||
});
|
||||
|
||||
it("should call row action callbacks for edit, toggle, and delete", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const alert = makeRule({ id: "alert-enabled" });
|
||||
const onEdit = vi.fn();
|
||||
const onToggleEnabled = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
render(
|
||||
<AlertsTable
|
||||
alerts={[alert]}
|
||||
mutatingId={null}
|
||||
onEdit={onEdit}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
onDelete={onDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /actions for critical findings/i }),
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: /edit/i }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /actions for critical findings/i }),
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: /disable/i }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /actions for critical findings/i }),
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: /delete/i }));
|
||||
|
||||
// Then
|
||||
expect(onEdit).toHaveBeenCalledWith(alert);
|
||||
expect(onToggleEnabled).toHaveBeenCalledWith(alert);
|
||||
expect(onDelete).toHaveBeenCalledWith(alert);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AlertCondition } from "@/app/(prowler)/alerts/_types";
|
||||
import type {
|
||||
AlertFormSubmitResult,
|
||||
AlertFormValues,
|
||||
} from "@/app/(prowler)/alerts/_types/alert-form";
|
||||
|
||||
const routerMocks = vi.hoisted(() => ({
|
||||
push: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}));
|
||||
|
||||
const actionMocks = vi.hoisted(() => ({
|
||||
createAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
const toastMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => routerMocks,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
ToastAction: ({
|
||||
asChild,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
children?: ReactNode;
|
||||
}) => (asChild ? children : <button {...props}>{children}</button>),
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/alerts/_actions", () => ({
|
||||
createAlert: actionMocks.createAlert,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/alerts/_components/alert-form-modal", () => ({
|
||||
AlertFormModal: ({
|
||||
open,
|
||||
initialFindingsFilters,
|
||||
selectedFindingsFilterChips,
|
||||
defaultName,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
initialFindingsFilters?: Record<string, string | string[]>;
|
||||
selectedFindingsFilterChips?: Array<{
|
||||
label: string;
|
||||
displayValue?: string;
|
||||
value: string;
|
||||
}>;
|
||||
defaultName?: string;
|
||||
onSubmit: (
|
||||
values: AlertFormValues,
|
||||
advancedCondition: AlertCondition | null,
|
||||
) => Promise<AlertFormSubmitResult>;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label="Create alert">
|
||||
<output data-testid="initial-filters">
|
||||
{JSON.stringify(initialFindingsFilters)}
|
||||
</output>
|
||||
<output data-testid="selected-filter-chips">
|
||||
{(selectedFindingsFilterChips ?? [])
|
||||
.map((chip) => `${chip.label}:${chip.displayValue ?? chip.value}`)
|
||||
.join("|")}
|
||||
</output>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSubmit(
|
||||
{
|
||||
name: defaultName ?? "Findings filter alert",
|
||||
description: "",
|
||||
method: "email",
|
||||
frequency: "after_scan",
|
||||
filterGroup: { operator: "all", children: [] },
|
||||
severities: [],
|
||||
deltas: [],
|
||||
providerTypes: [],
|
||||
providerIds: [],
|
||||
checkIds: [],
|
||||
categories: [],
|
||||
regions: [],
|
||||
services: [],
|
||||
resourceGroups: [],
|
||||
findingGroupIds: [],
|
||||
resourceTypes: [],
|
||||
recipientEmails: ["security@example.com"],
|
||||
enabled: true,
|
||||
},
|
||||
null,
|
||||
)
|
||||
}
|
||||
>
|
||||
Submit mock alert
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import { SeedFromFindingsButton } from "../seed-from-findings-button";
|
||||
|
||||
describe("SeedFromFindingsButton", () => {
|
||||
it("should explain why creating an alert is disabled when no real filters are applied", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<SeedFromFindingsButton filterBag={{ sort: "-inserted_at" }} />);
|
||||
|
||||
// When
|
||||
const button = screen.getByRole("button", {
|
||||
name: /Create Alert/i,
|
||||
});
|
||||
const tooltipTrigger = button.parentElement;
|
||||
expect(tooltipTrigger).not.toBeNull();
|
||||
await user.hover(tooltipTrigger as HTMLElement);
|
||||
|
||||
// Then
|
||||
expect(button).toBeDisabled();
|
||||
expect(
|
||||
await screen.findAllByText(/at least one findings filter/i),
|
||||
).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should enable creation from the first real filter, including unsupported backend filters", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<SeedFromFindingsButton
|
||||
filterBag={{
|
||||
"filter[status__in]": "FAIL",
|
||||
"filter[muted]": "false",
|
||||
"filter[scan__in]": "11111111-1111-1111-1111-111111111111",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Create Alert/i }),
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should open the modal in Findings and keep unsupported filters out of the payload seed", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SeedFromFindingsButton
|
||||
filterBag={{
|
||||
"filter[status__in]": "FAIL",
|
||||
"filter[muted]": "false",
|
||||
"filter[scan__in]": "11111111-1111-1111-1111-111111111111",
|
||||
"filter[severity__in]": "critical,high",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /Create Alert/i }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("dialog", { name: /create alert/i })).toBeVisible();
|
||||
expect(routerMocks.push).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("selected-filter-chips")).toHaveTextContent(
|
||||
/status:fail/i,
|
||||
);
|
||||
expect(screen.getByTestId("selected-filter-chips")).toHaveTextContent(
|
||||
/muted:false/i,
|
||||
);
|
||||
expect(screen.getByTestId("initial-filters")).toHaveTextContent(
|
||||
"filter[severity__in]",
|
||||
);
|
||||
expect(screen.getByTestId("initial-filters")).not.toHaveTextContent(
|
||||
"filter[status__in]",
|
||||
);
|
||||
expect(screen.getByTestId("initial-filters")).not.toHaveTextContent(
|
||||
"filter[muted]",
|
||||
);
|
||||
expect(screen.getByTestId("initial-filters")).not.toHaveTextContent(
|
||||
"filter[scan__in]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should create the alert through the existing alert action from the modal", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
actionMocks.createAlert.mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
data: {
|
||||
id: "alert-1",
|
||||
attributes: { name: "Findings filter alert" },
|
||||
},
|
||||
},
|
||||
});
|
||||
render(
|
||||
<SeedFromFindingsButton
|
||||
filterBag={{ "filter[severity__in]": "critical" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /Create Alert/i }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /submit mock alert/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(actionMocks.createAlert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Findings filter alert",
|
||||
trigger: "after_scan",
|
||||
recipientEmails: ["security@example.com"],
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(routerMocks.refresh).toHaveBeenCalled();
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Alert created",
|
||||
action: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should add a toast action to navigate to alerts after creating an alert", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
actionMocks.createAlert.mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
data: {
|
||||
id: "alert-1",
|
||||
attributes: { name: "Findings filter alert" },
|
||||
},
|
||||
},
|
||||
});
|
||||
render(
|
||||
<SeedFromFindingsButton
|
||||
filterBag={{ "filter[severity__in]": "critical" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /Create Alert/i }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /submit mock alert/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(toastMock).toHaveBeenCalled());
|
||||
const toastAction = toastMock.mock.calls[0][0].action;
|
||||
render(toastAction);
|
||||
expect(screen.getByRole("link", { name: /view alerts/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/alerts",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,663 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { previewAlertCondition } from "@/app/(prowler)/alerts/_actions";
|
||||
import { listAlertRecipients } from "@/app/(prowler)/alerts/_actions/recipients";
|
||||
import {
|
||||
ALERT_TRIGGER_KINDS,
|
||||
type AlertCondition,
|
||||
type AlertPreviewResponse,
|
||||
type AlertRecipient,
|
||||
type AlertRule,
|
||||
type AlertTriggerKind,
|
||||
} from "@/app/(prowler)/alerts/_types";
|
||||
import type { FilterChip } from "@/components/filters/filter-summary-strip";
|
||||
import { FilterSummaryStrip } from "@/components/filters/filter-summary-strip";
|
||||
import { FindingsFilterBatchControls } from "@/components/findings/findings-filters";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Field,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Separator,
|
||||
Skeleton,
|
||||
Textarea,
|
||||
} from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
MultiSelectItem,
|
||||
MultiSelectSelectAll,
|
||||
MultiSelectSeparator,
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import type { ScanEntity } from "@/types";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import {
|
||||
buildAlertCondition,
|
||||
getAlertFormDefaults,
|
||||
getAlertFormDefaultsFromFindingsFilters,
|
||||
getEmptyAlertFormDefaults,
|
||||
getFindingsFiltersFromAlertCondition,
|
||||
} from "../_lib/alert-adapter";
|
||||
import { alertFormSchema } from "../_lib/alert-form-schema";
|
||||
import type {
|
||||
AlertFormFindingFilterBag,
|
||||
AlertFormSubmitResult,
|
||||
AlertFormValues,
|
||||
} from "../_types/alert-form";
|
||||
import { ALERT_NOTIFICATION_METHODS } from "../_types/alert-form";
|
||||
|
||||
interface AlertFormModalProps {
|
||||
open: boolean;
|
||||
defaultFrequency: AlertTriggerKind;
|
||||
providers?: ProviderProps[];
|
||||
completedScanIds?: string[];
|
||||
scanDetails?: { [key: string]: ScanEntity }[];
|
||||
uniqueRegions?: string[];
|
||||
uniqueServices?: string[];
|
||||
uniqueResourceTypes?: string[];
|
||||
uniqueCategories?: string[];
|
||||
uniqueGroups?: string[];
|
||||
editingAlert?: AlertRule | null;
|
||||
initialFindingsFilters?: AlertFormFindingFilterBag | null;
|
||||
selectedFindingsFilterChips?: FilterChip[];
|
||||
defaultName?: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (
|
||||
values: AlertFormValues,
|
||||
advancedCondition: AlertCondition | null,
|
||||
) => Promise<AlertFormSubmitResult>;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
name?: string;
|
||||
recipientEmails?: string;
|
||||
root?: string;
|
||||
}
|
||||
|
||||
const normalizeEmail = (email: string): string => email.trim().toLowerCase();
|
||||
|
||||
const getRecipientEmails = (selectedEmails: Set<string>): string[] =>
|
||||
Array.from(selectedEmails);
|
||||
|
||||
const ALERT_FREQUENCY_OPTIONS = [
|
||||
{
|
||||
value: ALERT_TRIGGER_KINDS.AFTER_SCAN,
|
||||
label: "After each scan",
|
||||
},
|
||||
{
|
||||
value: ALERT_TRIGGER_KINDS.DAILY,
|
||||
label: "Daily digest",
|
||||
},
|
||||
{
|
||||
value: ALERT_TRIGGER_KINDS.BOTH,
|
||||
label: "After each scan and daily",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const serializeFindingFilters = (
|
||||
filters: AlertFormFindingFilterBag | null,
|
||||
): string => {
|
||||
if (!filters) return "none";
|
||||
|
||||
return Object.entries(filters)
|
||||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
||||
.map(([key, value]) => {
|
||||
const serializedValue = Array.isArray(value) ? value.join(",") : value;
|
||||
return `${key}:${serializedValue}`;
|
||||
})
|
||||
.join("|");
|
||||
};
|
||||
|
||||
const getAlertFormModalResetKey = ({
|
||||
open,
|
||||
defaultFrequency,
|
||||
editingAlert,
|
||||
initialFindingsFilters,
|
||||
}: Pick<
|
||||
AlertFormModalProps,
|
||||
"open" | "defaultFrequency" | "editingAlert" | "initialFindingsFilters"
|
||||
>): string =>
|
||||
[
|
||||
open ? "open" : "closed",
|
||||
editingAlert?.id ?? "create",
|
||||
editingAlert?.attributes.updated_at ?? "",
|
||||
defaultFrequency,
|
||||
serializeFindingFilters(initialFindingsFilters ?? null),
|
||||
].join("|");
|
||||
|
||||
const allowInitialDialogFocus = () => undefined;
|
||||
|
||||
const uniqueValues = (values: string[]): string[] =>
|
||||
Array.from(new Set(values));
|
||||
|
||||
interface PreviewState {
|
||||
status: "success" | "error";
|
||||
data?: AlertPreviewResponse;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const formatPreviewNumber = (value: number): string =>
|
||||
new Intl.NumberFormat("en-US").format(value);
|
||||
|
||||
const getPreviewSeverityLabel = (severity: string): string =>
|
||||
severity.charAt(0).toUpperCase() + severity.slice(1);
|
||||
|
||||
const PreviewSummarySkeleton = () => (
|
||||
<Card variant="inner" padding="sm">
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Skeleton className="h-5 w-28" />
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-5 w-12" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-3 w-14" />
|
||||
<Skeleton className="h-5 w-10" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const PreviewSummary = ({ preview }: { preview: PreviewState }) => {
|
||||
if (preview.status === "error") {
|
||||
return (
|
||||
<Card variant="danger" padding="sm">
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-text-neutral-primary text-sm font-medium">
|
||||
Test result
|
||||
</span>
|
||||
<Badge variant="tag">Error</Badge>
|
||||
</div>
|
||||
<Separator />
|
||||
<p className="text-destructive text-sm">{preview.error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const data = preview.data;
|
||||
if (!data) return null;
|
||||
|
||||
const totalFindings = data.summary.finding_count_total ?? 0;
|
||||
const topSeverity = data.summary.top_severity ?? "none";
|
||||
const duration = data.duration_ms === undefined ? null : data.duration_ms;
|
||||
const statusLabel = data.would_fire ? "Would fire" : "Would not fire";
|
||||
|
||||
return (
|
||||
<Card variant="inner" padding="sm">
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-text-neutral-primary text-sm font-medium">
|
||||
Test result
|
||||
</span>
|
||||
<Badge variant="tag">{statusLabel}</Badge>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-3 text-sm sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Findings
|
||||
</span>
|
||||
<span className="text-text-neutral-primary font-medium">
|
||||
{formatPreviewNumber(totalFindings)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Top severity
|
||||
</span>
|
||||
<span className="text-text-neutral-primary font-medium">
|
||||
{getPreviewSeverityLabel(topSeverity)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Duration
|
||||
</span>
|
||||
<span className="text-text-neutral-primary font-medium">
|
||||
{duration === null ? "-" : `${formatPreviewNumber(duration)} ms`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeFindingsFilterKey = (filterKey: string): string =>
|
||||
filterKey.startsWith("filter[") ? filterKey : `filter[${filterKey}]`;
|
||||
|
||||
interface AlertRecipientsSelectProps {
|
||||
selectedEmails: Set<string>;
|
||||
onValuesChange: (emails: string[]) => void;
|
||||
}
|
||||
|
||||
interface RecipientOption {
|
||||
email: string;
|
||||
status?: AlertRecipient["attributes"]["status"];
|
||||
}
|
||||
|
||||
const getRecipientStatusLabel = (
|
||||
status: AlertRecipient["attributes"]["status"],
|
||||
): string => status.charAt(0).toUpperCase() + status.slice(1);
|
||||
|
||||
const getRecipientOptions = (
|
||||
recipients: AlertRecipient[],
|
||||
selectedEmails: string[],
|
||||
): RecipientOption[] => {
|
||||
const options = new Map<string, RecipientOption>();
|
||||
|
||||
recipients.forEach((recipient) => {
|
||||
const email = normalizeEmail(recipient.attributes.email);
|
||||
if (!email) return;
|
||||
options.set(email, { email, status: recipient.attributes.status });
|
||||
});
|
||||
|
||||
selectedEmails.forEach((email) => {
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
if (!normalizedEmail || options.has(normalizedEmail)) return;
|
||||
options.set(normalizedEmail, { email: normalizedEmail });
|
||||
});
|
||||
|
||||
return Array.from(options.values()).sort((left, right) =>
|
||||
left.email.localeCompare(right.email),
|
||||
);
|
||||
};
|
||||
|
||||
const AlertRecipientsSelect = ({
|
||||
selectedEmails,
|
||||
onValuesChange,
|
||||
}: AlertRecipientsSelectProps) => {
|
||||
const [recipients, setRecipients] = useState<AlertRecipient[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useMountEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page[size]", "100");
|
||||
params.set("sort", "email");
|
||||
|
||||
listAlertRecipients(params).then((result) => {
|
||||
setLoading(false);
|
||||
if (result.ok) {
|
||||
setRecipients(result.data.data);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
setRecipients([]);
|
||||
setError(result.error.detail);
|
||||
});
|
||||
});
|
||||
|
||||
const selectedValues = Array.from(selectedEmails);
|
||||
const options = getRecipientOptions(recipients, selectedValues);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<MultiSelect values={selectedValues} onValuesChange={onValuesChange}>
|
||||
<MultiSelectTrigger
|
||||
id="alert-recipients"
|
||||
aria-label="Recipients"
|
||||
size="default"
|
||||
>
|
||||
<MultiSelectValue
|
||||
placeholder={loading ? "Loading recipients" : "Select emails"}
|
||||
/>
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent
|
||||
search={{
|
||||
placeholder: "Search recipients...",
|
||||
emptyMessage: "No confirmed recipients found.",
|
||||
}}
|
||||
width="wide"
|
||||
>
|
||||
<MultiSelectSelectAll
|
||||
mode="select"
|
||||
values={options.map((option) => option.email)}
|
||||
>
|
||||
Select All
|
||||
</MultiSelectSelectAll>
|
||||
<MultiSelectSeparator />
|
||||
{options.map((option) => (
|
||||
<MultiSelectItem
|
||||
key={option.email}
|
||||
value={option.email}
|
||||
badgeLabel={option.email}
|
||||
keywords={[option.email, option.status ?? ""]}
|
||||
>
|
||||
<span className="truncate">{option.email}</span>
|
||||
{option.status && (
|
||||
<Badge variant="tag">
|
||||
{getRecipientStatusLabel(option.status)}
|
||||
</Badge>
|
||||
)}
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>
|
||||
{error && <p className="text-destructive text-xs">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AlertFormModal = (props: AlertFormModalProps) => {
|
||||
const resetKey = getAlertFormModalResetKey(props);
|
||||
|
||||
return <AlertFormModalContent key={resetKey} {...props} />;
|
||||
};
|
||||
|
||||
const AlertFormModalContent = ({
|
||||
open,
|
||||
defaultFrequency,
|
||||
providers = [],
|
||||
completedScanIds = [],
|
||||
scanDetails = [],
|
||||
uniqueRegions = [],
|
||||
uniqueServices = [],
|
||||
uniqueResourceTypes = [],
|
||||
uniqueCategories = [],
|
||||
uniqueGroups = [],
|
||||
editingAlert = null,
|
||||
initialFindingsFilters = null,
|
||||
selectedFindingsFilterChips = [],
|
||||
defaultName = "Findings filter alert",
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: AlertFormModalProps) => {
|
||||
const defaults = editingAlert
|
||||
? getAlertFormDefaults(editingAlert)
|
||||
: initialFindingsFilters
|
||||
? getAlertFormDefaultsFromFindingsFilters(
|
||||
initialFindingsFilters,
|
||||
defaultFrequency,
|
||||
)
|
||||
: getEmptyAlertFormDefaults(defaultFrequency);
|
||||
const initialName = editingAlert
|
||||
? defaults.name
|
||||
: defaults.name || defaultName;
|
||||
|
||||
// Local state needed: user edits are buffered until the modal form is submitted.
|
||||
const [name, setName] = useState(initialName);
|
||||
const [description, setDescription] = useState(defaults.description);
|
||||
const [frequency, setFrequency] = useState<AlertTriggerKind>(
|
||||
defaults.frequency,
|
||||
);
|
||||
const [pendingFilters, setPendingFilters] = useState<
|
||||
Record<string, string[]>
|
||||
>(
|
||||
editingAlert
|
||||
? getFindingsFiltersFromAlertCondition(editingAlert.attributes.condition)
|
||||
: {},
|
||||
);
|
||||
const [selectedRecipientEmails, setSelectedRecipientEmails] = useState(
|
||||
() => new Set(defaults.recipientEmails.map(normalizeEmail)),
|
||||
);
|
||||
const [enabled] = useState(defaults.enabled);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [preview, setPreview] = useState<PreviewState | null>(null);
|
||||
|
||||
const submitLabel = editingAlert ? "Save" : "Create";
|
||||
|
||||
const setRecipientEmails = (emails: string[]) =>
|
||||
setSelectedRecipientEmails(
|
||||
new Set(emails.map(normalizeEmail).filter(Boolean)),
|
||||
);
|
||||
|
||||
const setPendingFilter = (filterKey: string, values: string[]) => {
|
||||
setPendingFilters((current) => ({
|
||||
...current,
|
||||
[normalizeFindingsFilterKey(filterKey)]: uniqueValues(values),
|
||||
}));
|
||||
setPreview(null);
|
||||
};
|
||||
|
||||
const getPendingFilterValue = (filterKey: string): string[] =>
|
||||
pendingFilters[normalizeFindingsFilterKey(filterKey)] ?? [];
|
||||
|
||||
const buildCurrentValues = (): AlertFormValues => {
|
||||
const filterDefaults = getAlertFormDefaultsFromFindingsFilters(
|
||||
pendingFilters,
|
||||
frequency,
|
||||
);
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
method: ALERT_NOTIFICATION_METHODS.EMAIL,
|
||||
frequency,
|
||||
filterGroup: filterDefaults.filterGroup,
|
||||
severities: filterDefaults.severities,
|
||||
deltas: filterDefaults.deltas,
|
||||
providerTypes: filterDefaults.providerTypes,
|
||||
providerIds: filterDefaults.providerIds,
|
||||
checkIds: filterDefaults.checkIds,
|
||||
categories: filterDefaults.categories,
|
||||
regions: filterDefaults.regions,
|
||||
services: filterDefaults.services,
|
||||
resourceGroups: filterDefaults.resourceGroups,
|
||||
findingGroupIds: filterDefaults.findingGroupIds,
|
||||
resourceTypes: filterDefaults.resourceTypes,
|
||||
recipientEmails: getRecipientEmails(selectedRecipientEmails),
|
||||
enabled,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!editingAlert) return;
|
||||
|
||||
const values = buildCurrentValues();
|
||||
const parsed = alertFormSchema.safeParse(values);
|
||||
if (!parsed.success) {
|
||||
setPreview({
|
||||
status: "error",
|
||||
error: "Fix alert fields before running test.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewLoading(true);
|
||||
const result = await previewAlertCondition({
|
||||
condition: buildAlertCondition(parsed.data.filterGroup),
|
||||
trigger: frequency,
|
||||
});
|
||||
setPreviewLoading(false);
|
||||
|
||||
if (!result.ok) {
|
||||
setPreview({ status: "error", error: result.error.detail });
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data.evaluation_failed) {
|
||||
setPreview({
|
||||
status: "error",
|
||||
error: result.data.last_error ?? "Preview evaluation failed.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPreview({ status: "success", data: result.data });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const values = buildCurrentValues();
|
||||
const parsed = alertFormSchema.safeParse(values);
|
||||
if (!parsed.success) {
|
||||
const fieldErrors = parsed.error.flatten().fieldErrors;
|
||||
setErrors({
|
||||
name: fieldErrors.name?.[0],
|
||||
recipientEmails: fieldErrors.recipientEmails?.[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
const result = await onSubmit(parsed.data, null);
|
||||
setSaving(false);
|
||||
if (result.ok) {
|
||||
setErrors({});
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
setErrors({ root: result.error ?? "Could not save alert." });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={editingAlert ? "Edit Alert" : "Create Alert"}
|
||||
description={
|
||||
editingAlert
|
||||
? "Update recipients, frequency, and finding filters for this alert."
|
||||
: "Create an alert from the current Findings filters."
|
||||
}
|
||||
onOpenAutoFocus={allowInitialDialogFocus}
|
||||
size={editingAlert ? "5xl" : "xl"}
|
||||
className={
|
||||
editingAlert
|
||||
? "minimal-scrollbar max-h-[calc(100vh-2rem)] overflow-y-auto"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FilterSummaryStrip chips={selectedFindingsFilterChips} />
|
||||
<Field>
|
||||
<FieldLabel htmlFor="alert-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="alert-name"
|
||||
aria-label="Name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
{errors.name && <FieldError>{errors.name}</FieldError>}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="alert-description">Description</FieldLabel>
|
||||
<Textarea
|
||||
id="alert-description"
|
||||
aria-label="Description"
|
||||
textareaSize="lg"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="alert-frequency">Frequency</FieldLabel>
|
||||
<Select
|
||||
value={frequency}
|
||||
onValueChange={(value) => {
|
||||
setFrequency(value as AlertTriggerKind);
|
||||
setPreview(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="alert-frequency" aria-label="Frequency">
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent width="wide" className="z-[60]">
|
||||
{ALERT_FREQUENCY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="alert-recipients">Recipients</FieldLabel>
|
||||
<AlertRecipientsSelect
|
||||
selectedEmails={selectedRecipientEmails}
|
||||
onValuesChange={setRecipientEmails}
|
||||
/>
|
||||
{errors.recipientEmails && (
|
||||
<FieldError>{errors.recipientEmails}</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
{editingAlert && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Card variant="inner" padding="sm">
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<h3 className="text-text-neutral-primary text-sm font-medium">
|
||||
Filters
|
||||
</h3>
|
||||
<FindingsFilterBatchControls
|
||||
providers={providers}
|
||||
completedScanIds={completedScanIds}
|
||||
scanDetails={scanDetails}
|
||||
uniqueRegions={uniqueRegions}
|
||||
uniqueServices={uniqueServices}
|
||||
uniqueResourceTypes={uniqueResourceTypes}
|
||||
uniqueCategories={uniqueCategories}
|
||||
uniqueGroups={uniqueGroups}
|
||||
appliedFilters={{}}
|
||||
pendingFilters={pendingFilters}
|
||||
changedFilters={pendingFilters}
|
||||
setPending={setPendingFilter}
|
||||
getFilterValue={getPendingFilterValue}
|
||||
showSummaries={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{(previewLoading || preview) && (
|
||||
<div className="pt-1">
|
||||
{previewLoading ? (
|
||||
<PreviewSummarySkeleton />
|
||||
) : (
|
||||
preview && <PreviewSummary preview={preview} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{errors.root && (
|
||||
<div className="text-destructive text-sm">{errors.root}</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{editingAlert && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePreview}
|
||||
disabled={previewLoading || saving}
|
||||
>
|
||||
{previewLoading ? "Running..." : "Test"}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSubmit} disabled={saving}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { BellRing, TagIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button, Card, CardContent } from "@/components/shadcn";
|
||||
|
||||
export const AlertsEmptyState = () => (
|
||||
<Card variant="base" padding="lg">
|
||||
<CardContent className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="bg-button-primary/10 flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<BellRing className="text-button-primary h-7 w-7" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-text-neutral-primary text-lg font-semibold">
|
||||
No alerts yet
|
||||
</h3>
|
||||
<p className="text-text-neutral-secondary max-w-md text-sm">
|
||||
Create alerts from Findings page to notify selected recipients when
|
||||
matching findings appear.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild size="sm">
|
||||
<Link href="/findings?filter[muted]=false&filter[status__in]=FAIL">
|
||||
<TagIcon size={14} aria-hidden="true" />
|
||||
Go to Findings
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
import {
|
||||
deleteAlert,
|
||||
disableAlert,
|
||||
enableAlert,
|
||||
updateAlert,
|
||||
} from "@/app/(prowler)/alerts/_actions";
|
||||
import {
|
||||
ALERT_TRIGGER_KINDS,
|
||||
type AlertCondition,
|
||||
type AlertRule,
|
||||
} from "@/app/(prowler)/alerts/_types";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import type { MetaDataProps } from "@/types";
|
||||
import type { ScanEntity } from "@/types";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { toAlertPayload } from "../_lib/alert-adapter";
|
||||
import type {
|
||||
AlertFormSubmitResult,
|
||||
AlertFormValues,
|
||||
} from "../_types/alert-form";
|
||||
import { AlertFormModal } from "./alert-form-modal";
|
||||
import { AlertsEmptyState } from "./alerts-empty-state";
|
||||
import { AlertsTable } from "./alerts-table";
|
||||
|
||||
interface AlertsManagerProps {
|
||||
alerts: AlertRule[];
|
||||
meta?: MetaDataProps;
|
||||
loadError: string | null;
|
||||
providers: ProviderProps[];
|
||||
completedScanIds: string[];
|
||||
scanDetails: { [key: string]: ScanEntity }[];
|
||||
uniqueRegions: string[];
|
||||
uniqueServices: string[];
|
||||
uniqueResourceTypes: string[];
|
||||
uniqueCategories: string[];
|
||||
uniqueGroups: string[];
|
||||
initialEditingAlert?: AlertRule | null;
|
||||
}
|
||||
|
||||
export const AlertsManager = ({
|
||||
alerts,
|
||||
meta,
|
||||
loadError,
|
||||
providers,
|
||||
completedScanIds,
|
||||
scanDetails,
|
||||
uniqueRegions,
|
||||
uniqueServices,
|
||||
uniqueResourceTypes,
|
||||
uniqueCategories,
|
||||
uniqueGroups,
|
||||
initialEditingAlert = null,
|
||||
}: AlertsManagerProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
const [modalOpen, setModalOpen] = useState(Boolean(initialEditingAlert));
|
||||
const [editingAlert, setEditingRule] = useState<AlertRule | null>(
|
||||
initialEditingAlert,
|
||||
);
|
||||
const [mutatingId, setMutatingId] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<AlertRule | null>(null);
|
||||
|
||||
const refresh = () => startTransition(() => router.refresh());
|
||||
|
||||
const closeModal = (open: boolean) => {
|
||||
setModalOpen(open);
|
||||
if (!open) {
|
||||
setEditingRule(null);
|
||||
router.replace("/alerts");
|
||||
}
|
||||
};
|
||||
|
||||
const submitAlert = async (
|
||||
values: AlertFormValues,
|
||||
advancedCondition: AlertCondition | null,
|
||||
): Promise<AlertFormSubmitResult> => {
|
||||
if (!editingAlert) {
|
||||
return { ok: false, error: "Create alerts from Findings." };
|
||||
}
|
||||
const payload = toAlertPayload(values, advancedCondition);
|
||||
const result = await updateAlert(editingAlert.id, payload);
|
||||
if (!result.ok) return { ok: false, error: result.error.detail };
|
||||
toast({
|
||||
title: "Alert updated",
|
||||
description: result.data.data.attributes.name,
|
||||
});
|
||||
refresh();
|
||||
return { ok: true, alertId: result.data.data.id };
|
||||
};
|
||||
|
||||
const toggleAlert = async (alert: AlertRule) => {
|
||||
setMutatingId(alert.id);
|
||||
const result = alert.attributes.enabled
|
||||
? await disableAlert(alert.id)
|
||||
: await enableAlert(alert.id);
|
||||
setMutatingId(null);
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Alert update failed",
|
||||
description: result.error.detail,
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: alert.attributes.enabled ? "Alert disabled" : "Alert enabled",
|
||||
description: result.data.data.attributes.name,
|
||||
});
|
||||
refresh();
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!pendingDelete) return;
|
||||
setMutatingId(pendingDelete.id);
|
||||
const result = await deleteAlert(pendingDelete.id);
|
||||
setMutatingId(null);
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Alert delete failed",
|
||||
description: result.error.detail,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPendingDelete(null);
|
||||
refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex max-w-3xl flex-col gap-2">
|
||||
<h1 className="text-text-neutral-primary text-2xl font-semibold">
|
||||
Alerts
|
||||
</h1>
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Manage alerts for finding conditions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="border-destructive/40 bg-destructive/10 text-destructive rounded-md border p-4 text-sm">
|
||||
Failed to load alerts: {loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alerts.length === 0 && !loadError ? (
|
||||
<AlertsEmptyState />
|
||||
) : (
|
||||
<AlertsTable
|
||||
alerts={alerts}
|
||||
meta={meta}
|
||||
mutatingId={mutatingId}
|
||||
onEdit={(alert) => {
|
||||
setEditingRule(alert);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
onToggleEnabled={toggleAlert}
|
||||
onDelete={setPendingDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertFormModal
|
||||
key={editingAlert?.id ?? "edit"}
|
||||
open={modalOpen}
|
||||
defaultFrequency={ALERT_TRIGGER_KINDS.AFTER_SCAN}
|
||||
providers={providers}
|
||||
completedScanIds={completedScanIds}
|
||||
scanDetails={scanDetails}
|
||||
uniqueRegions={uniqueRegions}
|
||||
uniqueServices={uniqueServices}
|
||||
uniqueResourceTypes={uniqueResourceTypes}
|
||||
uniqueCategories={uniqueCategories}
|
||||
uniqueGroups={uniqueGroups}
|
||||
editingAlert={editingAlert}
|
||||
onOpenChange={closeModal}
|
||||
onSubmit={submitAlert}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={Boolean(pendingDelete)}
|
||||
onOpenChange={(open) => !open && setPendingDelete(null)}
|
||||
title="Delete alert"
|
||||
description={
|
||||
pendingDelete
|
||||
? `Delete "${pendingDelete.attributes.name}"? This alert will stop evaluating.`
|
||||
: ""
|
||||
}
|
||||
size="md"
|
||||
>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="outline" onClick={() => setPendingDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={mutatingId === pendingDelete?.id}
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
Delete alert
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { PencilIcon, PowerIcon, TrashIcon } from "lucide-react";
|
||||
|
||||
import type { AlertRule } from "@/app/(prowler)/alerts/_types";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { DataTable } from "@/components/ui/table/data-table";
|
||||
import { DataTableColumnHeader } from "@/components/ui/table/data-table-column-header";
|
||||
import type { MetaDataProps } from "@/types";
|
||||
|
||||
interface AlertsTableProps {
|
||||
alerts: AlertRule[];
|
||||
meta?: MetaDataProps;
|
||||
mutatingId: string | null;
|
||||
onEdit: (alert: AlertRule) => void;
|
||||
onToggleEnabled: (alert: AlertRule) => void;
|
||||
onDelete: (alert: AlertRule) => void;
|
||||
}
|
||||
|
||||
const TRIGGER_LABELS = {
|
||||
after_scan: "After each scan",
|
||||
daily: "Daily digest",
|
||||
both: "After scan and daily",
|
||||
} as const satisfies Record<AlertRule["attributes"]["trigger"], string>;
|
||||
|
||||
const formatRecipients = (alert: AlertRule): string => {
|
||||
const recipients = alert.attributes.recipient_emails ?? [];
|
||||
if (recipients.length === 0) return "No recipients";
|
||||
if (recipients.length === 1) return recipients[0];
|
||||
return `${recipients[0]} +${recipients.length - 1} more`;
|
||||
};
|
||||
|
||||
interface GetAlertsTableColumnsOptions {
|
||||
mutatingId: string | null;
|
||||
onEdit: (alert: AlertRule) => void;
|
||||
onToggleEnabled: (alert: AlertRule) => void;
|
||||
onDelete: (alert: AlertRule) => void;
|
||||
}
|
||||
|
||||
const getAlertsTableColumns = ({
|
||||
mutatingId,
|
||||
onEdit,
|
||||
onToggleEnabled,
|
||||
onDelete,
|
||||
}: GetAlertsTableColumnsOptions): ColumnDef<AlertRule>[] => [
|
||||
{
|
||||
id: "name",
|
||||
size: 320,
|
||||
minSize: 280,
|
||||
accessorFn: (alert) => alert.attributes.name,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Name" param="name" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const alert = row.original;
|
||||
return (
|
||||
<div className="flex w-[320px] max-w-[320px] min-w-0 flex-col gap-1">
|
||||
<span className="truncate font-medium">{alert.attributes.name}</span>
|
||||
{alert.attributes.description && (
|
||||
<span
|
||||
className="text-text-neutral-secondary block w-full truncate text-xs"
|
||||
title={alert.attributes.description}
|
||||
>
|
||||
{alert.attributes.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
size: 140,
|
||||
minSize: 120,
|
||||
accessorFn: (alert) => (alert.attributes.enabled ? "Enabled" : "Disabled"),
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" param="enabled" />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
row.original.attributes.enabled ? "Enabled" : "Disabled",
|
||||
},
|
||||
{
|
||||
id: "trigger",
|
||||
size: 190,
|
||||
minSize: 170,
|
||||
accessorFn: (alert) => TRIGGER_LABELS[alert.attributes.trigger],
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title="Frequency"
|
||||
param="trigger"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => TRIGGER_LABELS[row.original.attributes.trigger],
|
||||
},
|
||||
{
|
||||
id: "recipients",
|
||||
size: 220,
|
||||
minSize: 180,
|
||||
accessorFn: (alert) => formatRecipients(alert),
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Recipients" />
|
||||
),
|
||||
cell: ({ row }) => formatRecipients(row.original),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 72,
|
||||
minSize: 64,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const alert = row.original;
|
||||
const isMutating = mutatingId === alert.id;
|
||||
const enabled = alert.attributes.enabled;
|
||||
const toggleLabel = enabled ? "Disable" : "Enable";
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<ActionDropdown ariaLabel={`Actions for ${alert.attributes.name}`}>
|
||||
<ActionDropdownItem
|
||||
icon={<PencilIcon />}
|
||||
label="Edit"
|
||||
onSelect={() => onEdit(alert)}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<PowerIcon />}
|
||||
label={toggleLabel}
|
||||
disabled={isMutating}
|
||||
onSelect={() => onToggleEnabled(alert)}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
<ActionDropdownItem
|
||||
icon={<TrashIcon />}
|
||||
label="Delete"
|
||||
destructive
|
||||
disabled={isMutating}
|
||||
onSelect={() => onDelete(alert)}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const AlertsTable = ({
|
||||
alerts,
|
||||
meta,
|
||||
mutatingId,
|
||||
onEdit,
|
||||
onToggleEnabled,
|
||||
onDelete,
|
||||
}: AlertsTableProps) => (
|
||||
<DataTable
|
||||
columns={getAlertsTableColumns({
|
||||
mutatingId,
|
||||
onEdit,
|
||||
onToggleEnabled,
|
||||
onDelete,
|
||||
})}
|
||||
data={alerts}
|
||||
metadata={meta}
|
||||
showSearch
|
||||
searchPlaceholder="Search alerts"
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./seed-from-findings-button";
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { BellPlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { createAlert } from "@/app/(prowler)/alerts/_actions";
|
||||
import { AlertFormModal } from "@/app/(prowler)/alerts/_components/alert-form-modal";
|
||||
import { toAlertPayload } from "@/app/(prowler)/alerts/_lib/alert-adapter";
|
||||
import {
|
||||
canSeedAlertFromFindingsFilters,
|
||||
toPortableAlertFilterBag,
|
||||
} from "@/app/(prowler)/alerts/_lib/seeding";
|
||||
import {
|
||||
ALERT_TRIGGER_KINDS,
|
||||
type AlertCondition,
|
||||
type AlertsFilterBag,
|
||||
} from "@/app/(prowler)/alerts/_types";
|
||||
import type {
|
||||
AlertFormSubmitResult,
|
||||
AlertFormValues,
|
||||
} from "@/app/(prowler)/alerts/_types/alert-form";
|
||||
import { buildFindingsFilterChips } from "@/components/findings/findings-filters.utils";
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import { ToastAction, useToast } from "@/components/ui";
|
||||
import type { ScanEntity } from "@/types";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
const DISABLED_FILTER_TOOLTIP =
|
||||
"Apply at least one Findings filter to create an alert from filters.";
|
||||
|
||||
interface SeedFromFindingsButtonProps {
|
||||
filterBag: AlertsFilterBag;
|
||||
providers?: ProviderProps[];
|
||||
scans?: Array<{ [scanId: string]: ScanEntity }>;
|
||||
uniqueRegions?: string[];
|
||||
uniqueServices?: string[];
|
||||
uniqueResourceTypes?: string[];
|
||||
uniqueCategories?: string[];
|
||||
uniqueGroups?: string[];
|
||||
className?: string;
|
||||
size?: "sm" | "default";
|
||||
defaultName?: string;
|
||||
}
|
||||
|
||||
const toChipFilterMap = (
|
||||
filterBag: AlertsFilterBag,
|
||||
): Record<string, string[]> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(filterBag)
|
||||
.filter(([key]) => key.startsWith("filter["))
|
||||
.map(([key, value]) => [
|
||||
key,
|
||||
(Array.isArray(value) ? value : value.split(","))
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
])
|
||||
.filter(([, values]) => values.length > 0),
|
||||
);
|
||||
|
||||
export const SeedFromFindingsButton = ({
|
||||
filterBag,
|
||||
providers = [],
|
||||
scans = [],
|
||||
uniqueRegions = [],
|
||||
uniqueServices = [],
|
||||
uniqueResourceTypes = [],
|
||||
uniqueCategories = [],
|
||||
uniqueGroups = [],
|
||||
className,
|
||||
size = "sm",
|
||||
defaultName = "Findings filter alert",
|
||||
}: SeedFromFindingsButtonProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const canSeedFromFilters = canSeedAlertFromFindingsFilters(filterBag);
|
||||
const portableFilterBag = toPortableAlertFilterBag(filterBag);
|
||||
const selectedFindingsFilterChips = buildFindingsFilterChips(
|
||||
toChipFilterMap(filterBag),
|
||||
{ providers, scans, includeMuted: true },
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!canSeedFromFilters) return;
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const submitAlert = async (
|
||||
values: AlertFormValues,
|
||||
advancedCondition: AlertCondition | null,
|
||||
): Promise<AlertFormSubmitResult> => {
|
||||
const result = await createAlert(toAlertPayload(values, advancedCondition));
|
||||
if (!result.ok) return { ok: false, error: result.error.detail };
|
||||
toast({
|
||||
title: "Alert created",
|
||||
description: result.data.data.attributes.name,
|
||||
action: (
|
||||
<ToastAction altText="View alerts" asChild>
|
||||
<Link href="/alerts">View Alerts</Link>
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
router.refresh();
|
||||
return { ok: true, alertId: result.data.data.id };
|
||||
};
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
size={size}
|
||||
variant="default"
|
||||
onClick={handleClick}
|
||||
disabled={!canSeedFromFilters}
|
||||
className={className}
|
||||
>
|
||||
<BellPlusIcon size={14} />
|
||||
Create Alert
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (canSeedFromFilters) {
|
||||
return (
|
||||
<>
|
||||
{button}
|
||||
<AlertFormModal
|
||||
open={modalOpen}
|
||||
defaultFrequency={ALERT_TRIGGER_KINDS.AFTER_SCAN}
|
||||
providers={providers}
|
||||
uniqueRegions={uniqueRegions}
|
||||
uniqueServices={uniqueServices}
|
||||
uniqueResourceTypes={uniqueResourceTypes}
|
||||
uniqueCategories={uniqueCategories}
|
||||
uniqueGroups={uniqueGroups}
|
||||
initialFindingsFilters={portableFilterBag}
|
||||
selectedFindingsFilterChips={selectedFindingsFilterChips}
|
||||
defaultName={defaultName}
|
||||
onOpenChange={setModalOpen}
|
||||
onSubmit={submitAlert}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="inline-flex"
|
||||
tabIndex={0}
|
||||
title={DISABLED_FILTER_TOOLTIP}
|
||||
>
|
||||
{button}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{DISABLED_FILTER_TOOLTIP}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,402 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ALERT_AGGREGATE_OPS,
|
||||
ALERT_BOOLEAN_OPS,
|
||||
ALERT_TRIGGER_KINDS,
|
||||
type AlertCondition,
|
||||
type AlertLeafFilter,
|
||||
type AlertRule,
|
||||
} from "@/app/(prowler)/alerts/_types";
|
||||
|
||||
import type { AlertFormValues } from "../../_types/alert-form";
|
||||
import {
|
||||
buildAlertCondition,
|
||||
getAlertFormDefaults,
|
||||
getAlertFormDefaultsFromFindingsFilters,
|
||||
getFindingsFiltersFromAlertCondition,
|
||||
toAlertPayload,
|
||||
} from "../alert-adapter";
|
||||
|
||||
const baseValues = {
|
||||
name: " Critical findings ",
|
||||
description: " Notify security ",
|
||||
method: "email",
|
||||
frequency: ALERT_TRIGGER_KINDS.DAILY,
|
||||
filterGroup: {
|
||||
operator: "all",
|
||||
children: [
|
||||
{
|
||||
kind: "filter",
|
||||
field: "checkSeverities",
|
||||
values: ["critical", "high"],
|
||||
},
|
||||
{ kind: "filter", field: "type", values: ["new"] },
|
||||
{ kind: "filter", field: "providers", values: ["aws"] },
|
||||
{ kind: "filter", field: "accounts", values: ["provider-1"] },
|
||||
{ kind: "filter", field: "checks", values: ["iam_user_no_mfa"] },
|
||||
{ kind: "filter", field: "regions", values: ["us-east-1"] },
|
||||
{ kind: "filter", field: "services", values: ["iam"] },
|
||||
{ kind: "filter", field: "categories", values: ["identity-security"] },
|
||||
{ kind: "filter", field: "resourceGroups", values: ["prod"] },
|
||||
{ kind: "filter", field: "resourceTypes", values: ["AWS::IAM::User"] },
|
||||
{
|
||||
kind: "filter",
|
||||
field: "resources",
|
||||
values: ["arn:aws:iam::123:user/alice"],
|
||||
},
|
||||
{ kind: "filter", field: "checkStatuses", values: ["FAIL"] },
|
||||
],
|
||||
},
|
||||
severities: ["critical", "high"],
|
||||
deltas: ["new"],
|
||||
providerTypes: ["aws"],
|
||||
providerIds: ["provider-1"],
|
||||
checkIds: ["iam_user_no_mfa"],
|
||||
categories: ["identity-security"],
|
||||
regions: ["us-east-1"],
|
||||
services: ["iam"],
|
||||
resourceGroups: ["prod"],
|
||||
findingGroupIds: [],
|
||||
resourceTypes: ["AWS::IAM::User"],
|
||||
recipientEmails: [" Security@Example.COM ", "ops@example.com"],
|
||||
enabled: true,
|
||||
} satisfies AlertFormValues;
|
||||
|
||||
const advancedCondition: AlertCondition = {
|
||||
op: "not",
|
||||
child: { op: ALERT_AGGREGATE_OPS.ANY, filter: { severity: ["critical"] } },
|
||||
};
|
||||
|
||||
const countFilter = (filter: AlertLeafFilter) => ({
|
||||
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
|
||||
filter,
|
||||
value: 1,
|
||||
});
|
||||
|
||||
const existingRule = {
|
||||
id: "alert-1",
|
||||
type: "alert-rules",
|
||||
attributes: {
|
||||
name: "Existing alert",
|
||||
description: "Existing description",
|
||||
enabled: false,
|
||||
trigger: ALERT_TRIGGER_KINDS.BOTH,
|
||||
condition: {
|
||||
op: ALERT_AGGREGATE_OPS.ANY,
|
||||
filter: { severity: ["medium", "low"] },
|
||||
},
|
||||
schema_version: 1,
|
||||
recipient_emails: ["alerts@example.com"],
|
||||
inserted_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
} satisfies AlertRule;
|
||||
|
||||
describe("simple alert adapter", () => {
|
||||
describe("payload mapping", () => {
|
||||
it("should map simple form values to the existing create payload contract", () => {
|
||||
// Given / When
|
||||
const payload = toAlertPayload(baseValues);
|
||||
|
||||
// Then
|
||||
expect(payload).toEqual({
|
||||
name: "Critical findings",
|
||||
description: "Notify security",
|
||||
enabled: true,
|
||||
trigger: ALERT_TRIGGER_KINDS.DAILY,
|
||||
condition: {
|
||||
op: ALERT_BOOLEAN_OPS.AND,
|
||||
children: [
|
||||
countFilter({ severity: ["critical", "high"] }),
|
||||
countFilter({ delta: ["new"] }),
|
||||
countFilter({ provider_type: ["aws"] }),
|
||||
countFilter({ provider_id: ["provider-1"] }),
|
||||
countFilter({ check_id: ["iam_user_no_mfa"] }),
|
||||
countFilter({ resource_regions: ["us-east-1"] }),
|
||||
countFilter({ resource_services: ["iam"] }),
|
||||
countFilter({ categories: ["identity-security"] }),
|
||||
countFilter({ resource_groups: ["prod"] }),
|
||||
countFilter({ resource_types: ["AWS::IAM::User"] }),
|
||||
countFilter({ resource_uid: ["arn:aws:iam::123:user/alice"] }),
|
||||
],
|
||||
},
|
||||
recipientEmails: ["security@example.com", "ops@example.com"],
|
||||
});
|
||||
expect(payload).not.toHaveProperty("method");
|
||||
});
|
||||
|
||||
it("should normalize an edited alert to a simple condition instead of preserving an advanced condition", () => {
|
||||
// Given / When
|
||||
const payload = toAlertPayload(baseValues, advancedCondition);
|
||||
|
||||
// Then
|
||||
expect(payload.condition).not.toBe(advancedCondition);
|
||||
expect(payload.condition).toEqual(
|
||||
expect.objectContaining({
|
||||
op: ALERT_BOOLEAN_OPS.AND,
|
||||
}),
|
||||
);
|
||||
expect(payload.trigger).toBe(ALERT_TRIGGER_KINDS.DAILY);
|
||||
});
|
||||
|
||||
it("should build supported Findings-equivalent filters without Date or Status", () => {
|
||||
// Given / When
|
||||
const condition = buildAlertCondition({
|
||||
operator: "all",
|
||||
children: [
|
||||
{ kind: "filter", field: "providers", values: ["gcp"] },
|
||||
{ kind: "filter", field: "accounts", values: ["provider-2"] },
|
||||
{ kind: "filter", field: "checkStatuses", values: ["FAIL"] },
|
||||
{ kind: "filter", field: "checkSeverities", values: ["medium"] },
|
||||
{ kind: "filter", field: "resources", values: ["resource-1"] },
|
||||
{ kind: "filter", field: "regions", values: ["global"] },
|
||||
{ kind: "filter", field: "services", values: ["compute"] },
|
||||
{ kind: "filter", field: "categories", values: ["forensics"] },
|
||||
{ kind: "filter", field: "resourceGroups", values: ["prod"] },
|
||||
{ kind: "filter", field: "type", values: ["new"] },
|
||||
{
|
||||
kind: "group",
|
||||
operator: "any",
|
||||
children: [
|
||||
{ kind: "filter", field: "checks", values: ["gcp_check"] },
|
||||
{ kind: "filter", field: "regions", values: ["europe-west1"] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(condition).toEqual({
|
||||
op: ALERT_BOOLEAN_OPS.AND,
|
||||
children: [
|
||||
countFilter({ provider_type: ["gcp"] }),
|
||||
countFilter({ provider_id: ["provider-2"] }),
|
||||
countFilter({ severity: ["medium"] }),
|
||||
countFilter({ resource_uid: ["resource-1"] }),
|
||||
countFilter({ resource_regions: ["global"] }),
|
||||
countFilter({ resource_services: ["compute"] }),
|
||||
countFilter({ categories: ["forensics"] }),
|
||||
countFilter({ resource_groups: ["prod"] }),
|
||||
countFilter({ delta: ["new"] }),
|
||||
{
|
||||
op: ALERT_BOOLEAN_OPS.OR,
|
||||
children: [
|
||||
countFilter({ check_id: ["gcp_check"] }),
|
||||
countFilter({ resource_regions: ["europe-west1"] }),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(condition)).not.toContain("status");
|
||||
expect(JSON.stringify(condition)).not.toContain("muted");
|
||||
expect(JSON.stringify(condition)).not.toContain("inserted_at");
|
||||
});
|
||||
|
||||
it("should prefill modal defaults from active Findings filters and drop unsupported fields", () => {
|
||||
// Given / When
|
||||
const defaults = getAlertFormDefaultsFromFindingsFilters({
|
||||
"filter[provider_type__in]": "aws,gcp",
|
||||
"filter[provider_id__in]": "provider-1",
|
||||
"filter[severity__in]": "critical,high",
|
||||
"filter[delta__in]": "new",
|
||||
"filter[region__in]": "us-east-1",
|
||||
"filter[service__in]": "iam",
|
||||
"filter[category__in]": "identity-security",
|
||||
"filter[resource_groups__in]": "prod",
|
||||
"filter[check_id__in]": "iam_user_no_mfa",
|
||||
"filter[finding_group_id]": "finding-group-1",
|
||||
"filter[resource_uid__in]": "arn:aws:iam::123:user/alice",
|
||||
"filter[status__in]": "FAIL",
|
||||
"filter[resource_type__in]": "AWS::IAM::User",
|
||||
"filter[inserted_at]": "2026-01-01",
|
||||
"filter[muted]": "false",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(defaults.filterGroup.children).toEqual([
|
||||
{ kind: "filter", field: "providers", values: ["aws", "gcp"] },
|
||||
{ kind: "filter", field: "accounts", values: ["provider-1"] },
|
||||
{
|
||||
kind: "filter",
|
||||
field: "checkSeverities",
|
||||
values: ["critical", "high"],
|
||||
},
|
||||
{ kind: "filter", field: "type", values: ["new"] },
|
||||
{ kind: "filter", field: "regions", values: ["us-east-1"] },
|
||||
{ kind: "filter", field: "services", values: ["iam"] },
|
||||
{
|
||||
kind: "filter",
|
||||
field: "categories",
|
||||
values: ["identity-security"],
|
||||
},
|
||||
{ kind: "filter", field: "resourceGroups", values: ["prod"] },
|
||||
{ kind: "filter", field: "checks", values: ["iam_user_no_mfa"] },
|
||||
{
|
||||
kind: "filter",
|
||||
field: "findingGroups",
|
||||
values: ["finding-group-1"],
|
||||
},
|
||||
{
|
||||
kind: "filter",
|
||||
field: "resourceTypes",
|
||||
values: ["AWS::IAM::User"],
|
||||
},
|
||||
{
|
||||
kind: "filter",
|
||||
field: "resources",
|
||||
values: ["arn:aws:iam::123:user/alice"],
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(defaults.filterGroup)).not.toContain("inserted_at");
|
||||
expect(JSON.stringify(defaults.filterGroup)).not.toContain("status");
|
||||
expect(JSON.stringify(defaults.filterGroup)).not.toContain("muted");
|
||||
});
|
||||
|
||||
it("should preserve finding group filters when seeding an alert from findings", () => {
|
||||
// Given
|
||||
const defaults = getAlertFormDefaultsFromFindingsFilters({
|
||||
"filter[finding_group_id]": "group-1",
|
||||
});
|
||||
|
||||
// When
|
||||
const payload = toAlertPayload({
|
||||
...baseValues,
|
||||
filterGroup: defaults.filterGroup,
|
||||
findingGroupIds: defaults.findingGroupIds,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(payload.condition).toEqual(
|
||||
countFilter({ finding_group_id: ["group-1"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep ALL type as an unbounded delta while NEW maps to delta=new", () => {
|
||||
// Given / When
|
||||
const condition = buildAlertCondition({
|
||||
operator: "any",
|
||||
children: [
|
||||
{ kind: "filter", field: "type", values: ["all"] },
|
||||
{ kind: "filter", field: "type", values: ["new"] },
|
||||
],
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(condition).toEqual({
|
||||
...countFilter({ delta: ["new"] }),
|
||||
});
|
||||
});
|
||||
|
||||
it("should build a broad threshold-one condition when no portable filter remains", () => {
|
||||
// Given / When
|
||||
const condition = buildAlertCondition({
|
||||
operator: "all",
|
||||
children: [],
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(condition).toEqual(
|
||||
countFilter({
|
||||
severity: ["critical", "high", "medium", "low", "informational"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit defaults", () => {
|
||||
it("should hydrate simple defaults from an existing severity-only alert", () => {
|
||||
// Given / When
|
||||
const defaults = getAlertFormDefaults(existingRule);
|
||||
|
||||
// Then
|
||||
expect(defaults).toEqual({
|
||||
name: "Existing alert",
|
||||
description: "Existing description",
|
||||
method: "email",
|
||||
frequency: ALERT_TRIGGER_KINDS.BOTH,
|
||||
filterGroup: {
|
||||
operator: "all",
|
||||
children: [
|
||||
{
|
||||
kind: "filter",
|
||||
field: "checkSeverities",
|
||||
values: ["medium", "low"],
|
||||
},
|
||||
],
|
||||
},
|
||||
severities: ["medium", "low"],
|
||||
deltas: [],
|
||||
providerTypes: [],
|
||||
providerIds: [],
|
||||
checkIds: [],
|
||||
categories: [],
|
||||
regions: [],
|
||||
services: [],
|
||||
resourceGroups: [],
|
||||
findingGroupIds: [],
|
||||
resourceTypes: [],
|
||||
recipientEmails: ["alerts@example.com"],
|
||||
enabled: false,
|
||||
advancedCondition: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should extract supported filters from advanced conditions and allow simple edits", () => {
|
||||
// Given
|
||||
const alert = {
|
||||
...existingRule,
|
||||
attributes: {
|
||||
...existingRule.attributes,
|
||||
condition: {
|
||||
op: ALERT_BOOLEAN_OPS.NOT,
|
||||
child: {
|
||||
op: ALERT_BOOLEAN_OPS.AND,
|
||||
children: [
|
||||
countFilter({ severity: ["critical"] }),
|
||||
countFilter({ provider_type: ["aws"] }),
|
||||
countFilter({ status: ["FAIL"] } as AlertLeafFilter),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies AlertRule;
|
||||
|
||||
// When
|
||||
const defaults = getAlertFormDefaults(alert);
|
||||
|
||||
// Then
|
||||
expect(defaults.filterGroup.children).toEqual([
|
||||
{
|
||||
kind: "filter",
|
||||
field: "checkSeverities",
|
||||
values: ["critical"],
|
||||
},
|
||||
{ kind: "filter", field: "providers", values: ["aws"] },
|
||||
]);
|
||||
expect(defaults.advancedCondition).toBeNull();
|
||||
});
|
||||
|
||||
it("should expose every editable alert condition as pending Findings filters", () => {
|
||||
// Given
|
||||
const condition = {
|
||||
op: ALERT_BOOLEAN_OPS.AND,
|
||||
children: [
|
||||
countFilter({ check_id: ["iam_user_no_mfa"] }),
|
||||
countFilter({ resource_uid: ["arn:aws:iam::123:user/alice"] }),
|
||||
countFilter({ finding_group_id: ["finding-group-1"] }),
|
||||
],
|
||||
} satisfies AlertCondition;
|
||||
|
||||
// When
|
||||
const filters = getFindingsFiltersFromAlertCondition(condition);
|
||||
|
||||
// Then
|
||||
expect(filters).toEqual({
|
||||
"filter[check_id__in]": ["iam_user_no_mfa"],
|
||||
"filter[resource_uid__in]": ["arn:aws:iam::123:user/alice"],
|
||||
"filter[finding_group_id]": ["finding-group-1"],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ALERT_ERROR_CODES } from "../../_types";
|
||||
import {
|
||||
buildSuccessResult,
|
||||
buildUnexpectedError,
|
||||
isThrottled,
|
||||
mapJsonApiErrorToAction,
|
||||
} from "../error-mapping";
|
||||
|
||||
describe("mapJsonApiErrorToAction", () => {
|
||||
it("maps a JSON:API validation error with a known code", () => {
|
||||
const error = mapJsonApiErrorToAction(
|
||||
400,
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
code: "unknown_filter_field",
|
||||
detail: "Unknown filter field 'foo'.",
|
||||
source: { pointer: "/data/attributes/condition/filter/foo" },
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
expect(error.code).toBe(ALERT_ERROR_CODES.UNKNOWN_FILTER_FIELD);
|
||||
expect(error.detail).toBe("Unknown filter field 'foo'.");
|
||||
expect(error.source?.pointer).toContain("foo");
|
||||
expect(error.status).toBe(400);
|
||||
});
|
||||
|
||||
it("falls back to status-based code when API code is unknown", () => {
|
||||
const error = mapJsonApiErrorToAction(
|
||||
404,
|
||||
{ errors: [{ detail: "Not found." }] },
|
||||
null,
|
||||
);
|
||||
expect(error.code).toBe(ALERT_ERROR_CODES.NOT_FOUND);
|
||||
});
|
||||
|
||||
it("parses Retry-After in seconds for throttled responses", () => {
|
||||
const error = mapJsonApiErrorToAction(429, null, "42");
|
||||
expect(error.code).toBe(ALERT_ERROR_CODES.THROTTLED);
|
||||
expect(error.retryAfterSeconds).toBe(42);
|
||||
});
|
||||
|
||||
it("collects seeding warnings from meta", () => {
|
||||
const error = mapJsonApiErrorToAction(
|
||||
400,
|
||||
{
|
||||
errors: [{ code: "unknown_operator", detail: "bad op" }],
|
||||
meta: { warnings: ["non_portable_date_filter", "garbage_warning"] },
|
||||
},
|
||||
null,
|
||||
);
|
||||
expect(error.warnings).toEqual(["non_portable_date_filter"]);
|
||||
});
|
||||
|
||||
it("returns UNKNOWN with status fallback for unrecognised 5xx", () => {
|
||||
const error = mapJsonApiErrorToAction(500, null, null);
|
||||
expect(error.code).toBe(ALERT_ERROR_CODES.UNKNOWN);
|
||||
expect(error.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSuccessResult", () => {
|
||||
it("returns ok=true with no warnings when meta has none", () => {
|
||||
const result = buildSuccessResult({ id: "1" }, null);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({ id: "1" });
|
||||
expect(result.warnings).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("filters meta.warnings to known seeding warnings", () => {
|
||||
const result = buildSuccessResult(
|
||||
{ id: "1" },
|
||||
{ meta: { warnings: ["pagination_not_supported", "totally_made_up"] } },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.warnings).toEqual(["pagination_not_supported"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isThrottled", () => {
|
||||
it("identifies a throttled action result", () => {
|
||||
const result = {
|
||||
ok: false as const,
|
||||
error: buildUnexpectedError(),
|
||||
};
|
||||
expect(isThrottled(result)).toBe(false);
|
||||
|
||||
const throttled = {
|
||||
ok: false as const,
|
||||
error: { code: ALERT_ERROR_CODES.THROTTLED, detail: "x" },
|
||||
};
|
||||
expect(isThrottled(throttled)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
canSeedAlertFromFindingsFilters,
|
||||
toPortableAlertFilterBag,
|
||||
} from "../seeding";
|
||||
|
||||
describe("canSeedAlertFromFindingsFilters", () => {
|
||||
it("should accept status, muted, and scan filters as real Findings filters", () => {
|
||||
// Given
|
||||
const filterBag = {
|
||||
"filter[status__in]": "FAIL",
|
||||
"filter[muted]": "false",
|
||||
"filter[scan__in]": "scan-1",
|
||||
};
|
||||
|
||||
// When
|
||||
const result = canSeedAlertFromFindingsFilters(filterBag);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject sort and pagination without a real filter", () => {
|
||||
// Given
|
||||
const filterBag = {
|
||||
sort: "-inserted_at",
|
||||
page: "2",
|
||||
pageSize: "50",
|
||||
};
|
||||
|
||||
// When
|
||||
const result = canSeedAlertFromFindingsFilters(filterBag);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should accept finding group id filters because the backend treats them as portable", () => {
|
||||
// Given
|
||||
const filterBag = {
|
||||
"filter[finding_group_id]": "group-1",
|
||||
};
|
||||
|
||||
// When
|
||||
const result = canSeedAlertFromFindingsFilters(filterBag);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept at least one supported portable finding filter", () => {
|
||||
// Given
|
||||
const filterBag = {
|
||||
"filter[status__in]": "FAIL",
|
||||
"filter[severity__in]": "critical,high",
|
||||
};
|
||||
|
||||
// When
|
||||
const result = canSeedAlertFromFindingsFilters(filterBag);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toPortableAlertFilterBag", () => {
|
||||
it("should keep backend-compatible filters and drop UI-only filters", () => {
|
||||
// Given
|
||||
const filterBag = {
|
||||
"filter[status__in]": "FAIL",
|
||||
"filter[muted]": "false",
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[severity__in]": "critical,high",
|
||||
"filter[region__in]": "us-east-1",
|
||||
sort: "-inserted_at",
|
||||
page: "2",
|
||||
};
|
||||
|
||||
// When
|
||||
const result = toPortableAlertFilterBag(filterBag);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
"filter[severity__in]": "critical,high",
|
||||
"filter[region__in]": "us-east-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return an empty bag when only unsupported filters are selected", () => {
|
||||
// Given
|
||||
const filterBag = {
|
||||
"filter[status__in]": "FAIL",
|
||||
"filter[muted]": "false",
|
||||
"filter[scan__in]": "scan-1",
|
||||
};
|
||||
|
||||
// When
|
||||
const result = toPortableAlertFilterBag(filterBag);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,634 @@
|
||||
import type { AlertPayload } from "@/app/(prowler)/alerts/_actions/alerts";
|
||||
import {
|
||||
ALERT_AGGREGATE_OPS,
|
||||
ALERT_BOOLEAN_OPS,
|
||||
ALERT_DELTA_VALUES,
|
||||
ALERT_SEVERITY_VALUES,
|
||||
ALERT_TRIGGER_KINDS,
|
||||
type AlertCondition,
|
||||
type AlertDelta,
|
||||
type AlertLeafFilter,
|
||||
type AlertProviderType,
|
||||
type AlertRule,
|
||||
type AlertSeverity,
|
||||
} from "@/app/(prowler)/alerts/_types";
|
||||
|
||||
import {
|
||||
ALERT_FILTER_FIELDS,
|
||||
ALERT_FILTER_OPERATORS,
|
||||
ALERT_NOTIFICATION_METHODS,
|
||||
type AlertFormDefaults,
|
||||
type AlertFormFilterGroup,
|
||||
type AlertFormFilterItem,
|
||||
type AlertFormFilterNode,
|
||||
type AlertFormFindingFilterBag,
|
||||
type AlertFormValues,
|
||||
} from "../_types/alert-form";
|
||||
|
||||
const DEFAULT_SEVERITIES: AlertSeverity[] = [...ALERT_SEVERITY_VALUES];
|
||||
|
||||
const normalizeStringValues = (values: string[]): string[] =>
|
||||
values.map((value) => value.trim()).filter(Boolean);
|
||||
|
||||
const createFilterNode = (
|
||||
field: AlertFormFilterItem["field"],
|
||||
values: string[],
|
||||
): AlertFormFilterItem => ({ kind: "filter", field, values });
|
||||
|
||||
const getStringArrayFilterValue = (
|
||||
filter: AlertLeafFilter,
|
||||
field: keyof AlertLeafFilter,
|
||||
): string[] => {
|
||||
const value = filter[field];
|
||||
return Array.isArray(value) ? value : [];
|
||||
};
|
||||
|
||||
const normalizeRecipientEmails = (emails: string[]): string[] =>
|
||||
emails
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.filter((email) => email.length > 0);
|
||||
|
||||
const filterItemToLeafFilter = (
|
||||
item: AlertFormFilterItem,
|
||||
): AlertLeafFilter | null => {
|
||||
const normalized = normalizeStringValues(item.values);
|
||||
if (normalized.length === 0) return null;
|
||||
|
||||
switch (item.field) {
|
||||
case ALERT_FILTER_FIELDS.PROVIDERS:
|
||||
return { provider_type: normalized as AlertProviderType[] };
|
||||
case ALERT_FILTER_FIELDS.ACCOUNTS:
|
||||
return { provider_id: normalized };
|
||||
case ALERT_FILTER_FIELDS.CHECK_SEVERITIES:
|
||||
return { severity: normalized as AlertSeverity[] };
|
||||
case ALERT_FILTER_FIELDS.RESOURCES:
|
||||
return { resource_uid: normalized };
|
||||
case ALERT_FILTER_FIELDS.RESOURCE_TYPES:
|
||||
return { resource_types: normalized };
|
||||
case ALERT_FILTER_FIELDS.REGIONS:
|
||||
return { resource_regions: normalized };
|
||||
case ALERT_FILTER_FIELDS.SERVICES:
|
||||
return { resource_services: normalized };
|
||||
case ALERT_FILTER_FIELDS.CATEGORIES:
|
||||
return { categories: normalized };
|
||||
case ALERT_FILTER_FIELDS.RESOURCE_GROUPS:
|
||||
return { resource_groups: normalized };
|
||||
case ALERT_FILTER_FIELDS.FINDING_GROUPS:
|
||||
return { finding_group_id: normalized };
|
||||
case ALERT_FILTER_FIELDS.TYPE: {
|
||||
const deltas = normalized.filter((value) =>
|
||||
ALERT_DELTA_VALUES.includes(value as AlertDelta),
|
||||
) as AlertDelta[];
|
||||
return deltas.length > 0 ? { delta: deltas } : null;
|
||||
}
|
||||
case ALERT_FILTER_FIELDS.CHECKS:
|
||||
return { check_id: normalized };
|
||||
case ALERT_FILTER_FIELDS.CHECK_STATUSES:
|
||||
case ALERT_FILTER_FIELDS.ACCOUNT_TAGS:
|
||||
case ALERT_FILTER_FIELDS.DATA_DATE_WINDOW:
|
||||
case ALERT_FILTER_FIELDS.INCLUDE_MUTED_FINDINGS:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const buildConditionFromNode = (
|
||||
node: AlertFormFilterNode,
|
||||
): AlertCondition | null => {
|
||||
if (node.kind === "filter") {
|
||||
const filter = filterItemToLeafFilter(node);
|
||||
return filter
|
||||
? { op: ALERT_AGGREGATE_OPS.COUNT_GTE, filter, value: 1 }
|
||||
: null;
|
||||
}
|
||||
|
||||
return buildConditionFromGroup(node);
|
||||
};
|
||||
|
||||
const buildConditionFromGroup = (
|
||||
group: AlertFormFilterGroup,
|
||||
): AlertCondition => {
|
||||
const children = group.children
|
||||
.map(buildConditionFromNode)
|
||||
.filter((condition): condition is AlertCondition => condition !== null);
|
||||
|
||||
if (children.length === 0) {
|
||||
return {
|
||||
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
|
||||
filter: { severity: DEFAULT_SEVERITIES },
|
||||
value: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (children.length === 1) return children[0];
|
||||
|
||||
return group.operator === ALERT_FILTER_OPERATORS.ANY
|
||||
? { op: ALERT_BOOLEAN_OPS.OR, children }
|
||||
: { op: ALERT_BOOLEAN_OPS.AND, children };
|
||||
};
|
||||
|
||||
const legacyValuesToFilterGroup = (
|
||||
values: Partial<AlertFormValues>,
|
||||
): AlertFormFilterGroup => ({
|
||||
operator: ALERT_FILTER_OPERATORS.ALL,
|
||||
children: [
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.CHECK_SEVERITIES,
|
||||
values.severities ?? DEFAULT_SEVERITIES,
|
||||
),
|
||||
createFilterNode(ALERT_FILTER_FIELDS.TYPE, values.deltas ?? []),
|
||||
createFilterNode(ALERT_FILTER_FIELDS.PROVIDERS, values.providerTypes ?? []),
|
||||
createFilterNode(ALERT_FILTER_FIELDS.ACCOUNTS, values.providerIds ?? []),
|
||||
createFilterNode(ALERT_FILTER_FIELDS.CHECKS, values.checkIds ?? []),
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.RESOURCE_TYPES,
|
||||
values.resourceTypes ?? [],
|
||||
),
|
||||
createFilterNode(ALERT_FILTER_FIELDS.REGIONS, values.regions ?? []),
|
||||
createFilterNode(ALERT_FILTER_FIELDS.SERVICES, values.services ?? []),
|
||||
createFilterNode(ALERT_FILTER_FIELDS.CATEGORIES, values.categories ?? []),
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.RESOURCE_GROUPS,
|
||||
values.resourceGroups ?? [],
|
||||
),
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.FINDING_GROUPS,
|
||||
values.findingGroupIds ?? [],
|
||||
),
|
||||
createFilterNode(ALERT_FILTER_FIELDS.RESOURCES, []),
|
||||
],
|
||||
});
|
||||
|
||||
const isAlertFormFilterGroup = (
|
||||
values: AlertFormFilterGroup | Partial<AlertFormValues>,
|
||||
): values is AlertFormFilterGroup =>
|
||||
"children" in values && "operator" in values;
|
||||
|
||||
export const buildAlertCondition = (
|
||||
values: AlertFormFilterGroup | Partial<AlertFormValues>,
|
||||
): AlertCondition => {
|
||||
const group = isAlertFormFilterGroup(values)
|
||||
? values
|
||||
: (values.filterGroup ?? legacyValuesToFilterGroup(values));
|
||||
return buildConditionFromGroup(group);
|
||||
};
|
||||
|
||||
const ALERT_FILTER_FIELDS_ALLOWED = new Set<keyof AlertLeafFilter>([
|
||||
"severity",
|
||||
"delta",
|
||||
"provider_type",
|
||||
"provider_id",
|
||||
"check_id",
|
||||
"resource_regions",
|
||||
"resource_services",
|
||||
"resource_types",
|
||||
"categories",
|
||||
"resource_groups",
|
||||
"finding_group_id",
|
||||
"resource_uid",
|
||||
]);
|
||||
|
||||
const isAlertFormFilter = (condition: AlertCondition): boolean => {
|
||||
if (
|
||||
condition.op !== ALERT_AGGREGATE_OPS.ANY &&
|
||||
condition.op !== ALERT_AGGREGATE_OPS.COUNT_GTE
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (condition.op === ALERT_AGGREGATE_OPS.COUNT_GTE && condition.value !== 1) {
|
||||
return false;
|
||||
}
|
||||
return Object.keys(condition.filter).every((field) =>
|
||||
ALERT_FILTER_FIELDS_ALLOWED.has(field as keyof AlertLeafFilter),
|
||||
);
|
||||
};
|
||||
|
||||
const mergeLeafFilters = (filters: AlertLeafFilter[]): AlertLeafFilter => {
|
||||
const merged: AlertLeafFilter = {};
|
||||
|
||||
filters.forEach((filter) => {
|
||||
Object.entries(filter).forEach(([field, value]) => {
|
||||
if (!Array.isArray(value)) return;
|
||||
const key = field as keyof AlertLeafFilter;
|
||||
const current = Array.isArray(merged[key]) ? merged[key] : [];
|
||||
merged[key] = Array.from(new Set([...current, ...value]));
|
||||
});
|
||||
});
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
const getSimpleFilterFromCondition = (
|
||||
condition: AlertCondition,
|
||||
): AlertLeafFilter | null => {
|
||||
if (
|
||||
isAlertFormFilter(condition) &&
|
||||
(condition.op === ALERT_AGGREGATE_OPS.ANY ||
|
||||
condition.op === ALERT_AGGREGATE_OPS.COUNT_GTE)
|
||||
) {
|
||||
return condition.filter;
|
||||
}
|
||||
|
||||
if (condition.op !== ALERT_BOOLEAN_OPS.AND) return null;
|
||||
|
||||
const childFilters = condition.children.map((child) =>
|
||||
isAlertFormFilter(child) &&
|
||||
(child.op === ALERT_AGGREGATE_OPS.ANY ||
|
||||
child.op === ALERT_AGGREGATE_OPS.COUNT_GTE)
|
||||
? child.filter
|
||||
: null,
|
||||
);
|
||||
|
||||
if (childFilters.some((filter) => filter === null)) return null;
|
||||
|
||||
return mergeLeafFilters(childFilters as AlertLeafFilter[]);
|
||||
};
|
||||
|
||||
const pickAlertFormFilterFields = (
|
||||
filter: AlertLeafFilter,
|
||||
): AlertLeafFilter | null => {
|
||||
const simpleFilter: AlertLeafFilter = {};
|
||||
|
||||
Object.entries(filter).forEach(([field, value]) => {
|
||||
if (
|
||||
!ALERT_FILTER_FIELDS_ALLOWED.has(field as keyof AlertLeafFilter) ||
|
||||
!Array.isArray(value) ||
|
||||
value.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
simpleFilter[field as keyof AlertLeafFilter] = value;
|
||||
});
|
||||
|
||||
return Object.keys(simpleFilter).length > 0 ? simpleFilter : null;
|
||||
};
|
||||
|
||||
const getPortableFiltersFromCondition = (
|
||||
condition: AlertCondition,
|
||||
): AlertLeafFilter[] => {
|
||||
if ("filter" in condition) {
|
||||
const simpleFilter = pickAlertFormFilterFields(condition.filter);
|
||||
return simpleFilter ? [simpleFilter] : [];
|
||||
}
|
||||
|
||||
if ("child" in condition) {
|
||||
return getPortableFiltersFromCondition(condition.child);
|
||||
}
|
||||
|
||||
return condition.children.flatMap(getPortableFiltersFromCondition);
|
||||
};
|
||||
|
||||
const getEditableFilterFromCondition = (
|
||||
condition: AlertCondition,
|
||||
): AlertLeafFilter | null =>
|
||||
getSimpleFilterFromCondition(condition) ??
|
||||
(() => {
|
||||
const portableFilters = getPortableFiltersFromCondition(condition);
|
||||
return portableFilters.length > 0
|
||||
? mergeLeafFilters(portableFilters)
|
||||
: null;
|
||||
})();
|
||||
|
||||
const filterToSimpleGroup = (
|
||||
filter: AlertLeafFilter,
|
||||
): AlertFormFilterGroup => ({
|
||||
operator: ALERT_FILTER_OPERATORS.ALL,
|
||||
children: [
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.CHECK_SEVERITIES,
|
||||
getStringArrayFilterValue(filter, "severity"),
|
||||
),
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.TYPE,
|
||||
getStringArrayFilterValue(filter, "delta"),
|
||||
),
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.PROVIDERS,
|
||||
getStringArrayFilterValue(filter, "provider_type"),
|
||||
),
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.ACCOUNTS,
|
||||
getStringArrayFilterValue(filter, "provider_id"),
|
||||
),
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.CHECKS,
|
||||
getStringArrayFilterValue(filter, "check_id"),
|
||||
),
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.REGIONS,
|
||||
getStringArrayFilterValue(filter, "resource_regions"),
|
||||
),
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.SERVICES,
|
||||
getStringArrayFilterValue(filter, "resource_services"),
|
||||
),
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.CATEGORIES,
|
||||
getStringArrayFilterValue(filter, "categories"),
|
||||
),
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.RESOURCE_GROUPS,
|
||||
getStringArrayFilterValue(filter, "resource_groups"),
|
||||
),
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.FINDING_GROUPS,
|
||||
getStringArrayFilterValue(filter, "finding_group_id"),
|
||||
),
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.RESOURCES,
|
||||
getStringArrayFilterValue(filter, "resource_uid"),
|
||||
),
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.RESOURCE_TYPES,
|
||||
getStringArrayFilterValue(filter, "resource_types"),
|
||||
),
|
||||
].filter((node) => node.values.length > 0),
|
||||
});
|
||||
|
||||
export const toAlertPayload = (
|
||||
values: AlertFormValues,
|
||||
_existingCondition?: AlertCondition | null,
|
||||
): AlertPayload => ({
|
||||
name: values.name.trim(),
|
||||
description: values.description.trim(),
|
||||
enabled: values.enabled,
|
||||
trigger: values.frequency,
|
||||
condition: buildAlertCondition(values.filterGroup),
|
||||
recipientEmails: normalizeRecipientEmails(values.recipientEmails),
|
||||
});
|
||||
|
||||
export const getEmptyAlertFormDefaults = (
|
||||
frequency: AlertFormValues["frequency"] = ALERT_TRIGGER_KINDS.AFTER_SCAN,
|
||||
): AlertFormDefaults => ({
|
||||
name: "",
|
||||
description: "",
|
||||
method: ALERT_NOTIFICATION_METHODS.EMAIL,
|
||||
frequency,
|
||||
filterGroup: {
|
||||
operator: ALERT_FILTER_OPERATORS.ALL,
|
||||
children: [
|
||||
createFilterNode(
|
||||
ALERT_FILTER_FIELDS.CHECK_SEVERITIES,
|
||||
DEFAULT_SEVERITIES,
|
||||
),
|
||||
],
|
||||
},
|
||||
severities: DEFAULT_SEVERITIES,
|
||||
deltas: [],
|
||||
providerTypes: [],
|
||||
providerIds: [],
|
||||
checkIds: [],
|
||||
categories: [],
|
||||
regions: [],
|
||||
services: [],
|
||||
resourceGroups: [],
|
||||
findingGroupIds: [],
|
||||
resourceTypes: [],
|
||||
recipientEmails: [],
|
||||
enabled: true,
|
||||
advancedCondition: null,
|
||||
});
|
||||
|
||||
export const getAlertFormDefaults = (alert: AlertRule): AlertFormDefaults => {
|
||||
const simpleFilter = getEditableFilterFromCondition(
|
||||
alert.attributes.condition,
|
||||
);
|
||||
const simpleSeverities = Array.isArray(simpleFilter?.severity)
|
||||
? (simpleFilter.severity as AlertSeverity[])
|
||||
: null;
|
||||
|
||||
return {
|
||||
name: alert.attributes.name,
|
||||
description: alert.attributes.description,
|
||||
method: ALERT_NOTIFICATION_METHODS.EMAIL,
|
||||
frequency: alert.attributes.trigger,
|
||||
filterGroup: simpleFilter
|
||||
? filterToSimpleGroup(simpleFilter)
|
||||
: getEmptyAlertFormDefaults(alert.attributes.trigger).filterGroup,
|
||||
severities: simpleSeverities ?? DEFAULT_SEVERITIES,
|
||||
deltas: (simpleFilter?.delta ?? []) as AlertDelta[],
|
||||
providerTypes: (simpleFilter?.provider_type ?? []) as AlertProviderType[],
|
||||
providerIds: getStringArrayFilterValue(simpleFilter ?? {}, "provider_id"),
|
||||
checkIds: getStringArrayFilterValue(simpleFilter ?? {}, "check_id"),
|
||||
categories: getStringArrayFilterValue(simpleFilter ?? {}, "categories"),
|
||||
regions: getStringArrayFilterValue(simpleFilter ?? {}, "resource_regions"),
|
||||
services: getStringArrayFilterValue(
|
||||
simpleFilter ?? {},
|
||||
"resource_services",
|
||||
),
|
||||
resourceGroups: getStringArrayFilterValue(
|
||||
simpleFilter ?? {},
|
||||
"resource_groups",
|
||||
),
|
||||
findingGroupIds: getStringArrayFilterValue(
|
||||
simpleFilter ?? {},
|
||||
"finding_group_id",
|
||||
),
|
||||
resourceTypes: getStringArrayFilterValue(
|
||||
simpleFilter ?? {},
|
||||
"resource_types",
|
||||
),
|
||||
recipientEmails: alert.attributes.recipient_emails ?? [],
|
||||
enabled: alert.attributes.enabled,
|
||||
advancedCondition: null,
|
||||
};
|
||||
};
|
||||
|
||||
const FINDINGS_FILTER_KEY_TO_SIMPLE_FIELD: Record<
|
||||
string,
|
||||
AlertFormFilterItem["field"]
|
||||
> = {
|
||||
provider_type: ALERT_FILTER_FIELDS.PROVIDERS,
|
||||
provider_type__in: ALERT_FILTER_FIELDS.PROVIDERS,
|
||||
"provider_type.in": ALERT_FILTER_FIELDS.PROVIDERS,
|
||||
provider_id: ALERT_FILTER_FIELDS.ACCOUNTS,
|
||||
provider_id__in: ALERT_FILTER_FIELDS.ACCOUNTS,
|
||||
"provider_id.in": ALERT_FILTER_FIELDS.ACCOUNTS,
|
||||
severity: ALERT_FILTER_FIELDS.CHECK_SEVERITIES,
|
||||
severity__in: ALERT_FILTER_FIELDS.CHECK_SEVERITIES,
|
||||
"severity.in": ALERT_FILTER_FIELDS.CHECK_SEVERITIES,
|
||||
delta: ALERT_FILTER_FIELDS.TYPE,
|
||||
delta__in: ALERT_FILTER_FIELDS.TYPE,
|
||||
"delta.in": ALERT_FILTER_FIELDS.TYPE,
|
||||
region: ALERT_FILTER_FIELDS.REGIONS,
|
||||
region__in: ALERT_FILTER_FIELDS.REGIONS,
|
||||
resource_regions: ALERT_FILTER_FIELDS.REGIONS,
|
||||
resource_regions__in: ALERT_FILTER_FIELDS.REGIONS,
|
||||
"resource_regions.in": ALERT_FILTER_FIELDS.REGIONS,
|
||||
service: ALERT_FILTER_FIELDS.SERVICES,
|
||||
service__in: ALERT_FILTER_FIELDS.SERVICES,
|
||||
resource_services: ALERT_FILTER_FIELDS.SERVICES,
|
||||
resource_services__in: ALERT_FILTER_FIELDS.SERVICES,
|
||||
"resource_services.in": ALERT_FILTER_FIELDS.SERVICES,
|
||||
category: ALERT_FILTER_FIELDS.CATEGORIES,
|
||||
category__in: ALERT_FILTER_FIELDS.CATEGORIES,
|
||||
categories: ALERT_FILTER_FIELDS.CATEGORIES,
|
||||
categories__in: ALERT_FILTER_FIELDS.CATEGORIES,
|
||||
"categories.in": ALERT_FILTER_FIELDS.CATEGORIES,
|
||||
resource_groups: ALERT_FILTER_FIELDS.RESOURCE_GROUPS,
|
||||
resource_groups__in: ALERT_FILTER_FIELDS.RESOURCE_GROUPS,
|
||||
"resource_groups.in": ALERT_FILTER_FIELDS.RESOURCE_GROUPS,
|
||||
finding_group_id: ALERT_FILTER_FIELDS.FINDING_GROUPS,
|
||||
finding_group_id__in: ALERT_FILTER_FIELDS.FINDING_GROUPS,
|
||||
"finding_group_id.in": ALERT_FILTER_FIELDS.FINDING_GROUPS,
|
||||
check_id: ALERT_FILTER_FIELDS.CHECKS,
|
||||
check_id__in: ALERT_FILTER_FIELDS.CHECKS,
|
||||
"check_id.in": ALERT_FILTER_FIELDS.CHECKS,
|
||||
resource_uid: ALERT_FILTER_FIELDS.RESOURCES,
|
||||
resource_uid__in: ALERT_FILTER_FIELDS.RESOURCES,
|
||||
"resource_uid.in": ALERT_FILTER_FIELDS.RESOURCES,
|
||||
resource_type: ALERT_FILTER_FIELDS.RESOURCE_TYPES,
|
||||
resource_type__in: ALERT_FILTER_FIELDS.RESOURCE_TYPES,
|
||||
resource_types: ALERT_FILTER_FIELDS.RESOURCE_TYPES,
|
||||
resource_types__in: ALERT_FILTER_FIELDS.RESOURCE_TYPES,
|
||||
"resource_types.in": ALERT_FILTER_FIELDS.RESOURCE_TYPES,
|
||||
};
|
||||
|
||||
const SIMPLE_FIELD_TO_FINDINGS_FILTER: Partial<
|
||||
Record<keyof AlertLeafFilter, string>
|
||||
> = {
|
||||
provider_type: "filter[provider_type__in]",
|
||||
provider_id: "filter[provider_id__in]",
|
||||
severity: "filter[severity__in]",
|
||||
delta: "filter[delta]",
|
||||
resource_regions: "filter[region__in]",
|
||||
resource_services: "filter[service__in]",
|
||||
resource_types: "filter[resource_type__in]",
|
||||
categories: "filter[category__in]",
|
||||
resource_groups: "filter[resource_groups__in]",
|
||||
check_id: "filter[check_id__in]",
|
||||
finding_group_id: "filter[finding_group_id]",
|
||||
resource_uid: "filter[resource_uid__in]",
|
||||
};
|
||||
|
||||
const unwrapFindingsFilterKey = (rawKey: string): string => {
|
||||
if (rawKey.startsWith("filter[") && rawKey.endsWith("]")) {
|
||||
return rawKey.slice("filter[".length, -1);
|
||||
}
|
||||
|
||||
return rawKey;
|
||||
};
|
||||
|
||||
const splitFindingsFilterValues = (
|
||||
value: AlertFormFindingFilterBag[string],
|
||||
): string[] => {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
return normalizeStringValues(
|
||||
values.flatMap((entry) => String(entry).split(",")),
|
||||
);
|
||||
};
|
||||
|
||||
const uniqueValues = (values: string[]): string[] =>
|
||||
Array.from(new Set(values));
|
||||
|
||||
export const getFindingsFiltersFromAlertCondition = (
|
||||
condition: AlertCondition,
|
||||
): Record<string, string[]> => {
|
||||
if ("filter" in condition) {
|
||||
return Object.entries(condition.filter).reduce<Record<string, string[]>>(
|
||||
(filters, [field, value]) => {
|
||||
const filterKey =
|
||||
SIMPLE_FIELD_TO_FINDINGS_FILTER[field as keyof AlertLeafFilter];
|
||||
if (!filterKey || !Array.isArray(value)) return filters;
|
||||
filters[filterKey] = uniqueValues([
|
||||
...(filters[filterKey] ?? []),
|
||||
...value,
|
||||
]);
|
||||
return filters;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
if ("child" in condition) {
|
||||
return getFindingsFiltersFromAlertCondition(condition.child);
|
||||
}
|
||||
|
||||
return condition.children.reduce<Record<string, string[]>>(
|
||||
(filters, child) => {
|
||||
const childFilters = getFindingsFiltersFromAlertCondition(child);
|
||||
Object.entries(childFilters).forEach(([filterKey, values]) => {
|
||||
filters[filterKey] = uniqueValues([
|
||||
...(filters[filterKey] ?? []),
|
||||
...values,
|
||||
]);
|
||||
});
|
||||
return filters;
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
|
||||
const getFieldValuesFromFindingsFilters = (
|
||||
filterBag: AlertFormFindingFilterBag,
|
||||
): Partial<Record<AlertFormFilterItem["field"], string[]>> => {
|
||||
const fieldValues: Partial<Record<AlertFormFilterItem["field"], string[]>> =
|
||||
{};
|
||||
|
||||
Object.entries(filterBag).forEach(([rawKey, rawValue]) => {
|
||||
const field =
|
||||
FINDINGS_FILTER_KEY_TO_SIMPLE_FIELD[unwrapFindingsFilterKey(rawKey)];
|
||||
if (!field) return;
|
||||
const values = splitFindingsFilterValues(rawValue);
|
||||
if (values.length === 0) return;
|
||||
fieldValues[field] = [...(fieldValues[field] ?? []), ...values];
|
||||
});
|
||||
|
||||
return fieldValues;
|
||||
};
|
||||
|
||||
const FINDINGS_FILTER_FIELD_ORDER = [
|
||||
ALERT_FILTER_FIELDS.PROVIDERS,
|
||||
ALERT_FILTER_FIELDS.ACCOUNTS,
|
||||
ALERT_FILTER_FIELDS.CHECK_SEVERITIES,
|
||||
ALERT_FILTER_FIELDS.TYPE,
|
||||
ALERT_FILTER_FIELDS.REGIONS,
|
||||
ALERT_FILTER_FIELDS.SERVICES,
|
||||
ALERT_FILTER_FIELDS.CATEGORIES,
|
||||
ALERT_FILTER_FIELDS.RESOURCE_GROUPS,
|
||||
ALERT_FILTER_FIELDS.CHECKS,
|
||||
ALERT_FILTER_FIELDS.FINDING_GROUPS,
|
||||
ALERT_FILTER_FIELDS.RESOURCE_TYPES,
|
||||
ALERT_FILTER_FIELDS.RESOURCES,
|
||||
] as const;
|
||||
|
||||
export const getAlertFormDefaultsFromFindingsFilters = (
|
||||
filterBag: AlertFormFindingFilterBag,
|
||||
frequency: AlertFormValues["frequency"] = ALERT_TRIGGER_KINDS.AFTER_SCAN,
|
||||
): AlertFormDefaults => {
|
||||
const fieldValues = getFieldValuesFromFindingsFilters(filterBag);
|
||||
const children = FINDINGS_FILTER_FIELD_ORDER.flatMap((field) => {
|
||||
const values = fieldValues[field] ?? [];
|
||||
return values.length > 0 ? [createFilterNode(field, values)] : [];
|
||||
});
|
||||
const defaults = getEmptyAlertFormDefaults(frequency);
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
filterGroup: {
|
||||
operator: ALERT_FILTER_OPERATORS.ALL,
|
||||
children: children.length > 0 ? children : defaults.filterGroup.children,
|
||||
},
|
||||
severities: (fieldValues[ALERT_FILTER_FIELDS.CHECK_SEVERITIES] ??
|
||||
defaults.severities) as AlertFormValues["severities"],
|
||||
deltas: (fieldValues[ALERT_FILTER_FIELDS.TYPE] ??
|
||||
defaults.deltas) as AlertFormValues["deltas"],
|
||||
providerTypes: (fieldValues[ALERT_FILTER_FIELDS.PROVIDERS] ??
|
||||
defaults.providerTypes) as AlertFormValues["providerTypes"],
|
||||
providerIds:
|
||||
fieldValues[ALERT_FILTER_FIELDS.ACCOUNTS] ?? defaults.providerIds,
|
||||
checkIds: fieldValues[ALERT_FILTER_FIELDS.CHECKS] ?? defaults.checkIds,
|
||||
regions: fieldValues[ALERT_FILTER_FIELDS.REGIONS] ?? defaults.regions,
|
||||
categories:
|
||||
fieldValues[ALERT_FILTER_FIELDS.CATEGORIES] ?? defaults.categories,
|
||||
services: fieldValues[ALERT_FILTER_FIELDS.SERVICES] ?? defaults.services,
|
||||
resourceGroups:
|
||||
fieldValues[ALERT_FILTER_FIELDS.RESOURCE_GROUPS] ??
|
||||
defaults.resourceGroups,
|
||||
findingGroupIds:
|
||||
fieldValues[ALERT_FILTER_FIELDS.FINDING_GROUPS] ??
|
||||
defaults.findingGroupIds,
|
||||
resourceTypes:
|
||||
fieldValues[ALERT_FILTER_FIELDS.RESOURCE_TYPES] ?? defaults.resourceTypes,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
ALERT_DELTA_VALUES,
|
||||
ALERT_PROVIDER_TYPE_VALUES,
|
||||
ALERT_SEVERITY_VALUES,
|
||||
ALERT_TRIGGER_KIND_VALUES,
|
||||
} from "@/app/(prowler)/alerts/_types";
|
||||
|
||||
import {
|
||||
ALERT_FILTER_FIELDS,
|
||||
ALERT_FILTER_OPERATORS,
|
||||
ALERT_NOTIFICATION_METHODS,
|
||||
} from "../_types/alert-form";
|
||||
|
||||
const alertFilterItemSchema = z.object({
|
||||
kind: z.literal("filter"),
|
||||
field: z.enum(Object.values(ALERT_FILTER_FIELDS)),
|
||||
values: z.array(z.string().trim()).default([]),
|
||||
});
|
||||
|
||||
type AlertFormFilterNodeSchema =
|
||||
| z.infer<typeof alertFilterItemSchema>
|
||||
| {
|
||||
kind: "group";
|
||||
operator: (typeof ALERT_FILTER_OPERATORS)[keyof typeof ALERT_FILTER_OPERATORS];
|
||||
children: AlertFormFilterNodeSchema[];
|
||||
};
|
||||
|
||||
const alertFilterNodeSchema: z.ZodType<AlertFormFilterNodeSchema> = z.lazy(() =>
|
||||
z.union([
|
||||
alertFilterItemSchema,
|
||||
z.object({
|
||||
kind: z.literal("group"),
|
||||
operator: z.enum(Object.values(ALERT_FILTER_OPERATORS)),
|
||||
children: z.array(alertFilterNodeSchema),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
export const alertFormSchema = z.object({
|
||||
name: z.string().trim().min(1, { error: "Name is required." }).max(120),
|
||||
description: z.string().trim().max(2000).default(""),
|
||||
method: z.literal(ALERT_NOTIFICATION_METHODS.EMAIL),
|
||||
frequency: z.enum(ALERT_TRIGGER_KIND_VALUES),
|
||||
filterGroup: z.object({
|
||||
operator: z.enum(Object.values(ALERT_FILTER_OPERATORS)),
|
||||
children: z.array(alertFilterNodeSchema).min(1),
|
||||
}),
|
||||
severities: z.array(z.enum(ALERT_SEVERITY_VALUES)).default([]),
|
||||
deltas: z.array(z.enum(ALERT_DELTA_VALUES)).default([]),
|
||||
providerTypes: z.array(z.enum(ALERT_PROVIDER_TYPE_VALUES)).default([]),
|
||||
providerIds: z.array(z.string().trim().min(1)).default([]),
|
||||
checkIds: z.array(z.string().trim().min(1)).default([]),
|
||||
categories: z.array(z.string().trim().min(1)).default([]),
|
||||
regions: z.array(z.string().trim().min(1)).default([]),
|
||||
services: z.array(z.string().trim().min(1)).default([]),
|
||||
resourceGroups: z.array(z.string().trim().min(1)).default([]),
|
||||
findingGroupIds: z.array(z.string().trim().min(1)).default([]),
|
||||
resourceTypes: z.array(z.string().trim().min(1)).default([]),
|
||||
recipientEmails: z
|
||||
.array(z.email({ error: "Enter a valid email address." }))
|
||||
.default([]),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export type AlertFormSchemaValues = z.infer<typeof alertFormSchema>;
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
ALERT_ERROR_CODES,
|
||||
type AlertPublicResponse,
|
||||
type AlertsActionResult,
|
||||
} from "../_types";
|
||||
|
||||
const ALERTS_DISABLED_MESSAGE =
|
||||
"Custom alerts are only available in Prowler Cloud.";
|
||||
|
||||
export const isAlertsEnabled = () =>
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
|
||||
export const buildAlertsDisabledResult = <T>(): AlertsActionResult<T> => ({
|
||||
ok: false,
|
||||
error: {
|
||||
code: ALERT_ERROR_CODES.FORBIDDEN,
|
||||
detail: ALERTS_DISABLED_MESSAGE,
|
||||
status: 403,
|
||||
},
|
||||
});
|
||||
|
||||
export const buildAlertsDisabledPublicResponse = (): AlertPublicResponse => ({
|
||||
state: "network_error",
|
||||
message: ALERTS_DISABLED_MESSAGE,
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
ALERT_ERROR_CODES,
|
||||
ALERT_SEEDING_WARNINGS,
|
||||
type AlertsActionError,
|
||||
type AlertsActionErrorSource,
|
||||
type AlertsActionResult,
|
||||
type AlertSeedingWarning,
|
||||
type AlertsErrorCode,
|
||||
} from "../_types";
|
||||
|
||||
interface JsonApiErrorObject {
|
||||
status?: string | number;
|
||||
code?: string;
|
||||
detail?: string;
|
||||
source?: AlertsActionErrorSource;
|
||||
meta?: { code?: string; warnings?: string[] };
|
||||
}
|
||||
|
||||
interface JsonApiErrorBody {
|
||||
errors?: JsonApiErrorObject[];
|
||||
detail?: string;
|
||||
message?: string;
|
||||
meta?: { warnings?: string[] };
|
||||
}
|
||||
|
||||
const KNOWN_ERROR_CODES = new Set<string>(Object.values(ALERT_ERROR_CODES));
|
||||
const KNOWN_WARNINGS = new Set<string>(Object.values(ALERT_SEEDING_WARNINGS));
|
||||
|
||||
const STATUS_FALLBACK_CODES: Record<number, AlertsErrorCode> = {
|
||||
401: ALERT_ERROR_CODES.FORBIDDEN,
|
||||
403: ALERT_ERROR_CODES.FORBIDDEN,
|
||||
404: ALERT_ERROR_CODES.NOT_FOUND,
|
||||
409: ALERT_ERROR_CODES.CONFLICT,
|
||||
429: ALERT_ERROR_CODES.THROTTLED,
|
||||
};
|
||||
|
||||
const pickCode = (raw: string | undefined): AlertsErrorCode | undefined => {
|
||||
if (!raw) return undefined;
|
||||
return KNOWN_ERROR_CODES.has(raw) ? (raw as AlertsErrorCode) : undefined;
|
||||
};
|
||||
|
||||
const pickWarning = (raw: string): AlertSeedingWarning | undefined =>
|
||||
KNOWN_WARNINGS.has(raw) ? (raw as AlertSeedingWarning) : undefined;
|
||||
|
||||
const collectWarnings = (
|
||||
body: JsonApiErrorBody | null,
|
||||
): AlertSeedingWarning[] =>
|
||||
(body?.meta?.warnings ?? [])
|
||||
.map((w) => pickWarning(w))
|
||||
.filter((w): w is AlertSeedingWarning => w !== undefined);
|
||||
|
||||
const parseRetryAfter = (header: string | null): number | undefined => {
|
||||
if (!header) return undefined;
|
||||
const seconds = Number(header);
|
||||
if (Number.isFinite(seconds) && seconds >= 0) return seconds;
|
||||
const date = Date.parse(header);
|
||||
if (Number.isNaN(date)) return undefined;
|
||||
return Math.max(0, Math.round((date - Date.now()) / 1000));
|
||||
};
|
||||
|
||||
export const mapJsonApiErrorToAction = (
|
||||
status: number,
|
||||
body: JsonApiErrorBody | null,
|
||||
retryAfterHeader: string | null,
|
||||
): AlertsActionError => {
|
||||
const firstError = body?.errors?.[0];
|
||||
const detail =
|
||||
firstError?.detail ||
|
||||
body?.detail ||
|
||||
body?.message ||
|
||||
"Custom alerts request failed.";
|
||||
const apiCode =
|
||||
pickCode(firstError?.code) ||
|
||||
pickCode(firstError?.meta?.code) ||
|
||||
pickCode((body as { code?: string } | null)?.code);
|
||||
const fallback = STATUS_FALLBACK_CODES[status] ?? ALERT_ERROR_CODES.UNKNOWN;
|
||||
const code: AlertsErrorCode = apiCode ?? fallback;
|
||||
const warnings = collectWarnings(body);
|
||||
return {
|
||||
code,
|
||||
detail,
|
||||
source: firstError?.source,
|
||||
status,
|
||||
retryAfterSeconds:
|
||||
code === ALERT_ERROR_CODES.THROTTLED
|
||||
? parseRetryAfter(retryAfterHeader)
|
||||
: undefined,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSuccessResult = <T>(
|
||||
data: T,
|
||||
body: JsonApiErrorBody | null,
|
||||
): AlertsActionResult<T> => {
|
||||
const warnings = collectWarnings(body);
|
||||
return warnings.length > 0
|
||||
? { ok: true, data, warnings }
|
||||
: { ok: true, data };
|
||||
};
|
||||
|
||||
export const isThrottled = (
|
||||
result: AlertsActionResult<unknown>,
|
||||
): result is { ok: false; error: AlertsActionError } =>
|
||||
!result.ok && result.error.code === ALERT_ERROR_CODES.THROTTLED;
|
||||
|
||||
export const buildUnexpectedError = (
|
||||
detail = "Unexpected error.",
|
||||
): AlertsActionError => ({
|
||||
code: ALERT_ERROR_CODES.UNKNOWN,
|
||||
detail,
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { AlertsFilterBag } from "../_types";
|
||||
|
||||
const PORTABLE_FINDINGS_FILTER_KEYS = [
|
||||
"severity",
|
||||
"severity.in",
|
||||
"severity__in",
|
||||
"delta",
|
||||
"delta.in",
|
||||
"delta__in",
|
||||
"check_id",
|
||||
"check_id.in",
|
||||
"check_id__in",
|
||||
"finding_group_id",
|
||||
"finding_group_id.in",
|
||||
"finding_group_id__in",
|
||||
"categories",
|
||||
"categories.in",
|
||||
"categories__in",
|
||||
"category",
|
||||
"category__in",
|
||||
"resource_regions",
|
||||
"resource_regions.in",
|
||||
"resource_regions__in",
|
||||
"region",
|
||||
"region__in",
|
||||
"resource_services",
|
||||
"resource_services.in",
|
||||
"resource_services__in",
|
||||
"service",
|
||||
"service__in",
|
||||
"resource_types",
|
||||
"resource_types.in",
|
||||
"resource_types__in",
|
||||
"resource_type",
|
||||
"resource_type__in",
|
||||
"resource_groups",
|
||||
"resource_groups.in",
|
||||
"resource_groups__in",
|
||||
"resource_uid",
|
||||
"resource_uid.in",
|
||||
"resource_uid__in",
|
||||
"provider_id",
|
||||
"provider_id.in",
|
||||
"provider_id__in",
|
||||
"provider_type",
|
||||
"provider_type.in",
|
||||
"provider_type__in",
|
||||
] as const;
|
||||
|
||||
const PORTABLE_FINDINGS_FILTER_KEY_SET = new Set<string>(
|
||||
PORTABLE_FINDINGS_FILTER_KEYS,
|
||||
);
|
||||
|
||||
const NON_FILTER_QUERY_KEYS = new Set(["sort", "page", "pageSize"]);
|
||||
|
||||
const unwrapFilterKey = (rawKey: string): string => {
|
||||
if (rawKey.startsWith("filter[") && rawKey.endsWith("]")) {
|
||||
return rawKey.slice("filter[".length, -1);
|
||||
}
|
||||
|
||||
return rawKey;
|
||||
};
|
||||
|
||||
const isFilterKey = (rawKey: string): boolean =>
|
||||
rawKey.startsWith("filter[") && rawKey.endsWith("]");
|
||||
|
||||
const hasSeedableFilterValue = (value: AlertsFilterBag[string]): boolean => {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
|
||||
return values.some((entry) =>
|
||||
entry
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.some(Boolean),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Product invariant for the Findings entry point: any visible filter can open
|
||||
* the alert modal, but only backend-portable filters are sent as rule criteria.
|
||||
*/
|
||||
export const canSeedAlertFromFindingsFilters = (
|
||||
filterBag: AlertsFilterBag,
|
||||
): boolean =>
|
||||
Object.entries(filterBag).some(([rawKey, value]) => {
|
||||
if (!isFilterKey(rawKey) || NON_FILTER_QUERY_KEYS.has(rawKey)) return false;
|
||||
|
||||
return hasSeedableFilterValue(value);
|
||||
});
|
||||
|
||||
export const toPortableAlertFilterBag = (
|
||||
filterBag: AlertsFilterBag,
|
||||
): AlertsFilterBag =>
|
||||
Object.fromEntries(
|
||||
Object.entries(filterBag).filter(([rawKey, value]) => {
|
||||
const key = unwrapFilterKey(rawKey);
|
||||
return (
|
||||
PORTABLE_FINDINGS_FILTER_KEY_SET.has(key) &&
|
||||
hasSeedableFilterValue(value)
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
AlertCondition,
|
||||
AlertDelta,
|
||||
AlertProviderType,
|
||||
AlertSeverity,
|
||||
AlertTriggerKind,
|
||||
} from "@/app/(prowler)/alerts/_types";
|
||||
|
||||
export const ALERT_FILTER_OPERATORS = {
|
||||
ALL: "all",
|
||||
ANY: "any",
|
||||
} as const;
|
||||
|
||||
export type AlertFormFilterOperator =
|
||||
(typeof ALERT_FILTER_OPERATORS)[keyof typeof ALERT_FILTER_OPERATORS];
|
||||
|
||||
export const ALERT_FILTER_FIELDS = {
|
||||
PROVIDERS: "providers",
|
||||
ACCOUNTS: "accounts",
|
||||
CHECK_STATUSES: "checkStatuses",
|
||||
CHECK_SEVERITIES: "checkSeverities",
|
||||
RESOURCES: "resources",
|
||||
RESOURCE_TYPES: "resourceTypes",
|
||||
REGIONS: "regions",
|
||||
SERVICES: "services",
|
||||
CATEGORIES: "categories",
|
||||
RESOURCE_GROUPS: "resourceGroups",
|
||||
FINDING_GROUPS: "findingGroups",
|
||||
ACCOUNT_TAGS: "accountTags",
|
||||
TYPE: "type",
|
||||
DATA_DATE_WINDOW: "dataDateWindow",
|
||||
INCLUDE_MUTED_FINDINGS: "includeMutedFindings",
|
||||
CHECKS: "checks",
|
||||
} as const;
|
||||
|
||||
export type AlertFormFilterField =
|
||||
(typeof ALERT_FILTER_FIELDS)[keyof typeof ALERT_FILTER_FIELDS];
|
||||
|
||||
export const ALERT_FINDING_TYPES = {
|
||||
NEW: "new",
|
||||
ALL: "all",
|
||||
} as const;
|
||||
|
||||
export type AlertFormFindingType =
|
||||
(typeof ALERT_FINDING_TYPES)[keyof typeof ALERT_FINDING_TYPES];
|
||||
|
||||
export interface AlertFormFilterItem {
|
||||
kind: "filter";
|
||||
field: AlertFormFilterField;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
export interface AlertFormFilterGroup {
|
||||
kind?: "group";
|
||||
operator: AlertFormFilterOperator;
|
||||
children: AlertFormFilterNode[];
|
||||
}
|
||||
|
||||
export type AlertFormFilterNode =
|
||||
| AlertFormFilterItem
|
||||
| (AlertFormFilterGroup & { kind: "group" });
|
||||
|
||||
export const ALERT_NOTIFICATION_METHODS = {
|
||||
EMAIL: "email",
|
||||
} as const;
|
||||
|
||||
export type AlertNotificationMethod =
|
||||
(typeof ALERT_NOTIFICATION_METHODS)[keyof typeof ALERT_NOTIFICATION_METHODS];
|
||||
|
||||
export const ALERT_NOTIFICATION_METHOD_OPTIONS = [
|
||||
{
|
||||
value: ALERT_NOTIFICATION_METHODS.EMAIL,
|
||||
label: "Email",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export interface AlertFormValues {
|
||||
name: string;
|
||||
description: string;
|
||||
method: AlertNotificationMethod;
|
||||
frequency: AlertTriggerKind;
|
||||
filterGroup: AlertFormFilterGroup;
|
||||
severities: AlertSeverity[];
|
||||
deltas: AlertDelta[];
|
||||
providerTypes: AlertProviderType[];
|
||||
providerIds: string[];
|
||||
checkIds: string[];
|
||||
categories: string[];
|
||||
regions: string[];
|
||||
services: string[];
|
||||
resourceGroups: string[];
|
||||
findingGroupIds: string[];
|
||||
resourceTypes: string[];
|
||||
recipientEmails: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AlertFormDefaults extends AlertFormValues {
|
||||
advancedCondition: AlertCondition | null;
|
||||
}
|
||||
|
||||
export interface AlertFormSubmitResult {
|
||||
ok: boolean;
|
||||
alertId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type AlertFormFindingFilterBag = Record<string, string | string[]>;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user