diff --git a/.gitignore b/.gitignore index 1b5075705c..6e7e284566 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ node_modules # Persistent data _data/ + +# Claude +CLAUDE.md diff --git a/ui/.gitignore b/ui/.gitignore index 45c1abce86..555ceeb8d3 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -33,4 +33,4 @@ yarn-error.log* # typescript *.tsbuildinfo -next-env.d.ts +next-env.d.ts \ No newline at end of file diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 88b657918a..8e726f0196 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added +- Security Hub integration [(#8552)](https://github.com/prowler-cloud/prowler/pull/8552) - `Cloud Provider` type filter to providers page [(#8473)](https://github.com/prowler-cloud/prowler/pull/8473) - New menu item under Configuration section for quick access to the Mutelist [(#8444)](https://github.com/prowler-cloud/prowler/pull/8444) - Resource agent to Lighthouse for querying resource information [(#8509)](https://github.com/prowler-cloud/prowler/pull/8509) diff --git a/ui/actions/integrations/index.ts b/ui/actions/integrations/index.ts index faf38d3ec7..ab4160c009 100644 --- a/ui/actions/integrations/index.ts +++ b/ui/actions/integrations/index.ts @@ -3,6 +3,7 @@ export { deleteIntegration, getIntegration, getIntegrations, + pollConnectionTestStatus, testIntegrationConnection, updateIntegration, } from "./integrations"; diff --git a/ui/actions/integrations/integrations.ts b/ui/actions/integrations/integrations.ts index 132dff1b17..2121810a50 100644 --- a/ui/actions/integrations/integrations.ts +++ b/ui/actions/integrations/integrations.ts @@ -9,7 +9,8 @@ import { parseStringify, } from "@/lib"; -import { getTask } from "../task"; +import { getTask } from "@/actions/task"; +import { IntegrationType } from "@/types/integrations"; export const getIntegrations = async (searchParams?: URLSearchParams) => { const headers = await getAuthHeaders({ contentType: false }); @@ -59,7 +60,7 @@ export const getIntegration = async (id: string) => { export const createIntegration = async ( formData: FormData, -): Promise<{ success: string; testConnection?: any } | { error: string }> => { +): Promise<{ success: string; integrationId?: string } | { error: string }> => { const headers = await getAuthHeaders({ contentType: true }); const url = new URL(`${apiBaseUrl}/integrations`); @@ -97,25 +98,33 @@ export const createIntegration = async ( const responseData = await response.json(); const integrationId = responseData.data.id; - const testResult = await testIntegrationConnection(integrationId); + // Revalidate the appropriate page based on integration type + if (integration_type === "amazon_s3") { + revalidatePath("/integrations/amazon-s3"); + } else if (integration_type === "aws_security_hub") { + revalidatePath("/integrations/aws-security-hub"); + } return { success: "Integration created successfully!", - testConnection: testResult, + integrationId, }; } const errorData = await response.json().catch(() => ({})); const errorMessage = errorData.errors?.[0]?.detail || - `Unable to create S3 integration: ${response.statusText}`; + `Unable to create integration: ${response.statusText}`; return { error: errorMessage }; } catch (error) { return handleApiError(error); } }; -export const updateIntegration = async (id: string, formData: FormData) => { +export const updateIntegration = async ( + id: string, + formData: FormData, +): Promise<{ success: string; integrationId?: string } | { error: string }> => { const headers = await getAuthHeaders({ contentType: true }); const url = new URL(`${apiBaseUrl}/integrations/${id}`); @@ -172,33 +181,33 @@ export const updateIntegration = async (id: string, formData: FormData) => { }); if (response.ok) { - revalidatePath("/integrations/s3"); - - // Only test connection if credentials or configuration were updated - if (credentials || configuration) { - const testResult = await testIntegrationConnection(id); - return { - success: "Integration updated successfully!", - testConnection: testResult, - }; - } else { - return { - success: "Integration updated successfully!", - }; + // Revalidate the appropriate page based on integration type + if (integration_type === "amazon_s3") { + revalidatePath("/integrations/amazon-s3"); + } else if (integration_type === "aws_security_hub") { + revalidatePath("/integrations/aws-security-hub"); } + + return { + success: "Integration updated successfully!", + integrationId: id, + }; } const errorData = await response.json().catch(() => ({})); const errorMessage = errorData.errors?.[0]?.detail || - `Unable to update S3 integration: ${response.statusText}`; + `Unable to update integration: ${response.statusText}`; return { error: errorMessage }; } catch (error) { return handleApiError(error); } }; -export const deleteIntegration = async (id: string) => { +export const deleteIntegration = async ( + id: string, + integration_type: IntegrationType, +) => { const headers = await getAuthHeaders({ contentType: true }); const url = new URL(`${apiBaseUrl}/integrations/${id}`); @@ -206,14 +215,20 @@ export const deleteIntegration = async (id: string) => { const response = await fetch(url.toString(), { method: "DELETE", headers }); if (response.ok) { - revalidatePath("/integrations/s3"); + // Revalidate the appropriate page based on integration type + if (integration_type === "amazon_s3") { + revalidatePath("/integrations/amazon-s3"); + } else if (integration_type === "aws_security_hub") { + revalidatePath("/integrations/aws-security-hub"); + } + return { success: "Integration deleted successfully!" }; } const errorData = await response.json().catch(() => ({})); const errorMessage = errorData.errors?.[0]?.detail || - `Unable to delete S3 integration: ${response.statusText}`; + `Unable to delete integration: ${response.statusText}`; return { error: errorMessage }; } catch (error) { return handleApiError(error); @@ -269,7 +284,10 @@ const pollTaskUntilComplete = async (taskId: string): Promise => { return { error: "Connection test timeout. Test took too long to complete." }; }; -export const testIntegrationConnection = async (id: string) => { +export const testIntegrationConnection = async ( + id: string, + waitForCompletion = true, +) => { const headers = await getAuthHeaders({ contentType: true }); const url = new URL(`${apiBaseUrl}/integrations/${id}/connection`); @@ -281,10 +299,22 @@ export const testIntegrationConnection = async (id: string) => { const taskId = data?.data?.id; if (taskId) { + // If waitForCompletion is false, return immediately with task started status + if (!waitForCompletion) { + return { + success: true, + message: + "Connection test started. It may take some time to complete.", + taskId, + data: parseStringify(data), + }; + } + // Poll the task until completion const pollResult = await pollTaskUntilComplete(taskId); - revalidatePath("/integrations/s3"); + revalidatePath("/integrations/amazon-s3"); + revalidatePath("/integrations/aws-security-hub"); if (pollResult.error) { return { error: pollResult.error }; @@ -292,17 +322,20 @@ export const testIntegrationConnection = async (id: string) => { if (pollResult.success) { return { - success: "Connection test completed successfully!", - message: pollResult.message, + success: true, + message: + pollResult.message || "Connection test completed successfully!", data: parseStringify(data), }; } else { return { + success: false, error: pollResult.message || "Connection test failed.", }; } } else { return { + success: false, error: "Failed to start connection test. No task ID received.", }; } @@ -311,9 +344,37 @@ export const testIntegrationConnection = async (id: string) => { const errorData = await response.json().catch(() => ({})); const errorMessage = errorData.errors?.[0]?.detail || - `Unable to test S3 integration connection: ${response.statusText}`; - return { error: errorMessage }; + `Unable to test integration connection: ${response.statusText}`; + return { success: false, error: errorMessage }; } catch (error) { return handleApiError(error); } }; + +export const pollConnectionTestStatus = async (taskId: string) => { + try { + const pollResult = await pollTaskUntilComplete(taskId); + + revalidatePath("/integrations/amazon-s3"); + revalidatePath("/integrations/aws-security-hub"); + + if (pollResult.error) { + return { success: false, error: pollResult.error }; + } + + if (pollResult.success) { + return { + success: true, + message: + pollResult.message || "Connection test completed successfully!", + }; + } else { + return { + success: false, + error: pollResult.message || "Connection test failed.", + }; + } + } catch (error) { + return { success: false, error: "Failed to check connection test status." }; + } +}; diff --git a/ui/app/(prowler)/integrations/s3/page.tsx b/ui/app/(prowler)/integrations/amazon-s3/page.tsx similarity index 100% rename from ui/app/(prowler)/integrations/s3/page.tsx rename to ui/app/(prowler)/integrations/amazon-s3/page.tsx diff --git a/ui/app/(prowler)/integrations/aws-security-hub/page.tsx b/ui/app/(prowler)/integrations/aws-security-hub/page.tsx new file mode 100644 index 0000000000..bbb6975840 --- /dev/null +++ b/ui/app/(prowler)/integrations/aws-security-hub/page.tsx @@ -0,0 +1,90 @@ +import React from "react"; + +import { getIntegrations } from "@/actions/integrations"; +import { getProviders } from "@/actions/providers"; +import { SecurityHubIntegrationsManager } from "@/components/integrations/security-hub/security-hub-integrations-manager"; +import { ContentLayout } from "@/components/ui"; + +interface SecurityHubIntegrationsProps { + searchParams: { [key: string]: string | string[] | undefined }; +} + +export default async function SecurityHubIntegrations({ + searchParams, +}: SecurityHubIntegrationsProps) { + const page = parseInt(searchParams.page?.toString() || "1", 10); + const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); + const sort = searchParams.sort?.toString(); + + const filters = Object.fromEntries( + Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")), + ); + + const urlSearchParams = new URLSearchParams(); + urlSearchParams.set("filter[integration_type]", "aws_security_hub"); + urlSearchParams.set("page[number]", page.toString()); + urlSearchParams.set("page[size]", pageSize.toString()); + + if (sort) { + urlSearchParams.set("sort", sort); + } + + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && key !== "filter[integration_type]") { + const stringValue = Array.isArray(value) ? value[0] : String(value); + urlSearchParams.set(key, stringValue); + } + }); + + const [integrations, providers] = await Promise.all([ + getIntegrations(urlSearchParams), + getProviders({ pageSize: 100 }), + ]); + + const securityHubIntegrations = integrations?.data || []; + const availableProviders = providers?.data || []; + const metadata = integrations?.meta; + + return ( + +
+
+

+ Configure AWS Security Hub integration to automatically send your + security findings for centralized monitoring and compliance. +

+ +
+

+ Features: +

+
    +
  • + + Automated findings export +
  • +
  • + + Multi-region support +
  • +
  • + + Send failed findings only +
  • +
  • + + Archive previous findings +
  • +
+
+
+ + +
+
+ ); +} diff --git a/ui/app/(prowler)/integrations/page.tsx b/ui/app/(prowler)/integrations/page.tsx index 265540c2be..f4626f5abe 100644 --- a/ui/app/(prowler)/integrations/page.tsx +++ b/ui/app/(prowler)/integrations/page.tsx @@ -1,6 +1,9 @@ import React from "react"; -import { S3IntegrationCard } from "@/components/integrations"; +import { + S3IntegrationCard, + SecurityHubIntegrationCard, +} from "@/components/integrations"; import { ContentLayout } from "@/components/ui"; export default async function Integrations() { @@ -17,6 +20,9 @@ export default async function Integrations() {
{/* Amazon S3 Integration */} + + {/* AWS Security Hub Integration */} +
diff --git a/ui/components/integrations/index.ts b/ui/components/integrations/index.ts index 5abff3e316..aa53bd26db 100644 --- a/ui/components/integrations/index.ts +++ b/ui/components/integrations/index.ts @@ -1,7 +1,10 @@ -export * from "../providers/provider-selector"; +export * from "../providers/enhanced-provider-selector"; export * from "./s3/s3-integration-card"; export * from "./s3/s3-integration-form"; export * from "./s3/s3-integrations-manager"; -export * from "./s3/skeleton-s3-integration-card"; export * from "./saml/saml-config-form"; export * from "./saml/saml-integration-card"; +export * from "./security-hub/security-hub-integration-card"; +export * from "./security-hub/security-hub-integration-form"; +export * from "./security-hub/security-hub-integrations-manager"; +export * from "./shared"; diff --git a/ui/components/integrations/s3/s3-integration-card.tsx b/ui/components/integrations/s3/s3-integration-card.tsx index 7f1b659c68..3c547f65fc 100644 --- a/ui/components/integrations/s3/s3-integration-card.tsx +++ b/ui/components/integrations/s3/s3-integration-card.tsx @@ -37,7 +37,7 @@ export const S3IntegrationCard = () => { size="sm" variant="bordered" startContent={} - asLink="/integrations/s3" + asLink="/integrations/amazon-s3" ariaLabel="Manage S3 integrations" > Manage diff --git a/ui/components/integrations/s3/s3-integration-form.tsx b/ui/components/integrations/s3/s3-integration-form.tsx index 0f1ac91620..6c473b5908 100644 --- a/ui/components/integrations/s3/s3-integration-form.tsx +++ b/ui/components/integrations/s3/s3-integration-form.tsx @@ -8,7 +8,7 @@ import { useState } from "react"; import { Control, useForm } from "react-hook-form"; import { createIntegration, updateIntegration } from "@/actions/integrations"; -import { ProviderSelector } from "@/components/providers/provider-selector"; +import { EnhancedProviderSelector } from "@/components/providers/enhanced-provider-selector"; import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form"; import { useToast } from "@/components/ui"; import { CustomInput } from "@/components/ui/custom"; @@ -27,7 +27,7 @@ import { ProviderProps } from "@/types/providers"; interface S3IntegrationFormProps { integration?: IntegrationProps | null; providers: ProviderProps[]; - onSuccess: () => void; + onSuccess: (integrationId?: string, shouldTestConnection?: boolean) => void; onCancel: () => void; editMode?: "configuration" | "credentials" | null; // null means creating new } @@ -75,15 +75,9 @@ export const S3IntegrationForm = ({ aws_access_key_id: "", aws_secret_access_key: "", aws_session_token: "", - // For credentials editing, show current values as placeholders but require new input - role_arn: isEditingCredentials - ? "" - : integration?.attributes.configuration.credentials?.role_arn || "", + role_arn: "", // External ID always defaults to tenantId, even when editing credentials - external_id: - integration?.attributes.configuration.credentials?.external_id || - session?.tenantId || - "", + external_id: session?.tenantId || "", role_session_name: "", session_duration: "", show_role_section: false, @@ -211,10 +205,16 @@ export const S3IntegrationForm = ({ try { let result; + let shouldTestConnection = false; + if (isEditing && integration) { result = await updateIntegration(integration.id, formData); + // Test connection if we're editing credentials or configuration (S3 needs both) + shouldTestConnection = isEditingCredentials || isEditingConfig; } else { result = await createIntegration(formData); + // Always test connection for new integrations + shouldTestConnection = true; } if ("success" in result) { @@ -223,23 +223,8 @@ export const S3IntegrationForm = ({ description: `S3 integration ${isEditing ? "updated" : "created"} successfully.`, }); - if ("testConnection" in result) { - if (result.testConnection.success) { - toast({ - title: "Connection test started!", - description: - "Connection test started. It may take some time to complete.", - }); - } else if (result.testConnection.error) { - toast({ - variant: "destructive", - title: "Connection test failed", - description: result.testConnection.error, - }); - } - } - - onSuccess(); + // Pass the integration ID and whether to test connection to the success callback + onSuccess(result.integrationId, shouldTestConnection); } else if ("error" in result) { const errorMessage = result.error; @@ -270,6 +255,7 @@ export const S3IntegrationForm = ({ const templateLinks = getAWSCredentialsTemplateLinks( externalId, bucketName, + "amazon_s3", ); return ( @@ -278,7 +264,7 @@ export const S3IntegrationForm = ({ setValue={form.setValue as any} externalId={externalId} templateLinks={templateLinks} - type="s3-integration" + type="integrations" /> ); } @@ -289,13 +275,15 @@ export const S3IntegrationForm = ({ <> {/* Provider Selection */}
- 10} />
diff --git a/ui/components/integrations/s3/s3-integrations-manager.tsx b/ui/components/integrations/s3/s3-integrations-manager.tsx index 3b4f417414..1b3061dbc3 100644 --- a/ui/components/integrations/s3/s3-integrations-manager.tsx +++ b/ui/components/integrations/s3/s3-integrations-manager.tsx @@ -1,14 +1,8 @@ "use client"; -import { Card, CardBody, CardHeader, Chip } from "@nextui-org/react"; +import { Card, CardBody, CardHeader } from "@nextui-org/react"; import { format } from "date-fns"; -import { - PlusIcon, - Power, - SettingsIcon, - TestTube, - Trash2Icon, -} from "lucide-react"; +import { PlusIcon, Trash2Icon } from "lucide-react"; import { useState } from "react"; import { @@ -17,15 +11,20 @@ import { updateIntegration, } from "@/actions/integrations"; import { AmazonS3Icon } from "@/components/icons/services/IconServices"; +import { + IntegrationActionButtons, + IntegrationCardHeader, + IntegrationSkeleton, +} from "@/components/integrations/shared"; import { useToast } from "@/components/ui"; import { CustomAlertModal, CustomButton } from "@/components/ui/custom"; import { DataTablePagination } from "@/components/ui/table/data-table-pagination"; +import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; import { MetaDataProps } from "@/types"; import { IntegrationProps } from "@/types/integrations"; import { ProviderProps } from "@/types/providers"; import { S3IntegrationForm } from "./s3-integration-form"; -import { S3IntegrationCardSkeleton } from "./skeleton-s3-integration-card"; interface S3IntegrationsManagerProps { integrations: IntegrationProps[]; @@ -78,7 +77,7 @@ export const S3IntegrationsManager = ({ const handleDeleteIntegration = async (id: string) => { setIsDeleting(id); try { - const result = await deleteIntegration(id); + const result = await deleteIntegration(id, "amazon_s3"); if (result.success) { toast({ @@ -173,11 +172,34 @@ export const S3IntegrationsManager = ({ setEditMode(null); }; - const handleFormSuccess = () => { + const handleFormSuccess = async ( + integrationId?: string, + shouldTestConnection?: boolean, + ) => { + // Close the modal immediately setIsModalOpen(false); setEditingIntegration(null); setEditMode(null); setIsOperationLoading(true); + + // Set testing state for server-triggered test connections + if (integrationId && shouldTestConnection) { + setIsTesting(integrationId); + } + + // Trigger test connection if needed + triggerTestConnectionWithDelay( + integrationId, + shouldTestConnection, + "s3", + toast, + 200, + () => { + // Clear testing state when server-triggered test completes + setIsTesting(null); + }, + ); + // Reset loading state after a short delay to show the skeleton briefly setTimeout(() => { setIsOperationLoading(false); @@ -274,44 +296,33 @@ export const S3IntegrationsManager = ({ {/* Integrations List */} {isOperationLoading ? ( - } + title="Amazon S3" + subtitle="Export security findings to Amazon S3 buckets." /> ) : integrations.length > 0 ? (
{integrations.map((integration) => ( -
-
- -
-

- {integration.attributes.configuration.bucket_name || - "Unknown Bucket"} -

-

- Output directory:{" "} - {integration.attributes.configuration - .output_directory || - integration.attributes.configuration.path || - "/"} -

-
-
- - {integration.attributes.connected - ? "Connected" - : "Disconnected"} - -
+ } + title={ + integration.attributes.configuration.bucket_name || + "Unknown Bucket" + } + subtitle={`Output directory: ${ + integration.attributes.configuration.output_directory || + integration.attributes.configuration.path || + "/" + }`} + connectionStatus={{ + connected: integration.attributes.connected, + }} + />
@@ -328,68 +339,15 @@ export const S3IntegrationsManager = ({

)}
-
- } - onPress={() => handleTestConnection(integration.id)} - isLoading={isTesting === integration.id} - isDisabled={!integration.attributes.enabled} - ariaLabel="Test connection" - className="w-full sm:w-auto" - > - Test - - } - onPress={() => handleEditConfiguration(integration)} - ariaLabel="Edit configuration" - className="w-full sm:w-auto" - > - Config - - } - onPress={() => handleEditCredentials(integration)} - ariaLabel="Edit credentials" - className="w-full sm:w-auto" - > - Credentials - - } - onPress={() => handleToggleEnabled(integration)} - ariaLabel={ - integration.attributes.enabled - ? "Disable integration" - : "Enable integration" - } - className="w-full sm:w-auto" - > - {integration.attributes.enabled ? "Disable" : "Enable"} - - } - onPress={() => handleOpenDeleteModal(integration)} - ariaLabel="Delete integration" - className="w-full sm:w-auto" - > - Delete - -
+
diff --git a/ui/components/integrations/s3/skeleton-s3-integration-card.tsx b/ui/components/integrations/s3/skeleton-s3-integration-card.tsx deleted file mode 100644 index 645b47db78..0000000000 --- a/ui/components/integrations/s3/skeleton-s3-integration-card.tsx +++ /dev/null @@ -1,95 +0,0 @@ -"use client"; - -import { Card, CardBody, CardHeader, Skeleton } from "@nextui-org/react"; - -import { AmazonS3Icon } from "@/components/icons/services/IconServices"; - -interface S3IntegrationCardSkeletonProps { - variant?: "main" | "manager"; - count?: number; -} - -export const S3IntegrationCardSkeleton = ({ - variant = "main", - count = 1, -}: S3IntegrationCardSkeletonProps) => { - if (variant === "main") { - return ( - - -
-
- -
-

Amazon S3

-
-

- Export security findings to Amazon S3 buckets. -

- -
-
-
-
- - -
-
-
- -
-
- {Array.from({ length: count }).map((_, index) => ( -
-
- - -
- -
- ))} -
-
-
-
- ); - } - - // Manager variant - for individual cards in S3IntegrationsManager - return ( -
- {Array.from({ length: count }).map((_, index) => ( - - -
-
- -
- - -
-
- -
-
- -
-
- - -
-
- - - -
-
-
-
- ))} -
- ); -}; diff --git a/ui/components/integrations/security-hub/security-hub-integration-card.tsx b/ui/components/integrations/security-hub/security-hub-integration-card.tsx new file mode 100644 index 0000000000..79da80b13a --- /dev/null +++ b/ui/components/integrations/security-hub/security-hub-integration-card.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Card, CardBody, CardHeader } from "@nextui-org/react"; +import { SettingsIcon } from "lucide-react"; + +import { AWSSecurityHubIcon } from "@/components/icons/services/IconServices"; +import { CustomButton } from "@/components/ui/custom"; +import { CustomLink } from "@/components/ui/custom/custom-link"; + +export const SecurityHubIntegrationCard = () => { + return ( + + +
+
+ +
+

+ AWS Security Hub +

+
+

+ Send security findings to AWS Security Hub. +

+ + Learn more + +
+
+
+
+ } + asLink="/integrations/aws-security-hub" + ariaLabel="Manage Security Hub integrations" + > + Manage + +
+
+
+ +
+

+ Configure and manage your AWS Security Hub integrations to + automatically send security findings for centralized monitoring. +

+
+
+
+ ); +}; diff --git a/ui/components/integrations/security-hub/security-hub-integration-form.tsx b/ui/components/integrations/security-hub/security-hub-integration-form.tsx new file mode 100644 index 0000000000..1d5a0c4399 --- /dev/null +++ b/ui/components/integrations/security-hub/security-hub-integration-form.tsx @@ -0,0 +1,490 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Checkbox, Divider, Radio, RadioGroup } from "@nextui-org/react"; +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { useSession } from "next-auth/react"; +import { useEffect, useMemo, useState } from "react"; +import { Control, useForm } from "react-hook-form"; + +import { createIntegration, updateIntegration } from "@/actions/integrations"; +import { EnhancedProviderSelector } from "@/components/providers/enhanced-provider-selector"; +import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form"; +import { useToast } from "@/components/ui"; +import { CustomLink } from "@/components/ui/custom/custom-link"; +import { Form, FormControl, FormField } from "@/components/ui/form"; +import { FormButtons } from "@/components/ui/form/form-buttons"; +import { getAWSCredentialsTemplateLinks } from "@/lib"; +import { AWSCredentialsRole } from "@/types"; +import { + editSecurityHubIntegrationFormSchema, + IntegrationProps, + securityHubIntegrationFormSchema, +} from "@/types/integrations"; +import { ProviderProps } from "@/types/providers"; + +interface SecurityHubIntegrationFormProps { + integration?: IntegrationProps | null; + providers: ProviderProps[]; + existingIntegrations?: IntegrationProps[]; + onSuccess: (integrationId?: string, shouldTestConnection?: boolean) => void; + onCancel: () => void; + editMode?: "configuration" | "credentials" | null; +} + +export const SecurityHubIntegrationForm = ({ + integration, + providers, + existingIntegrations = [], + onSuccess, + onCancel, + editMode = null, +}: SecurityHubIntegrationFormProps) => { + const { data: session } = useSession(); + const { toast } = useToast(); + const [currentStep, setCurrentStep] = useState( + editMode === "credentials" ? 1 : 0, + ); + const isEditing = !!integration; + const isCreating = !isEditing; + const isEditingConfig = editMode === "configuration"; + const isEditingCredentials = editMode === "credentials"; + + const disabledProviderIds = useMemo(() => { + // When editing, no providers should be disabled since we're not changing it + if (isEditing) { + return []; + } + + // When creating, disable providers that are already used by other Security Hub integrations + const usedProviderIds: string[] = []; + existingIntegrations.forEach((existingIntegration) => { + const providerRelationships = + existingIntegration.relationships?.providers?.data; + if (providerRelationships && providerRelationships.length > 0) { + usedProviderIds.push(providerRelationships[0].id); + } + }); + + return usedProviderIds; + }, [isEditing, existingIntegrations]); + + const form = useForm({ + resolver: zodResolver( + isEditingCredentials || isCreating + ? securityHubIntegrationFormSchema + : editSecurityHubIntegrationFormSchema, + ), + defaultValues: { + integration_type: "aws_security_hub" as const, + provider_id: integration?.relationships?.providers?.data?.[0]?.id || "", + send_only_fails: + integration?.attributes.configuration.send_only_fails ?? true, + archive_previous_findings: + integration?.attributes.configuration.archive_previous_findings ?? + false, + use_custom_credentials: false, + enabled: integration?.attributes.enabled ?? true, + credentials_type: "access-secret-key" as const, + aws_access_key_id: "", + aws_secret_access_key: "", + aws_session_token: "", + role_arn: "", + external_id: session?.tenantId || "", + role_session_name: "", + session_duration: "", + show_role_section: false, + }, + }); + + const isLoading = form.formState.isSubmitting; + const useCustomCredentials = form.watch("use_custom_credentials"); + const providerIdValue = form.watch("provider_id"); + const hasErrors = !!form.formState.errors.provider_id || !providerIdValue; + + useEffect(() => { + if (!useCustomCredentials && isCreating) { + setCurrentStep(0); + } + }, [useCustomCredentials, isCreating]); + + const handleNext = async (e: React.FormEvent) => { + e.preventDefault(); + + if (isEditingConfig || isEditingCredentials) { + return; + } + + const stepFields = currentStep === 0 ? (["provider_id"] as const) : []; + const isValid = stepFields.length === 0 || (await form.trigger(stepFields)); + + if (isValid) { + setCurrentStep(1); + } + }; + + const handleBack = () => { + setCurrentStep(0); + }; + + const buildCredentials = (values: any) => { + const credentials: any = {}; + + if (values.role_arn && values.role_arn.trim() !== "") { + credentials.role_arn = values.role_arn; + credentials.external_id = values.external_id; + + if (values.role_session_name) + credentials.role_session_name = values.role_session_name; + if (values.session_duration) + credentials.session_duration = + parseInt(values.session_duration, 10) || 3600; + } + + if (values.credentials_type === "access-secret-key") { + credentials.aws_access_key_id = values.aws_access_key_id; + credentials.aws_secret_access_key = values.aws_secret_access_key; + if (values.aws_session_token) + credentials.aws_session_token = values.aws_session_token; + } + + return credentials; + }; + + const buildConfiguration = (values: any) => { + const configuration: any = {}; + + configuration.send_only_fails = values.send_only_fails ?? true; + configuration.archive_previous_findings = + values.archive_previous_findings ?? false; + + return configuration; + }; + + const buildFormData = (values: any) => { + const formData = new FormData(); + formData.append("integration_type", values.integration_type); + + if (isEditingConfig) { + const configuration = buildConfiguration(values); + if (Object.keys(configuration).length > 0) { + formData.append("configuration", JSON.stringify(configuration)); + } + } else if (isEditingCredentials) { + // When editing credentials, check if using custom credentials + if (!values.use_custom_credentials) { + // Use provider credentials - send empty object + formData.append("credentials", JSON.stringify({})); + } else { + // Use custom credentials + const credentials = buildCredentials(values); + formData.append("credentials", JSON.stringify(credentials)); + } + } else { + const configuration = buildConfiguration(values); + formData.append("configuration", JSON.stringify(configuration)); + + if (values.use_custom_credentials) { + const credentials = buildCredentials(values); + formData.append("credentials", JSON.stringify(credentials)); + } else { + formData.append("credentials", JSON.stringify({})); + } + + formData.append("enabled", JSON.stringify(values.enabled ?? true)); + + // Send provider_id as an array for consistency with the action + formData.append("providers", JSON.stringify([values.provider_id])); + } + + return formData; + }; + + const onSubmit = async (values: any) => { + const formData = buildFormData(values); + + try { + let result; + let shouldTestConnection = false; + + if (isEditing && integration) { + result = await updateIntegration(integration.id, formData); + // Test connection ONLY if we're editing credentials (Security Hub doesn't need test for config changes) + shouldTestConnection = isEditingCredentials; + } else { + result = await createIntegration(formData); + // Always test connection for new integrations + shouldTestConnection = true; + } + + if ("success" in result) { + toast({ + title: "Success!", + description: `Security Hub integration ${isEditing ? "updated" : "created"} successfully.`, + }); + + // Pass the integration ID and whether to test connection to the success callback + onSuccess(result.integrationId, shouldTestConnection); + } else if ("error" in result) { + const errorMessage = result.error; + + toast({ + variant: "destructive", + title: "Security Hub Integration Error", + description: errorMessage, + }); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "An unexpected error occurred"; + + toast({ + variant: "destructive", + title: "Connection Error", + description: `${errorMessage}. Please check your network connection and try again.`, + }); + } + }; + + const renderStepContent = () => { + if (isEditingCredentials) { + // When editing credentials, show the credential type selector first + return ( +
+ { + form.setValue("use_custom_credentials", value === "custom", { + shouldValidate: true, + shouldDirty: true, + }); + }} + > + + Use provider credentials + + + Use custom credentials + + + + {useCustomCredentials && ( + <> + + } + setValue={form.setValue as any} + externalId={ + form.getValues("external_id") || session?.tenantId || "" + } + templateLinks={getAWSCredentialsTemplateLinks( + form.getValues("external_id") || session?.tenantId || "", + undefined, + "aws_security_hub", + )} + type="integrations" + /> + + )} +
+ ); + } + + if (currentStep === 1 && useCustomCredentials) { + const externalId = + form.getValues("external_id") || session?.tenantId || ""; + const templateLinks = getAWSCredentialsTemplateLinks( + externalId, + undefined, + "aws_security_hub", + ); + + return ( + } + setValue={form.setValue as any} + externalId={externalId} + templateLinks={templateLinks} + type="integrations" + /> + ); + } + + if (isEditingConfig || currentStep === 0) { + return ( + <> + {!isEditingConfig && ( + <> +
+ +
+ + + )} + +
+ ( + + + Send only Failed Findings + + + )} + /> + + ( + + + Archive previous findings + + + )} + /> + + {isCreating && ( + ( + + + Use custom credentials + + + )} + /> + )} +
+ + ); + } + + return null; + }; + + const renderStepButtons = () => { + if (isEditingConfig || isEditingCredentials) { + const updateText = isEditingConfig + ? "Update Configuration" + : "Update Credentials"; + const loadingText = isEditingConfig + ? "Updating Configuration..." + : "Updating Credentials..."; + + return ( + {}} + onCancel={onCancel} + submitText={updateText} + cancelText="Cancel" + loadingText={loadingText} + isDisabled={isLoading} + /> + ); + } + + if (currentStep === 0 && !useCustomCredentials) { + return ( + {}} + onCancel={onCancel} + submitText="Create Integration" + cancelText="Cancel" + loadingText="Creating..." + isDisabled={isLoading || hasErrors} + /> + ); + } + + if (currentStep === 0 && useCustomCredentials) { + return ( + {}} + onCancel={onCancel} + submitText="Next" + cancelText="Cancel" + loadingText="Processing..." + isDisabled={isLoading || hasErrors} + rightIcon={} + /> + ); + } + + return ( + {}} + onCancel={handleBack} + submitText="Create Integration" + cancelText="Back" + loadingText="Creating..." + leftIcon={} + isDisabled={isLoading} + /> + ); + }; + + return ( +
+ +
+
+

+ Need help configuring your AWS Security Hub integration? +

+ + Read the docs + +
+ {renderStepContent()} +
+ {renderStepButtons()} +
+ + ); +}; diff --git a/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx b/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx new file mode 100644 index 0000000000..6e16ab5497 --- /dev/null +++ b/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx @@ -0,0 +1,445 @@ +"use client"; + +import { Card, CardBody, CardHeader, Chip } from "@nextui-org/react"; +import { format } from "date-fns"; +import { PlusIcon, Trash2Icon } from "lucide-react"; +import { useState } from "react"; + +import { + deleteIntegration, + testIntegrationConnection, + updateIntegration, +} from "@/actions/integrations"; +import { AWSSecurityHubIcon } from "@/components/icons/services/IconServices"; +import { + IntegrationActionButtons, + IntegrationCardHeader, + IntegrationSkeleton, +} from "@/components/integrations/shared"; +import { useToast } from "@/components/ui"; +import { CustomAlertModal, CustomButton } from "@/components/ui/custom"; +import { DataTablePagination } from "@/components/ui/table/data-table-pagination"; +import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; +import { MetaDataProps } from "@/types"; +import { IntegrationProps } from "@/types/integrations"; +import { ProviderProps } from "@/types/providers"; + +import { SecurityHubIntegrationForm } from "./security-hub-integration-form"; + +interface SecurityHubIntegrationsManagerProps { + integrations: IntegrationProps[]; + providers: ProviderProps[]; + metadata?: MetaDataProps; +} + +export const SecurityHubIntegrationsManager = ({ + integrations, + providers, + metadata, +}: SecurityHubIntegrationsManagerProps) => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingIntegration, setEditingIntegration] = + useState(null); + const [editMode, setEditMode] = useState< + "configuration" | "credentials" | null + >(null); + const [isDeleting, setIsDeleting] = useState(null); + const [isTesting, setIsTesting] = useState(null); + const [isOperationLoading, setIsOperationLoading] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [integrationToDelete, setIntegrationToDelete] = + useState(null); + const { toast } = useToast(); + + const handleAddIntegration = () => { + setEditingIntegration(null); + setEditMode(null); + setIsModalOpen(true); + }; + + const handleEditConfiguration = (integration: IntegrationProps) => { + setEditingIntegration(integration); + setEditMode("configuration"); + setIsModalOpen(true); + }; + + const handleEditCredentials = (integration: IntegrationProps) => { + setEditingIntegration(integration); + setEditMode("credentials"); + setIsModalOpen(true); + }; + + const handleOpenDeleteModal = (integration: IntegrationProps) => { + setIntegrationToDelete(integration); + setIsDeleteOpen(true); + }; + + const handleDeleteIntegration = async (id: string) => { + setIsDeleting(id); + try { + const result = await deleteIntegration(id, "aws_security_hub"); + + if (result.success) { + toast({ + title: "Success!", + description: "Security Hub integration deleted successfully.", + }); + } else if (result.error) { + toast({ + variant: "destructive", + title: "Delete Failed", + description: result.error, + }); + } + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: + "Failed to delete Security Hub integration. Please try again.", + }); + } finally { + setIsDeleting(null); + setIsDeleteOpen(false); + setIntegrationToDelete(null); + } + }; + + const handleTestConnection = async (id: string) => { + setIsTesting(id); + try { + const result = await testIntegrationConnection(id); + + if (result.success) { + toast({ + title: "Connection test successful!", + description: + result.message || "Connection test completed successfully.", + }); + } else if (result.error) { + toast({ + variant: "destructive", + title: "Connection test failed", + description: result.error, + }); + } + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: "Failed to test connection. Please try again.", + }); + } finally { + setIsTesting(null); + } + }; + + const handleToggleEnabled = async (integration: IntegrationProps) => { + try { + const newEnabledState = !integration.attributes.enabled; + const formData = new FormData(); + formData.append( + "integration_type", + integration.attributes.integration_type, + ); + formData.append("enabled", JSON.stringify(newEnabledState)); + + const result = await updateIntegration(integration.id, formData); + + if (result && "success" in result) { + toast({ + title: "Success!", + description: `Integration ${newEnabledState ? "enabled" : "disabled"} successfully.`, + }); + + // If enabling, trigger test connection automatically + if (newEnabledState) { + setIsTesting(integration.id); + + triggerTestConnectionWithDelay( + integration.id, + true, + "security_hub", + toast, + 500, + () => { + setIsTesting(null); + }, + ); + } + } else if (result && "error" in result) { + toast({ + variant: "destructive", + title: "Toggle Failed", + description: result.error, + }); + } + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: "Failed to toggle integration. Please try again.", + }); + } + }; + + const handleModalClose = () => { + setIsModalOpen(false); + setEditingIntegration(null); + setEditMode(null); + }; + + const handleFormSuccess = async ( + integrationId?: string, + shouldTestConnection?: boolean, + ) => { + // Close the modal immediately + setIsModalOpen(false); + setEditingIntegration(null); + setEditMode(null); + setIsOperationLoading(true); + + // Set testing state for server-triggered test connections + if (integrationId && shouldTestConnection) { + setIsTesting(integrationId); + } + + // Trigger test connection if needed + triggerTestConnectionWithDelay( + integrationId, + shouldTestConnection, + "security_hub", + toast, + 200, + () => { + // Clear testing state when server-triggered test completes + setIsTesting(null); + }, + ); + + // Reset loading state after a short delay to show the skeleton briefly + setTimeout(() => { + setIsOperationLoading(false); + }, 1500); + }; + + const getProviderDetails = (integration: IntegrationProps) => { + const providerRelationships = integration.relationships?.providers?.data; + + if (!providerRelationships || providerRelationships.length === 0) { + return { displayName: "Unknown Account", accountId: null }; + } + + // Security Hub should only have one provider + const providerId = providerRelationships[0].id; + const provider = providers.find((p) => p.id === providerId); + + if (!provider) { + return { displayName: "Unknown Account", accountId: null }; + } + + return { + displayName: provider.attributes.alias || provider.attributes.uid, + accountId: provider.attributes.uid, + alias: provider.attributes.alias, + }; + }; + + const getEnabledRegions = (integration: IntegrationProps) => { + const regions = integration.attributes.configuration.regions; + if (!regions || typeof regions !== "object") return []; + + return Object.entries(regions) + .filter(([_, enabled]) => enabled === true) + .map(([region]) => region) + .sort(); + }; + + return ( + <> + +
+ { + setIsDeleteOpen(false); + setIntegrationToDelete(null); + }} + isDisabled={isDeleting !== null} + > + Cancel + + + } + onPress={() => + integrationToDelete && + handleDeleteIntegration(integrationToDelete.id) + } + > + {isDeleting ? "Deleting..." : "Delete"} + +
+
+ + + + + +
+
+
+

+ Configured Security Hub Integrations +

+

+ {integrations.length === 0 + ? "Not configured yet" + : `${integrations.length} integration${integrations.length !== 1 ? "s" : ""} configured`} +

+
+ } + onPress={handleAddIntegration} + ariaLabel="Add integration" + > + Add Integration + +
+ + {isOperationLoading ? ( + } + title="AWS Security Hub" + subtitle="Send security findings to AWS Security Hub." + /> + ) : integrations.length > 0 ? ( +
+ {integrations.map((integration) => { + const enabledRegions = getEnabledRegions(integration); + const providerDetails = getProviderDetails(integration); + + return ( + + + } + title={providerDetails.displayName} + subtitle={ + providerDetails.accountId && providerDetails.alias + ? `Account ID: ${providerDetails.accountId}` + : "AWS Security Hub Integration" + } + chips={[ + { + label: integration.attributes.configuration + .send_only_fails + ? "Failed Only" + : "All Findings", + }, + { + label: integration.attributes.configuration + .archive_previous_findings + ? "Archive Previous" + : "Keep Previous", + }, + ]} + connectionStatus={{ + connected: integration.attributes.connected, + }} + /> + + +
+ {enabledRegions.length > 0 && ( +
+ {enabledRegions.map((region) => ( + + {region} + + ))} +
+ )} + +
+
+ {integration.attributes.updated_at && ( +

+ Last updated:{" "} + {format( + new Date(integration.attributes.updated_at), + "yyyy/MM/dd", + )} +

+ )} +
+ +
+
+
+
+ ); + })} +
+ ) : null} + + {metadata && integrations.length > 0 && ( +
+ +
+ )} +
+ + ); +}; diff --git a/ui/components/integrations/shared/index.ts b/ui/components/integrations/shared/index.ts new file mode 100644 index 0000000000..499b38677a --- /dev/null +++ b/ui/components/integrations/shared/index.ts @@ -0,0 +1,3 @@ +export { IntegrationActionButtons } from "./integration-action-buttons"; +export { IntegrationCardHeader } from "./integration-card-header"; +export { IntegrationSkeleton } from "./integration-skeleton"; diff --git a/ui/components/integrations/shared/integration-action-buttons.tsx b/ui/components/integrations/shared/integration-action-buttons.tsx new file mode 100644 index 0000000000..0a56480dc5 --- /dev/null +++ b/ui/components/integrations/shared/integration-action-buttons.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { + LockIcon, + Power, + SettingsIcon, + TestTube, + Trash2Icon, +} from "lucide-react"; + +import { CustomButton } from "@/components/ui/custom"; +import { IntegrationProps } from "@/types/integrations"; + +interface IntegrationActionButtonsProps { + integration: IntegrationProps; + onTestConnection: (id: string) => void; + onEditConfiguration: (integration: IntegrationProps) => void; + onEditCredentials: (integration: IntegrationProps) => void; + onToggleEnabled: (integration: IntegrationProps) => void; + onDelete: (integration: IntegrationProps) => void; + isTesting?: boolean; + showCredentialsButton?: boolean; +} + +export const IntegrationActionButtons = ({ + integration, + onTestConnection, + onEditConfiguration, + onEditCredentials, + onToggleEnabled, + onDelete, + isTesting = false, + showCredentialsButton = true, +}: IntegrationActionButtonsProps) => { + return ( +
+ } + onPress={() => onTestConnection(integration.id)} + isLoading={isTesting} + isDisabled={!integration.attributes.enabled || isTesting} + ariaLabel="Test connection" + className="w-full sm:w-auto" + > + Test + + } + onPress={() => onEditConfiguration(integration)} + ariaLabel="Edit configuration" + className="w-full sm:w-auto" + > + Config + + {showCredentialsButton && ( + } + onPress={() => onEditCredentials(integration)} + ariaLabel="Edit credentials" + className="w-full sm:w-auto" + > + Credentials + + )} + } + onPress={() => onToggleEnabled(integration)} + isDisabled={isTesting} + ariaLabel={ + integration.attributes.enabled + ? "Disable integration" + : "Enable integration" + } + className="w-full sm:w-auto" + > + {integration.attributes.enabled ? "Disable" : "Enable"} + + } + onPress={() => onDelete(integration)} + ariaLabel="Delete integration" + className="w-full sm:w-auto" + > + Delete + +
+ ); +}; diff --git a/ui/components/integrations/shared/integration-card-header.tsx b/ui/components/integrations/shared/integration-card-header.tsx new file mode 100644 index 0000000000..a56473f410 --- /dev/null +++ b/ui/components/integrations/shared/integration-card-header.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Chip } from "@nextui-org/react"; +import { ReactNode } from "react"; + +interface IntegrationCardHeaderProps { + icon: ReactNode; + title: string; + subtitle?: string; + chips?: Array<{ + label: string; + color?: + | "default" + | "primary" + | "secondary" + | "success" + | "warning" + | "danger"; + variant?: "solid" | "bordered" | "light" | "flat" | "faded" | "shadow"; + }>; + connectionStatus?: { + connected: boolean; + label?: string; + }; +} + +export const IntegrationCardHeader = ({ + icon, + title, + subtitle, + chips = [], + connectionStatus, +}: IntegrationCardHeaderProps) => { + return ( +
+
+ {icon} +
+

{title}

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+
+ {(chips.length > 0 || connectionStatus) && ( +
+ {chips.map((chip, index) => ( + + {chip.label} + + ))} + {connectionStatus && ( + + {connectionStatus.label || + (connectionStatus.connected ? "Connected" : "Disconnected")} + + )} +
+ )} +
+ ); +}; diff --git a/ui/components/integrations/shared/integration-skeleton.tsx b/ui/components/integrations/shared/integration-skeleton.tsx new file mode 100644 index 0000000000..73ad4e5017 --- /dev/null +++ b/ui/components/integrations/shared/integration-skeleton.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { Card, CardBody, CardHeader, Skeleton } from "@nextui-org/react"; +import { ReactNode } from "react"; + +interface IntegrationSkeletonProps { + variant?: "main" | "manager"; + count?: number; + icon: ReactNode; + title?: string; + subtitle?: string; +} + +export const IntegrationSkeleton = ({ + variant = "main", + count = 1, + icon, + title = "Integration", + subtitle = "Loading integration details...", +}: IntegrationSkeletonProps) => { + if (variant === "main") { + return ( + + +
+
+ {icon} +
+

{title}

+
+

{subtitle}

+ +
+
+
+
+ +
+
+
+ +
+ + +
+
+
+ ); + } + + // Manager variant - for individual cards in integration managers + return ( +
+ {Array.from({ length: count }).map((_, index) => ( + + +
+
+ {icon} +
+ + +
+
+
+ + + +
+
+
+ +
+ {/* Region chips skeleton */} +
+ + + +
+
+ +
+ + + + + +
+
+
+
+
+ ))} +
+ ); +}; diff --git a/ui/components/providers/enhanced-provider-selector.tsx b/ui/components/providers/enhanced-provider-selector.tsx new file mode 100644 index 0000000000..39779382be --- /dev/null +++ b/ui/components/providers/enhanced-provider-selector.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { Button, Input, Select, SelectItem } from "@nextui-org/react"; +import { CheckSquare, Search, Square } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Control } from "react-hook-form"; + +import { FormControl, FormField, FormMessage } from "@/components/ui/form"; +import { ProviderProps, ProviderType } from "@/types/providers"; + +const providerTypeLabels: Record = { + aws: "Amazon Web Services", + gcp: "Google Cloud Platform", + azure: "Microsoft Azure", + m365: "Microsoft 365", + kubernetes: "Kubernetes", + github: "GitHub", +}; + +interface EnhancedProviderSelectorProps { + control: Control; + name: string; + providers: ProviderProps[]; + label?: string; + placeholder?: string; + isInvalid?: boolean; + showFormMessage?: boolean; + selectionMode?: "single" | "multiple"; + providerType?: ProviderType; + enableSearch?: boolean; + disabledProviderIds?: string[]; +} + +export const EnhancedProviderSelector = ({ + control, + name, + providers, + label = "Provider", + placeholder = "Select provider", + isInvalid = false, + showFormMessage = true, + selectionMode = "single", + providerType, + enableSearch = false, + disabledProviderIds = [], +}: EnhancedProviderSelectorProps) => { + const [searchValue, setSearchValue] = useState(""); + + const filteredProviders = useMemo(() => { + let filtered = providers; + + // Filter by provider type if specified + if (providerType) { + filtered = filtered.filter((p) => p.attributes.provider === providerType); + } + + // Filter by search value + if (searchValue && enableSearch) { + const lowerSearch = searchValue.toLowerCase(); + filtered = filtered.filter((p) => { + const displayName = p.attributes.alias || p.attributes.uid; + const typeLabel = providerTypeLabels[p.attributes.provider]; + return ( + displayName.toLowerCase().includes(lowerSearch) || + typeLabel.toLowerCase().includes(lowerSearch) + ); + }); + } + + // Sort providers + return filtered.sort((a, b) => { + const typeComparison = a.attributes.provider.localeCompare( + b.attributes.provider, + ); + if (typeComparison !== 0) return typeComparison; + + const nameA = a.attributes.alias || a.attributes.uid; + const nameB = b.attributes.alias || b.attributes.uid; + return nameA.localeCompare(nameB); + }); + }, [providers, providerType, searchValue, enableSearch]); + + return ( + { + const isMultiple = selectionMode === "multiple"; + const selectedIds = isMultiple ? value || [] : value ? [value] : []; + const allProviderIds = filteredProviders + .filter((p) => !disabledProviderIds.includes(p.id)) + .map((p) => p.id); + const isAllSelected = + isMultiple && + allProviderIds.length > 0 && + allProviderIds.every((id) => selectedIds.includes(id)); + + const handleSelectAll = () => { + if (isAllSelected) { + onChange([]); + } else { + onChange(allProviderIds); + } + }; + + const handleSelectionChange = (keys: any) => { + if (isMultiple) { + const selectedArray = Array.from(keys); + onChange(selectedArray); + } else { + const selectedValue = Array.from(keys)[0]; + onChange(selectedValue || ""); + } + }; + + return ( + <> + +
+ {isMultiple && filteredProviders.length > 1 && ( +
+ + {label} + + +
+ )} + } + value={searchValue} + onValueChange={setSearchValue} + onClear={() => setSearchValue("")} + classNames={{ + inputWrapper: + "border-default-200 bg-transparent hover:bg-default-100/50 dark:bg-transparent dark:hover:bg-default-100/20", + input: "text-small", + clearButton: "text-default-400", + }} + /> +
+ ) : null, + }} + > + {filteredProviders.map((provider) => { + const providerType = provider.attributes.provider; + const displayName = + provider.attributes.alias || provider.attributes.uid; + const typeLabel = providerTypeLabels[providerType]; + const isDisabled = disabledProviderIds.includes( + provider.id, + ); + + return ( + +
+
+
+
+ {displayName} +
+
+ {typeLabel} + {isDisabled && ( + + (Already used) + + )} +
+
+
+
+
+
+
+ + ); + })} + +
+
+ {showFormMessage && ( + + )} + + ); + }} + /> + ); +}; diff --git a/ui/components/providers/provider-selector.tsx b/ui/components/providers/provider-selector.tsx deleted file mode 100644 index a39a924bea..0000000000 --- a/ui/components/providers/provider-selector.tsx +++ /dev/null @@ -1,201 +0,0 @@ -"use client"; - -import { Button, Select, SelectItem } from "@nextui-org/react"; -import { CheckSquare, Square } from "lucide-react"; -import { Control } from "react-hook-form"; - -import { FormControl, FormField, FormMessage } from "@/components/ui/form"; -import { ProviderProps, ProviderType } from "@/types/providers"; - -const providerTypeLabels: Record = { - aws: "Amazon Web Services", - gcp: "Google Cloud Platform", - azure: "Microsoft Azure", - m365: "Microsoft 365", - kubernetes: "Kubernetes", - github: "GitHub", -}; - -interface ProviderSelectorProps { - control: Control; - name: string; - providers: ProviderProps[]; - label?: string; - placeholder?: string; - isInvalid?: boolean; - showFormMessage?: boolean; -} - -export const ProviderSelector = ({ - control, - name, - providers, - label = "Providers", - placeholder = "Select providers", - isInvalid = false, - showFormMessage = true, -}: ProviderSelectorProps) => { - // Sort providers by type and then by name for better organization - const sortedProviders = [...providers].sort((a, b) => { - const typeComparison = a.attributes.provider.localeCompare( - b.attributes.provider, - ); - if (typeComparison !== 0) return typeComparison; - - const nameA = a.attributes.alias || a.attributes.uid; - const nameB = b.attributes.alias || b.attributes.uid; - return nameA.localeCompare(nameB); - }); - - return ( - { - const selectedIds = value || []; - const allProviderIds = sortedProviders.map((p) => p.id); - const isAllSelected = - allProviderIds.length > 0 && - allProviderIds.every((id) => selectedIds.includes(id)); - - const handleSelectAll = () => { - if (isAllSelected) { - onChange([]); - } else { - onChange(allProviderIds); - } - }; - - return ( - <> - -
-
- - {label} - - {sortedProviders.length > 1 && ( - - )} -
- -
-
- {showFormMessage && ( - - )} - - ); - }} - /> - ); -}; diff --git a/ui/components/providers/workflow/credentials-role-helper.tsx b/ui/components/providers/workflow/credentials-role-helper.tsx index 209b62e6ee..52d5952530 100644 --- a/ui/components/providers/workflow/credentials-role-helper.tsx +++ b/ui/components/providers/workflow/credentials-role-helper.tsx @@ -11,7 +11,7 @@ interface CredentialsRoleHelperProps { cloudformationQuickLink: string; terraform: string; }; - type?: "providers" | "s3-integration"; + type?: "providers" | "integrations"; } export const CredentialsRoleHelper = ({ @@ -24,7 +24,7 @@ export const CredentialsRoleHelper = ({

A read-only IAM role must be manually created - {type === "s3-integration" ? " or updated" : ""}. + {type === "integrations" ? " or updated" : ""}.

{ const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; const defaultCredentialsType = isCloudEnv @@ -142,9 +142,6 @@ export const AWSRoleCredentialsForm = ({ isInvalid={ !!control._formState.errors[ ProviderCredentialFields.AWS_SECRET_ACCESS_KEY - ] || - !!control._formState.errors[ - ProviderCredentialFields.AWS_ACCESS_KEY_ID ] } /> diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts index c38f1a3574..125a227b87 100644 --- a/ui/lib/external-urls.ts +++ b/ui/lib/external-urls.ts @@ -1,3 +1,5 @@ +import { IntegrationType } from "../types/integrations"; + export const getProviderHelpText = (provider: string) => { switch (provider) { case "aws": @@ -41,12 +43,46 @@ export const getProviderHelpText = (provider: string) => { export const getAWSCredentialsTemplateLinks = ( externalId: string, bucketName?: string, -) => { + integrationType?: IntegrationType, +): { + cloudformation: string; + terraform: string; + cloudformationQuickLink: string; +} => { + let links = {}; + + if (integrationType === undefined) { + links = { + cloudformation: + "https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml", + terraform: + "https://github.com/prowler-cloud/prowler/tree/master/permissions/templates/terraform", + }; + } + + if (integrationType === "aws_security_hub") { + links = { + cloudformation: + "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-s3-integration/", + terraform: + "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-s3-integration/#terraform", + }; + } + + if (integrationType === "amazon_s3") { + links = { + cloudformation: + "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-s3-integration/", + terraform: + "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-s3-integration/#terraform", + }; + } + return { - cloudformation: - "https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml", + ...(links as { + cloudformation: string; + terraform: string; + }), cloudformationQuickLink: `https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Faws%2Fcloudformation%2Fprowler-scan-role.yml&stackName=Prowler¶m_ExternalId=${externalId}${bucketName ? `¶m_EnableS3Integration=true¶m_S3IntegrationBucketName=${bucketName}` : ""}`, - terraform: - "https://github.com/prowler-cloud/prowler/tree/master/permissions/templates/terraform", }; }; diff --git a/ui/lib/integrations/test-connection-helper.ts b/ui/lib/integrations/test-connection-helper.ts new file mode 100644 index 0000000000..6c92adbf39 --- /dev/null +++ b/ui/lib/integrations/test-connection-helper.ts @@ -0,0 +1,161 @@ +import { + pollConnectionTestStatus, + testIntegrationConnection, +} from "@/actions/integrations"; + +// Integration configuration type +export interface IntegrationMessages { + testingMessage: string; + successMessage: string; + errorMessage: string; +} + +// Configuration map for integration-specific messages +const INTEGRATION_CONFIG: Record = { + "amazon-s3": { + testingMessage: "Testing connection to Amazon S3 bucket...", + successMessage: "Successfully connected to Amazon S3 bucket.", + errorMessage: "Failed to connect to Amazon S3 bucket.", + }, + "aws-security-hub": { + testingMessage: "Testing connection to AWS Security Hub...", + successMessage: "Successfully connected to AWS Security Hub.", + errorMessage: "Failed to connect to AWS Security Hub.", + }, + // Legacy mappings for backward compatibility + s3: { + testingMessage: "Testing connection to Amazon S3 bucket...", + successMessage: "Successfully connected to Amazon S3 bucket.", + errorMessage: "Failed to connect to Amazon S3 bucket.", + }, + security_hub: { + testingMessage: "Testing connection to AWS Security Hub...", + successMessage: "Successfully connected to AWS Security Hub.", + errorMessage: "Failed to connect to AWS Security Hub.", + }, + // Add new integrations here as needed +}; + +// Helper function to register new integration types +export const registerIntegrationType = ( + type: string, + messages: IntegrationMessages, +): void => { + INTEGRATION_CONFIG[type] = messages; +}; + +// Helper function to get supported integration types +export const getSupportedIntegrationTypes = (): string[] => { + return Object.keys(INTEGRATION_CONFIG); +}; + +interface TestConnectionOptions { + integrationId: string; + integrationType: string; + onSuccess?: (message: string) => void; + onError?: (message: string) => void; + onStart?: () => void; + onComplete?: () => void; +} + +export const runTestConnection = async ({ + integrationId, + integrationType, + onSuccess, + onError, + onStart, + onComplete, +}: TestConnectionOptions) => { + try { + // Start the test without waiting for completion + const result = await testIntegrationConnection(integrationId, false); + + if (!result || (!result.success && !result.error)) { + onError?.("Connection test could not be started. Please try again."); + onComplete?.(); + return; + } + + if (result.error) { + onError?.(result.error); + onComplete?.(); + return; + } + + if (!result.taskId) { + onError?.("Failed to start connection test. No task ID received."); + onComplete?.(); + return; + } + + // Notify that test has started + onStart?.(); + + // Poll for the test completion + const pollResult = await pollConnectionTestStatus(result.taskId); + + if (pollResult.success) { + const config = INTEGRATION_CONFIG[integrationType]; + const defaultMessage = + config?.successMessage || + `Successfully connected to ${integrationType}.`; + onSuccess?.(pollResult.message || defaultMessage); + } else { + const config = INTEGRATION_CONFIG[integrationType]; + const defaultError = + config?.errorMessage || `Failed to connect to ${integrationType}.`; + onError?.(pollResult.error || defaultError); + } + } catch (error) { + onError?.( + "Failed to start connection test. You can try manually using the Test Connection button.", + ); + } finally { + onComplete?.(); + } +}; + +export const triggerTestConnectionWithDelay = ( + integrationId: string | undefined, + shouldTestConnection: boolean | undefined, + integrationType: string, + toast: any, + delay = 200, + onComplete?: () => void, +) => { + if (!integrationId || !shouldTestConnection) { + onComplete?.(); + return; + } + + setTimeout(() => { + runTestConnection({ + integrationId, + integrationType, + onStart: () => { + const config = INTEGRATION_CONFIG[integrationType]; + const description = + config?.testingMessage || + `Testing connection to ${integrationType}...`; + toast({ + title: "Connection test started!", + description, + }); + }, + onSuccess: (message) => { + toast({ + title: "Connection test successful!", + description: message, + }); + }, + onError: (message) => { + toast({ + variant: "destructive", + title: "Connection test failed", + description: message, + }); + }, + onComplete, + }); + }, delay); +}; diff --git a/ui/types/integrations.ts b/ui/types/integrations.ts index 63601aa8a9..a95174cb45 100644 --- a/ui/types/integrations.ts +++ b/ui/types/integrations.ts @@ -32,49 +32,92 @@ export interface IntegrationProps { links: { self: string }; } -const baseS3IntegrationSchema = z.object({ - integration_type: z.literal("amazon_s3"), - bucket_name: z.string().min(1, "Bucket name is required"), - output_directory: z.string().min(1, "Output directory is required"), - providers: z.array(z.string()).optional(), - enabled: z.boolean().optional(), - // AWS Credentials fields compatible with AWSCredentialsRole +// Shared AWS credential fields schema +const awsCredentialFields = { credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]), aws_access_key_id: z.string().optional(), aws_secret_access_key: z.string().optional(), aws_session_token: z.string().optional(), - // IAM Role fields role_arn: z.string().optional(), external_id: z.string().optional(), role_session_name: z.string().optional(), session_duration: z.string().optional(), - // Hidden field to track if role section is shown show_role_section: z.boolean().optional(), -}); +}; -const s3IntegrationValidation = (data: any, ctx: z.RefinementCtx) => { - // If using access-secret-key, require AWS credentials (for create form) - if (data.credentials_type === "access-secret-key") { +// Shared validation helper for AWS credentials (create mode) +const validateAwsCredentialsCreate = ( + data: any, + ctx: z.RefinementCtx, + requireCredentials: boolean = true, +) => { + if (data.credentials_type === "access-secret-key" && requireCredentials) { if (!data.aws_access_key_id) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: - "AWS Access Key ID is required when using access and secret key", + message: "AWS Access Key ID is required when using access and secret key", path: ["aws_access_key_id"], }); } if (!data.aws_secret_access_key) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: - "AWS Secret Access Key is required when using access and secret key", + message: "AWS Secret Access Key is required when using access and secret key", path: ["aws_secret_access_key"], }); } } +}; - // When role section is shown, both role_arn and external_id are required - if (data.show_role_section === true) { +// Shared validation helper for AWS credentials (edit mode) +const validateAwsCredentialsEdit = (data: any, ctx: z.RefinementCtx) => { + if (data.credentials_type === "access-secret-key") { + const hasAccessKey = !!data.aws_access_key_id; + const hasSecretKey = !!data.aws_secret_access_key; + + if (hasAccessKey && !hasSecretKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "AWS Secret Access Key is required when providing Access Key ID", + path: ["aws_secret_access_key"], + }); + } + + if (hasSecretKey && !hasAccessKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "AWS Access Key ID is required when providing Secret Access Key", + path: ["aws_access_key_id"], + }); + } + } +}; + +// Shared validation helper for IAM Role fields +const validateIamRole = ( + data: any, + ctx: z.RefinementCtx, + checkShowSection: boolean = true, +) => { + const shouldValidate = checkShowSection ? data.show_role_section === true : true; + + if (shouldValidate && data.role_arn) { + if (data.role_arn.trim() === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Role ARN is required", + path: ["role_arn"], + }); + } else if (!data.external_id || data.external_id.trim() === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "External ID is required when using Role ARN", + path: ["external_id"], + }); + } + } + + if (checkShowSection && data.show_role_section === true) { if (!data.role_arn || data.role_arn.trim() === "") { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -92,49 +135,15 @@ const s3IntegrationValidation = (data: any, ctx: z.RefinementCtx) => { } }; -const s3IntegrationEditValidation = (data: any, ctx: z.RefinementCtx) => { - // If using access-secret-key, and credentials are provided, require both - if (data.credentials_type === "access-secret-key") { - const hasAccessKey = !!data.aws_access_key_id; - const hasSecretKey = !!data.aws_secret_access_key; - - if (hasAccessKey && !hasSecretKey) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "AWS Secret Access Key is required when providing Access Key ID", - path: ["aws_secret_access_key"], - }); - } - - if (hasSecretKey && !hasAccessKey) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "AWS Access Key ID is required when providing Secret Access Key", - path: ["aws_access_key_id"], - }); - } - } - - // When role section is shown (editing credentials with role), both fields are required - if (data.show_role_section === true) { - if (data.role_arn && data.role_arn.trim() === "") { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Role ARN is required", - path: ["role_arn"], - }); - } - if (data.external_id && data.external_id.trim() === "") { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "External ID is required", - path: ["external_id"], - }); - } - } -}; +// S3 Integration Schemas +const baseS3IntegrationSchema = z.object({ + integration_type: z.literal("amazon_s3"), + bucket_name: z.string().min(1, "Bucket name is required"), + output_directory: z.string().min(1, "Output directory is required"), + providers: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + ...awsCredentialFields, +}); export const s3IntegrationFormSchema = baseS3IntegrationSchema .extend({ @@ -143,18 +152,67 @@ export const s3IntegrationFormSchema = baseS3IntegrationSchema .enum(["aws-sdk-default", "access-secret-key"]) .default("aws-sdk-default"), }) - .superRefine(s3IntegrationValidation); + .superRefine((data, ctx) => { + validateAwsCredentialsCreate(data, ctx); + validateIamRole(data, ctx); + }); export const editS3IntegrationFormSchema = baseS3IntegrationSchema .extend({ bucket_name: z.string().min(1, "Bucket name is required").optional(), - output_directory: z - .string() - .min(1, "Output directory is required") - .optional(), + output_directory: z.string().min(1, "Output directory is required").optional(), providers: z.array(z.string()).optional(), + credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]).optional(), + }) + .superRefine((data, ctx) => { + validateAwsCredentialsEdit(data, ctx); + validateIamRole(data, ctx); + }); + +// Security Hub Integration Schemas +const baseSecurityHubIntegrationSchema = z.object({ + integration_type: z.literal("aws_security_hub"), + provider_id: z.string().min(1, "AWS Provider is required"), + send_only_fails: z.boolean().optional(), + archive_previous_findings: z.boolean().optional(), + use_custom_credentials: z.boolean().optional(), + enabled: z.boolean().optional(), + ...awsCredentialFields, +}); + +export const securityHubIntegrationFormSchema = baseSecurityHubIntegrationSchema + .extend({ + enabled: z.boolean().default(true), + send_only_fails: z.boolean().default(true), + archive_previous_findings: z.boolean().default(false), + use_custom_credentials: z.boolean().default(false), credentials_type: z .enum(["aws-sdk-default", "access-secret-key"]) - .optional(), + .default("aws-sdk-default"), }) - .superRefine(s3IntegrationEditValidation); + .superRefine((data, ctx) => { + if (data.use_custom_credentials) { + validateAwsCredentialsCreate(data, ctx); + validateIamRole(data, ctx); + } + // Always validate role if role_arn is provided + if (!data.use_custom_credentials && data.role_arn) { + validateIamRole(data, ctx, false); + } + }); + +export const editSecurityHubIntegrationFormSchema = baseSecurityHubIntegrationSchema + .extend({ + provider_id: z.string().optional(), + send_only_fails: z.boolean().optional(), + archive_previous_findings: z.boolean().optional(), + use_custom_credentials: z.boolean().optional(), + credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]).optional(), + }) + .superRefine((data, ctx) => { + if (data.use_custom_credentials !== false) { + validateAwsCredentialsEdit(data, ctx); + } + // Always validate role if role_arn is provided + validateIamRole(data, ctx, false); + }); \ No newline at end of file