diff --git a/apps/api/src/attachments/upload-attachment.dto.ts b/apps/api/src/attachments/upload-attachment.dto.ts index 33f2aceb0..8173ac9bf 100644 --- a/apps/api/src/attachments/upload-attachment.dto.ts +++ b/apps/api/src/attachments/upload-attachment.dto.ts @@ -18,6 +18,8 @@ const ALLOWED_FILE_TYPES = [ 'text/plain', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ]; export class UploadAttachmentDto { diff --git a/apps/api/src/tasks/dto/upload-attachment.dto.ts b/apps/api/src/tasks/dto/upload-attachment.dto.ts index 33f2aceb0..8173ac9bf 100644 --- a/apps/api/src/tasks/dto/upload-attachment.dto.ts +++ b/apps/api/src/tasks/dto/upload-attachment.dto.ts @@ -18,6 +18,8 @@ const ALLOWED_FILE_TYPES = [ 'text/plain', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ]; export class UploadAttachmentDto { diff --git a/apps/app/package.json b/apps/app/package.json index a1c39c34d..a79da837f 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -8,6 +8,7 @@ "@ai-sdk/provider": "^2.0.0", "@ai-sdk/react": "^2.0.60", "@ai-sdk/rsc": "^1.0.0", + "@aws-sdk/client-ec2": "^3.911.0", "@aws-sdk/client-lambda": "^3.891.0", "@aws-sdk/client-s3": "^3.859.0", "@aws-sdk/client-sts": "^3.808.0", diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/connect-cloud.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/connect-cloud.ts new file mode 100644 index 000000000..878377adc --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/connect-cloud.ts @@ -0,0 +1,121 @@ +'use server'; + +import { encrypt } from '@/lib/encryption'; +import { getIntegrationHandler } from '@comp/integrations'; +import { db } from '@db'; +import { revalidatePath } from 'next/cache'; +import { headers } from 'next/headers'; +import { z } from 'zod'; +import { authActionClient } from '../../../../../actions/safe-action'; +import { runTests } from './run-tests'; + +const connectCloudSchema = z.object({ + cloudProvider: z.enum(['aws', 'gcp', 'azure']), + credentials: z.record(z.string(), z.string()), +}); + +export const connectCloudAction = authActionClient + .inputSchema(connectCloudSchema) + .metadata({ + name: 'connect-cloud', + track: { + event: 'connect-cloud', + channel: 'cloud-tests', + }, + }) + .action(async ({ parsedInput: { cloudProvider, credentials }, ctx: { session } }) => { + try { + if (!session.activeOrganizationId) { + return { + success: false, + error: 'No active organization found', + }; + } + + // Validate credentials before storing + try { + const integrationHandler = getIntegrationHandler(cloudProvider); + if (!integrationHandler) { + return { + success: false, + error: 'Integration handler not found', + }; + } + + // Process credentials to the format expected by the handler + const typedCredentials = await integrationHandler.processCredentials( + credentials, + async (data: any) => data, // Pass through without encryption for validation + ); + + // Validate by attempting to fetch (this will throw if credentials are invalid) + await integrationHandler.fetch(typedCredentials); + } catch (error) { + console.error('Credential validation failed:', error); + return { + success: false, + error: + error instanceof Error + ? `Invalid credentials: ${error.message}` + : 'Failed to validate credentials. Please check your credentials and try again.', + }; + } + + // Encrypt all credential fields after validation + const encryptedCredentials: Record = {}; + for (const [key, value] of Object.entries(credentials)) { + if (value) { + encryptedCredentials[key] = await encrypt(value); + } + } + + // Check if integration already exists + const existingIntegration = await db.integration.findFirst({ + where: { + integrationId: cloudProvider, + organizationId: session.activeOrganizationId, + }, + }); + + if (existingIntegration) { + // Update existing integration + await db.integration.update({ + where: { id: existingIntegration.id }, + data: { + userSettings: encryptedCredentials as any, + lastRunAt: null, // Reset to trigger new scan + }, + }); + } else { + // Create new integration + await db.integration.create({ + data: { + name: cloudProvider.toUpperCase(), + integrationId: cloudProvider, + organizationId: session.activeOrganizationId, + userSettings: encryptedCredentials as any, + settings: {}, + }, + }); + } + + // Trigger immediate scan + await runTests(); + + // Revalidate the path + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + revalidatePath(path); + + return { + success: true, + }; + } catch (error) { + console.error('Failed to connect cloud provider:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to connect cloud provider', + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/disconnect-cloud.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/disconnect-cloud.ts new file mode 100644 index 000000000..5e5aa7c75 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/disconnect-cloud.ts @@ -0,0 +1,67 @@ +'use server'; + +import { db } from '@db'; +import { revalidatePath } from 'next/cache'; +import { headers } from 'next/headers'; +import { z } from 'zod'; +import { authActionClient } from '../../../../../actions/safe-action'; + +const disconnectCloudSchema = z.object({ + cloudProvider: z.enum(['aws', 'gcp', 'azure']), +}); + +export const disconnectCloudAction = authActionClient + .inputSchema(disconnectCloudSchema) + .metadata({ + name: 'disconnect-cloud', + track: { + event: 'disconnect-cloud', + channel: 'cloud-tests', + }, + }) + .action(async ({ parsedInput: { cloudProvider }, ctx: { session } }) => { + try { + if (!session.activeOrganizationId) { + return { + success: false, + error: 'No active organization found', + }; + } + + // Find and delete the integration + const integration = await db.integration.findFirst({ + where: { + integrationId: cloudProvider, + organizationId: session.activeOrganizationId, + }, + }); + + if (!integration) { + return { + success: false, + error: 'Cloud provider not found', + }; + } + + // Delete the integration (cascade will delete results) + await db.integration.delete({ + where: { id: integration.id }, + }); + + // Revalidate the path + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + revalidatePath(path); + + return { + success: true, + }; + } catch (error) { + console.error('Failed to disconnect cloud provider:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to disconnect cloud provider', + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/tests/dashboard/actions/run-tests.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/run-tests.ts similarity index 86% rename from apps/app/src/app/(app)/[orgId]/tests/dashboard/actions/run-tests.ts rename to apps/app/src/app/(app)/[orgId]/cloud-tests/actions/run-tests.ts index 378d56534..6d73e7095 100644 --- a/apps/app/src/app/(app)/[orgId]/tests/dashboard/actions/run-tests.ts +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/run-tests.ts @@ -32,9 +32,8 @@ export const runTests = async () => { }); const headersList = await headers(); - let path = - headersList.get("x-pathname") || headersList.get("referer") || ""; - path = path.replace(/\/[a-z]{2}\//, "/"); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); revalidatePath(path); @@ -42,10 +41,11 @@ export const runTests = async () => { success: true, errors: null, taskId: handle.id, + publicAccessToken: handle.publicAccessToken, }; } catch (error) { console.error('Error triggering integration tests:', error); - + return { success: false, errors: [error instanceof Error ? error.message : 'Failed to trigger integration tests'], diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/update-cloud-credentials.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/update-cloud-credentials.ts new file mode 100644 index 000000000..7550d7099 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/update-cloud-credentials.ts @@ -0,0 +1,80 @@ +'use server'; + +import { encrypt } from '@/lib/encryption'; +import { db } from '@db'; +import { revalidatePath } from 'next/cache'; +import { headers } from 'next/headers'; +import { z } from 'zod'; +import { authActionClient } from '../../../../../actions/safe-action'; + +const updateCloudCredentialsSchema = z.object({ + cloudProvider: z.enum(['aws', 'gcp', 'azure']), + credentials: z.record(z.string(), z.string()), +}); + +export const updateCloudCredentialsAction = authActionClient + .inputSchema(updateCloudCredentialsSchema) + .metadata({ + name: 'update-cloud-credentials', + track: { + event: 'update-cloud-credentials', + channel: 'cloud-tests', + }, + }) + .action(async ({ parsedInput: { cloudProvider, credentials }, ctx: { session } }) => { + try { + if (!session.activeOrganizationId) { + return { + success: false, + error: 'No active organization found', + }; + } + + // Find the integration + const integration = await db.integration.findFirst({ + where: { + integrationId: cloudProvider, + organizationId: session.activeOrganizationId, + }, + }); + + if (!integration) { + return { + success: false, + error: 'Cloud provider not found', + }; + } + + // Encrypt all credential fields + const encryptedCredentials: Record = {}; + for (const [key, value] of Object.entries(credentials)) { + if (value) { + encryptedCredentials[key] = await encrypt(value); + } + } + + // Update the integration + await db.integration.update({ + where: { id: integration.id }, + data: { + userSettings: encryptedCredentials as any, + }, + }); + + // Revalidate the path + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + revalidatePath(path); + + return { + success: true, + }; + } catch (error) { + console.error('Failed to update cloud credentials:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update cloud credentials', + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/validate-aws-credentials.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/validate-aws-credentials.ts new file mode 100644 index 000000000..6eb90739f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/validate-aws-credentials.ts @@ -0,0 +1,98 @@ +'use server'; + +import { DescribeRegionsCommand, EC2Client } from '@aws-sdk/client-ec2'; +import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'; +import { z } from 'zod'; +import { authActionClient } from '../../../../../actions/safe-action'; + +const validateAwsCredentialsSchema = z.object({ + accessKeyId: z.string(), + secretAccessKey: z.string(), +}); + +export const validateAwsCredentialsAction = authActionClient + .inputSchema(validateAwsCredentialsSchema) + .metadata({ + name: 'validate-aws-credentials', + track: { + event: 'validate-aws-credentials', + channel: 'cloud-tests', + }, + }) + .action(async ({ parsedInput: { accessKeyId, secretAccessKey } }) => { + try { + // First, validate credentials using STS + const stsClient = new STSClient({ + region: 'us-east-1', // Default region for validation + credentials: { + accessKeyId, + secretAccessKey, + }, + }); + + const identity = await stsClient.send(new GetCallerIdentityCommand({})); + + // Get available regions + const ec2Client = new EC2Client({ + region: 'us-east-1', + credentials: { + accessKeyId, + secretAccessKey, + }, + }); + + const regionsResponse = await ec2Client.send(new DescribeRegionsCommand({})); + + // Map of common region codes to friendly names + const regionNames: Record = { + 'us-east-1': 'US East (N. Virginia)', + 'us-east-2': 'US East (Ohio)', + 'us-west-1': 'US West (N. California)', + 'us-west-2': 'US West (Oregon)', + 'eu-west-1': 'Europe (Ireland)', + 'eu-west-2': 'Europe (London)', + 'eu-west-3': 'Europe (Paris)', + 'eu-central-1': 'Europe (Frankfurt)', + 'eu-north-1': 'Europe (Stockholm)', + 'eu-south-1': 'Europe (Milan)', + 'ap-southeast-1': 'Asia Pacific (Singapore)', + 'ap-southeast-2': 'Asia Pacific (Sydney)', + 'ap-northeast-1': 'Asia Pacific (Tokyo)', + 'ap-northeast-2': 'Asia Pacific (Seoul)', + 'ap-northeast-3': 'Asia Pacific (Osaka)', + 'ap-south-1': 'Asia Pacific (Mumbai)', + 'ap-east-1': 'Asia Pacific (Hong Kong)', + 'ca-central-1': 'Canada (Central)', + 'sa-east-1': 'South America (São Paulo)', + 'me-south-1': 'Middle East (Bahrain)', + 'af-south-1': 'Africa (Cape Town)', + }; + + const regions = (regionsResponse.Regions || []) + .filter((region) => region.RegionName) + .map((region) => { + const code = region.RegionName!; + const friendlyName = regionNames[code] || code; + return { + value: code, + label: `${friendlyName} (${code})`, + }; + }) + .sort((a, b) => a.value.localeCompare(b.value)); + + return { + success: true, + accountId: identity.Account, + regions, + }; + } catch (error) { + console.error('AWS credential validation failed:', error); + return { + success: false, + error: + error instanceof Error + ? error.message + : 'Failed to validate AWS credentials. Please check your access key and secret.', + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ChatPlaceholder.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ChatPlaceholder.tsx new file mode 100644 index 000000000..33fb1691f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ChatPlaceholder.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card'; +import { MessageSquare } from 'lucide-react'; + +export function ChatPlaceholder() { + return ( + + + + + AI Remediation Assistant + + Coming soon + + +
+
+ +

+ Chat with AI to automatically +
+ remediate security findings +

+
+
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudConnectionCard.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudConnectionCard.tsx new file mode 100644 index 000000000..fb44d3d06 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudConnectionCard.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { Button } from '@comp/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card'; +import { Input } from '@comp/ui/input'; +import { Label } from '@comp/ui/label'; +import { ExternalLink, Loader2 } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { connectCloudAction } from '../actions/connect-cloud'; + +interface CloudField { + id: string; + label: string; + description: string; + placeholder?: string; + helpText?: string; + type?: string; +} + +interface CloudConnectionCardProps { + cloudProvider: 'aws' | 'gcp' | 'azure'; + name: string; + shortName: string; + description: string; + fields: CloudField[]; + guideUrl?: string; + color?: string; + logoUrl?: string; + onSuccess?: () => void; +} + +export function CloudConnectionCard({ + cloudProvider, + name, + shortName, + description, + fields, + guideUrl, + color = 'from-primary to-primary', + logoUrl, + onSuccess, +}: CloudConnectionCardProps) { + const [isConnecting, setIsConnecting] = useState(false); + const [credentials, setCredentials] = useState>({}); + const [errors, setErrors] = useState>({}); + + const handleFieldChange = (fieldId: string, value: string) => { + setCredentials((prev) => ({ ...prev, [fieldId]: value })); + if (errors[fieldId]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[fieldId]; + return newErrors; + }); + } + }; + + const validateFields = (): boolean => { + const newErrors: Record = {}; + fields.forEach((field) => { + if (!credentials[field.id]?.trim()) { + newErrors[field.id] = 'Required'; + } + }); + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleConnect = async () => { + if (!validateFields()) { + toast.error('Please fill in all required fields'); + return; + } + + try { + setIsConnecting(true); + const result = await connectCloudAction({ + cloudProvider, + credentials, + }); + + if (result?.data?.success) { + toast.success(`${name} connected! Running initial scan...`); + setCredentials({}); + onSuccess?.(); + } else { + toast.error(result?.data?.error || 'Failed to connect'); + } + } catch (error) { + console.error('Connection error:', error); + toast.error('An unexpected error occurred'); + } finally { + setIsConnecting(false); + } + }; + + return ( + + +
+
+ {logoUrl && ( + {`${shortName} + )} +
+
+ {shortName} + {description} +
+
+ {guideUrl && ( + + + Setup guide + + )} +
+ + {fields.map((field) => ( +
+ + {field.type === 'textarea' ? ( +