Compare commits

..

24 Commits

Author SHA1 Message Date
sumit_chaturvedi
fc1337a4e6 Merge branch 'master' into PRWLR-7732-add-mutelist-menu-item 2025-08-05 09:03:14 +05:30
Alejandro Bailo
c748e57878 feat: manage integration permission behavior (#8441)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2025-08-04 17:49:04 +02:00
sumit_chaturvedi
71b5f3714c docs: changelog update 2025-08-04 19:58:00 +05:30
sumit_chaturvedi
ae697d838c feat(ui): add mutelist menu item under configuration to link to cloud providers 2025-08-04 19:44:19 +05:30
Alejandro Bailo
a5187c6a42 feat(ui): S3 integration retouches (#8438) 2025-08-04 16:04:10 +02:00
Alejandro Bailo
e19ed30ac7 feat(UI): xml validation (#8429) 2025-08-04 12:09:18 +02:00
Hugo Pereira Brito
96ce1461b9 chore(sentry): add powershell user auth module connection errors to ignored list (#8420) 2025-08-04 11:58:05 +02:00
Alejandro Bailo
9da5fb67c3 feat(ui): S3 integration (#8391) 2025-08-04 11:43:14 +02:00
Chandrapal Badshah
eb1c1791e4 fix: clear only last message on error (#8431)
Co-authored-by: Chandrapal Badshah <12944530+Chan9390@users.noreply.github.com>
2025-08-04 10:33:45 +02:00
Adrián Jesús Peña Rodríguez
581afd38e6 fix: add default values for S3 class (#8417)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
Co-authored-by: MrCloudSec <hello@mistercloudsec.com>
2025-08-01 13:50:51 +02:00
sumit-tft
19a735aafe chore(ui): remove misconfigurations from Top Failed Findings in the s… (#8426) 2025-08-01 12:47:17 +02:00
Paul Negedu
2170fbb1ab feat(aws): add s3_bucket_shadow_resource_vulnerability check (#8398)
Co-authored-by: MrCloudSec <hello@mistercloudsec.com>
2025-08-01 18:26:03 +08:00
Pablo Lara
90c6c6b98d feat: add new provider GitHub and update enum source of truth (#8421) 2025-08-01 10:03:47 +02:00
sumit-tft
02b416b4f8 chore(ui): remove browse all resources from the sidebar (#8418) 2025-07-31 16:13:30 +02:00
Hugo Pereira Brito
1022b5e413 chore(docs): add a step to check development guide (#8416) 2025-07-31 12:45:15 +02:00
Pablo Lara
d1bad9d9ab chore: rename menu item (#8415) 2025-07-31 12:10:07 +02:00
Rubén De la Torre Vico
178f3850be chore: add M365 provider to PR labeler (#8406) 2025-07-31 17:32:18 +08:00
Adrián Jesús Peña Rodríguez
d239d299e2 fix(s3): use enabled to filter (#8409) 2025-07-31 10:00:05 +02:00
Pepe Fagoaga
88fae9ecae chore(ui): remove changelog entry (#8410) 2025-07-31 09:27:11 +02:00
Hugo Pereira Brito
a3bff9705c fix(tests): github and iac providers arguments_test naming and structure (#8408) 2025-07-30 17:16:34 +02:00
César Arroba
75989b09d7 chore(gha): fix payload on merged PR action (#8407) 2025-07-30 16:59:40 +02:00
Pablo Lara
9a622f60fe feat(providers): add GitHub provider support with credential types (#8405) 2025-07-30 15:55:40 +02:00
Rubén De la Torre Vico
7cd1966066 fix(azure,m365): use default tenant domain instead of first domain in list (#8402) 2025-07-30 13:23:25 +02:00
Pedro Martín
77e59203ae feat(prowler-threatscore): remove and add requirements (#8401) 2025-07-30 13:09:51 +02:00
95 changed files with 3737 additions and 436 deletions

5
.github/labeler.yml vendored
View File

@@ -22,6 +22,11 @@ provider/kubernetes:
- any-glob-to-any-file: "prowler/providers/kubernetes/**"
- any-glob-to-any-file: "tests/providers/kubernetes/**"
provider/m365:
- changed-files:
- any-glob-to-any-file: "prowler/providers/m365/**"
- any-glob-to-any-file: "tests/providers/m365/**"
provider/github:
- changed-files:
- any-glob-to-any-file: "prowler/providers/github/**"

View File

@@ -27,11 +27,12 @@ jobs:
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
repository: ${{ secrets.CLOUD_DISPATCH }}
event-type: prowler-pull-request-merged
client-payload: '{
"PROWLER_COMMIT_SHA": "${{ github.event.pull_request.merge_commit_sha }}",
"PROWLER_COMMIT_SHORT_SHA": "${{ env.SHORT_SHA }}",
"PROWLER_PR_TITLE": "${{ github.event.pull_request.title }}",
"PROWLER_PR_LABELS": ${{ toJson(github.event.pull_request.labels.*.name) }},
"PROWLER_PR_BODY": ${{ toJson(github.event.pull_request.body) }},
"PROWLER_PR_URL":${{ toJson(github.event.pull_request.html_url) }}
}'
client-payload: |
{
"PROWLER_COMMIT_SHA": "${{ github.event.pull_request.merge_commit_sha }}",
"PROWLER_COMMIT_SHORT_SHA": "${{ env.SHORT_SHA }}",
"PROWLER_PR_TITLE": ${{ toJson(github.event.pull_request.title) }},
"PROWLER_PR_LABELS": ${{ toJson(github.event.pull_request.labels.*.name) }},
"PROWLER_PR_BODY": ${{ toJson(github.event.pull_request.body) }},
"PROWLER_PR_URL": ${{ toJson(github.event.pull_request.html_url) }}
}

View File

@@ -8,6 +8,9 @@ All notable changes to the **Prowler API** are documented in this file.
- Github provider support [(#8271)](https://github.com/prowler-cloud/prowler/pull/8271)
- Integration with Amazon S3, enabling storage and retrieval of scan data via S3 buckets [(#8056)](https://github.com/prowler-cloud/prowler/pull/8056)
### Fixed
- Avoid sending errors to Sentry in M365 provider when user authentication fails [(#8420)](https://github.com/prowler-cloud/prowler/pull/8420)
---
## [1.10.2] (Prowler v5.9.2)

View File

@@ -69,6 +69,9 @@ IGNORED_EXCEPTIONS = [
"AzureClientIdAndClientSecretNotBelongingToTenantIdError",
"AzureHTTPResponseError",
"Error with credentials provided",
# PowerShell Errors in User Authentication
"Microsoft Teams User Auth connection failed: Please check your permissions and try again.",
"Exchange Online User Auth connection failed: Please check your permissions and try again.",
]

View File

@@ -95,7 +95,12 @@ def check_integration_connection(integration_id: str):
Args:
integration_id (str): The primary key of the Integration instance to check.
"""
integration = Integration.objects.get(pk=integration_id)
integration = Integration.objects.filter(pk=integration_id, enabled=True).first()
if not integration:
logger.info(f"Integration {integration_id} is not enabled")
return {"connected": False, "error": "Integration is not enabled"}
try:
result = prowler_integration_connection_test(integration)
except Exception as e:

View File

@@ -71,6 +71,7 @@ def upload_s3_integration(
Integration.objects.filter(
integrationproviderrelationship__provider_id=provider_id,
integration_type=Integration.IntegrationChoices.AMAZON_S3,
enabled=True,
)
)

View File

@@ -387,6 +387,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
s3_integrations = Integration.objects.filter(
integrationproviderrelationship__provider_id=provider_id,
integration_type=Integration.IntegrationChoices.AMAZON_S3,
enabled=True,
)
if s3_integrations:
@@ -486,7 +487,8 @@ def check_integrations_task(tenant_id: str, provider_id: str):
try:
with rls_transaction(tenant_id):
integrations = Integration.objects.filter(
integrationproviderrelationship__provider_id=provider_id
integrationproviderrelationship__provider_id=provider_id,
enabled=True,
)
if not integrations.exists():

View File

@@ -1,10 +1,15 @@
import uuid
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from tasks.jobs.connection import check_lighthouse_connection, check_provider_connection
from tasks.jobs.connection import (
check_integration_connection,
check_lighthouse_connection,
check_provider_connection,
)
from api.models import LighthouseConfiguration, Provider
from api.models import Integration, LighthouseConfiguration, Provider
@pytest.mark.parametrize(
@@ -127,3 +132,128 @@ def test_check_lighthouse_connection_missing_api_key(mock_lighthouse_get):
assert result["available_models"] == []
assert mock_lighthouse_instance.is_active is False
mock_lighthouse_instance.save.assert_called_once()
@pytest.mark.django_db
class TestCheckIntegrationConnection:
def setup_method(self):
self.integration_id = str(uuid.uuid4())
@patch("tasks.jobs.connection.Integration.objects.filter")
@patch("tasks.jobs.connection.prowler_integration_connection_test")
def test_check_integration_connection_success(
self, mock_prowler_test, mock_integration_filter
):
"""Test successful integration connection check with enabled=True filter."""
mock_integration = MagicMock()
mock_integration.id = self.integration_id
mock_integration.integration_type = Integration.IntegrationChoices.AMAZON_S3
mock_queryset = MagicMock()
mock_queryset.first.return_value = mock_integration
mock_integration_filter.return_value = mock_queryset
mock_connection_result = MagicMock()
mock_connection_result.is_connected = True
mock_connection_result.error = None
mock_prowler_test.return_value = mock_connection_result
result = check_integration_connection(integration_id=self.integration_id)
# Verify that Integration.objects.filter was called with enabled=True filter
mock_integration_filter.assert_called_once_with(
pk=self.integration_id, enabled=True
)
mock_queryset.first.assert_called_once()
mock_prowler_test.assert_called_once_with(mock_integration)
# Verify the integration properties were updated
assert mock_integration.connected is True
assert mock_integration.connection_last_checked_at is not None
mock_integration.save.assert_called_once()
# Verify the return value
assert result["connected"] is True
assert result["error"] is None
@patch("tasks.jobs.connection.Integration.objects.filter")
@patch("tasks.jobs.connection.prowler_integration_connection_test")
def test_check_integration_connection_failure(
self, mock_prowler_test, mock_integration_filter
):
"""Test failed integration connection check."""
mock_integration = MagicMock()
mock_integration.id = self.integration_id
mock_queryset = MagicMock()
mock_queryset.first.return_value = mock_integration
mock_integration_filter.return_value = mock_queryset
test_error = Exception("Connection failed")
mock_connection_result = MagicMock()
mock_connection_result.is_connected = False
mock_connection_result.error = test_error
mock_prowler_test.return_value = mock_connection_result
result = check_integration_connection(integration_id=self.integration_id)
# Verify that Integration.objects.filter was called with enabled=True filter
mock_integration_filter.assert_called_once_with(
pk=self.integration_id, enabled=True
)
mock_queryset.first.assert_called_once()
# Verify the integration properties were updated
assert mock_integration.connected is False
assert mock_integration.connection_last_checked_at is not None
mock_integration.save.assert_called_once()
# Verify the return value
assert result["connected"] is False
assert result["error"] == str(test_error)
@patch("tasks.jobs.connection.Integration.objects.filter")
def test_check_integration_connection_not_enabled(self, mock_integration_filter):
"""Test that disabled integrations return proper error response."""
# Mock that no enabled integration is found
mock_queryset = MagicMock()
mock_queryset.first.return_value = None
mock_integration_filter.return_value = mock_queryset
result = check_integration_connection(integration_id=self.integration_id)
# Verify the filter was called with enabled=True
mock_integration_filter.assert_called_once_with(
pk=self.integration_id, enabled=True
)
mock_queryset.first.assert_called_once()
# Verify the return value matches the expected error response
assert result["connected"] is False
assert result["error"] == "Integration is not enabled"
@patch("tasks.jobs.connection.Integration.objects.filter")
@patch("tasks.jobs.connection.prowler_integration_connection_test")
def test_check_integration_connection_exception(
self, mock_prowler_test, mock_integration_filter
):
"""Test integration connection check when prowler test raises exception."""
mock_integration = MagicMock()
mock_integration.id = self.integration_id
mock_queryset = MagicMock()
mock_queryset.first.return_value = mock_integration
mock_integration_filter.return_value = mock_queryset
test_exception = Exception("Unexpected error during connection test")
mock_prowler_test.side_effect = test_exception
with pytest.raises(Exception, match="Unexpected error during connection test"):
check_integration_connection(integration_id=self.integration_id)
# Verify that Integration.objects.filter was called with enabled=True filter
mock_integration_filter.assert_called_once_with(
pk=self.integration_id, enabled=True
)
mock_queryset.first.assert_called_once()
mock_prowler_test.assert_called_once_with(mock_integration)

View File

@@ -208,6 +208,30 @@ class TestS3IntegrationUploads:
"S3 connection failed for integration i-1: failed"
)
@patch("tasks.jobs.integrations.rls_transaction")
@patch("tasks.jobs.integrations.Integration.objects.filter")
def test_upload_s3_integration_filters_enabled_only(
self, mock_integration_filter, mock_rls
):
"""Test that upload_s3_integration only processes enabled integrations."""
tenant_id = "tenant-id"
provider_id = "provider-id"
output_directory = "/tmp/prowler_output/scan123"
# Mock that no enabled integrations are found
mock_integration_filter.return_value = []
mock_rls.return_value.__enter__.return_value = None
result = upload_s3_integration(tenant_id, provider_id, output_directory)
assert result is False
# Verify the filter includes the correct parameters including enabled=True
mock_integration_filter.assert_called_once_with(
integrationproviderrelationship__provider_id=provider_id,
integration_type=Integration.IntegrationChoices.AMAZON_S3,
enabled=True,
)
def test_s3_integration_validates_and_normalizes_output_directory(self):
"""Test that S3 integration validation normalizes output_directory paths."""
from api.models import Integration

View File

@@ -9,6 +9,8 @@ from tasks.tasks import (
s3_integration_task,
)
from api.models import Integration
# TODO Move this to outputs/reports jobs
@pytest.mark.django_db
@@ -418,6 +420,56 @@ class TestGenerateOutputs:
)
assert "Error deleting output files" in caplog.text
@patch("tasks.tasks.rls_transaction")
@patch("tasks.tasks.Integration.objects.filter")
def test_generate_outputs_filters_enabled_s3_integrations(
self, mock_integration_filter, mock_rls
):
"""Test that generate_outputs_task only processes enabled S3 integrations."""
with (
patch("tasks.tasks.ScanSummary.objects.filter") as mock_summary,
patch("tasks.tasks.Provider.objects.get"),
patch("tasks.tasks.initialize_prowler_provider"),
patch("tasks.tasks.Compliance.get_bulk"),
patch("tasks.tasks.get_compliance_frameworks", return_value=[]),
patch("tasks.tasks.Finding.all_objects.filter") as mock_findings,
patch(
"tasks.tasks._generate_output_directory", return_value=("out", "comp")
),
patch("tasks.tasks.FindingOutput._transform_findings_stats"),
patch("tasks.tasks.FindingOutput.transform_api_finding"),
patch("tasks.tasks._compress_output_files", return_value="/tmp/compressed"),
patch("tasks.tasks._upload_to_s3", return_value="s3://bucket/file.zip"),
patch("tasks.tasks.Scan.all_objects.filter"),
patch("tasks.tasks.rmtree"),
patch("tasks.tasks.s3_integration_task.apply_async") as mock_s3_task,
):
mock_summary.return_value.exists.return_value = True
mock_findings.return_value.order_by.return_value.iterator.return_value = [
[MagicMock()],
True,
]
mock_integration_filter.return_value = [MagicMock()]
mock_rls.return_value.__enter__.return_value = None
with (
patch("tasks.tasks.OUTPUT_FORMATS_MAPPING", {}),
patch("tasks.tasks.COMPLIANCE_CLASS_MAP", {"aws": []}),
):
generate_outputs_task(
scan_id=self.scan_id,
provider_id=self.provider_id,
tenant_id=self.tenant_id,
)
# Verify the S3 integrations filters
mock_integration_filter.assert_called_once_with(
integrationproviderrelationship__provider_id=self.provider_id,
integration_type=Integration.IntegrationChoices.AMAZON_S3,
enabled=True,
)
mock_s3_task.assert_called_once()
class TestScanCompleteTasks:
@patch("tasks.tasks.create_compliance_requirements_task.apply_async")
@@ -465,7 +517,8 @@ class TestCheckIntegrationsTask:
assert result == {"integrations_processed": 0}
mock_integration_filter.assert_called_once_with(
integrationproviderrelationship__provider_id=self.provider_id
integrationproviderrelationship__provider_id=self.provider_id,
enabled=True,
)
@patch("tasks.tasks.group")
@@ -488,11 +541,32 @@ class TestCheckIntegrationsTask:
assert result == {"integrations_processed": 0}
mock_integration_filter.assert_called_once_with(
integrationproviderrelationship__provider_id=self.provider_id
integrationproviderrelationship__provider_id=self.provider_id,
enabled=True,
)
# group should not be called since no integration tasks are created yet
mock_group.assert_not_called()
@patch("tasks.tasks.rls_transaction")
@patch("tasks.tasks.Integration.objects.filter")
def test_check_integrations_disabled_integrations_ignored(
self, mock_integration_filter, mock_rls
):
"""Test that disabled integrations are not processed."""
mock_integration_filter.return_value.exists.return_value = False
mock_rls.return_value.__enter__.return_value = None
result = check_integrations_task(
tenant_id=self.tenant_id,
provider_id=self.provider_id,
)
assert result == {"integrations_processed": 0}
mock_integration_filter.assert_called_once_with(
integrationproviderrelationship__provider_id=self.provider_id,
enabled=True,
)
@patch("tasks.tasks.upload_s3_integration")
def test_s3_integration_task_success(self, mock_upload):
mock_upload.return_value = True

View File

@@ -20,7 +20,8 @@ The most common high level steps to create a new check are:
5. Run the check locally to ensure it works as expected. For checking you can use the CLI in the next way:
- To ensure the check has been detected by Prowler: `poetry run python prowler-cli.py <provider> --list-checks | grep <check_name>`.
- To run the check, to find possible issues: `poetry run python prowler-cli.py <provider> --log-level ERROR --verbose --check <check_name>`.
6. If the check is working as expected, you can submit a PR to Prowler.
6. Create comprehensive tests for the check that cover multiple scenarios including both PASS (compliant) and FAIL (non-compliant) cases. For detailed information about test structure and implementation guidelines, refer to the [Testing](./unit-testing.md) documentation.
7. If the check and its corresponding tests are working as expected, you can submit a PR to Prowler.
### Naming Format for Checks

View File

@@ -12,6 +12,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `vm_desired_sku_size` check for Azure provider [(#8191)](https://github.com/prowler-cloud/prowler/pull/8191)
- `vm_scaleset_not_empty` check for Azure provider [(#8192)](https://github.com/prowler-cloud/prowler/pull/8192)
- GitHub repository and organization scoping support with `--repository/respositories` and `--organization/organizations` flags [(#8329)](https://github.com/prowler-cloud/prowler/pull/8329)
- `s3_bucket_shadow_resource_vulnerability` check for AWS provider [(#8398)](https://github.com/prowler-cloud/prowler/pull/8398)
### Changed
- Handle some AWS errors as warnings instead of errors [(#8347)](https://github.com/prowler-cloud/prowler/pull/8347)
@@ -24,6 +25,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Add missing audit evidence for controls 1.1.4 and 2.5.5 for ISMS-P compliance. [(#8386)](https://github.com/prowler-cloud/prowler/pull/8386)
- Use the correct @staticmethod decorator for `set_identity` and `set_session_config` methods in AwsProvider [(#8056)](https://github.com/prowler-cloud/prowler/pull/8056)
- Use the correct default value for `role_session_name` and `session_duration` in AwsSetUpSession [(#8056)](https://github.com/prowler-cloud/prowler/pull/8056)
- Use the correct default value for `role_session_name` and `session_duration` in S3 [(#8417)](https://github.com/prowler-cloud/prowler/pull/8417)
---
@@ -34,7 +36,11 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `sns_topics_not_publicly_accessible` false positive with `aws:SourceArn` conditions [(#8326)](https://github.com/prowler-cloud/prowler/issues/8326)
- Remove typo from description req 1.2.3 - Prowler ThreatScore m365 [(#8384)](https://github.com/prowler-cloud/prowler/pull/8384)
- Way of counting FAILED/PASS reqs from `kisa_isms_p_2023_aws` table [(#8382)](https://github.com/prowler-cloud/prowler/pull/8382)
- Use default tenant domain instead of first domain in list for Azure and M365 providers [(#8402)](https://github.com/prowler-cloud/prowler/pull/8402)
- Avoid multiple module error calls in M365 provider [(#8353)](https://github.com/prowler-cloud/prowler/pull/8353)
- Avoid sending errors to Sentry in M365 provider when user authentication fails [(#8420)](https://github.com/prowler-cloud/prowler/pull/8420)
- Tweaks from Prowler ThreatScore in order to handle the correct reqs [(#8401)](https://github.com/prowler-cloud/prowler/pull/8401)
- Make `setup_assumed_session` static for the AWS provider [(#8419)](https://github.com/prowler-cloud/prowler/pull/8419)
---

View File

@@ -6,24 +6,6 @@
"Requirements": [
{
"Id": "1.1.1",
"Description": "Ensure Security Defaults is enabled on Microsoft Entra ID",
"Checks": [
"entra_security_defaults_enabled"
],
"Attributes": [
{
"Title": "Security Defaults enabled on Entra ID",
"Section": "1. IAM",
"SubSection": "1.1 Authentication",
"AttributeDescription": "Microsoft Entra ID Security Defaults offer preconfigured security settings designed to protect organizations from common identity attacks at no additional cost. These settings enforce basic security measures such as MFA registration, risk-based authentication prompts, and blocking legacy authentication clients that do not support MFA. Security defaults are available to all organizations and can be enabled via the Azure portal to strengthen authentication security.",
"AdditionalInformation": "Security defaults provide built-in protections to reduce the risk of unauthorized access until organizations configure their own identity security policies. By requiring MFA, blocking weak authentication methods, and adapting authentication challenges based on risk factors, these settings create a stronger security foundation without additional licensing requirements.",
"LevelOfRisk": 4,
"Weight": 100
}
]
},
{
"Id": "1.1.2",
"Description": "Ensure that 'Multi-Factor Auth Status' is 'Enabled' for all Privileged Users",
"Checks": [
"entra_privileged_user_has_mfa"
@@ -41,7 +23,7 @@
]
},
{
"Id": "1.1.3",
"Id": "1.1.2",
"Description": "Ensure that 'Multi-Factor Auth Status' is 'Enabled' for all Non-Privileged Users",
"Checks": [
"entra_non_privileged_user_has_mfa"
@@ -59,7 +41,7 @@
]
},
{
"Id": "1.1.4",
"Id": "1.1.3",
"Description": "Ensure Multi-factor Authentication is Required for Windows Azure Service Management API",
"Checks": [
"entra_conditional_access_policy_require_mfa_for_management_api"
@@ -77,7 +59,7 @@
]
},
{
"Id": "1.1.5",
"Id": "1.1.4",
"Description": "Ensure Multi-factor Authentication is Required to access Microsoft Admin Portals",
"Checks": [
"defender_ensure_defender_for_server_is_on"
@@ -95,7 +77,7 @@
]
},
{
"Id": "1.1.6",
"Id": "1.1.5",
"Description": "Ensure only MFA enabled identities can access privileged Virtual Machine",
"Checks": [
"entra_user_with_vm_access_has_mfa"

View File

@@ -310,6 +310,24 @@
}
]
},
{
"Id": "1.1.18",
"Description": "Ensure that only administrative roles have access to Microsoft Admin Portals",
"Checks": [
"entra_admin_portals_access_restriction"
],
"Attributes": [
{
"Title": "Only administrative roles have access to Microsoft Admin Portals",
"Section": "1. IAM",
"SubSection": "1.1 Authentication",
"AttributeDescription": "Restrict access to Microsoft Admin Portals exclusively to administrative roles to prevent unauthorized modifications, privilege escalation, and security misconfigurations",
"AdditionalInformation": "Granting non-administrative users access to Microsoft Admin Portals exposes the environment to unauthorized changes, potential elevation of privileges, and misconfigured security settings. This could allow attackers to alter configurations, disable protections, or gain access to sensitive information.",
"LevelOfRisk": 4,
"Weight": 100
}
]
},
{
"Id": "1.2.1",
"Description": "Ensure that only organizationally managed/approved public groups exist",

View File

@@ -254,7 +254,7 @@ class AwsProvider(Provider):
)
# Assume the IAM Role
logger.info(f"Assuming role: {assumed_role_information.role_arn.arn}")
assumed_role_credentials = self.assume_role(
assumed_role_credentials = AwsProvider.assume_role(
self._session.current_session,
assumed_role_information,
)
@@ -267,8 +267,10 @@ class AwsProvider(Provider):
self._assumed_role_configuration = assumed_role_configuration
# Store a new current session using the assumed IAM Role
self._session.current_session = self.setup_assumed_session(
assumed_role_configuration.credentials
self._session.current_session = AwsProvider.setup_assumed_session(
self._identity,
assumed_role_configuration,
self._session,
)
logger.info("Audit session is the new session created assuming an IAM Role")
@@ -316,8 +318,10 @@ class AwsProvider(Provider):
credentials=organizations_assumed_role_credentials,
)
# Get a new session using the AWS Organizations IAM Role assumed
aws_organizations_session = self.setup_assumed_session(
organizations_assumed_role_configuration.credentials
aws_organizations_session = AwsProvider.setup_assumed_session(
self._identity,
organizations_assumed_role_configuration,
self._session,
)
logger.info(
"Generated new session for to get the AWS Organizations metadata"
@@ -576,9 +580,11 @@ class AwsProvider(Provider):
file=pathlib.Path(__file__).name,
)
@staticmethod
def setup_assumed_session(
self,
assumed_role_credentials: AWSCredentials,
identity: AWSIdentityInfo,
assumed_role_configuration: AWSAssumeRoleConfiguration,
session: AWSSession,
) -> Session:
"""
Sets up an assumed session using the provided assumed role credentials.
@@ -588,7 +594,9 @@ class AwsProvider(Provider):
refreshing of the assumed role credentials.
Args:
identity (AWSIdentityInfo): The identity information.
assumed_role_credentials (AWSCredentials): The assumed role credentials.
session (AWSSession): The AWS provider session.
Returns:
Session: The assumed session.
@@ -607,20 +615,22 @@ class AwsProvider(Provider):
# that needs to be a method without arguments that retrieves a new set of fresh credentials
# assuming the role again.
assumed_refreshable_credentials = RefreshableCredentials(
access_key=assumed_role_credentials.aws_access_key_id,
secret_key=assumed_role_credentials.aws_secret_access_key,
token=assumed_role_credentials.aws_session_token,
expiry_time=assumed_role_credentials.expiration,
refresh_using=self.refresh_credentials,
access_key=assumed_role_configuration.credentials.aws_access_key_id,
secret_key=assumed_role_configuration.credentials.aws_secret_access_key,
token=assumed_role_configuration.credentials.aws_session_token,
expiry_time=assumed_role_configuration.credentials.expiration,
refresh_using=lambda: AwsProvider.refresh_credentials(
assumed_role_configuration, session
),
method="sts-assume-role",
)
# Here we need the botocore session since it needs to use refreshable credentials
assumed_session = BotocoreSession()
assumed_session._credentials = assumed_refreshable_credentials
assumed_session.set_config_variable("region", self._identity.profile_region)
assumed_session.set_config_variable("region", identity.profile_region)
return Session(
profile_name=self._identity.profile,
profile_name=identity.profile,
botocore_session=assumed_session,
)
except Exception as error:
@@ -630,7 +640,10 @@ class AwsProvider(Provider):
raise error
# TODO: maybe this can be improved with botocore.credentials.DeferredRefreshableCredentials https://stackoverflow.com/a/75576540
def refresh_credentials(self) -> dict:
@staticmethod
def refresh_credentials(
assumed_role_configuration: AWSAssumeRoleConfiguration, session: AWSSession
) -> dict:
"""
Refresh credentials method using AWS STS Assume Role.
@@ -640,7 +653,7 @@ class AwsProvider(Provider):
logger.info("Refreshing assumed credentials...")
# Since this method does not accept arguments, we need to get the original_session and the assumed role credentials
current_credentials = self._assumed_role_configuration.credentials
current_credentials = assumed_role_configuration.credentials
refreshed_credentials = {
"access_key": current_credentials.aws_access_key_id,
"secret_key": current_credentials.aws_secret_access_key,
@@ -655,8 +668,8 @@ class AwsProvider(Provider):
if datetime.fromisoformat(refreshed_credentials["expiry_time"]) <= datetime.now(
get_localzone()
):
assume_role_response = self.assume_role(
self._session.original_session, self._assumed_role_configuration.info
assume_role_response = AwsProvider.assume_role(
session.original_session, assumed_role_configuration.info
)
refreshed_credentials = dict(
# Keys of the dict has to be the same as those that are being searched in the parent class

View File

@@ -76,9 +76,9 @@ class S3:
output_directory: str,
session: AWSSession = None,
role_arn: str = None,
session_duration: int = None,
session_duration: int = 3600,
external_id: str = None,
role_session_name: str = None,
role_session_name: str = ROLE_SESSION_NAME,
mfa: bool = None,
profile: str = None,
aws_access_key_id: str = None,

View File

@@ -132,7 +132,7 @@ class AwsSetUpSession:
)
# Assume the IAM Role
logger.info(f"Assuming role: {assumed_role_information.role_arn.arn}")
assumed_role_credentials = self.assume_role(
assumed_role_credentials = AwsProvider.assume_role(
self._session.current_session,
assumed_role_information,
)
@@ -145,8 +145,10 @@ class AwsSetUpSession:
self._assumed_role_configuration = assumed_role_configuration
# Store a new current session using the assumed IAM Role
self._session.current_session = self.setup_assumed_session(
assumed_role_configuration.credentials
self._session.current_session = AwsProvider.setup_assumed_session(
self._identity,
self._assumed_role_configuration,
self._session,
)
logger.info("Audit session is the new session created assuming an IAM Role")
@@ -192,10 +194,8 @@ def validate_arguments(
"If a role ARN is provided, a session duration, an external ID, and a role session name are required."
)
else:
if session_duration or external_id or role_session_name:
raise ValueError(
"If a session duration, an external ID, or a role session name is provided, a role ARN is required."
)
if external_id:
raise ValueError("If an external ID is provided, a role ARN is required.")
if not profile and not aws_access_key_id and not aws_secret_access_key:
raise ValueError(
"If no role ARN is provided, a profile, an AWS access key ID, or an AWS secret access key is required."

View File

@@ -0,0 +1,34 @@
{
"Provider": "aws",
"CheckID": "s3_bucket_shadow_resource_vulnerability",
"CheckTitle": "Check for S3 buckets vulnerable to Shadow Resource Hijacking (Bucket Monopoly)",
"CheckType": [
""
],
"ServiceName": "s3",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:s3:::bucket_name",
"Severity": "high",
"ResourceType": "AwsS3Bucket",
"Description": "Checks for S3 buckets with predictable names that could be hijacked by an attacker before legitimate use, leading to data leakage or other security breaches.",
"Risk": "An attacker can pre-create S3 buckets with predictable names used by various AWS services. When a legitimate user's service attempts to use that bucket, it may inadvertently write sensitive data to the attacker-controlled bucket, leading to information disclosure, denial of service, or even remote code execution.",
"RelatedUrl": "https://www.aquasec.com/blog/bucket-monopoly-breaching-aws-accounts-through-shadow-resources/",
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "Manually verify the ownership of any flagged S3 buckets. If a bucket is not owned by your account, investigate its origin and purpose. If it is not a legitimate resource, you should avoid using services that may interact with it.",
"Terraform": ""
},
"Recommendation": {
"Text": "Ensure that all S3 buckets associated with your AWS account are owned by your account. Be cautious of services that create buckets with predictable names. Whenever possible, pre-create these buckets in all regions to prevent hijacking.",
"Url": "https://www.aquasec.com/blog/bucket-monopoly-breaching-aws-accounts-through-shadow-resources/"
}
},
"Categories": [
"trustboundaries"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This check is based on the 'Bucket Monopoly' vulnerability disclosed by Aqua Security."
}

View File

@@ -0,0 +1,96 @@
import re
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.lib.logger import logger
from prowler.providers.aws.services.s3.s3_client import s3_client
class s3_bucket_shadow_resource_vulnerability(Check):
def execute(self):
findings = []
# Predictable bucket name patterns from the research article
# These patterns are used by AWS services and can be claimed by attackers
predictable_patterns = {
"Glue": f"aws-glue-assets-{s3_client.provider.identity.account}-<region>",
"SageMaker": f"sagemaker-<region>-{s3_client.provider.identity.account}",
# "CloudFormation": "cf-templates-.*-<region>",
"EMR": f"aws-emr-studio-{s3_client.provider.identity.account}-<region>",
"CodeStar": f"aws-codestar-<region>-{s3_client.provider.identity.account}",
# Add other patterns here as they are discovered
}
# Track buckets we've already reported to avoid duplicates
reported_buckets = set()
# First, check buckets in the current account
for bucket in s3_client.buckets.values():
report = Check_Report_AWS(self.metadata(), resource=bucket)
report.region = bucket.region
report.resource_id = bucket.name
report.resource_arn = bucket.arn
report.resource_tags = bucket.tags
report.status = "PASS"
report.status_extended = (
f"S3 bucket {bucket.name} is not a known shadow resource."
)
# Check if this bucket matches any predictable pattern
for service, pattern_format in predictable_patterns.items():
pattern = pattern_format.replace("<region>", bucket.region)
if re.match(pattern, bucket.name):
if bucket.owner_id != s3_client.audited_canonical_id:
report.status = "FAIL"
report.status_extended = f"S3 bucket {bucket.name} for service {service} is a known shadow resource and it is owned by another account ({bucket.owner_id})."
else:
report.status = "PASS"
report.status_extended = f"S3 bucket {bucket.name} for service {service} is a known shadow resource but it is correctly owned by the audited account."
break
findings.append(report)
reported_buckets.add(bucket.name)
# Now check for shadow resources in other accounts by testing predictable patterns
# We'll test different regions to see if shadow resources exist
regions_to_test = (
s3_client.provider.identity.audited_regions
or s3_client.regional_clients.keys()
)
for region in regions_to_test:
for service, pattern_format in predictable_patterns.items():
# Generate bucket name for this region
bucket_name = pattern_format.replace("<region>", region)
# Skip if we've already reported this bucket
if bucket_name in reported_buckets:
continue
logger.info(
f"Checking if shadow resource bucket {bucket_name} exists in other accounts"
)
# Check if this bucket exists in another account
if s3_client._head_bucket(bucket_name):
# Create a virtual bucket object for reporting
virtual_bucket = type(
"obj",
(object,),
{
"name": bucket_name,
"region": region,
"arn": f"arn:{s3_client.audited_partition}:s3:::{bucket_name}",
"tags": [],
},
)()
report = Check_Report_AWS(self.metadata(), resource=virtual_bucket)
report.region = region
report.resource_id = bucket_name
report.resource_arn = virtual_bucket.arn
report.resource_tags = []
report.status = "FAIL"
report.status_extended = f"S3 bucket {bucket_name} for service {service} is a known shadow resource that exists and is owned by another account."
findings.append(report)
reported_buckets.add(bucket_name)
return findings

View File

@@ -16,6 +16,7 @@ class S3(AWSService):
self.account_arn_template = f"arn:{self.audited_partition}:s3:{self.region}:{self.audited_account}:account"
self.regions_with_buckets = []
self.buckets = {}
self.audited_canonical_id = ""
self._list_buckets(provider)
self.__threading_call__(self._get_bucket_versioning, self.buckets.values())
self.__threading_call__(self._get_bucket_logging, self.buckets.values())
@@ -40,6 +41,7 @@ class S3(AWSService):
logger.info("S3 - Listing buckets...")
try:
list_buckets = self.client.list_buckets()
self.audited_canonical_id = list_buckets["Owner"]["ID"]
for bucket in list_buckets["Buckets"]:
try:
bucket_region = self.client.get_bucket_location(
@@ -237,9 +239,10 @@ class S3(AWSService):
logger.info("S3 - Get buckets acl...")
try:
regional_client = self.regional_clients[bucket.region]
acl = regional_client.get_bucket_acl(Bucket=bucket.name)
bucket.owner_id = acl["Owner"]["ID"]
grantees = []
acl_grants = regional_client.get_bucket_acl(Bucket=bucket.name)["Grants"]
for grant in acl_grants:
for grant in acl["Grants"]:
grantee = ACL_Grantee(type=grant["Grantee"]["Type"])
if "DisplayName" in grant["Grantee"]:
grantee.display_name = grant["Grantee"]["DisplayName"]
@@ -683,6 +686,8 @@ class ReplicationRule(BaseModel):
class Bucket(BaseModel):
arn: str
name: str
owner_id: Optional[str]
owner: Optional[str]
versioning: bool = False
logging: bool = False
public_access_block: Optional[PublicAccessBlock]

View File

@@ -894,9 +894,10 @@ class AzureProvider(Provider):
client = GraphServiceClient(credentials=credentials)
domain_result = await client.domains.get()
if getattr(domain_result, "value"):
if getattr(domain_result.value[0], "id"):
identity.tenant_domain = domain_result.value[0].id
for domain in getattr(domain_result, "value", []):
if getattr(domain, "is_default"):
identity.tenant_domain = domain.id
break
except HttpResponseError as error:
logger.error(

View File

@@ -286,7 +286,7 @@ class M365PowerShell(PowerShellSession):
return True
else:
logger.error(
"Microsoft Teams connection failed: Please check your permissions and try again."
"Microsoft Teams User Auth connection failed: Please check your permissions and try again."
)
return connection
# Application Auth
@@ -398,7 +398,7 @@ class M365PowerShell(PowerShellSession):
return True
else:
logger.error(
"Exchange Online connection failed: Please check your permissions and try again."
"Exchange Online User Auth connection failed: Please check your permissions and try again."
)
return False
# Application Auth

View File

@@ -928,11 +928,10 @@ class M365Provider(Provider):
client = GraphServiceClient(credentials=session)
domain_result = await client.domains.get()
if getattr(domain_result, "value"):
if getattr(domain_result.value[0], "id"):
identity.tenant_domain = domain_result.value[0].id
for domain in domain_result.value:
identity.tenant_domains.append(domain.id)
for domain in getattr(domain_result, "value", []):
identity.tenant_domains.append(domain.id)
if getattr(domain, "is_default", None):
identity.tenant_domain = domain.id
except HttpResponseError as error:
logger.error(

View File

@@ -2008,7 +2008,12 @@ aws:
).isoformat(),
}
assert aws_provider.refresh_credentials() == refreshed_credentials
assert (
AwsProvider.refresh_credentials(
aws_provider._assumed_role_configuration, aws_provider._session
)
== refreshed_credentials
)
@mock_aws
def test_refresh_credentials_after_expiration(self):
@@ -2025,7 +2030,9 @@ aws:
current_credentials = aws_provider._assumed_role_configuration.credentials
# Refresh credentials
refreshed_credentials = aws_provider.refresh_credentials()
refreshed_credentials = AwsProvider.refresh_credentials(
aws_provider._assumed_role_configuration, aws_provider._session
)
# Assert that the refreshed credentials are different
access_key = refreshed_credentials.get("access_key")

View File

@@ -413,5 +413,16 @@ class TestS3:
)
assert (
str(e.value)
== "If a session duration, an external ID, or a role session name is provided, a role ARN is required."
== "If no role ARN is provided, a profile, an AWS access key ID, or an AWS secret access key is required."
)
@mock_aws
def test_init_without_session_and_role_arn_but_profile(self):
with pytest.raises(ValueError) as e:
S3(
session=None,
bucket_name=S3_BUCKET_NAME,
output_directory=CURRENT_DIRECTORY,
external_id="1234567890",
)
assert str(e.value) == "If an external ID is provided, a role ARN is required."

View File

@@ -564,5 +564,5 @@ class TestSecurityHub:
assert (
str(e.value)
== "If a session duration, an external ID, or a role session name is provided, a role ARN is required."
== "If no role ARN is provided, a profile, an AWS access key ID, or an AWS secret access key is required."
)

View File

@@ -0,0 +1,210 @@
from unittest import mock
from moto import mock_aws
from prowler.providers.aws.services.s3.s3_service import Bucket
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
class Test_s3_bucket_shadow_resource_vulnerability:
@mock_aws
def test_no_buckets(self):
s3_client = mock.MagicMock
s3_client.buckets = {}
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
aws_provider.identity.identity_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:root"
s3_client = mock.MagicMock
s3_client.provider = aws_provider
s3_client._head_bucket = mock.MagicMock(return_value=False)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.s3.s3_bucket_shadow_resource_vulnerability.s3_bucket_shadow_resource_vulnerability.s3_client",
new=s3_client,
),
):
from prowler.providers.aws.services.s3.s3_bucket_shadow_resource_vulnerability.s3_bucket_shadow_resource_vulnerability import (
s3_bucket_shadow_resource_vulnerability,
)
check = s3_bucket_shadow_resource_vulnerability()
result = check.execute()
assert len(result) == 0
@mock_aws
def test_bucket_owned_by_account(self):
s3_client = mock.MagicMock
bucket_name = f"sagemaker-{AWS_REGION_US_EAST_1}-{AWS_ACCOUNT_NUMBER}"
s3_client.audited_account_id = AWS_ACCOUNT_NUMBER
s3_client.audited_identity_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:root"
s3_client.audited_canonical_id = AWS_ACCOUNT_NUMBER
s3_client.buckets = {
bucket_name: Bucket(
name=bucket_name,
arn=f"arn:aws:s3:::{bucket_name}",
region=AWS_REGION_US_EAST_1,
owner_id=AWS_ACCOUNT_NUMBER,
)
}
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
aws_provider.identity.identity_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:root"
s3_client = mock.MagicMock
s3_client.provider = aws_provider
s3_client._head_bucket = mock.MagicMock(return_value=False)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.s3.s3_bucket_shadow_resource_vulnerability.s3_bucket_shadow_resource_vulnerability.s3_client",
new=s3_client,
),
):
from prowler.providers.aws.services.s3.s3_bucket_shadow_resource_vulnerability.s3_bucket_shadow_resource_vulnerability import (
s3_bucket_shadow_resource_vulnerability,
)
check = s3_bucket_shadow_resource_vulnerability()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
"is correctly owned by the audited account" in result[0].status_extended
)
@mock_aws
def test_bucket_not_predictable(self):
s3_client = mock.MagicMock
bucket_name = "my-non-predictable-bucket"
s3_client.audited_account_id = AWS_ACCOUNT_NUMBER
s3_client.audited_identity_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:root"
s3_client.audited_canonical_id = AWS_ACCOUNT_NUMBER
s3_client.buckets = {
bucket_name: Bucket(
name=bucket_name,
arn=f"arn:aws:s3:::{bucket_name}",
region=AWS_REGION_US_EAST_1,
owner_id=AWS_ACCOUNT_NUMBER,
)
}
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
aws_provider.identity.identity_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:root"
s3_client = mock.MagicMock
s3_client.provider = aws_provider
s3_client._head_bucket = mock.MagicMock(return_value=False)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.s3.s3_bucket_shadow_resource_vulnerability.s3_bucket_shadow_resource_vulnerability.s3_client",
new=s3_client,
),
):
from prowler.providers.aws.services.s3.s3_bucket_shadow_resource_vulnerability.s3_bucket_shadow_resource_vulnerability import (
s3_bucket_shadow_resource_vulnerability,
)
check = s3_bucket_shadow_resource_vulnerability()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "is not a known shadow resource" in result[0].status_extended
@mock_aws
def test_shadow_resource_in_other_account(self):
# Mock S3 client with no buckets in current account
s3_client = mock.MagicMock()
s3_client.buckets = {}
s3_client.audited_account_id = AWS_ACCOUNT_NUMBER
s3_client.audited_identity_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:root"
s3_client.audited_canonical_id = AWS_ACCOUNT_NUMBER
s3_client.audited_partition = "aws"
# Mock regional clients - this is what the check uses to determine regions to test
s3_client.regional_clients = {
"us-east-1": mock.MagicMock(),
"us-west-2": mock.MagicMock(),
"eu-west-1": mock.MagicMock(),
}
# Define the shadow resources we want to simulate
shadow_resources = [
f"aws-glue-assets-{AWS_ACCOUNT_NUMBER}-us-west-2",
f"sagemaker-us-east-1-{AWS_ACCOUNT_NUMBER}",
f"aws-emr-studio-{AWS_ACCOUNT_NUMBER}-eu-west-1",
]
# Mock the _head_bucket method to simulate finding shadow resources
def mock_head_bucket(bucket_name):
return bucket_name in shadow_resources
s3_client._head_bucket = mock_head_bucket
# Mock provider with multiple regions to test
aws_provider = set_mocked_aws_provider(["us-east-1", "us-west-2", "eu-west-1"])
aws_provider.identity.identity_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:root"
aws_provider.identity.account = AWS_ACCOUNT_NUMBER
s3_client.provider = aws_provider
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.s3.s3_bucket_shadow_resource_vulnerability.s3_bucket_shadow_resource_vulnerability.s3_client",
new=s3_client,
),
):
from prowler.providers.aws.services.s3.s3_bucket_shadow_resource_vulnerability.s3_bucket_shadow_resource_vulnerability import (
s3_bucket_shadow_resource_vulnerability,
)
check = s3_bucket_shadow_resource_vulnerability()
result = check.execute()
# Should find shadow resources
assert len(result) >= 3
# Check if we found all expected shadow resources
found_services = set()
for finding in result:
if (
finding.status == "FAIL"
and "shadow resource" in finding.status_extended
):
if (
"aws-glue-assets" in finding.status_extended
and "Glue" in finding.status_extended
):
found_services.add("Glue")
assert "us-west-2" in finding.status_extended
elif (
"sagemaker" in finding.status_extended
and "SageMaker" in finding.status_extended
):
found_services.add("SageMaker")
assert "us-east-1" in finding.status_extended
elif (
"aws-emr-studio" in finding.status_extended
and "EMR" in finding.status_extended
):
found_services.add("EMR")
assert "eu-west-1" in finding.status_extended
# Verify common attributes
assert "owned by another account" in finding.status_extended
# Verify we found all expected services
expected_services = {"Glue", "SageMaker", "EMR"}
assert found_services == expected_services

View File

@@ -7,9 +7,18 @@ All notable changes to the **Prowler UI** are documented in this file.
### Added
- Lighthouse banner [(#8259)](https://github.com/prowler-cloud/prowler/pull/8259)
- Integration with Amazon S3, enabling storage and retrieval of scan data via S3 buckets [(#8056)](https://github.com/prowler-cloud/prowler/pull/8056)
___
- Amazon AWS S3 integration [(#8391)](https://github.com/prowler-cloud/prowler/pull/8391)
- Github provider support [(#8405)](https://github.com/prowler-cloud/prowler/pull/8405)
- XML validation for SAML metadata in the UI [(#8429)](https://github.com/prowler-cloud/prowler/pull/8429)
- Mutelist menu item under Configuration [(#8440)](https://github.com/prowler-cloud/prowler/pull/8440)
### 🔄 Changed
- Rename `Memberships` to `Organization` in the sidebar [(#8415)](https://github.com/prowler-cloud/prowler/pull/8415)
- Removed `Browse all resources` from the sidebar, sidebar now shows a single `Resources` entry [(#8418)](https://github.com/prowler-cloud/prowler/pull/8418)
- Removed `Misconfigurations` from the `Top Failed Findings` section in the sidebar [(#8426)](https://github.com/prowler-cloud/prowler/pull/8426)
---
## [v1.9.3] (Prowler v5.9.3)

View File

@@ -1 +1,15 @@
export * from "./saml";
export {
createIntegration,
deleteIntegration,
getIntegration,
getIntegrations,
testIntegrationConnection,
updateIntegration,
} from "./integrations";
export {
createSamlConfig,
deleteSamlConfig,
getSamlConfig,
initiateSamlAuth,
updateSamlConfig,
} from "./saml";

View File

@@ -0,0 +1,316 @@
"use server";
import { revalidatePath } from "next/cache";
import {
apiBaseUrl,
getAuthHeaders,
handleApiError,
parseStringify,
} from "@/lib";
import { getTask } from "../task";
export const getIntegrations = async (searchParams?: URLSearchParams) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/integrations`);
if (searchParams) {
searchParams.forEach((value, key) => {
url.searchParams.append(key, value);
});
}
try {
const response = await fetch(url.toString(), { method: "GET", headers });
if (response.ok) {
const data = await response.json();
return parseStringify(data);
}
console.error(`Failed to fetch integrations: ${response.statusText}`);
return { data: [], meta: { pagination: { count: 0 } } };
} catch (error) {
console.error("Error fetching integrations:", error);
return { data: [], meta: { pagination: { count: 0 } } };
}
};
export const getIntegration = async (id: string) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
try {
const response = await fetch(url.toString(), { method: "GET", headers });
if (response.ok) {
const data = await response.json();
return parseStringify(data);
}
console.error(`Failed to fetch integration: ${response.statusText}`);
return null;
} catch (error) {
console.error("Error fetching integration:", error);
return null;
}
};
export const createIntegration = async (
formData: FormData,
): Promise<{ success: string; testConnection?: any } | { error: string }> => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations`);
try {
const integration_type = formData.get("integration_type") as string;
const configuration = JSON.parse(formData.get("configuration") as string);
const credentials = JSON.parse(formData.get("credentials") as string);
const providers = JSON.parse(formData.get("providers") as string);
const integrationData = {
data: {
type: "integrations",
attributes: { integration_type, configuration, credentials },
relationships: {
providers: {
data: providers.map((providerId: string) => ({
id: providerId,
type: "providers",
})),
},
},
},
};
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify(integrationData),
});
if (response.ok) {
const responseData = await response.json();
const integrationId = responseData.data.id;
const testResult = await testIntegrationConnection(integrationId);
return {
success: "Integration created successfully!",
testConnection: testResult,
};
}
const errorData = await response.json().catch(() => ({}));
const errorMessage =
errorData.errors?.[0]?.detail ||
`Unable to create S3 integration: ${response.statusText}`;
return { error: errorMessage };
} catch (error) {
return handleApiError(error);
}
};
export const updateIntegration = async (id: string, formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
try {
const integration_type = formData.get("integration_type") as string;
const configuration = formData.get("configuration")
? JSON.parse(formData.get("configuration") as string)
: undefined;
const credentials = formData.get("credentials")
? JSON.parse(formData.get("credentials") as string)
: undefined;
const providers = formData.get("providers")
? JSON.parse(formData.get("providers") as string)
: undefined;
const enabled = formData.get("enabled")
? JSON.parse(formData.get("enabled") as string)
: undefined;
const integrationData: any = {
data: {
type: "integrations",
id,
attributes: { integration_type },
},
};
if (configuration) {
integrationData.data.attributes.configuration = configuration;
}
if (credentials) {
integrationData.data.attributes.credentials = credentials;
}
if (enabled !== undefined) {
integrationData.data.attributes.enabled = enabled;
}
if (providers) {
integrationData.data.relationships = {
providers: {
data: providers.map((providerId: string) => ({
id: providerId,
type: "providers",
})),
},
};
}
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify(integrationData),
});
if (response.ok) {
revalidatePath("/integrations/s3");
// Only test connection if credentials or configuration were updated
if (credentials || configuration) {
const testResult = await testIntegrationConnection(id);
return {
success: "Integration updated successfully!",
testConnection: testResult,
};
} else {
return {
success: "Integration updated successfully!",
};
}
}
const errorData = await response.json().catch(() => ({}));
const errorMessage =
errorData.errors?.[0]?.detail ||
`Unable to update S3 integration: ${response.statusText}`;
return { error: errorMessage };
} catch (error) {
return handleApiError(error);
}
};
export const deleteIntegration = async (id: string) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
try {
const response = await fetch(url.toString(), { method: "DELETE", headers });
if (response.ok) {
revalidatePath("/integrations/s3");
return { success: "Integration deleted successfully!" };
}
const errorData = await response.json().catch(() => ({}));
const errorMessage =
errorData.errors?.[0]?.detail ||
`Unable to delete S3 integration: ${response.statusText}`;
return { error: errorMessage };
} catch (error) {
return handleApiError(error);
}
};
const pollTaskUntilComplete = async (taskId: string): Promise<any> => {
const maxAttempts = 10;
let attempts = 0;
while (attempts < maxAttempts) {
try {
const taskResponse = await getTask(taskId);
if (taskResponse.error) {
return { error: taskResponse.error };
}
const task = taskResponse.data;
const taskState = task?.attributes?.state;
// Continue polling while task is executing
if (taskState === "executing") {
await new Promise((resolve) => setTimeout(resolve, 3000));
attempts++;
continue;
}
const result = task?.attributes?.result;
const isSuccessful =
taskState === "completed" &&
result?.connected === true &&
result?.error === null;
let message;
if (isSuccessful) {
message = "Connection test completed successfully.";
} else {
message = result?.error || "Connection test failed.";
}
return {
success: isSuccessful,
message,
taskState,
result,
};
} catch (error) {
return { error: "Failed to monitor connection test." };
}
}
return { error: "Connection test timeout. Test took too long to complete." };
};
export const testIntegrationConnection = async (id: string) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/${id}/connection`);
try {
const response = await fetch(url.toString(), { method: "POST", headers });
if (response.ok) {
const data = await response.json();
const taskId = data?.data?.id;
if (taskId) {
// Poll the task until completion
const pollResult = await pollTaskUntilComplete(taskId);
revalidatePath("/integrations/s3");
if (pollResult.error) {
return { error: pollResult.error };
}
if (pollResult.success) {
return {
success: "Connection test completed successfully!",
message: pollResult.message,
data: parseStringify(data),
};
} else {
return {
error: pollResult.message || "Connection test failed.",
};
}
} else {
return {
error: "Failed to start connection test. No task ID received.",
};
}
}
const errorData = await response.json().catch(() => ({}));
const errorMessage =
errorData.errors?.[0]?.detail ||
`Unable to test S3 integration connection: ${response.statusText}`;
return { error: errorMessage };
} catch (error) {
return handleApiError(error);
}
};

View File

@@ -8,14 +8,12 @@ import {
getAuthHeaders,
getErrorMessage,
getFormValue,
handleApiError,
handleApiResponse,
parseStringify,
wait,
} from "@/lib";
import {
buildSecretConfig,
handleApiError,
handleApiResponse,
} from "@/lib/provider-credentials/build-crendentials";
import { buildSecretConfig } from "@/lib/provider-credentials/build-crendentials";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { ProvidersApiResponse, ProviderType } from "@/types/providers";

View File

@@ -120,8 +120,7 @@ export const addRole = async (formData: FormData) => {
manage_providers: formData.get("manage_providers") === "true",
manage_scans: formData.get("manage_scans") === "true",
manage_account: formData.get("manage_account") === "true",
// TODO: Add back when we have integrations ready
// manage_integrations: formData.get("manage_integrations") === "true",
manage_integrations: formData.get("manage_integrations") === "true",
unlimited_visibility: formData.get("unlimited_visibility") === "true",
},
relationships: {},

View File

@@ -1,11 +1,27 @@
import React from "react";
import { getIntegrations } from "@/actions/integrations";
import { S3IntegrationCard } from "@/components/integrations";
import { ContentLayout } from "@/components/ui";
export default function Integrations() {
export default async function Integrations() {
const integrations = await getIntegrations();
return (
<ContentLayout title="Integrations" icon="tabler:puzzle">
<p>Integrations</p>
<ContentLayout title="Integrations" icon="lucide:puzzle">
<div className="space-y-6">
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-300">
Connect external services to enhance your security workflow and
automatically export your scan results.
</p>
</div>
<div className="grid gap-6">
{/* Amazon S3 Integration */}
<S3IntegrationCard integrations={integrations?.data || []} />
</div>
</div>
</ContentLayout>
);
}

View File

@@ -0,0 +1,60 @@
import React from "react";
import { getIntegrations } from "@/actions/integrations";
import { getProviders } from "@/actions/providers";
import { S3IntegrationsManager } from "@/components/integrations/s3/s3-integrations-manager";
import { ContentLayout } from "@/components/ui";
export default async function S3Integrations() {
const [integrations, providers] = await Promise.all([
getIntegrations(
new URLSearchParams({ "filter[integration_type]": "amazon_s3" }),
),
getProviders({ pageSize: 100 }),
]);
const s3Integrations = integrations?.data || [];
const availableProviders = providers?.data || [];
return (
<ContentLayout title="Amazon S3">
<div className="space-y-6">
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-300">
Configure Amazon S3 integration to automatically export your scan
results to S3 buckets.
</p>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800">
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">
Features:
</h3>
<ul className="grid grid-cols-1 gap-2 text-sm text-gray-600 dark:text-gray-300 md:grid-cols-2">
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Automated scan result exports
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Multi-Cloud support
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Configurable export paths
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
IAM role and static credentials
</li>
</ul>
</div>
</div>
<S3IntegrationsManager
integrations={s3Integrations}
providers={availableProviders}
/>
</div>
</ContentLayout>
);
}

View File

@@ -4,7 +4,7 @@ import { getSamlConfig } from "@/actions/integrations/saml";
import { getAllTenants } from "@/actions/users/tenants";
import { getUserInfo } from "@/actions/users/users";
import { getUserMemberships } from "@/actions/users/users";
import { SamlIntegrationCard } from "@/components/integrations/saml-integration-card";
import { SamlIntegrationCard } from "@/components/integrations/saml/saml-integration-card";
import { ContentLayout } from "@/components/ui";
import { UserBasicInfoCard } from "@/components/users/profile";
import { MembershipsCard } from "@/components/users/profile/memberships-card";

View File

@@ -9,6 +9,8 @@ import {
AddViaServiceAccountForm,
SelectViaGCP,
} from "@/components/providers/workflow/forms/select-credentials-type/gcp";
import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github";
import { getProviderFormType } from "@/lib/provider-helpers";
import { ProviderType } from "@/types/providers";
interface Props {
@@ -16,30 +18,27 @@ interface Props {
}
export default function AddCredentialsPage({ searchParams }: Props) {
return (
<>
{searchParams.type === "aws" && !searchParams.via && (
<SelectViaAWS initialVia={searchParams.via} />
)}
const { type: providerType, via } = searchParams;
const formType = getProviderFormType(providerType, via);
{searchParams.type === "gcp" && !searchParams.via && (
<SelectViaGCP initialVia={searchParams.via} />
)}
switch (formType) {
case "selector":
if (providerType === "aws") return <SelectViaAWS initialVia={via} />;
if (providerType === "gcp") return <SelectViaGCP initialVia={via} />;
if (providerType === "github")
return <SelectViaGitHub initialVia={via} />;
return null;
{((searchParams.type === "aws" && searchParams.via === "credentials") ||
(searchParams.type === "gcp" && searchParams.via === "credentials") ||
(searchParams.type !== "aws" && searchParams.type !== "gcp")) && (
<AddViaCredentialsForm searchParams={searchParams} />
)}
case "credentials":
return <AddViaCredentialsForm searchParams={searchParams} />;
{searchParams.type === "aws" && searchParams.via === "role" && (
<AddViaRoleForm searchParams={searchParams} />
)}
case "role":
return <AddViaRoleForm searchParams={searchParams} />;
{searchParams.type === "gcp" &&
searchParams.via === "service-account" && (
<AddViaServiceAccountForm searchParams={searchParams} />
)}
</>
);
case "service-account":
return <AddViaServiceAccountForm searchParams={searchParams} />;
default:
return null;
}
}

View File

@@ -6,6 +6,7 @@ import {
UpdateViaRoleForm,
} from "@/components/providers/workflow/forms";
import { UpdateViaServiceAccountForm } from "@/components/providers/workflow/forms/update-via-service-account-key-form";
import { getProviderFormType } from "@/lib/provider-helpers";
import { ProviderType } from "@/types/providers";
interface Props {
@@ -18,30 +19,25 @@ interface Props {
}
export default function UpdateCredentialsPage({ searchParams }: Props) {
return (
<>
{(searchParams.type === "aws" || searchParams.type === "gcp") &&
!searchParams.via && (
<CredentialsUpdateInfo
providerType={searchParams.type}
initialVia={searchParams.via}
/>
)}
const { type: providerType, via } = searchParams;
const formType = getProviderFormType(providerType, via);
{((searchParams.type === "aws" && searchParams.via === "credentials") ||
(searchParams.type === "gcp" && searchParams.via === "credentials") ||
(searchParams.type !== "aws" && searchParams.type !== "gcp")) && (
<UpdateViaCredentialsForm searchParams={searchParams} />
)}
switch (formType) {
case "selector":
return (
<CredentialsUpdateInfo providerType={providerType} initialVia={via} />
);
{searchParams.type === "aws" && searchParams.via === "role" && (
<UpdateViaRoleForm searchParams={searchParams} />
)}
case "credentials":
return <UpdateViaCredentialsForm searchParams={searchParams} />;
{searchParams.type === "gcp" &&
searchParams.via === "service-account" && (
<UpdateViaServiceAccountForm searchParams={searchParams} />
)}
</>
);
case "role":
return <UpdateViaRoleForm searchParams={searchParams} />;
case "service-account":
return <UpdateViaServiceAccountForm searchParams={searchParams} />;
default:
return null;
}
}

View File

@@ -1,4 +1,5 @@
import { Spacer } from "@nextui-org/react";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { getProviders } from "@/actions/providers";
@@ -23,19 +24,27 @@ export default async function Providers({
}) {
const searchParamsKey = JSON.stringify(searchParams || {});
const providersData = await getProviders({});
const hasProviders = providersData?.data && providersData.data.length > 0;
// If modal=mutelist is present but no providers, redirect
if (searchParams.modal === "mutelist" && !hasProviders) {
redirect("/providers");
}
return (
<ContentLayout title="Cloud Providers" icon="fluent:cloud-sync-24-regular">
<FilterControls search customFilters={filterProviders || []} />
<Spacer y={8} />
<div className="flex items-center gap-4 md:justify-end">
<ManageGroupsButton />
<MutedFindingsConfigButton isDisabled={!hasProviders} />
<AddProviderButton />
</div>
<Suspense
key={searchParamsKey}
fallback={
<>
<div className="flex items-center gap-4 md:justify-end">
<ManageGroupsButton />
<MutedFindingsConfigButton isDisabled={true} />
<AddProviderButton />
</div>
<Spacer y={8} />
<div className="grid grid-cols-12 gap-4">
<div className="col-span-12">
@@ -76,8 +85,6 @@ const ProvidersContent = async ({
pageSize,
});
const hasProviders = providersData?.data && providersData.data.length > 0;
const providerGroupDict =
providersData?.included
?.filter((item: any) => item.type === "provider-groups")
@@ -98,11 +105,6 @@ const ProvidersContent = async ({
return (
<>
<div className="flex items-center gap-4 md:justify-end">
<ManageGroupsButton />
<MutedFindingsConfigButton isDisabled={!hasProviders} />
<AddProviderButton />
</div>
<Spacer y={8} />
<div className="grid grid-cols-12 gap-4">

View File

@@ -4,6 +4,7 @@ import {
AWSProviderBadge,
AzureProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
} from "../icons/providers-badge";
@@ -52,3 +53,12 @@ export const CustomProviderInputKubernetes = () => {
</div>
);
};
export const CustomProviderInputGitHub = () => {
return (
<div className="flex items-center gap-x-2">
<GitHubProviderBadge width={25} height={25} />
<p className="text-sm">GitHub</p>
</div>
);
};

View File

@@ -4,41 +4,52 @@ import { Select, SelectItem } from "@nextui-org/react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useCallback, useMemo } from "react";
import { PROVIDER_TYPES, ProviderType } from "@/types/providers";
import {
CustomProviderInputAWS,
CustomProviderInputAzure,
CustomProviderInputGCP,
CustomProviderInputGitHub,
CustomProviderInputKubernetes,
CustomProviderInputM365,
} from "./custom-provider-inputs";
const dataInputsProvider = [
{
key: "aws",
const providerDisplayData: Record<
ProviderType,
{ label: string; component: React.ReactElement }
> = {
aws: {
label: "Amazon Web Services",
value: <CustomProviderInputAWS />,
component: <CustomProviderInputAWS />,
},
{
key: "gcp",
gcp: {
label: "Google Cloud Platform",
value: <CustomProviderInputGCP />,
component: <CustomProviderInputGCP />,
},
{
key: "azure",
azure: {
label: "Microsoft Azure",
value: <CustomProviderInputAzure />,
component: <CustomProviderInputAzure />,
},
{
key: "m365",
m365: {
label: "Microsoft 365",
value: <CustomProviderInputM365 />,
component: <CustomProviderInputM365 />,
},
{
key: "kubernetes",
kubernetes: {
label: "Kubernetes",
value: <CustomProviderInputKubernetes />,
component: <CustomProviderInputKubernetes />,
},
];
github: {
label: "GitHub",
component: <CustomProviderInputGitHub />,
},
};
const dataInputsProvider = PROVIDER_TYPES.map((providerType) => ({
key: providerType,
label: providerDisplayData[providerType].label,
value: providerDisplayData[providerType].component,
}));
export const CustomSelectProvider: React.FC = () => {
const router = useRouter();

View File

@@ -1,4 +1,5 @@
import { FilterType } from "@/types/filters";
import { PROVIDER_TYPES } from "@/types/providers";
export const filterProviders = [
{
@@ -13,7 +14,7 @@ export const filterScans = [
{
key: "provider_type__in",
labelCheckboxGroup: "Cloud Provider",
values: ["aws", "azure", "m365", "gcp", "kubernetes"],
values: [...PROVIDER_TYPES],
index: 0,
},
{
@@ -55,7 +56,7 @@ export const filterFindings = [
{
key: FilterType.PROVIDER_TYPE,
labelCheckboxGroup: "Cloud Provider",
values: ["aws", "azure", "m365", "gcp", "kubernetes"],
values: [...PROVIDER_TYPES],
index: 5,
},
{

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import { IconSvgProps } from "@/types";
export const GitHubProviderBadge: React.FC<IconSvgProps> = ({
size,
width,
height,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
fill="none"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 98 96"
width={size || width}
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"
fill="currentColor"
/>
</svg>
);

View File

@@ -1,5 +1,6 @@
export * from "./aws-provider-badge";
export * from "./azure-provider-badge";
export * from "./gcp-provider-badge";
export * from "./github-provider-badge";
export * from "./ks8-provider-badge";
export * from "./m365-provider-badge";

View File

@@ -1 +0,0 @@
export * from "./saml-config-form";

View File

@@ -1,2 +1,7 @@
export * from "./forms";
export * from "./saml-integration-card";
export * from "../providers/provider-selector";
export * from "./s3/s3-integration-card";
export * from "./s3/s3-integration-form";
export * from "./s3/s3-integrations-manager";
export * from "./s3/skeleton-s3-integration-card";
export * from "./saml/saml-config-form";
export * from "./saml/saml-integration-card";

View File

@@ -0,0 +1,147 @@
"use client";
import { Card, CardBody, CardHeader, Chip } from "@nextui-org/react";
import { SettingsIcon } from "lucide-react";
import { AmazonS3Icon } from "@/components/icons/services/IconServices";
import { CustomButton } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { IntegrationProps } from "@/types/integrations";
import { S3IntegrationCardSkeleton } from "./skeleton-s3-integration-card";
interface S3IntegrationCardProps {
integrations?: IntegrationProps[];
isLoading?: boolean;
}
export const S3IntegrationCard = ({
integrations = [],
isLoading = false,
}: S3IntegrationCardProps) => {
const s3Integrations = integrations.filter(
(integration) => integration.attributes.integration_type === "amazon_s3",
);
const isConfigured = s3Integrations.length > 0;
const connectedCount = s3Integrations.filter(
(integration) => integration.attributes.connected,
).length;
if (isLoading) {
return (
<S3IntegrationCardSkeleton
variant="main"
count={s3Integrations.length || 1}
/>
);
}
return (
<Card className="dark:bg-gray-800">
<CardHeader className="gap-2">
<div className="flex w-full flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<AmazonS3Icon size={40} />
<div className="flex flex-col gap-1">
<h4 className="text-lg font-bold text-gray-900 dark:text-gray-100">
Amazon S3
</h4>
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
<p className="text-nowrap text-xs text-gray-500 dark:text-gray-300">
Export security findings to Amazon S3 buckets.
</p>
<CustomLink
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-s3-integration/"
aria-label="Learn more about S3 integration"
size="xs"
>
Learn more
</CustomLink>
</div>
</div>
</div>
<div className="flex items-center gap-2 self-end sm:self-center">
{isConfigured && (
<Chip
size="sm"
color={connectedCount > 0 ? "success" : "warning"}
variant="flat"
>
{connectedCount} / {s3Integrations.length} connected
</Chip>
)}
<CustomButton
size="sm"
variant="bordered"
startContent={<SettingsIcon size={14} />}
asLink="/integrations/s3"
ariaLabel={
isConfigured
? "Manage S3 integrations"
: "Configure S3 integration"
}
>
{isConfigured ? "Manage" : "Configure"}
</CustomButton>
</div>
</div>
</CardHeader>
<CardBody>
<div className="flex flex-col gap-4">
{isConfigured ? (
<>
<div className="space-y-2">
{s3Integrations.map((integration) => (
<div
key={integration.id}
className="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
>
<div className="flex flex-col">
<span className="text-sm font-medium">
{integration.attributes.configuration.bucket_name ||
"Unknown Bucket"}
</span>
<span className="text-xs text-gray-500 dark:text-gray-300">
Output directory:{" "}
{integration.attributes.configuration
.output_directory ||
integration.attributes.configuration.path ||
"/"}
</span>
</div>
<Chip
size="sm"
color={
integration.attributes.connected ? "success" : "danger"
}
variant="dot"
>
{integration.attributes.connected
? "Connected"
: "Disconnected"}
</Chip>
</div>
))}
</div>
</>
) : (
<>
<div className="text-sm">
<span className="font-medium">Status: </span>
<span className="text-gray-500">Not configured</span>
</div>
<div className="space-y-3">
<p className="text-sm text-gray-600 dark:text-gray-300">
Export your security findings to Amazon S3 buckets
automatically.
</p>
</div>
</>
)}
</div>
</CardBody>
</Card>
);
};

View File

@@ -0,0 +1,414 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Divider } from "@nextui-org/react";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import { ProviderSelector } from "@/components/providers/provider-selector";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
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 { FormButtons } from "@/components/ui/form/form-buttons";
import { getAWSCredentialsTemplateBucketLinks } from "@/lib";
import { AWSCredentialsRole } from "@/types";
import {
editS3IntegrationFormSchema,
IntegrationProps,
s3IntegrationFormSchema,
} from "@/types/integrations";
import { ProviderProps } from "@/types/providers";
interface S3IntegrationFormProps {
integration?: IntegrationProps | null;
providers: ProviderProps[];
onSuccess: () => void;
onCancel: () => void;
editMode?: "configuration" | "credentials" | null; // null means creating new
}
export const S3IntegrationForm = ({
integration,
providers,
onSuccess,
onCancel,
editMode = null,
}: S3IntegrationFormProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const [currentStep, setCurrentStep] = useState(
editMode === "credentials" ? 1 : 0,
);
const isEditing = !!integration;
const isCreating = !isEditing;
const isEditingConfig = editMode === "configuration";
const isEditingCredentials = editMode === "credentials";
// Create the form with updated schema and default values
const form = useForm({
resolver: zodResolver(
// For credentials editing, use creation schema (all fields required)
// For config editing, use edit schema (partial updates allowed)
// For creation, use creation schema
isEditingCredentials || isCreating
? s3IntegrationFormSchema
: editS3IntegrationFormSchema,
),
defaultValues: {
integration_type: "amazon_s3" as const,
bucket_name: integration?.attributes.configuration.bucket_name || "",
output_directory:
integration?.attributes.configuration.output_directory || "",
providers:
integration?.relationships?.providers?.data?.map((p) => p.id) || [],
credentials_type: "access-secret-key" as const,
aws_access_key_id: "",
aws_secret_access_key: "",
aws_session_token: "",
// For credentials editing, show current values as placeholders but require new input
role_arn: isEditingCredentials
? ""
: integration?.attributes.configuration.credentials?.role_arn || "",
// External ID always defaults to tenantId, even when editing credentials
external_id:
integration?.attributes.configuration.credentials?.external_id ||
session?.tenantId ||
"",
role_session_name: "",
session_duration: "",
},
});
const isLoading = form.formState.isSubmitting;
const handleNext = async (e: React.FormEvent) => {
e.preventDefault();
// If we're in single-step edit mode, don't advance
if (isEditingConfig || isEditingCredentials) {
return;
}
// Validate current step fields for creation flow
const stepFields =
currentStep === 0
? (["bucket_name", "output_directory", "providers"] as const)
: // Step 1: No required fields since role_arn and external_id are optional
[];
const isValid = stepFields.length === 0 || (await form.trigger(stepFields));
if (isValid) {
setCurrentStep(1);
}
};
const handleBack = () => {
setCurrentStep(0);
};
// Helper function to build credentials object
const buildCredentials = (values: any) => {
const credentials: any = {};
// Only include role-related fields if role_arn is provided
if (values.role_arn && values.role_arn.trim() !== "") {
credentials.role_arn = values.role_arn;
credentials.external_id = values.external_id;
// Optional role fields
if (values.role_session_name)
credentials.role_session_name = values.role_session_name;
if (values.session_duration)
credentials.session_duration =
parseInt(values.session_duration, 10) || 3600;
}
// Add static credentials if using access-secret-key type
if (values.credentials_type === "access-secret-key") {
credentials.aws_access_key_id = values.aws_access_key_id;
credentials.aws_secret_access_key = values.aws_secret_access_key;
if (values.aws_session_token)
credentials.aws_session_token = values.aws_session_token;
}
return credentials;
};
const buildConfiguration = (values: any, isPartial = false) => {
const configuration: any = {};
// For creation mode, include all fields
if (!isPartial) {
configuration.bucket_name = values.bucket_name;
configuration.output_directory = values.output_directory;
} else {
// For edit mode, only include fields that have actually changed
const originalBucketName =
integration?.attributes.configuration.bucket_name || "";
const originalOutputDirectory =
integration?.attributes.configuration.output_directory || "";
// Only include bucket_name if it has changed
if (values.bucket_name && values.bucket_name !== originalBucketName) {
configuration.bucket_name = values.bucket_name;
}
// Only include output_directory if it has changed
if (
values.output_directory &&
values.output_directory !== originalOutputDirectory
) {
configuration.output_directory = values.output_directory;
}
}
return configuration;
};
// Helper function to build FormData based on edit mode
const buildFormData = (values: any) => {
const formData = new FormData();
formData.append("integration_type", values.integration_type);
if (isEditingConfig) {
const configuration = buildConfiguration(values, true);
if (Object.keys(configuration).length > 0) {
formData.append("configuration", JSON.stringify(configuration));
}
// Always send providers array, even if empty, to update relationships
formData.append("providers", JSON.stringify(values.providers || []));
} else if (isEditingCredentials) {
const credentials = buildCredentials(values);
formData.append("credentials", JSON.stringify(credentials));
} else {
// Creation mode - send everything
const configuration = buildConfiguration(values);
const credentials = buildCredentials(values);
formData.append("configuration", JSON.stringify(configuration));
formData.append("credentials", JSON.stringify(credentials));
formData.append("providers", JSON.stringify(values.providers));
}
return formData;
};
const onSubmit = async (values: any) => {
const formData = buildFormData(values);
try {
let result;
if (isEditing && integration) {
result = await updateIntegration(integration.id, formData);
} else {
result = await createIntegration(formData);
}
if ("success" in result) {
toast({
title: "Success!",
description: `S3 integration ${isEditing ? "updated" : "created"} successfully.`,
});
if ("testConnection" in result) {
if (result.testConnection.success) {
toast({
title: "Connection test started!",
description:
"Connection test started. It may take some time to complete.",
});
} else if (result.testConnection.error) {
toast({
variant: "destructive",
title: "Connection test failed",
description: result.testConnection.error,
});
}
}
onSuccess();
} else if ("error" in result) {
const errorMessage = result.error;
toast({
variant: "destructive",
title: "S3 Integration Error",
description: errorMessage,
});
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "An unexpected error occurred";
toast({
variant: "destructive",
title: "Connection Error",
description: `${errorMessage}. Please check your network connection and try again.`,
});
}
};
const renderStepContent = () => {
// If editing credentials, show only credentials form
if (isEditingCredentials || currentStep === 1) {
const bucketName = form.getValues("bucket_name") || "";
const externalId =
form.getValues("external_id") || session?.tenantId || "";
const templateLinks = getAWSCredentialsTemplateBucketLinks(
bucketName,
externalId,
);
return (
<AWSRoleCredentialsForm
control={form.control as unknown as Control<AWSCredentialsRole>}
setValue={form.setValue as any}
externalId={externalId}
templateLinks={templateLinks}
type="s3-integration"
/>
);
}
// Show configuration step (step 0 or editing configuration)
if (isEditingConfig || currentStep === 0) {
return (
<>
{/* Provider Selection */}
<div className="space-y-4">
<ProviderSelector
control={form.control}
name="providers"
providers={providers}
label="Cloud Providers"
placeholder="Select providers to integrate with"
isInvalid={!!form.formState.errors.providers}
/>
</div>
<Divider />
{/* S3 Configuration */}
<div className="space-y-4">
<CustomInput
control={form.control}
name="bucket_name"
type="text"
label="Bucket name"
labelPlacement="inside"
placeholder="my-security-findings-bucket"
variant="bordered"
isRequired
isInvalid={!!form.formState.errors.bucket_name}
/>
<CustomInput
control={form.control}
name="output_directory"
type="text"
label="Output directory"
labelPlacement="inside"
placeholder="/prowler-findings/"
variant="bordered"
isRequired
isInvalid={!!form.formState.errors.output_directory}
/>
</div>
</>
);
}
return null;
};
const renderStepButtons = () => {
// Single edit mode (configuration or credentials)
if (isEditingConfig || isEditingCredentials) {
const updateText = isEditingConfig
? "Update Configuration"
: "Update Credentials";
const loadingText = isEditingConfig
? "Updating Configuration..."
: "Updating Credentials...";
return (
<FormButtons
setIsOpen={() => {}}
onCancel={onCancel}
submitText={updateText}
cancelText="Cancel"
loadingText={loadingText}
isDisabled={isLoading}
/>
);
}
// Creation flow - step 0
if (currentStep === 0) {
return (
<FormButtons
setIsOpen={() => {}}
onCancel={onCancel}
submitText="Next"
cancelText="Cancel"
loadingText="Processing..."
isDisabled={isLoading}
rightIcon={<ArrowRightIcon size={24} />}
/>
);
}
// Creation flow - step 1 (final step)
return (
<FormButtons
setIsOpen={() => {}}
onCancel={handleBack}
submitText="Create Integration"
cancelText="Back"
loadingText="Creating..."
leftIcon={<ArrowLeftIcon size={24} />}
isDisabled={isLoading}
/>
);
};
return (
<Form {...form}>
<form
onSubmit={
// For edit modes, always submit
isEditingConfig || isEditingCredentials
? form.handleSubmit(onSubmit)
: // For creation flow, handle step logic
currentStep === 0
? handleNext
: form.handleSubmit(onSubmit)
}
className="flex flex-col space-y-6"
>
<div className="flex flex-col space-y-4">
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
<p className="flex items-center gap-2 text-sm text-default-500">
Need help configuring your Amazon S3 integrations?
</p>
<CustomLink
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-s3-integration/"
target="_blank"
size="sm"
>
Read the docs
</CustomLink>
</div>
{renderStepContent()}
</div>
{renderStepButtons()}
</form>
</Form>
);
};

View File

@@ -0,0 +1,398 @@
"use client";
import { Card, CardBody, CardHeader, Chip } from "@nextui-org/react";
import { format } from "date-fns";
import {
PlusIcon,
Power,
SettingsIcon,
TestTube,
Trash2Icon,
} from "lucide-react";
import { useState } from "react";
import {
deleteIntegration,
testIntegrationConnection,
updateIntegration,
} from "@/actions/integrations";
import { AmazonS3Icon } from "@/components/icons/services/IconServices";
import { useToast } from "@/components/ui";
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
import { IntegrationProps } from "@/types/integrations";
import { ProviderProps } from "@/types/providers";
import { S3IntegrationForm } from "./s3-integration-form";
import { S3IntegrationCardSkeleton } from "./skeleton-s3-integration-card";
interface S3IntegrationsManagerProps {
integrations: IntegrationProps[];
providers: ProviderProps[];
}
export const S3IntegrationsManager = ({
integrations,
providers,
}: S3IntegrationsManagerProps) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingIntegration, setEditingIntegration] =
useState<IntegrationProps | null>(null);
const [editMode, setEditMode] = useState<
"configuration" | "credentials" | null
>(null);
const [isDeleting, setIsDeleting] = useState<string | null>(null);
const [isTesting, setIsTesting] = useState<string | null>(null);
const [isOperationLoading, setIsOperationLoading] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [integrationToDelete, setIntegrationToDelete] =
useState<IntegrationProps | null>(null);
const { toast } = useToast();
const handleAddIntegration = () => {
setEditingIntegration(null);
setEditMode(null); // Creation mode
setIsModalOpen(true);
};
const handleEditConfiguration = (integration: IntegrationProps) => {
setEditingIntegration(integration);
setEditMode("configuration");
setIsModalOpen(true);
};
const handleEditCredentials = (integration: IntegrationProps) => {
setEditingIntegration(integration);
setEditMode("credentials");
setIsModalOpen(true);
};
const handleOpenDeleteModal = (integration: IntegrationProps) => {
setIntegrationToDelete(integration);
setIsDeleteOpen(true);
};
const handleDeleteIntegration = async (id: string) => {
setIsDeleting(id);
try {
const result = await deleteIntegration(id);
if (result.success) {
toast({
title: "Success!",
description: "S3 integration deleted successfully.",
});
} else if (result.error) {
toast({
variant: "destructive",
title: "Delete Failed",
description: result.error,
});
}
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to delete S3 integration. Please try again.",
});
} finally {
setIsDeleting(null);
setIsDeleteOpen(false);
setIntegrationToDelete(null);
}
};
const handleTestConnection = async (id: string) => {
setIsTesting(id);
try {
const result = await testIntegrationConnection(id);
if (result.success) {
toast({
title: "Connection test successful!",
description:
result.message || "Connection test completed successfully.",
});
} else if (result.error) {
toast({
variant: "destructive",
title: "Connection test failed",
description: result.error,
});
}
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to test connection. Please try again.",
});
} finally {
setIsTesting(null);
}
};
const handleToggleEnabled = async (integration: IntegrationProps) => {
try {
const newEnabledState = !integration.attributes.enabled;
const formData = new FormData();
formData.append(
"integration_type",
integration.attributes.integration_type,
);
formData.append("enabled", JSON.stringify(newEnabledState));
const result = await updateIntegration(integration.id, formData);
if (result && "success" in result) {
toast({
title: "Success!",
description: `Integration ${newEnabledState ? "enabled" : "disabled"} successfully.`,
});
} else if (result && "error" in result) {
toast({
variant: "destructive",
title: "Toggle Failed",
description: result.error,
});
}
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to toggle integration. Please try again.",
});
}
};
const handleModalClose = () => {
setIsModalOpen(false);
setEditingIntegration(null);
setEditMode(null);
};
const handleFormSuccess = () => {
setIsModalOpen(false);
setEditingIntegration(null);
setEditMode(null);
setIsOperationLoading(true);
// Reset loading state after a short delay to show the skeleton briefly
setTimeout(() => {
setIsOperationLoading(false);
}, 1500);
};
return (
<>
<CustomAlertModal
isOpen={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Delete S3 Integration"
description="This action cannot be undone. This will permanently delete your S3 integration."
>
<div className="flex w-full justify-center space-x-6">
<CustomButton
type="button"
ariaLabel="Cancel"
className="w-full bg-transparent"
variant="faded"
size="lg"
onPress={() => {
setIsDeleteOpen(false);
setIntegrationToDelete(null);
}}
isDisabled={isDeleting !== null}
>
<span>Cancel</span>
</CustomButton>
<CustomButton
type="button"
ariaLabel="Delete"
className="w-full"
variant="solid"
color="danger"
size="lg"
isLoading={isDeleting !== null}
startContent={!isDeleting && <Trash2Icon size={24} />}
onPress={() =>
integrationToDelete &&
handleDeleteIntegration(integrationToDelete.id)
}
>
{isDeleting ? "Deleting..." : "Delete"}
</CustomButton>
</div>
</CustomAlertModal>
<CustomAlertModal
isOpen={isModalOpen}
onOpenChange={setIsModalOpen}
title={
editMode === "configuration"
? "Edit Configuration"
: editMode === "credentials"
? "Edit Credentials"
: editingIntegration
? "Edit S3 Integration"
: "Add S3 Integration"
}
>
<S3IntegrationForm
integration={editingIntegration}
providers={providers}
onSuccess={handleFormSuccess}
onCancel={handleModalClose}
editMode={editMode}
/>
</CustomAlertModal>
<div className="space-y-6">
{/* Header with Add Button */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">
Configured S3 Integrations
</h3>
<p className="text-sm text-gray-500 dark:text-gray-300">
{integrations.length === 0
? "Not configured yet"
: `${integrations.length} integration${integrations.length !== 1 ? "s" : ""} configured`}
</p>
</div>
<CustomButton
color="action"
startContent={<PlusIcon size={16} />}
onPress={handleAddIntegration}
ariaLabel="Add integration"
>
Add Integration
</CustomButton>
</div>
{/* Integrations List */}
{isOperationLoading ? (
<S3IntegrationCardSkeleton
variant="manager"
count={integrations.length || 1}
/>
) : integrations.length > 0 ? (
<div className="grid gap-4">
{integrations.map((integration) => (
<Card key={integration.id} className="dark:bg-gray-800">
<CardHeader className="pb-2">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
<AmazonS3Icon size={32} />
<div>
<h4 className="text-md font-semibold">
{integration.attributes.configuration.bucket_name ||
"Unknown Bucket"}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-300">
Output directory:{" "}
{integration.attributes.configuration
.output_directory ||
integration.attributes.configuration.path ||
"/"}
</p>
</div>
</div>
<Chip
size="sm"
color={
integration.attributes.connected ? "success" : "danger"
}
variant="flat"
>
{integration.attributes.connected
? "Connected"
: "Disconnected"}
</Chip>
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-gray-500 dark:text-gray-300">
{integration.attributes.connection_last_checked_at && (
<p>
<span className="font-medium">Last checked:</span>{" "}
{format(
new Date(
integration.attributes.connection_last_checked_at,
),
"yyyy/MM/dd",
)}
</p>
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<CustomButton
size="sm"
variant="bordered"
startContent={<TestTube size={14} />}
onPress={() => handleTestConnection(integration.id)}
isLoading={isTesting === integration.id}
isDisabled={!integration.attributes.enabled}
ariaLabel="Test connection"
className="w-full sm:w-auto"
>
Test
</CustomButton>
<CustomButton
size="sm"
variant="bordered"
startContent={<SettingsIcon size={14} />}
onPress={() => handleEditConfiguration(integration)}
ariaLabel="Edit configuration"
className="w-full sm:w-auto"
>
Config
</CustomButton>
<CustomButton
size="sm"
variant="bordered"
startContent={<SettingsIcon size={14} />}
onPress={() => handleEditCredentials(integration)}
ariaLabel="Edit credentials"
className="w-full sm:w-auto"
>
Credentials
</CustomButton>
<CustomButton
size="sm"
variant="bordered"
color={
integration.attributes.enabled ? "warning" : "primary"
}
startContent={<Power size={14} />}
onPress={() => handleToggleEnabled(integration)}
ariaLabel={
integration.attributes.enabled
? "Disable integration"
: "Enable integration"
}
className="w-full sm:w-auto"
>
{integration.attributes.enabled ? "Disable" : "Enable"}
</CustomButton>
<CustomButton
size="sm"
color="danger"
variant="bordered"
startContent={<Trash2Icon size={14} />}
onPress={() => handleOpenDeleteModal(integration)}
ariaLabel="Delete integration"
className="w-full sm:w-auto"
>
Delete
</CustomButton>
</div>
</div>
</CardBody>
</Card>
))}
</div>
) : null}
</div>
</>
);
};

View File

@@ -0,0 +1,95 @@
"use client";
import { Card, CardBody, CardHeader, Skeleton } from "@nextui-org/react";
import { AmazonS3Icon } from "@/components/icons/services/IconServices";
interface S3IntegrationCardSkeletonProps {
variant?: "main" | "manager";
count?: number;
}
export const S3IntegrationCardSkeleton = ({
variant = "main",
count = 1,
}: S3IntegrationCardSkeletonProps) => {
if (variant === "main") {
return (
<Card className="dark:bg-prowler-blue-400">
<CardHeader className="gap-2">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
<AmazonS3Icon size={40} />
<div className="flex flex-col gap-1">
<h4 className="text-lg font-bold">Amazon S3</h4>
<div className="flex items-center gap-2">
<p className="text-xs text-gray-500">
Export security findings to Amazon S3 buckets.
</p>
<Skeleton className="h-3 w-16 rounded" />
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-8 w-20 rounded-lg" />
</div>
</div>
</CardHeader>
<CardBody>
<div className="flex flex-col gap-4">
<div className="space-y-2">
{Array.from({ length: count }).map((_, index) => (
<div
key={index}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex flex-col gap-1">
<Skeleton className="h-4 w-32 rounded" />
<Skeleton className="h-3 w-48 rounded" />
</div>
<Skeleton className="h-6 w-20 rounded-full" />
</div>
))}
</div>
</div>
</CardBody>
</Card>
);
}
// Manager variant - for individual cards in S3IntegrationsManager
return (
<div className="grid gap-4">
{Array.from({ length: count }).map((_, index) => (
<Card key={index} className="dark:bg-prowler-blue-400">
<CardHeader className="pb-2">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
<AmazonS3Icon size={32} />
<div className="flex flex-col gap-1">
<Skeleton className="h-5 w-40 rounded" />
<Skeleton className="h-3 w-32 rounded" />
</div>
</div>
<Skeleton className="h-6 w-20 rounded-full" />
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Skeleton className="h-3 w-48 rounded" />
<Skeleton className="h-3 w-36 rounded" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-16 rounded" />
<Skeleton className="h-8 w-16 rounded" />
<Skeleton className="h-8 w-20 rounded" />
</div>
</div>
</CardBody>
</Card>
))}
</div>
);
};

View File

@@ -13,6 +13,96 @@ import { SnippetChip } from "@/components/ui/entities";
import { FormButtons } from "@/components/ui/form";
import { apiBaseUrl } from "@/lib";
const validateXMLContent = (
xmlContent: string,
): { isValid: boolean; error?: string } => {
try {
// Basic checks
if (!xmlContent || !xmlContent.trim()) {
return {
isValid: false,
error: "XML content is empty.",
};
}
const trimmedContent = xmlContent.trim();
// Check if it starts and ends with XML tags
if (!trimmedContent.startsWith("<") || !trimmedContent.endsWith(">")) {
return {
isValid: false,
error: "Content does not appear to be valid XML format.",
};
}
// Use DOMParser to validate XML structure
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlContent, "text/xml");
// Check for parser errors
const parserError = xmlDoc.querySelector("parsererror");
if (parserError) {
const errorText = parserError.textContent || "Unknown XML parsing error";
return {
isValid: false,
error: `XML parsing error: ${errorText.substring(0, 100)}...`,
};
}
// Check if the document has a root element
if (!xmlDoc.documentElement) {
return {
isValid: false,
error: "XML does not have a valid root element.",
};
}
// Optional: Check for basic SAML metadata structure
const rootElement = xmlDoc.documentElement;
const rootTagName = rootElement.tagName.toLowerCase();
// Check if it looks like SAML metadata (common root elements)
const samlRootElements = [
"entitydescriptor",
"entitiesDescriptor",
"metadata",
"md:entitydescriptor",
"md:entitiesdescriptor",
];
const isSamlMetadata = samlRootElements.some((element) =>
rootTagName.includes(element.toLowerCase()),
);
if (!isSamlMetadata) {
// Check for common SAML namespace attributes
const xmlString = xmlContent.toLowerCase();
const hasSamlNamespace =
xmlString.includes("saml") ||
xmlString.includes("urn:oasis:names:tc:saml") ||
xmlString.includes("metadata");
if (!hasSamlNamespace) {
return {
isValid: false,
error:
"The XML file does not appear to be SAML metadata. Please ensure you're uploading the correct SAML metadata file from your Identity Provider.",
};
}
}
return { isValid: true };
} catch (error) {
return {
isValid: false,
error:
error instanceof Error
? error.message
: "Failed to validate XML content.",
};
}
};
export const SamlConfigForm = ({
setIsOpen,
samlConfig,
@@ -109,12 +199,13 @@ export const SamlConfigForm = ({
reader.onload = (e) => {
const content = e.target?.result as string;
// Basic XML validation
if (!content.trim().startsWith("<") || !content.includes("</")) {
// Comprehensive XML validation
const xmlValidationResult = validateXMLContent(content);
if (!xmlValidationResult.isValid) {
toast({
variant: "destructive",
title: "Invalid XML content",
description: "The file does not contain valid XML content.",
description: xmlValidationResult.error,
});
// Clear the file input
event.target.value = "";

View File

@@ -9,7 +9,7 @@ import { useToast } from "@/components/ui";
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { SamlConfigForm } from "./forms";
import { SamlConfigForm } from "./saml-config-form";
export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
const [isSamlModalOpen, setIsSamlModalOpen] = useState(false);

View File

@@ -41,7 +41,9 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
experimental_throttle: 100,
sendExtraMessageFields: true,
onFinish: (message) => {
// Detect error messages sent from backend using specific prefix
// There is no specific way to output the error message from langgraph supervisor
// Hence, all error messages are sent as normal messages with the prefix [LIGHTHOUSE_ANALYST_ERROR]:
// Detect error messages sent from backend using specific prefix and display the error
if (message.content?.startsWith("[LIGHTHOUSE_ANALYST_ERROR]:")) {
const errorText = message.content
.replace("[LIGHTHOUSE_ANALYST_ERROR]:", "")
@@ -57,6 +59,15 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
},
onError: (error) => {
console.error("Chat error:", error);
if (
error?.message?.includes("<html>") &&
error?.message?.includes("<title>403 Forbidden</title>")
) {
setErrorMessage("403 Forbidden");
return;
}
setErrorMessage(
error?.message || "An error occurred. Please retry your message.",
);
@@ -72,6 +83,30 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
const messageValue = form.watch("message");
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const latestUserMsgRef = useRef<HTMLDivElement | null>(null);
const messageValueRef = useRef<string>("");
// Keep ref in sync with current value
messageValueRef.current = messageValue;
// Restore last user message to input when any error occurs
useEffect(() => {
if (errorMessage) {
// Capture current messages to avoid dependency issues
setMessages((currentMessages) => {
const lastUserMessage = currentMessages
.filter((m) => m.role === "user")
.pop();
if (lastUserMessage) {
form.setValue("message", lastUserMessage.content);
// Remove the last user message from history since it's now in the input
return currentMessages.slice(0, -1);
}
return currentMessages;
});
}
}, [errorMessage, form, setMessages]);
// Sync form value with chat input
useEffect(() => {
@@ -88,25 +123,6 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
}
}, [status, form]);
// Populate input with last user message when any error occurs
useEffect(() => {
if (errorMessage && messages.length > 0) {
// Filter out the error message itself before finding the last user message
const nonErrorMessages = messages.filter(
(m) => !m.content?.startsWith("[LIGHTHOUSE_ANALYST_ERROR]:"),
);
if (nonErrorMessages.length > 0) {
const lastUserMessage = nonErrorMessages
.filter((m) => m.role === "user")
.pop();
if (lastUserMessage && !messageValue) {
form.setValue("message", lastUserMessage.content);
setMessages(nonErrorMessages.slice(0, -1));
}
}
}
}, [errorMessage, messages, messageValue, form, setMessages]);
const onFormSubmit = form.handleSubmit((data) => {
if (data.message.trim()) {
// Clear error on new submission

View File

@@ -7,11 +7,13 @@ import {
AWSProviderBadge,
AzureProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
} from "@/components/icons/providers-badge";
import { CustomButton } from "@/components/ui/custom/custom-button";
import { ProviderOverviewProps } from "@/types";
import { PROVIDER_TYPES, ProviderType } from "@/types/providers";
export const ProvidersOverview = ({
providersOverview,
@@ -21,7 +23,7 @@ export const ProvidersOverview = ({
const calculatePassingPercentage = (pass: number, total: number) =>
total > 0 ? ((pass / total) * 100).toFixed(2) : "0.00";
const renderProviderBadge = (providerId: string) => {
const renderProviderBadge = (providerId: ProviderType) => {
switch (providerId) {
case "aws":
return <AWSProviderBadge width={30} height={30} />;
@@ -33,18 +35,26 @@ export const ProvidersOverview = ({
return <GCPProviderBadge width={30} height={30} />;
case "kubernetes":
return <KS8ProviderBadge width={30} height={30} />;
case "github":
return <GitHubProviderBadge width={30} height={30} />;
default:
return null;
}
};
const providers = [
{ id: "aws", name: "AWS" },
{ id: "azure", name: "Azure" },
{ id: "m365", name: "M365" },
{ id: "gcp", name: "GCP" },
{ id: "kubernetes", name: "Kubernetes" },
];
const providerDisplayNames: Record<ProviderType, string> = {
aws: "AWS",
azure: "Azure",
m365: "M365",
gcp: "GCP",
kubernetes: "Kubernetes",
github: "GitHub",
};
const providers = PROVIDER_TYPES.map((providerType) => ({
id: providerType,
name: providerDisplayNames[providerType],
}));
if (!providersOverview || !Array.isArray(providersOverview.data)) {
return (

View File

@@ -2,6 +2,7 @@
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";
import { ProviderType } from "@/types/providers";
interface UpdateCredentialsInfoProps {
@@ -20,6 +21,9 @@ export const CredentialsUpdateInfo = ({
if (providerType === "gcp") {
return <SelectViaGCP initialVia={initialVia} />;
}
if (providerType === "github") {
return <SelectViaGitHub initialVia={initialVia} />;
}
return null;
};

View File

@@ -1,7 +1,7 @@
"use client";
import { Textarea } from "@nextui-org/react";
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useFormState } from "react-dom";
import {
@@ -23,11 +23,11 @@ import {
} from "@/types/processors";
interface MutedFindingsConfigFormProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
onCancel: () => void;
}
export const MutedFindingsConfigForm = ({
setIsOpen,
onCancel,
}: MutedFindingsConfigFormProps) => {
const [config, setConfig] = useState<ProcessorData | null>(null);
const [configText, setConfigText] = useState("");
@@ -64,7 +64,7 @@ export const MutedFindingsConfigForm = ({
title: "Configuration saved successfully",
description: state.success,
});
setIsOpen(false);
onCancel();
} else if (state?.errors?.general) {
toast({
variant: "destructive",
@@ -75,7 +75,7 @@ export const MutedFindingsConfigForm = ({
// Reset typing state when there are new server errors
setHasUserStartedTyping(false);
}
}, [state, toast, setIsOpen]);
}, [state, toast, onCancel]);
const handleConfigChange = (value: string) => {
setConfigText(value);
@@ -100,7 +100,7 @@ export const MutedFindingsConfigForm = ({
title: "Configuration deleted successfully",
description: result.success,
});
setIsOpen(false);
onCancel();
} else if (result?.errors?.general) {
toast({
variant: "destructive",
@@ -232,7 +232,8 @@ export const MutedFindingsConfigForm = ({
<div className="flex flex-col space-y-4">
<FormButtons
setIsOpen={setIsOpen}
setIsOpen={() => {}}
onCancel={onCancel}
submitText={config ? "Update" : "Save"}
isDisabled={!yamlValidation.isValid || !configText.trim()}
/>

View File

@@ -1,7 +1,7 @@
"use client";
import { SettingsIcon } from "lucide-react";
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
@@ -14,23 +14,33 @@ interface MutedFindingsConfigButtonProps {
export const MutedFindingsConfigButton = ({
isDisabled = false,
}: MutedFindingsConfigButtonProps) => {
const [isOpen, setIsOpen] = useState(false);
const searchParams = useSearchParams();
const isOpen = searchParams.get("modal") === "mutelist";
const handleOpenModal = () => {
if (!isDisabled) {
setIsOpen(true);
const params = new URLSearchParams(window.location.search);
params.set("modal", "mutelist");
window.history.pushState({}, "", `?${params.toString()}`);
}
};
const handleModalClose = () => {
const params = new URLSearchParams(window.location.search);
params.delete("modal");
window.history.pushState({}, "", `?${params.toString()}`);
};
return (
<>
<CustomAlertModal
isOpen={isOpen}
onOpenChange={setIsOpen}
onOpenChange={handleModalClose}
title="Configure Mutelist"
size="3xl"
>
<MutedFindingsConfigForm setIsOpen={setIsOpen} />
<MutedFindingsConfigForm onCancel={handleModalClose} />
</CustomAlertModal>
<CustomButton

View File

@@ -0,0 +1,201 @@
"use client";
import { Button, Select, SelectItem } from "@nextui-org/react";
import { CheckSquare, Square } from "lucide-react";
import { Control } from "react-hook-form";
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",
};
interface ProviderSelectorProps {
control: Control<any>;
name: string;
providers: ProviderProps[];
label?: string;
placeholder?: string;
isInvalid?: boolean;
showFormMessage?: boolean;
}
export const ProviderSelector = ({
control,
name,
providers,
label = "Providers",
placeholder = "Select providers",
isInvalid = false,
showFormMessage = true,
}: ProviderSelectorProps) => {
// Sort providers by type and then by name for better organization
const sortedProviders = [...providers].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 selectedIds = value || [];
const allProviderIds = sortedProviders.map((p) => p.id);
const isAllSelected =
allProviderIds.length > 0 &&
allProviderIds.every((id) => selectedIds.includes(id));
const handleSelectAll = () => {
if (isAllSelected) {
onChange([]);
} else {
onChange(allProviderIds);
}
};
return (
<>
<FormControl>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-default-700">
{label}
</span>
{sortedProviders.length > 1 && (
<Button
size="sm"
variant="light"
onPress={handleSelectAll}
startContent={
isAllSelected ? (
<CheckSquare size={16} />
) : (
<Square size={16} />
)
}
className="h-7 text-xs"
>
{isAllSelected ? "Deselect All" : "Select All"}
</Button>
)}
</div>
<Select
label={label}
placeholder={placeholder}
selectionMode="multiple"
selectedKeys={new Set(value || [])}
onSelectionChange={(keys) => {
const selectedArray = Array.from(keys);
onChange(selectedArray);
}}
onBlur={onBlur}
variant="bordered"
labelPlacement="inside"
isRequired={false}
isInvalid={isInvalid}
classNames={{
trigger: "min-h-12",
popoverContent: "dark:bg-gray-800",
listboxWrapper: "max-h-[300px] dark:bg-gray-800",
listbox: "gap-0",
label:
"tracking-tight font-light !text-default-500 text-xs !z-0",
value: "text-default-500 text-small dark:text-gray-300",
}}
renderValue={(items) => {
if (items.length === 0) {
return (
<span className="text-default-500">{placeholder}</span>
);
}
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>
);
}}
>
{sortedProviders.map((provider) => {
const providerType = provider.attributes.provider;
const displayName =
provider.attributes.alias || provider.attributes.uid;
const typeLabel = providerTypeLabels[providerType];
return (
<SelectItem
key={provider.id}
textValue={`${displayName} ${typeLabel}`}
className="py-2"
>
<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="truncate text-small font-medium">
{displayName}
</div>
<div className="truncate text-tiny text-default-500">
{typeLabel}
</div>
</div>
</div>
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${
provider.attributes.connection.connected
? "bg-success"
: "bg-danger"
}`}
title={
provider.attributes.connection.connected
? "Connected"
: "Disconnected"
}
/>
</div>
</div>
</SelectItem>
);
})}
</Select>
</div>
</FormControl>
{showFormMessage && (
<FormMessage className="max-w-full text-xs text-system-error dark:text-system-error" />
)}
</>
);
}}
/>
);
};

View File

@@ -11,6 +11,7 @@ import {
AWSProviderBadge,
AzureProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
} from "../icons/providers-badge";
@@ -71,6 +72,12 @@ export const RadioGroupProvider: React.FC<RadioGroupProviderProps> = ({
<span className="ml-2">Kubernetes</span>
</div>
</CustomRadio>
<CustomRadio description="GitHub" value="github">
<div className="flex items-center">
<GitHubProviderBadge size={26} />
<span className="ml-2">GitHub</span>
</div>
</CustomRadio>
</div>
</RadioGroup>
{errorMessage && (

View File

@@ -1,29 +1,40 @@
"use client";
import { Snippet } from "@nextui-org/react";
import { useSession } from "next-auth/react";
import { IdIcon } from "@/components/icons";
import { CustomButton } from "@/components/ui/custom";
import { getAWSCredentialsTemplateLinks } from "@/lib";
import { SnippetChip } from "@/components/ui/entities";
export const CredentialsRoleHelper = () => {
const { data: session } = useSession();
interface CredentialsRoleHelperProps {
externalId: string;
templateLinks: {
cloudformation: string;
cloudformationQuickLink: string;
terraform: string;
};
type?: "providers" | "s3-integration";
}
export const CredentialsRoleHelper = ({
externalId,
templateLinks,
type = "providers",
}: CredentialsRoleHelperProps) => {
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
A <strong>new read-only IAM role</strong> must be manually created.
A <strong>read-only IAM role</strong> must be manually created
{type === "s3-integration" ? " or updated" : ""}.
</p>
<CustomButton
ariaLabel="Use the following AWS CloudFormation Quick Link to deploy the IAM Role"
color="transparent"
className="h-auto w-fit min-w-0 p-0 text-blue-500"
asLink={`${getAWSCredentialsTemplateLinks().cloudformationQuickLink}${session?.tenantId}`}
asLink={templateLinks.cloudformationQuickLink}
target="_blank"
>
Use the following AWS CloudFormation Quick Link to deploy the IAM Role
Use the following AWS CloudFormation Quick Link to create the IAM Role
</CustomButton>
<div className="flex items-center gap-2">
@@ -33,8 +44,11 @@ export const CredentialsRoleHelper = () => {
</span>
<div className="h-px flex-1 bg-gray-200 dark:bg-gray-700" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Use one of the following templates to create the IAM role:
{type === "providers"
? "Use one of the following templates to create the IAM role"
: "Refer to the documentation"}
</p>
<div className="flex w-fit flex-col gap-2">
@@ -42,34 +56,28 @@ export const CredentialsRoleHelper = () => {
ariaLabel="CloudFormation Template"
color="transparent"
className="h-auto w-fit min-w-0 p-0 text-blue-500"
asLink={getAWSCredentialsTemplateLinks().cloudformation}
asLink={templateLinks.cloudformation}
target="_blank"
>
CloudFormation Template
CloudFormation {type === "providers" ? "Template" : ""}
</CustomButton>
<CustomButton
ariaLabel="Terraform Code"
color="transparent"
className="h-auto w-fit min-w-0 p-0 text-blue-500"
asLink={getAWSCredentialsTemplateLinks().terraform}
asLink={templateLinks.terraform}
target="_blank"
>
Terraform Code
Terraform {type === "providers" ? "Code" : ""}
</CustomButton>
</div>
<p className="text-xs font-bold text-gray-600 dark:text-gray-400">
The External ID will also be required:
</p>
<Snippet
className="max-w-full bg-gray-50 py-1 dark:bg-slate-800"
color="warning"
hideSymbol
>
<p className="whitespace-pre-line text-xs font-bold">
{session?.tenantId}
</p>
</Snippet>
<div className="flex items-center gap-2">
<span className="block text-xs font-medium text-default-500">
External ID:
</span>
<SnippetChip value={externalId} icon={<IdIcon size={16} />} />
</div>
</div>
</div>
);

View File

@@ -7,7 +7,9 @@ import { Control } from "react-hook-form";
import { CustomButton } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import { useCredentialsForm } from "@/hooks/use-credentials-form";
import { getAWSCredentialsTemplateScanLinks } from "@/lib";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { requiresBackButton } from "@/lib/provider-helpers";
import {
AWSCredentials,
AWSCredentialsRole,
@@ -25,6 +27,7 @@ import { AWSRoleCredentialsForm } from "./select-credentials-type/aws/credential
import { GCPDefaultCredentialsForm } from "./select-credentials-type/gcp/credentials-type";
import { GCPServiceAccountKeyForm } from "./select-credentials-type/gcp/credentials-type/gcp-service-account-key-form";
import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form";
import { GitHubCredentialsForm } from "./via-credentials/github-credentials-form";
import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form";
import { M365CredentialsForm } from "./via-credentials/m365-credentials-form";
@@ -59,6 +62,8 @@ export const BaseCredentialsForm = ({
successNavigationUrl,
});
const templateLinks = getAWSCredentialsTemplateScanLinks(externalId);
return (
<Form {...form}>
<form
@@ -85,6 +90,7 @@ export const BaseCredentialsForm = ({
control={form.control as unknown as Control<AWSCredentialsRole>}
setValue={form.setValue as any}
externalId={externalId}
templateLinks={templateLinks}
/>
)}
{providerType === "aws" && searchParamsObj.get("via") !== "role" && (
@@ -121,26 +127,29 @@ export const BaseCredentialsForm = ({
control={form.control as unknown as Control<KubernetesCredentials>}
/>
)}
{providerType === "github" && (
<GitHubCredentialsForm
control={form.control}
credentialsType={searchParamsObj.get("via") || undefined}
/>
)}
<div className="flex w-full justify-end sm:space-x-6">
{showBackButton &&
(searchParamsObj.get("via") === "credentials" ||
searchParamsObj.get("via") === "role" ||
searchParamsObj.get("via") === "service-account") && (
<CustomButton
type="button"
ariaLabel="Back"
className="w-1/2 bg-transparent"
variant="faded"
size="lg"
radius="lg"
onPress={handleBackStep}
startContent={!isLoading && <ChevronLeftIcon size={24} />}
isDisabled={isLoading}
>
<span>Back</span>
</CustomButton>
)}
{showBackButton && requiresBackButton(searchParamsObj.get("via")) && (
<CustomButton
type="button"
ariaLabel="Back"
className="w-1/2 bg-transparent"
variant="faded"
size="lg"
radius="lg"
onPress={handleBackStep}
startContent={!isLoading && <ChevronLeftIcon size={24} />}
isDisabled={isLoading}
>
<span>Back</span>
</CustomButton>
)}
<CustomButton
type="submit"
ariaLabel="Save"

View File

@@ -7,18 +7,19 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { addProvider } from "@/actions/providers/providers";
import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs";
import { useToast } from "@/components/ui";
import { CustomButton, CustomInput } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import { addProviderFormSchema, ApiError, ProviderType } from "@/types";
import { addProvider } from "../../../../actions/providers/providers";
import { addProviderFormSchema, ApiError } from "../../../../types";
import { RadioGroupProvider } from "../../radio-group-provider";
import { ProviderTitleDocs } from "../provider-title-docs";
export type FormValues = z.infer<typeof addProviderFormSchema>;
// Helper function for labels and placeholders
const getProviderFieldDetails = (providerType?: string) => {
const getProviderFieldDetails = (providerType?: ProviderType) => {
switch (providerType) {
case "aws":
return {
@@ -45,6 +46,11 @@ const getProviderFieldDetails = (providerType?: string) => {
label: "Domain ID",
placeholder: "e.g. your-domain.onmicrosoft.com",
};
case "github":
return {
label: "Username",
placeholder: "e.g. your-github-username",
};
default:
return {
label: "Provider UID",
@@ -142,6 +148,10 @@ export const ConnectAccountForm = () => {
const handleBackStep = () => {
setPrevStep((prev) => prev - 1);
//Deselect the providerType if the user is going back to the first step
if (prevStep === 2) {
form.setValue("providerType", undefined as unknown as ProviderType);
}
// Reset the providerUid and providerAlias fields when going back
form.setValue("providerUid", "");
form.setValue("providerAlias", "");

View File

@@ -1,4 +1,5 @@
import { Divider, Select, SelectItem, Spacer } from "@nextui-org/react";
import { Divider, Select, SelectItem, Switch } from "@nextui-org/react";
import { useState } from "react";
import { Control, UseFormSetValue, useWatch } from "react-hook-form";
import { CredentialsRoleHelper } from "@/components/providers/workflow";
@@ -10,35 +11,46 @@ export const AWSRoleCredentialsForm = ({
control,
setValue,
externalId,
templateLinks,
type = "providers",
}: {
control: Control<AWSCredentialsRole>;
setValue: UseFormSetValue<AWSCredentialsRole>;
externalId: string;
templateLinks: {
cloudformation: string;
cloudformationQuickLink: string;
terraform: string;
};
type?: "providers" | "s3-integration";
}) => {
const [showRoleSection, setShowRoleSection] = useState(type === "providers");
const credentialsType = useWatch({
control,
name: ProviderCredentialFields.CREDENTIALS_TYPE,
defaultValue: "aws-sdk-default",
defaultValue: "access-secret-key",
});
return (
<>
<div className="flex flex-col">
<div className="text-md font-bold leading-9 text-default-foreground">
Connect assuming IAM Role
</div>
<div className="text-sm text-default-500">
Please provide the information for your AWS credentials.
</div>
{type === "providers" && (
<div className="text-md font-bold leading-9 text-default-foreground">
Connect assuming IAM Role
</div>
)}
</div>
<span className="text-xs font-bold text-default-500">Authentication</span>
<span className="text-xs font-bold text-default-500">
Specify which AWS credentials to use
</span>
<Select
name={ProviderCredentialFields.CREDENTIALS_TYPE}
label="Authentication Method"
placeholder="Select credentials type"
defaultSelectedKeys={["aws-sdk-default"]}
defaultSelectedKeys={["access-secret-key"]}
className="mb-4"
variant="bordered"
onSelectionChange={(keys) =>
@@ -49,7 +61,7 @@ export const AWSRoleCredentialsForm = ({
}
>
<SelectItem key="aws-sdk-default">AWS SDK default</SelectItem>
<SelectItem key="access-secret-key">Access & secret key</SelectItem>
<SelectItem key="access-secret-key">Access & Secret Key</SelectItem>
</Select>
{credentialsType === "access-secret-key" && (
@@ -101,74 +113,97 @@ export const AWSRoleCredentialsForm = ({
/>
</>
)}
<Divider />
<span className="text-xs font-bold text-default-500">Assume Role</span>
<CredentialsRoleHelper />
<Divider className="" />
<Spacer y={2} />
{type === "providers" ? (
<span className="text-xs font-bold text-default-500">Assume Role</span>
) : (
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-default-500">
Optionally add a role
</span>
<Switch
size="sm"
isSelected={showRoleSection}
onValueChange={setShowRoleSection}
/>
</div>
)}
<CustomInput
control={control}
name={ProviderCredentialFields.ROLE_ARN}
type="text"
label="Role ARN"
labelPlacement="inside"
placeholder="Enter the Role ARN"
variant="bordered"
isRequired
isInvalid={
!!control._formState.errors[ProviderCredentialFields.ROLE_ARN]
}
/>
<CustomInput
control={control}
name={ProviderCredentialFields.EXTERNAL_ID}
type="text"
label="External ID"
labelPlacement="inside"
placeholder={externalId}
variant="bordered"
defaultValue={externalId}
isDisabled
isRequired
isInvalid={
!!control._formState.errors[ProviderCredentialFields.EXTERNAL_ID]
}
/>
{showRoleSection && (
<>
<CredentialsRoleHelper
externalId={externalId}
templateLinks={templateLinks}
type={type}
/>
<span className="text-xs text-default-500">Optional fields</span>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<CustomInput
control={control}
name={ProviderCredentialFields.ROLE_SESSION_NAME}
type="text"
label="Role Session Name"
labelPlacement="inside"
placeholder="Enter the Role Session Name"
variant="bordered"
isRequired={false}
isInvalid={
!!control._formState.errors[
ProviderCredentialFields.ROLE_SESSION_NAME
]
}
/>
<CustomInput
control={control}
name={ProviderCredentialFields.SESSION_DURATION}
type="number"
label="Session Duration (seconds)"
labelPlacement="inside"
placeholder="Enter the session duration (default: 3600)"
variant="bordered"
isRequired={false}
isInvalid={
!!control._formState.errors[
ProviderCredentialFields.SESSION_DURATION
]
}
/>
</div>
<Divider />
<CustomInput
control={control}
name={ProviderCredentialFields.ROLE_ARN}
type="text"
label="Role ARN"
labelPlacement="inside"
placeholder="Enter the Role ARN"
variant="bordered"
isRequired={type === "providers"}
isInvalid={
!!control._formState.errors[ProviderCredentialFields.ROLE_ARN]
}
/>
<CustomInput
control={control}
name={ProviderCredentialFields.EXTERNAL_ID}
type="text"
label="External ID"
labelPlacement="inside"
placeholder={externalId}
variant="bordered"
defaultValue={externalId}
isDisabled
isRequired
isInvalid={
!!control._formState.errors[ProviderCredentialFields.EXTERNAL_ID]
}
/>
<span className="text-xs text-default-500">Optional fields</span>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<CustomInput
control={control}
name={ProviderCredentialFields.ROLE_SESSION_NAME}
type="text"
label="Role session name"
labelPlacement="inside"
placeholder="Enter the role session name"
variant="bordered"
isRequired={false}
isInvalid={
!!control._formState.errors[
ProviderCredentialFields.ROLE_SESSION_NAME
]
}
/>
<CustomInput
control={control}
name={ProviderCredentialFields.SESSION_DURATION}
type="number"
label="Session duration (seconds)"
labelPlacement="inside"
placeholder="Enter the session duration (default: 3600 seconds)"
variant="bordered"
isRequired={false}
isInvalid={
!!control._formState.errors[
ProviderCredentialFields.SESSION_DURATION
]
}
/>
</div>
</>
)}
</>
);
};

View File

@@ -0,0 +1,47 @@
"use client";
import { Control } from "react-hook-form";
import { CustomInput, CustomTextarea } from "@/components/ui/custom";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
export const GitHubAppForm = ({ control }: { control: Control<any> }) => {
return (
<>
<div className="flex flex-col">
<div className="text-md font-bold leading-9 text-default-foreground">
Connect via GitHub App
</div>
<div className="text-sm text-default-500">
Please provide your GitHub App ID and private key.
</div>
</div>
<CustomInput
control={control}
name={ProviderCredentialFields.GITHUB_APP_ID}
type="text"
label="GitHub App ID"
labelPlacement="inside"
placeholder="Enter your GitHub App ID"
variant="bordered"
isRequired
isInvalid={
!!control._formState.errors[ProviderCredentialFields.GITHUB_APP_ID]
}
/>
<CustomTextarea
control={control}
name={ProviderCredentialFields.GITHUB_APP_KEY}
label="GitHub App Private Key"
labelPlacement="inside"
placeholder="Paste your GitHub App private key here"
variant="bordered"
isRequired
minRows={4}
isInvalid={
!!control._formState.errors[ProviderCredentialFields.GITHUB_APP_KEY]
}
/>
</>
);
};

View File

@@ -0,0 +1,34 @@
"use client";
import { Control } from "react-hook-form";
import { CustomInput } from "@/components/ui/custom";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
export const GitHubOAuthAppForm = ({ control }: { control: Control<any> }) => {
return (
<>
<div className="flex flex-col">
<div className="text-md font-bold leading-9 text-default-foreground">
Connect via OAuth App
</div>
<div className="text-sm text-default-500">
Please provide your GitHub OAuth App token.
</div>
</div>
<CustomInput
control={control}
name={ProviderCredentialFields.OAUTH_APP_TOKEN}
type="password"
label="OAuth App Token"
labelPlacement="inside"
placeholder="Enter your GitHub OAuth App token"
variant="bordered"
isRequired
isInvalid={
!!control._formState.errors[ProviderCredentialFields.OAUTH_APP_TOKEN]
}
/>
</>
);
};

View File

@@ -0,0 +1,40 @@
"use client";
import { Control } from "react-hook-form";
import { CustomInput } from "@/components/ui/custom";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
export const GitHubPersonalAccessTokenForm = ({
control,
}: {
control: Control<any>;
}) => {
return (
<>
<div className="flex flex-col">
<div className="text-md font-bold leading-9 text-default-foreground">
Connect via Personal Access Token
</div>
<div className="text-sm text-default-500">
Please provide your GitHub personal access token.
</div>
</div>
<CustomInput
control={control}
name={ProviderCredentialFields.PERSONAL_ACCESS_TOKEN}
type="password"
label="Personal Access Token"
labelPlacement="inside"
placeholder="Enter your GitHub personal access token"
variant="bordered"
isRequired
isInvalid={
!!control._formState.errors[
ProviderCredentialFields.PERSONAL_ACCESS_TOKEN
]
}
/>
</>
);
};

View File

@@ -0,0 +1,3 @@
export * from "./github-app-form";
export * from "./github-oauth-app-form";
export * from "./github-personal-access-token-form";

View File

@@ -0,0 +1,3 @@
export * from "./credentials-type";
export * from "./radio-group-github-via-credentials-type-form";
export * from "./select-via-github";

View File

@@ -0,0 +1,84 @@
"use client";
import { RadioGroup } from "@nextui-org/react";
import React from "react";
import { Control, Controller } from "react-hook-form";
import { CustomRadio } from "@/components/ui/custom";
import { FormMessage } from "@/components/ui/form";
type RadioGroupGitHubViaCredentialsFormProps = {
control: Control<any>;
isInvalid: boolean;
errorMessage?: string;
onChange?: (value: string) => void;
};
export const RadioGroupGitHubViaCredentialsTypeForm = ({
control,
isInvalid,
errorMessage,
onChange,
}: RadioGroupGitHubViaCredentialsFormProps) => {
return (
<Controller
name="githubCredentialsType"
control={control}
render={({ field }) => (
<>
<RadioGroup
className="flex flex-wrap"
isInvalid={isInvalid}
{...field}
value={field.value || ""}
onValueChange={(value) => {
field.onChange(value);
if (onChange) {
onChange(value);
}
}}
>
<div className="flex flex-col gap-4">
<span className="text-sm text-default-500">
Personal Access Token
</span>
<CustomRadio
description="Use a personal access token for authentication"
value="personal_access_token"
>
<div className="flex items-center">
<span className="ml-2">Personal Access Token</span>
</div>
</CustomRadio>
<span className="text-sm text-default-500">OAuth App</span>
<CustomRadio
description="Use OAuth App token for authentication"
value="oauth_app"
>
<div className="flex items-center">
<span className="ml-2">OAuth App Token</span>
</div>
</CustomRadio>
<span className="text-sm text-default-500">GitHub App</span>
<CustomRadio
description="Use GitHub App ID and private key for authentication"
value="github_app"
>
<div className="flex items-center">
<span className="ml-2">GitHub App</span>
</div>
</CustomRadio>
</div>
</RadioGroup>
{errorMessage && (
<FormMessage className="text-system-error dark:text-system-error">
{errorMessage}
</FormMessage>
)}
</>
)}
/>
);
};

View File

@@ -0,0 +1,38 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { Form } from "@/components/ui/form";
import { RadioGroupGitHubViaCredentialsTypeForm } from "./radio-group-github-via-credentials-type-form";
interface SelectViaGitHubProps {
initialVia?: string;
}
export const SelectViaGitHub = ({ initialVia }: SelectViaGitHubProps) => {
const router = useRouter();
const form = useForm({
defaultValues: {
githubCredentialsType: initialVia || "",
},
});
const handleSelectionChange = (value: string) => {
const url = new URL(window.location.href);
url.searchParams.set("via", value);
router.push(url.toString());
};
return (
<Form {...form}>
<RadioGroupGitHubViaCredentialsTypeForm
control={form.control}
isInvalid={!!form.formState.errors.githubCredentialsType}
errorMessage={form.formState.errors.githubCredentialsType?.message}
onChange={handleSelectionChange}
/>
</Form>
);
};

View File

@@ -0,0 +1,30 @@
"use client";
import { Control } from "react-hook-form";
import {
GitHubAppForm,
GitHubOAuthAppForm,
GitHubPersonalAccessTokenForm,
} from "../select-credentials-type/github";
interface GitHubCredentialsFormProps {
control: Control<any>;
credentialsType?: string;
}
export const GitHubCredentialsForm = ({
control,
credentialsType,
}: GitHubCredentialsFormProps) => {
switch (credentialsType) {
case "personal_access_token":
return <GitHubPersonalAccessTokenForm control={control} />;
case "oauth_app":
return <GitHubOAuthAppForm control={control} />;
case "github_app":
return <GitHubAppForm control={control} />;
default:
return null;
}
};

View File

@@ -1,3 +1,4 @@
export * from "./azure-credentials-form";
export * from "./github-credentials-form";
export * from "./k8s-credentials-form";
export * from "./m365-credentials-form";

View File

@@ -36,6 +36,7 @@ export const AddRoleForm = ({
name: "",
manage_users: false,
manage_providers: false,
manage_integrations: false,
manage_scans: false,
unlimited_visibility: false,
groups: [],
@@ -68,7 +69,7 @@ export const AddRoleForm = ({
"manage_account",
"manage_billing",
"manage_providers",
// "manage_integrations",
"manage_integrations",
"manage_scans",
"unlimited_visibility",
];
@@ -87,6 +88,7 @@ export const AddRoleForm = ({
formData.append("name", values.name);
formData.append("manage_users", String(values.manage_users));
formData.append("manage_providers", String(values.manage_providers));
formData.append("manage_integrations", String(values.manage_integrations));
formData.append("manage_scans", String(values.manage_scans));
formData.append("manage_account", String(values.manage_account));
formData.append(

View File

@@ -99,7 +99,7 @@ export const EditRoleForm = ({
updatedFields.manage_users = values.manage_users;
updatedFields.manage_providers = values.manage_providers;
updatedFields.manage_account = values.manage_account;
// updatedFields.manage_integrations = values.manage_integrations;
updatedFields.manage_integrations = values.manage_integrations;
updatedFields.manage_scans = values.manage_scans;
updatedFields.unlimited_visibility = values.unlimited_visibility;

View File

@@ -9,21 +9,17 @@ import { ReactNode } from "react";
export interface CustomBreadcrumbItem {
name: string;
path?: string;
icon?: string | ReactNode;
isLast?: boolean;
isClickable?: boolean;
onClick?: () => void;
}
interface BreadcrumbNavigationProps {
// For automatic breadcrumbs (like navbar)
mode?: "auto" | "custom" | "hybrid";
title?: string;
icon?: string | ReactNode;
// For custom breadcrumbs (like resource-detail)
customItems?: CustomBreadcrumbItem[];
// Common options
className?: string;
paramToPreserve?: string;
showTitle?: boolean;
@@ -42,6 +38,21 @@ export function BreadcrumbNavigation({
const searchParams = useSearchParams();
const generateAutoBreadcrumbs = (): CustomBreadcrumbItem[] => {
const pathIconMapping: Record<string, string> = {
"/integrations": "lucide:puzzle",
"/providers": "lucide:cloud",
"/users": "lucide:users",
"/compliance": "lucide:shield-check",
"/findings": "lucide:search",
"/scans": "lucide:activity",
"/roles": "lucide:key",
"/resources": "lucide:database",
"/lighthouse": "lucide:lightbulb",
"/manage-groups": "lucide:users-2",
"/services": "lucide:server",
"/workloads": "lucide:layers",
};
const pathSegments = pathname
.split("/")
.filter((segment) => segment !== "");
@@ -66,9 +77,12 @@ export function BreadcrumbNavigation({
.join(" ");
}
const segmentIcon = !isLast ? pathIconMapping[currentPath] : undefined;
breadcrumbs.push({
name: displayName,
path: currentPath,
icon: segmentIcon,
isLast,
isClickable: !isLast,
});
@@ -129,6 +143,18 @@ export function BreadcrumbNavigation({
href={buildNavigationUrl(breadcrumb.path)}
className="flex cursor-pointer items-center space-x-2"
>
{breadcrumb.icon && typeof breadcrumb.icon === "string" ? (
<Icon
className="text-default-500"
height={24}
icon={breadcrumb.icon}
width={24}
/>
) : breadcrumb.icon ? (
<div className="flex h-6 w-6 items-center justify-center [&>*]:h-full [&>*]:w-full">
{breadcrumb.icon}
</div>
) : null}
<span className="text-wrap text-sm font-bold text-default-700 transition-colors hover:text-primary">
{breadcrumb.name}
</span>
@@ -136,14 +162,40 @@ export function BreadcrumbNavigation({
) : breadcrumb.isClickable && breadcrumb.onClick ? (
<button
onClick={breadcrumb.onClick}
className="cursor-pointer text-wrap text-sm font-medium text-primary transition-colors hover:text-primary-600"
className="flex cursor-pointer items-center space-x-2 text-wrap text-sm font-medium text-primary transition-colors hover:text-primary-600"
>
{breadcrumb.name}
{breadcrumb.icon && typeof breadcrumb.icon === "string" ? (
<Icon
className="text-default-500"
height={24}
icon={breadcrumb.icon}
width={24}
/>
) : breadcrumb.icon ? (
<div className="flex h-6 w-6 items-center justify-center [&>*]:h-full [&>*]:w-full">
{breadcrumb.icon}
</div>
) : null}
<span>{breadcrumb.name}</span>
</button>
) : (
<span className="text-wrap text-sm font-medium text-gray-900 dark:text-gray-100">
{breadcrumb.name}
</span>
<div className="flex items-center space-x-2">
{breadcrumb.icon && typeof breadcrumb.icon === "string" ? (
<Icon
className="text-default-500"
height={24}
icon={breadcrumb.icon}
width={24}
/>
) : breadcrumb.icon ? (
<div className="flex h-6 w-6 items-center justify-center [&>*]:h-full [&>*]:w-full">
{breadcrumb.icon}
</div>
) : null}
<span className="text-wrap text-sm font-medium text-gray-900 dark:text-gray-100">
{breadcrumb.name}
</span>
</div>
)}
</BreadcrumbItem>
))}

View File

@@ -6,7 +6,7 @@ import { Navbar } from "../nav-bar/navbar";
interface ContentLayoutProps {
title: string;
icon: string | ReactNode;
icon?: string | ReactNode;
children: React.ReactNode;
}

View File

@@ -34,7 +34,7 @@ export const CustomLink = React.forwardRef<HTMLAnchorElement, CustomLinkProps>(
href={href}
scroll={scroll}
className={cn(
`text-${size} break-all font-medium text-primary decoration-1 hover:underline`,
`text-${size} text-nowrap break-all font-medium text-primary decoration-1 hover:underline`,
className,
)}
aria-label={ariaLabel}

View File

@@ -4,6 +4,7 @@ import {
AWSProviderBadge,
AzureProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
} from "@/components/icons/providers-badge";
@@ -21,6 +22,8 @@ export const getProviderLogo = (provider: ProviderType) => {
return <KS8ProviderBadge width={35} height={35} />;
case "m365":
return <M365ProviderBadge width={35} height={35} />;
case "github":
return <GitHubProviderBadge width={35} height={35} />;
default:
return null;
}
@@ -38,6 +41,8 @@ export const getProviderName = (provider: ProviderType): string => {
return "Kubernetes";
case "m365":
return "Microsoft 365";
case "github":
return "GitHub";
default:
return "Unknown Provider";
}

View File

@@ -9,27 +9,43 @@ import { CustomButton } from "../custom";
interface FormCancelButtonProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
onCancel?: () => void;
children?: React.ReactNode;
leftIcon?: React.ReactNode;
}
interface FormSubmitButtonProps {
children?: React.ReactNode;
loadingText?: string;
isDisabled?: boolean;
rightIcon?: React.ReactNode;
}
interface FormButtonsProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
onCancel?: () => void;
submitText?: string;
cancelText?: string;
loadingText?: string;
isDisabled?: boolean;
rightIcon?: React.ReactNode;
leftIcon?: React.ReactNode;
}
export const FormCancelButton = ({
const FormCancelButton = ({
setIsOpen,
onCancel,
children = "Cancel",
leftIcon,
}: FormCancelButtonProps) => {
const handleCancel = () => {
if (onCancel) {
onCancel();
} else {
setIsOpen(false);
}
};
return (
<CustomButton
type="button"
@@ -37,17 +53,19 @@ export const FormCancelButton = ({
className="w-full bg-transparent"
variant="faded"
size="lg"
onPress={() => setIsOpen(false)}
onPress={handleCancel}
startContent={leftIcon}
>
<span>{children}</span>
</CustomButton>
);
};
export const FormSubmitButton = ({
const FormSubmitButton = ({
children = "Save",
loadingText = "Loading",
isDisabled = false,
rightIcon,
}: FormSubmitButtonProps) => {
const { pending } = useFormStatus();
@@ -61,7 +79,7 @@ export const FormSubmitButton = ({
size="lg"
isLoading={pending}
isDisabled={isDisabled}
startContent={!pending && <SaveIcon size={24} />}
startContent={!pending && rightIcon}
>
{pending ? <>{loadingText}</> : <span>{children}</span>}
</CustomButton>
@@ -70,16 +88,29 @@ export const FormSubmitButton = ({
export const FormButtons = ({
setIsOpen,
onCancel,
submitText = "Save",
cancelText = "Cancel",
loadingText = "Loading",
isDisabled = false,
rightIcon = <SaveIcon size={24} />,
leftIcon,
}: FormButtonsProps) => {
return (
<div className="flex w-full justify-center space-x-6">
<FormCancelButton setIsOpen={setIsOpen}>{cancelText}</FormCancelButton>
<FormCancelButton
setIsOpen={setIsOpen}
onCancel={onCancel}
leftIcon={leftIcon}
>
{cancelText}
</FormCancelButton>
<FormSubmitButton loadingText={loadingText} isDisabled={isDisabled}>
<FormSubmitButton
loadingText={loadingText}
isDisabled={isDisabled}
rightIcon={rightIcon}
>
{submitText}
</FormSubmitButton>
</div>

View File

@@ -33,6 +33,10 @@ const MENU_HIDE_RULES: MenuHideRule[] = [
label: "Billing",
condition: (permissions) => permissions?.manage_billing === false,
},
{
label: "Integrations",
condition: (permissions) => permissions?.manage_integrations === false,
},
// Add more rules as needed:
// {
// label: "Users",

View File

@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}

View File

@@ -46,6 +46,10 @@ export const useCredentialsForm = ({
if (providerType === "gcp" && via === "service-account") {
return addCredentialsServiceAccountFormSchema(providerType);
}
// For GitHub, we need to pass the via parameter to determine which fields are required
if (providerType === "github") {
return addCredentialsFormSchema(providerType, via);
}
return addCredentialsFormSchema(providerType);
};
@@ -62,7 +66,7 @@ export const useCredentialsForm = ({
if (providerType === "aws" && via === "role") {
return {
...baseDefaults,
[ProviderCredentialFields.CREDENTIALS_TYPE]: "aws-sdk-default",
[ProviderCredentialFields.CREDENTIALS_TYPE]: "access-secret-key",
[ProviderCredentialFields.ROLE_ARN]: "",
[ProviderCredentialFields.EXTERNAL_ID]: session?.tenantId || "",
[ProviderCredentialFields.AWS_ACCESS_KEY_ID]: "",
@@ -117,6 +121,28 @@ export const useCredentialsForm = ({
...baseDefaults,
[ProviderCredentialFields.KUBECONFIG_CONTENT]: "",
};
case "github":
// GitHub credentials based on via parameter
if (via === "personal_access_token") {
return {
...baseDefaults,
[ProviderCredentialFields.PERSONAL_ACCESS_TOKEN]: "",
};
}
if (via === "oauth_app") {
return {
...baseDefaults,
[ProviderCredentialFields.OAUTH_APP_TOKEN]: "",
};
}
if (via === "github_app") {
return {
...baseDefaults,
[ProviderCredentialFields.GITHUB_APP_ID]: "",
[ProviderCredentialFields.GITHUB_APP_KEY]: "",
};
}
return baseDefaults;
default:
return baseDefaults;
}

View File

@@ -25,6 +25,11 @@ export const getProviderHelpText = (provider: string) => {
text: "Need help connecting your Kubernetes cluster?",
link: "https://goto.prowler.com/provider-k8s",
};
case "github":
return {
text: "Need help connecting your GitHub account?",
link: "https://goto.prowler.com/provider-github",
};
default:
return {
text: "How to setup a provider?",
@@ -33,12 +38,24 @@ export const getProviderHelpText = (provider: string) => {
}
};
export const getAWSCredentialsTemplateLinks = () => {
export const getAWSCredentialsTemplateScanLinks = (externalId: string) => {
return {
cloudformation:
"https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml",
cloudformationQuickLink:
"https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Faws%2Fcloudformation%2Fprowler-scan-role.yml&stackName=ProwlerScanRole&param_ExternalId=",
cloudformationQuickLink: `https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Faws%2Fcloudformation%2Fprowler-scan-role.yml&stackName=ProwlerScanRole&param_ExternalId=${externalId}`,
terraform:
"https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/terraform/main.tf",
};
};
export const getAWSCredentialsTemplateBucketLinks = (
bucketName: string,
externalId: string,
) => {
return {
cloudformation:
"https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml",
cloudformationQuickLink: `https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Faws%2Fcloudformation%2Fprowler-scan-role-with-s3-integration.yml&stackName=ProwlerScanS3Integration&param_AccountId=232136659152&param_IAMPrincipal=role%2Fprowler*&param_ExternalId=${externalId}&param_S3IntegrationBucketName=${bucketName}`,
terraform:
"https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/terraform/main.tf",
};

View File

@@ -1,3 +1,5 @@
import { revalidatePath } from "next/cache";
import { getComplianceCsv, getExportsZip } from "@/actions/scans";
import { getTask } from "@/actions/task";
import { auth } from "@/auth.config";
@@ -285,19 +287,16 @@ export function decryptKey(passkey: string) {
return atob(passkey);
}
export const getErrorMessage = async (error: unknown): Promise<string> => {
let message: string;
export const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
message = error.message;
return error.message;
} else if (error && typeof error === "object" && "message" in error) {
message = String(error.message);
return String(error.message);
} else if (typeof error === "string") {
message = error;
return error;
} else {
message = "Oops! Something went wrong.";
return "Oops! Something went wrong.";
}
return message;
};
export const permissionFormFields: PermissionInfo[] = [
@@ -323,12 +322,12 @@ export const permissionFormFields: PermissionInfo[] = [
description:
"Allows configuration and management of cloud provider connections",
},
// {
// field: "manage_integrations",
// label: "Manage Integrations",
// description:
// "Controls the setup and management of third-party integrations",
// },
{
field: "manage_integrations",
label: "Manage Integrations",
description:
"Allows configuration and management of third-party integrations",
},
{
field: "manage_scans",
label: "Manage Scans",
@@ -341,3 +340,25 @@ export const permissionFormFields: PermissionInfo[] = [
description: "Provides access to billing settings and invoices",
},
];
// Helper function to handle API responses consistently
export const handleApiResponse = async (
response: Response,
pathToRevalidate?: string,
) => {
const data = await response.json();
if (pathToRevalidate) {
revalidatePath(pathToRevalidate);
}
return parseStringify(data);
};
// Helper function to handle API errors consistently
export const handleApiError = (error: unknown) => {
console.error(error);
return {
error: getErrorMessage(error),
};
};

View File

@@ -1,14 +1,13 @@
"use client";
import {
AlertCircle,
Bookmark,
CloudCog,
Cog,
Group,
LayoutGrid,
Mail,
Package,
Puzzle,
Settings,
ShieldCheck,
SquareChartGantt,
@@ -31,6 +30,7 @@ import {
KubernetesIcon,
LighthouseIcon,
M365Icon,
MutedIcon,
SupportIcon,
} from "@/components/icons/Icons";
import { GroupProps } from "@/types";
@@ -80,11 +80,6 @@ export const getMenuList = (pathname: string): GroupProps[] => {
label: "Top failed findings",
icon: Bookmark,
submenus: [
{
href: "/findings?filter[status__in]=FAIL&sort=severity,-inserted_at",
label: "Misconfigurations",
icon: AlertCircle,
},
{
href: "/findings?filter[status__in]=FAIL&filter[severity__in]=critical%2Chigh%2Cmedium&filter[provider_type__in]=aws%2Cazure%2Cgcp%2Ckubernetes&filter[service__in]=iam%2Crbac&sort=-inserted_at",
label: "IAM Issues",
@@ -137,17 +132,9 @@ export const getMenuList = (pathname: string): GroupProps[] => {
groupLabel: "",
menus: [
{
href: "",
href: "/resources",
label: "Resources",
icon: Warehouse,
submenus: [
{
href: "/resources",
label: "Browse all resources",
icon: Package,
},
],
defaultOpen: true,
},
],
},
@@ -162,8 +149,14 @@ export const getMenuList = (pathname: string): GroupProps[] => {
{ href: "/providers", label: "Cloud Providers", icon: CloudCog },
{ href: "/manage-groups", label: "Provider Groups", icon: Group },
{ href: "/scans", label: "Scan Jobs", icon: Timer },
{ href: "/integrations", label: "Integrations", icon: Puzzle },
{ href: "/roles", label: "Roles", icon: UserCog },
{ href: "/lighthouse/config", label: "Lighthouse AI", icon: Cog },
{
href: "/providers?modal=mutelist",
label: "Mutelist",
icon: MutedIcon,
},
],
defaultOpen: true,
},
@@ -174,7 +167,7 @@ export const getMenuList = (pathname: string): GroupProps[] => {
menus: [
{
href: "",
label: "Memberships",
label: "Organization",
icon: Users,
submenus: [
{ href: "/users", label: "Users", icon: User },

View File

@@ -1,11 +1,4 @@
import { revalidatePath } from "next/cache";
import {
filterEmptyValues,
getErrorMessage,
getFormValue,
parseStringify,
} from "@/lib";
import { filterEmptyValues, getFormValue } from "@/lib";
import { ProviderType } from "@/types";
import { ProviderCredentialFields } from "./provider-credential-fields";
@@ -147,6 +140,56 @@ export const buildKubernetesSecret = (formData: FormData) => {
return filterEmptyValues(secret);
};
export const buildGitHubSecret = (formData: FormData) => {
// Check which authentication method is being used
const hasPersonalToken =
formData.get(ProviderCredentialFields.PERSONAL_ACCESS_TOKEN) !== null &&
formData.get(ProviderCredentialFields.PERSONAL_ACCESS_TOKEN) !== "";
const hasOAuthToken =
formData.get(ProviderCredentialFields.OAUTH_APP_TOKEN) !== null &&
formData.get(ProviderCredentialFields.OAUTH_APP_TOKEN) !== "";
const hasGitHubApp =
formData.get(ProviderCredentialFields.GITHUB_APP_ID) !== null &&
formData.get(ProviderCredentialFields.GITHUB_APP_ID) !== "";
if (hasPersonalToken) {
const secret = {
[ProviderCredentialFields.PERSONAL_ACCESS_TOKEN]: getFormValue(
formData,
ProviderCredentialFields.PERSONAL_ACCESS_TOKEN,
),
};
return filterEmptyValues(secret);
}
if (hasOAuthToken) {
const secret = {
[ProviderCredentialFields.OAUTH_APP_TOKEN]: getFormValue(
formData,
ProviderCredentialFields.OAUTH_APP_TOKEN,
),
};
return filterEmptyValues(secret);
}
if (hasGitHubApp) {
const secret = {
[ProviderCredentialFields.GITHUB_APP_ID]: getFormValue(
formData,
ProviderCredentialFields.GITHUB_APP_ID,
),
[ProviderCredentialFields.GITHUB_APP_KEY]: getFormValue(
formData,
ProviderCredentialFields.GITHUB_APP_KEY,
),
};
return filterEmptyValues(secret);
}
// If no credentials are provided, return empty object
return {};
};
// Main function to build secret configuration
export const buildSecretConfig = (
formData: FormData,
@@ -177,6 +220,10 @@ export const buildSecretConfig = (
secretType: "static",
secret: buildKubernetesSecret(formData),
}),
github: () => ({
secretType: "static",
secret: buildGitHubSecret(formData),
}),
};
const builder = secretBuilders[providerType];
@@ -186,25 +233,3 @@ export const buildSecretConfig = (
return builder();
};
// Helper function to handle API responses consistently
export const handleApiResponse = async (
response: Response,
pathToRevalidate?: string,
) => {
const data = await response.json();
if (pathToRevalidate) {
revalidatePath(pathToRevalidate);
}
return parseStringify(data);
};
// Helper function to handle API errors consistently
export const handleApiError = (error: unknown) => {
console.error(error);
return {
error: getErrorMessage(error),
};
};

View File

@@ -8,6 +8,7 @@ export const ProviderCredentialFields = {
CREDENTIALS_TYPE: "credentials_type",
CREDENTIALS_TYPE_AWS: "aws-sdk-default",
CREDENTIALS_TYPE_ACCESS_SECRET_KEY: "access-secret-key",
// Base fields for all providers
PROVIDER_ID: "providerId",
PROVIDER_TYPE: "providerType",
@@ -35,6 +36,12 @@ export const ProviderCredentialFields = {
// Kubernetes fields
KUBECONFIG_CONTENT: "kubeconfig_content",
// GitHub fields
PERSONAL_ACCESS_TOKEN: "personal_access_token",
OAUTH_APP_TOKEN: "oauth_app_token",
GITHUB_APP_ID: "github_app_id",
GITHUB_APP_KEY: "github_app_key_content",
} as const;
// Type for credential field values
@@ -59,6 +66,10 @@ export const ErrorPointers = {
SESSION_DURATION: "/data/attributes/secret/session_duration",
ROLE_SESSION_NAME: "/data/attributes/secret/role_session_name",
SERVICE_ACCOUNT_KEY: "/data/attributes/secret/service_account_key",
PERSONAL_ACCESS_TOKEN: "/data/attributes/secret/personal_access_token",
OAUTH_APP_TOKEN: "/data/attributes/secret/oauth_app_token",
GITHUB_APP_ID: "/data/attributes/secret/github_app_id",
GITHUB_APP_KEY: "/data/attributes/secret/github_app_key_content",
} as const;
export type ErrorPointer = (typeof ErrorPointers)[keyof typeof ErrorPointers];

View File

@@ -2,6 +2,7 @@ import {
ProviderEntity,
ProviderProps,
ProvidersApiResponse,
ProviderType,
} from "@/types/providers";
export const extractProviderUIDs = (
@@ -38,3 +39,67 @@ export const createProviderDetailsMapping = (
};
});
};
// Helper function to determine which form type to show
export type ProviderFormType =
| "selector"
| "credentials"
| "role"
| "service-account"
| null;
export const getProviderFormType = (
providerType: ProviderType,
via?: string,
): ProviderFormType => {
// Providers that need credential type selection
const needsSelector = ["aws", "gcp", "github"].includes(providerType);
// Show selector if no via parameter and provider needs it
if (needsSelector && !via) {
return "selector";
}
// AWS specific forms
if (providerType === "aws") {
if (via === "role") return "role";
if (via === "credentials") return "credentials";
}
// GCP specific forms
if (providerType === "gcp") {
if (via === "service-account") return "service-account";
if (via === "credentials") return "credentials";
}
// GitHub credential types
if (
providerType === "github" &&
["personal_access_token", "oauth_app", "github_app"].includes(via || "")
) {
return "credentials";
}
// Other providers go directly to credentials form
if (!needsSelector) {
return "credentials";
}
return null;
};
// Helper to check if back button should be shown based on via parameter
export const requiresBackButton = (via?: string | null): boolean => {
if (!via) return false;
const validViaTypes = [
"credentials",
"role",
"service-account",
"personal_access_token",
"oauth_app",
"github_app",
];
return validViaTypes.includes(via);
};

View File

@@ -29,6 +29,13 @@ export default auth((req: NextRequest & { auth: any }) => {
if (pathname.startsWith("/billing") && !permissions.manage_billing) {
return NextResponse.redirect(new URL("/profile", req.url));
}
if (
pathname.startsWith("/integrations") &&
!permissions.manage_integrations
) {
return NextResponse.redirect(new URL("/profile", req.url));
}
}
return NextResponse.next();

View File

@@ -190,7 +190,7 @@ export type AWSCredentials = {
};
export type AWSCredentialsRole = {
[ProviderCredentialFields.ROLE_ARN]: string;
[ProviderCredentialFields.ROLE_ARN]?: string;
[ProviderCredentialFields.AWS_ACCESS_KEY_ID]?: string;
[ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]?: string;
[ProviderCredentialFields.AWS_SESSION_TOKEN]?: string;

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { validateMutelistYaml, validateYaml } from "@/lib/yaml";
import { ProviderType } from "./providers";
import { PROVIDER_TYPES, ProviderType } from "./providers";
export const addRoleFormSchema = z.object({
name: z.string().min(1, "Name is required"),
@@ -11,7 +11,7 @@ export const addRoleFormSchema = z.object({
manage_account: z.boolean().default(false),
manage_billing: z.boolean().default(false),
manage_providers: z.boolean().default(false),
// manage_integrations: z.boolean().default(false),
manage_integrations: z.boolean().default(false),
manage_scans: z.boolean().default(false),
unlimited_visibility: z.boolean().default(false),
groups: z.array(z.string()).optional(),
@@ -23,7 +23,7 @@ export const editRoleFormSchema = z.object({
manage_account: z.boolean().default(false),
manage_billing: z.boolean().default(false),
manage_providers: z.boolean().default(false),
// manage_integrations: z.boolean().default(false),
manage_integrations: z.boolean().default(false),
manage_scans: z.boolean().default(false),
unlimited_visibility: z.boolean().default(false),
groups: z.array(z.string()).optional(),
@@ -71,7 +71,7 @@ export const awsCredentialsTypeSchema = z.object({
export const addProviderFormSchema = z
.object({
providerType: z.enum(["aws", "azure", "gcp", "kubernetes", "m365"], {
providerType: z.enum(PROVIDER_TYPES, {
required_error: "Please select a provider type",
}),
})
@@ -105,10 +105,18 @@ export const addProviderFormSchema = z
providerUid: z.string(),
awsCredentialsType: z.string().optional(),
}),
z.object({
providerType: z.literal("github"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
}),
]),
);
export const addCredentialsFormSchema = (providerType: string) =>
export const addCredentialsFormSchema = (
providerType: ProviderType,
via?: string | null,
) =>
z
.object({
[ProviderCredentialFields.PROVIDER_ID]: z.string(),
@@ -167,7 +175,22 @@ export const addCredentialsFormSchema = (providerType: string) =>
[ProviderCredentialFields.USER]: z.string().optional(),
[ProviderCredentialFields.PASSWORD]: z.string().optional(),
}
: {}),
: providerType === "github"
? {
[ProviderCredentialFields.PERSONAL_ACCESS_TOKEN]: z
.string()
.optional(),
[ProviderCredentialFields.OAUTH_APP_TOKEN]: z
.string()
.optional(),
[ProviderCredentialFields.GITHUB_APP_ID]: z
.string()
.optional(),
[ProviderCredentialFields.GITHUB_APP_KEY]: z
.string()
.optional(),
}
: {}),
})
.superRefine((data: Record<string, any>, ctx) => {
if (providerType === "m365") {
@@ -190,6 +213,42 @@ export const addCredentialsFormSchema = (providerType: string) =>
});
}
}
if (providerType === "github") {
// For GitHub, validation depends on the 'via' parameter
if (via === "personal_access_token") {
if (!data[ProviderCredentialFields.PERSONAL_ACCESS_TOKEN]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Personal Access Token is required",
path: [ProviderCredentialFields.PERSONAL_ACCESS_TOKEN],
});
}
} else if (via === "oauth_app") {
if (!data[ProviderCredentialFields.OAUTH_APP_TOKEN]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "OAuth App Token is required",
path: [ProviderCredentialFields.OAUTH_APP_TOKEN],
});
}
} else if (via === "github_app") {
if (!data[ProviderCredentialFields.GITHUB_APP_ID]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "GitHub App ID is required",
path: [ProviderCredentialFields.GITHUB_APP_ID],
});
}
if (!data[ProviderCredentialFields.GITHUB_APP_KEY]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "GitHub App Private Key is required",
path: [ProviderCredentialFields.GITHUB_APP_KEY],
});
}
}
}
});
export const addCredentialsRoleFormSchema = (providerType: string) =>

142
ui/types/integrations.ts Normal file
View File

@@ -0,0 +1,142 @@
import { z } from "zod";
export type IntegrationType = "amazon_s3" | "aws_security_hub" | "jira";
export interface IntegrationProps {
type: "integrations";
id: string;
attributes: {
inserted_at: string;
updated_at: string;
enabled: boolean;
connected: boolean;
connection_last_checked_at: string | null;
integration_type: IntegrationType;
configuration: {
bucket_name?: string;
output_directory?: string;
credentials?: {
aws_access_key_id?: string;
aws_secret_access_key?: string;
aws_session_token?: string;
role_arn?: string;
external_id?: string;
role_session_name?: string;
session_duration?: number;
};
[key: string]: any;
};
url?: string;
};
relationships?: { providers?: { data: { type: "providers"; id: string }[] } };
links: { self: string };
}
const baseS3IntegrationSchema = z.object({
integration_type: z.literal("amazon_s3"),
bucket_name: z.string().min(1, "Bucket name is required"),
output_directory: z.string().min(1, "Output directory is required"),
providers: z.array(z.string()).optional(),
// AWS Credentials fields compatible with AWSCredentialsRole
credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]),
aws_access_key_id: z.string().optional(),
aws_secret_access_key: z.string().optional(),
aws_session_token: z.string().optional(),
// IAM Role fields
role_arn: z.string().optional(),
external_id: z.string().optional(),
role_session_name: z.string().optional(),
session_duration: z.string().optional(),
});
const s3IntegrationValidation = (data: any, ctx: z.RefinementCtx) => {
// If using access-secret-key, require AWS credentials (for create form)
if (data.credentials_type === "access-secret-key") {
if (!data.aws_access_key_id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"AWS Access Key ID is required when using access and secret key",
path: ["aws_access_key_id"],
});
}
if (!data.aws_secret_access_key) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"AWS Secret Access Key is required when using access and secret key",
path: ["aws_secret_access_key"],
});
}
}
// If role_arn is provided, external_id is required
if (data.role_arn && data.role_arn.trim() !== "") {
if (!data.external_id || data.external_id.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "External ID is required when using Role ARN",
path: ["external_id"],
});
}
}
};
const s3IntegrationEditValidation = (data: any, ctx: z.RefinementCtx) => {
// If using access-secret-key, and credentials are provided, require both
if (data.credentials_type === "access-secret-key") {
const hasAccessKey = !!data.aws_access_key_id;
const hasSecretKey = !!data.aws_secret_access_key;
if (hasAccessKey && !hasSecretKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"AWS Secret Access Key is required when providing Access Key ID",
path: ["aws_secret_access_key"],
});
}
if (hasSecretKey && !hasAccessKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"AWS Access Key ID is required when providing Secret Access Key",
path: ["aws_access_key_id"],
});
}
}
// If role_arn is provided, external_id is required
if (data.role_arn && data.role_arn.trim() !== "") {
if (!data.external_id || data.external_id.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "External ID is required when using Role ARN",
path: ["external_id"],
});
}
}
};
export const s3IntegrationFormSchema = baseS3IntegrationSchema
.extend({
credentials_type: z
.enum(["aws-sdk-default", "access-secret-key"])
.default("aws-sdk-default"),
})
.superRefine(s3IntegrationValidation);
export const editS3IntegrationFormSchema = baseS3IntegrationSchema
.extend({
bucket_name: z.string().min(1, "Bucket name is required").optional(),
output_directory: z
.string()
.min(1, "Output directory is required")
.optional(),
providers: z.array(z.string()).optional(),
credentials_type: z
.enum(["aws-sdk-default", "access-secret-key"])
.optional(),
})
.superRefine(s3IntegrationEditValidation);

View File

@@ -1,4 +1,13 @@
export type ProviderType = "aws" | "azure" | "m365" | "gcp" | "kubernetes";
export const PROVIDER_TYPES = [
"aws",
"azure",
"gcp",
"kubernetes",
"m365",
"github",
] as const;
export type ProviderType = (typeof PROVIDER_TYPES)[number];
export interface ProviderProps {
id: string;