Compare commits

...

29 Commits

Author SHA1 Message Date
César Arroba 0449c4d466 chore: update changelogs for v5.18.3 2026-02-18 15:14:41 +01:00
Prowler Bot 987fad3aaf fix(ui): replace HeroUI dropdowns with shadcn selects (#10111)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-02-18 13:55:15 +01:00
Prowler Bot b6c7e24856 fix(api): gcp project id validation for legacy projects (#10109)
Co-authored-by: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-02-18 11:31:21 +01:00
Prowler Bot 9a5fe3a809 fix(compliance): remove account_id and location for manual reqs (#10106)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-02-18 09:58:08 +01:00
Prowler Bot c08d42036c fix(ui): changes for update credetials for AliababaCloud provider (#10099)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-02-17 16:30:20 +01:00
Prowler Bot c1244ea9b8 fix(kms): detect public access for any KMS action, not just kms:* (#10075)
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jfagoagas <16007882+jfagoagas@users.noreply.github.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-02-16 10:42:13 +01:00
Prowler Bot efc9bc61fd fix(sdk): update openstacksdk to fix pip install on systems without C compiler (#10062)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Co-authored-by: HugoPBrito <hugopbrit@gmail.com>
2026-02-13 16:05:36 +01:00
Prowler Bot 5228ba7af9 chore(api): Bump version to v1.19.3 (#10050)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-12 12:47:31 +01:00
Prowler Bot a9c54a4771 chore(release): Bump version to v5.18.3 (#10049)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-12 12:47:10 +01:00
Prowler Bot 4875ee3506 docs: Update version to v5.18.2 (#10051)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-12 12:46:44 +01:00
César Arroba ce134a01c3 chore: update changelogs 2026-02-12 11:44:46 +01:00
Prowler Bot 7e25ca719b chore(ui): improve changelog wording for v1.18.2 bug fixes (#10045)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-02-12 11:38:10 +01:00
Prowler Bot 0e79b70fee fix(ui): reapply filter transition opacity overlay on filter changes (#10037)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-02-11 22:25:46 +01:00
Prowler Bot b424eb302e fix(ui): move default muted filter from middleware to client-side hook (#10035)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-02-11 22:05:56 +01:00
Prowler Bot f15cf20b4f fix(ui): fix filter navigation and pagination bugs in findings and scans pages (#10015)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-02-11 11:31:29 +01:00
Prowler Bot 366f10cf0c fix(sdk): mute HPACK library logs to prevent token leakage (#10014)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-02-11 11:15:08 +01:00
Prowler Bot 6bba654059 fix(saml): prevent SAML role mapping from removing last manage-account user (#10009)
Co-authored-by: Adrián Peña <adrianjpr@gmail.com>
2026-02-11 09:49:02 +01:00
Prowler Bot 6d94f0fcc3 fix(github): combine --repository and --organization flags for scan scoping (#10005)
Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
2026-02-10 14:44:01 +01:00
Prowler Bot b8da7c9619 fix(ui): replace HeroUI dropdowns with Radix ActionDropdown to fix overlay conflict (#10002)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-02-10 10:47:06 +01:00
Prowler Bot 6d235278dc fix(ui): guard against unknown provider types in ProviderTypeSelector (#9995)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-02-10 09:56:30 +01:00
Prowler Bot 9285ad3569 chore(api): Bump version to v1.19.2 (#9980)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-06 12:45:59 +01:00
Prowler Bot 88caa9c198 chore(release): Bump version to v5.18.2 (#9979)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-06 12:45:46 +01:00
Prowler Bot cb4892dbe3 docs: Update version to v5.18.1 (#9981)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-06 12:45:32 +01:00
César Arroba 5c4386df5f chore: update UI changelog 2026-02-06 12:03:09 +01:00
Prowler Bot fd05080d12 fix(ui): optimize scans page polling to avoid redundant API calls (#9976)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-02-06 11:24:17 +01:00
Prowler Bot 4ce82d831a chore(api): Bump version to v1.19.1 (#9969)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-05 22:18:18 +01:00
Prowler Bot 4d47e6c2f1 chore(release): Bump version to v5.18.1 (#9967)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-05 22:17:16 +01:00
Prowler Bot 8dc6b3e2a3 docs: Update version to v5.18.0 (#9966)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-05 22:16:57 +01:00
Prowler Bot e1f70321c8 chore(api): Update prowler dependency to v5.18 for release 5.18.0 (#9963)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-05 14:06:12 +01:00
92 changed files with 2495 additions and 2063 deletions
+10 -7
View File
@@ -14,7 +14,7 @@ ignored:
- "*.md"
- "**/*.md"
- mkdocs.yml
# Config files that don't affect runtime
- .gitignore
- .gitattributes
@@ -23,7 +23,7 @@ ignored:
- .backportrc.json
- CODEOWNERS
- LICENSE
# IDE/Editor configs
- .vscode/**
- .idea/**
@@ -31,10 +31,13 @@ ignored:
# Examples and contrib (not production code)
- examples/**
- contrib/**
# Skills (AI agent configs, not runtime)
- skills/**
# E2E setup helpers (not runnable tests)
- ui/tests/setups/**
# Permissions docs
- permissions/**
@@ -47,18 +50,18 @@ critical:
- prowler/config/**
- prowler/exceptions/**
- prowler/providers/common/**
# API Core
- api/src/backend/api/models.py
- api/src/backend/config/**
- api/src/backend/conftest.py
# UI Core
- ui/lib/**
- ui/types/**
- ui/config/**
- ui/middleware.ts
# CI/CD changes
- .github/workflows/**
- .github/test-impact.yml
+2 -1
View File
@@ -61,8 +61,9 @@ jobs:
- name: Safety
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry run safety check --ignore 79023,79027
run: poetry run safety check --ignore 79023,79027,84420
# TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0
# TODO: 84420 from `azure-core`, that we need fix alltogether with `azure-cli-core` and `knack`
- name: Vulture
if: steps.check-changes.outputs.any_changed == 'true'
+11 -4
View File
@@ -25,7 +25,7 @@ jobs:
e2e-tests:
needs: impact-analysis
if: |
github.repository == 'prowler-cloud/prowler' &&
github.repository == 'prowler-cloud/prowler' &&
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
runs-on: ubuntu-latest
env:
@@ -200,7 +200,14 @@ jobs:
# e.g., "ui/tests/providers/**" -> "tests/providers"
TEST_PATHS="${{ env.E2E_TEST_PATHS }}"
# Remove ui/ prefix and convert ** to empty (playwright handles recursion)
TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u | tr '\n' ' ')
TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u)
# Drop auth setup helpers (not runnable test suites)
TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/')
if [[ -z "$TEST_PATHS" ]]; then
echo "No runnable E2E test paths after filtering setups"
exit 0
fi
TEST_PATHS=$(echo "$TEST_PATHS" | tr '\n' ' ')
echo "Resolved test paths: $TEST_PATHS"
pnpm exec playwright test $TEST_PATHS
fi
@@ -222,8 +229,8 @@ jobs:
skip-e2e:
needs: impact-analysis
if: |
github.repository == 'prowler-cloud/prowler' &&
needs.impact-analysis.outputs.has-ui-e2e != 'true' &&
github.repository == 'prowler-cloud/prowler' &&
needs.impact-analysis.outputs.has-ui-e2e != 'true' &&
needs.impact-analysis.outputs.run-all != 'true'
runs-on: ubuntu-latest
steps:
-172
View File
@@ -1,172 +0,0 @@
name: UI - E2E Tests
on:
pull_request:
branches:
- master
- "v5.*"
paths:
- '.github/workflows/ui-e2e-tests.yml'
- 'ui/**'
jobs:
e2e-tests:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
env:
AUTH_SECRET: 'fallback-ci-secret-for-testing'
AUTH_TRUST_HOST: true
NEXTAUTH_URL: 'http://localhost:3000'
NEXT_PUBLIC_API_BASE_URL: 'http://localhost:8080/api/v1'
E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }}
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }}
E2E_AWS_PROVIDER_ACCESS_KEY: ${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}
E2E_AWS_PROVIDER_SECRET_KEY: ${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}
E2E_AWS_PROVIDER_ROLE_ARN: ${{ secrets.E2E_AWS_PROVIDER_ROLE_ARN }}
E2E_AZURE_SUBSCRIPTION_ID: ${{ secrets.E2E_AZURE_SUBSCRIPTION_ID }}
E2E_AZURE_CLIENT_ID: ${{ secrets.E2E_AZURE_CLIENT_ID }}
E2E_AZURE_SECRET_ID: ${{ secrets.E2E_AZURE_SECRET_ID }}
E2E_AZURE_TENANT_ID: ${{ secrets.E2E_AZURE_TENANT_ID }}
E2E_M365_DOMAIN_ID: ${{ secrets.E2E_M365_DOMAIN_ID }}
E2E_M365_CLIENT_ID: ${{ secrets.E2E_M365_CLIENT_ID }}
E2E_M365_SECRET_ID: ${{ secrets.E2E_M365_SECRET_ID }}
E2E_M365_TENANT_ID: ${{ secrets.E2E_M365_TENANT_ID }}
E2E_M365_CERTIFICATE_CONTENT: ${{ secrets.E2E_M365_CERTIFICATE_CONTENT }}
E2E_KUBERNETES_CONTEXT: 'kind-kind'
E2E_KUBERNETES_KUBECONFIG_PATH: /home/runner/.kube/config
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY: ${{ secrets.E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY }}
E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }}
E2E_GITHUB_APP_ID: ${{ secrets.E2E_GITHUB_APP_ID }}
E2E_GITHUB_BASE64_APP_PRIVATE_KEY: ${{ secrets.E2E_GITHUB_BASE64_APP_PRIVATE_KEY }}
E2E_GITHUB_USERNAME: ${{ secrets.E2E_GITHUB_USERNAME }}
E2E_GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.E2E_GITHUB_PERSONAL_ACCESS_TOKEN }}
E2E_GITHUB_ORGANIZATION: ${{ secrets.E2E_GITHUB_ORGANIZATION }}
E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN: ${{ secrets.E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN }}
E2E_ORGANIZATION_ID: ${{ secrets.E2E_ORGANIZATION_ID }}
E2E_OCI_TENANCY_ID: ${{ secrets.E2E_OCI_TENANCY_ID }}
E2E_OCI_USER_ID: ${{ secrets.E2E_OCI_USER_ID }}
E2E_OCI_FINGERPRINT: ${{ secrets.E2E_OCI_FINGERPRINT }}
E2E_OCI_KEY_CONTENT: ${{ secrets.E2E_OCI_KEY_CONTENT }}
E2E_OCI_REGION: ${{ secrets.E2E_OCI_REGION }}
E2E_NEW_USER_PASSWORD: ${{ secrets.E2E_NEW_USER_PASSWORD }}
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Create k8s Kind Cluster
uses: helm/kind-action@v1
with:
cluster_name: kind
- name: Modify kubeconfig
run: |
# Modify the kubeconfig to use the kind cluster server to https://kind-control-plane:6443
# from worker service into docker-compose.yml
kubectl config set-cluster kind-kind --server=https://kind-control-plane:6443
kubectl config view
- name: Add network kind to docker compose
run: |
# Add the network kind to the docker compose to interconnect to kind cluster
yq -i '.networks.kind.external = true' docker-compose.yml
# Add network kind to worker service and default network too
yq -i '.services.worker.networks = ["kind","default"]' docker-compose.yml
- name: Fix API data directory permissions
run: docker run --rm -v $(pwd)/_data/api:/data alpine chown -R 1000:1000 /data
- name: Add AWS credentials for testing AWS SDK Default Adding Provider
run: |
echo "Adding AWS credentials for testing AWS SDK Default Adding Provider..."
echo "AWS_ACCESS_KEY_ID=${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}" >> .env
echo "AWS_SECRET_ACCESS_KEY=${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}" >> .env
- name: Start API services
run: |
# Override docker-compose image tag to use latest instead of stable
# This overrides any PROWLER_API_VERSION set in .env file
export PROWLER_API_VERSION=latest
echo "Using PROWLER_API_VERSION=${PROWLER_API_VERSION}"
docker compose up -d api worker worker-beat
- name: Wait for API to be ready
run: |
echo "Waiting for prowler-api..."
timeout=150 # 5 minutes max
elapsed=0
while [ $elapsed -lt $timeout ]; do
if curl -s ${NEXT_PUBLIC_API_BASE_URL}/docs >/dev/null 2>&1; then
echo "Prowler API is ready!"
exit 0
fi
echo "Waiting for prowler-api... (${elapsed}s elapsed)"
sleep 5
elapsed=$((elapsed + 5))
done
echo "Timeout waiting for prowler-api to start"
exit 1
- name: Load database fixtures for E2E tests
run: |
docker compose exec -T api sh -c '
echo "Loading all fixtures from api/fixtures/dev/..."
for fixture in api/fixtures/dev/*.json; do
if [ -f "$fixture" ]; then
echo "Loading $fixture"
poetry run python manage.py loaddata "$fixture" --database admin
fi
done
echo "All database fixtures loaded successfully!"
'
- name: Setup Node.js environment
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: '24.13.0'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm and Next.js cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ env.STORE_PATH }}
./ui/node_modules
./ui/.next/cache
key: ${{ runner.os }}-pnpm-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}-${{ hashFiles('ui/**/*.ts', 'ui/**/*.tsx', 'ui/**/*.js', 'ui/**/*.jsx') }}
restore-keys: |
${{ runner.os }}-pnpm-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}-
${{ runner.os }}-pnpm-nextjs-
- name: Install UI dependencies
working-directory: ./ui
run: pnpm install --frozen-lockfile --prefer-offline
- name: Build UI application
working-directory: ./ui
run: pnpm run build
- name: Cache Playwright browsers
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('ui/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install Playwright browsers
working-directory: ./ui
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm run test:e2e:install
- name: Run E2E tests
working-directory: ./ui
run: pnpm run test:e2e
- name: Upload test reports
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: failure()
with:
name: playwright-report
path: ui/playwright-report/
retention-days: 30
- name: Cleanup services
if: always()
run: |
echo "Shutting down services..."
docker compose down -v || true
echo "Cleanup completed"
+2 -1
View File
@@ -121,7 +121,8 @@ repos:
description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities"
# TODO: Botocore needs urllib3 1.X so we need to ignore these vulnerabilities 77744,77745. Remove this once we upgrade to urllib3 2.X
# TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0
entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027'
# TODO: 84420 from `azure-core`, that we need fix alltogether with `azure-cli-core` and `knack`
entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027,84420'
language: system
- id: vulture
+16
View File
@@ -2,6 +2,22 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.19.3] (Prowler v5.18.3)
### 🐞 Fixed
- GCP provider UID validation regex to allow domain prefixes [(#10078)](https://github.com/prowler-cloud/prowler/pull/10078)
---
## [1.19.2] (Prowler v5.18.2)
### 🐞 Fixed
- SAML role mapping now prevents removing the last MANAGE_ACCOUNT user [(#10007)](https://github.com/prowler-cloud/prowler/pull/10007)
---
## [1.19.0] (Prowler v5.18.0)
### 🚀 Added
+368 -13
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -24,7 +24,7 @@ dependencies = [
"drf-spectacular-jsonapi==0.5.1",
"gunicorn==23.0.0",
"lxml==5.3.2",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.18",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (>=1.0.1,<2.0.0)",
"sentry-sdk[django] (>=2.20.0,<3.0.0)",
@@ -49,7 +49,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.19.0"
version = "1.19.3"
[project.scripts]
celery = "src.backend.config.settings.celery"
+6 -3
View File
@@ -326,10 +326,13 @@ class Provider(RowLevelSecurityProtectedModel):
@staticmethod
def validate_gcp_uid(value):
if not re.match(r"^[a-z][a-z0-9-]{5,29}$", value):
# Standard format: 6-30 chars, starts with letter, lowercase + digits + hyphens
# Legacy App Engine format: domain.com:project-id
if not re.match(r"^([a-z][a-z0-9.-]*:)?[a-z][a-z0-9-]{5,29}$", value):
raise ModelValidationError(
detail="GCP provider ID must be 6 to 30 characters, start with a letter, and contain only lowercase "
"letters, numbers, and hyphens.",
detail="GCP provider ID must be a valid project ID: 6 to 30 characters, start with a letter, "
"and contain only lowercase letters, numbers, and hyphens. "
"Legacy App Engine project IDs with a domain prefix (e.g., example.com:my-project) are also accepted.",
code="gcp-uid",
pointer="/data/attributes/uid",
)
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.19.0
version: 1.19.3
description: |-
Prowler API specification.
+195 -38
View File
@@ -1079,6 +1079,11 @@ class TestProviderViewSet:
[
{"provider": "aws", "uid": "111111111111", "alias": "test"},
{"provider": "gcp", "uid": "a12322-test54321", "alias": "test"},
{
"provider": "gcp",
"uid": "example.com:my-project-123456",
"alias": "legacy-gcp",
},
{
"provider": "kubernetes",
"uid": "kubernetes-test-123456789",
@@ -1198,6 +1203,11 @@ class TestProviderViewSet:
[
{"provider": "aws", "uid": "111111111111", "alias": "test"},
{"provider": "gcp", "uid": "a12322-test54321", "alias": "test"},
{
"provider": "gcp",
"uid": "example.com:my-project-123456",
"alias": "legacy-gcp",
},
{
"provider": "kubernetes",
"uid": "kubernetes-test-123456789",
@@ -10841,25 +10851,20 @@ class TestTenantFinishACSView:
assert "sso_saml_failed=true" in response.url
def test_dispatch_skips_role_mapping_when_single_manage_account_user(
self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch
self,
create_test_user,
tenants_fixture,
admin_role_fixture,
saml_setup,
settings,
monkeypatch,
):
"""Test that role mapping is skipped when tenant has only one user with MANAGE_ACCOUNT role"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
user = create_test_user
tenant = tenants_fixture[0]
# Create a single role with manage_account=True for the user
admin_role = Role.objects.using(MainRouter.admin_db).create(
name="admin",
tenant=tenant,
manage_account=True,
manage_users=True,
manage_billing=True,
manage_providers=True,
manage_integrations=True,
manage_scans=True,
unlimited_visibility=True,
)
admin_role = admin_role_fixture
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=user, role=admin_role, tenant_id=tenant.id
)
@@ -10930,35 +10935,26 @@ class TestTenantFinishACSView:
.exists()
)
def test_dispatch_applies_role_mapping_when_multiple_manage_account_users(
self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch
def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_existing_role(
self,
create_test_user,
tenants_fixture,
admin_role_fixture,
roles_fixture,
saml_setup,
settings,
monkeypatch,
):
"""Test that role mapping is applied when tenant has multiple users with MANAGE_ACCOUNT role"""
"""Test that role mapping is skipped when it would remove the last MANAGE_ACCOUNT user"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
user = create_test_user
tenant = tenants_fixture[0]
# Create a second user with manage_account=True
second_admin = User.objects.using(MainRouter.admin_db).create(
email="admin2@prowler.com", name="Second Admin"
)
admin_role = Role.objects.using(MainRouter.admin_db).create(
name="admin",
tenant=tenant,
manage_account=True,
manage_users=True,
manage_billing=True,
manage_providers=True,
manage_integrations=True,
manage_scans=True,
unlimited_visibility=True,
)
admin_role = admin_role_fixture
viewer_role = roles_fixture[3]
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=user, role=admin_role, tenant_id=tenant.id
)
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=second_admin, role=admin_role, tenant_id=tenant.id
)
social_account = SocialAccount(
user=user,
@@ -10967,7 +10963,7 @@ class TestTenantFinishACSView:
"firstName": ["John"],
"lastName": ["Doe"],
"organization": ["testing_company"],
"userType": ["viewer"], # This SHOULD be applied
"userType": [viewer_role.name],
},
)
@@ -11005,10 +11001,91 @@ class TestTenantFinishACSView:
assert response.status_code == 302
# Verify the viewer role was created and assigned (role mapping was applied)
viewer_role = Role.objects.using(MainRouter.admin_db).get(
name="viewer", tenant=tenant
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, role=admin_role, tenant_id=tenant.id)
.exists()
)
assert not (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, role=viewer_role, tenant_id=tenant.id)
.exists()
)
def test_dispatch_applies_role_mapping_when_multiple_manage_account_users(
self,
create_test_user,
tenants_fixture,
admin_role_fixture,
roles_fixture,
saml_setup,
settings,
monkeypatch,
):
"""Test that role mapping is applied when tenant has multiple users with MANAGE_ACCOUNT role"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
user = create_test_user
tenant = tenants_fixture[0]
# Create a second user with manage_account=True
second_admin = User.objects.using(MainRouter.admin_db).create(
email="admin2@prowler.com", name="Second Admin"
)
admin_role = admin_role_fixture
viewer_role = roles_fixture[3]
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=user, role=admin_role, tenant_id=tenant.id
)
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=second_admin, role=admin_role, tenant_id=tenant.id
)
social_account = SocialAccount(
user=user,
provider="saml",
extra_data={
"firstName": ["John"],
"lastName": ["Doe"],
"organization": ["testing_company"],
"userType": [viewer_role.name], # This SHOULD be applied
},
)
request = RequestFactory().get(
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
)
request.user = user
request.session = {}
with (
patch(
"allauth.socialaccount.providers.saml.views.get_app_or_404"
) as mock_get_app_or_404,
patch(
"allauth.socialaccount.models.SocialApp.objects.get"
) as mock_socialapp_get,
patch(
"allauth.socialaccount.models.SocialAccount.objects.get"
) as mock_sa_get,
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
patch("api.models.User.objects.get") as mock_user_get,
):
mock_get_app_or_404.return_value = MagicMock(
provider="saml", client_id="testtenant", name="Test App", settings={}
)
mock_sa_get.return_value = social_account
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
mock_saml_config_get.return_value = MagicMock()
mock_user_get.return_value = user
view = TenantFinishACSView.as_view()
response = view(request, organization_slug="testtenant")
assert response.status_code == 302
# Verify the viewer role was assigned (role mapping was applied)
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, role=viewer_role, tenant_id=tenant.id)
@@ -11022,6 +11099,86 @@ class TestTenantFinishACSView:
.exists()
)
def test_dispatch_applies_role_mapping_for_non_admin_user_with_single_admin(
self,
create_test_user,
tenants_fixture,
admin_role_fixture,
roles_fixture,
saml_setup,
settings,
monkeypatch,
):
"""Test that role mapping is applied for a non-admin user when a single admin exists"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
admin_user = create_test_user
tenant = tenants_fixture[0]
non_admin_user = User.objects.using(MainRouter.admin_db).create(
email="viewer@prowler.com", name="Viewer"
)
admin_role = admin_role_fixture
viewer_role = roles_fixture[3]
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=admin_user, role=admin_role, tenant_id=tenant.id
)
social_account = SocialAccount(
user=non_admin_user,
provider="saml",
extra_data={
"firstName": ["Jane"],
"lastName": ["Doe"],
"organization": ["testing_company"],
"userType": [viewer_role.name],
},
)
request = RequestFactory().get(
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
)
request.user = non_admin_user
request.session = {}
with (
patch(
"allauth.socialaccount.providers.saml.views.get_app_or_404"
) as mock_get_app_or_404,
patch(
"allauth.socialaccount.models.SocialApp.objects.get"
) as mock_socialapp_get,
patch(
"allauth.socialaccount.models.SocialAccount.objects.get"
) as mock_sa_get,
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
patch("api.models.User.objects.get") as mock_user_get,
):
mock_get_app_or_404.return_value = MagicMock(
provider="saml", client_id="testtenant", name="Test App", settings={}
)
mock_sa_get.return_value = social_account
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
mock_saml_config_get.return_value = MagicMock()
mock_user_get.return_value = non_admin_user
view = TenantFinishACSView.as_view()
response = view(request, organization_slug="testtenant")
assert response.status_code == 302
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=non_admin_user, role=viewer_role, tenant_id=tenant.id)
.exists()
)
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=admin_user, role=admin_role, tenant_id=tenant.id)
.exists()
)
@pytest.mark.django_db
class TestLighthouseConfigViewSet:
+28 -15
View File
@@ -392,7 +392,7 @@ class SchemaView(SpectacularAPIView):
def get(self, request, *args, **kwargs):
spectacular_settings.TITLE = "Prowler API"
spectacular_settings.VERSION = "1.19.0"
spectacular_settings.VERSION = "1.19.3"
spectacular_settings.DESCRIPTION = (
"Prowler API specification.\n\nThis file is auto-generated."
)
@@ -763,27 +763,40 @@ class TenantFinishACSView(FinishACSView):
.tenant
)
# Check if tenant has only one user with MANAGE_ACCOUNT role
users_with_manage_account = (
role_name = (
extra.get("userType", ["no_permissions"])[0].strip()
if extra.get("userType")
else "no_permissions"
)
role = (
Role.objects.using(MainRouter.admin_db)
.filter(name=role_name, tenant=tenant)
.first()
)
# Only skip mapping if it would remove the last MANAGE_ACCOUNT user
remaining_manage_account_users = (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(role__manage_account=True, tenant_id=tenant.id)
.exclude(user_id=user_id)
.values("user")
.distinct()
.count()
)
user_has_manage_account = (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(role__manage_account=True, tenant_id=tenant.id, user_id=user_id)
.exists()
)
role_manage_account = role.manage_account if role else False
would_remove_last_manage_account = (
user_has_manage_account
and remaining_manage_account_users == 0
and not role_manage_account
)
# Only apply role mapping from userType if tenant does NOT have exactly one user with MANAGE_ACCOUNT
if users_with_manage_account != 1:
role_name = (
extra.get("userType", ["no_permissions"])[0].strip()
if extra.get("userType")
else "no_permissions"
)
try:
role = Role.objects.using(MainRouter.admin_db).get(
name=role_name, tenant=tenant
)
except Role.DoesNotExist:
if not would_remove_last_manage_account:
if role is None:
role = Role.objects.using(MainRouter.admin_db).create(
name=role_name,
tenant=tenant,
@@ -115,8 +115,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.17.0"
PROWLER_API_VERSION="5.17.0"
PROWLER_UI_VERSION="5.18.2"
PROWLER_API_VERSION="5.18.2"
```
<Note>
@@ -38,6 +38,7 @@ GitHub has deprecated Personal Access Tokens (classic) in favor of fine-grained
4. **Configure Token Settings**
- **Token name**: Give your token a descriptive name (e.g., "Prowler Security Scanner")
- **Resource owner**: Select the account that owns the resources to scan — either a personal account or a specific organization
- **Expiration**: Set an appropriate expiration date (recommended: 90 days or less)
- **Repository access**: Choose "All repositories" or "Only select repositories" based on your needs
@@ -56,11 +57,11 @@ GitHub has deprecated Personal Access Tokens (classic) in favor of fine-grained
- **Metadata**: Read-only access
- **Pull requests**: Read-only access
- **Organization permissions:**
- **Organization permissions** (available when an organization is selected as Resource Owner):
- **Administration**: Read-only access
- **Members**: Read-only access
- **Account permissions:**
- **Account permissions** (available when a personal account is selected as Resource Owner):
- **Email addresses**: Read-only access
6. **Copy and Store the Token**
@@ -54,7 +54,7 @@ title: 'Getting Started with GitHub'
</Tabs>
## Prowler CLI
### Automatic Login Method Detection
### Authentication
If no login method is explicitly provided, Prowler will automatically attempt to authenticate using environment variables in the following order of precedence:
@@ -68,15 +68,15 @@ Ensure the corresponding environment variables are set up before running Prowler
</Note>
For more details on how to set up authentication with GitHub, see [Authentication > GitHub](/user-guide/providers/github/authentication).
### Personal Access Token (PAT)
#### Personal Access Token (PAT)
Use this method by providing your personal access token directly.
Use this method by providing a personal access token directly.
```console
prowler github --personal-access-token pat
```
### OAuth App Token
#### OAuth App Token
Authenticate using an OAuth app token.
@@ -84,9 +84,62 @@ Authenticate using an OAuth app token.
prowler github --oauth-app-token oauth_token
```
### GitHub App Credentials
#### GitHub App Credentials
Use GitHub App credentials by specifying the App ID and the private key path.
```console
prowler github --github-app-id app_id --github-app-key-path app_key_path
```
### Scan Scoping
By default, Prowler scans all repositories accessible to the authenticated user or organization. To limit the scan to specific repositories or organizations, use the following flags.
#### Scanning Specific Repositories
To restrict the scan to one or more repositories, use the `--repository` flag followed by the repository name(s) in `owner/repo-name` format:
```console
prowler github --repository owner/repo-name
```
To scan multiple repositories, specify them as space-separated arguments:
```console
prowler github --repository owner/repo-name-1 owner/repo-name-2
```
#### Scanning Specific Organizations
To restrict the scan to one or more organizations or user accounts, use the `--organization` flag:
```console
prowler github --organization my-organization
```
To scan multiple organizations, specify them as space-separated arguments:
```console
prowler github --organization org-1 org-2
```
#### Scanning Specific Repositories Within an Organization
To scan specific repositories within an organization, combine the `--organization` and `--repository` flags. The `--organization` flag qualifies unqualified repository names automatically:
```console
prowler github --organization my-organization --repository my-repo
```
This scans only `my-organization/my-repo`. Fully qualified repository names (`owner/repo-name`) are also supported alongside `--organization`:
```console
prowler github --organization my-org --repository my-repo other-owner/other-repo
```
In this case, `my-repo` is qualified as `my-org/my-repo`, while `other-owner/other-repo` is used as-is.
<Note>
The `--repository` and `--organization` flags can be combined with any authentication method.
</Note>
Generated
+48 -48
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]]
name = "about-time"
@@ -4017,46 +4017,6 @@ files = [
{file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"},
]
[[package]]
name = "netifaces"
version = "0.11.0"
description = "Portable network interface information."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eb4813b77d5df99903af4757ce980a98c4d702bbcb81f32a0b305a1537bdf0b1"},
{file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:5f9ca13babe4d845e400921973f6165a4c2f9f3379c7abfc7478160e25d196a4"},
{file = "netifaces-0.11.0-cp27-cp27m-win32.whl", hash = "sha256:7dbb71ea26d304e78ccccf6faccef71bb27ea35e259fb883cfd7fd7b4f17ecb1"},
{file = "netifaces-0.11.0-cp27-cp27m-win_amd64.whl", hash = "sha256:0f6133ac02521270d9f7c490f0c8c60638ff4aec8338efeff10a1b51506abe85"},
{file = "netifaces-0.11.0-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:08e3f102a59f9eaef70948340aeb6c89bd09734e0dca0f3b82720305729f63ea"},
{file = "netifaces-0.11.0-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c03fb2d4ef4e393f2e6ffc6376410a22a3544f164b336b3a355226653e5efd89"},
{file = "netifaces-0.11.0-cp34-cp34m-win32.whl", hash = "sha256:73ff21559675150d31deea8f1f8d7e9a9a7e4688732a94d71327082f517fc6b4"},
{file = "netifaces-0.11.0-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815eafdf8b8f2e61370afc6add6194bd5a7252ae44c667e96c4c1ecf418811e4"},
{file = "netifaces-0.11.0-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:50721858c935a76b83dd0dd1ab472cad0a3ef540a1408057624604002fcfb45b"},
{file = "netifaces-0.11.0-cp35-cp35m-win32.whl", hash = "sha256:c9a3a47cd3aaeb71e93e681d9816c56406ed755b9442e981b07e3618fb71d2ac"},
{file = "netifaces-0.11.0-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:aab1dbfdc55086c789f0eb37affccf47b895b98d490738b81f3b2360100426be"},
{file = "netifaces-0.11.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c37a1ca83825bc6f54dddf5277e9c65dec2f1b4d0ba44b8fd42bc30c91aa6ea1"},
{file = "netifaces-0.11.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:28f4bf3a1361ab3ed93c5ef360c8b7d4a4ae060176a3529e72e5e4ffc4afd8b0"},
{file = "netifaces-0.11.0-cp36-cp36m-win32.whl", hash = "sha256:2650beee182fed66617e18474b943e72e52f10a24dc8cac1db36c41ee9c041b7"},
{file = "netifaces-0.11.0-cp36-cp36m-win_amd64.whl", hash = "sha256:cb925e1ca024d6f9b4f9b01d83215fd00fe69d095d0255ff3f64bffda74025c8"},
{file = "netifaces-0.11.0-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:84e4d2e6973eccc52778735befc01638498781ce0e39aa2044ccfd2385c03246"},
{file = "netifaces-0.11.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18917fbbdcb2d4f897153c5ddbb56b31fa6dd7c3fa9608b7e3c3a663df8206b5"},
{file = "netifaces-0.11.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:48324183af7f1bc44f5f197f3dad54a809ad1ef0c78baee2c88f16a5de02c4c9"},
{file = "netifaces-0.11.0-cp37-cp37m-win32.whl", hash = "sha256:8f7da24eab0d4184715d96208b38d373fd15c37b0dafb74756c638bd619ba150"},
{file = "netifaces-0.11.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2479bb4bb50968089a7c045f24d120f37026d7e802ec134c4490eae994c729b5"},
{file = "netifaces-0.11.0-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:3ecb3f37c31d5d51d2a4d935cfa81c9bc956687c6f5237021b36d6fdc2815b2c"},
{file = "netifaces-0.11.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96c0fe9696398253f93482c84814f0e7290eee0bfec11563bd07d80d701280c3"},
{file = "netifaces-0.11.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c92ff9ac7c2282009fe0dcb67ee3cd17978cffbe0c8f4b471c00fe4325c9b4d4"},
{file = "netifaces-0.11.0-cp38-cp38-win32.whl", hash = "sha256:d07b01c51b0b6ceb0f09fc48ec58debd99d2c8430b09e56651addeaf5de48048"},
{file = "netifaces-0.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:469fc61034f3daf095e02f9f1bbac07927b826c76b745207287bc594884cfd05"},
{file = "netifaces-0.11.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5be83986100ed1fdfa78f11ccff9e4757297735ac17391b95e17e74335c2047d"},
{file = "netifaces-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:54ff6624eb95b8a07e79aa8817288659af174e954cca24cdb0daeeddfc03c4ff"},
{file = "netifaces-0.11.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:841aa21110a20dc1621e3dd9f922c64ca64dd1eb213c47267a2c324d823f6c8f"},
{file = "netifaces-0.11.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e76c7f351e0444721e85f975ae92718e21c1f361bda946d60a214061de1f00a1"},
{file = "netifaces-0.11.0.tar.gz", hash = "sha256:043a79146eb2907edf439899f262b3dfe41717d34124298ed281139a8b93ca32"},
]
[[package]]
name = "networkx"
version = "3.2.1"
@@ -4290,14 +4250,14 @@ openapi-schema-validator = ">=0.6.0,<0.7.0"
[[package]]
name = "openstacksdk"
version = "4.0.1"
version = "4.2.0"
description = "An SDK for building applications to work with OpenStack"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "openstacksdk-4.0.1-py3-none-any.whl", hash = "sha256:d63187a006fff7c1de1486c9e2e1073a787af402620c3c0ed0cf5291225998ac"},
{file = "openstacksdk-4.0.1.tar.gz", hash = "sha256:19faa1d5e6a78a2c1dc06a171e65e776ba82e9df23e1d08586225dc5ade9fc63"},
{file = "openstacksdk-4.2.0-py3-none-any.whl", hash = "sha256:238be0fa5d9899872b00787ab38e84f92fd6dc87525fde0965dadcdc12196dc6"},
{file = "openstacksdk-4.2.0.tar.gz", hash = "sha256:5cb9450dcce8054a2caf89d8be9e55057ddfa219a954e781032241eb29280445"},
]
[package.dependencies]
@@ -4308,10 +4268,10 @@ iso8601 = ">=0.1.11"
jmespath = ">=0.9.0"
jsonpatch = ">=1.16,<1.20 || >1.20"
keystoneauth1 = ">=3.18.0"
netifaces = ">=0.10.4"
os-service-types = ">=1.7.0"
pbr = ">=2.0.0,<2.1.0 || >2.1.0"
platformdirs = ">=3"
psutil = ">=3.2.2"
PyYAML = ">=3.13"
requestsexceptions = ">=1.2.0"
@@ -4768,6 +4728,41 @@ files = [
{file = "protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a"},
]
[[package]]
name = "psutil"
version = "7.2.2"
description = "Cross-platform lib for process and system monitoring."
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"},
{file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"},
{file = "psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63"},
{file = "psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312"},
{file = "psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b"},
{file = "psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9"},
{file = "psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00"},
{file = "psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9"},
{file = "psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a"},
{file = "psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf"},
{file = "psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1"},
{file = "psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841"},
{file = "psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486"},
{file = "psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979"},
{file = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"},
{file = "psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e"},
{file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"},
{file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc"},
{file = "psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988"},
{file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"},
{file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"},
]
[package.extras]
dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""]
test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "setuptools", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""]
[[package]]
name = "py-iam-expand"
version = "0.1.0"
@@ -4861,7 +4856,7 @@ description = "C parser in Python"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
markers = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\""
files = [
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
@@ -5874,6 +5869,7 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"},
@@ -5882,6 +5878,7 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"},
@@ -5890,6 +5887,7 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"},
@@ -5898,6 +5896,7 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"},
@@ -5906,6 +5905,7 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"},
{file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"},
@@ -6853,4 +6853,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = ">3.9.1,<3.13"
content-hash = "f9ff21ae57caa3ddcd27f3753c29c1b3be2966709baed52e1bbc24e7bdc33f3c"
content-hash = "48d1a809c940ba8cf7a6056aca9ff72d931bd3ea5ef6193f83350a1f0b36dbb7"
+19
View File
@@ -2,6 +2,25 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.18.3] (Prowler v5.18.3)
### 🐞 Fixed
- `pip install prowler` failing on systems without C compiler due to `netifaces` transitive dependency from `openstacksdk` [(#10055)](https://github.com/prowler-cloud/prowler/pull/10055)
- `kms_key_not_publicly_accessible` false negative for specific KMS actions (e.g., `kms:DescribeKey`, `kms:Decrypt`) with unrestricted principals [(#10071)](https://github.com/prowler-cloud/prowler/pull/10071)
- Remove account_id and location for manual requirements in M365CIS [(#10105)](https://github.com/prowler-cloud/prowler/pull/10105)
---
## [5.18.2] (Prowler v5.18.2)
### 🐞 Fixed
- `--repository` and `--organization` flags combined interaction in GitHub provider, qualifying unqualified repository names with organization [(#10001)](https://github.com/prowler-cloud/prowler/pull/10001)
- HPACK library logging tokens in debug mode for Azure, M365, and Cloudflare providers [(#10010)](https://github.com/prowler-cloud/prowler/pull/10010)
---
## [5.18.0] (Prowler v5.18.0)
### 🚀 Added
+1 -1
View File
@@ -38,7 +38,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.18.0"
prowler_version = "5.18.3"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
@@ -77,8 +77,8 @@ class M365CIS(ComplianceOutput):
compliance_row = M365CISModel(
Provider=compliance.Provider.lower(),
Description=compliance.Description,
TenantId=finding.account_uid,
Location=finding.region,
TenantId="",
Location="",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
@@ -24,9 +24,9 @@
],
"Remediation": {
"Code": {
"CLI": "aws kms put-key-policy --key-id <example_resource_id> --policy-name default --policy '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::<account_id>:root\"},\"Action\":\"kms:*\",\"Resource\":\"*\"}]}'",
"CLI": "aws kms put-key-policy --key-id <example_resource_id> --policy-name default --policy '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::<account_id>:root\"},\"Action\":\"kms:\\*\",\"Resource\":\"\\*\"}]}'",
"NativeIaC": "```yaml\n# CloudFormation: restrict KMS key policy to account root (removes any public access)\nResources:\n <example_resource_name>:\n Type: AWS::KMS::Key\n Properties:\n KeyPolicy:\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Principal:\n AWS: arn:aws:iam::<account_id>:root # Critical: only account root can access; prevents public \"*\" principals\n Action: kms:*\n Resource: '*'\n```",
"Other": "1. Open AWS Console > Key Management Service (KMS)\n2. Select the affected key and go to the Key policy tab\n3. Click Edit and remove any statement with Principal set to \"*\" (or AWS: \"*\")\n4. Ensure a statement exists that allows only arn:aws:iam::<account_id>:root\n5. Save changes",
"Other": "1. Open AWS Console > Key Management Service (KMS)\n2. Select the affected key and go to the Key policy tab\n3. Click Edit and remove any statement with Principal set to \"\\*\" (or AWS: \"\\*\")\n4. Ensure a statement exists that allows only arn:aws:iam::<account_id>:root\n5. Save changes",
"Terraform": "```hcl\n# Restrict KMS key policy to the account root to avoid any public (\"*\") principals\ndata \"aws_caller_identity\" \"current\" {}\n\nresource \"aws_kms_key\" \"<example_resource_name>\" {\n policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [\n {\n Effect = \"Allow\"\n Principal = { AWS = \"arn:aws:iam::${data.aws_caller_identity.current.account_id}:root\" } # Critical: limit to account root to remove public access\n Action = \"kms:*\"\n Resource = \"*\"\n }\n ]\n })\n}\n```"
},
"Recommendation": {
@@ -19,7 +19,7 @@ class kms_key_not_publicly_accessible(Check):
if is_policy_public(
key.policy,
kms_client.audited_account,
not_allowed_actions=["kms:*"],
not_allowed_actions=[],
):
report.status = "FAIL"
report.status_extended = (
@@ -1,4 +1,5 @@
import asyncio
import logging
import os
import re
from argparse import ArgumentTypeError
@@ -217,6 +218,9 @@ class AzureProvider(Provider):
"""
logger.info("Setting Azure provider ...")
# Mute HPACK library logs to prevent token leakage in debug mode
logging.getLogger("hpack").setLevel(logging.CRITICAL)
logger.info("Checking if any credentials mode is set ...")
# Validate the authentication arguments
@@ -1,3 +1,4 @@
import logging
import os
from typing import Iterable
@@ -55,6 +56,9 @@ class CloudflareProvider(Provider):
):
logger.info("Instantiating Cloudflare provider...")
# Mute HPACK library logs to prevent token leakage in debug mode
logging.getLogger("hpack").setLevel(logging.CRITICAL)
if config_content:
self._audit_config = config_content
else:
+1 -2
View File
@@ -1,3 +1,4 @@
import logging
import os
from os import environ
from typing import Union
@@ -134,8 +135,6 @@ class GithubProvider(Provider):
logger.info("Instantiating GitHub Provider...")
# Mute GitHub library logs to reduce noise since it is already handled by the Prowler logger
import logging
logging.getLogger("github").setLevel(logging.CRITICAL)
logging.getLogger("github.GithubRetry").setLevel(logging.CRITICAL)
@@ -121,15 +121,22 @@ class Repository(GithubService):
)
):
if self.provider.repositories:
logger.info(
f"Filtering for specific repositories: {self.provider.repositories}"
)
qualified_repos = []
for repo_name in self.provider.repositories:
if not self._validate_repository_format(repo_name):
if self._validate_repository_format(repo_name):
qualified_repos.append(repo_name)
elif self.provider.organizations:
for org_name in self.provider.organizations:
qualified_repos.append(f"{org_name}/{repo_name}")
else:
logger.warning(
f"Repository name '{repo_name}' should be in 'owner/repo-name' format. Skipping."
)
continue
logger.info(
f"Filtering for specific repositories: {qualified_repos}"
)
for repo_name in qualified_repos:
try:
repo = client.get_repo(repo_name)
self._process_repository(repo, repos)
@@ -138,7 +145,7 @@ class Repository(GithubService):
error, "accessing repository", repo_name
)
if self.provider.organizations:
elif self.provider.organizations:
logger.info(
f"Filtering for repositories in organizations: {self.provider.organizations}"
)
+4
View File
@@ -1,5 +1,6 @@
import asyncio
import base64
import logging
import os
from argparse import ArgumentTypeError
from os import getenv
@@ -157,6 +158,9 @@ class M365Provider(Provider):
"""
logger.info("Setting M365 provider ...")
# Mute HPACK library logs to prevent token leakage in debug mode
logging.getLogger("hpack").setLevel(logging.CRITICAL)
logger.info("Checking if any credentials mode is set ...")
# Validate the authentication arguments
+2 -2
View File
@@ -57,7 +57,7 @@ dependencies = [
"microsoft-kiota-abstractions==1.9.2",
"msgraph-sdk==1.23.0",
"numpy==2.0.2",
"openstacksdk==4.0.1",
"openstacksdk==4.2.0",
"pandas==2.2.3",
"py-ocsf-models==0.5.0",
"pydantic (>=2.0,<3.0)",
@@ -92,7 +92,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">3.9.1,<3.13"
version = "5.18.0"
version = "5.18.3"
[project.scripts]
prowler = "prowler.__main__:prowler"
+8
View File
@@ -74,6 +74,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash
- One entry per PR (can link multiple PRs for related changes)
- No period at the end
- Do NOT start with redundant verbs (section header already provides the action)
- **CRITICAL: Preserve section order** — when adding a new section to the UNRELEASED block, insert it in the correct position relative to existing sections (Added → Changed → Deprecated → Removed → Fixed → Security). Never append a new section at the top or bottom without checking order
### Semantic Versioning Rules
@@ -177,6 +178,13 @@ This maintains chronological order within each section (oldest at top, newest at
### Bad Entries
```markdown
# BAD - Wrong section order (Fixed before Added)
### 🐞 Fixed
- Some bug fix [(#123)](...)
### 🚀 Added
- Some new feature [(#456)](...)
- Fixed bug. # Too vague, has period
- Added new feature for users # Missing PR link, redundant verb
- Add search bar [(#123)] # Redundant verb (section already says "Added")
@@ -97,8 +97,8 @@ class TestM365CIS:
assert output_data_manual.Provider == "m365"
assert output_data_manual.Framework == CIS_4_0_M365.Framework
assert output_data_manual.Name == CIS_4_0_M365.Name
assert output_data_manual.TenantId == TENANT_ID
assert output_data_manual.Location == LOCATION
assert output_data_manual.TenantId == ""
assert output_data_manual.Location == ""
assert output_data_manual.Description == CIS_4_0_M365.Description
assert output_data_manual.Requirements_Id == CIS_4_0_M365.Requirements[1].Id
assert (
@@ -184,6 +184,6 @@ class TestM365CIS:
mock_file.seek(0)
content = mock_file.read()
expected_csv = f"PROVIDER;DESCRIPTION;TENANTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;REQUIREMENTS_ATTRIBUTES_REFERENCES;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED;FRAMEWORK;NAME\r\nm365;The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for configuring security options for Microsoft 365 with an emphasis on foundational, testable, and architecture agnostic settings.;00000000-0000-0000-0000-000000000000;global;{datetime.now()};2.1.3;Ensure MFA Delete is enabled on S3 buckets;2.1. Simple Storage Service (S3);;Level 1;Automated;Once MFA Delete is enabled on your sensitive and classified S3 bucket it requires the user to have two forms of authentication.;Adding MFA delete to an S3 bucket, requires additional authentication when you change the version state of your bucket or you delete and object version adding another layer of security in the event your security credentials are compromised or unauthorized access is granted.;;Perform the steps below to enable MFA delete on an S3 bucket.Note:-You cannot enable MFA Delete using the AWS Management Console. You must use the AWS CLI or API.-You must use your 'root' account to enable MFA Delete on S3 buckets.**From Command line:**1. Run the s3api put-bucket-versioning command aws s3api put-bucket-versioning --profile my-root-profile --bucket Bucket_Name --versioning-configuration Status=Enabled,MFADelete=Enabled --mfa arn:aws:iam::aws_account_id:mfa/root-account-mfa-device passcode;Perform the steps below to confirm MFA delete is configured on an S3 Bucket**From Console:**1. Login to the S3 console at `https://console.aws.amazon.com/s3/`2. Click the `Check` box next to the Bucket name you want to confirm3. In the window under `Properties`4. Confirm that Versioning is `Enabled`5. Confirm that MFA Delete is `Enabled`**From Command Line:**1. Run the `get-bucket-versioning aws s3api get-bucket-versioning --bucket my-bucket Output example: <VersioningConfiguration xmlns=`http://s3.amazonaws.com/doc/2006-03-01/`> <Status>Enabled</Status> <MfaDelete>Enabled</MfaDelete></VersioningConfiguration>\ If the Console or the CLI output does not show Versioning and MFA Delete `enabled` refer to the remediation below.;;By default, MFA Delete is not enabled on S3 buckets.;https://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html#MultiFactorAuthenticationDelete:https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMFADelete.html:https://aws.amazon.com/blogs/security/securing-access-to-aws-using-mfa-part-3/:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_lost-or-broken.html;PASS;;;;service_test_check_id;False;CIS;CIS Microsoft 365 Foundations Benchmark v4.0.0\r\nm365;The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for configuring security options for Microsoft 365 with an emphasis on foundational, testable, and architecture agnostic settings.;00000000-0000-0000-0000-000000000000;global;{datetime.now()};2.1.4;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1.1 Control Plane Node Configuration Files;;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;https://kubernetes.io/docs/admin/kube-apiserver/;MANUAL;Manual check;manual_check;Manual check;manual;False;CIS;CIS Microsoft 365 Foundations Benchmark v4.0.0\r\n"
expected_csv = f"PROVIDER;DESCRIPTION;TENANTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;REQUIREMENTS_ATTRIBUTES_REFERENCES;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED;FRAMEWORK;NAME\r\nm365;The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for configuring security options for Microsoft 365 with an emphasis on foundational, testable, and architecture agnostic settings.;00000000-0000-0000-0000-000000000000;global;{datetime.now()};2.1.3;Ensure MFA Delete is enabled on S3 buckets;2.1. Simple Storage Service (S3);;Level 1;Automated;Once MFA Delete is enabled on your sensitive and classified S3 bucket it requires the user to have two forms of authentication.;Adding MFA delete to an S3 bucket, requires additional authentication when you change the version state of your bucket or you delete and object version adding another layer of security in the event your security credentials are compromised or unauthorized access is granted.;;Perform the steps below to enable MFA delete on an S3 bucket.Note:-You cannot enable MFA Delete using the AWS Management Console. You must use the AWS CLI or API.-You must use your 'root' account to enable MFA Delete on S3 buckets.**From Command line:**1. Run the s3api put-bucket-versioning command aws s3api put-bucket-versioning --profile my-root-profile --bucket Bucket_Name --versioning-configuration Status=Enabled,MFADelete=Enabled --mfa arn:aws:iam::aws_account_id:mfa/root-account-mfa-device passcode;Perform the steps below to confirm MFA delete is configured on an S3 Bucket**From Console:**1. Login to the S3 console at `https://console.aws.amazon.com/s3/`2. Click the `Check` box next to the Bucket name you want to confirm3. In the window under `Properties`4. Confirm that Versioning is `Enabled`5. Confirm that MFA Delete is `Enabled`**From Command Line:**1. Run the `get-bucket-versioning aws s3api get-bucket-versioning --bucket my-bucket Output example: <VersioningConfiguration xmlns=`http://s3.amazonaws.com/doc/2006-03-01/`> <Status>Enabled</Status> <MfaDelete>Enabled</MfaDelete></VersioningConfiguration>\ If the Console or the CLI output does not show Versioning and MFA Delete `enabled` refer to the remediation below.;;By default, MFA Delete is not enabled on S3 buckets.;https://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html#MultiFactorAuthenticationDelete:https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMFADelete.html:https://aws.amazon.com/blogs/security/securing-access-to-aws-using-mfa-part-3/:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_lost-or-broken.html;PASS;;;;service_test_check_id;False;CIS;CIS Microsoft 365 Foundations Benchmark v4.0.0\r\nm365;The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for configuring security options for Microsoft 365 with an emphasis on foundational, testable, and architecture agnostic settings.;;;{datetime.now()};2.1.4;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1.1 Control Plane Node Configuration Files;;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;https://kubernetes.io/docs/admin/kube-apiserver/;MANUAL;Manual check;manual_check;Manual check;manual;False;CIS;CIS Microsoft 365 Foundations Benchmark v4.0.0\r\n"
assert content == expected_csv
@@ -129,6 +129,116 @@ class Test_kms_key_not_publicly_accessible:
assert result[0].resource_id == key["KeyId"]
assert result[0].resource_arn == key["Arn"]
@mock_aws
def test_kms_key_public_accessible_with_describe_key(self):
# Generate KMS Client
kms_client = client("kms", region_name=AWS_REGION_US_EAST_1)
# Create KMS key with public policy allowing kms:DescribeKey
key = kms_client.create_key(
MultiRegion=False,
Policy=json.dumps(
{
"Version": "2012-10-17",
"Id": "key-default-1",
"Statement": [
{
"Sid": "AllowDescribeKeyPermissionForClusterOperator",
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "kms:DescribeKey",
"Resource": "*",
}
],
}
),
)["KeyMetadata"]
from prowler.providers.aws.services.kms.kms_service import KMS
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.kms.kms_key_not_publicly_accessible.kms_key_not_publicly_accessible.kms_client",
new=KMS(aws_provider),
),
):
# Test Check
from prowler.providers.aws.services.kms.kms_key_not_publicly_accessible.kms_key_not_publicly_accessible import (
kms_key_not_publicly_accessible,
)
check = kms_key_not_publicly_accessible()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"KMS key {key['KeyId']} may be publicly accessible."
)
assert result[0].resource_id == key["KeyId"]
assert result[0].resource_arn == key["Arn"]
@mock_aws
def test_kms_key_public_accessible_with_decrypt(self):
# Generate KMS Client
kms_client = client("kms", region_name=AWS_REGION_US_EAST_1)
# Create KMS key with public policy allowing kms:Decrypt
key = kms_client.create_key(
MultiRegion=False,
Policy=json.dumps(
{
"Version": "2012-10-17",
"Id": "key-default-1",
"Statement": [
{
"Sid": "AllowDecryptPermissionPublicly",
"Effect": "Allow",
"Principal": "*",
"Action": "kms:Decrypt",
"Resource": "*",
}
],
}
),
)["KeyMetadata"]
from prowler.providers.aws.services.kms.kms_service import KMS
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.kms.kms_key_not_publicly_accessible.kms_key_not_publicly_accessible.kms_client",
new=KMS(aws_provider),
),
):
# Test Check
from prowler.providers.aws.services.kms.kms_key_not_publicly_accessible.kms_key_not_publicly_accessible import (
kms_key_not_publicly_accessible,
)
check = kms_key_not_publicly_accessible()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"KMS key {key['KeyId']} may be publicly accessible."
)
assert result[0].resource_id == key["KeyId"]
assert result[0].resource_arn == key["Arn"]
@mock_aws
def test_kms_key_empty_principal(self):
# Generate KMS Client
@@ -245,19 +245,61 @@ class Test_Repository_Scoping:
self.mock_repo2.get_branch.side_effect = Exception("404 Not Found")
self.mock_repo2.get_dependabot_alerts.side_effect = Exception("404 Not Found")
def test_combined_repository_and_organization_scoping(self):
"""Test that both repository and organization scoping can be used together"""
def test_qualified_repo_with_organization_skips_org_fetch(self):
"""Test that a fully qualified repo with --organization does not fetch all org repos"""
provider = set_mocked_github_provider()
provider.repositories = ["owner1/repo1"]
provider.organizations = ["org2"]
mock_client = MagicMock()
# Repository lookup
mock_client.get_repo.return_value = self.mock_repo1
# Organization lookup
mock_org = MagicMock()
mock_org.get_repos.return_value = [self.mock_repo2]
mock_client.get_organization.return_value = mock_org
with patch(
"prowler.providers.github.services.repository.repository_service.GithubService.__init__"
):
repository_service = Repository(provider)
repository_service.clients = [mock_client]
repository_service.provider = provider
repos = repository_service._list_repositories()
assert len(repos) == 1
assert 1 in repos
assert repos[1].name == "repo1"
mock_client.get_repo.assert_called_once_with("owner1/repo1")
mock_client.get_organization.assert_not_called()
def test_unqualified_repo_qualified_with_organization(self):
"""Test that an unqualified repo name is qualified with the organization"""
provider = set_mocked_github_provider()
provider.repositories = ["repo1"]
provider.organizations = ["owner1"]
mock_client = MagicMock()
mock_client.get_repo.return_value = self.mock_repo1
with patch(
"prowler.providers.github.services.repository.repository_service.GithubService.__init__"
):
repository_service = Repository(provider)
repository_service.clients = [mock_client]
repository_service.provider = provider
repos = repository_service._list_repositories()
assert len(repos) == 1
assert 1 in repos
assert repos[1].name == "repo1"
mock_client.get_repo.assert_called_once_with("owner1/repo1")
def test_unqualified_repo_qualified_with_multiple_organizations(self):
"""Test that an unqualified repo is qualified with each organization"""
provider = set_mocked_github_provider()
provider.repositories = ["repo1"]
provider.organizations = ["org1", "org2"]
mock_client = MagicMock()
mock_client.get_repo.side_effect = [self.mock_repo1, self.mock_repo2]
with patch(
"prowler.providers.github.services.repository.repository_service.GithubService.__init__"
@@ -269,12 +311,56 @@ class Test_Repository_Scoping:
repos = repository_service._list_repositories()
assert len(repos) == 2
assert 1 in repos
assert 2 in repos
assert repos[1].name == "repo1"
assert repos[2].name == "repo2"
mock_client.get_repo.assert_called_once_with("owner1/repo1")
mock_client.get_organization.assert_called_once_with("org2")
mock_client.get_repo.assert_any_call("org1/repo1")
mock_client.get_repo.assert_any_call("org2/repo1")
def test_unqualified_repo_without_organization_is_skipped(self):
"""Test that an unqualified repo without --organization is skipped with a warning"""
provider = set_mocked_github_provider()
provider.repositories = ["repo1"]
provider.organizations = []
mock_client = MagicMock()
with patch(
"prowler.providers.github.services.repository.repository_service.GithubService.__init__"
):
repository_service = Repository(provider)
repository_service.clients = [mock_client]
repository_service.provider = provider
with patch(
"prowler.providers.github.services.repository.repository_service.logger"
) as mock_logger:
repos = repository_service._list_repositories()
assert len(repos) == 0
mock_logger.warning.assert_called_with(
"Repository name 'repo1' should be in 'owner/repo-name' format. Skipping."
)
mock_client.get_repo.assert_not_called()
def test_mixed_qualified_and_unqualified_repos_with_organization(self):
"""Test mix of qualified and unqualified repos with --organization"""
provider = set_mocked_github_provider()
provider.repositories = ["repo1", "owner2/repo2"]
provider.organizations = ["org1"]
mock_client = MagicMock()
mock_client.get_repo.side_effect = [self.mock_repo1, self.mock_repo2]
with patch(
"prowler.providers.github.services.repository.repository_service.GithubService.__init__"
):
repository_service = Repository(provider)
repository_service.clients = [mock_client]
repository_service.provider = provider
repos = repository_service._list_repositories()
assert len(repos) == 2
mock_client.get_repo.assert_any_call("org1/repo1")
mock_client.get_repo.assert_any_call("owner2/repo2")
class Test_Repository_Validation:
+30
View File
@@ -2,6 +2,36 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.18.3] (Prowler v5.18.3)
### 🐞 Fixed
- Dropdown selects in the "Send to Jira" modal and other dialogs not responding to clicks [(#10097)](https://github.com/prowler-cloud/prowler/pull/10097)
- Update credentials for the Alibaba Cloud provider [(#10098)](https://github.com/prowler-cloud/prowler/pull/10098)
---
## [1.18.2] (Prowler v5.18.2)
### 🐞 Fixed
- ProviderTypeSelector crashing when an unknown provider type is missing from PROVIDER_DATA [(#9991)](https://github.com/prowler-cloud/prowler/pull/9991)
- Infinite memory loop when opening modals from table row action dropdowns due to HeroUI and Radix Dialog overlay conflict [(#9996)](https://github.com/prowler-cloud/prowler/pull/9996)
- Filter changes not coordinating with Suspense boundaries in ProviderTypeSelector, AccountsSelector, and muted findings checkbox [(#10013)](https://github.com/prowler-cloud/prowler/pull/10013)
- Scans page pagination not refreshing table data after page change [(#10013)](https://github.com/prowler-cloud/prowler/pull/10013)
- Duplicate `filter[search]` parameter in findings and scans API calls [(#10013)](https://github.com/prowler-cloud/prowler/pull/10013)
- Filters on `/findings` silently reverting on first click in production [(#10034)](https://github.com/prowler-cloud/prowler/pull/10034)
---
## [1.18.1] (Prowler v5.18.1)
### 🐞 Fixed
- Scans page polling now only refreshes scan table data instead of re-rendering the entire server component tree, eliminating redundant API calls to providers, findings, and compliance endpoints every 5 seconds
---
## [1.18.0] (Prowler v5.18.0)
### 🔄 Changed
+8 -2
View File
@@ -25,7 +25,10 @@ export const getFindings = async ({
if (sort) url.searchParams.append("sort", sort);
Object.entries(filters).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
// Skip filter[search] since it's already added via the `query` param above
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
}
});
try {
@@ -63,7 +66,10 @@ export const getLatestFindings = async ({
if (sort) url.searchParams.append("sort", sort);
Object.entries(filters).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
// Skip filter[search] since it's already added via the `query` param above
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
}
});
try {
+4 -1
View File
@@ -37,7 +37,10 @@ export const getScans = async ({
// Add dynamic filters (e.g., "filter[state]", "fields[scans]")
Object.entries(filters).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
// Skip filter[search] since it's already added via the `query` param above
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
}
});
try {
@@ -1,6 +1,6 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { ReactNode } from "react";
import {
@@ -22,6 +22,7 @@ import {
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useUrlFilters } from "@/hooks/use-url-filters";
import type { ProviderProps, ProviderType } from "@/types/providers";
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
@@ -42,8 +43,8 @@ interface AccountsSelectorProps {
}
export function AccountsSelector({ providers }: AccountsSelectorProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
const filterKey = "filter[provider_id__in]";
const current = searchParams.get(filterKey) || "";
@@ -61,38 +62,37 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
);
const handleMultiValueChange = (ids: string[]) => {
const params = new URLSearchParams(searchParams.toString());
params.delete(filterKey);
navigateWithParams((params) => {
params.delete(filterKey);
if (ids.length > 0) {
params.set(filterKey, ids.join(","));
}
// Auto-deselect provider types that no longer have any selected accounts
if (selectedTypesList.length > 0) {
// Get provider types of currently selected accounts
const selectedProviders = providers.filter((p) => ids.includes(p.id));
const selectedProviderTypes = new Set(
selectedProviders.map((p) => p.attributes.provider),
);
// Keep only provider types that still have selected accounts
const remainingProviderTypes = selectedTypesList.filter((type) =>
selectedProviderTypes.has(type as ProviderType),
);
// Update provider_type__in filter
if (remainingProviderTypes.length > 0) {
params.set(
"filter[provider_type__in]",
remainingProviderTypes.join(","),
);
} else {
params.delete("filter[provider_type__in]");
if (ids.length > 0) {
params.set(filterKey, ids.join(","));
}
}
router.push(`?${params.toString()}`, { scroll: false });
// Auto-deselect provider types that no longer have any selected accounts
if (selectedTypesList.length > 0) {
// Get provider types of currently selected accounts
const selectedProviders = providers.filter((p) => ids.includes(p.id));
const selectedProviderTypes = new Set(
selectedProviders.map((p) => p.attributes.provider),
);
// Keep only provider types that still have selected accounts
const remainingProviderTypes = selectedTypesList.filter((type) =>
selectedProviderTypes.has(type as ProviderType),
);
// Update provider_type__in filter
if (remainingProviderTypes.length > 0) {
params.set(
"filter[provider_type__in]",
remainingProviderTypes.join(","),
);
} else {
params.delete("filter[provider_type__in]");
}
}
});
};
const selectedLabel = () => {
@@ -1,6 +1,6 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { lazy, Suspense } from "react";
import {
@@ -10,6 +10,7 @@ import {
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { type ProviderProps, ProviderType } from "@/types/providers";
const AWSProviderBadge = lazy(() =>
@@ -122,8 +123,8 @@ type ProviderTypeSelectorProps = {
export const ProviderTypeSelector = ({
providers,
}: ProviderTypeSelectorProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
const currentProviders = searchParams.get("filter[provider_type__in]") || "";
const selectedTypes = currentProviders
@@ -131,20 +132,18 @@ export const ProviderTypeSelector = ({
: [];
const handleMultiValueChange = (values: string[]) => {
const params = new URLSearchParams(searchParams.toString());
navigateWithParams((params) => {
// Update provider_type__in
if (values.length > 0) {
params.set("filter[provider_type__in]", values.join(","));
} else {
params.delete("filter[provider_type__in]");
}
// Update provider_type__in
if (values.length > 0) {
params.set("filter[provider_type__in]", values.join(","));
} else {
params.delete("filter[provider_type__in]");
}
// Clear account selection when changing provider types
// User should manually select accounts if they want to filter by specific accounts
params.delete("filter[provider_id__in]");
router.push(`?${params.toString()}`, { scroll: false });
// Clear account selection when changing provider types
// User should manually select accounts if they want to filter by specific accounts
params.delete("filter[provider_id__in]");
});
};
const availableTypes = Array.from(
@@ -153,7 +152,7 @@ export const ProviderTypeSelector = ({
// .filter((p) => p.attributes.connection?.connected)
.map((p) => p.attributes.provider),
),
) as ProviderType[];
).filter((type): type is ProviderType => type in PROVIDER_DATA);
const renderIcon = (providerType: ProviderType) => {
const IconComponent = PROVIDER_DATA[providerType].icon;
@@ -1,17 +1,15 @@
"use client";
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
} from "@heroui/dropdown";
import { Pencil, Trash2 } from "lucide-react";
import { MuteRuleData } from "@/actions/mute-rules/types";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import {
ActionDropdown,
ActionDropdownDangerZone,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
interface MuteRuleRowActionsProps {
muteRule: MuteRuleData;
@@ -26,11 +24,8 @@ export function MuteRuleRowActions({
}: MuteRuleRowActionsProps) {
return (
<div className="flex items-center justify-center px-2">
<Dropdown
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
placement="bottom"
>
<DropdownTrigger>
<ActionDropdown
trigger={
<Button
variant="outline"
size="icon-sm"
@@ -41,44 +36,22 @@ export function MuteRuleRowActions({
className="text-text-neutral-secondary"
/>
</Button>
</DropdownTrigger>
<DropdownMenu
closeOnSelect
aria-label="Mute rule actions"
color="default"
variant="flat"
>
<DropdownSection title="Actions">
<DropdownItem
key="edit"
description="Edit rule name and reason"
textValue="Edit"
startContent={
<Pencil className="text-default-500 pointer-events-none size-4 shrink-0" />
}
onPress={() => onEdit(muteRule)}
>
Edit
</DropdownItem>
<DropdownItem
key="delete"
description="Delete this mute rule"
textValue="Delete"
className="text-danger"
color="danger"
classNames={{
description: "text-danger",
}}
startContent={
<Trash2 className="pointer-events-none size-4 shrink-0" />
}
onPress={() => onDelete(muteRule)}
>
Delete
</DropdownItem>
</DropdownSection>
</DropdownMenu>
</Dropdown>
}
>
<ActionDropdownItem
icon={<Pencil />}
label="Edit Mute Rule"
onSelect={() => onEdit(muteRule)}
/>
<ActionDropdownDangerZone>
<ActionDropdownItem
icon={<Trash2 />}
label="Delete Mute Rule"
destructive
onSelect={() => onDelete(muteRule)}
/>
</ActionDropdownDangerZone>
</ActionDropdown>
</div>
);
}
+16 -9
View File
@@ -13,7 +13,7 @@ import {
} from "@/components/providers/table";
import { ContentLayout } from "@/components/ui";
import { DataTable } from "@/components/ui/table";
import { ProviderProps, SearchParamsProps } from "@/types";
import { PROVIDER_TYPES, ProviderProps, SearchParamsProps } from "@/types";
export default async function Providers({
searchParams,
@@ -89,15 +89,22 @@ const ProvidersTable = async ({
return acc;
}, {}) || {};
// Exclude provider types not yet supported in the UI
const enrichedProviders =
providersData?.data?.map((provider: ProviderProps) => {
const groupNames =
provider.relationships?.provider_groups?.data?.map(
(group: { id: string }) =>
providerGroupDict[group.id] || "Unknown Group",
) || [];
return { ...provider, groupNames };
}) || [];
providersData?.data
?.filter((provider: ProviderProps) =>
(PROVIDER_TYPES as readonly string[]).includes(
provider.attributes.provider,
),
)
.map((provider: ProviderProps) => {
const groupNames =
provider.relationships?.provider_groups?.data?.map(
(group: { id: string }) =>
providerGroupDict[group.id] || "Unknown Group",
) || [];
return { ...provider, groupNames };
}) || [];
return (
<>
+6 -19
View File
@@ -1,21 +1,19 @@
import { Suspense } from "react";
import { getAllProviders } from "@/actions/providers";
import { getScans, getScansByState } from "@/actions/scans";
import { getScans } from "@/actions/scans";
import { auth } from "@/auth.config";
import { MutedFindingsConfigButton } from "@/components/providers";
import {
AutoRefresh,
NoProvidersAdded,
NoProvidersConnected,
ScansFilters,
} from "@/components/scans";
import { LaunchScanWorkflow } from "@/components/scans/launch-workflow";
import { SkeletonTableScans } from "@/components/scans/table";
import { ColumnGetScans } from "@/components/scans/table/scans";
import { ScansTableWithPolling } from "@/components/scans/table/scans";
import { ContentLayout } from "@/components/ui";
import { CustomBanner } from "@/components/ui/custom/custom-banner";
import { DataTable } from "@/components/ui/table";
import {
createProviderDetailsMapping,
extractProviderUIDs,
@@ -57,15 +55,6 @@ export default async function Scans({
const hasManageScansPermission = session?.user?.permissions?.manage_scans;
// Get scans data to check for executing scans
const scansData = await getScansByState();
const hasExecutingScan = scansData?.data?.some(
(scan: ScanProps) =>
scan.attributes.state === "executing" ||
scan.attributes.state === "available",
);
// Extract provider UIDs and create provider details mapping for filtering
const providerUIDs = providersData ? extractProviderUIDs(providersData) : [];
const providerDetails = providersData
@@ -82,7 +71,6 @@ export default async function Scans({
return (
<ContentLayout title="Scans" icon="lucide:timer">
<AutoRefresh hasExecutingScan={hasExecutingScan} />
<>
<>
{!hasManageScansPermission ? (
@@ -177,11 +165,10 @@ const SSRDataTableScans = async ({
}) || [];
return (
<DataTable
key={`scans-${Date.now()}`}
columns={ColumnGetScans}
data={expandedScansData || []}
metadata={meta}
<ScansTableWithPolling
initialData={expandedScansData}
initialMeta={meta}
searchParams={searchParams}
/>
);
};
@@ -1,35 +0,0 @@
"use client";
import { Select, SelectItem } from "@heroui/select";
const accounts = [
{ key: "audit-test-1", label: "740350143844" },
{ key: "audit-test-2", label: "890837126756" },
{ key: "audit-test-3", label: "563829104923" },
{ key: "audit-test-4", label: "678943217543" },
{ key: "audit-test-5", label: "932187465320" },
{ key: "audit-test-6", label: "492837106587" },
{ key: "audit-test-7", label: "812736459201" },
{ key: "audit-test-8", label: "374829106524" },
{ key: "audit-test-9", label: "926481053298" },
{ key: "audit-test-10", label: "748192364579" },
{ key: "audit-test-11", label: "501374829106" },
];
export const CustomAccountSelection = () => {
return (
<Select
label="Account"
aria-label="Select an Account"
placeholder="Select an account"
classNames={{
selectorIcon: "right-2",
}}
selectionMode="multiple"
className="w-full"
size="sm"
>
{accounts.map((acc) => (
<SelectItem key={acc.key}>{acc.label}</SelectItem>
))}
</Select>
);
};
@@ -1,8 +1,9 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { Checkbox } from "@/components/shadcn";
import { useUrlFilters } from "@/hooks/use-url-filters";
// Constants for muted filter URL values
const MUTED_FILTER_VALUES = {
@@ -11,12 +12,10 @@ const MUTED_FILTER_VALUES = {
} as const;
export const CustomCheckboxMutedFindings = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
// Get the current muted filter value from URL
// Middleware ensures filter[muted] is always present when navigating to /findings
const mutedFilterValue = searchParams.get("filter[muted]");
// URL states:
@@ -26,22 +25,16 @@ export const CustomCheckboxMutedFindings = () => {
const handleMutedChange = (checked: boolean | "indeterminate") => {
const isChecked = checked === true;
const params = new URLSearchParams(searchParams.toString());
if (isChecked) {
// Include muted: set special value (API will ignore invalid value and show all)
params.set("filter[muted]", MUTED_FILTER_VALUES.INCLUDE);
} else {
// Exclude muted: apply filter to show only non-muted
params.set("filter[muted]", MUTED_FILTER_VALUES.EXCLUDE);
}
// Reset to page 1 when changing filter
if (params.has("page")) {
params.set("page", "1");
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
navigateWithParams((params) => {
if (isChecked) {
// Include muted: set special value (API will ignore invalid value and show all)
params.set("filter[muted]", MUTED_FILTER_VALUES.INCLUDE);
} else {
// Exclude muted: apply filter to show only non-muted
params.set("filter[muted]", MUTED_FILTER_VALUES.EXCLUDE);
}
});
};
return (
@@ -1,48 +0,0 @@
"use client";
import { Select, SelectItem } from "@heroui/select";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useCallback, useMemo } from "react";
export const CustomRegionSelection: React.FC = () => {
const router = useRouter();
const searchParams = useSearchParams();
const region = "none";
// Memoize selected keys based on the URL
const selectedKeys = useMemo(() => {
const params = searchParams.get("filter[regions]");
return params ? params.split(",") : [];
}, [searchParams]);
const applyRegionFilter = useCallback(
(values: string[]) => {
const params = new URLSearchParams(searchParams.toString());
if (values.length > 0) {
params.set("filter[regions]", values.join(","));
} else {
params.delete("filter[regions]");
}
router.push(`?${params.toString()}`, { scroll: false });
},
[router, searchParams],
);
return (
<Select
label="Region"
aria-label="Select a Region"
placeholder="Select a region"
classNames={{
selectorIcon: "right-2",
}}
className="w-full"
size="sm"
selectedKeys={selectedKeys}
onSelectionChange={(keys) =>
applyRegionFilter(Array.from(keys) as string[])
}
>
<SelectItem key={region}>{region}</SelectItem>
</Select>
);
};
@@ -1,128 +0,0 @@
"use client";
import { Select, SelectItem } from "@heroui/select";
import { useRouter, useSearchParams } from "next/navigation";
import { ReactElement } from "react";
import { PROVIDER_TYPES, ProviderType } from "@/types/providers";
import {
CustomProviderInputAlibabaCloud,
CustomProviderInputAWS,
CustomProviderInputAzure,
CustomProviderInputGCP,
CustomProviderInputGitHub,
CustomProviderInputIac,
CustomProviderInputKubernetes,
CustomProviderInputM365,
CustomProviderInputMongoDBAtlas,
CustomProviderInputOracleCloud,
} from "./custom-provider-inputs";
const providerDisplayData: Record<
ProviderType,
{ label: string; component: ReactElement }
> = {
aws: {
label: "Amazon Web Services",
component: <CustomProviderInputAWS />,
},
azure: {
label: "Microsoft Azure",
component: <CustomProviderInputAzure />,
},
gcp: {
label: "Google Cloud Platform",
component: <CustomProviderInputGCP />,
},
github: {
label: "GitHub",
component: <CustomProviderInputGitHub />,
},
iac: {
label: "Infrastructure as Code",
component: <CustomProviderInputIac />,
},
kubernetes: {
label: "Kubernetes",
component: <CustomProviderInputKubernetes />,
},
m365: {
label: "Microsoft 365",
component: <CustomProviderInputM365 />,
},
mongodbatlas: {
label: "MongoDB Atlas",
component: <CustomProviderInputMongoDBAtlas />,
},
oraclecloud: {
label: "Oracle Cloud Infrastructure",
component: <CustomProviderInputOracleCloud />,
},
alibabacloud: {
label: "Alibaba Cloud",
component: <CustomProviderInputAlibabaCloud />,
},
};
const dataInputsProvider = PROVIDER_TYPES.map((providerType) => ({
key: providerType,
label: providerDisplayData[providerType].label,
value: providerDisplayData[providerType].component,
}));
export const CustomSelectProvider = () => {
const router = useRouter();
const searchParams = useSearchParams();
const applyProviderFilter = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("filter[provider_type]", value);
} else {
params.delete("filter[provider_type]");
}
router.push(`?${params.toString()}`, { scroll: false });
};
const currentProvider = searchParams.get("filter[provider_type]") || "";
const selectedKeys = dataInputsProvider.some(
(provider) => provider.key === currentProvider,
)
? [currentProvider]
: [];
return (
<Select
items={dataInputsProvider}
aria-label="Select a Provider"
placeholder="Select a provider"
classNames={{
selectorIcon: "right-2",
label: "z-0! mb-2",
}}
label="Provider"
labelPlacement="inside"
size="sm"
onChange={(e) => {
const value = e.target.value;
applyProviderFilter(value);
}}
selectedKeys={selectedKeys}
renderValue={(items) => {
return items.map((item) => (
<div key={item.key} className="flex items-center gap-2">
{item.data?.value}
</div>
));
}}
>
{(item) => (
<SelectItem key={item.key} textValue={item.key} aria-label={item.label}>
<div className="flex items-center gap-2">{item.value}</div>
</SelectItem>
)}
</Select>
);
};
-20
View File
@@ -3,30 +3,15 @@
import { FilterOption } from "@/types";
import { DataTableFilterCustom } from "../ui/table";
import { CustomAccountSelection } from "./custom-account-selection";
import { CustomCheckboxMutedFindings } from "./custom-checkbox-muted-findings";
import { CustomDatePicker } from "./custom-date-picker";
import { CustomRegionSelection } from "./custom-region-selection";
import { CustomSearchInput } from "./custom-search-input";
import { CustomSelectProvider } from "./custom-select-provider";
export interface FilterControlsProps {
search?: boolean;
providers?: boolean;
date?: boolean;
regions?: boolean;
accounts?: boolean;
mutedFindings?: boolean;
customFilters?: FilterOption[];
}
export const FilterControls = ({
search = false,
providers = false,
date = false,
regions = false,
accounts = false,
mutedFindings = false,
customFilters,
}: FilterControlsProps) => {
return (
@@ -34,11 +19,6 @@ export const FilterControls = ({
<div className="mb-4 flex flex-col items-start gap-4 md:flex-row md:items-center">
<div className="grid w-full flex-1 grid-cols-1 items-center gap-x-4 gap-y-4 md:grid-cols-2 xl:grid-cols-4">
{search && <CustomSearchInput />}
{providers && <CustomSelectProvider />}
{date && <CustomDatePicker />}
{regions && <CustomRegionSelection />}
{accounts && <CustomAccountSelection />}
{mutedFindings && <CustomCheckboxMutedFindings />}
</div>
</div>
{customFilters && customFilters.length > 0 && (
-3
View File
@@ -1,9 +1,6 @@
export * from "./clear-filters-button";
export * from "./custom-account-selection";
export * from "./custom-checkbox-muted-findings";
export * from "./custom-date-picker";
export * from "./custom-provider-inputs";
export * from "./custom-region-selection";
export * from "./custom-select-provider";
export * from "./data-filters";
export * from "./filter-controls";
+67 -166
View File
@@ -1,10 +1,7 @@
"use client";
import { Input } from "@heroui/input";
import { Select, SelectItem } from "@heroui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import type { Selection } from "@react-types/shared";
import { Search, Send } from "lucide-react";
import { Send } from "lucide-react";
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -14,16 +11,11 @@ import {
pollJiraDispatchTask,
sendFindingToJira,
} from "@/actions/integrations/jira-dispatch";
import { JiraIcon } from "@/components/icons/services/IconServices";
import { Modal } from "@/components/shadcn/modal";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
import { CustomBanner } from "@/components/ui/custom/custom-banner";
import {
Form,
FormControl,
FormField,
FormMessage,
} from "@/components/ui/form";
import { Form, FormField, FormMessage } from "@/components/ui/form";
import { FormButtons } from "@/components/ui/form/form-buttons";
import { IntegrationProps } from "@/types/integrations";
@@ -42,15 +34,6 @@ const sendToJiraSchema = z.object({
type SendToJiraFormData = z.infer<typeof sendToJiraSchema>;
const selectorClassNames = {
trigger: "min-h-12",
popoverContent: "bg-bg-neutral-secondary",
listboxWrapper: "max-h-[300px] bg-bg-neutral-secondary",
listbox: "gap-0",
label: "tracking-tight font-light !text-text-neutral-secondary text-xs z-0!",
value: "text-text-neutral-secondary text-small",
};
// The commented code is related to issue types, which are not required for the first implementation, but will be used in the future
export const SendToJiraModal = ({
isOpen,
@@ -61,8 +44,6 @@ export const SendToJiraModal = ({
const { toast } = useToast();
const [integrations, setIntegrations] = useState<IntegrationProps[]>([]);
const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(false);
const [searchProjectValue, setSearchProjectValue] = useState("");
// const [searchIssueTypeValue, setSearchIssueTypeValue] = useState("");
const form = useForm<SendToJiraFormData>({
resolver: zodResolver(sendToJiraSchema),
@@ -75,18 +56,11 @@ export const SendToJiraModal = ({
});
const selectedIntegration = form.watch("integration");
// const selectedProject = form.watch("project");
const hasConnectedIntegration = integrations.some(
(i) => i.attributes.connected === true,
);
const getSelectedValue = (keys: Selection): string => {
if (keys === "all") return "";
const first = Array.from(keys)[0];
return first !== null ? String(first) : "";
};
const setOpenForFormButtons: Dispatch<SetStateAction<boolean>> = (value) => {
const next = typeof value === "function" ? value(isOpen) : value;
onOpenChange(next);
@@ -129,8 +103,6 @@ export const SendToJiraModal = ({
} else {
// Reset form when modal closes
form.reset();
setSearchProjectValue("");
// setSearchIssueTypeValue("");
}
}, [isOpen, form, toast]);
@@ -187,32 +159,16 @@ export const SendToJiraModal = ({
({} as Record<string, string>);
const projectEntries = Object.entries(projects);
const shouldShowProjectSearch = projectEntries.length > 5;
// const issueTypes: string[] =
// selectedIntegrationData?.attributes.configuration.issue_types ||
// ([] as string[]);
// Filter projects based on search
const filteredProjects = (() => {
if (!searchProjectValue) return projectEntries;
const integrationOptions = integrations.map((integration) => ({
value: integration.id,
label: integration.attributes.configuration.domain || integration.id,
}));
const lowerSearch = searchProjectValue.toLowerCase();
return projectEntries.filter(
([key, name]) =>
key.toLowerCase().includes(lowerSearch) ||
name.toLowerCase().includes(lowerSearch),
);
})();
// Filter issue types based on search
// const filteredIssueTypes = useMemo(() => {
// if (!searchIssueTypeValue) return issueTypes;
// const lowerSearch = searchIssueTypeValue.toLowerCase();
// return issueTypes.filter((type) =>
// type.toLowerCase().includes(lowerSearch),
// );
// }, [issueTypes, searchIssueTypeValue]);
const projectOptions = projectEntries.map(([key, name]) => ({
value: key,
label: `${key} - ${name}`,
}));
return (
<Modal
@@ -236,127 +192,72 @@ export const SendToJiraModal = ({
control={form.control}
name="integration"
render={({ field }) => (
<>
<FormControl>
<Select
label="Jira Integration"
placeholder="Select a Jira integration"
selectedKeys={
field.value ? new Set([field.value]) : new Set()
}
onSelectionChange={(keys: Selection) => {
const value = getSelectedValue(keys);
field.onChange(value);
// Reset dependent fields
form.setValue("project", "");
// Keep issue type defaulting to Task
form.setValue("issueType", "Task");
setSearchProjectValue("");
// setSearchIssueTypeValue("");
}}
variant="bordered"
labelPlacement="inside"
isDisabled={isFetchingIntegrations}
isInvalid={!!form.formState.errors.integration}
startContent={<JiraIcon size={16} />}
classNames={selectorClassNames}
>
{integrations.map((integration) => (
<SelectItem
key={integration.id}
textValue={
integration.attributes.configuration.domain
}
>
<div className="flex items-center gap-2">
<JiraIcon size={16} />
<span>
{integration.attributes.configuration.domain}
</span>
</div>
</SelectItem>
))}
</Select>
</FormControl>
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-integration-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Jira Integration
</label>
<EnhancedMultiSelect
id="jira-integration-select"
options={integrationOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
// Reset dependent fields
form.setValue("project", "");
form.setValue("issueType", "Task");
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Select a Jira integration"
searchable={true}
emptyIndicator="No integrations found."
disabled={isFetchingIntegrations}
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
<FormMessage className="text-text-error text-xs" />
</>
</div>
)}
/>
)}
{/* Project Selection - Enhanced Style */}
{selectedIntegration && Object.keys(projects).length > 0 && (
{/* Project Selection */}
{selectedIntegration && projectEntries.length > 0 && (
<FormField
control={form.control}
name="project"
render={({ field }) => (
<>
<FormControl>
<Select
label="Project"
placeholder="Select a Jira project"
selectedKeys={
field.value ? new Set([field.value]) : new Set()
}
onSelectionChange={(keys: Selection) => {
const value = getSelectedValue(keys);
field.onChange(value);
// Keep issue type defaulting to Task when project changes
form.setValue("issueType", "Task");
// setSearchIssueTypeValue("");
}}
variant="bordered"
labelPlacement="inside"
isInvalid={!!form.formState.errors.project}
classNames={selectorClassNames}
listboxProps={{
topContent: shouldShowProjectSearch ? (
<div className="sticky top-0 z-10 py-2">
<Input
isClearable
placeholder="Search projects..."
size="sm"
variant="bordered"
startContent={<Search size={16} />}
value={searchProjectValue}
onValueChange={setSearchProjectValue}
onClear={() => setSearchProjectValue("")}
classNames={{
inputWrapper:
"border-border-input-primary bg-bg-input-primary hover:bg-bg-neutral-secondary",
input: "text-small",
clearButton: "text-default-400",
}}
/>
</div>
) : null,
}}
>
{filteredProjects.map(([key, name]) => (
<SelectItem key={key} textValue={`${key} - ${name}`}>
<div className="flex w-full items-center justify-between py-1">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-small font-semibold">
{key}
</span>
<span className="text-tiny text-default-500">
-
</span>
<span className="text-small truncate">
{name}
</span>
</div>
</div>
</div>
</div>
</SelectItem>
))}
</Select>
</FormControl>
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-project-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Project
</label>
<EnhancedMultiSelect
id="jira-project-select"
options={projectOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
// Keep issue type defaulting to Task when project changes
form.setValue("issueType", "Task");
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Select a Jira project"
searchable={true}
emptyIndicator="No projects found."
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
<FormMessage className="text-text-error text-xs" />
</>
</div>
)}
/>
)}
@@ -66,30 +66,13 @@ export function DataTableRowActions<T extends FindingRowData>({
return [finding.id];
};
const getMuteDescription = (): string => {
if (isMuted) {
return "This finding is already muted";
}
const ids = getMuteIds();
if (ids.length > 1) {
return `Mute ${ids.length} selected findings`;
}
return "Mute this finding";
};
const getMuteLabel = () => {
if (isMuted) return "Muted";
if (!isMuted && isCurrentSelected && hasMultipleSelected) {
return (
<>
Mute
<span className="ml-1 text-xs text-slate-500">
({selectedFindingIds.length})
</span>
</>
);
const ids = getMuteIds();
if (ids.length > 1) {
return `Mute ${ids.length} Findings`;
}
return "Mute";
return "Mute Finding";
};
const handleMuteComplete = () => {
@@ -146,7 +129,6 @@ export function DataTableRowActions<T extends FindingRowData>({
)
}
label={getMuteLabel()}
description={getMuteDescription()}
disabled={isMuted}
onSelect={() => {
setIsMuteModalOpen(true);
@@ -155,7 +137,6 @@ export function DataTableRowActions<T extends FindingRowData>({
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label="Send to Jira"
description="Create a Jira issue for this finding"
onSelect={() => setIsJiraModalOpen(true)}
/>
</ActionDropdown>
@@ -12,7 +12,7 @@ import {
} from "@/components/icons/providers-badge";
import { ProviderType } from "@/types";
const PROVIDER_ICONS = {
export const PROVIDER_ICONS = {
aws: AWSProviderBadge,
azure: AzureProviderBadge,
gcp: GCPProviderBadge,
-1
View File
@@ -1,4 +1,3 @@
export * from "../providers/enhanced-provider-selector";
export * from "./api-key/api-key-link-card";
export * from "./jira/jira-integration-card";
export * from "./jira/jira-integration-form";
@@ -8,12 +8,18 @@ import { useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import { EnhancedProviderSelector } from "@/components/providers/enhanced-provider-selector";
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { Form } from "@/components/ui/form";
import {
Form,
FormControl,
FormField,
FormMessage,
} from "@/components/ui/form";
import { FormButtons } from "@/components/ui/form/form-buttons";
import { getAWSCredentialsTemplateLinks } from "@/lib";
import { AWSCredentialsRole } from "@/types";
@@ -272,18 +278,40 @@ export const S3IntegrationForm = ({
// Show configuration step (step 0 or editing configuration)
if (isEditingConfig || currentStep === 0) {
const providerOptions = providers.map((provider) => {
const Icon = PROVIDER_ICONS[provider.attributes.provider];
return {
value: provider.id,
label: provider.attributes.alias || provider.attributes.uid,
icon: Icon ? <Icon width={20} height={20} /> : undefined,
description: provider.attributes.connection.connected
? "Connected"
: "Disconnected",
};
});
return (
<>
{/* Provider Selection */}
<div className="flex flex-col gap-4">
<EnhancedProviderSelector
<FormField
control={form.control}
name="providers"
providers={providers}
label="Cloud Providers"
placeholder="Select providers to integrate with"
selectionMode="multiple"
enableSearch={true}
render={({ field }) => (
<>
<FormControl>
<EnhancedMultiSelect
options={providerOptions}
onValueChange={field.onChange}
defaultValue={field.value || []}
placeholder="Select providers to integrate with"
searchable={true}
maxCount={1}
/>
</FormControl>
<FormMessage className="text-text-error max-w-full text-xs" />
</>
)}
/>
</div>
@@ -6,15 +6,21 @@ import { Radio, RadioGroup } from "@heroui/radio";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import { EnhancedProviderSelector } from "@/components/providers/enhanced-provider-selector";
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { Form, FormControl, FormField } from "@/components/ui/form";
import {
Form,
FormControl,
FormField,
FormMessage,
} from "@/components/ui/form";
import { FormButtons } from "@/components/ui/form/form-buttons";
import { getAWSCredentialsTemplateLinks } from "@/lib";
import { AWSCredentialsRole } from "@/types";
@@ -52,7 +58,7 @@ export const SecurityHubIntegrationForm = ({
const isEditingConfig = editMode === "configuration";
const isEditingCredentials = editMode === "credentials";
const disabledProviderIds = useMemo(() => {
const disabledProviderIds = (() => {
// When editing, no providers should be disabled since we're not changing it
if (isEditing) {
return [];
@@ -69,7 +75,7 @@ export const SecurityHubIntegrationForm = ({
});
return usedProviderIds;
}, [isEditing, existingIntegrations]);
})();
const form = useForm({
resolver: zodResolver(
@@ -107,6 +113,26 @@ export const SecurityHubIntegrationForm = ({
const providerIdValue = form.watch("provider_id");
const hasErrors = !!form.formState.errors.provider_id || !providerIdValue;
const providerOptions = providers
.filter((provider) => provider.attributes.provider === "aws")
.map((provider) => {
const isDisabled = disabledProviderIds.includes(provider.id);
const connectionLabel = provider.attributes.connection.connected
? "Connected"
: "Disconnected";
const Icon = PROVIDER_ICONS[provider.attributes.provider];
return {
value: provider.id,
label: provider.attributes.alias || provider.attributes.uid,
icon: Icon ? <Icon width={20} height={20} /> : undefined,
description: isDisabled
? `${connectionLabel} (Already in use)`
: connectionLabel,
disabled: isDisabled,
};
});
useEffect(() => {
if (!useCustomCredentials && isCreating) {
setCurrentStep(0);
@@ -325,17 +351,29 @@ export const SecurityHubIntegrationForm = ({
{!isEditingConfig && (
<>
<div className="flex flex-col gap-4">
<EnhancedProviderSelector
<FormField
control={form.control}
name="provider_id"
providers={providers}
label="AWS Provider"
placeholder="Search and select an AWS provider"
isInvalid={!!form.formState.errors.provider_id}
selectionMode="single"
providerType="aws"
enableSearch={true}
disabledProviderIds={disabledProviderIds}
render={({ field }) => (
<>
<FormControl>
<EnhancedMultiSelect
options={providerOptions}
onValueChange={(values) => {
field.onChange(values.at(-1) ?? "");
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Search and select an AWS provider"
searchable={true}
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
</FormControl>
<FormMessage className="text-text-error max-w-full text-xs" />
</>
)}
/>
</div>
<Divider />
@@ -1,6 +1,5 @@
"use client";
import { Chip } from "@heroui/chip";
import { format } from "date-fns";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
@@ -16,7 +15,7 @@ import {
IntegrationCardHeader,
IntegrationSkeleton,
} from "@/components/integrations/shared";
import { Button } from "@/components/shadcn";
import { Badge, Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
@@ -385,14 +384,13 @@ export const SecurityHubIntegrationsManager = ({
{enabledRegions.length > 0 && (
<div className="flex flex-wrap gap-1">
{enabledRegions.map((region) => (
<Chip
<Badge
key={region}
size="sm"
variant="flat"
className="bg-bg-neutral-secondary"
variant="outline"
className="border-border-neutral-secondary bg-bg-neutral-secondary text-text-neutral-primary text-xs font-normal"
>
{region}
</Chip>
</Badge>
))}
</div>
)}
@@ -1,23 +1,18 @@
"use client";
import { Chip } from "@heroui/chip";
import { ExternalLink } from "lucide-react";
import { ReactNode } from "react";
import { Badge } from "@/components/shadcn";
import { cn } from "@/lib/utils";
interface IntegrationCardHeaderProps {
icon: ReactNode;
title: string;
subtitle?: string;
chips?: Array<{
label: string;
color?:
| "default"
| "primary"
| "secondary"
| "success"
| "warning"
| "danger";
variant?: "solid" | "bordered" | "light" | "flat" | "faded" | "shadow";
className?: string;
}>;
connectionStatus?: {
connected: boolean;
@@ -63,25 +58,30 @@ export const IntegrationCardHeader = ({
{(chips.length > 0 || connectionStatus) && (
<div className="flex flex-wrap items-center gap-2">
{chips.map((chip, index) => (
<Chip
<Badge
key={index}
size="sm"
variant={chip.variant || "flat"}
color={chip.color || "default"}
className="text-xs"
variant="outline"
className={cn(
"border-border-neutral-secondary bg-bg-neutral-secondary text-text-neutral-primary text-xs font-normal",
chip.className,
)}
>
{chip.label}
</Chip>
</Badge>
))}
{connectionStatus && (
<Chip
size="sm"
color={connectionStatus.connected ? "success" : "danger"}
variant="flat"
<Badge
variant="outline"
className={cn(
"text-xs font-normal",
connectionStatus.connected
? "bg-bg-pass-secondary text-text-success-primary border-transparent"
: "bg-bg-danger-secondary text-text-danger border-transparent",
)}
>
{connectionStatus.label ||
(connectionStatus.connected ? "Connected" : "Disconnected")}
</Chip>
</Badge>
)}
</div>
)}
+22 -18
View File
@@ -1,4 +1,3 @@
import { Select, SelectItem } from "@heroui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import { MailIcon, ShieldIcon } from "lucide-react";
import { Dispatch, SetStateAction } from "react";
@@ -6,6 +5,13 @@ import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import { updateInvite } from "@/actions/invitations/invitation";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { Form, FormButtons } from "@/components/ui/form";
@@ -126,27 +132,25 @@ export const EditForm = ({
isRequired={false}
/>
</div>
<div>
<div className="flex flex-col gap-1.5">
<label className="text-text-neutral-secondary text-sm font-medium">
Role
</label>
<Controller
name="role"
control={form.control}
render={({ field }) => (
<Select
{...field}
label="Role"
placeholder="Select a role"
classNames={{
selectorIcon: "right-2",
}}
variant="flat"
selectedKeys={[field.value || ""]}
onSelectionChange={(selected) =>
field.onChange(selected?.currentKey || "")
}
>
{roles.map((role) => (
<SelectItem key={role.id}>{role.name}</SelectItem>
))}
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
@@ -1,24 +1,17 @@
"use client";
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
} from "@heroui/dropdown";
import {
AddNoteBulkIcon,
DeleteDocumentBulkIcon,
EditDocumentBulkIcon,
} from "@heroui/shared-icons";
import { Row } from "@tanstack/react-table";
import clsx from "clsx";
import { Eye, Pencil, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import {
ActionDropdown,
ActionDropdownDangerZone,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Modal } from "@/components/shadcn/modal";
import { DeleteForm, EditForm } from "../forms";
@@ -27,7 +20,6 @@ interface DataTableRowActionsProps<InvitationProps> {
row: Row<InvitationProps>;
roles?: { id: string; name: string }[];
}
const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0";
export function DataTableRowActions<InvitationProps>({
row,
@@ -67,65 +59,36 @@ export function DataTableRowActions<InvitationProps>({
</Modal>
<div className="relative flex items-center justify-end gap-2">
<Dropdown
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
placement="bottom"
>
<DropdownTrigger>
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-slate-400" />
</Button>
</DropdownTrigger>
<DropdownMenu
closeOnSelect
aria-label="Actions"
color="default"
variant="flat"
>
<DropdownSection title="Actions">
<DropdownItem
key="check-details"
description="View invitation details"
textValue="Check Details"
startContent={<AddNoteBulkIcon className={iconClasses} />}
onPress={() =>
router.push(`/invitations/check-details?id=${invitationId}`)
}
>
Check Details
</DropdownItem>
<DropdownItem
key="edit"
description="Allows you to edit the invitation"
textValue="Edit Invitation"
startContent={<EditDocumentBulkIcon className={iconClasses} />}
onPress={() => setIsEditOpen(true)}
isDisabled={invitationAccepted === "accepted"}
>
Edit Invitation
</DropdownItem>
</DropdownSection>
<DropdownSection title="Danger zone">
<DropdownItem
key="delete"
className="text-text-error"
color="danger"
description="Delete the invitation permanently"
textValue="Delete Invitation"
startContent={
<DeleteDocumentBulkIcon
className={clsx(iconClasses, "!text-text-error")}
/>
}
onPress={() => setIsDeleteOpen(true)}
isDisabled={invitationAccepted === "accepted"}
>
Revoke Invitation
</DropdownItem>
</DropdownSection>
</DropdownMenu>
</Dropdown>
}
>
<ActionDropdownItem
icon={<Eye />}
label="Check Details"
onSelect={() =>
router.push(`/invitations/check-details?id=${invitationId}`)
}
/>
<ActionDropdownItem
icon={<Pencil />}
label="Edit Invitation"
onSelect={() => setIsEditOpen(true)}
disabled={invitationAccepted === "accepted"}
/>
<ActionDropdownDangerZone>
<ActionDropdownItem
icon={<Trash2 />}
label="Revoke Invitation"
destructive
onSelect={() => setIsDeleteOpen(true)}
disabled={invitationAccepted === "accepted"}
/>
</ActionDropdownDangerZone>
</ActionDropdown>
</div>
</>
);
@@ -1,6 +1,5 @@
"use client";
import { Select, SelectItem } from "@heroui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import { SaveIcon } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -9,6 +8,13 @@ import * as z from "zod";
import { sendInvite } from "@/actions/invitations/invitation";
import { Button } from "@/components/shadcn";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
@@ -111,35 +117,33 @@ export const SendInvitationForm = ({
name="roleId"
control={form.control}
render={({ field }) => (
<>
<div className="flex flex-col gap-1.5">
<Select
{...field}
label="Role"
placeholder="Select a role"
classNames={{
selectorIcon: "right-2",
}}
variant="flat"
isDisabled={isSelectorDisabled}
selectedKeys={[field.value]}
onSelectionChange={(selected) =>
field.onChange(selected?.currentKey || "")
}
value={field.value || undefined}
onValueChange={field.onChange}
disabled={isSelectorDisabled}
>
{isSelectorDisabled ? (
<SelectItem key={defaultRole}>{defaultRole}</SelectItem>
) : (
roles.map((role) => (
<SelectItem key={role.id}>{role.name}</SelectItem>
))
)}
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{isSelectorDisabled ? (
<SelectItem value={defaultRole}>{defaultRole}</SelectItem>
) : (
roles.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
{form.formState.errors.roleId && (
<p className="text-text-error mt-2 text-sm">
{form.formState.errors.roleId.message}
</p>
)}
</>
</div>
)}
/>
@@ -7,8 +7,9 @@ import * as z from "zod";
import { createProviderGroup } from "@/actions/manage-groups";
import { Button } from "@/components/shadcn";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
import { CustomDropdownSelection, CustomInput } from "@/components/ui/custom";
import { CustomInput } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import { ApiError } from "@/types";
@@ -39,6 +40,14 @@ export const AddGroupForm = ({
});
const isLoading = form.formState.isSubmitting;
const providerOptions = providers.map((provider) => ({
label: provider.name,
value: provider.id,
}));
const roleOptions = roles.map((role) => ({
label: role.name,
value: role.id,
}));
const onSubmitClient = async (values: FormValues) => {
try {
@@ -128,15 +137,19 @@ export const AddGroupForm = ({
name="providers"
control={form.control}
render={({ field }) => (
<CustomDropdownSelection
label="Select Providers"
name="providers"
values={providers}
selectedKeys={field.value || []}
onChange={(name, selectedValues) =>
field.onChange(selectedValues)
}
/>
<div className="flex flex-col gap-2">
<EnhancedMultiSelect
options={providerOptions}
onValueChange={field.onChange}
defaultValue={field.value || []}
placeholder="Select providers"
aria-label="Select providers"
searchable={true}
hideSelectAll={true}
emptyIndicator="No results found"
resetOnDefaultValueChange={true}
/>
</div>
)}
/>
{form.formState.errors.providers && (
@@ -155,15 +168,19 @@ export const AddGroupForm = ({
name="roles"
control={form.control}
render={({ field }) => (
<CustomDropdownSelection
label="Select Roles"
name="roles"
values={roles}
selectedKeys={field.value || []}
onChange={(name, selectedValues) =>
field.onChange(selectedValues)
}
/>
<div className="flex flex-col gap-2">
<EnhancedMultiSelect
options={roleOptions}
onValueChange={field.onChange}
defaultValue={field.value || []}
placeholder="Select roles"
aria-label="Select roles"
searchable={true}
hideSelectAll={true}
emptyIndicator="No results found"
resetOnDefaultValueChange={true}
/>
</div>
)}
/>
{form.formState.errors.roles && (
@@ -9,8 +9,9 @@ import * as z from "zod";
import { updateProviderGroup } from "@/actions/manage-groups/manage-groups";
import { Button } from "@/components/shadcn";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
import { CustomDropdownSelection, CustomInput } from "@/components/ui/custom";
import { CustomInput } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import { ApiError } from "@/types";
@@ -176,18 +177,29 @@ export const EditGroupForm = ({
];
return (
<CustomDropdownSelection
label="Select Providers"
name="providers"
values={combinedProviders}
selectedKeys={field.value?.map((p) => p.id) || []}
onChange={(name, selectedValues) => {
const selectedProviders = combinedProviders.filter(
(provider) => selectedValues.includes(provider.id),
);
field.onChange(selectedProviders);
}}
/>
<div className="flex flex-col gap-2">
<EnhancedMultiSelect
options={combinedProviders.map((provider) => ({
label: provider.name,
value: provider.id,
}))}
onValueChange={(selectedValues) => {
const selectedProviders = combinedProviders.filter(
(provider) => selectedValues.includes(provider.id),
);
field.onChange(selectedProviders);
}}
defaultValue={
field.value?.map((provider) => provider.id) || []
}
placeholder="Select providers"
aria-label="Select providers"
searchable={true}
hideSelectAll={true}
emptyIndicator="No results found"
resetOnDefaultValueChange={true}
/>
</div>
);
}}
/>
@@ -216,18 +228,27 @@ export const EditGroupForm = ({
];
return (
<CustomDropdownSelection
label="Select Roles"
name="roles"
values={combinedRoles}
selectedKeys={field.value?.map((r) => r.id) || []}
onChange={(name, selectedValues) => {
const selectedRoles = combinedRoles.filter((role) =>
selectedValues.includes(role.id),
);
field.onChange(selectedRoles);
}}
/>
<div className="flex flex-col gap-2">
<EnhancedMultiSelect
options={combinedRoles.map((role) => ({
label: role.name,
value: role.id,
}))}
onValueChange={(selectedValues) => {
const selectedRoles = combinedRoles.filter((role) =>
selectedValues.includes(role.id),
);
field.onChange(selectedRoles);
}}
defaultValue={field.value?.map((role) => role.id) || []}
placeholder="Select roles"
aria-label="Select roles"
searchable={true}
hideSelectAll={true}
emptyIndicator="No results found"
resetOnDefaultValueChange={true}
/>
</div>
);
}}
/>
@@ -1,23 +1,17 @@
"use client";
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
} from "@heroui/dropdown";
import {
DeleteDocumentBulkIcon,
EditDocumentBulkIcon,
} from "@heroui/shared-icons";
import { Row } from "@tanstack/react-table";
import clsx from "clsx";
import { Pencil, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import {
ActionDropdown,
ActionDropdownDangerZone,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Modal } from "@/components/shadcn/modal";
import { DeleteGroupForm } from "../forms";
@@ -25,7 +19,6 @@ import { DeleteGroupForm } from "../forms";
interface DataTableRowActionsProps<ProviderProps> {
row: Row<ProviderProps>;
}
const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0";
export function DataTableRowActions<ProviderProps>({
row,
@@ -47,51 +40,27 @@ export function DataTableRowActions<ProviderProps>({
</Modal>
<div className="relative flex items-center justify-end gap-2">
<Dropdown
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
placement="bottom"
>
<DropdownTrigger>
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-slate-400" />
</Button>
</DropdownTrigger>
<DropdownMenu
closeOnSelect
aria-label="Actions"
color="default"
variant="flat"
>
<DropdownSection title="Actions">
<DropdownItem
key="edit"
description="Allows you to edit the provider group"
textValue="Edit Provider Group"
startContent={<EditDocumentBulkIcon className={iconClasses} />}
onPress={() => router.push(`/manage-groups?groupId=${groupId}`)}
>
Edit Provider Group
</DropdownItem>
</DropdownSection>
<DropdownSection title="Danger zone">
<DropdownItem
key="delete"
className="text-text-error"
color="danger"
description="Delete the provider group permanently"
textValue="Delete Provider Group"
startContent={
<DeleteDocumentBulkIcon
className={clsx(iconClasses, "!text-text-error")}
/>
}
onPress={() => setIsDeleteOpen(true)}
>
Delete Provider Group
</DropdownItem>
</DropdownSection>
</DropdownMenu>
</Dropdown>
}
>
<ActionDropdownItem
icon={<Pencil />}
label="Edit Provider Group"
onSelect={() => router.push(`/manage-groups?groupId=${groupId}`)}
/>
<ActionDropdownDangerZone>
<ActionDropdownItem
icon={<Trash2 />}
label="Delete Provider Group"
destructive
onSelect={() => setIsDeleteOpen(true)}
/>
</ActionDropdownDangerZone>
</ActionDropdown>
</div>
</>
);
@@ -1,5 +1,6 @@
"use client";
import { SelectViaAlibabaCloud } from "@/components/providers/workflow/forms/select-credentials-type/alibabacloud";
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
import { SelectViaGCP } from "@/components/providers/workflow/forms/select-credentials-type/gcp";
import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github";
@@ -28,6 +29,9 @@ export const CredentialsUpdateInfo = ({
if (providerType === "m365") {
return <SelectViaM365 initialVia={initialVia} />;
}
if (providerType === "alibabacloud") {
return <SelectViaAlibabaCloud initialVia={initialVia} />;
}
return null;
};
@@ -1,307 +0,0 @@
"use client";
import { Input } from "@heroui/input";
import { Select, SelectItem } from "@heroui/select";
import { SharedSelection } from "@heroui/system";
import { CheckSquare, Search, Square } from "lucide-react";
import { useState } from "react";
import { Control, FieldValues, Path } from "react-hook-form";
import { Button } from "@/components/shadcn";
import { FormControl, FormField, FormMessage } from "@/components/ui/form";
import { ProviderProps, ProviderType } from "@/types/providers";
const providerTypeLabels: Record<ProviderType, string> = {
aws: "Amazon Web Services",
gcp: "Google Cloud Platform",
azure: "Microsoft Azure",
m365: "Microsoft 365",
kubernetes: "Kubernetes",
github: "GitHub",
iac: "Infrastructure as Code",
oraclecloud: "Oracle Cloud Infrastructure",
mongodbatlas: "MongoDB Atlas",
alibabacloud: "Alibaba Cloud",
};
interface EnhancedProviderSelectorProps<T extends FieldValues> {
control: Control<T>;
name: Path<T>;
providers: ProviderProps[];
label?: string;
placeholder?: string;
isInvalid?: boolean;
showFormMessage?: boolean;
selectionMode?: "single" | "multiple";
providerType?: ProviderType;
enableSearch?: boolean;
disabledProviderIds?: string[];
}
export const EnhancedProviderSelector = <T extends FieldValues>({
control,
name,
providers,
label = "Provider",
placeholder = "Select provider",
isInvalid = false,
showFormMessage = true,
selectionMode = "single",
providerType,
enableSearch = false,
disabledProviderIds = [],
}: EnhancedProviderSelectorProps<T>) => {
const [searchValue, setSearchValue] = useState("");
const filteredProviders = (() => {
let filtered = providers;
// Filter by provider type if specified
if (providerType) {
filtered = filtered.filter((p) => p.attributes.provider === providerType);
}
// Filter by search value
if (searchValue && enableSearch) {
const lowerSearch = searchValue.toLowerCase();
filtered = filtered.filter((p) => {
const displayName = p.attributes.alias || p.attributes.uid;
const typeLabel = providerTypeLabels[p.attributes.provider];
return (
displayName.toLowerCase().includes(lowerSearch) ||
typeLabel.toLowerCase().includes(lowerSearch)
);
});
}
// Sort providers
return filtered.sort((a, b) => {
const typeComparison = a.attributes.provider.localeCompare(
b.attributes.provider,
);
if (typeComparison !== 0) return typeComparison;
const nameA = a.attributes.alias || a.attributes.uid;
const nameB = b.attributes.alias || b.attributes.uid;
return nameA.localeCompare(nameB);
});
})();
return (
<FormField
control={control}
name={name}
render={({ field: { onChange, value, onBlur } }) => {
const isMultiple = selectionMode === "multiple";
const selectedIds: string[] = isMultiple
? (value as string[] | undefined) || []
: value
? [value as string]
: [];
const allProviderIds = filteredProviders
.filter((p) => !disabledProviderIds.includes(p.id))
.map((p) => p.id);
const isAllSelected =
isMultiple &&
allProviderIds.length > 0 &&
allProviderIds.every((id) => selectedIds.includes(id));
const handleSelectAll = () => {
if (isAllSelected) {
onChange([]);
} else {
onChange(allProviderIds);
}
};
const handleSelectionChange = (keys: SharedSelection) => {
if (keys === "all") {
onChange(allProviderIds);
return;
}
if (isMultiple) {
const selectedArray = Array.from(keys).map(String);
onChange(selectedArray);
} else {
const selectedValue = Array.from(keys)[0];
onChange(selectedValue ? String(selectedValue) : "");
}
};
return (
<>
<FormControl>
<div className="flex flex-col gap-2">
{isMultiple && filteredProviders.length > 1 && (
<div className="flex items-center justify-between">
<span className="text-text-neutral-primary text-sm font-medium">
{label}
</span>
<Button
size="sm"
variant="ghost"
onClick={handleSelectAll}
className="h-7 text-xs"
>
{isAllSelected ? (
<CheckSquare size={16} />
) : (
<Square size={16} />
)}
{isAllSelected ? "Deselect All" : "Select All"}
</Button>
</div>
)}
<Select
label={label}
placeholder={placeholder}
selectionMode={isMultiple ? "multiple" : "single"}
selectedKeys={
new Set(isMultiple ? value || [] : value ? [value] : [])
}
onSelectionChange={handleSelectionChange}
onBlur={onBlur}
variant="bordered"
labelPlacement="inside"
isRequired={false}
isInvalid={isInvalid}
classNames={{
trigger: "min-h-12",
popoverContent: "bg-bg-neutral-secondary",
listboxWrapper: "max-h-[300px] bg-bg-neutral-secondary",
listbox: "gap-0",
label:
"tracking-tight font-light !text-text-neutral-secondary text-xs z-0!",
value: "text-text-neutral-secondary text-small",
}}
renderValue={(items) => {
if (!isMultiple && value) {
const provider = providers.find((p) => p.id === value);
if (provider) {
const displayName =
provider.attributes.alias || provider.attributes.uid;
return (
<div className="flex items-center gap-2">
<span className="truncate">{displayName}</span>
</div>
);
}
}
if (items.length === 0) {
return (
<span className="text-default-500">{placeholder}</span>
);
}
if (isMultiple) {
if (items.length === 1) {
const provider = providers.find(
(p) => p.id === items[0].key,
);
if (provider) {
const displayName =
provider.attributes.alias ||
provider.attributes.uid;
return (
<div className="flex items-center gap-2">
<span className="truncate">{displayName}</span>
</div>
);
}
}
return (
<span className="text-small">
{items.length} provider{items.length !== 1 ? "s" : ""}{" "}
selected
</span>
);
}
return null;
}}
listboxProps={{
topContent: enableSearch ? (
<div className="sticky top-0 z-10 py-2">
<Input
isClearable
placeholder="Search providers..."
size="sm"
variant="bordered"
startContent={<Search size={16} />}
value={searchValue}
onValueChange={setSearchValue}
onClear={() => setSearchValue("")}
classNames={{
inputWrapper:
"border-border-input-primary bg-bg-input-primary hover:bg-bg-neutral-secondary",
input: "text-small",
clearButton: "text-default-400",
}}
/>
</div>
) : null,
}}
>
{filteredProviders.map((provider) => {
const providerType = provider.attributes.provider;
const displayName =
provider.attributes.alias || provider.attributes.uid;
const typeLabel = providerTypeLabels[providerType];
const isDisabled = disabledProviderIds.includes(
provider.id,
);
return (
<SelectItem
key={provider.id}
textValue={`${displayName} ${typeLabel}`}
className={`py-2 ${isDisabled ? "pointer-events-none cursor-not-allowed opacity-50" : ""}`}
>
<div className="flex w-full items-center justify-between">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="min-w-0 flex-1">
<div className="text-small truncate font-medium">
{displayName}
</div>
<div className="text-tiny text-text-neutral-secondary truncate">
{typeLabel}
{isDisabled && (
<span className="text-text-error ml-2">
(Already used)
</span>
)}
</div>
</div>
</div>
<div className="ml-2 flex shrink-0 items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${
provider.attributes.connection.connected
? "bg-bg-pass"
: "bg-bg-fail"
}`}
title={
provider.attributes.connection.connected
? "Connected"
: "Disconnected"
}
/>
</div>
</div>
</SelectItem>
);
})}
</Select>
</div>
</FormControl>
{showFormMessage && (
<FormMessage className="text-text-error max-w-full text-xs" />
)}
</>
);
}}
/>
);
};
@@ -1,25 +1,18 @@
"use client";
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
} from "@heroui/dropdown";
import {
AddNoteBulkIcon,
DeleteDocumentBulkIcon,
EditDocumentBulkIcon,
} from "@heroui/shared-icons";
import { Row } from "@tanstack/react-table";
import clsx from "clsx";
import { Pencil, PlugZap, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { checkConnectionProvider } from "@/actions/providers/providers";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import {
ActionDropdown,
ActionDropdownDangerZone,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Modal } from "@/components/shadcn/modal";
import { EditForm } from "../forms";
@@ -28,7 +21,6 @@ import { DeleteForm } from "../forms/delete-form";
interface DataTableRowActionsProps<ProviderProps> {
row: Row<ProviderProps>;
}
const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0";
export function DataTableRowActions<ProviderProps>({
row,
@@ -53,12 +45,6 @@ export function DataTableRowActions<ProviderProps>({
const hasSecret = (row.original as any).relationships?.secret?.data;
// Calculate disabled keys based on conditions
const disabledKeys = [];
if (!hasSecret || loading) {
disabledKeys.push("new");
}
return (
<>
<Modal
@@ -82,88 +68,52 @@ export function DataTableRowActions<ProviderProps>({
</Modal>
<div className="relative flex items-center justify-end gap-2">
<Dropdown
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
placement="bottom"
>
<DropdownTrigger>
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-slate-400" />
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="Actions"
color="default"
variant="flat"
disabledKeys={disabledKeys}
closeOnSelect={false}
>
<DropdownSection title="Actions">
<DropdownItem
key={hasSecret ? "update" : "add"}
description={
hasSecret
? "Update the provider credentials"
: "Add the provider credentials"
}
textValue={hasSecret ? "Update Credentials" : "Add Credentials"}
startContent={<EditDocumentBulkIcon className={iconClasses} />}
onPress={() =>
router.push(
`/providers/${hasSecret ? "update" : "add"}-credentials?type=${providerType}&id=${providerId}${providerSecretId ? `&secretId=${providerSecretId}` : ""}`,
)
}
closeOnSelect={true}
>
{hasSecret ? "Update Credentials" : "Add Credentials"}
</DropdownItem>
<DropdownItem
key="new"
description={
hasSecret && !loading
? "Check the provider connection"
: loading
? "Checking provider connection"
: "Add credentials to test the connection"
}
textValue="Check Connection"
startContent={<AddNoteBulkIcon className={iconClasses} />}
onPress={handleTestConnection}
closeOnSelect={false}
>
{loading ? "Testing..." : "Test Connection"}
</DropdownItem>
<DropdownItem
key="edit"
description="Allows you to edit the provider"
textValue="Edit Provider"
startContent={<EditDocumentBulkIcon className={iconClasses} />}
onPress={() => setIsEditOpen(true)}
closeOnSelect={true}
>
Edit Provider Alias
</DropdownItem>
</DropdownSection>
<DropdownSection title="Danger zone">
<DropdownItem
key="delete"
className="text-text-error"
color="danger"
description="Delete the provider permanently"
textValue="Delete Provider"
startContent={
<DeleteDocumentBulkIcon
className={clsx(iconClasses, "!text-text-error")}
/>
}
onPress={() => setIsDeleteOpen(true)}
closeOnSelect={true}
>
Delete Provider
</DropdownItem>
</DropdownSection>
</DropdownMenu>
</Dropdown>
}
>
<ActionDropdownItem
icon={<Pencil />}
label={hasSecret ? "Update Credentials" : "Add Credentials"}
onSelect={() =>
router.push(
`/providers/${hasSecret ? "update" : "add"}-credentials?type=${providerType}&id=${providerId}${providerSecretId ? `&secretId=${providerSecretId}` : ""}`,
)
}
/>
<ActionDropdownItem
icon={<PlugZap />}
label={loading ? "Testing..." : "Test Connection"}
description={
hasSecret && !loading
? "Check the provider connection"
: loading
? "Checking provider connection"
: "Add credentials to test the connection"
}
onSelect={(e) => {
e.preventDefault();
handleTestConnection();
}}
disabled={!hasSecret || loading}
/>
<ActionDropdownItem
icon={<Pencil />}
label="Edit Provider Alias"
onSelect={() => setIsEditOpen(true)}
/>
<ActionDropdownDangerZone>
<ActionDropdownItem
icon={<Trash2 />}
label="Delete Provider"
destructive
onSelect={() => setIsDeleteOpen(true)}
/>
</ActionDropdownDangerZone>
</ActionDropdown>
</div>
</>
);
@@ -1,11 +1,17 @@
import { Chip } from "@heroui/chip";
import { Divider } from "@heroui/divider";
import { Select, SelectItem } from "@heroui/select";
import { Switch } from "@heroui/switch";
import { useEffect, useState } from "react";
import { Control, UseFormSetValue, useWatch } from "react-hook-form";
import { CredentialsRoleHelper } from "@/components/providers/workflow";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { CustomInput } from "@/components/ui/custom";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { AWSCredentialsRole } from "@/types";
@@ -77,47 +83,47 @@ export const AWSRoleCredentialsForm = ({
Specify which AWS credentials to use
</span>
<Select
name={ProviderCredentialFields.CREDENTIALS_TYPE}
label="Authentication Method"
placeholder="Select credentials type"
selectedKeys={[credentialsType || defaultCredentialsType]}
className="mb-4"
variant="bordered"
onSelectionChange={(keys) =>
setValue(
ProviderCredentialFields.CREDENTIALS_TYPE,
Array.from(keys)[0] as "aws-sdk-default" | "access-secret-key",
)
}
>
<SelectItem
key="aws-sdk-default"
textValue={
isCloudEnv
? "Prowler Cloud will assume your IAM role"
: "AWS SDK Default"
}
<div className="mb-4 flex flex-col gap-1.5">
<Select
value={credentialsType || defaultCredentialsType}
onValueChange={(value) => {
setValue(
ProviderCredentialFields.CREDENTIALS_TYPE,
value as "aws-sdk-default" | "access-secret-key",
);
}}
>
<div className="flex w-full items-center justify-between">
<span>
{isCloudEnv
? "Prowler Cloud will assume your IAM role"
: "AWS SDK Default"}
</span>
{isCloudEnv && (
<Chip size="sm" variant="flat" color="success" className="ml-2">
Recommended
</Chip>
)}
</div>
</SelectItem>
<SelectItem key="access-secret-key" textValue="Access & Secret Key">
<div className="flex w-full items-center justify-between">
<span>Access & Secret Key</span>
</div>
</SelectItem>
</Select>
<SelectTrigger>
<SelectValue placeholder="Select credentials type" />
</SelectTrigger>
<SelectContent className="z-[60]">
<SelectItem value="aws-sdk-default">
<div className="flex w-full items-center justify-between">
<span>
{isCloudEnv
? "Prowler Cloud will assume your IAM role"
: "AWS SDK Default"}
</span>
{isCloudEnv && (
<Chip
size="sm"
variant="flat"
color="success"
className="ml-2"
>
Recommended
</Chip>
)}
</div>
</SelectItem>
<SelectItem value="access-secret-key">
<div className="flex w-full items-center justify-between">
<span>Access & Secret Key</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{credentialsType === "access-secret-key" && (
<>
@@ -83,7 +83,6 @@ const FailedFindingsBadge = ({ count }: { count: number }) => {
// Row actions dropdown
const ResourceRowActions = ({ row }: { row: { original: ResourceProps } }) => {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const resourceName = row.original.attributes?.name || "Resource";
return (
<>
@@ -102,8 +101,7 @@ const ResourceRowActions = ({ row }: { row: { original: ResourceProps } }) => {
>
<ActionDropdownItem
icon={<Eye className="size-5" />}
label="View details"
description={`View details for ${resourceName}`}
label="View Details"
onSelect={() => setIsDrawerOpen(true)}
/>
</ActionDropdown>
@@ -1,30 +1,23 @@
"use client";
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
} from "@heroui/dropdown";
import {
DeleteDocumentBulkIcon,
EditDocumentBulkIcon,
} from "@heroui/shared-icons";
import { Row } from "@tanstack/react-table";
import clsx from "clsx";
import { Pencil, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import {
ActionDropdown,
ActionDropdownDangerZone,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Modal } from "@/components/shadcn/modal";
import { DeleteRoleForm } from "../workflow/forms";
interface DataTableRowActionsProps<RoleProps> {
row: Row<RoleProps>;
}
const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0";
export function DataTableRowActions<RoleProps>({
row,
@@ -43,51 +36,27 @@ export function DataTableRowActions<RoleProps>({
<DeleteRoleForm roleId={roleId} setIsOpen={setIsDeleteOpen} />
</Modal>
<div className="relative flex items-center justify-end gap-2">
<Dropdown
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
placement="bottom"
>
<DropdownTrigger>
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-slate-400" />
</Button>
</DropdownTrigger>
<DropdownMenu
closeOnSelect
aria-label="Actions"
color="default"
variant="flat"
>
<DropdownSection title="Actions">
<DropdownItem
key="edit"
description="Edit the role details"
textValue="Edit Role"
startContent={<EditDocumentBulkIcon className={iconClasses} />}
onPress={() => router.push(`/roles/edit?roleId=${roleId}`)}
>
Edit Role
</DropdownItem>
</DropdownSection>
<DropdownSection title="Danger zone">
<DropdownItem
key="delete"
className="text-text-error"
color="danger"
description="Delete the role permanently"
textValue="Delete Role"
startContent={
<DeleteDocumentBulkIcon
className={clsx(iconClasses, "!text-text-error")}
/>
}
onPress={() => setIsDeleteOpen(true)}
>
Delete Role
</DropdownItem>
</DropdownSection>
</DropdownMenu>
</Dropdown>
}
>
<ActionDropdownItem
icon={<Pencil />}
label="Edit Role"
onSelect={() => router.push(`/roles/edit?roleId=${roleId}`)}
/>
<ActionDropdownDangerZone>
<ActionDropdownItem
icon={<Trash2 />}
label="Delete Role"
destructive
onSelect={() => setIsDeleteOpen(true)}
/>
</ActionDropdownDangerZone>
</ActionDropdown>
</div>
</>
);
@@ -12,8 +12,9 @@ import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { addRole } from "@/actions/roles/roles";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
import { CustomDropdownSelection, CustomInput } from "@/components/ui/custom";
import { CustomInput } from "@/components/ui/custom";
import { Form, FormButtons } from "@/components/ui/form";
import { getErrorMessage, permissionFormFields } from "@/lib";
import { addRoleFormSchema, ApiError } from "@/types";
@@ -232,15 +233,21 @@ export const AddRoleForm = ({
name="groups"
control={form.control}
render={({ field }) => (
<CustomDropdownSelection
label="Select Groups"
name="groups"
values={groups}
selectedKeys={field.value || []}
onChange={(name, selectedValues) =>
field.onChange(selectedValues)
}
/>
<div className="flex flex-col gap-2">
<EnhancedMultiSelect
options={groups.map((group) => ({
label: group.name,
value: group.id,
}))}
onValueChange={field.onChange}
defaultValue={field.value || []}
placeholder="Select groups"
searchable={true}
hideSelectAll={true}
emptyIndicator="No results found"
resetOnDefaultValueChange={true}
/>
</div>
)}
/>
{form.formState.errors.groups && (
@@ -12,8 +12,9 @@ import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { updateRole } from "@/actions/roles/roles";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
import { CustomDropdownSelection, CustomInput } from "@/components/ui/custom";
import { CustomInput } from "@/components/ui/custom";
import { Form, FormButtons } from "@/components/ui/form";
import { getErrorMessage, permissionFormFields } from "@/lib";
import { ApiError, editRoleFormSchema } from "@/types";
@@ -250,15 +251,21 @@ export const EditRoleForm = ({
name="groups"
control={form.control}
render={({ field }) => (
<CustomDropdownSelection
label="Select Groups"
name="groups"
values={groups}
selectedKeys={field.value}
onChange={(name, selectedValues) => {
field.onChange(selectedValues);
}}
/>
<div className="flex flex-col gap-2">
<EnhancedMultiSelect
options={groups.map((group) => ({
label: group.name,
value: group.id,
}))}
onValueChange={field.onChange}
defaultValue={field.value || []}
placeholder="Select groups"
searchable={true}
hideSelectAll={true}
emptyIndicator="No results found"
resetOnDefaultValueChange={true}
/>
</div>
)}
/>
@@ -13,6 +13,7 @@ import { Form } from "@/components/ui/form";
import { toast } from "@/components/ui/toast";
import { onDemandScanFormSchema } from "@/types";
import { SCAN_LAUNCHED_EVENT } from "../table/scans/scans-table-with-polling";
import { SelectScanProvider } from "./select-scan-provider";
type ProviderInfo = {
@@ -85,6 +86,8 @@ export const LaunchScanWorkflow = ({
});
// Reset form after successful submission
form.reset();
// Notify the scans table to refresh and pick up the new scan
window.dispatchEvent(new Event(SCAN_LAUNCHED_EVENT));
}
};
@@ -1,22 +1,15 @@
"use client";
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
} from "@heroui/dropdown";
import {
// DeleteDocumentBulkIcon,
EditDocumentBulkIcon,
} from "@heroui/shared-icons";
import { Row } from "@tanstack/react-table";
import { DownloadIcon } from "lucide-react";
import { Download, Pencil } from "lucide-react";
import { useState } from "react";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { downloadScanZip } from "@/lib/helper";
@@ -26,7 +19,6 @@ import { EditScanForm } from "../../forms";
interface DataTableRowActionsProps<ScanProps> {
row: Row<ScanProps>;
}
const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0";
export function DataTableRowActions<ScanProps>({
row,
@@ -52,46 +44,26 @@ export function DataTableRowActions<ScanProps>({
</Modal>
<div className="relative flex items-center justify-end gap-2">
<Dropdown
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
placement="bottom"
>
<DropdownTrigger>
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-slate-400" />
</Button>
</DropdownTrigger>
<DropdownMenu
closeOnSelect
aria-label="Actions"
color="default"
variant="flat"
>
<DropdownSection title="Download reports">
<DropdownItem
key="export"
description="Available only for completed scans"
textValue="Download .zip"
startContent={<DownloadIcon className={iconClasses} />}
onPress={() => downloadScanZip(scanId, toast)}
isDisabled={scanState !== "completed"}
>
Download .zip
</DropdownItem>
</DropdownSection>
<DropdownSection title="Actions">
<DropdownItem
key="edit"
description="Allows you to edit the scan name"
textValue="Edit Scan Name"
startContent={<EditDocumentBulkIcon className={iconClasses} />}
onPress={() => setIsEditOpen(true)}
>
Edit scan name
</DropdownItem>
</DropdownSection>
</DropdownMenu>
</Dropdown>
}
>
<ActionDropdownItem
icon={<Download />}
label="Download .zip"
description="Available only for completed scans"
onSelect={() => downloadScanZip(scanId, toast)}
disabled={scanState !== "completed"}
/>
<ActionDropdownItem
icon={<Pencil />}
label="Edit Scan Name"
onSelect={() => setIsEditOpen(true)}
/>
</ActionDropdown>
</div>
</>
);
+1
View File
@@ -1,3 +1,4 @@
export * from "./column-get-scans";
export * from "./data-table-row-actions";
export * from "./data-table-row-details";
export * from "./scans-table-with-polling";
@@ -0,0 +1,134 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { getScans } from "@/actions/scans";
import { AutoRefresh } from "@/components/scans";
import { DataTable } from "@/components/ui/table";
import { MetaDataProps, ScanProps, SearchParamsProps } from "@/types";
import { ColumnGetScans } from "./column-get-scans";
export const SCAN_LAUNCHED_EVENT = "scan-launched";
interface ScansTableWithPollingProps {
initialData: ScanProps[];
initialMeta?: MetaDataProps;
searchParams: SearchParamsProps;
}
const EXECUTING_STATES = ["executing", "available"] as const;
function expandScansWithProviderInfo(
scans: ScanProps[],
included?: Array<{ type: string; id: string; attributes: any }>,
) {
return (
scans?.map((scan) => {
const providerId = scan.relationships?.provider?.data?.id;
if (!providerId) {
return { ...scan, providerInfo: undefined };
}
const providerData = included?.find(
(item) => item.type === "providers" && item.id === providerId,
);
if (!providerData) {
return { ...scan, providerInfo: undefined };
}
return {
...scan,
providerInfo: {
provider: providerData.attributes.provider,
uid: providerData.attributes.uid,
alias: providerData.attributes.alias,
},
};
}) || []
);
}
export function ScansTableWithPolling({
initialData,
initialMeta,
searchParams,
}: ScansTableWithPollingProps) {
const [scansData, setScansData] = useState<ScanProps[]>(initialData);
const [meta, setMeta] = useState<MetaDataProps | undefined>(initialMeta);
// Sync state with server data when props change (e.g., pagination or filter changes).
// useState only uses its argument on first mount, so without this effect,
// navigating to page 2 would change the URL but keep showing page 1 data.
useEffect(() => {
setScansData(initialData);
setMeta(initialMeta);
}, [initialData, initialMeta]);
const hasExecutingScan = scansData.some((scan) =>
EXECUTING_STATES.includes(
scan.attributes.state as (typeof EXECUTING_STATES)[number],
),
);
const handleRefresh = useCallback(async () => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
const sort = searchParams.sort?.toString();
const filters = Object.fromEntries(
Object.entries(searchParams).filter(
([key]) => key.startsWith("filter[") && key !== "scanId",
),
);
const query = (filters["filter[search]"] as string) || "";
const result = await getScans({
query,
page,
sort,
filters,
pageSize,
include: "provider",
});
if (result?.data) {
const expanded = expandScansWithProviderInfo(
result.data,
result.included,
);
setScansData(expanded);
if (result && "meta" in result) {
setMeta(result.meta as MetaDataProps);
}
}
}, [searchParams]);
// Listen for scan launch events to trigger an immediate refresh
useEffect(() => {
const handler = () => {
handleRefresh();
};
window.addEventListener(SCAN_LAUNCHED_EVENT, handler);
return () => window.removeEventListener(SCAN_LAUNCHED_EVENT, handler);
}, [handleRefresh]);
return (
<>
<AutoRefresh
hasExecutingScan={hasExecutingScan}
onRefresh={handleRefresh}
/>
<DataTable
key={`scans-${scansData.length}-${meta?.pagination?.page}`}
columns={ColumnGetScans}
data={scansData}
metadata={meta}
/>
</>
);
}
+1
View File
@@ -17,6 +17,7 @@ const badgeVariants = cva(
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
tag: "bg-bg-tag border-border-tag text-text-neutral-primary",
},
},
defaultVariants: {
+1 -1
View File
@@ -66,7 +66,7 @@ function CommandInput({
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
className="border-border-neutral-primary flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
+1 -1
View File
@@ -6,7 +6,7 @@ import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
function Drawer({ ...props }: ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
return <DrawerPrimitive.Root data-slot="drawer" handleOnly {...props} />;
}
function DrawerTrigger({
@@ -9,7 +9,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./dropdown";
@@ -17,8 +16,6 @@ import {
interface ActionDropdownProps {
/** The dropdown trigger element. Defaults to a vertical dots icon button */
trigger?: ReactNode;
/** Label shown at the top of the dropdown */
label?: string;
/** Alignment of the dropdown content */
align?: "start" | "center" | "end";
/** Additional className for the content */
@@ -30,7 +27,6 @@ interface ActionDropdownProps {
export function ActionDropdown({
trigger,
label = "Actions",
align = "end",
className,
ariaLabel = "Open actions menu",
@@ -52,16 +48,10 @@ export function ActionDropdown({
<DropdownMenuContent
align={align}
className={cn(
"border-border-neutral-secondary bg-bg-neutral-secondary w-56",
"border-border-neutral-secondary bg-bg-neutral-secondary w-56 rounded-xl",
className,
)}
>
{label && (
<>
<DropdownMenuLabel>{label}</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
{children}
</DropdownMenuContent>
</DropdownMenu>
@@ -91,8 +81,9 @@ export function ActionDropdownItem({
return (
<DropdownMenuItem
className={cn(
"flex cursor-pointer items-center gap-2",
destructive && "text-destructive focus:text-destructive",
"hover:bg-bg-neutral-tertiary flex cursor-pointer items-start gap-2 rounded-md transition-colors",
destructive &&
"text-text-error-primary focus:text-text-error-primary hover:bg-destructive/10",
className,
)}
{...props}
@@ -100,8 +91,8 @@ export function ActionDropdownItem({
{icon && (
<span
className={cn(
"text-muted-foreground shrink-0 [&>svg]:size-5",
destructive && "text-destructive",
"text-muted-foreground mt-0.5 shrink-0 [&>svg]:size-4",
destructive && "text-text-error-primary",
)}
>
{icon}
@@ -113,7 +104,7 @@ export function ActionDropdownItem({
<span
className={cn(
"text-muted-foreground text-xs",
destructive && "text-destructive/70",
destructive && "text-text-error-primary/70",
)}
>
{description}
@@ -124,8 +115,18 @@ export function ActionDropdownItem({
);
}
// Re-export commonly used components for convenience
export {
DropdownMenuLabel as ActionDropdownLabel,
DropdownMenuSeparator as ActionDropdownSeparator,
} from "./dropdown";
export function ActionDropdownDangerZone({
children,
}: {
children: ReactNode;
}) {
return (
<>
<DropdownMenuSeparator />
<span className="text-text-neutral-tertiary px-2 py-1.5 text-xs">
Danger zone
</span>
{children}
</>
);
}
+1 -2
View File
@@ -1,8 +1,7 @@
export {
ActionDropdown,
ActionDropdownDangerZone,
ActionDropdownItem,
ActionDropdownLabel,
ActionDropdownSeparator,
} from "./action-dropdown";
export {
DropdownMenu,
+6 -3
View File
@@ -20,16 +20,19 @@ function PopoverContent({
className,
align = "center",
sideOffset = 4,
container,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
container?: HTMLElement | null;
}) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Portal container={container ?? undefined}>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 pointer-events-auto z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
@@ -0,0 +1,404 @@
"use client";
import { ChevronDown, XCircle, XIcon } from "lucide-react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/shadcn/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/shadcn/popover";
import { Separator } from "@/components/shadcn/separator/separator";
import { cn } from "@/lib/utils";
interface MultiSelectOption {
label: string;
value: string;
icon?: ReactNode;
description?: string;
disabled?: boolean;
}
interface EnhancedMultiSelectProps {
options: MultiSelectOption[];
onValueChange: (values: string[]) => void;
defaultValue?: string[];
placeholder?: string;
searchable?: boolean;
hideSelectAll?: boolean;
maxCount?: number;
closeOnSelect?: boolean;
resetOnDefaultValueChange?: boolean;
emptyIndicator?: ReactNode;
disabled?: boolean;
className?: string;
id?: string;
"aria-label"?: string;
}
function arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((val, index) => val === sortedB[index]);
}
export function EnhancedMultiSelect({
options,
onValueChange,
defaultValue = [],
placeholder = "Select options",
searchable = true,
hideSelectAll = false,
maxCount = 3,
closeOnSelect = false,
resetOnDefaultValueChange = true,
emptyIndicator,
disabled = false,
className,
id,
"aria-label": ariaLabel,
}: EnhancedMultiSelectProps) {
const [selectedValues, setSelectedValues] = useState<string[]>(defaultValue);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(
null,
);
const buttonRef = useRef<HTMLButtonElement>(null);
const prevDefaultValueRef = useRef<string[]>(defaultValue);
const selectedAtOpenRef = useRef<string[]>(selectedValues);
const multiSelectId = useId();
const listboxId = `${multiSelectId}-listbox`;
// Detect dialog container for portal stacking (critical for Jira modal)
useEffect(() => {
if (!buttonRef.current) return;
const closestDialogContainer = buttonRef.current.closest(
"[data-slot='dialog-content'], [data-slot='modal-content'], [role='dialog']",
);
setPortalContainer(
closestDialogContainer instanceof HTMLElement
? closestDialogContainer
: null,
);
}, []);
// Reset when defaultValue changes externally (e.g. React Hook Form reset)
useEffect(() => {
if (!resetOnDefaultValueChange) return;
const prev = prevDefaultValueRef.current;
if (!arraysEqual(prev, defaultValue)) {
if (!arraysEqual(selectedValues, defaultValue)) {
setSelectedValues(defaultValue);
}
prevDefaultValueRef.current = [...defaultValue];
}
}, [defaultValue, selectedValues, resetOnDefaultValueChange]);
function handleOpenChange(nextOpen: boolean) {
if (nextOpen) {
selectedAtOpenRef.current = [...selectedValues];
} else {
setSearch("");
}
setOpen(nextOpen);
}
const enabledOptions = options.filter((o) => !o.disabled);
const filteredOptions = (
searchable && search
? options.filter(
(o) =>
o.label.toLowerCase().includes(search.toLowerCase()) ||
o.value.toLowerCase().includes(search.toLowerCase()),
)
: options
).toSorted((a, b) => {
const snapshot = selectedAtOpenRef.current;
const aSelected = snapshot.includes(a.value) ? 0 : 1;
const bSelected = snapshot.includes(b.value) ? 0 : 1;
return aSelected - bSelected;
});
function getOptionByValue(value: string) {
return options.find((o) => o.value === value);
}
function toggleOption(value: string) {
if (disabled) return;
const option = getOptionByValue(value);
if (option?.disabled) return;
const next = selectedValues.includes(value)
? selectedValues.filter((v) => v !== value)
: [...selectedValues, value];
setSelectedValues(next);
onValueChange(next);
if (closeOnSelect) setOpen(false);
}
function toggleAll() {
if (disabled) return;
if (selectedValues.length === enabledOptions.length) {
handleClear();
} else {
const all = enabledOptions.map((o) => o.value);
setSelectedValues(all);
onValueChange(all);
}
if (closeOnSelect) setOpen(false);
}
function handleClear() {
if (disabled) return;
setSelectedValues([]);
onValueChange([]);
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
id={id}
ref={buttonRef}
variant="outline"
onClick={() => !disabled && setOpen((prev) => !prev)}
disabled={disabled}
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
aria-controls={open ? listboxId : undefined}
aria-label={ariaLabel}
className={cn(
"border-border-input-primary bg-bg-input-primary text-text-neutral-primary data-[placeholder]:text-text-neutral-tertiary [&_svg:not([class*='text-'])]:text-text-neutral-tertiary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-input-primary active:bg-bg-input-primary focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex h-auto min-h-12 w-full items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 [&_svg]:pointer-events-auto",
disabled && "cursor-not-allowed opacity-50",
className,
)}
>
{selectedValues.length > 0 ? (
<div className="flex w-full items-center justify-between">
<div className="flex flex-wrap items-center gap-1">
{selectedValues
.slice(0, maxCount)
.map((value) => {
const option = getOptionByValue(value);
if (!option) return null;
return (
<Badge
key={value}
variant="tag"
className="m-1 cursor-default [&>svg]:pointer-events-auto"
>
<span className="cursor-default">{option.label}</span>
<span
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
toggleOption(value);
}}
aria-label={`Remove ${option.label} from selection`}
className="focus:ring-border-input-primary-press -m-0.5 ml-2 inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center self-center rounded-sm p-0.5 focus:ring-1 focus:outline-none"
>
<XCircle className="h-3 w-3" />
</span>
</Badge>
);
})
.filter(Boolean)}
{selectedValues.length > maxCount && (
<Badge
variant="tag"
className="m-1 cursor-default [&>svg]:pointer-events-auto"
>
{`+ ${selectedValues.length - maxCount} more`}
<span
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
const trimmed = selectedValues.slice(0, maxCount);
setSelectedValues(trimmed);
onValueChange(trimmed);
}}
className="ml-2 inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center self-center rounded-sm"
aria-label="Clear extra selected options"
>
<XCircle className="h-3 w-3" />
</span>
</Badge>
)}
</div>
<div className="flex items-center justify-between">
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
handleClear();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
handleClear();
}
}}
aria-label={`Clear all ${selectedValues.length} selected options`}
className="text-text-neutral-tertiary hover:text-text-neutral-primary focus:ring-border-input-primary-press mx-2 flex h-4 w-4 cursor-pointer items-center justify-center rounded-sm focus:ring-2 focus:ring-offset-1 focus:outline-none"
>
<XIcon className="h-4 w-4" />
</div>
<Separator
orientation="vertical"
className="flex h-full min-h-6"
/>
<ChevronDown
className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer"
aria-hidden="true"
/>
</div>
</div>
) : (
<div className="mx-auto flex w-full items-center justify-between">
<span className="text-text-neutral-tertiary mx-3 text-sm">
{placeholder}
</span>
<ChevronDown className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer" />
</div>
)}
</Button>
</PopoverTrigger>
<PopoverContent
container={portalContainer}
id={listboxId}
role="listbox"
aria-multiselectable="true"
aria-label="Available options"
className="border-border-input-primary bg-bg-input-primary text-text-neutral-primary pointer-events-auto z-50 w-[var(--radix-popover-trigger-width)] max-w-[var(--radix-popover-trigger-width)] touch-manipulation rounded-lg p-0"
align="start"
onEscapeKeyDown={() => setOpen(false)}
>
<Command>
{searchable && (
<CommandInput
placeholder="Search options..."
value={search}
onValueChange={setSearch}
aria-label="Search through available options"
/>
)}
<CommandList className="minimal-scrollbar multiselect-scrollbar max-h-[40vh] overflow-x-hidden overflow-y-auto overscroll-y-contain">
<CommandEmpty>{emptyIndicator || "No results found."}</CommandEmpty>
{!hideSelectAll && !search && (
<CommandGroup>
<CommandItem
key="all"
onSelect={toggleAll}
role="option"
aria-selected={
selectedValues.length === enabledOptions.length
}
className="cursor-pointer"
>
<Checkbox
checked={selectedValues.length === enabledOptions.length}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none mr-2 size-4"
/>
<span>Select All</span>
</CommandItem>
</CommandGroup>
)}
<CommandGroup>
{filteredOptions.map((option) => {
const isSelected = selectedValues.includes(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => toggleOption(option.value)}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
className={cn(
"cursor-pointer",
option.disabled && "cursor-not-allowed opacity-50",
)}
disabled={option.disabled}
>
<Checkbox
checked={isSelected}
disabled={option.disabled}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none mr-2 size-4"
/>
{option.icon && (
<span className="shrink-0">{option.icon}</span>
)}
<div className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
{option.description && (
<span className="text-text-neutral-tertiary text-xs">
{option.description}
</span>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
<Separator />
<div className="flex items-center justify-between p-1">
{selectedValues.length > 0 && (
<>
<Button
variant="ghost"
size="sm"
onClick={handleClear}
className="flex-1"
>
Clear
</Button>
<Separator
orientation="vertical"
className="flex h-full min-h-6"
/>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setOpen(false)}
className="flex-1"
>
Close
</Button>
</div>
</Command>
</PopoverContent>
</Popover>
);
}
EnhancedMultiSelect.displayName = "EnhancedMultiSelect";
export type { EnhancedMultiSelectProps, MultiSelectOption };
+5 -5
View File
@@ -224,9 +224,9 @@ export function MultiSelectValue({
.filter((value) => items.has(value))
.map((value) => (
<Badge
variant="outline"
variant="tag"
data-selected-item
className="text-bg-button-secondary group flex items-center gap-1.5 border-slate-300 bg-slate-100 px-2 py-1 text-xs font-medium dark:border-slate-600 dark:bg-slate-800"
className="group flex items-center gap-1.5 px-2 py-1 text-xs font-medium"
key={value}
onClick={
clickToRemove
@@ -239,7 +239,7 @@ export function MultiSelectValue({
>
{items.get(value)}
{clickToRemove && (
<XIcon className="text-bg-button-secondary group-hover:text-destructive size-3 transition-colors" />
<XIcon className="text-text-neutral-primary group-hover:text-destructive size-3 transition-colors" />
)}
</Badge>
))}
@@ -247,9 +247,9 @@ export function MultiSelectValue({
style={{
display: overflowAmount > 0 && !shouldWrap ? "block" : "none",
}}
variant="outline"
variant="tag"
ref={overflowRef}
className="text-bg-button-secondary border-slate-300 bg-slate-100 px-2 py-1 text-xs font-medium dark:border-slate-600 dark:bg-slate-800"
className="px-2 py-1 text-xs font-medium"
>
+{overflowAmount}
</Badge>
@@ -1,53 +0,0 @@
"use client";
import React, { useCallback } from "react";
import {
MultiSelect,
MultiSelectContent,
MultiSelectItem,
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
interface CustomDropdownSelectionProps {
label: string;
name: string;
values: { id: string; name: string }[];
onChange: (name: string, selectedValues: string[]) => void;
selectedKeys?: string[];
}
export const CustomDropdownSelection: React.FC<
CustomDropdownSelectionProps
> = ({ label, name, values, onChange, selectedKeys = [] }) => {
const handleValuesChange = useCallback(
(newValues: string[]) => {
onChange(name, newValues);
},
[name, onChange],
);
return (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">{label}</p>
<MultiSelect values={selectedKeys} onValuesChange={handleValuesChange}>
<MultiSelectTrigger>
<MultiSelectValue placeholder={`Select ${label.toLowerCase()}`} />
</MultiSelectTrigger>
<MultiSelectContent
search={{
placeholder: `Search ${label.toLowerCase()}...`,
emptyMessage: "No results found",
}}
>
{values.map((item) => (
<MultiSelectItem key={item.id} value={item.id}>
{item.name}
</MultiSelectItem>
))}
</MultiSelectContent>
</MultiSelect>
</div>
);
};
@@ -21,7 +21,9 @@ export const TableLink = ({ href, label, isDisabled }: TableLinkProps) => {
return (
<Button asChild variant="link" size="sm" className="text-xs">
<Link href={href}>{label}</Link>
<Link href={href} prefetch={false}>
{label}
</Link>
</Button>
);
};
-1
View File
@@ -1,5 +1,4 @@
export * from "./custom-banner";
export * from "./custom-dropdown-selection";
export * from "./custom-input";
export * from "./custom-link";
export * from "./custom-modal-buttons";
+19 -20
View File
@@ -1,6 +1,5 @@
"use client";
import { Select, SelectItem } from "@heroui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import { ShieldIcon, UserIcon } from "lucide-react";
import { Dispatch, SetStateAction } from "react";
@@ -9,6 +8,13 @@ import * as z from "zod";
import { updateUser, updateUserRole } from "@/actions/users/users";
import { Card } from "@/components/shadcn";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { Form, FormButtons } from "@/components/ui/form";
@@ -168,29 +174,22 @@ export const EditForm = ({
/>
</div>
<div>
<div className="flex flex-col gap-1.5">
<Controller
name="role"
control={form.control}
render={({ field }) => (
<Select
{...field}
label="Role"
labelPlacement="outside"
placeholder="Select a role"
classNames={{
selectorIcon: "right-2",
}}
variant="bordered"
selectedKeys={[field.value || ""]}
onSelectionChange={(selected) => {
const selectedKey = Array.from(selected).pop();
field.onChange(selectedKey || "");
}}
>
{roles.map((role: { id: string; name: string }) => (
<SelectItem key={role.id}>{role.name}</SelectItem>
))}
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((role: { id: string; name: string }) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
@@ -1,21 +1,15 @@
"use client";
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
} from "@heroui/dropdown";
import {
DeleteDocumentBulkIcon,
EditDocumentBulkIcon,
} from "@heroui/shared-icons";
import { Row } from "@tanstack/react-table";
import clsx from "clsx";
import { Pencil, Trash2 } from "lucide-react";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import {
ActionDropdown,
ActionDropdownDangerZone,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { EnrichedApiKey } from "./types";
@@ -25,8 +19,6 @@ interface DataTableRowActionsProps {
onRevoke: (apiKey: EnrichedApiKey) => void;
}
const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0";
export function DataTableRowActions({
row,
onEdit,
@@ -39,53 +31,29 @@ export function DataTableRowActions({
return (
<div className="relative flex items-center justify-end gap-2">
<Dropdown
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
placement="bottom"
>
<DropdownTrigger>
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-slate-400" />
</Button>
</DropdownTrigger>
<DropdownMenu
closeOnSelect
aria-label="API Key actions"
color="default"
variant="flat"
>
<DropdownSection title="Actions">
<DropdownItem
key="edit"
description="Edit the API key name"
textValue="Edit name"
startContent={<EditDocumentBulkIcon className={iconClasses} />}
onPress={() => onEdit(apiKey)}
>
Edit name
</DropdownItem>
</DropdownSection>
{canRevoke ? (
<DropdownSection title="Danger zone">
<DropdownItem
key="revoke"
className="text-text-error"
color="danger"
description="Revoke this API key permanently"
textValue="Revoke"
startContent={
<DeleteDocumentBulkIcon
className={clsx(iconClasses, "!text-text-error")}
/>
}
onPress={() => onRevoke(apiKey)}
>
Revoke
</DropdownItem>
</DropdownSection>
) : null}
</DropdownMenu>
</Dropdown>
}
>
<ActionDropdownItem
icon={<Pencil />}
label="Edit API Key"
onSelect={() => onEdit(apiKey)}
/>
{canRevoke && (
<ActionDropdownDangerZone>
<ActionDropdownItem
icon={<Trash2 />}
label="Revoke API Key"
destructive
onSelect={() => onRevoke(apiKey)}
/>
</ActionDropdownDangerZone>
)}
</ActionDropdown>
</div>
);
}
@@ -1,22 +1,16 @@
"use client";
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
} from "@heroui/dropdown";
import {
DeleteDocumentBulkIcon,
EditDocumentBulkIcon,
} from "@heroui/shared-icons";
import { Row } from "@tanstack/react-table";
import clsx from "clsx";
import { Pencil, Trash2 } from "lucide-react";
import { useState } from "react";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import {
ActionDropdown,
ActionDropdownDangerZone,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Modal } from "@/components/shadcn/modal";
import { DeleteForm, EditForm } from "../forms";
@@ -25,7 +19,6 @@ interface DataTableRowActionsProps<UserProps> {
row: Row<UserProps>;
roles?: { id: string; name: string }[];
}
const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0";
export function DataTableRowActions<UserProps>({
row,
@@ -66,51 +59,27 @@ export function DataTableRowActions<UserProps>({
</Modal>
<div className="relative flex items-center justify-end gap-2">
<Dropdown
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
placement="bottom"
>
<DropdownTrigger>
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-slate-400" />
</Button>
</DropdownTrigger>
<DropdownMenu
closeOnSelect
aria-label="Actions"
color="default"
variant="flat"
>
<DropdownSection title="Actions">
<DropdownItem
key="edit"
description="Allows you to edit the user"
textValue="Edit User"
startContent={<EditDocumentBulkIcon className={iconClasses} />}
onPress={() => setIsEditOpen(true)}
>
Edit User
</DropdownItem>
</DropdownSection>
<DropdownSection title="Danger zone">
<DropdownItem
key="delete"
className="text-text-error"
color="danger"
description="Delete the user permanently"
textValue="Delete User"
startContent={
<DeleteDocumentBulkIcon
className={clsx(iconClasses, "!text-text-error")}
/>
}
onPress={() => setIsDeleteOpen(true)}
>
Delete User
</DropdownItem>
</DropdownSection>
</DropdownMenu>
</Dropdown>
}
>
<ActionDropdownItem
icon={<Pencil />}
label="Edit User"
onSelect={() => setIsEditOpen(true)}
/>
<ActionDropdownDangerZone>
<ActionDropdownItem
icon={<Trash2 />}
label="Delete User"
destructive
onSelect={() => setIsDeleteOpen(true)}
/>
</ActionDropdownDangerZone>
</ActionDropdown>
</div>
</>
);
+26 -5
View File
@@ -1,16 +1,17 @@
"use client";
import { useSearchParams } from "next/navigation";
import {
createContext,
ReactNode,
TransitionStartFunction,
useContext,
useTransition,
useEffect,
useState,
} from "react";
interface FilterTransitionContextType {
isPending: boolean;
startTransition: TransitionStartFunction;
signalFilterChange: () => void;
}
const FilterTransitionContext = createContext<
@@ -39,13 +40,33 @@ interface FilterTransitionProviderProps {
children: ReactNode;
}
/**
* Provides a shared pending state for filter changes.
*
* Filter components signal the start of navigation via signalFilterChange(),
* and use their own local useTransition() for the actual router.push().
* This avoids a known Next.js production bug where a shared useTransition()
* wrapping router.push() causes the navigation to be silently reverted.
*
* The pending state auto-resets when searchParams change (navigation completed).
*/
export const FilterTransitionProvider = ({
children,
}: FilterTransitionProviderProps) => {
const [isPending, startTransition] = useTransition();
const searchParams = useSearchParams();
const [isPending, setIsPending] = useState(false);
// Auto-reset pending state when searchParams change (navigation completed)
useEffect(() => {
setIsPending(false);
}, [searchParams]);
const signalFilterChange = () => {
setIsPending(true);
};
return (
<FilterTransitionContext.Provider value={{ isPending, startTransition }}>
<FilterTransitionContext.Provider value={{ isPending, signalFilterChange }}>
{children}
</FilterTransitionContext.Provider>
);
+81 -63
View File
@@ -1,89 +1,90 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useTransition } from "react";
import { useFilterTransitionOptional } from "@/contexts";
const FINDINGS_PATH = "/findings";
const DEFAULT_MUTED_FILTER = "false";
/**
* Custom hook to handle URL filters and automatically reset
* pagination when filters change.
*
* Uses useTransition to prevent full page reloads when filters change,
* keeping the current UI visible while the new data loads.
*
* When used within a FilterTransitionProvider, the transition state is shared
* across all components using this hook, enabling coordinated loading indicators.
* Uses client-side router navigation to update query params without
* full page reloads when filters change.
*/
export const useUrlFilters = () => {
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
const filterTransition = useFilterTransitionOptional();
const isPending = false;
// Use shared context if available, otherwise fall back to local transition
const sharedTransition = useFilterTransitionOptional();
const [localIsPending, localStartTransition] = useTransition();
const ensureFindingsDefaultMuted = (params: URLSearchParams) => {
// Findings defaults to excluding muted findings unless user sets it explicitly.
if (pathname === FINDINGS_PATH && !params.has("filter[muted]")) {
params.set("filter[muted]", DEFAULT_MUTED_FILTER);
}
};
const isPending = sharedTransition?.isPending ?? localIsPending;
const startTransition =
sharedTransition?.startTransition ?? localStartTransition;
const navigate = (params: URLSearchParams) => {
ensureFindingsDefaultMuted(params);
const updateFilter = useCallback(
(key: string, value: string | string[] | null) => {
const params = new URLSearchParams(searchParams.toString());
const queryString = params.toString();
if (queryString === searchParams.toString()) return;
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
const targetUrl = queryString ? `${pathname}?${queryString}` : pathname;
filterTransition?.signalFilterChange();
router.push(targetUrl, { scroll: false });
};
const currentValue = params.get(filterKey);
const nextValue = Array.isArray(value)
? value.length > 0
? value.join(",")
: null
: value === null
? null
: value;
const updateFilter = (key: string, value: string | string[] | null) => {
const params = new URLSearchParams(searchParams.toString());
// If effective value is unchanged, do nothing (avoids redundant fetches)
if (currentValue === nextValue) return;
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
// Only reset page to 1 if page parameter already exists
if (params.has("page")) {
params.set("page", "1");
}
const currentValue = params.get(filterKey);
const nextValue = Array.isArray(value)
? value.length > 0
? value.join(",")
: null
: value === null
? null
: value;
if (nextValue === null) {
params.delete(filterKey);
} else {
params.set(filterKey, nextValue);
}
// If effective value is unchanged, do nothing (avoids redundant fetches)
if (currentValue === nextValue) return;
startTransition(() => {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
});
},
[router, searchParams, pathname, startTransition],
);
const clearFilter = useCallback(
(key: string) => {
const params = new URLSearchParams(searchParams.toString());
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
// Only reset page to 1 if page parameter already exists
if (params.has("page")) {
params.set("page", "1");
}
if (nextValue === null) {
params.delete(filterKey);
} else {
params.set(filterKey, nextValue);
}
// Only reset page to 1 if page parameter already exists
if (params.has("page")) {
params.set("page", "1");
}
navigate(params);
};
startTransition(() => {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
});
},
[router, searchParams, pathname, startTransition],
);
const clearFilter = (key: string) => {
const params = new URLSearchParams(searchParams.toString());
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
const clearAllFilters = useCallback(() => {
params.delete(filterKey);
// Only reset page to 1 if page parameter already exists
if (params.has("page")) {
params.set("page", "1");
}
navigate(params);
};
const clearAllFilters = () => {
const params = new URLSearchParams(searchParams.toString());
Array.from(params.keys()).forEach((key) => {
if (key.startsWith("filter[") || key === "sort") {
@@ -93,17 +94,33 @@ export const useUrlFilters = () => {
params.delete("page");
startTransition(() => {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
});
}, [router, searchParams, pathname, startTransition]);
navigate(params);
};
const hasFilters = useCallback(() => {
const hasFilters = () => {
const params = new URLSearchParams(searchParams.toString());
return Array.from(params.keys()).some(
(key) => key.startsWith("filter[") || key === "sort",
);
}, [searchParams]);
};
/**
* Low-level navigation function for complex filter updates that need
* to modify multiple params atomically (e.g., setting provider_type
* while clearing provider_id). The modifier receives a mutable
* URLSearchParams; page is auto-reset if already present.
*/
const navigateWithParams = (modifier: (params: URLSearchParams) => void) => {
const params = new URLSearchParams(searchParams.toString());
modifier(params);
// Only reset page to 1 if page parameter already exists
if (params.has("page")) {
params.set("page", "1");
}
navigate(params);
};
return {
updateFilter,
@@ -111,5 +128,6 @@ export const useUrlFilters = () => {
clearAllFilters,
hasFilters,
isPending,
navigateWithParams,
};
};
-14
View File
@@ -50,20 +50,6 @@ export default auth((req: NextRequest & { auth: any }) => {
}
}
// Redirect /findings to include default muted filter if not present
if (
pathname === "/findings" &&
!req.nextUrl.searchParams.has("filter[muted]")
) {
const findingsUrl = new URL("/findings", req.url);
// Preserve existing search params
req.nextUrl.searchParams.forEach((value, key) => {
findingsUrl.searchParams.set(key, value);
});
findingsUrl.searchParams.set("filter[muted]", "false");
return NextResponse.redirect(findingsUrl);
}
return NextResponse.next();
});
+7
View File
@@ -396,3 +396,10 @@
@apply bg-background text-foreground;
}
}
/* Override vaul's injected user-select: none to allow text selection in drawers */
@media (hover: hover) and (pointer: fine) {
[data-vaul-drawer][data-vaul-drawer] {
user-select: text;
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ export class InvitationsPage extends BasePage {
this.emailInput = page.getByRole("textbox", { name: "Email" });
// Form select
this.roleSelect = page.getByRole("button", { name: /Role|Select a role/i });
this.roleSelect = page.getByRole("combobox", { name: /Role|Select a role/i });
// Form details
this.reviewInvitationDetailsButton = page.getByRole('button', { name: /Review Invitation Details/i });
+5 -8
View File
@@ -990,19 +990,16 @@ export class ProvidersPage extends BasePage {
}
async selectAuthenticationMethod(method: AWSCredentialType): Promise<void> {
// Select the authentication method
// Select the authentication method (shadcn Select renders as combobox + listbox)
const button = this.page.locator("button").filter({
const trigger = this.page.locator('[role="combobox"]').filter({
hasText: /AWS SDK Default|Prowler Cloud will assume|Access & Secret Key/i,
});
await button.click();
await trigger.click();
const modal = this.page
.locator('[role="dialog"], .modal, [data-testid*="modal"]')
.first();
await expect(modal).toBeVisible({ timeout: 10000 });
const listbox = this.page.getByRole("listbox");
await expect(listbox).toBeVisible({ timeout: 10000 });
if (method === AWS_CREDENTIAL_OPTIONS.AWS_ROLE_ARN) {
await this.page
+1
View File
@@ -105,6 +105,7 @@ export class ScansPage extends BasePage {
await expect(this.scanTable).toBeVisible();
// Find a row that contains the account ID (provider UID in Cloud Provider column)
// Note: Use a more specific locator strategy if possible in the future
const rowWithAccountId = this.scanTable
.locator("tbody tr")
.filter({ hasText: accountId })