Merge branch 'master' into PRWLR-7606-add-github-provider-to-ui

This commit is contained in:
Alejandro Bailo
2025-07-24 10:39:35 +02:00
committed by GitHub
33 changed files with 522 additions and 136 deletions
+9 -2
View File
@@ -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
+5 -4
View File
@@ -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)
+1 -2
View File
@@ -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 <your-project-id>` 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 <project-id>` or by setting an environment variable:
`export GOOGLE_CLOUD_QUOTA_PROJECT=<project-id>`
+2 -3
View File
@@ -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 <your-project-id>` 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 <project-id>` or by setting an environment variable:
`export GOOGLE_CLOUD_QUOTA_PROJECT=<project-id>`
+1 -1
View File
@@ -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 organizations hierarchy is scanned, providing full visibility across the entire organization.
+14 -1
View File
@@ -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)
@@ -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}"
@@ -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}"
)
@@ -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} --"
@@ -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}"
@@ -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} --"
@@ -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
)
),
)
)
@@ -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()
+1
View File
@@ -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
+3 -3
View File
@@ -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&apos;re sorry for the inconvenience. Please try again or contact
support if the problem persists.
</AlertDescription>
<Link href="/" className="font-bold">
<CustomLink href="/" target="_self" className="font-bold">
Go to the homepage
</Link>
</CustomLink>
</Alert>
);
}
+14 -18
View File
@@ -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&nbsp;
<Link
<CustomLink
href="https://prowler.com/terms-of-service/"
size="sm"
target="_blank"
>
Terms of Service
</Link>
</CustomLink>
&nbsp;of Prowler
</Checkbox>
</FormControl>
@@ -359,13 +359,9 @@ export const AuthForm = ({
content={
<div className="flex-inline text-small">
Social Login with Google is not enabled.{" "}
<Link
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-primary"
>
<CustomLink href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#google-oauth-configuration">
Read the docs
</Link>
</CustomLink>
</div>
}
placement="right-start"
@@ -392,13 +388,9 @@ export const AuthForm = ({
content={
<div className="flex-inline text-small">
Social Login with Github is not enabled.{" "}
<Link
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-primary"
>
<CustomLink href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#github-oauth-configuration">
Read the docs
</Link>
</CustomLink>
</div>
}
placement="right-start"
@@ -451,12 +443,16 @@ export const AuthForm = ({
{type === "sign-in" ? (
<p className="text-center text-small">
Need to create an account?&nbsp;
<Link href="/sign-up">Sign up</Link>
<CustomLink size="base" href="/sign-up" target="_self">
Sign up
</CustomLink>
</p>
) : (
<p className="text-center text-small">
Already have an account?&nbsp;
<Link href="/sign-in">Log in</Link>
<CustomLink size="base" href="/sign-in" target="_self">
Log in
</CustomLink>
</p>
)}
</div>
@@ -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 && (
<ComplianceDetailSection title="Implementation Guidance">
<ComplianceLink
href={requirement.implementation_guidance_url as string}
>
<CustomLink href={requirement.implementation_guidance_url as string}>
{requirement.implementation_guidance_url as string}
</ComplianceLink>
</CustomLink>
</ComplianceDetailSection>
)}
</ComplianceDetailContainer>
@@ -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) => (
<div key={index}>
<ComplianceLink href={url}>{url}</ComplianceLink>
<CustomLink href={url}>{url}</CustomLink>
</div>
),
)}
@@ -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 && (
<ComplianceDetailSection title="MITRE ATT&CK Reference">
<ComplianceLink href={requirement.technique_url as string}>
<CustomLink href={requirement.technique_url as string}>
{requirement.technique_url as string}
</ComplianceLink>
</CustomLink>
</ComplianceDetailSection>
)}
@@ -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 (
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
className="break-all text-sm text-blue-600 decoration-1 transition-colors hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{children}
</Link>
);
};
export const ComplianceDetailContainer = ({
children,
}: {
@@ -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}
</p>
{attributes.check_metadata.remediation.recommendation.url && (
<Link
<CustomLink
href={
attributes.check_metadata.remediation.recommendation.url
}
target="_blank"
className="text-sm text-blue-500 hover:underline"
size="sm"
>
Learn more
</Link>
</CustomLink>
)}
</div>
</InfoField>
@@ -179,13 +178,12 @@ export const FindingDetail = ({
{/* Additional Resources section */}
{attributes.check_metadata.remediation.code.other && (
<InfoField label="Additional Resources">
<Link
<CustomLink
href={attributes.check_metadata.remediation.code.other}
target="_blank"
className="text-sm text-blue-500 hover:underline"
size="sm"
>
View documentation
</Link>
</CustomLink>
</InfoField>
)}
</div>
@@ -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&apos;s role. If the role does not exist, one will
be created with minimal permissions. You can assign permissions to
roles on the{" "}
<Link href="/roles">
<span className="underline">Roles</span>
</Link>{" "}
<CustomLink href="/roles" target="_self">
<span>Roles</span>
</CustomLink>{" "}
page.
</p>
</div>
@@ -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.{" "}
<Link
target="_blank"
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-sso"
rel="noopener noreferrer"
className="text-xs font-medium text-primary"
>
<CustomLink href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-sso">
Read the docs
</Link>
</CustomLink>
</>
)}
</p>
+6 -4
View File
@@ -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."}
</p>
<Link
<CustomLink
href="/lighthouse/config"
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
target="_self"
size="sm"
>
Configure API Key
</Link>
</CustomLink>
</div>
</div>
)}
@@ -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 = ({
</li>
<li>
Learn more about configuring the Mutelist{" "}
<Link
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/mutelist/"
target="_blank"
className="text-primary-600 hover:underline"
>
<CustomLink href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/mutelist/">
here
</Link>
</CustomLink>
.
</li>
<li>
@@ -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 = ({
<div className="flex w-full justify-end sm:space-x-6">
{apiErrorMessage ? (
<Link
<CustomLink
href="/providers"
target="_self"
className="mr-3 flex w-fit items-center justify-center space-x-2 rounded-lg border border-solid border-gray-200 px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700"
>
<Icon
@@ -304,7 +305,7 @@ export const TestConnectionForm = ({
className="h-5 w-5 text-gray-600 dark:text-gray-400"
/>
<span>Back to providers</span>
</Link>
</CustomLink>
) : connectionStatus?.error ? (
<CustomButton
onPress={isUpdated ? () => router.back() : onResetCredentials}
@@ -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&apos;s on-going MFA enforcement across all tenants (see{" "}
<Link
<CustomLink
href="https://azure.microsoft.com/en-us/blog/announcing-mandatory-multi-factor-authentication-for-azure-sign-in/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
size="sm"
>
Microsoft docs
</Link>
</CustomLink>
).
</p>
@@ -76,14 +74,12 @@ export const M365CredentialsForm = ({
</div>
<p className="text-sm text-default-500">
Due to that change, you must only{" "}
<Link
<CustomLink
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/microsoft365/getting-started-m365/#step-3-configure-your-m365-account"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
size="sm"
>
use application authentication
</Link>{" "}
</CustomLink>{" "}
to maintain all Prowler M365 scan capabilities.
</p>
<CustomInput
@@ -1,5 +1,6 @@
import Link from "next/link";
"use client";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { getProviderName } from "@/components/ui/entities/get-provider-logo";
import { getProviderLogo } from "@/components/ui/entities/get-provider-logo";
import { getProviderHelpText } from "@/lib";
@@ -24,13 +25,12 @@ export const ProviderTitleDocs = ({
<p className="whitespace-nowrap text-sm text-default-500">
{getProviderHelpText(providerType as string).text}
</p>
<Link
<CustomLink
href={getProviderHelpText(providerType as string).link}
target="_blank"
className="whitespace-nowrap text-sm font-medium text-primary"
size="sm"
>
Read the docs
</Link>
</CustomLink>
</div>
</div>
);
+51
View File
@@ -0,0 +1,51 @@
import Link from "next/link";
import React from "react";
import { cn } from "@/lib";
interface CustomLinkProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
target?: "_self" | "_blank" | string;
ariaLabel?: string;
className?: string;
children: React.ReactNode;
scroll?: boolean;
size?: string;
}
export const CustomLink = React.forwardRef<HTMLAnchorElement, CustomLinkProps>(
(
{
href,
target = "_blank",
ariaLabel,
className,
children,
scroll = true,
size = "xs",
...props
},
ref,
) => {
return (
<Link
ref={ref}
href={href}
scroll={scroll}
className={cn(
`text-${size} break-all font-medium text-primary decoration-1 hover:underline`,
className,
)}
aria-label={ariaLabel}
target={target}
rel="noopener noreferrer"
{...props}
>
{children}
</Link>
);
},
);
CustomLink.displayName = "CustomLink";
+8 -5
View File
@@ -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 = () => {
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem className="hover:cursor-pointer" asChild>
<Link href="/profile" className="flex items-center">
<CustomLink
href="/profile"
className="flex items-center font-normal text-prowler-black dark:text-prowler-white"
target="_self"
>
<User className="text-muted-foreground mr-3 h-4 w-4" />
Account
</Link>
</CustomLink>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
+5
View File
@@ -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@",
},
},
});
+5
View File
@@ -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();
});
+2 -2
View File
@@ -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",