Merge branch 'master' into PROWLER-465-add-alibaba-cloud-provider-support-to-the-api

This commit is contained in:
pedrooot
2025-12-09 17:11:19 +01:00
33 changed files with 1287 additions and 727 deletions

View File

@@ -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]

View File

@@ -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]

View File

@@ -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 }}"}'

View File

@@ -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'

View File

@@ -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]

View File

@@ -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"),

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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";

View File

@@ -0,0 +1,4 @@
// Risk Plot Actions
export * from "./risk-plot";
export * from "./risk-plot.adapter";
export * from "./types/risk-plot.types";

View 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 };
}

View 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);
}

View 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;
}>;
}

View File

@@ -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 });

View File

@@ -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"];

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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} />;
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1 @@
export * from "./time-range.constants";

View File

@@ -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];
};

View File

@@ -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" />;

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 }),
},
});

View File

@@ -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) || {

View File

@@ -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 {