mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 17:40:25 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76199ccff3 | ||
|
|
1b7f20fc5b | ||
|
|
79e595f36e | ||
|
|
cbe06314ca | ||
|
|
4b72cc8dd4 | ||
|
|
ce9d46065a | ||
|
|
1c7c5ca5e9 | ||
|
|
35b3ff2c8e | ||
|
|
d7d4cc4849 | ||
|
|
848e9a7fa9 |
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.35.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.36.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
OCI provider secrets no longer require `region`; legacy `region` input is accepted for backwards compatibility but ignored before storing or scanning
|
||||
+1
-1
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.36.0"
|
||||
version = "1.37.0"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.36.0
|
||||
version: 1.37.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@ from api.v1.serializer_utils.integrations import (
|
||||
JiraCredentialSerializer,
|
||||
S3ConfigSerializer,
|
||||
)
|
||||
from api.v1.serializers import ImageProviderSecret, KubernetesProviderSecret
|
||||
from api.v1.serializer_utils.providers import ProviderSecretField
|
||||
from api.v1.serializers import (
|
||||
ImageProviderSecret,
|
||||
KubernetesProviderSecret,
|
||||
OracleCloudProviderSecret,
|
||||
)
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
|
||||
@@ -190,6 +195,64 @@ class TestImageProviderSecret:
|
||||
assert "non_field_errors" in serializer.errors
|
||||
|
||||
|
||||
class TestOracleCloudProviderSecret:
|
||||
def valid_secret(self, **overrides):
|
||||
secret = {
|
||||
"user": "ocid1.user.oc1..aaaaaaaexample",
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
}
|
||||
secret.update(overrides)
|
||||
return secret
|
||||
|
||||
def test_accepts_regionless_secret(self):
|
||||
serializer = OracleCloudProviderSecret(data=self.valid_secret())
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert "region" not in serializer.validated_data
|
||||
|
||||
def test_accepts_and_ignores_region_field(self):
|
||||
secret = self.valid_secret(region="us-phoenix-1")
|
||||
serializer = OracleCloudProviderSecret(data=secret)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
assert "region" not in serializer.validated_data
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"legacy_field, legacy_value",
|
||||
[
|
||||
("region", None),
|
||||
("region", ""),
|
||||
("region", {"name": "us-ashburn-1"}),
|
||||
],
|
||||
)
|
||||
def test_accepts_and_ignores_any_legacy_region_value(
|
||||
self, legacy_field, legacy_value
|
||||
):
|
||||
serializer = OracleCloudProviderSecret(
|
||||
data=self.valid_secret(**{legacy_field: legacy_value})
|
||||
)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
assert legacy_field not in serializer.validated_data
|
||||
|
||||
|
||||
class TestProviderSecretFieldSchema:
|
||||
def test_oraclecloud_schema_includes_legacy_region_field(self):
|
||||
schema = ProviderSecretField._spectacular_annotation["field"]
|
||||
oraclecloud_schema = next(
|
||||
credential_schema
|
||||
for credential_schema in schema["oneOf"]
|
||||
if credential_schema["title"]
|
||||
== "Oracle Cloud Infrastructure (OCI) API Key Credentials"
|
||||
)
|
||||
|
||||
assert oraclecloud_schema["properties"]["region"]["deprecated"] is True
|
||||
|
||||
|
||||
class TestKubernetesProviderSecret:
|
||||
def test_valid_static_kubeconfig_is_accepted(self):
|
||||
kubeconfig_content = """
|
||||
|
||||
@@ -171,6 +171,53 @@ class TestInitializeProwlerProvider:
|
||||
key="value", mutelist_content={"key": "value"}
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_initialize_oraclecloud_provider_removes_region_string(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
|
||||
provider.secret.secret = {
|
||||
"user": "ocid1.user.oc1..fake",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..fake",
|
||||
"region": "us-ashburn-1",
|
||||
}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
initialize_prowler_provider(provider)
|
||||
|
||||
mock_return_prowler_provider.return_value.assert_called_once_with(
|
||||
user="ocid1.user.oc1..fake",
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..fake",
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_initialize_oraclecloud_provider_without_region_omits_scan_filter(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
|
||||
provider.secret.secret = {
|
||||
"user": "ocid1.user.oc1..fake",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..fake",
|
||||
}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
initialize_prowler_provider(provider)
|
||||
|
||||
mock_return_prowler_provider.return_value.assert_called_once_with(
|
||||
user="ocid1.user.oc1..fake",
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..fake",
|
||||
)
|
||||
|
||||
|
||||
class TestProwlerProviderConnectionTest:
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
@@ -185,6 +232,37 @@ class TestProwlerProviderConnectionTest:
|
||||
key="value", provider_id="1234567890", raise_on_exception=False
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_oraclecloud_connection_test_uses_direct_credentials_without_region(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.uid = "ocid1.tenancy.oc1..aaaaaaaexample"
|
||||
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
|
||||
provider.secret.secret = {
|
||||
"user": "ocid1.user.oc1..aaaaaaaexample",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
prowler_provider_connection_test(provider)
|
||||
|
||||
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
|
||||
user="ocid1.user.oc1..aaaaaaaexample",
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
region=getattr(
|
||||
OraclecloudProvider,
|
||||
"_bootstrap_region",
|
||||
OraclecloudProvider._home_region,
|
||||
),
|
||||
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_prowler_provider_connection_test_without_secret(
|
||||
@@ -356,7 +434,7 @@ class TestGetProwlerProviderKwargs:
|
||||
expected_result = {**secret_dict, **expected_extra_kwargs}
|
||||
assert result == expected_result
|
||||
|
||||
def test_get_prowler_provider_kwargs_oraclecloud_converts_region_string_to_set(
|
||||
def test_get_prowler_provider_kwargs_oraclecloud_removes_region(
|
||||
self,
|
||||
):
|
||||
secret_dict = {
|
||||
@@ -377,8 +455,13 @@ class TestGetProwlerProviderKwargs:
|
||||
|
||||
result = get_prowler_provider_kwargs(provider)
|
||||
|
||||
expected_result = {**secret_dict, "region": {"us-ashburn-1"}}
|
||||
assert result == expected_result
|
||||
assert result == {
|
||||
"user": "ocid1.user.oc1..fake",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
|
||||
"tenancy": "ocid1.tenancy.oc1..fake",
|
||||
"pass_phrase": "fake-passphrase",
|
||||
}
|
||||
|
||||
def test_get_prowler_provider_kwargs_with_mutelist(self):
|
||||
provider_uid = "provider_uid"
|
||||
|
||||
@@ -2917,6 +2917,48 @@ class TestProviderGroupViewSet:
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProviderSecretViewSet:
|
||||
@staticmethod
|
||||
def _oraclecloud_secret(**overrides):
|
||||
secret = {
|
||||
"user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq",
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_content": "test-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
}
|
||||
secret.update(overrides)
|
||||
return secret
|
||||
|
||||
def _create_oraclecloud_secret(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
secret,
|
||||
name="OCI Secret",
|
||||
):
|
||||
data = {
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"attributes": {
|
||||
"name": name,
|
||||
"secret_type": ProviderSecret.TypeChoices.STATIC,
|
||||
"secret": secret,
|
||||
},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": str(oraclecloud_provider.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
return authenticated_client.post(
|
||||
reverse("providersecret-list"),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
def test_provider_secrets_list(self, authenticated_client, provider_secret_fixture):
|
||||
response = authenticated_client.get(reverse("providersecret-list"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -3076,7 +3118,6 @@ current-context: test-context
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-key-content\n-----END RSA PRIVATE KEY-----",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
"region": "us-ashburn-1",
|
||||
},
|
||||
),
|
||||
# OCI with API key credentials (with key_file)
|
||||
@@ -3088,7 +3129,6 @@ current-context: test-context
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_file": "/path/to/oci_api_key.pem",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
"region": "us-ashburn-1",
|
||||
},
|
||||
),
|
||||
# OCI with API key credentials (with passphrase)
|
||||
@@ -3100,7 +3140,6 @@ current-context: test-context
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-encrypted-key\n-----END RSA PRIVATE KEY-----",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
"region": "us-ashburn-1",
|
||||
"pass_phrase": "my-secure-passphrase",
|
||||
},
|
||||
),
|
||||
@@ -3258,6 +3297,103 @@ current-context: test-context
|
||||
== data["data"]["relationships"]["provider"]["data"]["id"]
|
||||
)
|
||||
|
||||
def test_provider_secrets_create_oraclecloud_without_region_stores_no_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
):
|
||||
response = self._create_oraclecloud_secret(
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
provider_secret = ProviderSecret.objects.get()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_create_oraclecloud_accepts_and_ignores_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
):
|
||||
response = self._create_oraclecloud_secret(
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(
|
||||
key_content=" test-key-content ", region=" us-ashburn-1 "
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
provider_secret = ProviderSecret.objects.get()
|
||||
assert provider_secret.secret["key_content"] == "test-key-content"
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_update_oraclecloud_without_region_stores_no_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
):
|
||||
create_response = self._create_oraclecloud_secret(
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(),
|
||||
)
|
||||
provider_secret = ProviderSecret.objects.get(
|
||||
id=create_response.json()["data"]["id"]
|
||||
)
|
||||
data = {
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"id": str(provider_secret.id),
|
||||
"attributes": {"secret": self._oraclecloud_secret()},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.patch(
|
||||
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
provider_secret.refresh_from_db()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_update_oraclecloud_accepts_and_ignores_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
):
|
||||
create_response = self._create_oraclecloud_secret(
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(),
|
||||
)
|
||||
provider_secret = ProviderSecret.objects.get(
|
||||
id=create_response.json()["data"]["id"]
|
||||
)
|
||||
data = {
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"id": str(provider_secret.id),
|
||||
"attributes": {
|
||||
"secret": self._oraclecloud_secret(region=" us-ashburn-1 ")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.patch(
|
||||
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
provider_secret.refresh_from_db()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attributes, error_code, error_pointer",
|
||||
(
|
||||
|
||||
@@ -252,12 +252,6 @@ def get_prowler_provider_kwargs(
|
||||
**prowler_provider_kwargs,
|
||||
"filter_accounts": [provider.uid],
|
||||
}
|
||||
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
if isinstance(prowler_provider_kwargs.get("region"), str):
|
||||
prowler_provider_kwargs = {
|
||||
**prowler_provider_kwargs,
|
||||
"region": {prowler_provider_kwargs["region"]},
|
||||
}
|
||||
elif provider.provider == Provider.ProviderChoices.OPENSTACK.value:
|
||||
# clouds_yaml_content, clouds_yaml_cloud and provider_id are validated
|
||||
# in the provider itself, so it's not needed here.
|
||||
@@ -288,6 +282,11 @@ def get_prowler_provider_kwargs(
|
||||
**{k: v for k, v in prowler_provider_kwargs.items() if v},
|
||||
}
|
||||
|
||||
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
prowler_provider_kwargs = _normalize_oraclecloud_provider_kwargs(
|
||||
prowler_provider_kwargs
|
||||
)
|
||||
|
||||
if mutelist_processor:
|
||||
mutelist_content = mutelist_processor.configuration.get("Mutelist", {})
|
||||
# IaC and Image providers don't support mutelist (both use Trivy's built-in logic)
|
||||
@@ -300,6 +299,40 @@ def get_prowler_provider_kwargs(
|
||||
return prowler_provider_kwargs
|
||||
|
||||
|
||||
def _normalize_oraclecloud_provider_kwargs(secret: dict) -> dict:
|
||||
"""Normalize external OCI secret fields into SDK provider kwargs."""
|
||||
prowler_provider_kwargs = secret.copy()
|
||||
prowler_provider_kwargs.pop("region", None)
|
||||
|
||||
return prowler_provider_kwargs
|
||||
|
||||
|
||||
def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
|
||||
"""Normalize external OCI secret fields into test_connection kwargs."""
|
||||
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
|
||||
|
||||
prowler_provider_kwargs = secret.copy()
|
||||
prowler_provider_kwargs.pop("region", None)
|
||||
|
||||
if (
|
||||
prowler_provider_kwargs.get("user")
|
||||
and prowler_provider_kwargs.get("fingerprint")
|
||||
and prowler_provider_kwargs.get("tenancy")
|
||||
and (
|
||||
prowler_provider_kwargs.get("key_content")
|
||||
or prowler_provider_kwargs.get("key_file")
|
||||
)
|
||||
):
|
||||
# Connection validation needs one OCI endpoint, but scans remain unfiltered.
|
||||
prowler_provider_kwargs["region"] = getattr(
|
||||
OraclecloudProvider,
|
||||
"_bootstrap_region",
|
||||
OraclecloudProvider._home_region,
|
||||
)
|
||||
|
||||
return prowler_provider_kwargs
|
||||
|
||||
|
||||
def initialize_prowler_provider(
|
||||
provider: Provider,
|
||||
mutelist_processor: Processor | None = None,
|
||||
@@ -402,6 +435,15 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
|
||||
if prowler_provider_kwargs.get("registry_token"):
|
||||
image_kwargs["registry_token"] = prowler_provider_kwargs["registry_token"]
|
||||
return prowler_provider.test_connection(**image_kwargs)
|
||||
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
oraclecloud_kwargs = _normalize_oraclecloud_connection_test_kwargs(
|
||||
prowler_provider_kwargs
|
||||
)
|
||||
return prowler_provider.test_connection(
|
||||
**oraclecloud_kwargs,
|
||||
provider_id=provider.uid,
|
||||
raise_on_exception=False,
|
||||
)
|
||||
else:
|
||||
return prowler_provider.test_connection(
|
||||
**prowler_provider_kwargs,
|
||||
|
||||
@@ -295,16 +295,21 @@ from rest_framework_json_api import serializers
|
||||
"type": "string",
|
||||
"description": "The OCID of the tenancy.",
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "The OCI region identifier (e.g., us-ashburn-1, us-phoenix-1).",
|
||||
},
|
||||
"pass_phrase": {
|
||||
"type": "string",
|
||||
"description": "The passphrase for the private key, if encrypted.",
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"deprecated": True,
|
||||
"description": "Legacy OCI region field accepted for backwards compatibility but ignored; OCI scans all regions.",
|
||||
},
|
||||
},
|
||||
"required": ["user", "fingerprint", "tenancy", "region"],
|
||||
"required": ["user", "fingerprint", "tenancy"],
|
||||
"anyOf": [
|
||||
{"required": ["key_file"]},
|
||||
{"required": ["key_content"]},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
|
||||
@@ -1672,6 +1672,7 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
|
||||
validation_error.detail[f"secret/{key}"] = value
|
||||
del validation_error.detail[key]
|
||||
raise validation_error
|
||||
return serializer.validated_data
|
||||
|
||||
|
||||
class AwsProviderSecret(serializers.Serializer):
|
||||
@@ -1813,14 +1814,32 @@ class IacProviderSecret(serializers.Serializer):
|
||||
resource_name = "provider-secrets"
|
||||
|
||||
|
||||
class LegacyOCIRegionField(serializers.Field):
|
||||
def to_internal_value(self, data):
|
||||
return data
|
||||
|
||||
def to_representation(self, value):
|
||||
return value
|
||||
|
||||
|
||||
class OracleCloudProviderSecret(serializers.Serializer):
|
||||
user = serializers.CharField()
|
||||
fingerprint = serializers.CharField()
|
||||
key_file = serializers.CharField(required=False)
|
||||
key_content = serializers.CharField(required=False)
|
||||
tenancy = serializers.CharField()
|
||||
region = serializers.CharField()
|
||||
pass_phrase = serializers.CharField(required=False)
|
||||
region = LegacyOCIRegionField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs.pop("region", None)
|
||||
|
||||
if "key_file" not in attrs and "key_content" not in attrs:
|
||||
raise serializers.ValidationError(
|
||||
{"key_file": "Either key_file or key_content must be provided."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
class Meta:
|
||||
resource_name = "provider-secrets"
|
||||
@@ -1965,7 +1984,11 @@ class ProviderSecretCreateSerializer(RLSSerializer, BaseWriteProviderSecretSeria
|
||||
secret = attrs.get("secret")
|
||||
|
||||
validated_attrs = super().validate(attrs)
|
||||
self.validate_secret_based_on_provider(provider.provider, secret_type, secret)
|
||||
validated_secret = self.validate_secret_based_on_provider(
|
||||
provider.provider, secret_type, secret
|
||||
)
|
||||
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
validated_attrs["secret"] = validated_secret
|
||||
return validated_attrs
|
||||
|
||||
|
||||
@@ -1997,7 +2020,11 @@ class ProviderSecretUpdateSerializer(BaseWriteProviderSecretSerializer):
|
||||
secret = attrs.get("secret")
|
||||
|
||||
validated_attrs = super().validate(attrs)
|
||||
self.validate_secret_based_on_provider(provider.provider, secret_type, secret)
|
||||
validated_secret = self.validate_secret_based_on_provider(
|
||||
provider.provider, secret_type, secret
|
||||
)
|
||||
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
validated_attrs["secret"] = validated_secret
|
||||
return validated_attrs
|
||||
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -4762,7 +4762,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.36.0"
|
||||
version = "1.37.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
|
||||
@@ -128,8 +128,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.34.0"
|
||||
PROWLER_API_VERSION="5.34.0"
|
||||
PROWLER_UI_VERSION="5.35.0"
|
||||
PROWLER_API_VERSION="5.35.0"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Jira output rendering supports grouped Finding Group issues with caller-provided links and capped or uncapped finding copy
|
||||
@@ -49,7 +49,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.35.0"
|
||||
prowler_version = "5.36.0"
|
||||
html_logo_url = "https://github.com/prowler-cloud/prowler/"
|
||||
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
|
||||
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
|
||||
|
||||
@@ -417,6 +417,19 @@ class Jira:
|
||||
message=init_error, file=os.path.basename(__file__)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_summary(summary: str) -> str:
|
||||
"""Normalize and truncate a Jira issue summary.
|
||||
|
||||
Args:
|
||||
summary: Raw summary text.
|
||||
|
||||
Returns:
|
||||
The summary collapsed to one line and limited to Jira's 255-character
|
||||
summary maximum.
|
||||
"""
|
||||
return " ".join(summary.split())[:255]
|
||||
|
||||
@staticmethod
|
||||
def _build_code_block_content(code_value: str) -> Optional[Dict]:
|
||||
if not code_value:
|
||||
@@ -1155,6 +1168,101 @@ class Jira:
|
||||
return "#0000FF"
|
||||
return "#000000" # Default black color for unknown severities
|
||||
|
||||
@staticmethod
|
||||
def _adf_colored_strong_marks(color_mark_type: str, color: str) -> list[dict]:
|
||||
"""Build ADF marks for bold text with a Jira color mark.
|
||||
|
||||
Args:
|
||||
color_mark_type: Jira ADF color mark type, such as textColor or
|
||||
backgroundColor.
|
||||
color: Hex color value for the mark.
|
||||
|
||||
Returns:
|
||||
ADF marks for strong colored text.
|
||||
"""
|
||||
return [
|
||||
{"type": "strong"},
|
||||
{"type": color_mark_type, "attrs": {"color": color}},
|
||||
]
|
||||
|
||||
def _adf_severity_marks(
|
||||
self, severity: str = "", severity_color: str | None = None
|
||||
) -> list[dict]:
|
||||
"""Build ADF marks for severity text.
|
||||
|
||||
Args:
|
||||
severity: Finding severity used to derive a color when severity_color
|
||||
is not provided.
|
||||
severity_color: Optional explicit severity color.
|
||||
|
||||
Returns:
|
||||
ADF marks for highlighted severity text.
|
||||
"""
|
||||
color = severity_color or self.get_severity_color(str(severity).lower())
|
||||
return self._adf_colored_strong_marks("backgroundColor", color)
|
||||
|
||||
def _adf_status_marks(
|
||||
self, status: str = "", status_color: str | None = None
|
||||
) -> list[dict]:
|
||||
"""Build ADF marks for status text.
|
||||
|
||||
Args:
|
||||
status: Finding status used to derive a color when status_color is
|
||||
not provided.
|
||||
status_color: Optional explicit status color.
|
||||
|
||||
Returns:
|
||||
ADF marks for colored status text.
|
||||
"""
|
||||
color = status_color or self.get_color_from_status(str(status).upper())
|
||||
return self._adf_colored_strong_marks("textColor", color)
|
||||
|
||||
@staticmethod
|
||||
def _adf_text_node(text: str, marks: list[dict] | None = None) -> dict:
|
||||
"""Build an ADF text node.
|
||||
|
||||
Args:
|
||||
text: Text content for the node.
|
||||
marks: Optional ADF marks to apply to the text.
|
||||
|
||||
Returns:
|
||||
ADF text node with optional marks.
|
||||
"""
|
||||
node = {"type": "text", "text": text}
|
||||
if marks:
|
||||
node["marks"] = marks
|
||||
return node
|
||||
|
||||
def _adf_severity_text_node(
|
||||
self, severity: str = "", severity_color: str | None = None
|
||||
) -> dict:
|
||||
"""Build an ADF text node for severity.
|
||||
|
||||
Args:
|
||||
severity: Severity text to render.
|
||||
severity_color: Optional explicit severity color.
|
||||
|
||||
Returns:
|
||||
ADF text node with severity marks.
|
||||
"""
|
||||
return self._adf_text_node(
|
||||
severity, self._adf_severity_marks(severity, severity_color)
|
||||
)
|
||||
|
||||
def _adf_status_text_node(
|
||||
self, status: str = "", status_color: str | None = None
|
||||
) -> dict:
|
||||
"""Build an ADF text node for status.
|
||||
|
||||
Args:
|
||||
status: Status text to render.
|
||||
status_color: Optional explicit status color.
|
||||
|
||||
Returns:
|
||||
ADF text node with status marks.
|
||||
"""
|
||||
return self._adf_text_node(status, self._adf_status_marks(status, status_color))
|
||||
|
||||
def get_adf_description(
|
||||
self,
|
||||
check_id: str = "",
|
||||
@@ -1293,19 +1401,9 @@ class Jira:
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": severity,
|
||||
"marks": [
|
||||
{"type": "strong"},
|
||||
{
|
||||
"type": "backgroundColor",
|
||||
"attrs": {
|
||||
"color": severity_color,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
self._adf_severity_text_node(
|
||||
severity, severity_color
|
||||
)
|
||||
],
|
||||
}
|
||||
],
|
||||
@@ -1338,17 +1436,7 @@ class Jira:
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": status,
|
||||
"marks": [
|
||||
{"type": "strong"},
|
||||
{
|
||||
"type": "textColor",
|
||||
"attrs": {"color": status_color},
|
||||
},
|
||||
],
|
||||
}
|
||||
self._adf_status_text_node(status, status_color)
|
||||
],
|
||||
}
|
||||
],
|
||||
@@ -1872,6 +1960,239 @@ class Jira:
|
||||
],
|
||||
}
|
||||
|
||||
def get_grouped_adf_description(
|
||||
self,
|
||||
check_id: str = "",
|
||||
check_title: str = "",
|
||||
check_description: str = "",
|
||||
severity: str = "",
|
||||
status: str = "",
|
||||
provider: str = "",
|
||||
service: str = "",
|
||||
affected_failing_resources: int = 0,
|
||||
last_seen: str = "",
|
||||
failing_for: str = "",
|
||||
grouped_resources: list[dict] | None = None,
|
||||
resources_total: int = 0,
|
||||
resources_shown: int = 0,
|
||||
finding_group_url: str = "",
|
||||
finding_group_link_text: str = "",
|
||||
risk: str = "",
|
||||
recommendation_text: str = "",
|
||||
recommendation_url: str = "",
|
||||
) -> dict:
|
||||
"""Build a Jira ADF description for a grouped finding issue.
|
||||
|
||||
Args:
|
||||
check_id: Finding check ID.
|
||||
check_title: Finding check title.
|
||||
check_description: Finding check description.
|
||||
severity: Finding group severity.
|
||||
status: Finding group status.
|
||||
provider: Cloud provider name.
|
||||
service: Provider service name.
|
||||
affected_failing_resources: Number of failing resources in the group.
|
||||
last_seen: Last time the finding group was seen.
|
||||
failing_for: Duration the finding group has been failing.
|
||||
grouped_resources: Resource rows to include in the grouped issue.
|
||||
resources_total: Total number of resources in the group.
|
||||
resources_shown: Number of resources rendered in this Jira issue.
|
||||
finding_group_url: Optional URL for the full finding group.
|
||||
finding_group_link_text: Optional link text for finding_group_url.
|
||||
risk: Risk description for the check.
|
||||
recommendation_text: Remediation recommendation text.
|
||||
recommendation_url: Optional remediation recommendation URL.
|
||||
|
||||
Returns:
|
||||
Jira ADF document describing the finding group.
|
||||
"""
|
||||
|
||||
def _safe(value) -> str:
|
||||
return str(value) if value not in (None, "") else "-"
|
||||
|
||||
def _text(value, marks: list[dict] | None = None) -> dict:
|
||||
node = {"type": "text", "text": _safe(value)}
|
||||
if marks:
|
||||
node["marks"] = marks
|
||||
return node
|
||||
|
||||
def _paragraph(value, marks: list[dict] | None = None) -> dict:
|
||||
return {"type": "paragraph", "content": [_text(value, marks)]}
|
||||
|
||||
def _cell(value, marks: list[dict] | None = None) -> dict:
|
||||
return {"type": "tableCell", "content": [_paragraph(value, marks)]}
|
||||
|
||||
def _content_cell(content: list[dict]) -> dict:
|
||||
return {"type": "tableCell", "content": content}
|
||||
|
||||
def _append_link(content: list[dict], url: str) -> list[dict]:
|
||||
if not url:
|
||||
return content
|
||||
|
||||
link_node = {
|
||||
"type": "text",
|
||||
"text": url,
|
||||
"marks": [{"type": "link", "attrs": {"href": url}}],
|
||||
}
|
||||
if content and content[-1].get("type") == "paragraph":
|
||||
paragraph_content = content[-1].setdefault("content", [])
|
||||
if paragraph_content:
|
||||
last_inline = paragraph_content[-1]
|
||||
if last_inline.get("type") != "text" or not last_inline.get(
|
||||
"text", ""
|
||||
).endswith(" "):
|
||||
paragraph_content.append({"type": "text", "text": " "})
|
||||
paragraph_content.append(link_node)
|
||||
else:
|
||||
content.append({"type": "paragraph", "content": [link_node]})
|
||||
return content
|
||||
|
||||
def _row(cells: list[dict]) -> dict:
|
||||
return {"type": "tableRow", "content": cells}
|
||||
|
||||
strong = [{"type": "strong"}]
|
||||
code = [{"type": "code"}]
|
||||
severity_marks = self._adf_severity_marks(severity)
|
||||
status_marks = self._adf_status_marks(status)
|
||||
recommendation_content = _append_link(
|
||||
self._markdown_converter.convert(_safe(recommendation_text)),
|
||||
recommendation_url,
|
||||
)
|
||||
main_rows = [
|
||||
_row([_cell("Check Id", strong), _cell(check_id, code)]),
|
||||
_row([_cell("Check Title", strong), _cell(check_title)]),
|
||||
_row([_cell("Severity", strong), _cell(severity, severity_marks)]),
|
||||
_row([_cell("Status", strong), _cell(status, status_marks)]),
|
||||
_row([_cell("Provider", strong), _cell(provider, code)]),
|
||||
_row([_cell("Service", strong), _cell(service, code)]),
|
||||
_row(
|
||||
[
|
||||
_cell("Affected Failing Resources", strong),
|
||||
_cell(affected_failing_resources, strong),
|
||||
]
|
||||
),
|
||||
_row([_cell("Last Seen", strong), _cell(last_seen)]),
|
||||
_row([_cell("Failing For", strong), _cell(failing_for)]),
|
||||
_row(
|
||||
[
|
||||
_cell("Risk", strong),
|
||||
_content_cell(self._markdown_converter.convert(_safe(risk))),
|
||||
]
|
||||
),
|
||||
_row(
|
||||
[
|
||||
_cell("Recommendation", strong),
|
||||
_content_cell(recommendation_content),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
resource_rows = [
|
||||
_row(
|
||||
[
|
||||
_cell("Resource", strong),
|
||||
_cell("Resource UID", strong),
|
||||
_cell("Provider", strong),
|
||||
_cell("Service", strong),
|
||||
_cell("Account / Tenant", strong),
|
||||
_cell("Status", strong),
|
||||
_cell("Severity", strong),
|
||||
_cell("Region", strong),
|
||||
_cell("Last Seen", strong),
|
||||
_cell("Failing For", strong),
|
||||
_cell("Triage", strong),
|
||||
]
|
||||
)
|
||||
]
|
||||
for resource in grouped_resources or []:
|
||||
resource_status = resource.get("status")
|
||||
resource_severity = str(resource.get("severity", "")).upper()
|
||||
resource_status_marks = self._adf_status_marks(resource_status)
|
||||
resource_severity_marks = self._adf_severity_marks(resource_severity)
|
||||
resource_rows.append(
|
||||
_row(
|
||||
[
|
||||
_cell(resource.get("resource_name"), code),
|
||||
_cell(resource.get("resource_uid"), code),
|
||||
_cell(resource.get("provider"), code),
|
||||
_cell(resource.get("service"), code),
|
||||
_cell(resource.get("provider_account"), code),
|
||||
_cell(resource_status, resource_status_marks),
|
||||
_cell(resource_severity, resource_severity_marks),
|
||||
_cell(resource.get("region"), code),
|
||||
_cell(resource.get("last_seen")),
|
||||
_cell(resource.get("failing_for")),
|
||||
_cell(resource.get("triage")),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
content = [
|
||||
_paragraph("Prowler has discovered the following Finding Group:"),
|
||||
{"type": "table", "attrs": {"layout": "full-width"}, "content": main_rows},
|
||||
]
|
||||
|
||||
content.extend(
|
||||
[
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": {"level": 2},
|
||||
"content": [_text("Affected failing resources")],
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"attrs": {"layout": "full-width"},
|
||||
"content": resource_rows,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
if resources_total > resources_shown:
|
||||
remaining_content = [
|
||||
_text(f"Showing {resources_shown} of {resources_total} Findings.")
|
||||
]
|
||||
if finding_group_url and finding_group_link_text:
|
||||
remaining_content = [
|
||||
_text(
|
||||
f"Showing {resources_shown} of {resources_total} Findings "
|
||||
"in this Jira issue. "
|
||||
),
|
||||
_text(
|
||||
finding_group_link_text,
|
||||
[
|
||||
{
|
||||
"type": "link",
|
||||
"attrs": {"href": finding_group_url},
|
||||
}
|
||||
],
|
||||
),
|
||||
]
|
||||
content.append(
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": remaining_content,
|
||||
}
|
||||
)
|
||||
elif finding_group_url and finding_group_link_text:
|
||||
content.append(
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
_text(
|
||||
finding_group_link_text,
|
||||
[
|
||||
{
|
||||
"type": "link",
|
||||
"attrs": {"href": finding_group_url},
|
||||
}
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return {"type": "doc", "version": 1, "content": content}
|
||||
|
||||
def send_findings(
|
||||
self,
|
||||
findings: list[Finding] = None,
|
||||
@@ -1965,7 +2286,7 @@ class Jira:
|
||||
summary_parts.append(finding.resource_uid)
|
||||
|
||||
summary = " - ".join(summary_parts[1:])
|
||||
summary = f"{summary_parts[0]} {summary}"[:255]
|
||||
summary = self._sanitize_summary(f"{summary_parts[0]} {summary}")
|
||||
|
||||
payload = {
|
||||
"fields": {
|
||||
@@ -2048,11 +2369,13 @@ class Jira:
|
||||
self,
|
||||
check_id: str = "",
|
||||
check_title: str = "",
|
||||
check_description: str = "",
|
||||
severity: str = "",
|
||||
status: str = "",
|
||||
status_extended: str = "",
|
||||
provider: str = "",
|
||||
region: str = "",
|
||||
service: str = "",
|
||||
resource_uid: str = "",
|
||||
resource_name: str = "",
|
||||
risk: str = "",
|
||||
@@ -2069,6 +2392,14 @@ class Jira:
|
||||
issue_labels: list[str] = "",
|
||||
finding_url: str = "",
|
||||
tenant_info: str = "",
|
||||
affected_failing_resources: int = 0,
|
||||
grouped_resources: list[dict] | None = None,
|
||||
resources_total: int = 0,
|
||||
resources_shown: int = 0,
|
||||
last_seen: str = "",
|
||||
failing_for: str = "",
|
||||
finding_group_url: str = "",
|
||||
finding_group_link_text: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Send the finding to Jira
|
||||
@@ -2076,11 +2407,13 @@ class Jira:
|
||||
Args:
|
||||
- check_id: The check ID
|
||||
- check_title: The check title
|
||||
- check_description: The check description
|
||||
- severity: The severity
|
||||
- status: The status
|
||||
- status_extended: The status extended
|
||||
- provider: The provider
|
||||
- region: The region
|
||||
- service: The service
|
||||
- resource_uid: The resource UID
|
||||
- resource_name: The resource name
|
||||
- risk: The risk
|
||||
@@ -2097,6 +2430,15 @@ class Jira:
|
||||
- issue_labels: The issue labels
|
||||
- finding_url: The finding URL
|
||||
- tenant_info: The tenant info
|
||||
- affected_failing_resources: The number of affected failing resources
|
||||
- grouped_resources: The grouped resources to render, or None for a
|
||||
single finding issue
|
||||
- resources_total: The total resources in the finding group
|
||||
- resources_shown: The resources shown in the Jira issue
|
||||
- last_seen: The last time the finding group was seen
|
||||
- failing_for: The duration the finding group has been failing
|
||||
- finding_group_url: The finding group URL
|
||||
- finding_group_link_text: The link text for the finding group URL
|
||||
|
||||
Raises:
|
||||
- JiraRefreshTokenError: Failed to refresh the access token
|
||||
@@ -2140,40 +2482,66 @@ class Jira:
|
||||
|
||||
status_color = self.get_color_from_status(status)
|
||||
severity_color = self.get_severity_color(severity.lower())
|
||||
adf_description = self.get_adf_description(
|
||||
check_id=check_id,
|
||||
check_title=check_title,
|
||||
severity=severity.upper(),
|
||||
severity_color=severity_color,
|
||||
status=status,
|
||||
status_color=status_color,
|
||||
status_extended=status_extended,
|
||||
provider=provider,
|
||||
region=region,
|
||||
resource_uid=resource_uid,
|
||||
resource_name=resource_name,
|
||||
risk=risk,
|
||||
recommendation_text=recommendation_text,
|
||||
recommendation_url=recommendation_url,
|
||||
remediation_code_native_iac=remediation_code_native_iac,
|
||||
remediation_code_terraform=remediation_code_terraform,
|
||||
remediation_code_cli=remediation_code_cli,
|
||||
remediation_code_other=remediation_code_other,
|
||||
resource_tags=resource_tags,
|
||||
compliance=compliance,
|
||||
finding_url=finding_url,
|
||||
tenant_info=tenant_info,
|
||||
)
|
||||
if grouped_resources is not None:
|
||||
adf_description = self.get_grouped_adf_description(
|
||||
check_id=check_id,
|
||||
check_title=check_title,
|
||||
check_description=check_description,
|
||||
severity=severity.upper(),
|
||||
status=status,
|
||||
provider=provider,
|
||||
service=service,
|
||||
affected_failing_resources=affected_failing_resources,
|
||||
last_seen=last_seen,
|
||||
failing_for=failing_for,
|
||||
grouped_resources=grouped_resources,
|
||||
resources_total=resources_total,
|
||||
resources_shown=resources_shown,
|
||||
finding_group_url=finding_group_url,
|
||||
finding_group_link_text=finding_group_link_text,
|
||||
risk=risk,
|
||||
recommendation_text=recommendation_text,
|
||||
recommendation_url=recommendation_url,
|
||||
)
|
||||
else:
|
||||
adf_description = self.get_adf_description(
|
||||
check_id=check_id,
|
||||
check_title=check_title,
|
||||
severity=severity.upper(),
|
||||
severity_color=severity_color,
|
||||
status=status,
|
||||
status_color=status_color,
|
||||
status_extended=status_extended,
|
||||
provider=provider,
|
||||
region=region,
|
||||
resource_uid=resource_uid,
|
||||
resource_name=resource_name,
|
||||
risk=risk,
|
||||
recommendation_text=recommendation_text,
|
||||
recommendation_url=recommendation_url,
|
||||
remediation_code_native_iac=remediation_code_native_iac,
|
||||
remediation_code_terraform=remediation_code_terraform,
|
||||
remediation_code_cli=remediation_code_cli,
|
||||
remediation_code_other=remediation_code_other,
|
||||
resource_tags=resource_tags,
|
||||
compliance=compliance,
|
||||
finding_url=finding_url,
|
||||
tenant_info=tenant_info,
|
||||
)
|
||||
|
||||
summary_parts = ["[Prowler]"]
|
||||
if severity:
|
||||
summary_parts.append(severity.upper())
|
||||
if check_id:
|
||||
summary_parts.append(check_id)
|
||||
if resource_uid:
|
||||
if grouped_resources is not None:
|
||||
summary_parts.append(
|
||||
f"{affected_failing_resources} affected failing resources"
|
||||
)
|
||||
elif resource_uid:
|
||||
summary_parts.append(resource_uid)
|
||||
summary = " - ".join(summary_parts[1:])
|
||||
summary = f"{summary_parts[0]} {summary}"[:255]
|
||||
summary = self._sanitize_summary(f"{summary_parts[0]} {summary}")
|
||||
|
||||
payload = {
|
||||
"fields": {
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
|
||||
name = "prowler"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.14"
|
||||
version = "5.35.0"
|
||||
version = "5.36.0"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
|
||||
@@ -98,6 +98,41 @@ class TestJiraIntegration:
|
||||
return found
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _find_link_mark_by_href(nodes: List[dict], href: str) -> Optional[dict]:
|
||||
for node in nodes:
|
||||
if node.get("type") == "text":
|
||||
for mark in node.get("marks", []):
|
||||
if (
|
||||
mark.get("type") == "link"
|
||||
and mark.get("attrs", {}).get("href") == href
|
||||
):
|
||||
return mark
|
||||
found = TestJiraIntegration._find_link_mark_by_href(
|
||||
node.get("content", []), href
|
||||
)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _collect_link_texts_by_href(nodes: List[dict], href: str) -> List[str]:
|
||||
link_texts: List[str] = []
|
||||
|
||||
for node in nodes:
|
||||
if node.get("type") == "text" and any(
|
||||
mark.get("type") == "link" and mark.get("attrs", {}).get("href") == href
|
||||
for mark in node.get("marks", [])
|
||||
):
|
||||
link_texts.append(node.get("text", ""))
|
||||
link_texts.extend(
|
||||
TestJiraIntegration._collect_link_texts_by_href(
|
||||
node.get("content", []), href
|
||||
)
|
||||
)
|
||||
|
||||
return link_texts
|
||||
|
||||
@staticmethod
|
||||
def _find_table_row(rows: List[dict], header: str) -> dict:
|
||||
for row in rows:
|
||||
@@ -918,6 +953,12 @@ class TestJiraIntegration:
|
||||
intro_text = intro_paragraph["content"][0]
|
||||
assert intro_text["type"] == "text"
|
||||
assert intro_text["text"] == "Prowler has discovered the following finding:"
|
||||
assert all(
|
||||
self._collect_text_from_cell({"content": node.get("content", [])})
|
||||
!= "Summary"
|
||||
for node in description_content
|
||||
if node.get("type") == "heading"
|
||||
)
|
||||
|
||||
table = description_content[1]
|
||||
assert table["type"] == "table"
|
||||
@@ -1201,6 +1242,218 @@ class TestJiraIntegration:
|
||||
value_cell = row["content"][1]
|
||||
assert self._collect_text_from_cell(value_cell) == "-"
|
||||
|
||||
def test_get_grouped_adf_description_uses_capped_finding_group_link_copy(self):
|
||||
finding_group_url = (
|
||||
"https://security.example.com/findings?"
|
||||
"filter%5Bcheck_id%5D=admincenter_users_admins_reduced_license_footprint&"
|
||||
"expandedCheckId=admincenter_users_admins_reduced_license_footprint"
|
||||
)
|
||||
finding_group_link_text = "View the remaining grouped findings."
|
||||
recommendation_url = (
|
||||
"https://hub.prowler.com/check/"
|
||||
"admincenter_users_admins_reduced_license_footprint"
|
||||
)
|
||||
adf_description = self.jira_integration.get_grouped_adf_description(
|
||||
check_id="admincenter_users_admins_reduced_license_footprint",
|
||||
check_title="Administrative user has no license or an allowed license",
|
||||
check_description="Administrative users are assigned productivity licenses.",
|
||||
severity="HIGH",
|
||||
status="FAIL",
|
||||
provider="m365",
|
||||
service="exchange",
|
||||
affected_failing_resources=123,
|
||||
last_seen="Jul 09, 2026 11:38AM UTC",
|
||||
failing_for="< 1 day",
|
||||
grouped_resources=[
|
||||
{
|
||||
"resource_name": "rich@prowler.com",
|
||||
"resource_uid": "3f9a216b-b66b-4d5d-a812-2ad538732cfb",
|
||||
"provider": "m365",
|
||||
"service": "exchange",
|
||||
"provider_account": "ProwlerPro.onmicrosoft.com",
|
||||
"status": "FAIL",
|
||||
"severity": "high",
|
||||
"region": "global",
|
||||
"last_seen": "Jul 09, 2026 11:38AM UTC",
|
||||
"failing_for": "< 1 day",
|
||||
"triage": "Open",
|
||||
}
|
||||
],
|
||||
resources_total=123,
|
||||
resources_shown=100,
|
||||
finding_group_url=finding_group_url,
|
||||
finding_group_link_text=finding_group_link_text,
|
||||
risk="Productivity licenses on privileged identities create risk.",
|
||||
recommendation_text="Maintain dedicated admin accounts.",
|
||||
recommendation_url=recommendation_url,
|
||||
)
|
||||
|
||||
assert adf_description["type"] == "doc"
|
||||
assert self._find_empty_text_nodes(adf_description) == []
|
||||
|
||||
main_table = adf_description["content"][1]
|
||||
main_rows = {}
|
||||
for row in main_table["content"]:
|
||||
key_cell, value_cell = row["content"]
|
||||
main_rows[self._collect_text_from_cell(key_cell)] = (
|
||||
self._collect_text_from_cell(value_cell)
|
||||
)
|
||||
|
||||
assert (
|
||||
main_rows["Check Id"]
|
||||
== "admincenter_users_admins_reduced_license_footprint"
|
||||
)
|
||||
assert main_rows["Service"] == "exchange"
|
||||
assert main_rows["Affected Failing Resources"] == "123"
|
||||
assert (
|
||||
main_rows["Risk"]
|
||||
== "Productivity licenses on privileged identities create risk."
|
||||
)
|
||||
assert main_rows["Recommendation"] == (
|
||||
"Maintain dedicated admin accounts. " + recommendation_url
|
||||
)
|
||||
assert "Finding Group Link" not in main_rows
|
||||
assert "Region" not in main_rows
|
||||
|
||||
top_level_headings = [
|
||||
self._collect_text_from_cell({"content": node.get("content", [])})
|
||||
for node in adf_description["content"]
|
||||
if node.get("type") == "heading"
|
||||
]
|
||||
assert "Risk" not in top_level_headings
|
||||
assert "Recommendation" not in top_level_headings
|
||||
assert "Summary" not in top_level_headings
|
||||
|
||||
def text_marks(cell: dict) -> list[dict]:
|
||||
return cell["content"][0]["content"][0]["marks"]
|
||||
|
||||
severity_marks = text_marks(
|
||||
self._find_table_row(main_table["content"], "Severity")["content"][1]
|
||||
)
|
||||
status_marks = text_marks(
|
||||
self._find_table_row(main_table["content"], "Status")["content"][1]
|
||||
)
|
||||
assert {
|
||||
"type": "backgroundColor",
|
||||
"attrs": {"color": "#FFA500"},
|
||||
} in severity_marks
|
||||
assert {"type": "textColor", "attrs": {"color": "#FF0000"}} in status_marks
|
||||
|
||||
resource_table = next(
|
||||
node
|
||||
for node in adf_description["content"]
|
||||
if node.get("type") == "table"
|
||||
and self._collect_text_from_cell(node["content"][0]["content"][0])
|
||||
== "Resource"
|
||||
)
|
||||
resource_cells = resource_table["content"][1]["content"]
|
||||
assert {"type": "textColor", "attrs": {"color": "#FF0000"}} in text_marks(
|
||||
resource_cells[5]
|
||||
)
|
||||
assert {
|
||||
"type": "backgroundColor",
|
||||
"attrs": {"color": "#FFA500"},
|
||||
} in text_marks(resource_cells[6])
|
||||
|
||||
document_text = self._collect_text_from_cell(
|
||||
{"content": adf_description["content"]}
|
||||
)
|
||||
assert (
|
||||
"Administrative users are assigned productivity licenses."
|
||||
not in document_text
|
||||
)
|
||||
assert "Affected failing resources" in document_text
|
||||
capped_link_copy = (
|
||||
f"Showing 100 of 123 Findings in this Jira issue. {finding_group_link_text}"
|
||||
)
|
||||
assert document_text.count(capped_link_copy) == 1
|
||||
assert "Finding Group Link" not in document_text
|
||||
assert recommendation_url in document_text
|
||||
recommendation_link_mark = self._find_link_mark_by_href(
|
||||
adf_description["content"], recommendation_url
|
||||
)
|
||||
assert recommendation_link_mark is not None
|
||||
link_mark = self._find_link_mark_by_href(
|
||||
adf_description["content"], finding_group_url
|
||||
)
|
||||
assert link_mark is not None
|
||||
assert link_mark["attrs"]["href"] == finding_group_url
|
||||
assert self._collect_link_texts_by_href(
|
||||
adf_description["content"], finding_group_url
|
||||
) == [finding_group_link_text]
|
||||
assert (
|
||||
len(
|
||||
self._collect_link_texts_by_href(
|
||||
adf_description["content"], finding_group_url
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert "filter%5Bcheck_id%5D=" in link_mark["attrs"]["href"]
|
||||
assert "expandedCheckId=" in link_mark["attrs"]["href"]
|
||||
|
||||
def test_get_grouped_adf_description_includes_link_when_not_capped(self):
|
||||
finding_group_url = (
|
||||
"https://security.example.com/findings?"
|
||||
"filter%5Bcheck_id%5D=s3_bucket_public_access&"
|
||||
"expandedCheckId=s3_bucket_public_access"
|
||||
)
|
||||
finding_group_link_text = "View this grouped finding."
|
||||
adf_description = self.jira_integration.get_grouped_adf_description(
|
||||
check_id="s3_bucket_public_access",
|
||||
check_title="S3 bucket public access",
|
||||
severity="HIGH",
|
||||
status="FAIL",
|
||||
provider="aws",
|
||||
service="s3",
|
||||
affected_failing_resources=1,
|
||||
grouped_resources=[
|
||||
{
|
||||
"resource_name": "bucket-a",
|
||||
"resource_uid": "arn:aws:s3:::bucket-a",
|
||||
"provider": "aws",
|
||||
"service": "s3",
|
||||
"provider_account": "production (123456789012)",
|
||||
"status": "FAIL",
|
||||
"severity": "high",
|
||||
"region": "us-east-1",
|
||||
"last_seen": "Jul 09, 2026 11:38AM UTC",
|
||||
"failing_for": "< 1 day",
|
||||
"triage": "Open",
|
||||
}
|
||||
],
|
||||
resources_total=1,
|
||||
resources_shown=1,
|
||||
finding_group_url=finding_group_url,
|
||||
finding_group_link_text=finding_group_link_text,
|
||||
)
|
||||
|
||||
document_text = self._collect_text_from_cell(
|
||||
{"content": adf_description["content"]}
|
||||
)
|
||||
assert "Showing 1 of 1 Findings." not in document_text
|
||||
assert "remaining Findings" not in document_text
|
||||
assert document_text.count(finding_group_link_text) == 1
|
||||
assert "Finding Group Link" not in document_text
|
||||
main_table = adf_description["content"][1]
|
||||
main_row_headers = [
|
||||
self._collect_text_from_cell(row["content"][0])
|
||||
for row in main_table["content"]
|
||||
]
|
||||
assert "Finding Group Link" not in main_row_headers
|
||||
link_mark = self._find_link_mark_by_href(
|
||||
adf_description["content"], finding_group_url
|
||||
)
|
||||
assert link_mark is not None
|
||||
assert link_mark["attrs"]["href"] == finding_group_url
|
||||
assert self._collect_link_texts_by_href(
|
||||
adf_description["content"], finding_group_url
|
||||
) == [finding_group_link_text]
|
||||
assert (
|
||||
"filter%5Bcheck_id%5D=s3_bucket_public_access" in link_mark["attrs"]["href"]
|
||||
)
|
||||
assert "expandedCheckId=s3_bucket_public_access" in link_mark["attrs"]["href"]
|
||||
|
||||
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
|
||||
@patch.object(
|
||||
Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"]
|
||||
@@ -1709,6 +1962,54 @@ class TestJiraIntegration:
|
||||
assert result is True
|
||||
mock_post.assert_called_once()
|
||||
|
||||
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
|
||||
@patch.object(
|
||||
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
|
||||
)
|
||||
@patch.object(Jira, "get_projects", return_value={"TEST": {"name": "Test Project"}})
|
||||
@patch.object(Jira, "get_available_issue_types", return_value=["Bug"])
|
||||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||||
def test_send_finding_sanitizes_summary_control_characters(
|
||||
self,
|
||||
mock_post,
|
||||
mock_get_issue_types,
|
||||
mock_get_projects,
|
||||
mock_cloud_id,
|
||||
mock_get_access_token,
|
||||
):
|
||||
"""Test that Jira summary is sent as one line."""
|
||||
# To disable vulture
|
||||
mock_cloud_id = mock_cloud_id
|
||||
mock_get_access_token = mock_get_access_token
|
||||
mock_get_projects = mock_get_projects
|
||||
mock_get_issue_types = mock_get_issue_types
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = {"id": "ISSUE-123", "key": "TEST-123"}
|
||||
mock_post.return_value = mock_response
|
||||
long_check_id = "check\nwith\rcontrol\tcharacters " + "x" * 260
|
||||
|
||||
result = self.jira_integration.send_finding(
|
||||
check_id=long_check_id,
|
||||
check_title="Test Finding",
|
||||
severity="High\n",
|
||||
status="FAIL",
|
||||
project_key="TEST",
|
||||
issue_type="Bug",
|
||||
affected_failing_resources=2,
|
||||
grouped_resources=[],
|
||||
)
|
||||
|
||||
assert result is True
|
||||
payload = mock_post.call_args.kwargs["json"]
|
||||
expected_summary = (
|
||||
f"[Prowler] HIGH - {' '.join(long_check_id.split())} - "
|
||||
"2 affected failing resources"
|
||||
)[:255]
|
||||
assert payload["fields"]["summary"] == expected_summary
|
||||
assert len(payload["fields"]["summary"]) == 255
|
||||
|
||||
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
|
||||
@patch.object(
|
||||
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Billing navigation is hidden when Cloud billing is disabled, including Enterprise deployments
|
||||
@@ -0,0 +1 @@
|
||||
OCI provider E2E tests no longer require or submit a region when adding or updating credentials
|
||||
@@ -0,0 +1 @@
|
||||
Provider wizard modal state no longer loops or closes before the launch step
|
||||
@@ -24,10 +24,15 @@ interface AppSidebarContentProps {
|
||||
export function AppSidebarContent({ onSelect }: AppSidebarContentProps) {
|
||||
const pathname = usePathname();
|
||||
const { permissions } = useAuth();
|
||||
const { apiDocsUrl } = useRuntimeConfig();
|
||||
const { apiDocsUrl, cloudBillingEnabled } = useRuntimeConfig();
|
||||
const mode = useAppSidebarMode((state) => state.mode);
|
||||
const isCloudEnvironment = isCloud();
|
||||
const sections = getNavigationConfig({ pathname, apiDocsUrl, permissions });
|
||||
const sections = getNavigationConfig({
|
||||
pathname,
|
||||
apiDocsUrl,
|
||||
cloudBillingEnabled,
|
||||
permissions,
|
||||
});
|
||||
const showChat = isCloudEnvironment && mode === APP_SIDEBAR_MODE.CHAT;
|
||||
|
||||
return (
|
||||
|
||||
@@ -157,6 +157,7 @@ describe("getNavigationConfig", () => {
|
||||
const billing = getNavigationConfig({
|
||||
pathname: "/billing",
|
||||
apiDocsUrl: null,
|
||||
cloudBillingEnabled: true,
|
||||
permissions,
|
||||
})
|
||||
.flatMap((section) => section.items)
|
||||
@@ -183,17 +184,28 @@ describe("getNavigationConfig", () => {
|
||||
const cloudItems = getNavigationConfig({
|
||||
pathname: "/",
|
||||
apiDocsUrl: null,
|
||||
cloudBillingEnabled: true,
|
||||
permissions,
|
||||
}).flatMap((section) => section.items);
|
||||
const enterpriseItems = getNavigationConfig({
|
||||
pathname: "/",
|
||||
apiDocsUrl: null,
|
||||
cloudBillingEnabled: false,
|
||||
permissions: { ...permissions, manage_billing: true },
|
||||
}).flatMap((section) => section.items);
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
const localItems = getNavigationConfig({
|
||||
pathname: "/",
|
||||
apiDocsUrl: null,
|
||||
cloudBillingEnabled: true,
|
||||
permissions: { ...permissions, manage_billing: true },
|
||||
}).flatMap((section) => section.items);
|
||||
|
||||
// Then
|
||||
expect(cloudItems.find((item) => item.label === "Billing")).toBeUndefined();
|
||||
expect(
|
||||
enterpriseItems.find((item) => item.label === "Billing"),
|
||||
).toBeUndefined();
|
||||
expect(localItems.find((item) => item.label === "Billing")).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
interface NavigationConfigOptions {
|
||||
pathname: string;
|
||||
apiDocsUrl?: string | null;
|
||||
cloudBillingEnabled?: boolean;
|
||||
permissions?: RolePermissionAttributes;
|
||||
}
|
||||
|
||||
@@ -106,6 +107,7 @@ export function filterNavigationByPermissions(
|
||||
export function getNavigationConfig({
|
||||
pathname,
|
||||
apiDocsUrl = null,
|
||||
cloudBillingEnabled = false,
|
||||
permissions,
|
||||
}: NavigationConfigOptions): NavigationSection[] {
|
||||
const isCloudEnvironment = isCloud();
|
||||
@@ -265,7 +267,7 @@ export function getNavigationConfig({
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isCloudEnvironment
|
||||
...(isCloudEnvironment && cloudBillingEnabled
|
||||
? [
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PROVIDER_WIZARD_STEP,
|
||||
} from "@/types/provider-wizard";
|
||||
|
||||
import { WIZARD_FOOTER_ACTION_TYPE } from "../steps/footer-controls";
|
||||
import type { ProviderWizardInitialData } from "../types";
|
||||
import { useProviderWizardController } from "./use-provider-wizard-controller";
|
||||
|
||||
@@ -237,7 +238,7 @@ describe("useProviderWizardController", () => {
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the wizard after a successful connection test in update mode", async () => {
|
||||
it("moves to launch step after a successful connection test in update mode", async () => {
|
||||
// Given
|
||||
const onOpenChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
@@ -265,10 +266,83 @@ describe("useProviderWizardController", () => {
|
||||
result.current.handleTestSuccess();
|
||||
});
|
||||
|
||||
// Credential rotation skips the launch/schedule step.
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(refreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.currentStep).not.toBe(PROVIDER_WIZARD_STEP.LAUNCH);
|
||||
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.LAUNCH);
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the footer setter stable and uses replacement action callbacks", () => {
|
||||
// Given
|
||||
const onOpenChange = vi.fn();
|
||||
const firstOnBack = vi.fn();
|
||||
const firstOnSecondaryAction = vi.fn();
|
||||
const firstOnAction = vi.fn();
|
||||
const latestOnBack = vi.fn();
|
||||
const latestOnSecondaryAction = vi.fn();
|
||||
const latestOnAction = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useProviderWizardController({
|
||||
open: true,
|
||||
onOpenChange,
|
||||
}),
|
||||
);
|
||||
const initialSetFooterConfig = result.current.setFooterConfig;
|
||||
|
||||
const firstFooterConfig = {
|
||||
showBack: true,
|
||||
backLabel: "Back",
|
||||
onBack: firstOnBack,
|
||||
showSecondaryAction: true,
|
||||
secondaryActionLabel: "Cancel",
|
||||
secondaryActionVariant: "outline" as const,
|
||||
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
onSecondaryAction: firstOnSecondaryAction,
|
||||
showAction: true,
|
||||
actionLabel: "Next",
|
||||
actionDisabled: false,
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
onAction: firstOnAction,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.setFooterConfig(firstFooterConfig);
|
||||
});
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.setFooterConfig({
|
||||
...firstFooterConfig,
|
||||
onBack: latestOnBack,
|
||||
onSecondaryAction: latestOnSecondaryAction,
|
||||
onAction: latestOnAction,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
result.current.resolvedFooterConfig.onBack?.();
|
||||
result.current.resolvedFooterConfig.onSecondaryAction?.();
|
||||
result.current.resolvedFooterConfig.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.setFooterConfig).toBe(initialSetFooterConfig);
|
||||
expect(result.current.resolvedFooterConfig).toMatchObject({
|
||||
showBack: true,
|
||||
backLabel: "Back",
|
||||
showSecondaryAction: true,
|
||||
secondaryActionLabel: "Cancel",
|
||||
secondaryActionVariant: "outline",
|
||||
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
showAction: true,
|
||||
actionLabel: "Next",
|
||||
actionDisabled: false,
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
|
||||
});
|
||||
expect(firstOnBack).not.toHaveBeenCalled();
|
||||
expect(firstOnSecondaryAction).not.toHaveBeenCalled();
|
||||
expect(firstOnAction).not.toHaveBeenCalled();
|
||||
expect(latestOnBack).toHaveBeenCalledTimes(1);
|
||||
expect(latestOnSecondaryAction).toHaveBeenCalledTimes(1);
|
||||
expect(latestOnAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not override launch footer config in the controller", () => {
|
||||
|
||||
@@ -82,7 +82,7 @@ export function useProviderWizardController({
|
||||
const [orgCurrentStep, setOrgCurrentStep] = useState<OrgWizardStep>(
|
||||
ORG_WIZARD_STEP.SETUP,
|
||||
);
|
||||
const [footerConfig, setFooterConfig] =
|
||||
const [resolvedFooterConfig, setFooterConfig] =
|
||||
useState<WizardFooterConfig>(EMPTY_FOOTER_CONFIG);
|
||||
const [providerTypeHint, setProviderTypeHint] = useState<ProviderType | null>(
|
||||
null,
|
||||
@@ -220,12 +220,6 @@ export function useProviderWizardController({
|
||||
};
|
||||
|
||||
const handleTestSuccess = () => {
|
||||
if (
|
||||
useProviderWizardStore.getState().mode === PROVIDER_WIZARD_MODE.UPDATE
|
||||
) {
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
|
||||
};
|
||||
|
||||
@@ -254,13 +248,11 @@ export function useProviderWizardController({
|
||||
const docsLink = isProviderFlow
|
||||
? getProviderHelpText(providerTypeHint ?? providerType ?? "").link
|
||||
: DOCS_URLS.AWS_ORGANIZATIONS;
|
||||
const resolvedFooterConfig: WizardFooterConfig = footerConfig;
|
||||
const modalTitle = getProviderWizardModalTitle(mode);
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
docsLink,
|
||||
footerConfig,
|
||||
handleClose,
|
||||
handleDialogOpenChange,
|
||||
handleTestSuccess,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useOrgSetupStore } from "@/store/organizations/store";
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
|
||||
|
||||
import { ProviderWizardModal } from "./provider-wizard-modal";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-scroll-hint", () => ({
|
||||
useScrollHint: () => ({
|
||||
containerRef: vi.fn(),
|
||||
sentinelRef: vi.fn(),
|
||||
showScrollHint: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard/steps/connect-step", () => ({
|
||||
ConnectStep: () => <div>Connect step</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard/steps/credentials-step", () => ({
|
||||
CredentialsStep: ({ onNext }: { onNext: () => void }) => (
|
||||
<div>
|
||||
<div>Credentials step</div>
|
||||
<button type="button" onClick={onNext}>
|
||||
Continue to validate connection
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard/steps/test-connection-step", () => ({
|
||||
TestConnectionStep: ({ onSuccess }: { onSuccess: () => void }) => (
|
||||
<div>
|
||||
<div>Test connection step</div>
|
||||
<button type="button" onClick={onSuccess}>
|
||||
Check connection
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard/steps/launch-step", () => ({
|
||||
LaunchStep: () => <div>Launch step</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/organizations/org-setup-form", () => ({
|
||||
OrgSetupForm: () => <div>Organization setup</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/organizations/org-account-selection", () => ({
|
||||
OrgAccountSelection: () => <div>Organization account selection</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/organizations/org-launch-scan", () => ({
|
||||
OrgLaunchScan: () => <div>Organization launch scan</div>,
|
||||
}));
|
||||
|
||||
describe("ProviderWizardModal", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
useProviderWizardStore.getState().reset();
|
||||
useOrgSetupStore.getState().reset();
|
||||
});
|
||||
|
||||
it("provides an accessible dialog description without requiring visible helper text", () => {
|
||||
// Given
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
// When
|
||||
render(<ProviderWizardModal open onOpenChange={onOpenChange} />);
|
||||
|
||||
// Then
|
||||
const dialog = screen.getByRole("dialog", { name: /adding a provider/i });
|
||||
|
||||
expect(dialog).toHaveAccessibleDescription(/connect or update a provider/i);
|
||||
});
|
||||
|
||||
it("shows the launch progress step when update mode reaches launch", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ProviderWizardModal
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
initialData={{
|
||||
providerId: "provider-1",
|
||||
providerType: "aws",
|
||||
providerUid: "111111111111",
|
||||
providerAlias: "production",
|
||||
secretId: "secret-1",
|
||||
mode: PROVIDER_WIZARD_MODE.UPDATE,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText("Credentials step")).toBeVisible();
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /continue to validate connection/i }),
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: /check connection/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Launch step")).toBeVisible();
|
||||
expect(screen.getByText("Launch Scan")).toBeVisible();
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -33,9 +33,12 @@ import { PROVIDER_WIZARD_STEPS, WizardStepper } from "./wizard-stepper";
|
||||
|
||||
const UPDATE_MODE_WIZARD_STEPS = PROVIDER_WIZARD_STEPS.slice(
|
||||
0,
|
||||
PROVIDER_WIZARD_STEP.LAUNCH,
|
||||
PROVIDER_WIZARD_STEP.LAUNCH + 1,
|
||||
);
|
||||
|
||||
const PROVIDER_WIZARD_MODAL_DESCRIPTION =
|
||||
"Connect or update a provider by adding account details, credentials, testing the connection, and launching a scan.";
|
||||
|
||||
interface ProviderWizardModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -100,6 +103,7 @@ export function ProviderWizardModal({
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
description={PROVIDER_WIZARD_MODAL_DESCRIPTION}
|
||||
size="4xl"
|
||||
className="flex !h-[90vh] !max-h-[90vh] !min-h-[90vh] !w-[calc(100vw-24px)] !max-w-[1192px] flex-col overflow-hidden p-4 sm:!w-[calc(100vw-40px)] sm:p-6 lg:!w-[calc(100vw-64px)] lg:p-8"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
|
||||
import { ConnectStep } from "./connect-step";
|
||||
|
||||
type ConnectStepUiState = {
|
||||
showBack: boolean;
|
||||
showAction: boolean;
|
||||
actionLabel: string;
|
||||
actionDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
type CapturedConnectAccountFormProps = {
|
||||
onUiStateChange?: (state: ConnectStepUiState) => void;
|
||||
};
|
||||
|
||||
const { capturedConnectAccountFormProps } = vi.hoisted(() => ({
|
||||
capturedConnectAccountFormProps: {
|
||||
current: null as CapturedConnectAccountFormProps | null,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/workflow/forms", () => ({
|
||||
ConnectAccountForm: (props: CapturedConnectAccountFormProps) => {
|
||||
capturedConnectAccountFormProps.current = props;
|
||||
|
||||
return <div data-testid="connect-account-form" />;
|
||||
},
|
||||
}));
|
||||
|
||||
describe("ConnectStep", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
capturedConnectAccountFormProps.current = null;
|
||||
useProviderWizardStore.getState().reset();
|
||||
});
|
||||
|
||||
it("does not republish footer config for repeated unchanged form UI state", async () => {
|
||||
// Given
|
||||
const onFooterChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ConnectStep
|
||||
onNext={vi.fn()}
|
||||
onSelectOrganizations={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
onProviderTypeChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onFooterChange).toHaveBeenCalledTimes(1));
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
const unchangedUiState = {
|
||||
showBack: false,
|
||||
showAction: false,
|
||||
actionLabel: "Next",
|
||||
actionDisabled: true,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
capturedConnectAccountFormProps.current?.onUiStateChange?.(
|
||||
unchangedUiState,
|
||||
);
|
||||
capturedConnectAccountFormProps.current?.onUiStateChange?.({
|
||||
...unchangedUiState,
|
||||
});
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(onFooterChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("publishes a new footer config when form UI state changes", async () => {
|
||||
// Given
|
||||
const onFooterChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ConnectStep
|
||||
onNext={vi.fn()}
|
||||
onSelectOrganizations={vi.fn()}
|
||||
onFooterChange={onFooterChange}
|
||||
onProviderTypeChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onFooterChange).toHaveBeenCalledTimes(1));
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
capturedConnectAccountFormProps.current?.onUiStateChange?.({
|
||||
showBack: true,
|
||||
showAction: true,
|
||||
actionLabel: "Next",
|
||||
actionDisabled: false,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(onFooterChange).toHaveBeenCalledTimes(2));
|
||||
expect(onFooterChange.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
showBack: true,
|
||||
showAction: true,
|
||||
actionDisabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,35 @@ import {
|
||||
WizardFooterConfig,
|
||||
} from "./footer-controls";
|
||||
|
||||
type ConnectStepUiState = {
|
||||
showBack: boolean;
|
||||
showAction: boolean;
|
||||
actionLabel: string;
|
||||
actionDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const CONNECT_STEP_INITIAL_UI_STATE: ConnectStepUiState = {
|
||||
showBack: false,
|
||||
showAction: false,
|
||||
actionLabel: "Next",
|
||||
actionDisabled: true,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
function isSameConnectStepUiState(
|
||||
current: ConnectStepUiState,
|
||||
next: ConnectStepUiState,
|
||||
) {
|
||||
return (
|
||||
current.showBack === next.showBack &&
|
||||
current.showAction === next.showAction &&
|
||||
current.actionLabel === next.actionLabel &&
|
||||
current.actionDisabled === next.actionDisabled &&
|
||||
current.isLoading === next.isLoading
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectStepProps {
|
||||
onNext: () => void;
|
||||
onSelectOrganizations: () => void;
|
||||
@@ -31,13 +60,7 @@ export function ConnectStep({
|
||||
const { setProvider, setVia, setSecretId, setMode } =
|
||||
useProviderWizardStore();
|
||||
const backHandlerRef = useRef<(() => void) | null>(null);
|
||||
const [uiState, setUiState] = useState({
|
||||
showBack: false,
|
||||
showAction: false,
|
||||
actionLabel: "Next",
|
||||
actionDisabled: true,
|
||||
isLoading: false,
|
||||
});
|
||||
const [uiState, setUiState] = useState(CONNECT_STEP_INITIAL_UI_STATE);
|
||||
|
||||
const formId = "provider-wizard-connect-form";
|
||||
|
||||
@@ -54,6 +77,16 @@ export function ConnectStep({
|
||||
onNext();
|
||||
};
|
||||
|
||||
const handleUiStateChange = (nextUiState: ConnectStepUiState) => {
|
||||
setUiState((currentUiState) => {
|
||||
if (isSameConnectStepUiState(currentUiState, nextUiState)) {
|
||||
return currentUiState;
|
||||
}
|
||||
|
||||
return nextUiState;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onFooterChange({
|
||||
showBack: uiState.showBack,
|
||||
@@ -75,7 +108,7 @@ export function ConnectStep({
|
||||
onSuccess={handleSuccess}
|
||||
onSelectOrganizations={onSelectOrganizations}
|
||||
onProviderTypeChange={onProviderTypeChange}
|
||||
onUiStateChange={setUiState}
|
||||
onUiStateChange={handleUiStateChange}
|
||||
onBackHandlerChange={(handler) => {
|
||||
backHandlerRef.current = handler;
|
||||
}}
|
||||
|
||||
-10
@@ -48,16 +48,6 @@ export const OracleCloudCredentialsForm = ({
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name={ProviderCredentialFields.OCI_REGION}
|
||||
type="text"
|
||||
label="Region"
|
||||
labelPlacement="inside"
|
||||
placeholder="e.g. us-ashburn-1"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardTextareaField
|
||||
control={control}
|
||||
name={ProviderCredentialFields.OCI_KEY_CONTENT}
|
||||
|
||||
@@ -79,6 +79,11 @@ export const Modal = ({
|
||||
)}
|
||||
</DialogHeader>
|
||||
)}
|
||||
{!title && description && (
|
||||
<DialogDescription className="sr-only">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
)}
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -180,7 +180,6 @@ export const useCredentialsForm = ({
|
||||
[ProviderCredentialFields.OCI_FINGERPRINT]: "",
|
||||
[ProviderCredentialFields.OCI_KEY_CONTENT]: "",
|
||||
[ProviderCredentialFields.OCI_TENANCY]: providerUid || "",
|
||||
[ProviderCredentialFields.OCI_REGION]: "",
|
||||
[ProviderCredentialFields.OCI_PASS_PHRASE]: "",
|
||||
};
|
||||
case "mongodbatlas":
|
||||
|
||||
@@ -105,6 +105,8 @@ describe("getRuntimeConfigClient", () => {
|
||||
"reoDevClientId",
|
||||
"sentryDsn",
|
||||
"sentryEnvironment",
|
||||
"stripePublishableKey",
|
||||
"stripePublishableKeyV2",
|
||||
].sort(),
|
||||
);
|
||||
expect(config.apiBaseUrl).toBe("https://api.example.com/api/v1");
|
||||
|
||||
@@ -21,6 +21,8 @@ const pickConfig = (
|
||||
posthogHost: parsed.posthogHost ?? null,
|
||||
reoDevClientId: parsed.reoDevClientId ?? null,
|
||||
cloudBillingEnabled: parsed.cloudBillingEnabled ?? false,
|
||||
stripePublishableKey: parsed.stripePublishableKey ?? null,
|
||||
stripePublishableKeyV2: parsed.stripePublishableKeyV2 ?? null,
|
||||
});
|
||||
|
||||
// Reads the <head> island once (memoized); all-null during SSR or if it's
|
||||
|
||||
@@ -389,10 +389,6 @@ export const buildOracleCloudSecret = (
|
||||
[ProviderCredentialFields.OCI_TENANCY]:
|
||||
providerUid ||
|
||||
getFormValue(formData, ProviderCredentialFields.OCI_TENANCY),
|
||||
[ProviderCredentialFields.OCI_REGION]: getFormValue(
|
||||
formData,
|
||||
ProviderCredentialFields.OCI_REGION,
|
||||
),
|
||||
[ProviderCredentialFields.OCI_PASS_PHRASE]: getFormValue(
|
||||
formData,
|
||||
ProviderCredentialFields.OCI_PASS_PHRASE,
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface RuntimePublicConfig {
|
||||
posthogHost: string | null; // reserved
|
||||
reoDevClientId: string | null; // reserved
|
||||
cloudBillingEnabled: boolean;
|
||||
stripePublishableKey: string | null; // reserved
|
||||
stripePublishableKeyV2: string | null; // reserved
|
||||
}
|
||||
|
||||
export const RUNTIME_CONFIG_SCRIPT_ID = "__PROWLER_RUNTIME_CONFIG__";
|
||||
@@ -25,4 +27,6 @@ export const EMPTY_RUNTIME_PUBLIC_CONFIG: RuntimePublicConfig = {
|
||||
posthogHost: null,
|
||||
reoDevClientId: null,
|
||||
cloudBillingEnabled: false,
|
||||
stripePublishableKey: null,
|
||||
stripePublishableKeyV2: null,
|
||||
};
|
||||
|
||||
@@ -49,5 +49,13 @@ export async function getRuntimePublicConfig(): Promise<RuntimePublicConfig> {
|
||||
// server-side for V1/V2 routing). Default (unset) is off.
|
||||
cloudBillingEnabled:
|
||||
(readEnv("CLOUD_BILLING_ENABLED") ?? "false") !== "false",
|
||||
stripePublishableKey: readEnv(
|
||||
"UI_CLOUD_STRIPE_PUBLISHABLE_KEY",
|
||||
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
|
||||
),
|
||||
stripePublishableKeyV2: readEnv(
|
||||
"UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2",
|
||||
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_V2",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
+10
-4
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import type { NextAuthRequest } from "next-auth";
|
||||
|
||||
import { auth } from "@/auth.config";
|
||||
import { readEnv } from "@/lib/runtime-env";
|
||||
|
||||
const publicRoutes = [
|
||||
"/sign-in",
|
||||
@@ -23,6 +24,8 @@ export default auth((req: NextAuthRequest) => {
|
||||
|
||||
const user = req.auth?.user;
|
||||
const sessionError = req.auth?.error;
|
||||
const cloudBillingEnabled =
|
||||
(readEnv("CLOUD_BILLING_ENABLED") ?? "false") !== "false";
|
||||
|
||||
// If there's a session error (e.g., RefreshAccessTokenError), redirect to login with error info
|
||||
if (sessionError && !isPublicRoute(pathname)) {
|
||||
@@ -38,13 +41,16 @@ export default auth((req: NextAuthRequest) => {
|
||||
return NextResponse.redirect(signInUrl);
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.startsWith("/billing") &&
|
||||
(!cloudBillingEnabled || user?.permissions?.manage_billing !== true)
|
||||
) {
|
||||
return NextResponse.redirect(new URL("/profile", req.url));
|
||||
}
|
||||
|
||||
if (user?.permissions) {
|
||||
const permissions = user.permissions;
|
||||
|
||||
if (pathname.startsWith("/billing") && !permissions.manage_billing) {
|
||||
return NextResponse.redirect(new URL("/profile", req.url));
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.startsWith("/integrations") &&
|
||||
!permissions.manage_integrations
|
||||
|
||||
@@ -224,7 +224,6 @@ export interface OCIProviderCredential {
|
||||
userId?: string;
|
||||
fingerprint?: string;
|
||||
keyContent?: string;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
// AlibabaCloud credential options
|
||||
@@ -366,7 +365,6 @@ export class ProvidersPage extends BasePage {
|
||||
readonly ociUserIdInput: Locator;
|
||||
readonly ociFingerprintInput: Locator;
|
||||
readonly ociKeyContentInput: Locator;
|
||||
readonly ociRegionInput: Locator;
|
||||
|
||||
// AlibabaCloud provider form elements
|
||||
readonly alibabacloudAccountIdInput: Locator;
|
||||
@@ -510,7 +508,6 @@ export class ProvidersPage extends BasePage {
|
||||
this.ociKeyContentInput = page.getByRole("textbox", {
|
||||
name: /Private Key Content/i,
|
||||
});
|
||||
this.ociRegionInput = page.getByRole("textbox", { name: /Region/i });
|
||||
|
||||
// AlibabaCloud provider form inputs
|
||||
this.alibabacloudAccountIdInput = page.getByRole("textbox", {
|
||||
@@ -1300,9 +1297,6 @@ export class ProvidersPage extends BasePage {
|
||||
if (credentials.keyContent) {
|
||||
await this.ociKeyContentInput.fill(credentials.keyContent);
|
||||
}
|
||||
if (credentials.region) {
|
||||
await this.ociRegionInput.fill(credentials.region);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyOCICredentialsPageLoaded(): Promise<void> {
|
||||
@@ -1313,7 +1307,6 @@ export class ProvidersPage extends BasePage {
|
||||
await expect(this.ociUserIdInput).toBeVisible();
|
||||
await expect(this.ociFingerprintInput).toBeVisible();
|
||||
await expect(this.ociKeyContentInput).toBeVisible();
|
||||
await expect(this.ociRegionInput).toBeVisible();
|
||||
}
|
||||
|
||||
async verifyOCIUpdateCredentialsPageLoaded(): Promise<void> {
|
||||
@@ -1324,7 +1317,6 @@ export class ProvidersPage extends BasePage {
|
||||
await expect(this.ociUserIdInput).toBeVisible();
|
||||
await expect(this.ociFingerprintInput).toBeVisible();
|
||||
await expect(this.ociKeyContentInput).toBeVisible();
|
||||
await expect(this.ociRegionInput).toBeVisible();
|
||||
}
|
||||
|
||||
async selectAlibabaCloudProvider(): Promise<void> {
|
||||
|
||||
@@ -667,7 +667,7 @@
|
||||
**Preconditions:**
|
||||
|
||||
- Admin user authentication required (admin.auth.setup setup)
|
||||
- Environment variables configured: E2E_OCI_TENANCY_ID, E2E_OCI_USER_ID, E2E_OCI_FINGERPRINT, E2E_OCI_KEY_CONTENT, E2E_OCI_REGION
|
||||
- Environment variables configured: E2E_OCI_TENANCY_ID, E2E_OCI_USER_ID, E2E_OCI_FINGERPRINT, E2E_OCI_KEY_CONTENT
|
||||
- Remove any existing provider with the same Tenancy ID before starting the test
|
||||
- This test must be run serially and never in parallel with other tests, as it requires the Tenancy ID not to be already registered beforehand.
|
||||
|
||||
@@ -678,7 +678,7 @@
|
||||
3. Select OCI provider type
|
||||
4. Fill provider details (tenancy ID and alias)
|
||||
5. Verify OCI credentials page is loaded
|
||||
6. Fill OCI credentials (user ID, fingerprint, key content, region)
|
||||
6. Fill OCI credentials (user ID, fingerprint, key content)
|
||||
7. Confirm provider connection without launching a scan
|
||||
8. Verify return to Providers page
|
||||
9. Verify provider exists in Providers table
|
||||
@@ -696,7 +696,7 @@
|
||||
- Connect account page displays OCI option
|
||||
- Provider details form accepts tenancy ID and alias
|
||||
- OCI credentials page loads
|
||||
- Credentials form accepts all required fields (user ID, fingerprint, key content, region)
|
||||
- Credentials form accepts all required fields (user ID, fingerprint, key content)
|
||||
- Launch step appears
|
||||
- Successful return to Providers page after closing the launch step
|
||||
- Provider exists in Providers table (verified by tenancy ID)
|
||||
@@ -726,7 +726,7 @@
|
||||
**Preconditions:**
|
||||
|
||||
- Admin user authentication required (admin.auth.setup setup)
|
||||
- Environment variables configured: E2E_OCI_TENANCY_ID, E2E_OCI_USER_ID, E2E_OCI_FINGERPRINT, E2E_OCI_KEY_CONTENT, E2E_OCI_REGION
|
||||
- Environment variables configured: E2E_OCI_TENANCY_ID, E2E_OCI_USER_ID, E2E_OCI_FINGERPRINT, E2E_OCI_KEY_CONTENT
|
||||
- An OCI provider with the specified Tenancy ID must already exist (run PROVIDER-E2E-012 first)
|
||||
- This test must be run serially and never in parallel with other tests
|
||||
|
||||
@@ -738,7 +738,7 @@
|
||||
4. Click "Update Credentials" option
|
||||
5. Verify update credentials page is loaded
|
||||
6. Verify OCI credentials form fields are visible (confirms providerUid is loaded)
|
||||
7. Fill OCI credentials (user ID, fingerprint, key content, region)
|
||||
7. Fill OCI credentials (user ID, fingerprint, key content)
|
||||
8. Click Next to submit
|
||||
9. Verify successful navigation to test connection page
|
||||
|
||||
@@ -756,7 +756,7 @@
|
||||
- OCI provider row is visible in providers table
|
||||
- Row actions dropdown opens and displays "Update Credentials" option
|
||||
- Update credentials page URL contains correct parameters
|
||||
- OCI credentials form displays all fields (tenancy ID, user ID, fingerprint, key content, region)
|
||||
- OCI credentials form displays all required fields (tenancy ID, user ID, fingerprint, key content)
|
||||
- Form submission succeeds (no silent failures due to missing provider UID)
|
||||
- Successful redirect to test connection page
|
||||
|
||||
|
||||
@@ -1029,12 +1029,11 @@ test.describe("Add Provider", () => {
|
||||
const userId = process.env.E2E_OCI_USER_ID ?? "";
|
||||
const fingerprint = process.env.E2E_OCI_FINGERPRINT ?? "";
|
||||
const keyContent = process.env.E2E_OCI_KEY_CONTENT ?? "";
|
||||
const region = process.env.E2E_OCI_REGION ?? "";
|
||||
|
||||
// Setup before each test
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.skip(
|
||||
!tenancyId || !userId || !fingerprint || !keyContent || !region,
|
||||
!tenancyId || !userId || !fingerprint || !keyContent,
|
||||
"OCI E2E env vars are not set",
|
||||
);
|
||||
providersPage = new ProvidersPage(page);
|
||||
@@ -1071,7 +1070,6 @@ test.describe("Add Provider", () => {
|
||||
userId: userId,
|
||||
fingerprint: fingerprint,
|
||||
keyContent: keyContent,
|
||||
region: region,
|
||||
};
|
||||
|
||||
// Navigate to providers page
|
||||
@@ -1516,12 +1514,11 @@ test.describe("Update Provider Credentials", () => {
|
||||
const userId = process.env.E2E_OCI_USER_ID ?? "";
|
||||
const fingerprint = process.env.E2E_OCI_FINGERPRINT ?? "";
|
||||
const keyContent = process.env.E2E_OCI_KEY_CONTENT ?? "";
|
||||
const region = process.env.E2E_OCI_REGION ?? "";
|
||||
|
||||
// Setup before each test
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.skip(
|
||||
!tenancyId || !userId || !fingerprint || !keyContent || !region,
|
||||
!tenancyId || !userId || !fingerprint || !keyContent,
|
||||
"OCI E2E env vars are not set",
|
||||
);
|
||||
providersPage = new ProvidersPage(page);
|
||||
@@ -1543,7 +1540,6 @@ test.describe("Update Provider Credentials", () => {
|
||||
userId: userId,
|
||||
fingerprint: fingerprint,
|
||||
keyContent: keyContent,
|
||||
region: region,
|
||||
};
|
||||
|
||||
// Navigate to providers page
|
||||
|
||||
@@ -20,6 +20,8 @@ export const RUNTIME_CONFIG_KEYS = [
|
||||
"posthogHost",
|
||||
"reoDevClientId",
|
||||
"cloudBillingEnabled",
|
||||
"stripePublishableKey",
|
||||
"stripePublishableKeyV2",
|
||||
] as const satisfies ReadonlyArray<keyof RuntimePublicConfig>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -280,7 +280,6 @@ export type OCICredentials = {
|
||||
[ProviderCredentialFields.OCI_FINGERPRINT]: string;
|
||||
[ProviderCredentialFields.OCI_KEY_CONTENT]: string;
|
||||
[ProviderCredentialFields.OCI_TENANCY]: string;
|
||||
[ProviderCredentialFields.OCI_REGION]: string;
|
||||
[ProviderCredentialFields.OCI_PASS_PHRASE]?: string;
|
||||
[ProviderCredentialFields.PROVIDER_ID]: string;
|
||||
};
|
||||
|
||||
Vendored
+9
-1
@@ -30,6 +30,15 @@ declare global {
|
||||
|
||||
CLOUD_BILLING_ENABLED?: "legacy" | "metronome" | "false";
|
||||
|
||||
// Cloud-only Stripe publishable keys (public; shipped to the browser).
|
||||
// V1 = legacy billing, V2 = metronome.
|
||||
/** @deprecated use UI_CLOUD_STRIPE_PUBLISHABLE_KEY */
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY?: string;
|
||||
UI_CLOUD_STRIPE_PUBLISHABLE_KEY?: string;
|
||||
/** @deprecated use UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2 */
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_V2?: string;
|
||||
UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2?: string;
|
||||
|
||||
// Build-time public config
|
||||
NEXT_PUBLIC_IS_CLOUD_ENV?: "true" | "false";
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION?: string;
|
||||
@@ -134,7 +143,6 @@ declare global {
|
||||
E2E_OCI_USER_ID?: string;
|
||||
E2E_OCI_FINGERPRINT?: string;
|
||||
E2E_OCI_KEY_CONTENT?: string;
|
||||
E2E_OCI_REGION?: string;
|
||||
|
||||
// E2E Alibaba Cloud
|
||||
E2E_ALIBABACLOUD_ACCOUNT_ID?: string;
|
||||
|
||||
@@ -220,3 +220,23 @@ users:
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addCredentialsFormSchema - oraclecloud", () => {
|
||||
const BASE_OCI_VALUES = {
|
||||
[ProviderCredentialFields.PROVIDER_ID]: "provider-oci-1",
|
||||
[ProviderCredentialFields.PROVIDER_TYPE]: "oraclecloud",
|
||||
[ProviderCredentialFields.OCI_USER]: "ocid1.user.oc1..example",
|
||||
[ProviderCredentialFields.OCI_FINGERPRINT]: "aa:bb:cc:dd",
|
||||
[ProviderCredentialFields.OCI_KEY_CONTENT]:
|
||||
"-----BEGIN PRIVATE KEY-----\nMIIEvQ...\n-----END PRIVATE KEY-----",
|
||||
[ProviderCredentialFields.OCI_TENANCY]: "ocid1.tenancy.oc1..example",
|
||||
} as const;
|
||||
|
||||
it("accepts OCI API key credentials without region", () => {
|
||||
const schema = addCredentialsFormSchema("oraclecloud");
|
||||
|
||||
const result = schema.safeParse(BASE_OCI_VALUES);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -288,9 +288,6 @@ export const addCredentialsFormSchema = (
|
||||
[ProviderCredentialFields.OCI_TENANCY]: z
|
||||
.string()
|
||||
.min(1, "Tenancy OCID is required"),
|
||||
[ProviderCredentialFields.OCI_REGION]: z
|
||||
.string()
|
||||
.min(1, "Region is required"),
|
||||
[ProviderCredentialFields.OCI_PASS_PHRASE]: z
|
||||
.union([z.string(), z.literal("")])
|
||||
.optional(),
|
||||
|
||||
Reference in New Issue
Block a user