diff --git a/.github/workflows/ui-pull-request.yml b/.github/workflows/ui-pull-request.yml index c67014dc73..ad22630e32 100644 --- a/.github/workflows/ui-pull-request.yml +++ b/.github/workflows/ui-pull-request.yml @@ -53,16 +53,20 @@ jobs: AUTH_TRUST_HOST: true NEXTAUTH_URL: http://localhost:3000 PROWLER_API_PORT: 8080 - NEXT_PUBLIC_API_BASE_URL: http://localhost:8080/api/v1 + NEXT_PUBLIC_API_BASE_URL: ${{ secrets.API_BASE_URL || 'http://localhost:8080/api/v1' }} + E2E_USER: ${{ secrets.E2E_USER }} + E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }} steps: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Start needed services with docker compose + if: github.repository == 'prowler-cloud/prowler' run: | docker compose up -d api worker worker-beat - name: Wait for prowler-api to respond + if: github.repository == 'prowler-cloud/prowler' run: | echo "Waiting for prowler-api..." for i in {1..30}; do @@ -74,6 +78,7 @@ jobs: sleep 3 done - name: Run database migrations + if: github.repository == 'prowler-cloud/prowler' run: | echo "Running Django migrations..." docker compose exec -T api sh -c ' @@ -81,9 +86,11 @@ jobs: ' echo "Database migrations completed!" - name: Copy local fixtures into API container + if: github.repository == 'prowler-cloud/prowler' run: | docker cp ./api/src/backend/api/fixtures/dev/. prowler-api-1:/home/prowler/backend/api/fixtures/dev - name: Load database fixtures for e2e tests + if: github.repository == 'prowler-cloud/prowler' run: | docker compose exec -T api sh -c ' echo "Loading all fixtures from api/fixtures/dev/..." @@ -131,7 +138,7 @@ jobs: retention-days: 30 - name: Cleanup services - if: always() + if: github.repository == 'prowler-cloud/prowler' run: | echo "Shutting down services..." docker-compose down -v || true diff --git a/contrib/wazuh/prowler-wrapper.py b/contrib/wazuh/prowler-wrapper.py index a76344a00b..e7e7faa9af 100644 --- a/contrib/wazuh/prowler-wrapper.py +++ b/contrib/wazuh/prowler-wrapper.py @@ -23,6 +23,7 @@ import argparse import json import os import re +import shlex import signal import socket import subprocess @@ -145,11 +146,11 @@ def _get_script_arguments(): def _run_prowler(prowler_args): _debug("Running prowler with args: {0}".format(prowler_args), 1) - _prowler_command = "{prowler}/prowler {args}".format( - prowler=PATH_TO_PROWLER, args=prowler_args + _prowler_command = shlex.split( + "{prowler}/prowler {args}".format(prowler=PATH_TO_PROWLER, args=prowler_args) ) - _debug("Running command: {0}".format(_prowler_command), 2) - _process = subprocess.Popen(_prowler_command, stdout=subprocess.PIPE, shell=True) + _debug("Running command: {0}".format(" ".join(_prowler_command)), 2) + _process = subprocess.Popen(_prowler_command, stdout=subprocess.PIPE) _output, _error = _process.communicate() _debug("Raw prowler output: {0}".format(_output), 3) _debug("Raw prowler error: {0}".format(_error), 3) diff --git a/docs/getting-started/requirements.md b/docs/getting-started/requirements.md index d2be93c630..eb623f5f28 100644 --- a/docs/getting-started/requirements.md +++ b/docs/getting-started/requirements.md @@ -109,13 +109,12 @@ Prowler will follow the same credentials search as [Google authentication librar Prowler for Google Cloud needs the following permissions to be set: -- **Viewer (`roles/viewer`) IAM role**: granted at the project / folder / org level in order to scan the target projects +- **Reader (`roles/reader`) IAM role**: granted at the project / folder / org level in order to scan the target projects - **Project level settings**: you need to have at least one project with the below settings: - Identity and Access Management (IAM) API (`iam.googleapis.com`) enabled by either using the [Google Cloud API UI](https://console.cloud.google.com/apis/api/iam.googleapis.com/metrics) or by using the gcloud CLI `gcloud services enable iam.googleapis.com --project ` command - - Service Usage Consumer (`roles/serviceusage.serviceUsageConsumer`) IAM role - Set the quota project to be this project by either running `gcloud auth application-default set-quota-project ` or by setting an environment variable: `export GOOGLE_CLOUD_QUOTA_PROJECT=` diff --git a/docs/tutorials/gcp/authentication.md b/docs/tutorials/gcp/authentication.md index 971796a753..da4104041d 100644 --- a/docs/tutorials/gcp/authentication.md +++ b/docs/tutorials/gcp/authentication.md @@ -51,7 +51,7 @@ Prowler follows the same search order as [Google authentication libraries](https ???+ note The credentials must belong to a user or service account with the necessary permissions. - To ensure full access, assign the roles/viewer IAM role to the identity being used. + To ensure full access, assign the roles/reader IAM role to the identity being used. ???+ note Prowler will use the enabled Google Cloud APIs to get the information needed to perform the checks. @@ -63,13 +63,12 @@ Prowler follows the same search order as [Google authentication libraries](https Prowler for Google Cloud needs the following permissions to be set: -- **Viewer (`roles/viewer`) IAM role**: granted at the project / folder / org level in order to scan the target projects +- **Reader (`roles/reader`) IAM role**: granted at the project / folder / org level in order to scan the target projects - **Project level settings**: you need to have at least one project with the below settings: - Identity and Access Management (IAM) API (`iam.googleapis.com`) enabled by either using the [Google Cloud API UI](https://console.cloud.google.com/apis/api/iam.googleapis.com/metrics) or by using the gcloud CLI `gcloud services enable iam.googleapis.com --project ` command - - Service Usage Consumer (`roles/serviceusage.serviceUsageConsumer`) IAM role - Set the quota project to be this project by either running `gcloud auth application-default set-quota-project ` or by setting an environment variable: `export GOOGLE_CLOUD_QUOTA_PROJECT=` diff --git a/docs/tutorials/gcp/organization.md b/docs/tutorials/gcp/organization.md index 43a58aa7b9..272fd6b7c6 100644 --- a/docs/tutorials/gcp/organization.md +++ b/docs/tutorials/gcp/organization.md @@ -9,7 +9,7 @@ prowler gcp --organization-id organization-id ``` ???+ warning - Make sure that the used credentials have the role Cloud Asset Viewer (`roles/cloudasset.viewer`) or Cloud Asset Owner (`roles/cloudasset.owner`) on the organization level. + Make sure that the used credentials have a role with the `cloudasset.assets.listResource` permission on the organization level like `roles/cloudasset.viewer` (Cloud Asset Viewer) or `roles/cloudasset.owner` (Cloud Asset Owner). ???+ note With this option, Prowler retrieves all projects within the specified organization, including those organized in folders and nested subfolders. This ensures that every project under the organization’s hierarchy is scanned, providing full visibility across the entire organization. diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 1883fe67f9..e8250cf07f 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -5,9 +5,22 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [v5.10.0] (Prowler UNRELEASED) ### Added -- Add `bedrock_api_key_no_administrative_privileges` check for AWS provider [(#8321)](https://github.com/prowler-cloud/prowler/pull/8321) +- `bedrock_api_key_no_administrative_privileges` check for AWS provider [(#8321)](https://github.com/prowler-cloud/prowler/pull/8321) - Support App Key Content in GitHub provider [(#8271)](https://github.com/prowler-cloud/prowler/pull/8271) +### Changed +- Handle some AWS errors as warnings instead of errors [(#8347)](https://github.com/prowler-cloud/prowler/pull/8347) + +### Fixed +- False positives in SQS encryption check for ephemeral queues [(#8330)](https://github.com/prowler-cloud/prowler/pull/8330) + +--- + +## [v5.9.3] (Prowler UNRELEASED) + +### Fixed +- Add more validations to Azure Storage models when some values are None to avoid serialization issues [(#8325)](https://github.com/prowler-cloud/prowler/pull/8325) + --- ## [v5.9.2] (Prowler v5.9.2) diff --git a/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service.py b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service.py index 0005177bdb..c8d821687d 100644 --- a/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service.py +++ b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service.py @@ -1,5 +1,6 @@ from typing import Optional +from botocore.client import ClientError from pydantic.v1 import BaseModel from prowler.lib.logger import logger @@ -71,6 +72,17 @@ class ElasticBeanstalk(AWSService): and option["OptionName"] == "StreamLogs" ): environment.cloudwatch_stream_logs = option.get("Value", "false") + except ClientError as error: + if error.response["Error"]["Code"] in [ + "InvalidParameterValue", + ]: + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) except Exception as error: logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -84,10 +96,17 @@ class ElasticBeanstalk(AWSService): "ResourceTags" ] resource.tags = response - except Exception as error: - logger.error( - f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) + except ClientError as error: + if error.response["Error"]["Code"] in [ + "ResourceNotFoundException", + ]: + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) except Exception as error: logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" diff --git a/prowler/providers/aws/services/iam/iam_service.py b/prowler/providers/aws/services/iam/iam_service.py index 914483081b..1ff3dbd07a 100644 --- a/prowler/providers/aws/services/iam/iam_service.py +++ b/prowler/providers/aws/services/iam/iam_service.py @@ -865,7 +865,10 @@ class IAM(AWSService): SAMLProviderArn=resource.arn ).get("Tags", []) except Exception as error: - if error.response["Error"]["Code"] == "NoSuchEntityException": + if error.response["Error"]["Code"] in [ + "NoSuchEntity", + "NoSuchEntityException", + ]: logger.warning( f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) diff --git a/prowler/providers/aws/services/secretsmanager/secretsmanager_service.py b/prowler/providers/aws/services/secretsmanager/secretsmanager_service.py index 8a85d33502..695072c878 100644 --- a/prowler/providers/aws/services/secretsmanager/secretsmanager_service.py +++ b/prowler/providers/aws/services/secretsmanager/secretsmanager_service.py @@ -2,6 +2,7 @@ import json from datetime import datetime, timezone from typing import Dict, List, Optional +from botocore.client import ClientError from pydantic.v1 import BaseModel, Field from prowler.lib.logger import logger @@ -67,6 +68,21 @@ class SecretsManager(AWSService): ) if secret_policy.get("ResourcePolicy"): secret.policy = json.loads(secret_policy["ResourcePolicy"]) + except ClientError as error: + if error.response["Error"]["Code"] in [ + "ResourceNotFoundException", + ]: + logger.warning( + f"{self.region} --" + f" {error.__class__.__name__}[{error.__traceback__.tb_lineno}]:" + f" {error}" + ) + else: + logger.error( + f"{self.region} --" + f" {error.__class__.__name__}[{error.__traceback__.tb_lineno}]:" + f" {error}" + ) except Exception as error: logger.error( f"{self.region} --" diff --git a/prowler/providers/aws/services/sqs/sqs_service.py b/prowler/providers/aws/services/sqs/sqs_service.py index 598cd625a4..9e6bb0c230 100644 --- a/prowler/providers/aws/services/sqs/sqs_service.py +++ b/prowler/providers/aws/services/sqs/sqs_service.py @@ -51,6 +51,7 @@ class SQS(AWSService): def _get_queue_attributes(self): try: logger.info("SQS - describing queue attributes...") + valid_queues = [] for queue in self.queues: try: regional_client = self.regional_clients[queue.region] @@ -72,6 +73,7 @@ class SQS(AWSService): == "true" ): queue.kms_key_id = "SqsManagedSseEnabled" + valid_queues.append(queue) except ClientError as error: if ( error.response["Error"]["Code"] @@ -84,10 +86,13 @@ class SQS(AWSService): logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + valid_queues.append(queue) except Exception as error: logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + valid_queues.append(queue) + self.queues = valid_queues except Exception as error: logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" diff --git a/prowler/providers/aws/services/ssm/ssm_service.py b/prowler/providers/aws/services/ssm/ssm_service.py index 33f1187993..689047551c 100644 --- a/prowler/providers/aws/services/ssm/ssm_service.py +++ b/prowler/providers/aws/services/ssm/ssm_service.py @@ -99,6 +99,21 @@ class SSM(AWSService): "AccountIds" ] + except ClientError as error: + if error.response["Error"]["Code"] in [ + "InvalidDocumentOperation", + ]: + logger.warning( + f"{regional_client.region} --" + f" {error.__class__.__name__}[{error.__traceback__.tb_lineno}]:" + f" {error}" + ) + else: + logger.error( + f"{regional_client.region} --" + f" {error.__class__.__name__}[{error.__traceback__.tb_lineno}]:" + f" {error}" + ) except Exception as error: logger.error( f"{regional_client.region} --" diff --git a/prowler/providers/azure/services/storage/storage_service.py b/prowler/providers/azure/services/storage/storage_service.py index 887fb80b7b..e93b920380 100644 --- a/prowler/providers/azure/services/storage/storage_service.py +++ b/prowler/providers/azure/services/storage/storage_service.py @@ -70,17 +70,44 @@ class Storage(AzureService): ], key_expiration_period_in_days=key_expiration_period_in_days, location=storage_account.location, - default_to_entra_authorization=getattr( - storage_account, - "default_to_o_auth_authentication", - False, + default_to_entra_authorization=( + False + if getattr( + storage_account, + "default_to_o_auth_authentication", + False, + ) + is None + else getattr( + storage_account, + "default_to_o_auth_authentication", + False, + ) ), replication_settings=replication_settings, - allow_cross_tenant_replication=getattr( - storage_account, "allow_cross_tenant_replication", True + allow_cross_tenant_replication=( + True + if getattr( + storage_account, + "allow_cross_tenant_replication", + True, + ) + is None + else getattr( + storage_account, + "allow_cross_tenant_replication", + True, + ) ), - allow_shared_key_access=getattr( - storage_account, "allow_shared_key_access", True + allow_shared_key_access=( + True + if getattr( + storage_account, "allow_shared_key_access", True + ) + is None + else getattr( + storage_account, "allow_shared_key_access", True + ) ), ) ) diff --git a/tests/contrib/wazuh/prowler_wrapper_security_test.py b/tests/contrib/wazuh/prowler_wrapper_security_test.py new file mode 100644 index 0000000000..0f10144168 --- /dev/null +++ b/tests/contrib/wazuh/prowler_wrapper_security_test.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python +""" +Security test for prowler-wrapper.py command injection vulnerability +This test demonstrates the command injection vulnerability and validates the fix +""" + +import os +import shutil +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch + + +class TestProwlerWrapperSecurity(unittest.TestCase): + """Test cases for command injection vulnerability in prowler-wrapper.py""" + + def setUp(self): + """Set up test environment""" + # Create a temporary directory for testing + self.test_dir = tempfile.mkdtemp() + self.prowler_wrapper_path = os.path.join( + os.path.dirname( + os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + ), + "contrib", + "wazuh", + "prowler-wrapper.py", + ) + + def tearDown(self): + """Clean up test environment""" + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _import_prowler_wrapper(self): + """Helper to import prowler_wrapper with mocked WAZUH_PATH""" + sys.path.insert(0, os.path.dirname(self.prowler_wrapper_path)) + + # Mock the WAZUH_PATH that's read at module level + with patch("builtins.open", create=True) as mock_open: + mock_open.return_value.readline.return_value = 'DIRECTORY="/opt/wazuh"' + + import importlib.util + + spec = importlib.util.spec_from_file_location( + "prowler_wrapper", self.prowler_wrapper_path + ) + prowler_wrapper = importlib.util.module_from_spec(spec) + spec.loader.exec_module(prowler_wrapper) + return prowler_wrapper._run_prowler + + def test_command_injection_semicolon(self): + """Test command injection using semicolon""" + # Create a test file that should not be created if injection is prevented + test_file = os.path.join(self.test_dir, "pwned.txt") + + # Malicious profile that attempts to create a file + malicious_profile = f"test; touch {test_file}" + + # Mock the subprocess.Popen to capture the command + with patch("subprocess.Popen") as mock_popen: + mock_process = MagicMock() + mock_process.communicate.return_value = (b"test output", None) + mock_popen.return_value = mock_process + + # Import and run the vulnerable function + _run_prowler = self._import_prowler_wrapper() + + # Run with malicious input + _run_prowler(f'-p "{malicious_profile}" -V') + + # Check that Popen was called + self.assertTrue(mock_popen.called) + + # Get the actual command that was passed to Popen + actual_command = mock_popen.call_args[0][0] + + # With the fix, the command should be a list (from shlex.split) + # and should NOT have shell=True + self.assertIsInstance( + actual_command, list, "Command should be a list after shlex.split" + ) + + # Check that shell=True is not in the call + call_kwargs = mock_popen.call_args[1] + self.assertNotIn( + "shell", + call_kwargs, + "shell parameter should not be present (defaults to False)", + ) + + def test_command_injection_ampersand(self): + """Test command injection using ampersand""" + # Create a test file that should not be created if injection is prevented + test_file = os.path.join(self.test_dir, "pwned2.txt") + + # Malicious profile that attempts to create a file + malicious_profile = f"test && touch {test_file}" + + with patch("subprocess.Popen") as mock_popen: + mock_process = MagicMock() + mock_process.communicate.return_value = (b"test output", None) + mock_popen.return_value = mock_process + + # Import and run the function + _run_prowler = self._import_prowler_wrapper() + + # Run with malicious input + _run_prowler(f'-p "{malicious_profile}" -V') + + # Get the actual command + actual_command = mock_popen.call_args[0][0] + + # Verify it's a list (safe execution) + self.assertIsInstance(actual_command, list) + + # The malicious characters should be preserved as part of the argument + # not interpreted as shell commands + command_str = " ".join(actual_command) + self.assertIn( + "&&", + command_str, + "Shell metacharacters should be preserved as literals", + ) + + def test_command_injection_pipe(self): + """Test command injection using pipe""" + malicious_profile = 'test | echo "injected"' + + with patch("subprocess.Popen") as mock_popen: + mock_process = MagicMock() + mock_process.communicate.return_value = (b"test output", None) + mock_popen.return_value = mock_process + + # Import and run the function + _run_prowler = self._import_prowler_wrapper() + + # Run with malicious input + _run_prowler(f'-p "{malicious_profile}" -V') + + # Get the actual command + actual_command = mock_popen.call_args[0][0] + + # Verify safe execution + self.assertIsInstance(actual_command, list) + + # Pipe should be preserved as literal + command_str = " ".join(actual_command) + self.assertIn("|", command_str) + + def test_command_injection_backticks(self): + """Test command injection using backticks""" + malicious_profile = "test `echo injected`" + + with patch("subprocess.Popen") as mock_popen: + mock_process = MagicMock() + mock_process.communicate.return_value = (b"test output", None) + mock_popen.return_value = mock_process + + # Import and run the function + _run_prowler = self._import_prowler_wrapper() + + # Run with malicious input + _run_prowler(f'-p "{malicious_profile}" -V') + + # Get the actual command + actual_command = mock_popen.call_args[0][0] + + # Verify safe execution + self.assertIsInstance(actual_command, list) + + # Backticks should be preserved as literals + command_str = " ".join(actual_command) + self.assertIn("`", command_str) + + def test_command_injection_dollar_parentheses(self): + """Test command injection using $() syntax""" + malicious_profile = "test $(echo injected)" + + with patch("subprocess.Popen") as mock_popen: + mock_process = MagicMock() + mock_process.communicate.return_value = (b"test output", None) + mock_popen.return_value = mock_process + + # Import and run the function + _run_prowler = self._import_prowler_wrapper() + + # Run with malicious input + _run_prowler(f'-p "{malicious_profile}" -V') + + # Get the actual command + actual_command = mock_popen.call_args[0][0] + + # Verify safe execution + self.assertIsInstance(actual_command, list) + + # $() should be preserved as literals + command_str = " ".join(actual_command) + self.assertIn("$(", command_str) + + def test_legitimate_profile_name(self): + """Test that legitimate profile names still work correctly""" + legitimate_profile = "production-aws-profile" + + with patch("subprocess.Popen") as mock_popen: + mock_process = MagicMock() + mock_process.communicate.return_value = (b"test output", None) + mock_popen.return_value = mock_process + + # Import and run the function + _run_prowler = self._import_prowler_wrapper() + + # Run with legitimate input + result = _run_prowler(f"-p {legitimate_profile} -V") + + # Verify the function returns output + self.assertEqual(result, b"test output") + + # Verify Popen was called correctly + actual_command = mock_popen.call_args[0][0] + self.assertIsInstance(actual_command, list) + + # Check the profile is passed correctly + command_str = " ".join(actual_command) + self.assertIn(legitimate_profile, command_str) + + def test_shlex_split_behavior(self): + """Test that shlex properly handles quoted arguments""" + profile_with_spaces = "my profile name" + + with patch("subprocess.Popen") as mock_popen: + mock_process = MagicMock() + mock_process.communicate.return_value = (b"test output", None) + mock_popen.return_value = mock_process + + # Import and run the function + _run_prowler = self._import_prowler_wrapper() + + # Run with profile containing spaces + _run_prowler(f'-p "{profile_with_spaces}" -V') + + # Get the actual command + actual_command = mock_popen.call_args[0][0] + + # Verify it's properly split + self.assertIsInstance(actual_command, list) + + # The profile name should be preserved as a single argument + # despite containing spaces + self.assertIn("my profile name", actual_command) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index dc8f55f1eb..d4e0727a43 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Upgrade to Next.js 14.2.30 and lock TypeScript to 5.5.4 for ESLint compatibility [(#8189)](https://github.com/prowler-cloud/prowler/pull/8189) - Improved active step highlighting and updated step titles and descriptions in the Cloud Provider credentials update flow [(#8303)](https://github.com/prowler-cloud/prowler/pull/8303) +- Refactored all existing links across the app to use new custom-link component for consistent styling [(#8341)](https://github.com/prowler-cloud/prowler/pull/8341) ### šŸž Fixed diff --git a/ui/app/(prowler)/error.tsx b/ui/app/(prowler)/error.tsx index d5083d9d33..f1813e0ede 100644 --- a/ui/app/(prowler)/error.tsx +++ b/ui/app/(prowler)/error.tsx @@ -1,10 +1,10 @@ "use client"; -import Link from "next/link"; import { useEffect } from "react"; import { RocketIcon } from "@/components/icons"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui"; +import { CustomLink } from "@/components/ui/custom/custom-link"; export default function Error({ error, @@ -27,9 +27,9 @@ export default function Error({ We're sorry for the inconvenience. Please try again or contact support if the problem persists. - + Go to the homepage - + ); } diff --git a/ui/components/auth/oss/auth-form.tsx b/ui/components/auth/oss/auth-form.tsx index a965204dbc..401a691354 100644 --- a/ui/components/auth/oss/auth-form.tsx +++ b/ui/components/auth/oss/auth-form.tsx @@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Icon } from "@iconify/react"; -import { Button, Checkbox, Divider, Link, Tooltip } from "@nextui-org/react"; +import { Button, Checkbox, Divider, Tooltip } from "@nextui-org/react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -15,6 +15,7 @@ import { NotificationIcon, ProwlerExtended } from "@/components/icons"; import { ThemeSwitch } from "@/components/ThemeSwitch"; import { useToast } from "@/components/ui"; import { CustomButton, CustomInput } from "@/components/ui/custom"; +import { CustomLink } from "@/components/ui/custom/custom-link"; import { Form, FormControl, @@ -301,13 +302,12 @@ export const AuthForm = ({ onChange={(e) => field.onChange(e.target.checked)} > I agree with the  - Terms of Service - +  of Prowler @@ -359,13 +359,9 @@ export const AuthForm = ({ content={
Social Login with Google is not enabled.{" "} - + Read the docs - +
} placement="right-start" @@ -392,13 +388,9 @@ export const AuthForm = ({ content={
Social Login with Github is not enabled.{" "} - + Read the docs - +
} placement="right-start" @@ -451,12 +443,16 @@ export const AuthForm = ({ {type === "sign-in" ? (

Need to create an account?  - Sign up + + Sign up +

) : (

Already have an account?  - Log in + + Log in +

)} diff --git a/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx b/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx index 9317c61485..96ed16a8f1 100644 --- a/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx +++ b/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx @@ -1,3 +1,4 @@ +import { CustomLink } from "@/components/ui/custom/custom-link"; import { SeverityBadge } from "@/components/ui/table"; import { Requirement } from "@/types/compliance"; @@ -7,7 +8,6 @@ import { ComplianceDetailContainer, ComplianceDetailSection, ComplianceDetailText, - ComplianceLink, } from "./shared-components"; export const AWSWellArchitectedCustomDetails = ({ @@ -75,11 +75,9 @@ export const AWSWellArchitectedCustomDetails = ({ {requirement.implementation_guidance_url && ( - + {requirement.implementation_guidance_url as string} - + )} diff --git a/ui/components/compliance/compliance-custom-details/cis-details.tsx b/ui/components/compliance/compliance-custom-details/cis-details.tsx index f804f8e7d0..e9244d6e57 100644 --- a/ui/components/compliance/compliance-custom-details/cis-details.tsx +++ b/ui/components/compliance/compliance-custom-details/cis-details.tsx @@ -1,5 +1,6 @@ import ReactMarkdown from "react-markdown"; +import { CustomLink } from "@/components/ui/custom/custom-link"; import { Requirement } from "@/types/compliance"; import { @@ -8,7 +9,6 @@ import { ComplianceDetailContainer, ComplianceDetailSection, ComplianceDetailText, - ComplianceLink, } from "./shared-components"; interface CISDetailsProps { @@ -121,7 +121,7 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => { {processReferences(requirement.references).map( (url: string, index: number) => (
- {url} + {url}
), )} diff --git a/ui/components/compliance/compliance-custom-details/mitre-details.tsx b/ui/components/compliance/compliance-custom-details/mitre-details.tsx index 71eca0d370..747531af3c 100644 --- a/ui/components/compliance/compliance-custom-details/mitre-details.tsx +++ b/ui/components/compliance/compliance-custom-details/mitre-details.tsx @@ -1,3 +1,4 @@ +import { CustomLink } from "@/components/ui/custom/custom-link"; import { Requirement } from "@/types/compliance"; import { @@ -7,7 +8,6 @@ import { ComplianceDetailContainer, ComplianceDetailSection, ComplianceDetailText, - ComplianceLink, } from "./shared-components"; export const MITRECustomDetails = ({ @@ -63,9 +63,9 @@ export const MITRECustomDetails = ({ {requirement.technique_url && ( - + {requirement.technique_url as string} - + )} diff --git a/ui/components/compliance/compliance-custom-details/shared-components.tsx b/ui/components/compliance/compliance-custom-details/shared-components.tsx index 2d1344b19b..416df6cc9c 100644 --- a/ui/components/compliance/compliance-custom-details/shared-components.tsx +++ b/ui/components/compliance/compliance-custom-details/shared-components.tsx @@ -1,26 +1,5 @@ -import Link from "next/link"; - import { cn } from "@/lib/utils"; -export const ComplianceLink = ({ - href, - children, -}: { - href: string; - children: React.ReactNode; -}) => { - return ( - - {children} - - ); -}; - export const ComplianceDetailContainer = ({ children, }: { diff --git a/ui/components/findings/table/finding-detail.tsx b/ui/components/findings/table/finding-detail.tsx index 06d665658c..00c88e285f 100644 --- a/ui/components/findings/table/finding-detail.tsx +++ b/ui/components/findings/table/finding-detail.tsx @@ -1,10 +1,10 @@ "use client"; import { Snippet } from "@nextui-org/react"; -import Link from "next/link"; import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; import { CustomSection } from "@/components/ui/custom"; +import { CustomLink } from "@/components/ui/custom/custom-link"; import { EntityInfoShort, InfoField } from "@/components/ui/entities"; import { DateWithTime } from "@/components/ui/entities/date-with-time"; import { SeverityBadge } from "@/components/ui/table/severity-badge"; @@ -151,15 +151,14 @@ export const FindingDetail = ({ {attributes.check_metadata.remediation.recommendation.text}

{attributes.check_metadata.remediation.recommendation.url && ( - Learn more - + )} @@ -179,13 +178,12 @@ export const FindingDetail = ({ {/* Additional Resources section */} {attributes.check_metadata.remediation.code.other && ( - View documentation - + )} diff --git a/ui/components/integrations/forms/saml-config-form.tsx b/ui/components/integrations/forms/saml-config-form.tsx index ecea01343f..c9e71d628d 100644 --- a/ui/components/integrations/forms/saml-config-form.tsx +++ b/ui/components/integrations/forms/saml-config-form.tsx @@ -1,6 +1,5 @@ "use client"; -import Link from "next/link"; import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; import { useFormState } from "react-dom"; import { z } from "zod"; @@ -9,6 +8,7 @@ import { createSamlConfig, updateSamlConfig } from "@/actions/integrations"; import { AddIcon } from "@/components/icons"; import { useToast } from "@/components/ui"; import { CustomButton, CustomServerInput } from "@/components/ui/custom"; +import { CustomLink } from "@/components/ui/custom/custom-link"; import { SnippetChip } from "@/components/ui/entities"; import { FormButtons } from "@/components/ui/form"; import { apiBaseUrl } from "@/lib"; @@ -239,9 +239,9 @@ export const SamlConfigForm = ({ assign the user's role. If the role does not exist, one will be created with minimal permissions. You can assign permissions to roles on the{" "} - - Roles - {" "} + + Roles + {" "} page.

diff --git a/ui/components/integrations/saml-integration-card.tsx b/ui/components/integrations/saml-integration-card.tsx index ae3eb6f60d..6ad745aa85 100644 --- a/ui/components/integrations/saml-integration-card.tsx +++ b/ui/components/integrations/saml-integration-card.tsx @@ -1,13 +1,13 @@ "use client"; import { Card, CardBody, CardHeader } from "@nextui-org/react"; -import { Link } from "@nextui-org/react"; import { CheckIcon, Trash2Icon } from "lucide-react"; import { useState } from "react"; import { deleteSamlConfig } from "@/actions/integrations"; import { useToast } from "@/components/ui"; import { CustomAlertModal, CustomButton } from "@/components/ui/custom"; +import { CustomLink } from "@/components/ui/custom/custom-link"; import { SamlConfigForm } from "./forms"; @@ -73,14 +73,9 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => { ) : ( <> Configure SAML Single Sign-On for secure authentication.{" "} - + Read the docs - + )}

diff --git a/ui/components/lighthouse/chat.tsx b/ui/components/lighthouse/chat.tsx index c31c6a08b9..54ca5604e7 100644 --- a/ui/components/lighthouse/chat.tsx +++ b/ui/components/lighthouse/chat.tsx @@ -1,12 +1,12 @@ "use client"; import { useChat } from "@ai-sdk/react"; -import Link from "next/link"; import { useEffect, useRef } from "react"; import { useForm } from "react-hook-form"; import { MemoizedMarkdown } from "@/components/lighthouse/memoized-markdown"; import { CustomButton, CustomTextarea } from "@/components/ui/custom"; +import { CustomLink } from "@/components/ui/custom/custom-link"; import { Form } from "@/components/ui/form"; interface SuggestedAction { @@ -138,12 +138,14 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => { ? "Please configure your OpenAI API key to use Lighthouse AI." : "OpenAI API key is invalid. Please update your key to use Lighthouse AI."}

- Configure API Key - + )} diff --git a/ui/components/providers/forms/muted-findings-config-form.tsx b/ui/components/providers/forms/muted-findings-config-form.tsx index 52c5e2c7df..1a5241b330 100644 --- a/ui/components/providers/forms/muted-findings-config-form.tsx +++ b/ui/components/providers/forms/muted-findings-config-form.tsx @@ -1,7 +1,6 @@ "use client"; import { Textarea } from "@nextui-org/react"; -import Link from "next/link"; import { Dispatch, SetStateAction, useEffect, useState } from "react"; import { useFormState } from "react-dom"; @@ -14,6 +13,7 @@ import { import { DeleteIcon } from "@/components/icons"; import { useToast } from "@/components/ui"; import { CustomButton } from "@/components/ui/custom"; +import { CustomLink } from "@/components/ui/custom/custom-link"; import { FormButtons } from "@/components/ui/form"; import { fontMono } from "@/config/fonts"; import { convertToYaml, parseYamlValidation } from "@/lib/yaml"; @@ -178,13 +178,9 @@ export const MutedFindingsConfigForm = ({
  • Learn more about configuring the Mutelist{" "} - + here - + .
  • diff --git a/ui/components/providers/workflow/forms/test-connection-form.tsx b/ui/components/providers/workflow/forms/test-connection-form.tsx index 6fdc6e2215..0ac38c03a1 100644 --- a/ui/components/providers/workflow/forms/test-connection-form.tsx +++ b/ui/components/providers/workflow/forms/test-connection-form.tsx @@ -3,7 +3,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Icon } from "@iconify/react"; import { Checkbox } from "@nextui-org/react"; -import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -18,6 +17,7 @@ import { getTask } from "@/actions/task/tasks"; import { CheckIcon, RocketIcon } from "@/components/icons"; import { useToast } from "@/components/ui"; import { CustomButton } from "@/components/ui/custom"; +import { CustomLink } from "@/components/ui/custom/custom-link"; import { Form } from "@/components/ui/form"; import { checkTaskStatus } from "@/lib/helper"; import { ProviderType } from "@/types"; @@ -295,8 +295,9 @@ export const TestConnectionForm = ({
    {apiErrorMessage ? ( - Back to providers - + ) : connectionStatus?.error ? ( router.back() : onResetCredentials} diff --git a/ui/components/providers/workflow/forms/via-credentials/m365-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/m365-credentials-form.tsx index 5fa8f2f9a1..13eb61710d 100644 --- a/ui/components/providers/workflow/forms/via-credentials/m365-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/via-credentials/m365-credentials-form.tsx @@ -1,8 +1,8 @@ -import Link from "next/link"; import { Control } from "react-hook-form"; import { InfoIcon } from "@/components/icons"; import { CustomInput } from "@/components/ui/custom"; +import { CustomLink } from "@/components/ui/custom/custom-link"; import { M365Credentials } from "@/types"; export const M365CredentialsForm = ({ @@ -57,14 +57,12 @@ export const M365CredentialsForm = ({ {" "} User and password authentication is being deprecated due to Microsoft's on-going MFA enforcement across all tenants (see{" "} - Microsoft docs - + ).

    @@ -76,14 +74,12 @@ export const M365CredentialsForm = ({

    Due to that change, you must only{" "} - use application authentication - {" "} + {" "} to maintain all Prowler M365 scan capabilities.

    {getProviderHelpText(providerType as string).text}

    - Read the docs - + ); diff --git a/ui/components/ui/custom/custom-link.tsx b/ui/components/ui/custom/custom-link.tsx new file mode 100644 index 0000000000..d52fbeaca2 --- /dev/null +++ b/ui/components/ui/custom/custom-link.tsx @@ -0,0 +1,51 @@ +import Link from "next/link"; +import React from "react"; + +import { cn } from "@/lib"; + +interface CustomLinkProps + extends React.AnchorHTMLAttributes { + href: string; + target?: "_self" | "_blank" | string; + ariaLabel?: string; + className?: string; + children: React.ReactNode; + scroll?: boolean; + size?: string; +} + +export const CustomLink = React.forwardRef( + ( + { + href, + target = "_blank", + ariaLabel, + className, + children, + scroll = true, + size = "xs", + ...props + }, + ref, + ) => { + return ( + + {children} + + ); + }, +); + +CustomLink.displayName = "CustomLink"; diff --git a/ui/components/ui/user-nav/user-nav.tsx b/ui/components/ui/user-nav/user-nav.tsx index ff441f0ec8..f4734e6384 100644 --- a/ui/components/ui/user-nav/user-nav.tsx +++ b/ui/components/ui/user-nav/user-nav.tsx @@ -1,7 +1,6 @@ "use client"; import { LogOut, User } from "lucide-react"; -import Link from "next/link"; import { useSession } from "next-auth/react"; import { logOut } from "@/actions/auth"; @@ -10,6 +9,8 @@ import { AvatarFallback, AvatarImage, } from "@/components/ui/avatar/avatar"; +import { Button } from "@/components/ui/button/button"; +import { CustomLink } from "@/components/ui/custom/custom-link"; import { DropdownMenu, DropdownMenuContent, @@ -26,8 +27,6 @@ import { TooltipTrigger, } from "@/components/ui/tooltip/tooltip"; -import { Button } from "../button/button"; - export const UserNav = () => { const { data: session } = useSession(); @@ -80,10 +79,14 @@ export const UserNav = () => { - + Account - + diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index a31621d537..a680feabdb 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -8,6 +8,9 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, reporter: [["list"]], outputDir: "/tmp/playwright-tests", + expect: { + timeout: 20000, + }, use: { baseURL: "http://localhost:3000", @@ -34,6 +37,8 @@ export default defineConfig({ AUTH_SECRET: process.env.AUTH_SECRET || "fallback-ci-secret-for-testing", AUTH_TRUST_HOST: process.env.AUTH_TRUST_HOST || "true", NEXTAUTH_URL: process.env.NEXTAUTH_URL || "http://localhost:3000", + E2E_USER: process.env.E2E_USER || "e2e@prowler.com", + E2E_PASSWORD: process.env.E2E_PASSWORD || "Thisisapassword123@", }, }, }); diff --git a/ui/tests/auth-login.spec.ts b/ui/tests/auth-login.spec.ts index c1764a3b28..fc612a71a5 100644 --- a/ui/tests/auth-login.spec.ts +++ b/ui/tests/auth-login.spec.ts @@ -196,6 +196,11 @@ test.describe("Accessibility", () => { await page.keyboard.press("Tab"); // Show password button await page.keyboard.press("Tab"); // Login button + + if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") { + await page.keyboard.press("Tab"); // Forgot password + } + await expect(page.getByRole("button", { name: "Log in" })).toBeFocused(); }); diff --git a/ui/tests/helpers.ts b/ui/tests/helpers.ts index e807c595fd..cb2e8d2857 100644 --- a/ui/tests/helpers.ts +++ b/ui/tests/helpers.ts @@ -13,8 +13,8 @@ export const URLS = { export const TEST_CREDENTIALS = { VALID: { - email: "e2e@prowler.com", - password: "Thisisapassword123@", + email: process.env.E2E_USER || "e2e@prowler.com", + password: process.env.E2E_PASSWORD || "Thisisapassword123@", }, INVALID: { email: "invalid@example.com",