mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-01 13:47:21 +00:00
Compare commits
24 Commits
nitpicks/5
...
PRWLR-7732
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc1337a4e6 | ||
|
|
c748e57878 | ||
|
|
71b5f3714c | ||
|
|
ae697d838c | ||
|
|
a5187c6a42 | ||
|
|
e19ed30ac7 | ||
|
|
96ce1461b9 | ||
|
|
9da5fb67c3 | ||
|
|
eb1c1791e4 | ||
|
|
581afd38e6 | ||
|
|
19a735aafe | ||
|
|
2170fbb1ab | ||
|
|
90c6c6b98d | ||
|
|
02b416b4f8 | ||
|
|
1022b5e413 | ||
|
|
d1bad9d9ab | ||
|
|
178f3850be | ||
|
|
d239d299e2 | ||
|
|
88fae9ecae | ||
|
|
a3bff9705c | ||
|
|
75989b09d7 | ||
|
|
9a622f60fe | ||
|
|
7cd1966066 | ||
|
|
77e59203ae |
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@@ -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/**"
|
||||
|
||||
17
.github/workflows/pull-request-merged.yml
vendored
17
.github/workflows/pull-request-merged.yml
vendored
@@ -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) }}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -71,6 +71,7 @@ def upload_s3_integration(
|
||||
Integration.objects.filter(
|
||||
integrationproviderrelationship__provider_id=provider_id,
|
||||
integration_type=Integration.IntegrationChoices.AMAZON_S3,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1 +1,15 @@
|
||||
export * from "./saml";
|
||||
export {
|
||||
createIntegration,
|
||||
deleteIntegration,
|
||||
getIntegration,
|
||||
getIntegrations,
|
||||
testIntegrationConnection,
|
||||
updateIntegration,
|
||||
} from "./integrations";
|
||||
export {
|
||||
createSamlConfig,
|
||||
deleteSamlConfig,
|
||||
getSamlConfig,
|
||||
initiateSamlAuth,
|
||||
updateSamlConfig,
|
||||
} from "./saml";
|
||||
|
||||
316
ui/actions/integrations/integrations.ts
Normal file
316
ui/actions/integrations/integrations.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
60
ui/app/(prowler)/integrations/s3/page.tsx
Normal file
60
ui/app/(prowler)/integrations/s3/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./saml-config-form";
|
||||
@@ -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";
|
||||
|
||||
147
ui/components/integrations/s3/s3-integration-card.tsx
Normal file
147
ui/components/integrations/s3/s3-integration-card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
414
ui/components/integrations/s3/s3-integration-form.tsx
Normal file
414
ui/components/integrations/s3/s3-integration-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
398
ui/components/integrations/s3/s3-integrations-manager.tsx
Normal file
398
ui/components/integrations/s3/s3-integrations-manager.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 = "";
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
201
ui/components/providers/provider-selector.tsx
Normal file
201
ui/components/providers/provider-selector.tsx
Normal 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" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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", "");
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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]
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./github-app-form";
|
||||
export * from "./github-oauth-app-form";
|
||||
export * from "./github-personal-access-token-form";
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./credentials-type";
|
||||
export * from "./radio-group-github-via-credentials-type-form";
|
||||
export * from "./select-via-github";
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./azure-credentials-form";
|
||||
export * from "./github-credentials-form";
|
||||
export * from "./k8s-credentials-form";
|
||||
export * from "./m365-credentials-form";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Navbar } from "../nav-bar/navbar";
|
||||
|
||||
interface ContentLayoutProps {
|
||||
title: string;
|
||||
icon: string | ReactNode;
|
||||
icon?: string | ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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¶m_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¶m_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¶m_AccountId=232136659152¶m_IAMPrincipal=role%2Fprowler*¶m_ExternalId=${externalId}¶m_S3IntegrationBucketName=${bucketName}`,
|
||||
terraform:
|
||||
"https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/terraform/main.tf",
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
142
ui/types/integrations.ts
Normal 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);
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user