diff --git a/ui/actions/integrations/github-dispatch.ts b/ui/actions/integrations/github-dispatch.ts new file mode 100644 index 0000000000..2f04e514eb --- /dev/null +++ b/ui/actions/integrations/github-dispatch.ts @@ -0,0 +1,165 @@ +"use server"; + +import { pollTaskUntilSettled } from "@/actions/task/poll"; +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiError } from "@/lib/server-actions-helper"; +import type { IntegrationProps } from "@/types/integrations"; + +export interface GitHubDispatchRequest { + data: { + type: "integrations-github-dispatches"; + attributes: { + repository: string; + labels?: string[]; + }; + }; +} + +export interface GitHubDispatchResponse { + data: { + id: string; + type: "tasks"; + attributes: { + state: string; + result?: { + created_count?: number; + failed_count?: number; + error?: string; + }; + }; + }; +} + +export const getGitHubIntegrations = async (): Promise< + | { success: true; data: IntegrationProps[] } + | { success: false; error: string } +> => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/integrations`); + + // Filter for GitHub integrations only + url.searchParams.append("filter[integration_type]", "github"); + + try { + const response = await fetch(url.toString(), { method: "GET", headers }); + + if (response.ok) { + const data: { data: IntegrationProps[] } = await response.json(); + // Filter for enabled integrations on the client side + const enabledIntegrations = (data.data || []).filter( + (integration: IntegrationProps) => + integration.attributes.enabled === true, + ); + return { success: true, data: enabledIntegrations }; + } + + const errorData: unknown = await response.json().catch(() => ({})); + const errorMessage = + (errorData as { errors?: { detail?: string }[] }).errors?.[0]?.detail || + `Unable to fetch GitHub integrations: ${response.statusText}`; + return { success: false, error: errorMessage }; + } catch (error) { + const errorResult = handleApiError(error); + return { success: false, error: errorResult.error || "An error occurred" }; + } +}; + +export const sendFindingToGitHub = async ( + integrationId: string, + findingId: string, + repository: string, + labels?: string[], +): Promise< + | { success: true; taskId: string; message: string } + | { success: false; error: string } +> => { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL( + `${apiBaseUrl}/integrations/${integrationId}/github/dispatches`, + ); + + // Single finding: use direct filter without array notation + url.searchParams.append("filter[finding_id]", findingId); + + const payload: GitHubDispatchRequest = { + data: { + type: "integrations-github-dispatches", + attributes: { + repository: repository, + labels: labels || [], + }, + }, + }; + + try { + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(payload), + }); + + if (response.ok) { + const data: GitHubDispatchResponse = await response.json(); + const taskId = data?.data?.id; + + if (taskId) { + return { + success: true, + taskId, + message: "GitHub issue creation started. Processing...", + }; + } else { + return { + success: false, + error: "Failed to start GitHub dispatch. No task ID received.", + }; + } + } + + const errorData: unknown = await response.json().catch(() => ({})); + const errorMessage = + (errorData as { errors?: { detail?: string }[] }).errors?.[0]?.detail || + `Unable to send finding to GitHub: ${response.statusText}`; + return { success: false, error: errorMessage }; + } catch (error) { + const errorResult = handleApiError(error); + return { success: false, error: errorResult.error || "An error occurred" }; + } +}; + +export const pollGitHubDispatchTask = async ( + taskId: string, +): Promise< + { success: true; message: string } | { success: false; error: string } +> => { + const res = await pollTaskUntilSettled(taskId, { + maxAttempts: 10, + delayMs: 2000, + }); + if (!res.ok) { + return { success: false, error: res.error }; + } + const { state, result } = res; + type GitHubTaskResult = + GitHubDispatchResponse["data"]["attributes"]["result"]; + const githubResult = result as GitHubTaskResult | undefined; + + if (state === "completed") { + if (!githubResult?.error) { + return { + success: true, + message: "Finding successfully sent to GitHub!", + }; + } + return { + success: false, + error: githubResult?.error || "Failed to create GitHub issue.", + }; + } + + if (state === "failed") { + return { success: false, error: githubResult?.error || "Task failed." }; + } + + return { success: false, error: `Unknown task state: ${state}` }; +}; diff --git a/ui/app/(prowler)/integrations/github/page.tsx b/ui/app/(prowler)/integrations/github/page.tsx new file mode 100644 index 0000000000..7330954acf --- /dev/null +++ b/ui/app/(prowler)/integrations/github/page.tsx @@ -0,0 +1,93 @@ +import { getIntegrations } from "@/actions/integrations"; +import { GitHubIntegrationsManager } from "@/components/integrations/github/github-integrations-manager"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn"; +import { ContentLayout } from "@/components/ui"; + +interface GitHubIntegrationsProps { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +} + +export default async function GitHubIntegrations({ + searchParams, +}: GitHubIntegrationsProps) { + const resolvedSearchParams = await searchParams; + const page = parseInt(resolvedSearchParams.page?.toString() || "1", 10); + const pageSize = parseInt( + resolvedSearchParams.pageSize?.toString() || "10", + 10, + ); + const sort = resolvedSearchParams.sort?.toString(); + + // Extract all filter parameters + const filters = Object.fromEntries( + Object.entries(resolvedSearchParams).filter(([key]) => + key.startsWith("filter["), + ), + ); + + const urlSearchParams = new URLSearchParams(); + urlSearchParams.set("filter[integration_type]", "github"); + urlSearchParams.set("page[number]", page.toString()); + urlSearchParams.set("page[size]", pageSize.toString()); + + if (sort) { + urlSearchParams.set("sort", sort); + } + + // Add any additional filters + 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] = await Promise.all([getIntegrations(urlSearchParams)]); + + const githubIntegrations = integrations?.data || []; + const metadata = integrations?.meta; + + return ( + +
+
+

+ Configure GitHub integration to automatically create issues for + security findings in your GitHub repositories. +

+ + + + Features + + +
    +
  • + + Automated issue creation +
  • +
  • + + Multi-Cloud support +
  • +
  • + + Repository-based tracking +
  • +
  • + + Label customization +
  • +
+
+
+
+ + +
+
+ ); +} diff --git a/ui/app/(prowler)/integrations/page.tsx b/ui/app/(prowler)/integrations/page.tsx index d3f733d658..39304d46b5 100644 --- a/ui/app/(prowler)/integrations/page.tsx +++ b/ui/app/(prowler)/integrations/page.tsx @@ -1,5 +1,6 @@ import { ApiKeyLinkCard, + GitHubIntegrationCard, JiraIntegrationCard, S3IntegrationCard, SecurityHubIntegrationCard, @@ -25,6 +26,9 @@ export default async function Integrations() { {/* AWS Security Hub Integration */} + {/* GitHub Integration */} + + {/* Jira Integration */} diff --git a/ui/components/integrations/github/github-integration-card.tsx b/ui/components/integrations/github/github-integration-card.tsx new file mode 100644 index 0000000000..768d3c24e2 --- /dev/null +++ b/ui/components/integrations/github/github-integration-card.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { GithubIcon, SettingsIcon } from "lucide-react"; +import Link from "next/link"; + +import { Button } from "@/components/shadcn"; +import { CustomLink } from "@/components/ui/custom/custom-link"; + +import { Card, CardContent, CardHeader } from "../../shadcn"; + +export const GitHubIntegrationCard = () => { + return ( + + +
+
+ +
+

+ GitHub +

+
+

+ Create security issues in GitHub repositories. +

+ + Learn more + +
+
+
+
+ +
+
+
+ +

+ Configure and manage your GitHub integrations to automatically create + issues for security findings in your GitHub repositories. +

+
+
+ ); +}; diff --git a/ui/components/integrations/github/github-integration-form.tsx b/ui/components/integrations/github/github-integration-form.tsx new file mode 100644 index 0000000000..cc1cf95aca --- /dev/null +++ b/ui/components/integrations/github/github-integration-form.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; + +import { createIntegration, updateIntegration } from "@/actions/integrations"; +import { useToast } from "@/components/ui"; +import { CustomInput } from "@/components/ui/custom"; +import { CustomLink } from "@/components/ui/custom/custom-link"; +import { Form } from "@/components/ui/form"; +import { FormButtons } from "@/components/ui/form/form-buttons"; +import { + editGitHubIntegrationFormSchema, + type GitHubCreateValues, + type GitHubCredentialsPayload, + type GitHubFormValues, + githubIntegrationFormSchema, + IntegrationProps, +} from "@/types/integrations"; + +interface GitHubIntegrationFormProps { + integration?: IntegrationProps | null; + onSuccess: (integrationId?: string, shouldTestConnection?: boolean) => void; + onCancel: () => void; +} + +export const GitHubIntegrationForm = ({ + integration, + onSuccess, + onCancel, +}: GitHubIntegrationFormProps) => { + const { toast } = useToast(); + const isEditing = !!integration; + const isCreating = !isEditing; + + const form = useForm({ + resolver: zodResolver( + isCreating + ? githubIntegrationFormSchema + : editGitHubIntegrationFormSchema, + ), + defaultValues: { + integration_type: "github" as const, + owner: integration?.attributes.configuration.owner || "", + enabled: integration?.attributes.enabled ?? true, + token: "", + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmit = async (data: GitHubFormValues) => { + try { + const formData = new FormData(); + + // Add integration type + formData.append("integration_type", "github"); + + // Prepare credentials object + const credentials: GitHubCredentialsPayload = {}; + + // For editing, only add fields that have values + if (isEditing) { + if (data.token) credentials.token = data.token; + if (data.owner) credentials.owner = data.owner.trim(); + } else { + // For creation, token is required + const createData = data as GitHubCreateValues; + credentials.token = createData.token; + if (createData.owner) credentials.owner = createData.owner.trim(); + } + + // Add credentials as JSON + if (Object.keys(credentials).length > 0) { + formData.append("credentials", JSON.stringify(credentials)); + } + + // For creation, we need to provide configuration and providers + if (isCreating) { + formData.append("configuration", JSON.stringify({})); + formData.append("providers", JSON.stringify([])); + // enabled exists only in create schema + formData.append( + "enabled", + JSON.stringify((data as GitHubCreateValues).enabled), + ); + } + + type IntegrationResult = + | { success: string; integrationId?: string } + | { error: string }; + let result: IntegrationResult; + if (isEditing) { + result = await updateIntegration(integration.id, formData); + } else { + result = await createIntegration(formData); + } + + if (result && "success" in result && result.success) { + toast({ + title: "Success!", + description: `GitHub integration ${isEditing ? "updated" : "created"} successfully.`, + }); + + // Always test connection when creating or updating + const shouldTestConnection = true; + const integrationId = + "integrationId" in result ? result.integrationId : integration?.id; + + onSuccess(integrationId, shouldTestConnection); + } else if (result && "error" in result) { + toast({ + variant: "destructive", + title: "Operation Failed", + description: result.error, + }); + } + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: `Failed to ${isEditing ? "update" : "create"} GitHub integration. Please try again.`, + }); + } + }; + + const renderForm = () => { + return ( + <> + + + + +
+

+ To generate a Personal Access Token with the repo{" "} + scope, visit your{" "} + + GitHub token settings + + . The owner field is optional and filters repositories by user or + organization. +

+
+ + ); + }; + + const getButtonLabel = () => { + if (isEditing) { + return "Update Credentials"; + } + return "Create Integration"; + }; + + return ( +
+ +
+
+

+ Need help configuring your GitHub integration? +

+ + Read the docs + +
+ {renderForm()} +
+ {}} + onCancel={onCancel} + submitText={getButtonLabel()} + cancelText="Cancel" + loadingText="Processing..." + isDisabled={isLoading} + /> + + + ); +}; diff --git a/ui/components/integrations/github/github-integrations-manager.tsx b/ui/components/integrations/github/github-integrations-manager.tsx new file mode 100644 index 0000000000..72cf1f5b9c --- /dev/null +++ b/ui/components/integrations/github/github-integrations-manager.tsx @@ -0,0 +1,376 @@ +"use client"; + +import { format } from "date-fns"; +import { GithubIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { useState } from "react"; + +import { + deleteIntegration, + testIntegrationConnection, + updateIntegration, +} from "@/actions/integrations"; +import { + IntegrationActionButtons, + IntegrationCardHeader, + IntegrationSkeleton, +} from "@/components/integrations/shared"; +import { Button } from "@/components/shadcn"; +import { useToast } from "@/components/ui"; +import { CustomAlertModal } 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 { Card, CardContent, CardHeader } from "../../shadcn"; +import { GitHubIntegrationForm } from "./github-integration-form"; + +interface GitHubIntegrationsManagerProps { + integrations: IntegrationProps[]; + metadata?: MetaDataProps; +} + +export const GitHubIntegrationsManager = ({ + integrations, + metadata, +}: GitHubIntegrationsManagerProps) => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingIntegration, setEditingIntegration] = + useState(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); + setIsModalOpen(true); + }; + + const handleEditCredentials = (integration: IntegrationProps) => { + setEditingIntegration(integration); + setIsModalOpen(true); + }; + + const handleOpenDeleteModal = (integration: IntegrationProps) => { + setIntegrationToDelete(integration); + setIsDeleteOpen(true); + }; + + const handleDeleteIntegration = async (id: string) => { + setIsDeleting(id); + try { + const result = await deleteIntegration(id, "github"); + + if (result.success) { + toast({ + title: "Success!", + description: "GitHub 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 GitHub 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, + "github", + 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); + }; + + const handleFormSuccess = async ( + integrationId?: string, + shouldTestConnection?: boolean, + ) => { + // Close the modal immediately + setIsModalOpen(false); + setEditingIntegration(null); + setIsOperationLoading(true); + + // Set testing state for server-triggered test connections + if (integrationId && shouldTestConnection) { + setIsTesting(integrationId); + } + + // Trigger test connection if needed + triggerTestConnectionWithDelay( + integrationId, + shouldTestConnection, + "github", + 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); + }; + + return ( + <> + +
+ + + +
+
+ + + + + +
+
+
+

GitHub Integrations

+

+ Manage your GitHub integrations to send findings as issues +

+
+ +
+ +
+ {isOperationLoading && } + {!isOperationLoading && integrations.length === 0 && ( + + + +

+ No GitHub integrations configured +

+

+ Get started by adding your first GitHub integration +

+ +
+
+ )} + {!isOperationLoading && + integrations.map((integration) => ( + + + + } + title="GitHub Integration" + subtitle={ + integration.attributes.configuration.owner + ? `Owner: ${integration.attributes.configuration.owner}` + : "All accessible repositories" + } + integrationId={integration.id} + enabled={integration.attributes.enabled} + connected={integration.attributes.connected} + lastChecked={integration.attributes.connection_last_checked_at} + isTesting={isTesting === integration.id} + /> + + +
+ {integration.attributes.configuration.repositories && + Object.keys( + integration.attributes.configuration.repositories, + ).length > 0 && ( +
+

+ Accessible Repositories:{" "} + { + Object.keys( + integration.attributes.configuration.repositories, + ).length + } +

+

+ Last synced:{" "} + {integration.attributes.connection_last_checked_at + ? format( + new Date( + integration.attributes.connection_last_checked_at, + ), + "PPpp", + ) + : "Never"} +

+
+ )} + + +
+
+
+ ))} +
+ + {metadata && integrations.length > 0 && !isOperationLoading && ( + + )} +
+ + ); +}; diff --git a/ui/components/integrations/index.ts b/ui/components/integrations/index.ts index d46942d57c..22300c8e91 100644 --- a/ui/components/integrations/index.ts +++ b/ui/components/integrations/index.ts @@ -1,5 +1,8 @@ export * from "../providers/enhanced-provider-selector"; export * from "./api-key/api-key-link-card"; +export * from "./github/github-integration-card"; +export * from "./github/github-integration-form"; +export * from "./github/github-integrations-manager"; export * from "./jira/jira-integration-card"; export * from "./jira/jira-integration-form"; export * from "./jira/jira-integrations-manager"; diff --git a/ui/types/integrations.ts b/ui/types/integrations.ts index 4f579847e3..169b71cf80 100644 --- a/ui/types/integrations.ts +++ b/ui/types/integrations.ts @@ -310,3 +310,26 @@ export interface JiraCredentialsPayload { user_mail?: string; api_token?: string; } + +// GitHub Integration Schemas +export const githubIntegrationFormSchema = z.object({ + integration_type: z.literal("github"), + token: z.string().min(1, "GitHub token is required"), + owner: z.string().optional(), + enabled: z.boolean().default(true), +}); + +export const editGitHubIntegrationFormSchema = z.object({ + integration_type: z.literal("github"), + token: z.string().min(1, "GitHub token is required").optional(), + owner: z.string().optional(), +}); + +export type GitHubCreateValues = z.infer; +export type GitHubEditValues = z.infer; +export type GitHubFormValues = GitHubCreateValues | GitHubEditValues; + +export interface GitHubCredentialsPayload { + token?: string; + owner?: string; +}