mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-22 03:08:23 +00:00
Merge branch 'master' into PROWLER-465-add-alibaba-cloud-provider-support-to-the-api
This commit is contained in:
94
.github/workflows/api-container-build-push.yml
vendored
94
.github/workflows/api-container-build-push.yml
vendored
@@ -48,8 +48,34 @@ jobs:
|
||||
id: set-short-sha
|
||||
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
|
||||
container-build-push:
|
||||
notify-release-started:
|
||||
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
message-ts: ${{ steps.slack-notification.outputs.ts }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Notify container push started
|
||||
id: slack-notification
|
||||
uses: ./.github/actions/slack-notification
|
||||
env:
|
||||
SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
COMPONENT: API
|
||||
RELEASE_TAG: ${{ env.RELEASE_TAG }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload-file-path: "./.github/scripts/slack-messages/container-release-started.json"
|
||||
|
||||
container-build-push:
|
||||
needs: [setup, notify-release-started]
|
||||
if: always() && needs.setup.result == 'success' && (needs.notify-release-started.result == 'success' || needs.notify-release-started.result == 'skipped')
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -78,21 +104,6 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Notify container push started
|
||||
id: slack-notification-started
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
uses: ./.github/actions/slack-notification
|
||||
env:
|
||||
SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
COMPONENT: API
|
||||
RELEASE_TAG: ${{ env.RELEASE_TAG }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload-file-path: "./.github/scripts/slack-messages/container-release-started.json"
|
||||
|
||||
- name: Build and push API container for ${{ matrix.arch }}
|
||||
id: container-push
|
||||
if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
@@ -106,23 +117,6 @@ jobs:
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||
|
||||
- name: Notify container push completed
|
||||
if: (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && always()
|
||||
uses: ./.github/actions/slack-notification
|
||||
env:
|
||||
SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
MESSAGE_TS: ${{ steps.slack-notification-started.outputs.ts }}
|
||||
COMPONENT: API
|
||||
RELEASE_TAG: ${{ env.RELEASE_TAG }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json"
|
||||
step-outcome: ${{ steps.container-push.outcome }}
|
||||
update-ts: ${{ steps.slack-notification-started.outputs.ts }}
|
||||
|
||||
# Create and push multi-architecture manifest
|
||||
create-manifest:
|
||||
needs: [setup, container-build-push]
|
||||
@@ -169,6 +163,40 @@ jobs:
|
||||
regctl tag delete "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-arm64" || true
|
||||
echo "Cleanup completed"
|
||||
|
||||
notify-release-completed:
|
||||
if: always() && needs.notify-release-started.result == 'success' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
needs: [setup, notify-release-started, container-build-push, create-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Determine overall outcome
|
||||
id: outcome
|
||||
run: |
|
||||
if [[ "${{ needs.container-build-push.result }}" == "success" && "${{ needs.create-manifest.result }}" == "success" ]]; then
|
||||
echo "outcome=success" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "outcome=failure" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Notify container push completed
|
||||
uses: ./.github/actions/slack-notification
|
||||
env:
|
||||
SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
MESSAGE_TS: ${{ needs.notify-release-started.outputs.message-ts }}
|
||||
COMPONENT: API
|
||||
RELEASE_TAG: ${{ env.RELEASE_TAG }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
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 }}
|
||||
|
||||
trigger-deployment:
|
||||
if: github.event_name == 'push'
|
||||
needs: [setup, container-build-push]
|
||||
|
||||
94
.github/workflows/mcp-container-build-push.yml
vendored
94
.github/workflows/mcp-container-build-push.yml
vendored
@@ -47,8 +47,34 @@ jobs:
|
||||
id: set-short-sha
|
||||
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
|
||||
container-build-push:
|
||||
notify-release-started:
|
||||
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
message-ts: ${{ steps.slack-notification.outputs.ts }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Notify container push started
|
||||
id: slack-notification
|
||||
uses: ./.github/actions/slack-notification
|
||||
env:
|
||||
SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
COMPONENT: MCP
|
||||
RELEASE_TAG: ${{ env.RELEASE_TAG }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload-file-path: "./.github/scripts/slack-messages/container-release-started.json"
|
||||
|
||||
container-build-push:
|
||||
needs: [setup, notify-release-started]
|
||||
if: always() && needs.setup.result == 'success' && (needs.notify-release-started.result == 'success' || needs.notify-release-started.result == 'skipped')
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -76,21 +102,6 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Notify container push started
|
||||
id: slack-notification-started
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
uses: ./.github/actions/slack-notification
|
||||
env:
|
||||
SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
COMPONENT: MCP
|
||||
RELEASE_TAG: ${{ env.RELEASE_TAG }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload-file-path: "./.github/scripts/slack-messages/container-release-started.json"
|
||||
|
||||
- name: Build and push MCP container for ${{ matrix.arch }}
|
||||
id: container-push
|
||||
if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
@@ -112,23 +123,6 @@ jobs:
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||
|
||||
- name: Notify container push completed
|
||||
if: (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && always()
|
||||
uses: ./.github/actions/slack-notification
|
||||
env:
|
||||
SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
MESSAGE_TS: ${{ steps.slack-notification-started.outputs.ts }}
|
||||
COMPONENT: MCP
|
||||
RELEASE_TAG: ${{ env.RELEASE_TAG }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json"
|
||||
step-outcome: ${{ steps.container-push.outcome }}
|
||||
update-ts: ${{ steps.slack-notification-started.outputs.ts }}
|
||||
|
||||
# Create and push multi-architecture manifest
|
||||
create-manifest:
|
||||
needs: [setup, container-build-push]
|
||||
@@ -175,6 +169,40 @@ jobs:
|
||||
regctl tag delete "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-arm64" || true
|
||||
echo "Cleanup completed"
|
||||
|
||||
notify-release-completed:
|
||||
if: always() && needs.notify-release-started.result == 'success' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
needs: [setup, notify-release-started, container-build-push, create-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Determine overall outcome
|
||||
id: outcome
|
||||
run: |
|
||||
if [[ "${{ needs.container-build-push.result }}" == "success" && "${{ needs.create-manifest.result }}" == "success" ]]; then
|
||||
echo "outcome=success" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "outcome=failure" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Notify container push completed
|
||||
uses: ./.github/actions/slack-notification
|
||||
env:
|
||||
SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
MESSAGE_TS: ${{ needs.notify-release-started.outputs.message-ts }}
|
||||
COMPONENT: MCP
|
||||
RELEASE_TAG: ${{ env.RELEASE_TAG }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
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 }}
|
||||
|
||||
trigger-deployment:
|
||||
if: github.event_name == 'push'
|
||||
needs: [setup, container-build-push]
|
||||
|
||||
182
.github/workflows/sdk-container-build-push.yml
vendored
182
.github/workflows/sdk-container-build-push.yml
vendored
@@ -50,30 +50,15 @@ env:
|
||||
AWS_REGION: us-east-1
|
||||
|
||||
jobs:
|
||||
container-build-push:
|
||||
setup:
|
||||
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
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
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 }}
|
||||
env:
|
||||
POETRY_VIRTUALENVS_CREATE: 'false'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
@@ -93,32 +78,24 @@ jobs:
|
||||
run: |
|
||||
PROWLER_VERSION="$(poetry version -s 2>/dev/null)"
|
||||
echo "prowler_version=${PROWLER_VERSION}" >> "${GITHUB_OUTPUT}"
|
||||
echo "PROWLER_VERSION=${PROWLER_VERSION}" >> "${GITHUB_ENV}"
|
||||
|
||||
# Extract major version
|
||||
PROWLER_VERSION_MAJOR="${PROWLER_VERSION%%.*}"
|
||||
echo "prowler_version_major=${PROWLER_VERSION_MAJOR}" >> "${GITHUB_OUTPUT}"
|
||||
echo "PROWLER_VERSION_MAJOR=${PROWLER_VERSION_MAJOR}" >> "${GITHUB_ENV}"
|
||||
|
||||
# Set version-specific tags
|
||||
case ${PROWLER_VERSION_MAJOR} in
|
||||
3)
|
||||
echo "LATEST_TAG=v3-latest" >> "${GITHUB_ENV}"
|
||||
echo "STABLE_TAG=v3-stable" >> "${GITHUB_ENV}"
|
||||
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_ENV}"
|
||||
echo "STABLE_TAG=v4-stable" >> "${GITHUB_ENV}"
|
||||
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_ENV}"
|
||||
echo "STABLE_TAG=stable" >> "${GITHUB_ENV}"
|
||||
echo "latest_tag=latest" >> "${GITHUB_OUTPUT}"
|
||||
echo "stable_tag=stable" >> "${GITHUB_OUTPUT}"
|
||||
echo "✓ Prowler v5 detected - tags: latest, stable"
|
||||
@@ -129,6 +106,53 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
|
||||
notify-release-started:
|
||||
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
message-ts: ${{ steps.slack-notification.outputs.ts }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Notify container push started
|
||||
id: slack-notification
|
||||
uses: ./.github/actions/slack-notification
|
||||
env:
|
||||
SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
COMPONENT: SDK
|
||||
RELEASE_TAG: ${{ needs.setup.outputs.prowler_version }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload-file-path: "./.github/scripts/slack-messages/container-release-started.json"
|
||||
|
||||
container-build-push:
|
||||
needs: [setup, notify-release-started]
|
||||
if: always() && needs.setup.result == 'success' && (needs.notify-release-started.result == 'success' || needs.notify-release-started.result == 'skipped')
|
||||
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
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
@@ -147,21 +171,6 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Notify container push started
|
||||
id: slack-notification-started
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
uses: ./.github/actions/slack-notification
|
||||
env:
|
||||
SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
COMPONENT: SDK
|
||||
RELEASE_TAG: ${{ env.PROWLER_VERSION }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload-file-path: "./.github/scripts/slack-messages/container-release-started.json"
|
||||
|
||||
- name: Build and push SDK container for ${{ matrix.arch }}
|
||||
id: container-push
|
||||
if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
@@ -172,30 +181,13 @@ jobs:
|
||||
push: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: |
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }}-${{ matrix.arch }}
|
||||
${{ 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 }}
|
||||
|
||||
- name: Notify container push completed
|
||||
if: (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && always()
|
||||
uses: ./.github/actions/slack-notification
|
||||
env:
|
||||
SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
MESSAGE_TS: ${{ steps.slack-notification-started.outputs.ts }}
|
||||
COMPONENT: SDK
|
||||
RELEASE_TAG: ${{ env.PROWLER_VERSION }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json"
|
||||
step-outcome: ${{ steps.container-push.outcome }}
|
||||
update-ts: ${{ steps.slack-notification-started.outputs.ts }}
|
||||
|
||||
# Create and push multi-architecture manifest
|
||||
create-manifest:
|
||||
needs: [container-build-push]
|
||||
needs: [setup, container-build-push]
|
||||
if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -222,24 +214,24 @@ jobs:
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.container-build-push.outputs.latest_tag }} \
|
||||
-t ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.container-build-push.outputs.latest_tag }} \
|
||||
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.container-build-push.outputs.latest_tag }} \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.container-build-push.outputs.latest_tag }}-amd64 \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.container-build-push.outputs.latest_tag }}-arm64
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }} \
|
||||
-t ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }} \
|
||||
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }} \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-amd64 \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-arm64
|
||||
|
||||
- name: Create and push manifests for release event
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ needs.container-build-push.outputs.prowler_version }} \
|
||||
-t ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ needs.container-build-push.outputs.stable_tag }} \
|
||||
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ needs.container-build-push.outputs.prowler_version }} \
|
||||
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ needs.container-build-push.outputs.stable_tag }} \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.container-build-push.outputs.prowler_version }} \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.container-build-push.outputs.stable_tag }} \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.container-build-push.outputs.latest_tag }}-amd64 \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.container-build-push.outputs.latest_tag }}-arm64
|
||||
-t ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ needs.setup.outputs.prowler_version }} \
|
||||
-t ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ needs.setup.outputs.stable_tag }} \
|
||||
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ needs.setup.outputs.prowler_version }} \
|
||||
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ needs.setup.outputs.stable_tag }} \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.prowler_version }} \
|
||||
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.stable_tag }} \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-amd64 \
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-arm64
|
||||
|
||||
- name: Install regctl
|
||||
if: always()
|
||||
@@ -249,13 +241,47 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
echo "Cleaning up intermediate tags..."
|
||||
regctl tag delete "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.container-build-push.outputs.latest_tag }}-amd64" || true
|
||||
regctl tag delete "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.container-build-push.outputs.latest_tag }}-arm64" || true
|
||||
regctl tag delete "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-amd64" || true
|
||||
regctl tag delete "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-arm64" || true
|
||||
echo "Cleanup completed"
|
||||
|
||||
notify-release-completed:
|
||||
if: always() && needs.notify-release-started.result == 'success' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
needs: [setup, notify-release-started, container-build-push, create-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Determine overall outcome
|
||||
id: outcome
|
||||
run: |
|
||||
if [[ "${{ needs.container-build-push.result }}" == "success" && "${{ needs.create-manifest.result }}" == "success" ]]; then
|
||||
echo "outcome=success" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "outcome=failure" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Notify container push completed
|
||||
uses: ./.github/actions/slack-notification
|
||||
env:
|
||||
SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
MESSAGE_TS: ${{ needs.notify-release-started.outputs.message-ts }}
|
||||
COMPONENT: SDK
|
||||
RELEASE_TAG: ${{ needs.setup.outputs.prowler_version }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
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:
|
||||
if: needs.container-build-push.outputs.prowler_version_major == '3'
|
||||
needs: container-build-push
|
||||
if: needs.setup.outputs.prowler_version_major == '3'
|
||||
needs: [setup, container-build-push]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
@@ -282,4 +308,4 @@ jobs:
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
repository: ${{ secrets.DISPATCH_OWNER }}/${{ secrets.DISPATCH_REPO }}
|
||||
event-type: dispatch
|
||||
client-payload: '{"version":"release","tag":"${{ needs.container-build-push.outputs.prowler_version }}"}'
|
||||
client-payload: '{"version":"release","tag":"${{ needs.setup.outputs.prowler_version }}"}'
|
||||
|
||||
103
.github/workflows/sdk-tests.yml
vendored
103
.github/workflows/sdk-tests.yml
vendored
@@ -82,9 +82,110 @@ jobs:
|
||||
./tests/**/aws/**
|
||||
./poetry.lock
|
||||
|
||||
- name: Resolve AWS services under test
|
||||
if: steps.changed-aws.outputs.any_changed == 'true'
|
||||
id: aws-services
|
||||
shell: bash
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
dependents = {
|
||||
"acm": ["elb"],
|
||||
"autoscaling": ["dynamodb"],
|
||||
"awslambda": ["ec2", "inspector2"],
|
||||
"backup": ["dynamodb", "ec2", "rds"],
|
||||
"cloudfront": ["shield"],
|
||||
"cloudtrail": ["awslambda", "cloudwatch"],
|
||||
"cloudwatch": ["bedrock"],
|
||||
"ec2": ["dlm", "dms", "elbv2", "emr", "inspector2", "rds", "redshift", "route53", "shield", "ssm"],
|
||||
"ecr": ["inspector2"],
|
||||
"elb": ["shield"],
|
||||
"elbv2": ["shield"],
|
||||
"globalaccelerator": ["shield"],
|
||||
"iam": ["bedrock", "cloudtrail", "cloudwatch", "codebuild"],
|
||||
"kafka": ["firehose"],
|
||||
"kinesis": ["firehose"],
|
||||
"kms": ["kafka"],
|
||||
"organizations": ["iam", "servicecatalog"],
|
||||
"route53": ["shield"],
|
||||
"s3": ["bedrock", "cloudfront", "cloudtrail", "macie"],
|
||||
"ssm": ["ec2"],
|
||||
"vpc": ["awslambda", "ec2", "efs", "elasticache", "neptune", "networkfirewall", "rds", "redshift", "workspaces"],
|
||||
"waf": ["elbv2"],
|
||||
"wafv2": ["cognito", "elbv2"],
|
||||
}
|
||||
|
||||
changed_raw = """${{ steps.changed-aws.outputs.all_changed_files }}"""
|
||||
# all_changed_files is space-separated, not newline-separated
|
||||
# Strip leading "./" if present for consistent path handling
|
||||
changed_files = [Path(f.lstrip("./")) for f in changed_raw.split() if f]
|
||||
|
||||
services = set()
|
||||
run_all = False
|
||||
|
||||
for path in changed_files:
|
||||
path_str = path.as_posix()
|
||||
parts = path.parts
|
||||
if path_str.startswith("prowler/providers/aws/services/"):
|
||||
if len(parts) > 4 and "." not in parts[4]:
|
||||
services.add(parts[4])
|
||||
else:
|
||||
run_all = True
|
||||
elif path_str.startswith("tests/providers/aws/services/"):
|
||||
if len(parts) > 4 and "." not in parts[4]:
|
||||
services.add(parts[4])
|
||||
else:
|
||||
run_all = True
|
||||
elif path_str.startswith("prowler/providers/aws/") or path_str.startswith("tests/providers/aws/"):
|
||||
run_all = True
|
||||
|
||||
# Expand with direct dependent services (one level only)
|
||||
# We only test services that directly depend on the changed services,
|
||||
# not transitive dependencies (services that depend on dependents)
|
||||
original_services = set(services)
|
||||
for svc in original_services:
|
||||
for dep in dependents.get(svc, []):
|
||||
services.add(dep)
|
||||
|
||||
if run_all or not services:
|
||||
run_all = True
|
||||
services = set()
|
||||
|
||||
service_paths = " ".join(sorted(f"tests/providers/aws/services/{svc}" for svc in services))
|
||||
|
||||
output_lines = [
|
||||
f"run_all={'true' if run_all else 'false'}",
|
||||
f"services={' '.join(sorted(services))}",
|
||||
f"service_paths={service_paths}",
|
||||
]
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as gh_out:
|
||||
for line in output_lines:
|
||||
gh_out.write(line + "\n")
|
||||
|
||||
print(f"AWS changed files (filtered): {changed_raw or 'none'}")
|
||||
print(f"Run all AWS tests: {run_all}")
|
||||
if services:
|
||||
print(f"AWS service test paths: {service_paths}")
|
||||
else:
|
||||
print("AWS service test paths: none detected")
|
||||
PY
|
||||
|
||||
- name: Run AWS tests
|
||||
if: steps.changed-aws.outputs.any_changed == 'true'
|
||||
run: poetry run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml tests/providers/aws
|
||||
run: |
|
||||
echo "AWS run_all=${{ steps.aws-services.outputs.run_all }}"
|
||||
echo "AWS service_paths='${{ steps.aws-services.outputs.service_paths }}'"
|
||||
|
||||
if [ "${{ steps.aws-services.outputs.run_all }}" = "true" ]; then
|
||||
poetry run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml tests/providers/aws
|
||||
elif [ -z "${{ steps.aws-services.outputs.service_paths }}" ]; then
|
||||
echo "No AWS service paths detected; skipping AWS tests."
|
||||
else
|
||||
poetry run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml ${{ steps.aws-services.outputs.service_paths }}
|
||||
fi
|
||||
|
||||
- name: Upload AWS coverage to Codecov
|
||||
if: steps.changed-aws.outputs.any_changed == 'true'
|
||||
|
||||
94
.github/workflows/ui-container-build-push.yml
vendored
94
.github/workflows/ui-container-build-push.yml
vendored
@@ -50,8 +50,34 @@ jobs:
|
||||
id: set-short-sha
|
||||
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
|
||||
container-build-push:
|
||||
notify-release-started:
|
||||
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
message-ts: ${{ steps.slack-notification.outputs.ts }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Notify container push started
|
||||
id: slack-notification
|
||||
uses: ./.github/actions/slack-notification
|
||||
env:
|
||||
SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
COMPONENT: UI
|
||||
RELEASE_TAG: ${{ env.RELEASE_TAG }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload-file-path: "./.github/scripts/slack-messages/container-release-started.json"
|
||||
|
||||
container-build-push:
|
||||
needs: [setup, notify-release-started]
|
||||
if: always() && needs.setup.result == 'success' && (needs.notify-release-started.result == 'success' || needs.notify-release-started.result == 'skipped')
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -80,21 +106,6 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Notify container push started
|
||||
id: slack-notification-started
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
uses: ./.github/actions/slack-notification
|
||||
env:
|
||||
SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
COMPONENT: UI
|
||||
RELEASE_TAG: ${{ env.RELEASE_TAG }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload-file-path: "./.github/scripts/slack-messages/container-release-started.json"
|
||||
|
||||
- name: Build and push UI container for ${{ matrix.arch }}
|
||||
id: container-push
|
||||
if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
@@ -111,23 +122,6 @@ jobs:
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||
|
||||
- name: Notify container push completed
|
||||
if: (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && always()
|
||||
uses: ./.github/actions/slack-notification
|
||||
env:
|
||||
SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
MESSAGE_TS: ${{ steps.slack-notification-started.outputs.ts }}
|
||||
COMPONENT: UI
|
||||
RELEASE_TAG: ${{ env.RELEASE_TAG }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json"
|
||||
step-outcome: ${{ steps.container-push.outcome }}
|
||||
update-ts: ${{ steps.slack-notification-started.outputs.ts }}
|
||||
|
||||
# Create and push multi-architecture manifest
|
||||
create-manifest:
|
||||
needs: [setup, container-build-push]
|
||||
@@ -174,6 +168,40 @@ jobs:
|
||||
regctl tag delete "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-arm64" || true
|
||||
echo "Cleanup completed"
|
||||
|
||||
notify-release-completed:
|
||||
if: always() && needs.notify-release-started.result == 'success' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
needs: [setup, notify-release-started, container-build-push, create-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Determine overall outcome
|
||||
id: outcome
|
||||
run: |
|
||||
if [[ "${{ needs.container-build-push.result }}" == "success" && "${{ needs.create-manifest.result }}" == "success" ]]; then
|
||||
echo "outcome=success" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "outcome=failure" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Notify container push completed
|
||||
uses: ./.github/actions/slack-notification
|
||||
env:
|
||||
SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }}
|
||||
MESSAGE_TS: ${{ needs.notify-release-started.outputs.message-ts }}
|
||||
COMPONENT: UI
|
||||
RELEASE_TAG: ${{ env.RELEASE_TAG }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
with:
|
||||
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
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 }}
|
||||
|
||||
trigger-deployment:
|
||||
if: github.event_name == 'push'
|
||||
needs: [setup, container-build-push]
|
||||
|
||||
@@ -19,12 +19,17 @@ class CheckRemediation(MinimalSerializerMixin, BaseModel):
|
||||
default=None,
|
||||
description="Terraform code snippet with best practices for remediation",
|
||||
)
|
||||
recommendation_text: str | None = Field(
|
||||
default=None, description="Text description with best practices"
|
||||
)
|
||||
recommendation_url: str | None = Field(
|
||||
nativeiac: str | None = Field(
|
||||
default=None,
|
||||
description="URL to external remediation documentation",
|
||||
description="Native Infrastructure as Code code snippet with best practices for remediation",
|
||||
)
|
||||
other: str | None = Field(
|
||||
default=None,
|
||||
description="Other remediation code snippet with best practices for remediation, usually used for web interfaces or other tools",
|
||||
)
|
||||
recommendation: str | None = Field(
|
||||
default=None,
|
||||
description="Text description with general best recommended practices to avoid the issue",
|
||||
)
|
||||
|
||||
|
||||
@@ -33,9 +38,6 @@ class CheckMetadata(MinimalSerializerMixin, BaseModel):
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
check_id: str = Field(
|
||||
description="Unique provider identifier for the security check (e.g., 's3_bucket_public_access')",
|
||||
)
|
||||
title: str = Field(
|
||||
description="Human-readable title of the security check",
|
||||
)
|
||||
@@ -59,9 +61,9 @@ class CheckMetadata(MinimalSerializerMixin, BaseModel):
|
||||
default=None,
|
||||
description="Remediation guidance including CLI commands and recommendations",
|
||||
)
|
||||
related_url: str | None = Field(
|
||||
default=None,
|
||||
description="URL to additional documentation or references",
|
||||
additional_urls: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of additional URLs related to the check",
|
||||
)
|
||||
categories: list[str] = Field(
|
||||
default_factory=list,
|
||||
@@ -79,23 +81,23 @@ class CheckMetadata(MinimalSerializerMixin, BaseModel):
|
||||
recommendation = remediation_data.get("recommendation", {})
|
||||
|
||||
remediation = CheckRemediation(
|
||||
cli=code.get("cli"),
|
||||
terraform=code.get("terraform"),
|
||||
recommendation_text=recommendation.get("text"),
|
||||
recommendation_url=recommendation.get("url"),
|
||||
cli=code["cli"],
|
||||
terraform=code["terraform"],
|
||||
nativeiac=code["nativeiac"],
|
||||
other=code["other"],
|
||||
recommendation=recommendation["text"],
|
||||
)
|
||||
|
||||
return cls(
|
||||
check_id=data["checkid"],
|
||||
title=data["checktitle"],
|
||||
description=data["description"],
|
||||
provider=data["provider"],
|
||||
risk=data.get("risk"),
|
||||
risk=data["risk"],
|
||||
service=data["servicename"],
|
||||
resource_type=data["resourcetype"],
|
||||
remediation=remediation,
|
||||
related_url=data.get("relatedurl"),
|
||||
categories=data.get("categories", []),
|
||||
additional_urls=data["additionalurls"],
|
||||
categories=data["categories"],
|
||||
)
|
||||
|
||||
|
||||
@@ -116,35 +118,36 @@ class SimplifiedFinding(MinimalSerializerMixin, BaseModel):
|
||||
severity: Literal["critical", "high", "medium", "low", "informational"] = Field(
|
||||
description="Severity level of the finding",
|
||||
)
|
||||
check_metadata: CheckMetadata = Field(
|
||||
description="Metadata about the security check that generated this finding",
|
||||
check_id: str = Field(
|
||||
description="ID of the security check that generated this finding",
|
||||
)
|
||||
status_extended: str = Field(
|
||||
description="Extended status information providing additional context",
|
||||
)
|
||||
delta: Literal["new", "changed"] = Field(
|
||||
delta: Literal["new", "changed"] | None = Field(
|
||||
default=None,
|
||||
description="Change status: 'new' (not seen before), 'changed' (modified since last scan), or None (unchanged)",
|
||||
)
|
||||
muted: bool = Field(
|
||||
muted: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether this finding has been muted/suppressed by the user",
|
||||
)
|
||||
muted_reason: str = Field(
|
||||
muted_reason: str | None = Field(
|
||||
default=None,
|
||||
description="Reason provided when muting this finding (3-500 chars if muted)",
|
||||
description="Reason provided when muting this finding",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, data: dict) -> "SimplifiedFinding":
|
||||
"""Transform JSON:API finding response to simplified format."""
|
||||
attributes = data["attributes"]
|
||||
check_metadata = attributes["check_metadata"]
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
uid=attributes["uid"],
|
||||
status=attributes["status"],
|
||||
severity=attributes["severity"],
|
||||
check_metadata=CheckMetadata.from_api_response(check_metadata),
|
||||
check_id=attributes["check_metadata"]["checkid"],
|
||||
status_extended=attributes["status_extended"],
|
||||
delta=attributes["delta"],
|
||||
muted=attributes["muted"],
|
||||
@@ -179,6 +182,9 @@ class DetailedFinding(SimplifiedFinding):
|
||||
default_factory=list,
|
||||
description="List of UUIDs for cloud resources associated with this finding",
|
||||
)
|
||||
check_metadata: CheckMetadata = Field(
|
||||
description="Metadata about the security check that generated this finding",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, data: dict) -> "DetailedFinding":
|
||||
@@ -204,6 +210,7 @@ class DetailedFinding(SimplifiedFinding):
|
||||
uid=attributes["uid"],
|
||||
status=attributes["status"],
|
||||
severity=attributes["severity"],
|
||||
check_id=check_metadata["checkid"],
|
||||
check_metadata=CheckMetadata.from_api_response(check_metadata),
|
||||
status_extended=attributes.get("status_extended"),
|
||||
delta=attributes.get("delta"),
|
||||
|
||||
@@ -19,9 +19,9 @@ class FindingsTools(BaseTool):
|
||||
"""Tools for security findings operations.
|
||||
|
||||
Provides tools for:
|
||||
- Searching and filtering security findings
|
||||
- Getting detailed finding information
|
||||
- Viewing findings overview/statistics
|
||||
- search_security_findings: Fast and lightweight searching across findings
|
||||
- get_finding_details: Get complete details for a specific finding
|
||||
- get_findings_overview: Get aggregate statistics and trends across all findings
|
||||
"""
|
||||
|
||||
async def search_security_findings(
|
||||
@@ -90,27 +90,27 @@ class FindingsTools(BaseTool):
|
||||
) -> dict[str, Any]:
|
||||
"""Search and filter security findings across all cloud providers with rich filtering capabilities.
|
||||
|
||||
This is the primary tool for browsing and filtering security findings. Returns lightweight findings
|
||||
optimized for searching across large result sets. For detailed information about a specific finding,
|
||||
use get_finding_details.
|
||||
IMPORTANT: This tool returns LIGHTWEIGHT findings. Use this for fast searching and filtering across many findings.
|
||||
For complete details use prowler_app_get_finding_details on specific findings.
|
||||
|
||||
Default behavior:
|
||||
- Returns latest findings from most recent scans (no date parameters needed)
|
||||
- Filters to FAIL status only (security issues found)
|
||||
- Returns 100 results per page
|
||||
- Returns 50 results per page
|
||||
|
||||
Date filtering:
|
||||
- Without dates: queries findings from the most recent completed scan across all providers (most efficient). This returns the latest snapshot of findings, not a time-based query.
|
||||
- With dates: queries historical findings (2-day maximum range)
|
||||
- Without dates: queries findings from the most recent completed scan across all providers (most efficient)
|
||||
- With dates: queries historical findings (2-day maximum range between date_from and date_to)
|
||||
|
||||
Each finding includes:
|
||||
- Core identification: id, uid, check_id
|
||||
- Security context: status, severity, check_metadata (title, description, remediation)
|
||||
- State tracking: delta (new/changed), muted status
|
||||
- Extended details: status_extended for additional context
|
||||
- Core identification: id (UUID for get_finding_details), uid, check_id
|
||||
- Security context: status (FAIL/PASS/MANUAL), severity (critical/high/medium/low/informational)
|
||||
- State tracking: delta (new/changed/unchanged), muted (boolean), muted_reason
|
||||
- Extended details: status_extended with additional context
|
||||
|
||||
Returns:
|
||||
Paginated list of simplified findings with total count and pagination metadata
|
||||
Workflow:
|
||||
1. Use this tool to search and filter findings by severity, status, provider, service, region, etc.
|
||||
2. Use prowler_app_get_finding_details with the finding 'id' to get complete information about the finding
|
||||
"""
|
||||
# Validate page_size parameter
|
||||
self.api_client.validate_page_size(page_size)
|
||||
@@ -185,21 +185,39 @@ class FindingsTools(BaseTool):
|
||||
) -> dict[str, Any]:
|
||||
"""Retrieve comprehensive details about a specific security finding by its ID.
|
||||
|
||||
This tool provides MORE detailed information than search_security_findings. Use this when you need
|
||||
to deeply analyze a specific finding or understand its complete context and history.
|
||||
IMPORTANT: This tool returns COMPLETE finding details.
|
||||
Use this after finding a specific finding via prowler_app_search_security_findings
|
||||
|
||||
Additional information compared to search_security_findings:
|
||||
- Temporal metadata: when the finding was first seen, inserted, and last updated
|
||||
- Scan relationship: ID of the scan that generated this finding
|
||||
- Resource relationships: IDs of all cloud resources associated with this finding
|
||||
This tool provides ALL information that prowler_app_search_security_findings returns PLUS:
|
||||
|
||||
1. Check Metadata (information about the check script that generated the finding):
|
||||
- title: Human-readable phrase used to summarize the check
|
||||
- description: Detailed explanation of what the check validates and why it is important
|
||||
- risk: What could happen if this check fails
|
||||
- remediation: Complete remediation guidance including step-by-step instructions and code snippets with best practices to fix the issue:
|
||||
* cli: Command-line commands to fix the issue
|
||||
* terraform: Terraform code snippets with best practices
|
||||
* nativeiac: Provider native Infrastructure as Code code snippets with best practices to fix the issue
|
||||
* other: Other remediation code snippets with best practices, usually used for web interfaces or other tools
|
||||
* recommendation: Text description with general best recommended practices to avoid the issue
|
||||
- provider: Cloud provider (aws/azure/gcp/etc)
|
||||
- service: Service name (s3/ec2/keyvault/etc)
|
||||
- resource_type: Resource type being evaluated
|
||||
- categories: Security categories this check belongs to
|
||||
- additional_urls: List of additional URLs related to the check
|
||||
|
||||
2. Temporal Metadata:
|
||||
- inserted_at: When this finding was first inserted into database
|
||||
- updated_at: When this finding was last updated
|
||||
- first_seen_at: When this finding was first detected across all scans
|
||||
|
||||
3. Relationships:
|
||||
- scan_id: UUID of the scan that generated this finding
|
||||
- resource_ids: List of UUIDs for cloud resources associated with this finding
|
||||
|
||||
Workflow:
|
||||
1. Use search_security_findings to browse and filter across many findings
|
||||
2. Use get_finding_details to drill down into specific findings of interest
|
||||
|
||||
Returns:
|
||||
dict containing detailed finding with comprehensive security metadata, temporal information,
|
||||
and relationships to scans and resources
|
||||
1. Use prowler_app_search_security_findings to browse and filter findings
|
||||
2. Use this tool with the finding 'id' to get remediation guidance and complete context
|
||||
"""
|
||||
params = {
|
||||
# Return comprehensive fields including temporal metadata
|
||||
@@ -225,26 +243,31 @@ class FindingsTools(BaseTool):
|
||||
description="Filter statistics by cloud provider. Multiple values allowed. If empty, all providers are returned. For valid values, please refer to Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server.",
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
"""Get high-level statistics about security findings formatted as a human-readable markdown report.
|
||||
"""Get aggregate statistics and trends about security findings as a markdown report.
|
||||
|
||||
Use this tool to get a quick overview of your security posture without retrieving individual findings.
|
||||
Perfect for understanding trends, identifying areas of concern, and tracking improvements over time.
|
||||
This tool provides a HIGH-LEVEL OVERVIEW without retrieving individual findings. Use this when you
|
||||
need to understand the overall security posture, trends, or remediation progress across all findings.
|
||||
|
||||
The report includes:
|
||||
- Summary statistics: total findings, fail/pass/muted counts with percentages
|
||||
- Delta analysis: breakdown of new vs changed findings
|
||||
- Trending information: how findings are evolving over time
|
||||
The markdown report includes:
|
||||
|
||||
Output format: Markdown-formatted report ready to present to users or include in documentation.
|
||||
1. Summary Statistics:
|
||||
- Total number of findings
|
||||
- Failed checks (security issues) with percentage
|
||||
- Passed checks (no issues) with percentage
|
||||
- Muted findings (user-suppressed) with percentage
|
||||
|
||||
Use cases:
|
||||
- Quick security posture assessment
|
||||
- Tracking remediation progress over time
|
||||
- Identifying which providers have most issues
|
||||
- Understanding finding trends (improving or degrading)
|
||||
2. Delta Analysis (Change Tracking):
|
||||
- New findings: never seen before in previous scans
|
||||
* Broken down by: new failures, new passes, new muted
|
||||
- Changed findings: status changed since last scan
|
||||
* Broken down by: changed to fail, changed to pass, changed to muted
|
||||
- Unchanged findings: same status as previous scan
|
||||
|
||||
Returns:
|
||||
Dictionary with 'report' key containing markdown-formatted summary statistics
|
||||
This helps answer questions like:
|
||||
- "What's my overall security posture?"
|
||||
- "How many critical security issues do I have?"
|
||||
- "Are we improving or getting worse over time?"
|
||||
- "How many new security issues appeared since last scan?"
|
||||
"""
|
||||
params = {
|
||||
# Return only LLM-relevant aggregate statistics
|
||||
|
||||
@@ -62,7 +62,7 @@ You are a code reviewer for the Prowler UI project. Analyze the full file conten
|
||||
**RULES TO CHECK:**
|
||||
1. React Imports: NO `import * as React` or `import React, {` → Use `import { useState }`
|
||||
2. TypeScript: NO union types like `type X = "a" | "b"` → Use const-based: `const X = {...} as const`
|
||||
3. Tailwind: NO `var()` or hex colors in className → Use Tailwind utilities and semantic color classes.
|
||||
3. Tailwind: NO `var()` or hex colors in className → Use Tailwind utilities and semantic color classes. Exception: `var()` is allowed when passing colors to chart/graph components that require CSS color strings (not Tailwind classes) for their APIs.
|
||||
4. cn(): Use for merging multiple classes or for conditionals (handles Tailwind conflicts with twMerge) → `cn(BUTTON_STYLES.base, BUTTON_STYLES.active, isLoading && "opacity-50")`
|
||||
5. React 19: NO `useMemo`/`useCallback` without reason
|
||||
6. Zod v4: Use `.min(1)` not `.nonempty()`, `z.email()` not `z.string().email()`. All inputs must be validated with Zod.
|
||||
|
||||
@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Risk Plot component with interactive legend and severity navigation to Overview page [(#9469)](https://github.com/prowler-cloud/prowler/pull/9469)
|
||||
- Navigation progress bar for page transitions using Next.js `onRouterTransitionStart` [(#9465)](https://github.com/prowler-cloud/prowler/pull/9465)
|
||||
- Finding Severity Over Time chart component to Overview page [(#9405)](https://github.com/prowler-cloud/prowler/pull/9405)
|
||||
- Attack Surface component to Overview page [(#9412)](https://github.com/prowler-cloud/prowler/pull/9412)
|
||||
@@ -23,6 +24,13 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [1.14.3] (Prowler Unreleased)
|
||||
|
||||
### 🐞 Fixed
|
||||
- Show top failed requirements in compliance specific view for compliance without sections [(#9471)](https://github.com/prowler-cloud/prowler/pull/9471)
|
||||
|
||||
---
|
||||
|
||||
## [1.14.2] (Prowler v5.14.2)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from "./attack-surface";
|
||||
export * from "./findings";
|
||||
export * from "./providers";
|
||||
export * from "./regions";
|
||||
export * from "./risk-plot";
|
||||
export * from "./services";
|
||||
export * from "./severity-trends";
|
||||
export * from "./threat-score";
|
||||
|
||||
4
ui/actions/overview/risk-plot/index.ts
Normal file
4
ui/actions/overview/risk-plot/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Risk Plot Actions
|
||||
export * from "./risk-plot";
|
||||
export * from "./risk-plot.adapter";
|
||||
export * from "./types/risk-plot.types";
|
||||
94
ui/actions/overview/risk-plot/risk-plot.adapter.ts
Normal file
94
ui/actions/overview/risk-plot/risk-plot.adapter.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { getProviderDisplayName } from "@/types/providers";
|
||||
|
||||
import type {
|
||||
ProviderRiskData,
|
||||
RiskPlotDataResponse,
|
||||
RiskPlotPoint,
|
||||
} from "./types/risk-plot.types";
|
||||
|
||||
/**
|
||||
* Calculates percentage with proper rounding.
|
||||
*/
|
||||
function calculatePercentage(value: number, total: number): number {
|
||||
if (total === 0) return 0;
|
||||
return Math.round((value / total) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts raw provider risk data to the format expected by RiskPlotClient.
|
||||
*
|
||||
* @param providersRiskData - Array of risk data per provider from API
|
||||
* @returns Formatted data for the Risk Plot scatter chart
|
||||
*/
|
||||
export function adaptToRiskPlotData(
|
||||
providersRiskData: ProviderRiskData[],
|
||||
): RiskPlotDataResponse {
|
||||
const points: RiskPlotPoint[] = [];
|
||||
const providersWithoutData: RiskPlotDataResponse["providersWithoutData"] = [];
|
||||
|
||||
for (const providerData of providersRiskData) {
|
||||
// Skip providers without ThreatScore data (no completed scans)
|
||||
if (providerData.overallScore === null) {
|
||||
providersWithoutData.push({
|
||||
id: providerData.providerId,
|
||||
name: providerData.providerName,
|
||||
type: providerData.providerType,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert provider type to display name (aws -> AWS, gcp -> Google, etc.)
|
||||
const providerDisplayName = getProviderDisplayName(
|
||||
providerData.providerType,
|
||||
);
|
||||
|
||||
// Build severity data for the horizontal bar chart with percentages
|
||||
let severityData;
|
||||
let totalFailedFindings = 0;
|
||||
|
||||
if (providerData.severity) {
|
||||
const { critical, high, medium, low, informational } =
|
||||
providerData.severity;
|
||||
totalFailedFindings = critical + high + medium + low + informational;
|
||||
|
||||
severityData = [
|
||||
{
|
||||
name: "Critical",
|
||||
value: critical,
|
||||
percentage: calculatePercentage(critical, totalFailedFindings),
|
||||
},
|
||||
{
|
||||
name: "High",
|
||||
value: high,
|
||||
percentage: calculatePercentage(high, totalFailedFindings),
|
||||
},
|
||||
{
|
||||
name: "Medium",
|
||||
value: medium,
|
||||
percentage: calculatePercentage(medium, totalFailedFindings),
|
||||
},
|
||||
{
|
||||
name: "Low",
|
||||
value: low,
|
||||
percentage: calculatePercentage(low, totalFailedFindings),
|
||||
},
|
||||
{
|
||||
name: "Info",
|
||||
value: informational,
|
||||
percentage: calculatePercentage(informational, totalFailedFindings),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
points.push({
|
||||
x: providerData.overallScore ?? 0,
|
||||
y: totalFailedFindings,
|
||||
provider: providerDisplayName,
|
||||
name: providerData.providerName,
|
||||
providerId: providerData.providerId,
|
||||
severityData,
|
||||
});
|
||||
}
|
||||
|
||||
return { points, providersWithoutData };
|
||||
}
|
||||
69
ui/actions/overview/risk-plot/risk-plot.ts
Normal file
69
ui/actions/overview/risk-plot/risk-plot.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
"use server";
|
||||
|
||||
import { getFindingsBySeverity } from "@/actions/overview/findings";
|
||||
import { getThreatScore } from "@/actions/overview/threat-score";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { ProviderRiskData } from "./types/risk-plot.types";
|
||||
|
||||
/**
|
||||
* Fetches risk data for a single provider.
|
||||
* Combines ThreatScore and Severity data in parallel.
|
||||
*/
|
||||
export async function getProviderRiskData(
|
||||
provider: ProviderProps,
|
||||
): Promise<ProviderRiskData> {
|
||||
const providerId = provider.id;
|
||||
const providerType = provider.attributes.provider;
|
||||
const providerName = provider.attributes.alias || provider.attributes.uid;
|
||||
|
||||
// Fetch ThreatScore and Severity in parallel
|
||||
const [threatScoreResponse, severityResponse] = await Promise.all([
|
||||
getThreatScore({
|
||||
filters: {
|
||||
provider_id: providerId,
|
||||
include: "provider",
|
||||
},
|
||||
}),
|
||||
getFindingsBySeverity({
|
||||
filters: {
|
||||
"filter[provider_id]": providerId,
|
||||
"filter[status]": "FAIL",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Extract ThreatScore data
|
||||
// When filtering by single provider, API returns array with one item (not aggregated)
|
||||
const threatScoreData = threatScoreResponse?.data?.[0]?.attributes;
|
||||
const overallScore = threatScoreData?.overall_score
|
||||
? parseFloat(threatScoreData.overall_score)
|
||||
: null;
|
||||
const failedFindings = threatScoreData?.failed_findings ?? 0;
|
||||
|
||||
// Extract Severity data
|
||||
const severityData = severityResponse?.data?.attributes ?? null;
|
||||
|
||||
return {
|
||||
providerId,
|
||||
providerType,
|
||||
providerName,
|
||||
overallScore,
|
||||
failedFindings,
|
||||
severity: severityData,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches risk data for multiple providers in parallel.
|
||||
* Used by the Risk Plot SSR component.
|
||||
*/
|
||||
export async function getProvidersRiskData(
|
||||
providers: ProviderProps[],
|
||||
): Promise<ProviderRiskData[]> {
|
||||
const riskDataPromises = providers.map((provider) =>
|
||||
getProviderRiskData(provider),
|
||||
);
|
||||
|
||||
return Promise.all(riskDataPromises);
|
||||
}
|
||||
58
ui/actions/overview/risk-plot/types/risk-plot.types.ts
Normal file
58
ui/actions/overview/risk-plot/types/risk-plot.types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// Risk Plot Types
|
||||
// Data structures for the Risk Plot scatter chart
|
||||
|
||||
import type { BarDataPoint } from "@/components/graphs/types";
|
||||
|
||||
/**
|
||||
* Represents a single point in the Risk Plot scatter chart.
|
||||
* Each point represents a provider/account with its risk metrics.
|
||||
*/
|
||||
export interface RiskPlotPoint {
|
||||
/** ThreatScore (0-100 scale, higher = better) */
|
||||
x: number;
|
||||
/** Total failed findings count */
|
||||
y: number;
|
||||
/** Provider type display name (AWS, Azure, Google, etc.) */
|
||||
provider: string;
|
||||
/** Provider alias or UID (account identifier) */
|
||||
name: string;
|
||||
/** Provider ID for filtering/navigation */
|
||||
providerId: string;
|
||||
/** Severity breakdown for the horizontal bar chart */
|
||||
severityData?: BarDataPoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw data from the API combined for a single provider.
|
||||
* Used internally before transformation to RiskPlotPoint.
|
||||
*/
|
||||
export interface ProviderRiskData {
|
||||
providerId: string;
|
||||
providerType: string;
|
||||
providerName: string;
|
||||
/** ThreatScore overall_score (0-100 scale) */
|
||||
overallScore: number | null;
|
||||
/** Failed findings from ThreatScore snapshot */
|
||||
failedFindings: number;
|
||||
/** Severity breakdown */
|
||||
severity: {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
informational: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response structure for risk plot data fetching.
|
||||
*/
|
||||
export interface RiskPlotDataResponse {
|
||||
points: RiskPlotPoint[];
|
||||
/** Providers that have no data or no completed scans */
|
||||
providersWithoutData: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}>;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
getDateFromForTimeRange,
|
||||
type TimeRange,
|
||||
} from "@/app/(prowler)/_new-overview/severity-over-time/_constants/time-range.constants";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
@@ -9,20 +13,6 @@ import {
|
||||
FindingsSeverityOverTimeResponse,
|
||||
} from "./types";
|
||||
|
||||
const TIME_RANGE_VALUES = {
|
||||
FIVE_DAYS: "5D",
|
||||
ONE_WEEK: "1W",
|
||||
ONE_MONTH: "1M",
|
||||
} as const;
|
||||
|
||||
type TimeRange = (typeof TIME_RANGE_VALUES)[keyof typeof TIME_RANGE_VALUES];
|
||||
|
||||
const TIME_RANGE_DAYS: Record<TimeRange, number> = {
|
||||
"5D": 5,
|
||||
"1W": 7,
|
||||
"1M": 30,
|
||||
};
|
||||
|
||||
export type SeverityTrendsResult =
|
||||
| { status: "success"; data: AdaptedSeverityTrendsResponse }
|
||||
| { status: "empty" }
|
||||
@@ -76,21 +66,9 @@ export const getSeverityTrendsByTimeRange = async ({
|
||||
timeRange: TimeRange;
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
}): Promise<SeverityTrendsResult> => {
|
||||
const days = TIME_RANGE_DAYS[timeRange];
|
||||
|
||||
if (!days) {
|
||||
console.error("Invalid time range provided");
|
||||
return { status: "error" };
|
||||
}
|
||||
|
||||
const endDate = new Date();
|
||||
const startDate = new Date(endDate.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
|
||||
const dateFrom = startDate.toISOString().split("T")[0];
|
||||
|
||||
const dateFilters = {
|
||||
...filters,
|
||||
date_from: dateFrom,
|
||||
"filter[date_from]": getDateFromForTimeRange(timeRange),
|
||||
};
|
||||
|
||||
return getFindingsSeverityTrends({ filters: dateFilters });
|
||||
|
||||
@@ -11,15 +11,15 @@ export const GRAPH_TABS = [
|
||||
id: "threat-map",
|
||||
label: "Threat Map",
|
||||
},
|
||||
{
|
||||
id: "risk-plot",
|
||||
label: "Risk Plot",
|
||||
},
|
||||
// TODO: Uncomment when ready to enable other tabs
|
||||
// {
|
||||
// id: "risk-radar",
|
||||
// label: "Risk Radar",
|
||||
// },
|
||||
// {
|
||||
// id: "risk-plot",
|
||||
// label: "Risk Plot",
|
||||
// },
|
||||
] as const;
|
||||
|
||||
export type TabId = (typeof GRAPH_TABS)[number]["id"];
|
||||
|
||||
@@ -7,9 +7,9 @@ import { GraphsTabsClient } from "./_components/graphs-tabs-client";
|
||||
import { GRAPH_TABS, type TabId } from "./_config/graphs-tabs-config";
|
||||
import { FindingsViewSSR } from "./findings-view";
|
||||
import { RiskPipelineViewSSR } from "./risk-pipeline-view/risk-pipeline-view.ssr";
|
||||
import { RiskPlotSSR } from "./risk-plot/risk-plot.ssr";
|
||||
import { ThreatMapViewSSR } from "./threat-map-view/threat-map-view.ssr";
|
||||
// TODO: Uncomment when ready to enable other tabs
|
||||
// import { RiskPlotView } from "./risk-plot/risk-plot-view";
|
||||
// import { RiskRadarViewSSR } from "./risk-radar-view/risk-radar-view.ssr";
|
||||
|
||||
const LoadingFallback = () => (
|
||||
@@ -25,9 +25,9 @@ const GRAPH_COMPONENTS: Record<TabId, GraphComponent> = {
|
||||
findings: FindingsViewSSR as GraphComponent,
|
||||
"risk-pipeline": RiskPipelineViewSSR as GraphComponent,
|
||||
"threat-map": ThreatMapViewSSR as GraphComponent,
|
||||
"risk-plot": RiskPlotSSR as GraphComponent,
|
||||
// TODO: Uncomment when ready to enable other tabs
|
||||
// "risk-radar": RiskRadarViewSSR as GraphComponent,
|
||||
// "risk-plot": RiskPlotView as GraphComponent,
|
||||
};
|
||||
|
||||
interface GraphsTabsWrapperProps {
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Risk Plot Client Component
|
||||
*
|
||||
* NOTE: This component uses CSS variables (var()) for Recharts styling.
|
||||
* Recharts SVG-based components (Scatter, XAxis, YAxis, CartesianGrid, etc.)
|
||||
* do not support Tailwind classes and require raw color values or CSS variables.
|
||||
* This is a documented limitation of the Recharts library.
|
||||
* @see https://recharts.org/en-US/api
|
||||
*/
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Scatter,
|
||||
ScatterChart,
|
||||
@@ -12,6 +22,7 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import type { RiskPlotPoint } from "@/actions/overview/risk-plot";
|
||||
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
|
||||
import { AlertPill } from "@/components/graphs/shared/alert-pill";
|
||||
import { ChartLegend } from "@/components/graphs/shared/chart-legend";
|
||||
@@ -19,69 +30,83 @@ import {
|
||||
AXIS_FONT_SIZE,
|
||||
CustomXAxisTick,
|
||||
} from "@/components/graphs/shared/custom-axis-tick";
|
||||
import { getSeverityColorByRiskScore } from "@/components/graphs/shared/utils";
|
||||
import type { BarDataPoint } from "@/components/graphs/types";
|
||||
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
|
||||
import { SEVERITY_FILTER_MAP } from "@/types/severities";
|
||||
|
||||
const PROVIDER_COLORS = {
|
||||
AWS: "var(--color-bg-data-aws)",
|
||||
Azure: "var(--color-bg-data-azure)",
|
||||
Google: "var(--color-bg-data-gcp)",
|
||||
};
|
||||
// Threat Score colors (0-100 scale, higher = better)
|
||||
const THREAT_COLORS = {
|
||||
DANGER: "var(--bg-fail-primary)", // 0-30
|
||||
WARNING: "var(--bg-warning-primary)", // 31-60
|
||||
SUCCESS: "var(--bg-pass-primary)", // 61-100
|
||||
} as const;
|
||||
|
||||
export interface ScatterPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
provider: string;
|
||||
name: string;
|
||||
severityData?: BarDataPoint[];
|
||||
/**
|
||||
* Get color based on ThreatScore (0-100 scale, higher = better)
|
||||
*/
|
||||
function getThreatScoreColor(score: number): string {
|
||||
if (score > 60) return THREAT_COLORS.SUCCESS;
|
||||
if (score > 30) return THREAT_COLORS.WARNING;
|
||||
return THREAT_COLORS.DANGER;
|
||||
}
|
||||
|
||||
// Provider colors from globals.css
|
||||
const PROVIDER_COLORS: Record<string, string> = {
|
||||
AWS: "var(--bg-data-aws)",
|
||||
Azure: "var(--bg-data-azure)",
|
||||
"Google Cloud": "var(--bg-data-gcp)",
|
||||
Kubernetes: "var(--bg-data-kubernetes)",
|
||||
"Microsoft 365": "var(--bg-data-m365)",
|
||||
GitHub: "var(--bg-data-github)",
|
||||
"MongoDB Atlas": "var(--bg-data-azure)",
|
||||
"Infrastructure as Code": "var(--bg-data-kubernetes)",
|
||||
"Oracle Cloud Infrastructure": "var(--bg-data-gcp)",
|
||||
};
|
||||
|
||||
interface RiskPlotClientProps {
|
||||
data: ScatterPoint[];
|
||||
data: RiskPlotPoint[];
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: ScatterPoint }>;
|
||||
payload?: Array<{ payload: RiskPlotPoint }>;
|
||||
}
|
||||
|
||||
interface ScatterDotProps {
|
||||
// Props that Recharts passes to the shape component
|
||||
interface RechartsScatterDotProps {
|
||||
cx: number;
|
||||
cy: number;
|
||||
payload: ScatterPoint;
|
||||
selectedPoint: ScatterPoint | null;
|
||||
onSelectPoint: (point: ScatterPoint) => void;
|
||||
allData: ScatterPoint[];
|
||||
payload: RiskPlotPoint;
|
||||
}
|
||||
|
||||
interface LegendProps {
|
||||
payload?: Array<{ value: string; color: string }>;
|
||||
// Extended props for our custom scatter dot component
|
||||
interface ScatterDotProps extends RechartsScatterDotProps {
|
||||
selectedPoint: RiskPlotPoint | null;
|
||||
onSelectPoint: (point: RiskPlotPoint) => void;
|
||||
allData: RiskPlotPoint[];
|
||||
selectedProvider: string | null;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: TooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
const severityColor = getSeverityColorByRiskScore(data.x);
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
return (
|
||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
|
||||
<p className="text-text-neutral-primary mb-2 text-sm font-semibold">
|
||||
{data.name}
|
||||
</p>
|
||||
<p className="text-text-neutral-secondary text-sm font-medium">
|
||||
{/* Dynamic color from getSeverityColorByRiskScore - required inline style */}
|
||||
<span style={{ color: severityColor, fontWeight: "bold" }}>
|
||||
{data.x}
|
||||
</span>{" "}
|
||||
Risk Score
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<AlertPill value={data.y} />
|
||||
</div>
|
||||
const { name, x, y } = payload[0].payload;
|
||||
const scoreColor = getThreatScoreColor(x);
|
||||
|
||||
return (
|
||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
|
||||
<p className="text-text-neutral-primary mb-2 text-sm font-semibold">
|
||||
{name}
|
||||
</p>
|
||||
<p className="text-text-neutral-secondary text-sm font-medium">
|
||||
<span style={{ color: scoreColor, fontWeight: "bold" }}>{x}%</span>{" "}
|
||||
Threat Score
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<AlertPill value={y} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomScatterDot = ({
|
||||
@@ -91,24 +116,31 @@ const CustomScatterDot = ({
|
||||
selectedPoint,
|
||||
onSelectPoint,
|
||||
allData,
|
||||
selectedProvider,
|
||||
}: ScatterDotProps) => {
|
||||
const isSelected = selectedPoint?.name === payload.name;
|
||||
const size = isSelected ? 18 : 8;
|
||||
const selectedColor = "var(--bg-button-primary)"; // emerald-400
|
||||
const selectedColor = "var(--bg-button-primary)";
|
||||
const fill = isSelected
|
||||
? selectedColor
|
||||
: PROVIDER_COLORS[payload.provider as keyof typeof PROVIDER_COLORS] ||
|
||||
"var(--color-text-neutral-tertiary)";
|
||||
: PROVIDER_COLORS[payload.provider] || "var(--color-text-neutral-tertiary)";
|
||||
const isFaded =
|
||||
selectedProvider !== null && payload.provider !== selectedProvider;
|
||||
|
||||
const handleClick = () => {
|
||||
const fullDataItem = allData?.find(
|
||||
(d: ScatterPoint) => d.name === payload.name,
|
||||
);
|
||||
const fullDataItem = allData?.find((d) => d.name === payload.name);
|
||||
onSelectPoint?.(fullDataItem || payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<g style={{ cursor: "pointer" }} onClick={handleClick}>
|
||||
<g
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
opacity: isFaded ? 0.2 : 1,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{isSelected && (
|
||||
<>
|
||||
<circle
|
||||
@@ -143,60 +175,86 @@ const CustomScatterDot = ({
|
||||
);
|
||||
};
|
||||
|
||||
const CustomLegend = ({ payload }: LegendProps) => {
|
||||
const items =
|
||||
payload?.map((entry: { value: string; color: string }) => ({
|
||||
label: entry.value,
|
||||
color: entry.color,
|
||||
})) || [];
|
||||
|
||||
return <ChartLegend items={items} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function that creates a scatter dot shape component with closure over selection state.
|
||||
* Recharts shape prop types the callback parameter as `unknown` due to its flexible API.
|
||||
* We safely cast to RechartsScatterDotProps since we know the actual shape of props passed by Scatter.
|
||||
* @see https://recharts.org/en-US/api/Scatter#shape
|
||||
*/
|
||||
function createScatterDotShape(
|
||||
selectedPoint: ScatterPoint | null,
|
||||
onSelectPoint: (point: ScatterPoint) => void,
|
||||
allData: ScatterPoint[],
|
||||
) {
|
||||
const ScatterDotShape = (props: unknown) => {
|
||||
const dotProps = props as Omit<
|
||||
ScatterDotProps,
|
||||
"selectedPoint" | "onSelectPoint" | "allData"
|
||||
>;
|
||||
return (
|
||||
<CustomScatterDot
|
||||
{...dotProps}
|
||||
selectedPoint={selectedPoint}
|
||||
onSelectPoint={onSelectPoint}
|
||||
allData={allData}
|
||||
/>
|
||||
);
|
||||
};
|
||||
selectedPoint: RiskPlotPoint | null,
|
||||
onSelectPoint: (point: RiskPlotPoint) => void,
|
||||
allData: RiskPlotPoint[],
|
||||
selectedProvider: string | null,
|
||||
): (props: unknown) => React.JSX.Element {
|
||||
const ScatterDotShape = (props: unknown) => (
|
||||
<CustomScatterDot
|
||||
{...(props as RechartsScatterDotProps)}
|
||||
selectedPoint={selectedPoint}
|
||||
onSelectPoint={onSelectPoint}
|
||||
allData={allData}
|
||||
selectedProvider={selectedProvider}
|
||||
/>
|
||||
);
|
||||
ScatterDotShape.displayName = "ScatterDotShape";
|
||||
return ScatterDotShape;
|
||||
}
|
||||
|
||||
export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
||||
const [selectedPoint, setSelectedPoint] = useState<ScatterPoint | null>(null);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [selectedPoint, setSelectedPoint] = useState<RiskPlotPoint | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||
|
||||
const dataByProvider = data.reduce(
|
||||
// Group data by provider for separate Scatter series
|
||||
const dataByProvider = data.reduce<Record<string, RiskPlotPoint[]>>(
|
||||
(acc, point) => {
|
||||
const provider = point.provider;
|
||||
if (!acc[provider]) {
|
||||
acc[provider] = [];
|
||||
}
|
||||
acc[provider].push(point);
|
||||
(acc[point.provider] ??= []).push(point);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof data>,
|
||||
{},
|
||||
);
|
||||
|
||||
const handleSelectPoint = (point: ScatterPoint) => {
|
||||
if (selectedPoint?.name === point.name) {
|
||||
setSelectedPoint(null);
|
||||
} else {
|
||||
setSelectedPoint(point);
|
||||
const providers = Object.keys(dataByProvider);
|
||||
|
||||
const handleSelectPoint = (point: RiskPlotPoint) => {
|
||||
setSelectedPoint((current) =>
|
||||
current?.name === point.name ? null : point,
|
||||
);
|
||||
};
|
||||
|
||||
const handleProviderClick = (provider: string) => {
|
||||
setSelectedProvider((current) => (current === provider ? null : provider));
|
||||
};
|
||||
|
||||
const handleBarClick = (dataPoint: BarDataPoint) => {
|
||||
if (!selectedPoint) return;
|
||||
|
||||
// Build the URL with current filters
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
// Transform provider filters (provider_id__in -> provider__in)
|
||||
mapProviderFiltersForFindings(params);
|
||||
|
||||
// Add severity filter
|
||||
const severity = SEVERITY_FILTER_MAP[dataPoint.name];
|
||||
if (severity) {
|
||||
params.set("filter[severity__in]", severity);
|
||||
}
|
||||
|
||||
// Add provider filter for the selected point
|
||||
params.set("filter[provider__in]", selectedPoint.providerId);
|
||||
|
||||
// Add exclude muted findings filter
|
||||
params.set("filter[muted]", "false");
|
||||
|
||||
// Filter by FAIL findings
|
||||
params.set("filter[status__in]", "FAIL");
|
||||
|
||||
// Navigate to findings page
|
||||
router.push(`/findings?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -204,26 +262,18 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
||||
<div className="flex flex-1 gap-12">
|
||||
{/* Plot Section - in Card */}
|
||||
<div className="flex basis-[70%] flex-col">
|
||||
<div
|
||||
className="flex flex-1 flex-col rounded-lg border p-4"
|
||||
style={{
|
||||
borderColor: "var(--border-neutral-primary)",
|
||||
backgroundColor: "var(--bg-neutral-secondary)",
|
||||
}}
|
||||
>
|
||||
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex flex-1 flex-col rounded-lg border p-4">
|
||||
<div className="mb-4">
|
||||
<h3
|
||||
className="text-lg font-semibold"
|
||||
style={{ color: "var(--text-neutral-primary)" }}
|
||||
>
|
||||
<h3 className="text-text-neutral-primary text-lg font-semibold">
|
||||
Risk Plot
|
||||
</h3>
|
||||
<p className="text-text-neutral-tertiary mt-1 text-xs">
|
||||
Threat Score is severity-weighted, not quantity-based. Higher
|
||||
severity findings have greater impact on the score.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative w-full flex-1"
|
||||
style={{ minHeight: "400px" }}
|
||||
>
|
||||
<div className="relative min-h-[400px] w-full flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ScatterChart
|
||||
margin={{ top: 20, right: 30, bottom: 60, left: 60 }}
|
||||
@@ -237,24 +287,24 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="x"
|
||||
name="Risk Score"
|
||||
name="Threat Score"
|
||||
label={{
|
||||
value: "Risk Score",
|
||||
value: "Threat Score",
|
||||
position: "bottom",
|
||||
offset: 10,
|
||||
fill: "var(--color-text-neutral-secondary)",
|
||||
}}
|
||||
tick={CustomXAxisTick}
|
||||
tickLine={false}
|
||||
domain={[0, 10]}
|
||||
domain={[0, 100]}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="y"
|
||||
name="Failed Findings"
|
||||
name="Fail Findings"
|
||||
label={{
|
||||
value: "Failed Findings",
|
||||
value: "Fail Findings",
|
||||
angle: -90,
|
||||
position: "left",
|
||||
offset: 10,
|
||||
@@ -268,30 +318,43 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
content={<CustomLegend />}
|
||||
wrapperStyle={{ paddingTop: "40px" }}
|
||||
/>
|
||||
{Object.entries(dataByProvider).map(([provider, points]) => (
|
||||
<Scatter
|
||||
key={provider}
|
||||
name={provider}
|
||||
data={points}
|
||||
fill={
|
||||
PROVIDER_COLORS[
|
||||
provider as keyof typeof PROVIDER_COLORS
|
||||
] || "var(--color-text-neutral-tertiary)"
|
||||
PROVIDER_COLORS[provider] ||
|
||||
"var(--color-text-neutral-tertiary)"
|
||||
}
|
||||
shape={createScatterDotShape(
|
||||
selectedPoint,
|
||||
handleSelectPoint,
|
||||
data,
|
||||
selectedProvider,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Interactive Legend - below chart */}
|
||||
<div className="mt-4 flex flex-col items-start gap-2">
|
||||
<p className="text-text-neutral-tertiary pl-2 text-xs">
|
||||
Click to filter by provider
|
||||
</p>
|
||||
<ChartLegend
|
||||
items={providers.map((p) => ({
|
||||
label: p,
|
||||
color:
|
||||
PROVIDER_COLORS[p] || "var(--color-text-neutral-tertiary)",
|
||||
dataKey: p,
|
||||
}))}
|
||||
selectedItem={selectedProvider}
|
||||
onItemClick={handleProviderClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -300,28 +363,22 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
||||
{selectedPoint && selectedPoint.severityData ? (
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="mb-4">
|
||||
<h4
|
||||
className="text-base font-semibold"
|
||||
style={{ color: "var(--text-neutral-primary)" }}
|
||||
>
|
||||
<h4 className="text-text-neutral-primary text-base font-semibold">
|
||||
{selectedPoint.name}
|
||||
</h4>
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: "var(--text-neutral-tertiary)" }}
|
||||
>
|
||||
Risk Score: {selectedPoint.x} | Failed Findings:{" "}
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
Threat Score: {selectedPoint.x}% | Fail Findings:{" "}
|
||||
{selectedPoint.y}
|
||||
</p>
|
||||
</div>
|
||||
<HorizontalBarChart data={selectedPoint.severityData} />
|
||||
<HorizontalBarChart
|
||||
data={selectedPoint.severityData}
|
||||
onBarClick={handleBarClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full items-center justify-center text-center">
|
||||
<p
|
||||
className="text-sm"
|
||||
style={{ color: "var(--text-neutral-tertiary)" }}
|
||||
>
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
Select a point on the plot to view details
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { RiskPlotClient, type ScatterPoint } from "./risk-plot-client";
|
||||
|
||||
// Mock data - Risk Score (0-10) vs Failed Findings count
|
||||
const mockScatterData: ScatterPoint[] = [
|
||||
{
|
||||
x: 9.2,
|
||||
y: 1456,
|
||||
provider: "AWS",
|
||||
name: "Amazon RDS",
|
||||
severityData: [
|
||||
{ name: "Critical", value: 456 },
|
||||
{ name: "High", value: 600 },
|
||||
{ name: "Medium", value: 250 },
|
||||
{ name: "Low", value: 120 },
|
||||
{ name: "Info", value: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 8.5,
|
||||
y: 892,
|
||||
provider: "AWS",
|
||||
name: "Amazon EC2",
|
||||
severityData: [
|
||||
{ name: "Critical", value: 280 },
|
||||
{ name: "High", value: 350 },
|
||||
{ name: "Medium", value: 180 },
|
||||
{ name: "Low", value: 70 },
|
||||
{ name: "Info", value: 12 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 7.1,
|
||||
y: 445,
|
||||
provider: "AWS",
|
||||
name: "Amazon S3",
|
||||
severityData: [
|
||||
{ name: "Critical", value: 140 },
|
||||
{ name: "High", value: 180 },
|
||||
{ name: "Medium", value: 90 },
|
||||
{ name: "Low", value: 30 },
|
||||
{ name: "Info", value: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 6.3,
|
||||
y: 678,
|
||||
provider: "AWS",
|
||||
name: "AWS Lambda",
|
||||
severityData: [
|
||||
{ name: "Critical", value: 214 },
|
||||
{ name: "High", value: 270 },
|
||||
{ name: "Medium", value: 135 },
|
||||
{ name: "Low", value: 54 },
|
||||
{ name: "Info", value: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 4.2,
|
||||
y: 156,
|
||||
provider: "AWS",
|
||||
name: "AWS Backup",
|
||||
severityData: [
|
||||
{ name: "Critical", value: 49 },
|
||||
{ name: "High", value: 62 },
|
||||
{ name: "Medium", value: 31 },
|
||||
{ name: "Low", value: 12 },
|
||||
{ name: "Info", value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 8.8,
|
||||
y: 1023,
|
||||
provider: "Azure",
|
||||
name: "Azure SQL Database",
|
||||
severityData: [
|
||||
{ name: "Critical", value: 323 },
|
||||
{ name: "High", value: 410 },
|
||||
{ name: "Medium", value: 205 },
|
||||
{ name: "Low", value: 82 },
|
||||
{ name: "Info", value: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 7.9,
|
||||
y: 834,
|
||||
provider: "Azure",
|
||||
name: "Azure Virtual Machines",
|
||||
severityData: [
|
||||
{ name: "Critical", value: 263 },
|
||||
{ name: "High", value: 334 },
|
||||
{ name: "Medium", value: 167 },
|
||||
{ name: "Low", value: 67 },
|
||||
{ name: "Info", value: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 6.4,
|
||||
y: 567,
|
||||
provider: "Azure",
|
||||
name: "Azure Storage",
|
||||
severityData: [
|
||||
{ name: "Critical", value: 179 },
|
||||
{ name: "High", value: 227 },
|
||||
{ name: "Medium", value: 113 },
|
||||
{ name: "Low", value: 45 },
|
||||
{ name: "Info", value: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 5.1,
|
||||
y: 289,
|
||||
provider: "Azure",
|
||||
name: "Azure Key Vault",
|
||||
severityData: [
|
||||
{ name: "Critical", value: 91 },
|
||||
{ name: "High", value: 115 },
|
||||
{ name: "Medium", value: 58 },
|
||||
{ name: "Low", value: 23 },
|
||||
{ name: "Info", value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 7.6,
|
||||
y: 712,
|
||||
provider: "Google",
|
||||
name: "Cloud SQL",
|
||||
severityData: [
|
||||
{ name: "Critical", value: 225 },
|
||||
{ name: "High", value: 285 },
|
||||
{ name: "Medium", value: 142 },
|
||||
{ name: "Low", value: 57 },
|
||||
{ name: "Info", value: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 6.9,
|
||||
y: 623,
|
||||
provider: "Google",
|
||||
name: "Compute Engine",
|
||||
severityData: [
|
||||
{ name: "Critical", value: 197 },
|
||||
{ name: "High", value: 249 },
|
||||
{ name: "Medium", value: 124 },
|
||||
{ name: "Low", value: 50 },
|
||||
{ name: "Info", value: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 5.8,
|
||||
y: 412,
|
||||
provider: "Google",
|
||||
name: "Cloud Storage",
|
||||
severityData: [
|
||||
{ name: "Critical", value: 130 },
|
||||
{ name: "High", value: 165 },
|
||||
{ name: "Medium", value: 82 },
|
||||
{ name: "Low", value: 33 },
|
||||
{ name: "Info", value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 4.5,
|
||||
y: 198,
|
||||
provider: "Google",
|
||||
name: "Cloud Run",
|
||||
severityData: [
|
||||
{ name: "Critical", value: 63 },
|
||||
{ name: "High", value: 79 },
|
||||
{ name: "Medium", value: 39 },
|
||||
{ name: "Low", value: 16 },
|
||||
{ name: "Info", value: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 8.9,
|
||||
y: 945,
|
||||
provider: "AWS",
|
||||
name: "Amazon RDS Aurora",
|
||||
severityData: [
|
||||
{ name: "Critical", value: 299 },
|
||||
{ name: "High", value: 378 },
|
||||
{ name: "Medium", value: 189 },
|
||||
{ name: "Low", value: 76 },
|
||||
{ name: "Info", value: 3 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function RiskPlotView() {
|
||||
return <RiskPlotClient data={mockScatterData} />;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import {
|
||||
adaptToRiskPlotData,
|
||||
getProvidersRiskData,
|
||||
} from "@/actions/overview/risk-plot";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { pickFilterParams } from "../../_lib/filter-params";
|
||||
import { RiskPlotClient } from "./risk-plot-client";
|
||||
|
||||
export async function RiskPlotSSR({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
}) {
|
||||
const filters = pickFilterParams(searchParams);
|
||||
|
||||
const providerTypeFilter = filters["filter[provider_type__in]"];
|
||||
const providerIdFilter = filters["filter[provider_id__in]"];
|
||||
|
||||
// Fetch all providers
|
||||
const providersListResponse = await getProviders({ pageSize: 200 });
|
||||
const allProviders = providersListResponse?.data || [];
|
||||
|
||||
// Filter providers based on search params
|
||||
let filteredProviders = allProviders;
|
||||
|
||||
if (providerIdFilter) {
|
||||
// Filter by specific provider IDs
|
||||
const selectedIds = String(providerIdFilter)
|
||||
.split(",")
|
||||
.map((id) => id.trim());
|
||||
filteredProviders = allProviders.filter((p) => selectedIds.includes(p.id));
|
||||
} else if (providerTypeFilter) {
|
||||
// Filter by provider types
|
||||
const selectedTypes = String(providerTypeFilter)
|
||||
.split(",")
|
||||
.map((t) => t.trim().toLowerCase());
|
||||
filteredProviders = allProviders.filter((p) =>
|
||||
selectedTypes.includes(p.attributes.provider.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
// No providers to show
|
||||
if (filteredProviders.length === 0) {
|
||||
return (
|
||||
<div className="flex h-[460px] w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<Info size={48} className="text-text-neutral-tertiary" />
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
No providers available for the selected filters
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch risk data for all filtered providers in parallel
|
||||
const providersRiskData = await getProvidersRiskData(filteredProviders);
|
||||
|
||||
// Transform to chart format
|
||||
const { points, providersWithoutData } =
|
||||
adaptToRiskPlotData(providersRiskData);
|
||||
|
||||
// No data available
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<div className="flex h-[460px] w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<Info size={48} className="text-text-neutral-tertiary" />
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
No risk data available for the selected providers
|
||||
</p>
|
||||
{providersWithoutData.length > 0 && (
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
{providersWithoutData.length} provider(s) have no completed scans
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex-1 overflow-visible">
|
||||
<RiskPlotClient data={points} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,12 @@ import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends
|
||||
import { LineChart } from "@/components/graphs/line-chart";
|
||||
import { LineConfig, LineDataPoint } from "@/components/graphs/types";
|
||||
import {
|
||||
MUTED_COLOR,
|
||||
SEVERITY_LEVELS,
|
||||
SEVERITY_LINE_CONFIGS,
|
||||
SeverityLevel,
|
||||
} from "@/types/severities";
|
||||
|
||||
import { DEFAULT_TIME_RANGE } from "../_constants/time-range.constants";
|
||||
import { type TimeRange, TimeRangeSelector } from "./time-range-selector";
|
||||
|
||||
interface FindingSeverityOverTimeProps {
|
||||
@@ -24,7 +24,7 @@ export const FindingSeverityOverTime = ({
|
||||
}: FindingSeverityOverTimeProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("5D");
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(DEFAULT_TIME_RANGE);
|
||||
const [data, setData] = useState<LineDataPoint[]>(initialData);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -39,6 +39,9 @@ export const FindingSeverityOverTime = ({
|
||||
const params = new URLSearchParams();
|
||||
params.set("filter[inserted_at]", point.date);
|
||||
|
||||
// Always filter by FAIL status since this chart shows failed findings
|
||||
params.set("filter[status__in]", "FAIL");
|
||||
|
||||
// Add scan_ids filter
|
||||
if (
|
||||
point.scan_ids &&
|
||||
@@ -96,15 +99,6 @@ export const FindingSeverityOverTime = ({
|
||||
// Build line configurations from shared severity configs
|
||||
const lines: LineConfig[] = [...SEVERITY_LINE_CONFIGS];
|
||||
|
||||
// Only add muted line if data contains it
|
||||
if (data.some((item) => item.muted !== undefined)) {
|
||||
lines.push({
|
||||
dataKey: "muted",
|
||||
color: MUTED_COLOR,
|
||||
label: "Muted",
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate x-axis interval based on data length to show all labels without overlap
|
||||
const getXAxisInterval = (): number => {
|
||||
const dataLength = data.length;
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TIME_RANGE_OPTIONS = {
|
||||
FIVE_DAYS: "5D",
|
||||
ONE_WEEK: "1W",
|
||||
ONE_MONTH: "1M",
|
||||
} as const;
|
||||
import {
|
||||
TIME_RANGE_OPTIONS,
|
||||
type TimeRange,
|
||||
} from "../_constants/time-range.constants";
|
||||
|
||||
export type TimeRange =
|
||||
(typeof TIME_RANGE_OPTIONS)[keyof typeof TIME_RANGE_OPTIONS];
|
||||
export type { TimeRange };
|
||||
|
||||
interface TimeRangeSelectorProps {
|
||||
value: TimeRange;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./time-range.constants";
|
||||
@@ -0,0 +1,23 @@
|
||||
export const TIME_RANGE_OPTIONS = {
|
||||
FIVE_DAYS: "5D",
|
||||
ONE_WEEK: "1W",
|
||||
ONE_MONTH: "1M",
|
||||
} as const;
|
||||
|
||||
export type TimeRange =
|
||||
(typeof TIME_RANGE_OPTIONS)[keyof typeof TIME_RANGE_OPTIONS];
|
||||
|
||||
export const TIME_RANGE_DAYS: Record<TimeRange, number> = {
|
||||
"5D": 5,
|
||||
"1W": 7,
|
||||
"1M": 30,
|
||||
};
|
||||
|
||||
export const DEFAULT_TIME_RANGE: TimeRange = "5D";
|
||||
|
||||
export const getDateFromForTimeRange = (timeRange: TimeRange): string => {
|
||||
const days = TIME_RANGE_DAYS[timeRange];
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - days);
|
||||
return date.toISOString().split("T")[0];
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import { getFindingsSeverityTrends } from "@/actions/overview/severity-trends";
|
||||
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
import { FindingSeverityOverTime } from "./_components/finding-severity-over-time";
|
||||
import { FindingSeverityOverTimeSkeleton } from "./_components/finding-severity-over-time.skeleton";
|
||||
import { DEFAULT_TIME_RANGE } from "./_constants/time-range.constants";
|
||||
|
||||
export { FindingSeverityOverTimeSkeleton };
|
||||
|
||||
@@ -25,7 +26,11 @@ export const FindingSeverityOverTimeSSR = async ({
|
||||
searchParams,
|
||||
}: SSRComponentProps) => {
|
||||
const filters = pickFilterParams(searchParams);
|
||||
const result = await getFindingsSeverityTrends({ filters });
|
||||
|
||||
const result = await getSeverityTrendsByTimeRange({
|
||||
timeRange: DEFAULT_TIME_RANGE,
|
||||
filters,
|
||||
});
|
||||
|
||||
if (result.status === "error") {
|
||||
return <EmptyState message="Failed to load severity trends data" />;
|
||||
|
||||
@@ -195,7 +195,7 @@ const SSRComplianceContent = async ({
|
||||
{ pass: 0, fail: 0, manual: 0 },
|
||||
);
|
||||
const accordionItems = mapper.toAccordionItems(data, scanId);
|
||||
const topFailedSections = mapper.getTopFailedSections(data);
|
||||
const topFailedResult = mapper.getTopFailedSections(data);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -205,7 +205,10 @@ const SSRComplianceContent = async ({
|
||||
fail={totalRequirements.fail}
|
||||
manual={totalRequirements.manual}
|
||||
/>
|
||||
<TopFailedSectionsCard sections={topFailedSections} />
|
||||
<TopFailedSectionsCard
|
||||
sections={topFailedResult.items}
|
||||
dataType={topFailedResult.type}
|
||||
/>
|
||||
{/* <SectionsFailureRateCard categories={categoryHeatmapData} /> */}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,14 +3,20 @@
|
||||
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
|
||||
import { BarDataPoint } from "@/components/graphs/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { FailedSection } from "@/types/compliance";
|
||||
import {
|
||||
FailedSection,
|
||||
TOP_FAILED_DATA_TYPE,
|
||||
TopFailedDataType,
|
||||
} from "@/types/compliance";
|
||||
|
||||
interface TopFailedSectionsCardProps {
|
||||
sections: FailedSection[];
|
||||
dataType?: TopFailedDataType;
|
||||
}
|
||||
|
||||
export function TopFailedSectionsCard({
|
||||
sections,
|
||||
dataType = TOP_FAILED_DATA_TYPE.SECTIONS,
|
||||
}: TopFailedSectionsCardProps) {
|
||||
// Transform FailedSection[] to BarDataPoint[]
|
||||
const total = sections.reduce((sum, section) => sum + section.total, 0);
|
||||
@@ -22,13 +28,18 @@ export function TopFailedSectionsCard({
|
||||
color: "var(--bg-fail-primary)",
|
||||
}));
|
||||
|
||||
const title =
|
||||
dataType === TOP_FAILED_DATA_TYPE.REQUIREMENTS
|
||||
? "Top Failed Requirements"
|
||||
: "Top Failed Sections";
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
className="flex min-h-[372px] w-full flex-col sm:min-w-[500px]"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Failed Sections</CardTitle>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 items-center justify-start">
|
||||
<HorizontalBarChart data={barData} />
|
||||
|
||||
@@ -68,10 +68,31 @@ const CustomLineTooltip = ({
|
||||
const typedPayload = payload as unknown as TooltipPayloadItem[];
|
||||
|
||||
// Filter payload if a line is selected or hovered
|
||||
const displayPayload = filterLine
|
||||
const filteredPayload = filterLine
|
||||
? typedPayload.filter((item) => item.dataKey === filterLine)
|
||||
: typedPayload;
|
||||
|
||||
// Sort by severity order: critical, high, medium, low, informational
|
||||
const severityOrder = [
|
||||
"critical",
|
||||
"high",
|
||||
"medium",
|
||||
"low",
|
||||
"informational",
|
||||
] as const;
|
||||
const displayPayload = [...filteredPayload].sort((a, b) => {
|
||||
const aIndex = severityOrder.indexOf(
|
||||
a.dataKey as (typeof severityOrder)[number],
|
||||
);
|
||||
const bIndex = severityOrder.indexOf(
|
||||
b.dataKey as (typeof severityOrder)[number],
|
||||
);
|
||||
// Items not in severityOrder go to the end
|
||||
if (aIndex === -1) return 1;
|
||||
if (bIndex === -1) return -1;
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
|
||||
if (displayPayload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -96,12 +117,17 @@ const CustomLineTooltip = ({
|
||||
|
||||
return (
|
||||
<div key={item.dataKey} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: item.stroke }}
|
||||
/>
|
||||
<span className="text-text-neutral-primary text-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: item.stroke }}
|
||||
/>
|
||||
<span className="text-text-neutral-secondary text-sm">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-text-neutral-primary text-sm font-medium">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
@@ -260,7 +286,7 @@ export function LineChart({
|
||||
|
||||
<div className="mt-4 flex flex-col items-start gap-2">
|
||||
<p className="text-text-neutral-tertiary pl-2 text-xs">
|
||||
Click to filter by severity.
|
||||
Click to filter by severity
|
||||
</p>
|
||||
<ChartLegend
|
||||
items={legendItems}
|
||||
|
||||
@@ -32,11 +32,11 @@ export function AlertPill({
|
||||
>
|
||||
<AlertTriangle
|
||||
size={iconSize}
|
||||
style={{ color: "var(--color-text-text-error)" }}
|
||||
style={{ color: "var(--color-text-error-primary)" }}
|
||||
/>
|
||||
<span
|
||||
className={cn(textSizeClass, "font-semibold")}
|
||||
style={{ color: "var(--color-text-text-error)" }}
|
||||
style={{ color: "var(--color-text-error-primary)" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
|
||||
@@ -1,14 +1,65 @@
|
||||
import {
|
||||
Category,
|
||||
CategoryData,
|
||||
Control,
|
||||
FailedSection,
|
||||
Framework,
|
||||
Requirement,
|
||||
REQUIREMENT_STATUS,
|
||||
RequirementItemData,
|
||||
RequirementsData,
|
||||
RequirementStatus,
|
||||
TOP_FAILED_DATA_TYPE,
|
||||
TopFailedDataType,
|
||||
TopFailedResult,
|
||||
} from "@/types/compliance";
|
||||
|
||||
// Type for the internal map used in getTopFailedSections
|
||||
interface FailedSectionData {
|
||||
total: number;
|
||||
types: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the TopFailedResult from the accumulated map data
|
||||
*/
|
||||
const buildTopFailedResult = (
|
||||
map: Map<string, FailedSectionData>,
|
||||
type: TopFailedDataType,
|
||||
): TopFailedResult => ({
|
||||
items: Array.from(map.entries())
|
||||
.map(([name, data]): FailedSection => ({ name, ...data }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 5),
|
||||
type,
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks if the framework uses a flat structure (requirements directly on framework)
|
||||
* vs hierarchical structure (categories -> controls -> requirements)
|
||||
*/
|
||||
const hasFlatStructure = (frameworks: Framework[]): boolean =>
|
||||
frameworks.some(
|
||||
(framework) =>
|
||||
(framework.requirements?.length ?? 0) > 0 &&
|
||||
framework.categories.length === 0,
|
||||
);
|
||||
|
||||
/**
|
||||
* Increments the failed count for a given name in the map
|
||||
*/
|
||||
const incrementFailedCount = (
|
||||
map: Map<string, FailedSectionData>,
|
||||
name: string,
|
||||
type: string,
|
||||
): void => {
|
||||
if (!map.has(name)) {
|
||||
map.set(name, { total: 0, types: {} });
|
||||
}
|
||||
const data = map.get(name)!;
|
||||
data.total += 1;
|
||||
data.types[type] = (data.types[type] || 0) + 1;
|
||||
};
|
||||
|
||||
export const updateCounters = (
|
||||
target: { pass: number; fail: number; manual: number },
|
||||
status: RequirementStatus,
|
||||
@@ -24,38 +75,45 @@ export const updateCounters = (
|
||||
|
||||
export const getTopFailedSections = (
|
||||
mappedData: Framework[],
|
||||
): FailedSection[] => {
|
||||
const failedSectionMap = new Map();
|
||||
): TopFailedResult => {
|
||||
const failedSectionMap = new Map<string, FailedSectionData>();
|
||||
|
||||
if (hasFlatStructure(mappedData)) {
|
||||
// Handle flat structure: count failed requirements directly
|
||||
mappedData.forEach((framework) => {
|
||||
const directRequirements = framework.requirements ?? [];
|
||||
|
||||
directRequirements.forEach((requirement) => {
|
||||
if (requirement.status === REQUIREMENT_STATUS.FAIL) {
|
||||
const type =
|
||||
typeof requirement.type === "string" ? requirement.type : "Fails";
|
||||
incrementFailedCount(failedSectionMap, requirement.name, type);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return buildTopFailedResult(
|
||||
failedSectionMap,
|
||||
TOP_FAILED_DATA_TYPE.REQUIREMENTS,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle hierarchical structure: count by category (section)
|
||||
mappedData.forEach((framework) => {
|
||||
framework.categories.forEach((category) => {
|
||||
category.controls.forEach((control) => {
|
||||
control.requirements.forEach((requirement) => {
|
||||
if (requirement.status === REQUIREMENT_STATUS.FAIL) {
|
||||
const sectionName = category.name;
|
||||
|
||||
if (!failedSectionMap.has(sectionName)) {
|
||||
failedSectionMap.set(sectionName, { total: 0, types: {} });
|
||||
}
|
||||
|
||||
const sectionData = failedSectionMap.get(sectionName);
|
||||
sectionData.total += 1;
|
||||
|
||||
const type = requirement.type || "Fails";
|
||||
|
||||
sectionData.types[type as string] =
|
||||
(sectionData.types[type as string] || 0) + 1;
|
||||
const type =
|
||||
typeof requirement.type === "string" ? requirement.type : "Fails";
|
||||
incrementFailedCount(failedSectionMap, category.name, type);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Convert in descending order and slice top 5
|
||||
return Array.from(failedSectionMap.entries())
|
||||
.map(([name, data]) => ({ name, ...data }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 5); // Top 5
|
||||
return buildTopFailedResult(failedSectionMap, TOP_FAILED_DATA_TYPE.SECTIONS);
|
||||
};
|
||||
|
||||
export const calculateCategoryHeatmapData = (
|
||||
@@ -146,9 +204,9 @@ export const findOrCreateFramework = (
|
||||
};
|
||||
|
||||
export const findOrCreateCategory = (
|
||||
categories: any[],
|
||||
categories: Category[],
|
||||
categoryName: string,
|
||||
) => {
|
||||
): Category => {
|
||||
let category = categories.find((c) => c.name === categoryName);
|
||||
if (!category) {
|
||||
category = {
|
||||
@@ -163,7 +221,10 @@ export const findOrCreateCategory = (
|
||||
return category;
|
||||
};
|
||||
|
||||
export const findOrCreateControl = (controls: any[], controlLabel: string) => {
|
||||
export const findOrCreateControl = (
|
||||
controls: Control[],
|
||||
controlLabel: string,
|
||||
): Control => {
|
||||
let control = controls.find((c) => c.label === controlLabel);
|
||||
if (!control) {
|
||||
control = {
|
||||
@@ -178,7 +239,7 @@ export const findOrCreateControl = (controls: any[], controlLabel: string) => {
|
||||
return control;
|
||||
};
|
||||
|
||||
export const calculateFrameworkCounters = (frameworks: Framework[]) => {
|
||||
export const calculateFrameworkCounters = (frameworks: Framework[]): void => {
|
||||
frameworks.forEach((framework) => {
|
||||
// Reset framework counters
|
||||
framework.pass = 0;
|
||||
@@ -186,9 +247,9 @@ export const calculateFrameworkCounters = (frameworks: Framework[]) => {
|
||||
framework.manual = 0;
|
||||
|
||||
// Handle flat structure (requirements directly in framework)
|
||||
const directRequirements = (framework as any).requirements || [];
|
||||
const directRequirements = framework.requirements ?? [];
|
||||
if (directRequirements.length > 0) {
|
||||
directRequirements.forEach((requirement: Requirement) => {
|
||||
directRequirements.forEach((requirement) => {
|
||||
updateCounters(framework, requirement.status);
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import { createElement, ReactNode } from "react";
|
||||
|
||||
import { AWSWellArchitectedCustomDetails } from "@/components/compliance/compliance-custom-details/aws-well-architected-details";
|
||||
import { C5CustomDetails } from "@/components/compliance/compliance-custom-details/c5-details";
|
||||
@@ -14,10 +14,10 @@ import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
|
||||
import {
|
||||
AttributesData,
|
||||
CategoryData,
|
||||
FailedSection,
|
||||
Framework,
|
||||
Requirement,
|
||||
RequirementsData,
|
||||
TopFailedResult,
|
||||
} from "@/types/compliance";
|
||||
|
||||
import {
|
||||
@@ -74,9 +74,9 @@ export interface ComplianceMapper {
|
||||
data: Framework[],
|
||||
scanId: string | undefined,
|
||||
) => AccordionItemProps[];
|
||||
getTopFailedSections: (mappedData: Framework[]) => FailedSection[];
|
||||
getTopFailedSections: (mappedData: Framework[]) => TopFailedResult;
|
||||
calculateCategoryHeatmapData: (complianceData: Framework[]) => CategoryData[];
|
||||
getDetailsComponent: (requirement: Requirement) => React.ReactNode;
|
||||
getDetailsComponent: (requirement: Requirement) => ReactNode;
|
||||
}
|
||||
|
||||
const getDefaultMapper = (): ComplianceMapper => ({
|
||||
@@ -86,7 +86,7 @@ const getDefaultMapper = (): ComplianceMapper => ({
|
||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||
calculateCategoryHeatmapData(data),
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
React.createElement(GenericCustomDetails, { requirement }),
|
||||
createElement(GenericCustomDetails, { requirement }),
|
||||
});
|
||||
|
||||
const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
||||
@@ -97,7 +97,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||
calculateCategoryHeatmapData(data),
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
React.createElement(C5CustomDetails, { requirement }),
|
||||
createElement(C5CustomDetails, { requirement }),
|
||||
},
|
||||
ENS: {
|
||||
mapComplianceData: mapENSComplianceData,
|
||||
@@ -106,7 +106,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||
calculateCategoryHeatmapData(data),
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
React.createElement(ENSCustomDetails, { requirement }),
|
||||
createElement(ENSCustomDetails, { requirement }),
|
||||
},
|
||||
ISO27001: {
|
||||
mapComplianceData: mapISOComplianceData,
|
||||
@@ -115,7 +115,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||
calculateCategoryHeatmapData(data),
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
React.createElement(ISOCustomDetails, { requirement }),
|
||||
createElement(ISOCustomDetails, { requirement }),
|
||||
},
|
||||
CIS: {
|
||||
mapComplianceData: mapCISComplianceData,
|
||||
@@ -124,7 +124,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||
calculateCategoryHeatmapData(data),
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
React.createElement(CISCustomDetails, { requirement }),
|
||||
createElement(CISCustomDetails, { requirement }),
|
||||
},
|
||||
"AWS-Well-Architected-Framework-Security-Pillar": {
|
||||
mapComplianceData: mapAWSWellArchitectedComplianceData,
|
||||
@@ -133,7 +133,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||
calculateCategoryHeatmapData(data),
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
React.createElement(AWSWellArchitectedCustomDetails, { requirement }),
|
||||
createElement(AWSWellArchitectedCustomDetails, { requirement }),
|
||||
},
|
||||
"AWS-Well-Architected-Framework-Reliability-Pillar": {
|
||||
mapComplianceData: mapAWSWellArchitectedComplianceData,
|
||||
@@ -142,7 +142,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||
calculateCategoryHeatmapData(data),
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
React.createElement(AWSWellArchitectedCustomDetails, { requirement }),
|
||||
createElement(AWSWellArchitectedCustomDetails, { requirement }),
|
||||
},
|
||||
"KISA-ISMS-P": {
|
||||
mapComplianceData: mapKISAComplianceData,
|
||||
@@ -151,7 +151,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||
calculateCategoryHeatmapData(data),
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
React.createElement(KISACustomDetails, { requirement }),
|
||||
createElement(KISACustomDetails, { requirement }),
|
||||
},
|
||||
"MITRE-ATTACK": {
|
||||
mapComplianceData: mapMITREComplianceData,
|
||||
@@ -159,7 +159,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
||||
getTopFailedSections: getMITRETopFailedSections,
|
||||
calculateCategoryHeatmapData: calculateMITRECategoryHeatmapData,
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
React.createElement(MITRECustomDetails, { requirement }),
|
||||
createElement(MITRECustomDetails, { requirement }),
|
||||
},
|
||||
ProwlerThreatScore: {
|
||||
mapComplianceData: mapThetaComplianceData,
|
||||
@@ -168,7 +168,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
||||
calculateCategoryHeatmapData: (complianceData: Framework[]) =>
|
||||
calculateCategoryHeatmapData(complianceData),
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
React.createElement(ThreatCustomDetails, { requirement }),
|
||||
createElement(ThreatCustomDetails, { requirement }),
|
||||
},
|
||||
CCC: {
|
||||
mapComplianceData: mapCCCComplianceData,
|
||||
@@ -177,7 +177,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||
calculateCategoryHeatmapData(data),
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
React.createElement(CCCCustomDetails, { requirement }),
|
||||
createElement(CCCCustomDetails, { requirement }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
REQUIREMENT_STATUS,
|
||||
RequirementsData,
|
||||
RequirementStatus,
|
||||
TOP_FAILED_DATA_TYPE,
|
||||
TopFailedResult,
|
||||
} from "@/types/compliance";
|
||||
|
||||
import {
|
||||
@@ -20,6 +22,12 @@ import {
|
||||
findOrCreateFramework,
|
||||
} from "./commons";
|
||||
|
||||
// Type for the internal map used in getTopFailedSections
|
||||
interface FailedSectionData {
|
||||
total: number;
|
||||
types: Record<string, number>;
|
||||
}
|
||||
|
||||
export const mapComplianceData = (
|
||||
attributesData: AttributesData,
|
||||
requirementsData: RequirementsData,
|
||||
@@ -92,9 +100,9 @@ export const mapComplianceData = (
|
||||
}) || [],
|
||||
};
|
||||
|
||||
// Add requirement directly to framework (store in a special property)
|
||||
(framework as any).requirements = (framework as any).requirements || [];
|
||||
(framework as any).requirements.push(requirement);
|
||||
// Add requirement directly to framework (flat structure - no categories)
|
||||
framework.requirements = framework.requirements ?? [];
|
||||
framework.requirements.push(requirement);
|
||||
}
|
||||
|
||||
// Calculate counters using common helper (works with flat structure)
|
||||
@@ -108,63 +116,63 @@ export const toAccordionItems = (
|
||||
scanId: string | undefined,
|
||||
): AccordionItemProps[] => {
|
||||
return data.flatMap((framework) => {
|
||||
const requirements = (framework as any).requirements || [];
|
||||
const requirements = framework.requirements ?? [];
|
||||
|
||||
// Filter out requirements without metadata (can't be displayed in accordion)
|
||||
const displayableRequirements = requirements.filter(
|
||||
(requirement: Requirement) => requirement.hasMetadata !== false,
|
||||
(requirement) => requirement.hasMetadata !== false,
|
||||
);
|
||||
|
||||
return displayableRequirements.map(
|
||||
(requirement: Requirement, i: number) => {
|
||||
const itemKey = `${framework.name}-req-${i}`;
|
||||
return displayableRequirements.map((requirement, i) => {
|
||||
const itemKey = `${framework.name}-req-${i}`;
|
||||
|
||||
return {
|
||||
key: itemKey,
|
||||
title: (
|
||||
<ComplianceAccordionRequirementTitle
|
||||
type=""
|
||||
name={requirement.name}
|
||||
status={requirement.status as FindingStatus}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
<ClientAccordionContent
|
||||
key={`content-${itemKey}`}
|
||||
requirement={requirement}
|
||||
scanId={scanId || ""}
|
||||
framework={framework.name}
|
||||
disableFindings={
|
||||
requirement.check_ids.length === 0 && requirement.manual === 0
|
||||
}
|
||||
/>
|
||||
),
|
||||
items: [],
|
||||
};
|
||||
},
|
||||
);
|
||||
return {
|
||||
key: itemKey,
|
||||
title: (
|
||||
<ComplianceAccordionRequirementTitle
|
||||
type=""
|
||||
name={requirement.name}
|
||||
status={requirement.status as FindingStatus}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
<ClientAccordionContent
|
||||
key={`content-${itemKey}`}
|
||||
requirement={requirement}
|
||||
scanId={scanId || ""}
|
||||
framework={framework.name}
|
||||
disableFindings={
|
||||
requirement.check_ids.length === 0 && requirement.manual === 0
|
||||
}
|
||||
/>
|
||||
),
|
||||
items: [],
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Custom function for MITRE to get top failed sections grouped by tactics
|
||||
export const getTopFailedSections = (
|
||||
mappedData: Framework[],
|
||||
): FailedSection[] => {
|
||||
const failedSectionMap = new Map();
|
||||
): TopFailedResult => {
|
||||
const failedSectionMap = new Map<string, FailedSectionData>();
|
||||
|
||||
mappedData.forEach((framework) => {
|
||||
const requirements = (framework as any).requirements || [];
|
||||
const requirements = framework.requirements ?? [];
|
||||
|
||||
requirements.forEach((requirement: Requirement) => {
|
||||
requirements.forEach((requirement) => {
|
||||
if (requirement.status === REQUIREMENT_STATUS.FAIL) {
|
||||
const tactics = (requirement.tactics as string[]) || [];
|
||||
const tactics = Array.isArray(requirement.tactics)
|
||||
? (requirement.tactics as string[])
|
||||
: [];
|
||||
|
||||
tactics.forEach((tactic) => {
|
||||
if (!failedSectionMap.has(tactic)) {
|
||||
failedSectionMap.set(tactic, { total: 0, types: {} });
|
||||
}
|
||||
|
||||
const sectionData = failedSectionMap.get(tactic);
|
||||
const sectionData = failedSectionMap.get(tactic)!;
|
||||
sectionData.total += 1;
|
||||
|
||||
const type = "Fails";
|
||||
@@ -175,10 +183,13 @@ export const getTopFailedSections = (
|
||||
});
|
||||
|
||||
// Convert in descending order and slice top 5
|
||||
return Array.from(failedSectionMap.entries())
|
||||
.map(([name, data]) => ({ name, ...data }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 5); // Top 5
|
||||
return {
|
||||
items: Array.from(failedSectionMap.entries())
|
||||
.map(([name, data]): FailedSection => ({ name, ...data }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 5),
|
||||
type: TOP_FAILED_DATA_TYPE.SECTIONS,
|
||||
};
|
||||
};
|
||||
|
||||
// Custom function for MITRE to calculate category heatmap data grouped by tactics
|
||||
@@ -197,10 +208,12 @@ export const calculateCategoryHeatmapData = (
|
||||
|
||||
// Aggregate data by tactics
|
||||
complianceData.forEach((framework) => {
|
||||
const requirements = (framework as any).requirements || [];
|
||||
const requirements = framework.requirements ?? [];
|
||||
|
||||
requirements.forEach((requirement: Requirement) => {
|
||||
const tactics = (requirement.tactics as string[]) || [];
|
||||
requirements.forEach((requirement) => {
|
||||
const tactics = Array.isArray(requirement.tactics)
|
||||
? (requirement.tactics as string[])
|
||||
: [];
|
||||
|
||||
tactics.forEach((tactic) => {
|
||||
const existing = tacticMap.get(tactic) || {
|
||||
|
||||
@@ -68,12 +68,27 @@ export interface Framework {
|
||||
fail: number;
|
||||
manual: number;
|
||||
categories: Category[];
|
||||
// Optional: flat structure for frameworks like MITRE that don't have categories
|
||||
requirements?: Requirement[];
|
||||
}
|
||||
|
||||
export interface FailedSection {
|
||||
name: string;
|
||||
total: number;
|
||||
types?: { [key: string]: number };
|
||||
types?: Record<string, number>;
|
||||
}
|
||||
|
||||
export const TOP_FAILED_DATA_TYPE = {
|
||||
SECTIONS: "sections",
|
||||
REQUIREMENTS: "requirements",
|
||||
} as const;
|
||||
|
||||
export type TopFailedDataType =
|
||||
(typeof TOP_FAILED_DATA_TYPE)[keyof typeof TOP_FAILED_DATA_TYPE];
|
||||
|
||||
export interface TopFailedResult {
|
||||
items: FailedSection[];
|
||||
type: TopFailedDataType;
|
||||
}
|
||||
|
||||
export interface RequirementsTotals {
|
||||
@@ -92,7 +107,7 @@ export interface ENSAttributesMetadata {
|
||||
Nivel: string;
|
||||
Dimensiones: string[];
|
||||
ModoEjecucion: string;
|
||||
Dependencias: any[];
|
||||
Dependencias: unknown[];
|
||||
}
|
||||
|
||||
export interface ISO27001AttributesMetadata {
|
||||
|
||||
Reference in New Issue
Block a user