diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts index 2c94986de9f..2a0d1d59477 100644 --- a/apps/sim/app/api/tools/onedrive/upload/route.ts +++ b/apps/sim/app/api/tools/onedrive/upload/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' +/** Pinned to the SheetJS CDN, not npm — see the note in `lib/file-parsers/xlsx-parser.ts`. */ import * as XLSX from 'xlsx' import { onedriveUploadContract } from '@/lib/api/contracts/tools/microsoft' import { parseRequest } from '@/lib/api/server' diff --git a/apps/sim/app/api/tools/video/route.test.ts b/apps/sim/app/api/tools/video/route.test.ts new file mode 100644 index 00000000000..5e120b8b947 --- /dev/null +++ b/apps/sim/app/api/tools/video/route.test.ts @@ -0,0 +1,336 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + hybridAuthMockFns, + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_FAL_QUEUE_JSON_BYTES } from '@/lib/media/falai' + +const { mockUploadFile } = vi.hoisted(() => ({ + mockUploadFile: vi.fn(), +})) + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) +vi.mock('@/lib/core/execution-limits', () => ({ getMaxExecutionTimeout: () => 30_000 })) +vi.mock('@sim/utils/helpers', () => ({ sleep: () => Promise.resolve() })) +vi.mock('@/app/api/files/authorization', () => ({ + assertToolFileAccess: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/uploads', () => ({ + StorageService: { uploadFile: mockUploadFile }, +})) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' })) + +import { POST } from '@/app/api/tools/video/route' + +const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns + +function jsonResponse(payload: unknown) { + const text = JSON.stringify(payload) + return { + ok: true, + status: 200, + headers: { get: () => null }, + body: null, + text: async () => text, + arrayBuffer: async () => new ArrayBuffer(0), + } +} + +function videoResponse() { + return { + ok: true, + status: 200, + headers: { + get: (name: string) => { + if (name === 'content-type') return 'video/mp4' + return name === 'content-length' ? '8' : null + }, + }, + body: null, + text: async () => '', + arrayBuffer: async () => new ArrayBuffer(8), + } +} + +function errorResponse(status: number) { + return { + ok: false, + status, + headers: { get: () => null }, + body: null, + text: async () => 'denied', + arrayBuffer: async () => new ArrayBuffer(0), + } +} + +const baseBody = { + provider: 'falai', + apiKey: 'fal-key', + model: 'kling-v3-pro', + prompt: 'a cat riding a bike', +} + +describe('POST /api/tools/video (Fal.ai queue)', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '8.8.8.8' }) + mockUploadFile.mockResolvedValue({ path: '/api/files/serve/video.mp4' }) + }) + + it('normalizes the echoed /response URL and bounds queue reads with the shared Fal cap', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + request_id: 'req-1', + status_url: 'https://queue.fal.run/fal-ai/kling-video/requests/req-1/status', + response_url: 'https://queue.fal.run/fal-ai/kling-video/requests/req-1/response', + }), + { status: 200 } + ) + ) + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' })) + .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } })) + .mockResolvedValueOnce(videoResponse()) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(200) + + const [statusCall, resultCall, downloadCall] = mockSecureFetchWithPinnedIP.mock.calls + expect(statusCall[0]).toBe('https://queue.fal.run/fal-ai/kling-video/requests/req-1/status') + // `/response` is not a GET route on queue.fal.run — it must be stripped. + expect(resultCall[0]).toBe('https://queue.fal.run/fal-ai/kling-video/requests/req-1') + expect(downloadCall[0]).toBe('https://cdn.fal.media/a.mp4') + + expect(statusCall[2].maxResponseBytes).toBe(MAX_FAL_QUEUE_JSON_BYTES) + expect(resultCall[2].maxResponseBytes).toBe(MAX_FAL_QUEUE_JSON_BYTES) + }) + + it('falls back to the constructed multi-segment queue URL when the candidate is off-origin', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + request_id: 'req-2', + status_url: 'https://evil.example.net/steal', + response_url: 'https://evil.example.net/steal', + }), + { status: 200 } + ) + ) + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' })) + .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } })) + .mockResolvedValueOnce(videoResponse()) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(200) + + // `fal-ai/kling-video/v3/pro/text-to-video` polls under the app id only. + expect(mockSecureFetchWithPinnedIP.mock.calls.slice(0, 2).map(([url]) => url)).toEqual([ + 'https://queue.fal.run/fal-ai/kling-video/requests/req-2/status', + 'https://queue.fal.run/fal-ai/kling-video/requests/req-2', + ]) + }) +}) + +/** + * Runway, Veo, Luma and MiniMax all download the finished asset through + * `downloadVideoFromUrl` (the SSRF-guarded client), each with its own label and + * error prefix. These cover that plumbing per provider. + */ +describe('POST /api/tools/video (provider download paths)', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '8.8.8.8' }) + mockUploadFile.mockResolvedValue({ path: '/api/files/serve/video.mp4' }) + }) + + function apiResponse(payload: unknown) { + return new Response(JSON.stringify(payload), { status: 200 }) + } + + function veoFetches(uri: string) { + fetchMock.mockResolvedValueOnce(apiResponse({ name: 'operations/op-1' })).mockResolvedValueOnce( + apiResponse({ + done: true, + response: { generateVideoResponse: { generatedSamples: [{ video: { uri } }] } }, + }) + ) + } + + it('downloads the Runway asset through the guarded client', async () => { + fetchMock + .mockResolvedValueOnce(apiResponse({ id: 'task-1' })) + .mockResolvedValueOnce( + apiResponse({ status: 'SUCCEEDED', output: ['https://cdn.runwayml.test/a.mp4'] }) + ) + mockSecureFetchWithPinnedIP.mockResolvedValueOnce(videoResponse()) + + const response = await POST( + createMockRequest('POST', { + provider: 'runway', + apiKey: 'runway-key', + model: 'gen-4', + prompt: 'a cat riding a bike', + }) + ) + + expect(response.status).toBe(200) + expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1) + expect(mockSecureFetchWithPinnedIP.mock.calls[0][0]).toBe('https://cdn.runwayml.test/a.mp4') + expect(mockSecureFetchWithPinnedIP.mock.calls[0][2].headers).toBeUndefined() + }) + + it('surfaces the default download error prefix when the Runway asset fetch fails', async () => { + fetchMock + .mockResolvedValueOnce(apiResponse({ id: 'task-1' })) + .mockResolvedValueOnce( + apiResponse({ status: 'SUCCEEDED', output: ['https://cdn.runwayml.test/a.mp4'] }) + ) + mockSecureFetchWithPinnedIP.mockResolvedValueOnce(errorResponse(401)) + + const response = await POST( + createMockRequest('POST', { + provider: 'runway', + apiKey: 'runway-key', + model: 'gen-4', + prompt: 'a cat riding a bike', + }) + ) + + expect(response.status).toBe(500) + expect((await response.json()).error).toBe('Failed to download video: 401') + }) + + it('downloads the Luma asset through the guarded client', async () => { + fetchMock + .mockResolvedValueOnce(apiResponse({ id: 'gen-1' })) + .mockResolvedValueOnce( + apiResponse({ state: 'completed', assets: { video: 'https://cdn.lumalabs.test/a.mp4' } }) + ) + mockSecureFetchWithPinnedIP.mockResolvedValueOnce(videoResponse()) + + const response = await POST( + createMockRequest('POST', { + provider: 'luma', + apiKey: 'luma-key', + model: 'ray-2', + prompt: 'a cat riding a bike', + }) + ) + + expect(response.status).toBe(200) + expect(mockSecureFetchWithPinnedIP.mock.calls[0][0]).toBe('https://cdn.lumalabs.test/a.mp4') + expect(mockSecureFetchWithPinnedIP.mock.calls[0][2].headers).toBeUndefined() + }) + + it('keeps the MiniMax-specific download error prefix', async () => { + fetchMock + .mockResolvedValueOnce(apiResponse({ base_resp: { status_code: 0 }, task_id: 'task-1' })) + .mockResolvedValueOnce( + apiResponse({ base_resp: { status_code: 0 }, status: 'Success', file_id: 'file-1' }) + ) + .mockResolvedValueOnce( + apiResponse({ file: { download_url: 'https://cdn.minimax.test/a.mp4' } }) + ) + mockSecureFetchWithPinnedIP.mockResolvedValueOnce(errorResponse(401)) + + const response = await POST( + createMockRequest('POST', { + provider: 'minimax', + apiKey: 'minimax-key', + model: 'hailuo-2.3', + prompt: 'a cat riding a bike', + }) + ) + + expect(response.status).toBe(500) + expect((await response.json()).error).toBe('Failed to download video from URL: 401') + }) + + it('downloads the MiniMax asset through the guarded client', async () => { + fetchMock + .mockResolvedValueOnce(apiResponse({ base_resp: { status_code: 0 }, task_id: 'task-1' })) + .mockResolvedValueOnce( + apiResponse({ base_resp: { status_code: 0 }, status: 'Success', file_id: 'file-1' }) + ) + .mockResolvedValueOnce( + apiResponse({ file: { download_url: 'https://cdn.minimax.test/a.mp4' } }) + ) + mockSecureFetchWithPinnedIP.mockResolvedValueOnce(videoResponse()) + + const response = await POST( + createMockRequest('POST', { + provider: 'minimax', + apiKey: 'minimax-key', + model: 'hailuo-2.3', + prompt: 'a cat riding a bike', + }) + ) + + expect(response.status).toBe(200) + expect(mockSecureFetchWithPinnedIP.mock.calls[0][0]).toBe('https://cdn.minimax.test/a.mp4') + }) + + it('attaches the Veo API key only for a genuine https Google API host', async () => { + veoFetches('https://generativelanguage.googleapis.com/v1beta/files/a:download') + mockSecureFetchWithPinnedIP.mockResolvedValueOnce(videoResponse()) + + const response = await POST( + createMockRequest('POST', { + provider: 'veo', + apiKey: 'veo-key', + model: 'veo-3', + prompt: 'a cat riding a bike', + }) + ) + + expect(response.status).toBe(200) + expect(mockSecureFetchWithPinnedIP.mock.calls[0][2].headers).toEqual({ + 'x-goog-api-key': 'veo-key', + }) + }) + + it.each([ + ['a suffix-spoofed host', 'https://evil.googleapis.com.attacker.test/a.mp4'], + ['a prefix-spoofed host', 'https://xgoogleapis.com/a.mp4'], + ['plaintext http', 'http://generativelanguage.googleapis.com/a.mp4'], + ])('withholds the Veo API key for %s', async (_label, uri) => { + veoFetches(uri) + mockSecureFetchWithPinnedIP.mockResolvedValueOnce(videoResponse()) + + const response = await POST( + createMockRequest('POST', { + provider: 'veo', + apiKey: 'veo-key', + model: 'veo-3', + prompt: 'a cat riding a bike', + }) + ) + + expect(response.status).toBe(200) + expect(mockSecureFetchWithPinnedIP.mock.calls[0][0]).toBe(uri) + expect(mockSecureFetchWithPinnedIP.mock.calls[0][2].headers).toBeUndefined() + }) +}) diff --git a/apps/sim/app/api/tools/video/route.ts b/apps/sim/app/api/tools/video/route.ts index b98d4fed2c5..e3a2553d815 100644 --- a/apps/sim/app/api/tools/video/route.ts +++ b/apps/sim/app/api/tools/video/route.ts @@ -8,6 +8,10 @@ import { videoProviders, videoToolContract } from '@/lib/api/contracts/tools/med import { getValidationErrorMessage, parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { assertKnownSizeWithinLimit, DEFAULT_MAX_ERROR_BODY_BYTES, @@ -18,6 +22,12 @@ import { readResponseToBufferWithLimit, } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + buildFalQueueUrl, + FAL_QUEUE_ORIGIN, + MAX_FAL_QUEUE_JSON_BYTES, + resolveFalQueueUrl, +} from '@/lib/media/falai' import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { assertToolFileAccess } from '@/app/api/files/authorization' @@ -36,7 +46,23 @@ export const dynamic = 'force-dynamic' */ export const maxDuration = 5400 -async function readVideoResponseBuffer(response: Response, label: string): Promise { +/** + * Structural shape shared by `fetch`'s `Response` and the SSRF-guarded + * `SecureFetchResponse`, so the body readers below accept either. + */ +interface ReadableHttpResponse { + ok: boolean + status: number + headers: { get(name: string): string | null } + body?: ReadableStream | null + arrayBuffer?: () => Promise + text?: () => Promise +} + +async function readVideoResponseBuffer( + response: ReadableHttpResponse, + label: string +): Promise { return readResponseToBufferWithLimit(response, { maxBytes: MAX_VIDEO_OUTPUT_BYTES, label, @@ -44,22 +70,66 @@ async function readVideoResponseBuffer(response: Response, label: string): Promi } async function readVideoJson>( - response: Response, - label: string + response: ReadableHttpResponse, + label: string, + maxBytes: number = MAX_VIDEO_JSON_BYTES ): Promise { return readResponseJsonWithLimit(response, { - maxBytes: MAX_VIDEO_JSON_BYTES, + maxBytes, label, }) } -async function readVideoErrorText(response: Response, label: string): Promise { +async function readVideoErrorText(response: ReadableHttpResponse, label: string): Promise { return readResponseTextWithLimit(response, { maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, label, }).catch(() => '') } +/** + * Fetches a provider-supplied URL through the SSRF-guarded client. These URLs come out of + * provider response bodies, so they are untrusted input. + */ +async function secureGetFromUntrustedUrl( + url: string, + paramName: string, + options: { headers?: Record; maxResponseBytes: number } +) { + const validation = await validateUrlWithDNS(url, paramName) + if (!validation.isValid || !validation.resolvedIP) { + throw new Error(validation.error || `${paramName} failed validation`) + } + return secureFetchWithPinnedIP(url, validation.resolvedIP, { + method: 'GET', + headers: options.headers, + maxResponseBytes: options.maxResponseBytes, + }) +} + +/** + * Downloads a provider-supplied video URL through the SSRF-guarded client. + * `errorPrefix` overrides the thrown message so each provider keeps the exact + * failure string runbooks and log filters key on. + */ +async function downloadVideoFromUrl( + url: string, + label: string, + options?: { headers?: Record; errorPrefix?: string } +): Promise { + const response = await secureGetFromUntrustedUrl(url, 'videoUrl', { + headers: options?.headers, + maxResponseBytes: MAX_VIDEO_OUTPUT_BYTES, + }) + + if (!response.ok) { + await readVideoErrorText(response, `${label} error response`) + throw new Error(`${options?.errorPrefix ?? 'Failed to download video'}: ${response.status}`) + } + + return readVideoResponseBuffer(response, `${label} response`) +} + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId() logger.info(`[${requestId}] Video generation request started`) @@ -461,14 +531,8 @@ async function generateWithRunway( throw new Error('No video URL in response') } - const videoResponse = await fetch(videoUrl) - if (!videoResponse.ok) { - await readVideoErrorText(videoResponse, 'Runway video error response') - throw new Error(`Failed to download video: ${videoResponse.status}`) - } - return { - buffer: await readVideoResponseBuffer(videoResponse, 'Runway video response'), + buffer: await downloadVideoFromUrl(videoUrl, 'Runway video'), width: dimensions.width, height: dimensions.height, jobId: taskId, @@ -486,6 +550,30 @@ async function generateWithRunway( throw new Error('Runway generation timed out') } +/** Host of a provider-supplied URI for logging, without leaking the path or query. */ +function safeHostname(url: string): string { + try { + return new URL(url).host + } catch { + return 'unparseable' + } +} + +/** + * True when a provider-supplied URI is served by Google. The Veo download URI comes out of + * the operation status body, so the `x-goog-api-key` credential is only attached when the + * target really is a Google host — a hostile or spoofed URI never receives the key. + */ +function isGoogleApiHost(url: string): boolean { + try { + const { protocol, hostname } = new URL(url) + if (protocol !== 'https:') return false + return hostname === 'googleapis.com' || hostname.endsWith('.googleapis.com') + } catch { + return false + } +} + async function generateWithVeo( apiKey: string, model: string, @@ -583,19 +671,18 @@ async function generateWithVeo( throw new Error('No video URI in response') } - const videoResponse = await fetch(videoUri, { - headers: { - 'x-goog-api-key': apiKey, - }, - }) - - if (!videoResponse.ok) { - await readVideoErrorText(videoResponse, 'Veo video error response') - throw new Error(`Failed to download video: ${videoResponse.status}`) + const isGoogleHosted = isGoogleApiHost(videoUri) + if (!isGoogleHosted) { + logger.warn( + `[${requestId}] Veo download URI is not a Google API host; sending the request unauthenticated. A 401 here means the URI host is wrong, not the API key.`, + { host: safeHostname(videoUri) } + ) } return { - buffer: await readVideoResponseBuffer(videoResponse, 'Veo video response'), + buffer: await downloadVideoFromUrl(videoUri, 'Veo video', { + headers: isGoogleHosted ? { 'x-goog-api-key': apiKey } : undefined, + }), width: dimensions.width, height: dimensions.height, jobId: operationName, @@ -697,14 +784,8 @@ async function generateWithLuma( throw new Error('No video URL in response') } - const videoResponse = await fetch(videoUrl) - if (!videoResponse.ok) { - await readVideoErrorText(videoResponse, 'Luma video error response') - throw new Error(`Failed to download video: ${videoResponse.status}`) - } - return { - buffer: await readVideoResponseBuffer(videoResponse, 'Luma video response'), + buffer: await downloadVideoFromUrl(videoUrl, 'Luma video'), width: dimensions.width, height: dimensions.height, jobId: generationId, @@ -859,15 +940,10 @@ async function generateWithMiniMax( throw new Error('No download URL in file response') } - // Download the actual video file - const videoResponse = await fetch(videoUrl) - if (!videoResponse.ok) { - await readVideoErrorText(videoResponse, 'MiniMax video error response') - throw new Error(`Failed to download video from URL: ${videoResponse.status}`) - } - return { - buffer: await readVideoResponseBuffer(videoResponse, 'MiniMax video response'), + buffer: await downloadVideoFromUrl(videoUrl, 'MiniMax video', { + errorPrefix: 'Failed to download video from URL', + }), width: dimensions.width, height: dimensions.height, jobId: taskId, @@ -1160,12 +1236,20 @@ function getFalAIErrorMessage(error: unknown): string { return 'Unknown error' } -function buildFalAIQueueUrl( - endpoint: string, - requestId: string, - path: 'response' | 'status' -): string { - return `https://queue.fal.run/${endpoint}/requests/${requestId}/${path}` +/** + * Requests a Fal.ai queue URL through the SSRF-guarded client. Invoked on every poll so + * the provider-supplied URL is revalidated each time rather than trusted once. + */ +async function fetchFalAIQueue(url: string, apiKey: string) { + return secureGetFromUntrustedUrl(url, 'falQueueUrl', { + headers: { Authorization: `Key ${apiKey}` }, + maxResponseBytes: MAX_FAL_QUEUE_JSON_BYTES, + }) +} + +/** Fal.ai queue envelopes share the cap enforced by `runFalQueue`, not the generic video cap. */ +async function readFalQueueJson(response: ReadableHttpResponse, label: string): Promise { + return readVideoJson(response, label, MAX_FAL_QUEUE_JSON_BYTES) } async function generateWithFalAI( @@ -1218,7 +1302,7 @@ async function generateWithFalAI( requestBody.generate_audio = generateAudio } - const createResponse = await fetch(`https://queue.fal.run/${modelConfig.endpoint}`, { + const createResponse = await fetch(`${FAL_QUEUE_ORIGIN}/${modelConfig.endpoint}`, { method: 'POST', headers: { Authorization: `Key ${apiKey}`, @@ -1232,7 +1316,7 @@ async function generateWithFalAI( throw new Error(`Fal.ai API error: ${createResponse.status} - ${error}`) } - const createData = await readVideoJson(createResponse, 'Fal.ai queue response') + const createData = await readFalQueueJson(createResponse, 'Fal.ai queue response') if (!isRecordLike(createData)) { throw new Error('Invalid Fal.ai queue response') } @@ -1242,12 +1326,14 @@ async function generateWithFalAI( throw new Error('Fal.ai queue response missing request_id') } - const statusUrl = - getStringProperty(createData, 'status_url') || - buildFalAIQueueUrl(modelConfig.endpoint, requestIdFal, 'status') - const responseUrl = - getStringProperty(createData, 'response_url') || - buildFalAIQueueUrl(modelConfig.endpoint, requestIdFal, 'response') + const statusUrl = resolveFalQueueUrl( + getStringProperty(createData, 'status_url'), + buildFalQueueUrl(modelConfig.endpoint, requestIdFal, 'status') + ) + const responseUrl = resolveFalQueueUrl( + getStringProperty(createData, 'response_url'), + buildFalQueueUrl(modelConfig.endpoint, requestIdFal, 'result') + ) logger.info(`[${requestId}] Fal.ai request created: ${requestIdFal}`) @@ -1258,18 +1344,14 @@ async function generateWithFalAI( while (attempts < maxAttempts) { await sleep(pollIntervalMs) - const statusResponse = await fetch(statusUrl, { - headers: { - Authorization: `Key ${apiKey}`, - }, - }) + const statusResponse = await fetchFalAIQueue(statusUrl, apiKey) if (!statusResponse.ok) { await readVideoErrorText(statusResponse, 'Fal.ai status error response') throw new Error(`Fal.ai status check failed: ${statusResponse.status}`) } - const statusData = await readVideoJson(statusResponse, 'Fal.ai status response') + const statusData = await readFalQueueJson(statusResponse, 'Fal.ai status response') if (!isRecordLike(statusData)) { throw new Error('Invalid Fal.ai status response') } @@ -1282,13 +1364,9 @@ async function generateWithFalAI( logger.info(`[${requestId}] Fal.ai generation completed after ${attempts * 5}s`) - const resultResponse = await fetch( - getStringProperty(statusData, 'response_url') || responseUrl, - { - headers: { - Authorization: `Key ${apiKey}`, - }, - } + const resultResponse = await fetchFalAIQueue( + resolveFalQueueUrl(getStringProperty(statusData, 'response_url'), responseUrl), + apiKey ) if (!resultResponse.ok) { @@ -1296,7 +1374,7 @@ async function generateWithFalAI( throw new Error(`Failed to fetch result: ${resultResponse.status}`) } - const resultData = await readVideoJson(resultResponse, 'Fal.ai result response') + const resultData = await readFalQueueJson(resultResponse, 'Fal.ai result response') if (!isRecordLike(resultData)) { throw new Error('Invalid Fal.ai result response') } @@ -1309,11 +1387,7 @@ async function generateWithFalAI( throw new Error('No video URL in response') } - const videoResponse = await fetch(videoUrl) - if (!videoResponse.ok) { - await readVideoErrorText(videoResponse, 'Fal.ai video error response') - throw new Error(`Failed to download video: ${videoResponse.status}`) - } + const videoBuffer = await downloadVideoFromUrl(videoUrl, 'Fal.ai video') let width = getNumberProperty(videoOutput, 'width') || 1920 let height = getNumberProperty(videoOutput, 'height') || 1080 @@ -1325,7 +1399,7 @@ async function generateWithFalAI( } return { - buffer: await readVideoResponseBuffer(videoResponse, 'Fal.ai video response'), + buffer: videoBuffer, width, height, jobId: requestIdFal, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 9984e16b0df..44114a8bef4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -16,7 +16,6 @@ import { DocxPreview } from './docx-preview' import { resolveFileCategory } from './file-category' import { ImagePreview } from './image-preview' import type { PdfDocumentSource } from './pdf-viewer' -import { PptxPreview } from './pptx-preview' import { PreviewPanel, resolvePreviewType } from './preview-panel' import { PREVIEW_LOADING_OVERLAY, @@ -33,6 +32,23 @@ const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfView ssr: false, }) +/** + * Lazy so `echarts` (~330 KB gzip, reached via `pptx-sandbox-host` → + * `lib/pptx-renderer/renderer/chart-renderer`) stays out of every bundle that + * statically imports this viewer - the Files page, the Home view and the public + * share page - instead of only the visitors who open a PowerPoint file. The + * fallback matches the frame `./pptx-preview` renders while it fetches, so the chunk load + * and the binary fetch look like one continuous loading state. Rendered inside a + * {@link PreviewErrorBoundary} so a rejected chunk load degrades to the preview + * fallback — which offers a page reload, the only way to refetch a chunk whose + * rejection the module system has cached — instead of unwinding to the route + * error boundary. + */ +const PptxPreview = dynamic(() => import('./pptx-preview').then((m) => m.PptxPreview), { + ssr: false, + loading: () => , +}) + const RichMarkdownEditor = dynamic( () => import('./rich-markdown-editor/rich-markdown-editor').then((m) => m.RichMarkdownEditor), { ssr: false, loading: () => } @@ -229,7 +245,11 @@ function FileViewerContent({ } if (category === 'pptx-previewable') { - return + return ( + + + + ) } if (category === 'xlsx-previewable') { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.test.tsx new file mode 100644 index 00000000000..8367b9cdc63 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.test.tsx @@ -0,0 +1,168 @@ +/** + * @vitest-environment jsdom + * + * `PreviewErrorBoundary` must contain a *rejected dynamic import* — the failure mode + * introduced by lazy-loading the PDF and PowerPoint renderers. Without it a chunk-load + * failure unwinds to the route-level `error.tsx` and blanks the whole Files page, the + * Home resource panel and the public share page, all of which statically import the viewer. + * + * It must also recover in place: the boundary's error state only ever cleared via remount, + * so a tripped boundary stayed on the fallback until the user navigated to another file + * and back. + */ +import { act, lazy, Suspense } from 'react' +import { sleep } from '@sim/utils/helpers' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + PreviewError, + PreviewErrorBoundary, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared' + +let container: HTMLDivElement | null = null +let root: Root | null = null + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + container = null + root = null + vi.restoreAllMocks() +}) + +async function render(node: React.ReactNode) { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render(node) + }) +} + +function actionLabels(): string[] { + return Array.from(container?.querySelectorAll('button') ?? []).map((b) => b.textContent ?? '') +} + +async function clickAction(label: string) { + const button = Array.from(container?.querySelectorAll('button') ?? []).find( + (b) => b.textContent === label + ) + if (!button) + throw new Error(`No "${label}" action rendered (found: ${actionLabels().join(', ')})`) + await act(async () => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +describe('PreviewErrorBoundary', () => { + it('renders the preview fallback when a lazy chunk fails to load', async () => { + const Broken = lazy(() => Promise.reject(new Error('Loading chunk 4821 failed'))) + + await render( + + loading}> + + + + ) + + expect(container?.textContent).toContain('Failed to preview PowerPoint') + expect(container?.textContent).toContain('Loading chunk 4821 failed') + }) + + it('offers a page reload — not a retry — for a cached chunk rejection', async () => { + const Broken = lazy(() => Promise.reject(new Error('Loading chunk 4821 failed'))) + + await render( + + loading}> + + + + ) + + expect(actionLabels()).toEqual(['Reload page']) + }) + + it('offers a page reload for a failed CSS chunk, whose message omits "Loading chunk"', async () => { + const Broken = lazy(() => Promise.reject(new Error('Loading CSS chunk 4821 failed'))) + + await render( + + loading}> + + + + ) + + expect(actionLabels()).toEqual(['Reload page']) + }) + + it('renders children when nothing throws', async () => { + await render( + + preview + + ) + + expect(container?.textContent).toBe('preview') + }) + + it('recovers in place when the retried render succeeds', async () => { + let shouldThrow = true + function Flaky() { + if (shouldThrow) throw new Error('transient render failure') + return preview + } + + await render( + + + + ) + expect(container?.textContent).toContain('transient render failure') + + shouldThrow = false + await clickAction('Try again') + + expect(container?.textContent).toBe('preview') + }) + + it('settles back on the fallback — never loops — when the retried render throws again', async () => { + const renderSpy = vi.fn() + function AlwaysThrows(): never { + renderSpy() + throw new Error('still broken') + } + + await render( + + + + ) + const attemptsBeforeRetry = renderSpy.mock.calls.length + + await clickAction('Try again') + const attemptsAfterRetry = renderSpy.mock.calls.length + + expect(attemptsAfterRetry).toBeGreaterThan(attemptsBeforeRetry) + + await act(async () => { + await sleep(1) + }) + + expect(renderSpy.mock.calls.length).toBe(attemptsAfterRetry) + expect(container?.textContent).toContain('Failed to preview PDF') + expect(container?.textContent).toContain('still broken') + expect(actionLabels()).toEqual(['Try again']) + }) +}) + +describe('PreviewError', () => { + it('renders no action when none is supplied', async () => { + await render() + + expect(container?.textContent).toContain('Failed to preview CSV') + expect(container?.querySelectorAll('button')).toHaveLength(0) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx index 041a0225074..f35a331aebe 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx @@ -1,23 +1,61 @@ 'use client' -import { Component, type ErrorInfo, type ReactNode } from 'react' -import { cn } from '@sim/emcn' +import { Component, type ErrorInfo, Fragment, type ReactNode } from 'react' +import { Chip, cn } from '@sim/emcn' import { Loader } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' const logger = createLogger('FilePreview') -export function PreviewError({ label, error }: { label: string; error: string }) { +interface PreviewErrorAction { + label: string + onClick: () => void +} + +interface PreviewErrorProps { + /** Format label shown in the message, e.g. "PDF". */ + label: string + error: string + /** Recovery affordance. Omitted for fallbacks with nothing to retry. */ + action?: PreviewErrorAction +} + +export function PreviewError({ label, error, action }: PreviewErrorProps) { return (

Failed to preview {label}

{error}

+ {action ? ( + + {action.label} + + ) : null}
) } +/** + * webpack's chunk-load failure messages. The JS form is `Loading chunk 5 failed` + * and the stylesheet form is `Loading CSS chunk 5 failed`, so a plain + * `Loading chunk` substring test misses every CSS chunk 404. + */ +const CHUNK_LOAD_MESSAGE = /Loading (?:CSS )?chunk/ + +/** + * A `next/dynamic` / `React.lazy` chunk fetch that rejected. The module system + * caches the rejection on the lazy component itself, so re-rendering it throws + * the same error synchronously — only a fresh page load can refetch the chunk. + */ +function isChunkLoadError(error: Error | undefined): boolean { + if (!error) return false + if (error.name === 'ChunkLoadError') return true + return ( + CHUNK_LOAD_MESSAGE.test(error.message) || error.message.includes('dynamically imported module') + ) +} + interface PreviewErrorBoundaryProps { /** Format label shown in the fallback, e.g. "PDF". */ label: string @@ -27,6 +65,8 @@ interface PreviewErrorBoundaryProps { interface PreviewErrorBoundaryState { hasError: boolean error?: Error + /** Bumped by "Try again" so the retried subtree remounts rather than resuming. */ + attempt: number } /** @@ -35,9 +75,15 @@ interface PreviewErrorBoundaryState { * PreviewError fallback instead of unwinding to the route-level error boundary * and replacing the whole workspace view. * - * Callers must `key` this boundary by the identity of the rendered content - * (e.g. file id + data version) — the error state resets only via remount, so - * keying the child alone would leave a tripped boundary stuck on the fallback. + * The fallback recovers in place, so a tripped boundary is never stuck: + * - A render-time crash offers "Try again", which clears the error and remounts + * the subtree. If the cause is still there the child throws once more and the + * fallback returns — bounded, never a retry loop. + * - A rejected chunk load is cached by the module system and cannot be retried + * by re-rendering, so it offers "Reload page" instead. + * + * Keying the boundary by content identity (e.g. file id + data version) is still + * worthwhile so a *different* file starts from a clean boundary. */ export class PreviewErrorBoundary extends Component< PreviewErrorBoundaryProps, @@ -45,9 +91,12 @@ export class PreviewErrorBoundary extends Component< > { public state: PreviewErrorBoundaryState = { hasError: false, + attempt: 0, } - public static getDerivedStateFromError(error: Error): PreviewErrorBoundaryState { + public static getDerivedStateFromError( + error: Error + ): Pick { return { hasError: true, error } } @@ -59,17 +108,36 @@ export class PreviewErrorBoundary extends Component< }) } + private readonly handleRetry = () => { + this.setState((previous) => ({ + hasError: false, + error: undefined, + attempt: previous.attempt + 1, + })) + } + + private readonly handleReload = () => { + window.location.reload() + } + public render() { - if (this.state.hasError) { + const { attempt, error, hasError } = this.state + + if (hasError) { return ( ) } - return this.props.children + return {this.props.children} } } diff --git a/apps/sim/executor/orchestrators/loop.test.ts b/apps/sim/executor/orchestrators/loop.test.ts index 9180fc2bd8d..f03f4a8f598 100644 --- a/apps/sim/executor/orchestrators/loop.test.ts +++ b/apps/sim/executor/orchestrators/loop.test.ts @@ -308,6 +308,76 @@ describe('LoopOrchestrator', () => { ) }) + describe('while condition serialization', () => { + /** + * Runs one while-loop condition evaluation with `resolved` standing in for the value a + * `` reference resolves to, and returns the JavaScript handed to the VM. + */ + async function evaluatedCode(resolved: unknown): Promise { + const resolver = { resolveSingleReference: vi.fn().mockResolvedValue(resolved) } + mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: false }) + const orchestrator = new LoopOrchestrator( + { loopConfigs: new Map(), parallelConfigs: new Map(), nodes: new Map() } as any, + createState(), + resolver as any + ) + const ctx = createContext({ + iteration: 0, + currentIterationOutputs: new Map(), + allIterationOutputs: [], + loopType: 'while', + condition: '', + }) + + await orchestrator.evaluateInitialCondition(ctx, 'loop-1') + + return mockExecuteInIsolatedVM.mock.calls.at(-1)?.[0].code + } + + it('inlines an ordinary string as a quoted literal', async () => { + expect(await evaluatedCode('hello')).toBe('return Boolean("hello")') + }) + + it('keeps numbers, booleans and null unquoted', async () => { + expect(await evaluatedCode(42)).toBe('return Boolean(42)') + expect(await evaluatedCode(true)).toBe('return Boolean(true)') + expect(await evaluatedCode(null)).toBe('return Boolean(null)') + }) + + it('still folds a boolean-valued string to a bare boolean', async () => { + expect(await evaluatedCode('true')).toBe('return Boolean(true)') + expect(await evaluatedCode(' FALSE ')).toBe('return Boolean(false)') + }) + + it('serializes objects and arrays', async () => { + expect(await evaluatedCode({ a: 1 })).toBe('return Boolean({"a":1})') + expect(await evaluatedCode(['x'])).toBe('return Boolean(["x"])') + }) + + it('escapes a quote instead of closing the literal', async () => { + const code = await evaluatedCode('he said "hi"') + expect(code).toBe('return Boolean("he said \\"hi\\"")') + expect(() => new Function(code)()).not.toThrow() + expect(new Function(code)()).toBe(true) + }) + + it('escapes a newline instead of breaking the statement', async () => { + const code = await evaluatedCode('line1\nline2') + expect(code).toBe('return Boolean("line1\\nline2")') + expect(code).not.toContain('\n') + expect(new Function(code)()).toBe(true) + }) + + it('treats an injection payload as data, not code', async () => { + // A block output an attacker controls — an API response, webhook body, or model + // completion. Wrapped in quotes by hand this closed the literal and ran. + const code = await evaluatedCode('" + (globalThis.__pwned = true) + "') + expect(code).toBe('return Boolean("\\" + (globalThis.__pwned = true) + \\"")') + new Function(code)() + expect((globalThis as Record).__pwned).toBeUndefined() + }) + }) + it('exits doWhile loops when the configured iteration cap is reached', async () => { const { orchestrator } = createOrchestrator() const ctx = createContext({ diff --git a/apps/sim/executor/orchestrators/loop.ts b/apps/sim/executor/orchestrators/loop.ts index 913de932915..ccba48f98f2 100644 --- a/apps/sim/executor/orchestrators/loop.ts +++ b/apps/sim/executor/orchestrators/loop.ts @@ -732,7 +732,9 @@ export class LoopOrchestrator { if (lower === 'true' || lower === 'false') { return lower } - return `"${resolved}"` + // Serialized rather than hand-quoted: the value is a block output, so a `"` or + // newline would close the literal early and run as code in the condition VM. + return JSON.stringify(resolved) } return JSON.stringify(resolved) } diff --git a/apps/sim/lib/chunkers/json-yaml-chunker.test.ts b/apps/sim/lib/chunkers/json-yaml-chunker.test.ts index 8ac0eafada8..3d542ac4c94 100644 --- a/apps/sim/lib/chunkers/json-yaml-chunker.test.ts +++ b/apps/sim/lib/chunkers/json-yaml-chunker.test.ts @@ -3,8 +3,26 @@ */ import { describe, expect, it, vi } from 'vitest' +import { YamlComplexityError } from '@/lib/file-parsers/yaml-parser' import { JsonYamlChunker } from './json-yaml-chunker' +/** + * Build a chained alias-expansion ("billion laughs") YAML bomb: each level is + * an array that references the previous level `width` times, so the expanded + * node count grows as `width ^ levels` while the source stays tiny. + */ +function buildAliasBomb(levels: number, width: number): string { + const lines: string[] = [`l0: &l0 [${Array(width).fill('"x"').join(',')}]`] + for (let i = 1; i <= levels; i++) { + const refs = Array(width) + .fill(`*l${i - 1}`) + .join(',') + lines.push(`l${i}: &l${i} [${refs}]`) + } + lines.push(`root: [${Array(width).fill(`*l${levels}`).join(',')}]`) + return lines.join('\n') +} + vi.mock('@/lib/tokenization', () => ({ getAccurateTokenCount: (text: string) => Math.ceil(text.length / 4), })) @@ -372,4 +390,42 @@ server: expect(chunks[0].text.length).toBeGreaterThan(0) }) }) + + describe('YAML alias-expansion guard', () => { + it.concurrent('should reject an alias-expansion bomb instead of chunking it', async () => { + const bomb = buildAliasBomb(9, 10) + expect(Buffer.byteLength(bomb)).toBeLessThan(2048) + + const chunker = new JsonYamlChunker({ chunkSize: 100 }) + await expect(chunker.chunk(bomb)).rejects.toBeInstanceOf(YamlComplexityError) + }) + + it.concurrent('should not classify an alias-expansion bomb as structured data', () => { + expect(JsonYamlChunker.isStructuredData(buildAliasBomb(9, 10))).toBe(false) + }) + + it.concurrent('should still chunk an ordinary YAML document', async () => { + const chunker = new JsonYamlChunker({ chunkSize: 100, minCharactersPerChunk: 1 }) + const chunks = await chunker.chunk('name: sim\nnested:\n key: value\nlist:\n - a\n - b\n') + + expect(chunks.length).toBeGreaterThan(0) + expect(chunks.map((c) => c.text).join('\n')).toContain('sim') + }) + + it.concurrent('should still chunk YAML that uses benign aliases', async () => { + const chunker = new JsonYamlChunker({ chunkSize: 100, minCharactersPerChunk: 1 }) + const chunks = await chunker.chunk('base: &base\n region: us\nprod: *base\nstaging: *base\n') + + expect(chunks.length).toBeGreaterThan(0) + expect(chunks.map((c) => c.text).join('\n')).toContain('us') + }) + + it.concurrent('should still chunk ordinary JSON without touching the YAML path', async () => { + const chunker = new JsonYamlChunker({ chunkSize: 100, minCharactersPerChunk: 1 }) + const chunks = await chunker.chunk(JSON.stringify({ name: 'sim', values: [1, 2, 3] })) + + expect(chunks.length).toBeGreaterThan(0) + expect(chunks.map((c) => c.text).join('\n')).toContain('sim') + }) + }) }) diff --git a/apps/sim/lib/chunkers/json-yaml-chunker.ts b/apps/sim/lib/chunkers/json-yaml-chunker.ts index d18cd0859f9..d557ce54451 100644 --- a/apps/sim/lib/chunkers/json-yaml-chunker.ts +++ b/apps/sim/lib/chunkers/json-yaml-chunker.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import * as yaml from 'js-yaml' import type { Chunk, ChunkerOptions } from '@/lib/chunkers/types' import { estimateTokens } from '@/lib/chunkers/utils' +import { assertYamlWithinLimits, isYamlComplexityError } from '@/lib/file-parsers/yaml-parser' const logger = createLogger('JsonYamlChunker') @@ -12,6 +13,21 @@ type JsonArray = JsonValue[] const MAX_DEPTH = 5 +/** + * Parse YAML and validate the expanded document against the shared complexity + * limits before it is handed to the chunker. `js-yaml` has no `maxAliasCount`, + * so an unguarded `yaml.load` on untrusted knowledge-base input is an uncapped + * alias-expansion ("billion laughs") bomb: the parsed value is a compact DAG, + * but the `JSON.stringify` calls throughout chunking expand it into a full tree. + * + * @throws {import('@/lib/file-parsers/yaml-parser').YamlComplexityError} when the expanded document exceeds the limits + */ +function loadGuardedYaml(content: string): JsonValue { + const parsed = yaml.load(content) as JsonValue + assertYamlWithinLimits(parsed) + return parsed +} + export class JsonYamlChunker { private chunkSize: number private minCharactersPerChunk: number @@ -27,7 +43,7 @@ export class JsonYamlChunker { return typeof parsed === 'object' && parsed !== null } catch { try { - const parsed = yaml.load(content) + const parsed = loadGuardedYaml(content) return typeof parsed === 'object' && parsed !== null } catch { return false @@ -35,13 +51,21 @@ export class JsonYamlChunker { } } + /** + * Chunk a JSON or YAML document, rethrowing on an alias-expansion bomb rather + * than falling back to text chunking. In the knowledge-base path this rethrow + * is unreachable: `isStructuredData` already returns false for a bomb, so + * `document-processor` routes the document to `TextChunker`, which chunks the + * bounded raw string and never materializes the expansion. The rethrow covers + * direct callers that skip that check. + */ async chunk(content: string): Promise { try { let data: JsonValue try { data = JSON.parse(content) as JsonValue } catch { - data = yaml.load(content) as JsonValue + data = loadGuardedYaml(content) } const chunks = this.chunkStructuredData(data, [], 0) @@ -50,6 +74,10 @@ export class JsonYamlChunker { return chunks } catch (error) { + if (isYamlComplexityError(error)) { + logger.warn('Rejected YAML document exceeding complexity limits', { error: error.message }) + throw error + } logger.info('JSON parsing failed, falling back to text chunking') return this.chunkAsText(content) } diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index 72eea0cefb9..7e29111160e 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -24,7 +24,7 @@ vi.mock('./upload-file-reader', () => ({ grepChatUpload, })) -import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' +import { GlobPatternError, WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' import { executeVfsGlob, executeVfsGrep, executeVfsRead } from './vfs' const OVERSIZED_INLINE_CONTENT = 'x'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1) @@ -291,6 +291,24 @@ describe('vfs grep workspace-file routing', () => { expect(result.success).toBe(false) expect(result.error).toContain('single workspace file') }) + + it('surfaces an over-cap grep path scope verbatim, like glob', async () => { + const vfs = makeVfs() + const patternError = new GlobPatternError( + 'Glob pattern has too many wildcards (33, limit 32). Narrow the pattern with literal path segments.' + ) + vfs.grep.mockRejectedValue(patternError) + vfs.glob.mockImplementation(() => { + throw patternError + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const grepResult = await executeVfsGrep({ pattern: 'x', path: '*'.repeat(33) }, GREP_CTX) + const globResult = await executeVfsGlob({ pattern: '*'.repeat(33) }, GREP_CTX) + + expect(grepResult).toEqual({ success: false, error: patternError.message }) + expect(grepResult).toEqual(globResult) + }) }) describe('vfs uploads are opt-in (like recently-deleted/)', () => { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index fd4a515a8af..8783cbae74b 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -5,7 +5,7 @@ import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { getOrMaterializeVFS } from '@/lib/copilot/vfs' import type { GrepCountEntry, GrepMatch } from '@/lib/copilot/vfs/operations' -import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' +import { GlobPatternError, WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' import { withBlockVisibility } from '@/blocks/visibility/server-context' import { grepChatUpload, listChatUploads, readChatUpload } from './upload-file-reader' @@ -165,6 +165,12 @@ export async function executeVfsGrep( }) return { success: false, error: err.message } } + // An over-cap `path` scope reaches the same matcher glob uses; expected user + // input, not an internal failure. + if (err instanceof GlobPatternError) { + logger.warn('vfs_grep path scope rejected', { pattern, path: rawPath, error: err.message }) + return { success: false, error: err.message } + } logger.error('vfs_grep failed', { pattern, path: rawPath, @@ -203,6 +209,11 @@ export async function executeVfsGlob( logger.debug('vfs_glob result', { pattern, fileCount: files.length }) return { success: true, output: { files } } } catch (err) { + // An over-cap pattern is expected user input, not an internal failure. + if (err instanceof GlobPatternError) { + logger.warn('vfs_glob pattern rejected', { pattern, error: err.message }) + return { success: false, error: err.message } + } logger.error('vfs_glob failed', { pattern, error: toError(err).message, diff --git a/apps/sim/lib/copilot/vfs/document-style.test.ts b/apps/sim/lib/copilot/vfs/document-style.test.ts new file mode 100644 index 00000000000..4f6575a9818 --- /dev/null +++ b/apps/sim/lib/copilot/vfs/document-style.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import JSZip from 'jszip' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' + +const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50 + +const THEME_XML = + '' + + '' + + '' + + '' + + '' + + '' + +async function buildDocxArchive(): Promise { + const zip = new JSZip() + zip.file('word/theme/theme1.xml', THEME_XML) + return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) +} + +/** + * Overwrite every central-directory record's declared uncompressed size so the + * archive claims a multi-gigabyte expansion without allocating it — the shape + * of a zip bomb the guard rejects without decompressing anything. + */ +function forgeDeclaredUncompressedSize(zipBuffer: Buffer, declaredBytes: number): Buffer { + const forged = Buffer.from(zipBuffer) + for (let offset = 0; offset + 46 <= forged.length; offset++) { + if (forged.readUInt32LE(offset) === CENTRAL_DIRECTORY_HEADER_SIGNATURE) { + forged.writeUInt32LE(declaredBytes, offset + 24) + } + } + return forged +} + +describe('extractDocumentStyle zip-bomb guard', () => { + /** + * `extractDocumentStyle` swallows every failure and returns `null`, and a + * forged archive is one JSZip rejects on its own — so asserting `null` passes + * with the guard deleted. Spying on the decompressor is what actually proves + * the buffer never reached it. + */ + let loadAsync: ReturnType> + + beforeEach(() => { + loadAsync = vi.spyOn(JSZip, 'loadAsync') + }) + + afterEach(() => { + loadAsync.mockRestore() + }) + + it('refuses an archive whose declared expansion exceeds the limit', async () => { + const bomb = forgeDeclaredUncompressedSize(await buildDocxArchive(), 0xfffffff0) + + await expect(extractDocumentStyle(bomb, 'docx')).resolves.toBeNull() + expect(loadAsync).not.toHaveBeenCalled() + }) + + it('refuses a bomb whose EOCD lies about the entry count behind an empty-EOCD decoy', async () => { + const bomb = forgeDeclaredUncompressedSize(await buildDocxArchive(), 0xfffffff0) + const eocdOffset = bomb.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])) + const cdOffset = bomb.readUInt32LE(eocdOffset + 16) + const decoy = Buffer.alloc(22) + decoy.writeUInt32LE(0x06054b50, 0) + + const realEocd = Buffer.from(bomb.subarray(eocdOffset)) + realEocd.writeUInt16LE(bomb.readUInt16LE(eocdOffset + 10) + 1, 8) + realEocd.writeUInt16LE(bomb.readUInt16LE(eocdOffset + 10) + 1, 10) + realEocd.writeUInt32LE(cdOffset + decoy.length, 16) + + const attack = Buffer.concat([ + bomb.subarray(0, cdOffset), + decoy, + bomb.subarray(cdOffset, eocdOffset), + realEocd, + ]) + + await expect(extractDocumentStyle(attack, 'docx')).resolves.toBeNull() + expect(loadAsync).not.toHaveBeenCalled() + }) + + it('still extracts style from a well-formed archive', async () => { + const summary = await extractDocumentStyle(await buildDocxArchive(), 'docx') + + expect(loadAsync).toHaveBeenCalledTimes(1) + expect(summary).not.toBeNull() + expect(summary?.theme?.fonts.minor).toBe('Calibri') + expect(summary?.theme?.colors.accent1).toBe('4472C4') + }) +}) diff --git a/apps/sim/lib/copilot/vfs/document-style.ts b/apps/sim/lib/copilot/vfs/document-style.ts index 7edbe202fc2..4198b16f0e2 100644 --- a/apps/sim/lib/copilot/vfs/document-style.ts +++ b/apps/sim/lib/copilot/vfs/document-style.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard' const logger = createLogger('DocumentStyle') @@ -395,6 +396,8 @@ export async function extractDocumentStyle( } try { + assertOoxmlArchiveWithinLimits(buffer) + const JSZip = (await import('jszip')).default const zip = await JSZip.loadAsync(buffer) diff --git a/apps/sim/lib/copilot/vfs/operations.glob-semantics.test.ts b/apps/sim/lib/copilot/vfs/operations.glob-semantics.test.ts new file mode 100644 index 00000000000..617055f372a --- /dev/null +++ b/apps/sim/lib/copilot/vfs/operations.glob-semantics.test.ts @@ -0,0 +1,436 @@ +/** + * @vitest-environment node + */ +import micromatch from 'micromatch' +import { describe, expect, it } from 'vitest' +import { + compileGlobMatcher, + glob, + pathWithinGrepScope, + VFS_GLOB_OPTIONS, +} from '@/lib/copilot/vfs/operations' + +/** + * Differential test: {@link compileGlobMatcher} runs micromatch's own compiled pattern on + * RE2, so its answer must be micromatch's answer for every pattern and every path. This + * generates the pattern grid from glob atoms rather than listing cases, because the parts + * that are easy to get wrong are the combinations — a star next to a globstar, a dot + * segment under a wildcard, a class at a segment start. + * + * A pattern the translation cannot represent compiles to `null` and matches nothing. That + * is the deliberate fail-closed path, so those patterns are counted rather than compared. + * They are all degenerate shapes (unbalanced parens, a POSIX class behind a star); the + * patterns anyone would actually type are pinned separately by {@link REALISTIC}. + */ +const GLOB_OPTIONS = VFS_GLOB_OPTIONS + +/** An astral character, and the two lone surrogates it decomposes into. */ +const ASTRAL = '\u{1F600}' +const HIGH_SURROGATE = '\uD83D' +const LOW_SURROGATE = '\uDE00' + +/** A class range wide enough to bracket the private-use markers the translation reserves. */ +const WIDE_CLASS = '[\u{2000}-\u{F000}]' + +/** + * Atoms for the exhaustive grid. Negated classes and parentheses are included because + * picomatch treats `[^…]` and `(` specially even under `noext`, and neither goes through + * the `(?!\.)` guard that the rest of the translation keys on. `|` is included because a + * marker slot opened inside one branch of a group is not the one the next branch or the + * position after the group needs. The astral and lone-surrogate atoms are included because + * picomatch counts UTF-16 code units and RE2 counts code points, so every `?` and `[^…]` + * next to one is a place the two engines can disagree. The escape atoms and the wide class + * range are included because picomatch passes a caller's `\X` and `[x-y]` through verbatim and + * RE2 reads several of those differently than ECMAScript does, and `!` because it is the one + * glob feature whose compiled shape is a lookahead. + */ +const ATOMS = [ + 'a', + '.', + '..', + 'x', + '*', + '**', + '?', + '/', + '.git', + '[abc]', + '[.]', + '[!a]', + '{b,c}', + '+(z)', + '.a', + 'a.', + '\\.', + '\\*', + 'b', + '[^a]', + '[^a-z]', + '[^0-9-]', + '[^[:alpha:]]', + '(', + ')', + '|', + '!', + '\\a', + '\\A', + '\\p{L}', + WIDE_CLASS, + ASTRAL, + HIGH_SURROGATE, + LOW_SURROGATE, +] as const + +/** + * Atoms for a deeper walk. Four of these compose the shapes a three-atom walk cannot reach + * at all: globstar, slash, dot and star together are the dotfile pattern that returned nothing. + */ +const DEEP_ATOMS = [ + '*', + '**', + '/', + '/**', + '.', + '.*', + './', + 'a', + '.ts', + 'rc', + '[^a]', + '(', + ')', + '|', + ASTRAL, +] as const + +const PATHS = [ + 'a', + 'a/b', + 'a/b.ts', + 'b.ts', + 'a/b/c.ts', + '.hidden', + 'a/.hidden', + '.hidden/x', + 'files', + 'files/a', + 'files/a/meta.json', + 'files/Reports/q1.csv/meta.json', + 'weird{brace}/x', + 'weird[bracket]/x', + 'a.b', + '/', + 'a/', + '/a', + 'a//b', + '.', + '..', + 'a/./b', + 'a/..', + 'x', + 'xy', + 'abc', + 'a?c', + '+(a)', + '*', + 'node_modules/x', + '.a/.b', + 'a/b/.c/d', + 'a\nb', + 'a/b\nc', + 'a.', + '..a', + 'a b/c', + 'a*b', + 'a\\b', + '.git', + '.git/config', + 'a/.git/b', + 'A/B', + 'a/b/c/d/e', + 'ab', + 'ba', + '..hidden', + 'a/.b.ts', + '.b.ts', + 'a.b.c', + 'x..y', + 'a.b.', + 'a/b/', + 'x/', + 'a/x.ts', + 'a.ts', + '.x', + 'x.', + 'a/.', + './a', + 'x.y', + 'a/.b', + '[abc]', + '-x', + ']x', + '.a', + 'a/.a', + 'z', + '..a/b', + '.git/.x', + 'a.git', + '{b,c}', + '+(z)', + '.gitignore', + 'a/b/.', + 'b/..', + '', + '\\u2028b', + '\na$', + '.\n', + '.é}()', + '\\u2029x', + 'a/\nb', + 'a/\\u2028', + '.env', + 'app/.env.local', + 'src/.eslintrc', + 'src/index.ts', + ASTRAL, + `${ASTRAL}.png`, + `a${ASTRAL}`, + `${ASTRAL}${ASTRAL}`, + `.${ASTRAL}`, + `${ASTRAL}/a`, + `a/${ASTRAL}`, + HIGH_SURROGATE, + LOW_SURROGATE, + `${LOW_SURROGATE}${HIGH_SURROGATE}`, + `${HIGH_SURROGATE}.ts`, + `.${HIGH_SURROGATE}`, + 'a|b', +] as const + +/** Patterns a caller would plausibly type; none of these may fail closed. */ +const REALISTIC = [ + '**/*.ts', + 'src/**', + '*.{ts,tsx}', + '**/test_*.py', + 'docs/**/*.md', + '.env', + '.github/**', + '**/.env', + '**/.gitignore', + '.*', + 'a b/*.md', + '**/[Rr]eadme*', + 'files/*/meta.json', + '**/.*', + '**/.*.ts', + './**/.*', + '**/.*rc', + '.*/**', + '**/.*/**', + 'uploads/*', + '**', +] as const + +function buildPatterns(): string[] { + const patterns = new Set() + const walk = (atoms: readonly string[], remaining: number, current: string) => { + if (current) patterns.add(current) + if (remaining === 0) return + for (const atom of atoms) walk(atoms, remaining - 1, current + atom) + } + walk(ATOMS, 3, '') + walk(DEEP_ATOMS, 4, '') + return Array.from(patterns) +} + +/** + * Atoms RE2 cannot be handed at all: it reads `\A` and `\z` as anchors, `\a` as BEL, `\p{…}` + * as a Unicode class, and a range spanning the private-use block would consume a segment + * marker. They are compared like every other pattern but held out of the unrepresentable + * ratio, which measures how often a pattern someone might type is refused. + */ +const REFUSED_ATOMS = ['\\a', '\\A', '\\p{L}', WIDE_CLASS] as const + +function containsRefusedAtom(pattern: string): boolean { + return REFUSED_ATOMS.some((atom) => pattern.includes(atom)) +} + +function expectAgreement(pattern: string, path: string) { + const matcher = compileGlobMatcher(pattern) + const expected = micromatch.isMatch(path, pattern, GLOB_OPTIONS) + expect(matcher?.matches(path) ?? false, `pattern=${pattern} path=${JSON.stringify(path)}`).toBe( + expected + ) +} + +describe('glob matcher equivalence with micromatch', () => { + it('agrees with micromatch.isMatch across the full pattern grid', () => { + const patterns = buildPatterns() + const mismatches: string[] = [] + let unrepresentable = 0 + let translatable = 0 + let compared = 0 + + for (const pattern of patterns) { + const matcher = compileGlobMatcher(pattern) + const held = containsRefusedAtom(pattern) + if (!held) translatable++ + if (!matcher) { + if (!held) unrepresentable++ + continue + } + for (const path of PATHS) { + const expected = micromatch.isMatch(path, pattern, GLOB_OPTIONS) + const actual = matcher.matches(path) + compared++ + if (expected !== actual && mismatches.length < 20) { + mismatches.push( + `pattern=${JSON.stringify(pattern)} path=${JSON.stringify(path)} micromatch=${expected} re2=${actual}` + ) + } + } + } + + expect(mismatches).toEqual([]) + expect(compared).toBeGreaterThan(6_000_000) + expect(unrepresentable / translatable).toBeLessThan(0.08) + }, 120_000) + + it('covers the shapes the translation keys on', () => { + const patterns = new Set(buildPatterns()) + for (const pattern of ['**/.*', '**/.*.ts', './**/.*', '**/.*rc', '.*/**', '**/.*/**']) { + expect(patterns.has(pattern), pattern).toBe(true) + } + expect(Array.from(patterns).some((p) => p.includes('[^'))).toBe(true) + expect(Array.from(patterns).some((p) => p.includes('('))).toBe(true) + }) + + it('represents every realistic pattern rather than failing closed', () => { + for (const pattern of REALISTIC) { + expect(compileGlobMatcher(pattern), pattern).not.toBeNull() + } + }) + + it('matches dotfiles at every depth under a globstar', () => { + const matcher = compileGlobMatcher('**/.*') + expect(matcher).not.toBeNull() + for (const path of ['.env', '.gitignore', 'app/.env.local', 'src/.eslintrc']) { + expect(matcher?.matches(path), path).toBe(true) + } + for (const path of ['src/index.ts', 'README.md']) { + expect(matcher?.matches(path), path).toBe(false) + } + }) + + it('keeps the dot guard off classes picomatch never guarded', () => { + for (const pattern of ['[^a]x', '[^a-z]x', '[^0-9-]x', '[^[:alpha:]]x', '(*)']) { + expectAgreement(pattern, '.x') + } + expectAgreement('[^a]*', '.é}()') + }) + + it('keeps the non-empty assertion when the tail rewrite does not fire', () => { + expectAgreement('*.', '\na$...') + expectAgreement('*?', '\\u2028b\\é') + }) + + it('counts UTF-16 code units the way picomatch does, not code points', () => { + // `?` compiles to `[^/]`, which picomatch runs without the `u` flag, so it consumes one + // code unit and an astral character does not match it. RE2 counts code points, which + // silently widened `?` and `[^…]` into astral filenames — files a scope should exclude. + for (const [pattern, path] of [ + ['?', ASTRAL], + ['?.png', `${ASTRAL}.png`], + ['[^a]', ASTRAL], + ['??', ASTRAL], + ['?', HIGH_SURROGATE], + ['*', ASTRAL], + ['??b', `${ASTRAL}b`], + [`${ASTRAL}.png`, `${ASTRAL}.png`], + ['**/?', `a/${ASTRAL}`], + ['.?', `.${ASTRAL}`], + ] as const) { + expectAgreement(pattern, path) + } + expect(compileGlobMatcher('?')?.matches(ASTRAL)).toBe(false) + expect(compileGlobMatcher('??')?.matches(ASTRAL)).toBe(true) + }) + + it('reopens the marker slot a group would otherwise trap', () => { + // A slot opened inside a group is unreachable once the group matches nothing, so the + // segment marker stayed unconsumable and no dot path could ever match. + for (const pattern of ['(a|b)?*', '(a|)*', '((a|b)|(c|d))?*', '**|*']) { + for (const path of ['.hidden', '.', '..', '.env', 'ab', 'a']) { + expectAgreement(pattern, path) + } + } + expect(compileGlobMatcher('(a|b)?*')?.matches('.hidden')).toBe(true) + }) + + it('refuses the escape alphabet picomatch never emits', () => { + // picomatch escapes only punctuation, so `\A`, `\z`, `\a` and `\p{…}` are caller text it + // passes through. RE2 reads them as anchors, BEL and a Unicode class — and `\p{Any}` + // consumes the segment markers, taking `dot: false` with it. + for (const [pattern, path] of [ + ['\\A**/**', 'secrets/prod.key'], + ['\\A**/**', 'README.md'], + ['\\p{Any}**/**', '.git/config'], + ['*\\z', 'README.md'], + ['[^\\a]', 'a'], + ] as const) { + expectAgreement(pattern, path) + } + // `[\a-z]` is the fail-safe half of the same rule: ECMAScript reads `\a` as a literal `a` + // and matches, and refusing costs a match rather than granting one. + expect(compileGlobMatcher('[\\a-z]')).toBeNull() + expect(compileGlobMatcher('\\A**/**')).toBeNull() + expect(pathWithinGrepScope('secrets/prod.key', '\\A**/**')).toBe(false) + }) + + it('refuses a class range spanning the reserved code points', () => { + // The range brackets the segment markers and the surrogate escape block, so the class + // consumes a marker where micromatch requires a real character and every later atom + // matches one position to the left. + for (const [pattern, path] of [ + [`${WIDE_CLASS}.env`, '.env'], + [`${WIDE_CLASS}*`, '.env'], + [`${WIDE_CLASS}.git/config`, '.git/config'], + [`${WIDE_CLASS}..`, '..'], + [`[^a${WIDE_CLASS.slice(1)}`, '.env'], + ] as const) { + expectAgreement(pattern, path) + } + // A pair of such classes spans both halves of a remapped astral character, which is the + // fail-safe half of the rule: micromatch matches it and refusing costs that match. + expect(compileGlobMatcher(`${WIDE_CLASS}${WIDE_CLASS}`)).toBeNull() + expect(compileGlobMatcher(`${WIDE_CLASS}.env`)).toBeNull() + expect(pathWithinGrepScope('.env', `${WIDE_CLASS}*`)).toBe(false) + }) + + it('matches negated patterns instead of silently matching nothing', () => { + const files = new Map([ + ['a.ts', ''], + ['b.js', ''], + ]) + expect(glob(files, '!*.ts')).toEqual(['b.js']) + for (const pattern of ['!*.ts', '!.env', '!**/*.md', '!a', '!', '!!a', '!!*.ts', '!(a)']) { + for (const path of PATHS) { + expectAgreement(pattern, path) + } + } + }) + + it('refuses a zero-padded repeat bound RE2 reads as literal text', () => { + for (const pattern of ['a{00}b', 'a{00,2}b', '.{00,}*', 'a{1,02}b']) { + expect(compileGlobMatcher(pattern), pattern).toBeNull() + } + expect(compileGlobMatcher('a{0,2}b'), 'a{0,2}b').not.toBeNull() + }) + + it('rejects the empty path the way micromatch does', () => { + for (const pattern of ['**', '**/**', '**/', '**/**/**', '\\']) { + expectAgreement(pattern, '') + } + }) +}) diff --git a/apps/sim/lib/copilot/vfs/operations.test.ts b/apps/sim/lib/copilot/vfs/operations.test.ts index e626b702842..68e89e4f094 100644 --- a/apps/sim/lib/copilot/vfs/operations.test.ts +++ b/apps/sim/lib/copilot/vfs/operations.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { glob, grep } from '@/lib/copilot/vfs/operations' +import { GlobPatternError, glob, grep } from '@/lib/copilot/vfs/operations' function vfsFromEntries(entries: [string, string][]): Map { return new Map(entries) @@ -58,6 +58,122 @@ describe('glob', () => { }) }) +/** + * Matching runs on RE2, not on picomatch's own backtracking `RegExp`. These pin the glob + * semantics that translation has to preserve, so a future change to it cannot quietly + * widen or narrow what a pattern selects. + */ +describe('glob semantics', () => { + it('does not let a single star cross a slash', () => { + const files = vfsFromEntries([ + ['b.ts', ''], + ['a/b.ts', ''], + ]) + expect(glob(files, '*.ts')).toEqual(['b.ts']) + }) + + it('lets a double star cross slashes', () => { + const files = vfsFromEntries([ + ['b.ts', ''], + ['a/b.ts', ''], + ['a/c/d.ts', ''], + ]) + expect(glob(files, '**/*.ts').sort()).toEqual(['a/b.ts', 'a/c/d.ts', 'b.ts']) + }) + + it('matches exactly one character for a question mark, and not a slash', () => { + const files = vfsFromEntries([ + ['abc', ''], + ['ac', ''], + ['a/c', ''], + ]) + expect(glob(files, 'a?c')).toEqual(['abc']) + }) + + it('never matches a dotfile with a wildcard when dot is false', () => { + const files = vfsFromEntries([ + ['.hidden', ''], + ['visible', ''], + ['a/.hidden', ''], + ]) + expect(glob(files, '*')).toEqual(['a', 'visible']) + expect(glob(files, '**')).toEqual(['a', 'visible']) + expect(glob(files, 'a/*')).toEqual([]) + }) + + it('still matches a dotfile when the pattern spells the dot out', () => { + const files = vfsFromEntries([ + ['.hidden', ''], + ['visible', ''], + ]) + expect(glob(files, '.*')).toEqual(['.hidden']) + expect(glob(files, '.hidden')).toEqual(['.hidden']) + }) + + it('treats extglob syntax literally when noext is set', () => { + const files = vfsFromEntries([ + ['+(a)', ''], + ['a', ''], + ['aaa', ''], + ]) + expect(glob(files, '+(a)')).toEqual(['+(a)']) + }) + + it('treats a character class as a class, and brackets in a path as literals', () => { + const files = vfsFromEntries([ + ['ax', ''], + ['bx', ''], + ['cx', ''], + ['weird[bracket]/x', ''], + ]) + expect(glob(files, '[ab]x').sort()).toEqual(['ax', 'bx']) + expect(glob(files, 'weird[bracket]/x')).toEqual(['weird[bracket]/x']) + }) +}) + +describe('glob regex safety', () => { + it('runs a catastrophically backtracking pattern in linear time', () => { + // `*a` twelve times then `b` is 25 characters and takes ~41s against this 48-character + // path on picomatch's backtracking `RegExp`. Both sides are caller-supplied. + const files = vfsFromEntries([['a'.repeat(48), '']]) + const pattern = `${'*a'.repeat(12)}b` + + const start = Date.now() + const hits = glob(files, pattern) + + expect(hits).toEqual([]) + expect(Date.now() - start).toBeLessThan(1000) + }) + + it('still matches when the same shape has a real answer', () => { + const files = vfsFromEntries([[`${'a'.repeat(48)}b`, '']]) + expect(glob(files, `${'*a'.repeat(12)}b`)).toEqual([`${'a'.repeat(48)}b`]) + }) + + it('rejects an over-long pattern with a clear error', () => { + const files = vfsFromEntries([['a', '']]) + expect(() => glob(files, 'a'.repeat(1001))).toThrow(GlobPatternError) + expect(() => glob(files, 'a'.repeat(1001))).toThrow(/too long/) + }) + + it('rejects an absurd wildcard count with a clear error', () => { + const files = vfsFromEntries([['a', '']]) + expect(() => glob(files, '*'.repeat(33))).toThrow(GlobPatternError) + expect(() => glob(files, '*'.repeat(33))).toThrow(/too many wildcards/) + }) + + it('applies the same engine to a wildcard grep scope', () => { + const files = vfsFromEntries([['a'.repeat(48), 'needle']]) + const pattern = `${'*a'.repeat(12)}b` + + const start = Date.now() + const hits = grep(files, 'needle', pattern, { outputMode: 'files_with_matches' }) + + expect(hits).toEqual([]) + expect(Date.now() - start).toBeLessThan(1000) + }) +}) + describe('grep', () => { it('returns content matches per line in default mode', () => { const files = vfsFromEntries([['a.txt', 'hello\nworld\nhello']]) diff --git a/apps/sim/lib/copilot/vfs/operations.ts b/apps/sim/lib/copilot/vfs/operations.ts index 624707b43e9..2d19741d809 100644 --- a/apps/sim/lib/copilot/vfs/operations.ts +++ b/apps/sim/lib/copilot/vfs/operations.ts @@ -94,8 +94,13 @@ export interface ReadResult { * never crosses path slashes (required for `files` + star + `meta.json` style paths). `nobrace` * and `noext` disable brace and extglob expansion like the old builder. Uses `micromatch` for * well-tested `**` and edge cases instead of a custom `RegExp`. + * + * Only `micromatch.makeRe` is used — never `micromatch.isMatch`. See {@link compileGlobMatcher}. + * + * Exported so the differential test compiles against the production options rather than a + * copy that can drift out of sync with them. */ -const VFS_GLOB_OPTIONS: micromatch.Options = { +export const VFS_GLOB_OPTIONS: micromatch.Options = { bash: false, dot: false, windows: false, @@ -103,6 +108,872 @@ const VFS_GLOB_OPTIONS: micromatch.Options = { noext: true, } +/** + * Longest glob pattern accepted. Every real VFS pattern is a path shape well under this; + * the cap only exists so a pathological pattern cannot reach the compiler at all. + */ +const MAX_GLOB_PATTERN_LENGTH = 1000 + +/** + * Most `*`/`?` wildcards accepted in one pattern. A path-shaped glob uses a handful; + * dozens only appear in a probe. Bounded independently of length because the ReDoS + * shapes are short (`*a*a*a…b` is 25 characters). + */ +const MAX_GLOB_WILDCARDS = 32 + +/** + * Markers inserted at the start of a path segment while a pattern is matched. + * + * `dot: false` and picomatch's "a star must leave something to match" rule are encoded as + * lookaheads RE2 cannot express. Every one of them constrains the *shape of the segment* the + * pattern is about to match, so marking each segment start turns the assertion into a character + * exclusion: a guarded pattern position omits the marker from the class it may consume, an + * unguarded one consumes it optionally. U+E000–U+E002 are private-use, so a real VFS path + * cannot contain one; a path that does is not matched. + * + * - {@link SEG_DOT} — the segment starts with `.` and is neither `.` nor `..` (`(?!\.)`). + * - {@link SEG_DOT_ONLY} — the segment is `.` or `..` (`(?!\.{0,1}(?:\/|$))`, plus `(?!\.)`). + * - {@link SEG_NEWLINE} — the segment starts with a line terminator (`(?=.)`). + */ +const SEG_DOT = '\u{E000}' +const SEG_DOT_ONLY = '\u{E001}' +const SEG_NEWLINE = '\u{E002}' + +/** The markers as RE2 escapes, for use inside generated pattern source. */ +const SEG_DOT_RE = '\\x{e000}' +const SEG_DOT_ONLY_RE = '\\x{e001}' +const SEG_NEWLINE_RE = '\\x{e002}' +const ALL_MARKERS_RE = `${SEG_DOT_RE}${SEG_DOT_ONLY_RE}${SEG_NEWLINE_RE}` + +/** + * Where surrogate code units are remapped so RE2 sees one code point per UTF-16 code unit. + * + * picomatch compiles `?` to `[^/]` and runs it on a `RegExp` with no `u` flag, so it consumes + * exactly one code unit and an astral character (two units) does not match it. RE2 always + * matches whole code points, so the same class consumed `😀` and `glob('?')` returned files + * micromatch excludes — an over-match, and the dangerous direction for a scope filter. + * + * Splitting each surrogate half onto its own private-use code point restores UTF-16 counting + * exactly: `?` matches one half and so cannot match `😀`, while `??` matches both and does, + * which is what micromatch answers in each case. The alternative — rejecting astral input — + * would trade an over-match for an under-match on real filenames, so it is not taken. The + * range is 2048 wide (one per surrogate) and disjoint from the segment markers above. + */ +const SURROGATE_ESCAPE_BASE = 0xe800 + +/** Any surrogate code unit, paired or lone. Deliberately not `u`-flagged. */ +const SURROGATE_CHAR = /[\uD800-\uDFFF]/ + +/** + * Code points the translation reserves for its own use — the segment markers and the surrogate + * escape range — so a pattern or path already containing one is rejected rather than confused + * with a marker or with half of an astral character. + */ +const RESERVED_CHAR = /[\u{E000}-\u{E002}\u{E800}-\u{EFFF}]/u + +/** The bounds of {@link RESERVED_CHAR}, for rejecting a class range that spans them. */ +const RESERVED_FIRST = 0xe000 +const RESERVED_LAST = 0xefff + +/** + * Escapes picomatch never emits — it escapes only punctuation. A `\` before a word character + * is therefore caller text passed through verbatim, and RE2 reads several of those as + * something ECMAScript does not: `\A` and `\z` are zero-width anchors rather than literals, + * and `\p{…}` is a Unicode class that would consume a segment marker. + */ +const WORD_ESCAPE = /[A-Za-z0-9]/ + +/** Rewrite every surrogate code unit to its {@link SURROGATE_ESCAPE_BASE} counterpart. */ +function escapeSurrogates(text: string): string { + if (!SURROGATE_CHAR.test(text)) return text + let out = '' + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i) + out += + code >= 0xd800 && code <= 0xdfff + ? String.fromCharCode(SURROGATE_ESCAPE_BASE + (code - 0xd800)) + : text[i] + } + return out +} + +/** + * {@link escapeSurrogates} over generated pattern source, or `null` when a surrogate sits + * inside a character class. Remapping there would move a range endpoint above the segment + * markers, so `[a-😀]` would start consuming them; the shape is degenerate enough that failing + * closed costs nothing. + */ +function escapeSurrogatesInSource(source: string): string | null { + if (!SURROGATE_CHAR.test(source)) return source + let out = '' + let inClass = false + for (let i = 0; i < source.length; i++) { + const char = source[i] + const code = source.charCodeAt(i) + if (code >= 0xd800 && code <= 0xdfff) { + if (inClass) return null + out += String.fromCharCode(SURROGATE_ESCAPE_BASE + (code - 0xd800)) + continue + } + if (char === '\\') { + const next = source[i + 1] + if (next === undefined) return null + // picomatch escapes each surrogate half individually; `\` before one is decorative, and + // the remapped code point is not a metacharacter, so the escape is dropped rather than + // handed to RE2 as an unknown one. + if (SURROGATE_CHAR.test(next)) { + if (inClass) return null + out += String.fromCharCode(SURROGATE_ESCAPE_BASE + (source.charCodeAt(i + 1) - 0xd800)) + i += 1 + continue + } + out += char + next + i += 1 + continue + } + if (inClass) { + if (char === ']') inClass = false + } else if (char === '[') { + inClass = true + } + out += char + } + return out +} + +/** The code points ECMAScript's `.` excludes, as a matcher and as RE2 escapes. */ +const LINE_TERMINATOR = /[\n\r\u2028\u2029]/ +const NEWLINE_RE = '\\x{a}\\x{d}\\x{2028}\\x{2029}' + +/** Picomatch's `**` body: any run of characters that does not open a dot segment. */ +const GLOBSTAR_BODY = '(?:(?:(?!(?:^|\\/)\\.).)*?)' +const GLOBSTAR_CLASS = `[^${ALL_MARKERS_RE}${NEWLINE_RE}]` + +/** The three assertion shapes picomatch emits under {@link VFS_GLOB_OPTIONS}. */ +const DOT_GUARD = '(?!\\.)' +const NON_EMPTY_GUARD = '(?=.)' +const DOT_SEGMENT_GUARD = '(?!\\.{0,1}(?:\\/|$))' + +export interface GlobMatcher { + matches(path: string): boolean +} + +/** + * Thrown when a caller-supplied glob pattern is outside the safety caps. Both the `glob` + * and `grep` tool handlers already turn a thrown error into `{ success: false, error }`, + * so the message reaches the caller verbatim. + */ +export class GlobPatternError extends Error { + readonly code = 'GLOB_PATTERN' as const + constructor(message: string) { + super(message) + this.name = 'GlobPatternError' + } +} + +/** + * Compiled matchers, keyed by pattern. `grep` tests one scope against every file in the VFS + * and RE2 compilation costs far more than a match, so a pattern must not recompile per file. + * Cleared wholesale past the cap — patterns arrive in per-request bursts, so eviction order + * does not matter. + */ +const globMatcherCache = new Map() + +/** Entries kept in {@link globMatcherCache} before it is cleared. */ +const GLOB_MATCHER_CACHE_LIMIT = 256 + +/** {@link compileGlobMatcher}, memoized. Throws {@link GlobPatternError} the same way. */ +function getGlobMatcher(pattern: string): GlobMatcher | null { + const cached = globMatcherCache.get(pattern) + if (cached !== undefined) return cached + + const matcher = compileGlobMatcher(pattern) + if (globMatcherCache.size >= GLOB_MATCHER_CACHE_LIMIT) globMatcherCache.clear() + globMatcherCache.set(pattern, matcher) + return matcher +} + +/** Index just past the character class starting at `at`, or -1 when it is unterminated. */ +function classEnd(source: string, at: number): number { + let i = at + 1 + if (source[i] === '^') i += 1 + if (source[i] === ']') i += 1 + while (i < source.length && source[i] !== ']') { + if (source[i] === '\\') i += 1 + i += 1 + } + return i < source.length ? i + 1 : -1 +} + +/** + * True when a character-class body can be handed to RE2 unchanged. + * + * Two shapes cannot. A `\` escape is caller passthrough RE2 reinterprets (see + * {@link WORD_ESCAPE}). A range whose endpoints bracket the reserved block lets the class + * consume a segment marker or half a remapped astral character, which shifts every later + * atom one position left — `[\u{2000}-\u{F000}].env` would match `.env` by eating its + * {@link SEG_DOT}, bypassing `dot: false` entirely. + */ +function classBodyIsRepresentable(body: string): boolean { + let previous = -1 + let i = 0 + while (i < body.length) { + if (body[i] === '-' && previous >= 0 && i + 1 < body.length) { + i += 1 + let high: number + if (body[i] === '\\') { + const next = body[i + 1] + if (next === undefined || WORD_ESCAPE.test(next)) return false + high = next.charCodeAt(0) + i += 2 + } else { + high = body.charCodeAt(i) + i += 1 + } + if (previous <= RESERVED_LAST && high >= RESERVED_FIRST) return false + previous = -1 + continue + } + if (body[i] === '\\') { + const next = body[i + 1] + if (next === undefined || WORD_ESCAPE.test(next)) return false + previous = next.charCodeAt(0) + i += 2 + continue + } + previous = body.charCodeAt(i) + i += 1 + } + return true +} + +/** Index of the `)` closing the group opened at `at`, or -1. */ +function groupEnd(source: string, at: number): number { + let depth = 0 + for (let i = at; i < source.length; i++) { + const char = source[i] + if (char === '\\') { + i += 1 + } else if (char === '[') { + const end = classEnd(source, i) + if (end === -1) return -1 + i = end - 1 + } else if (char === '(') { + depth += 1 + } else if (char === ')') { + depth -= 1 + if (depth === 0) return i + } + } + return -1 +} + +/** The quantifier at `at`, or `''` when there is none (`{ts,tsx}` is a literal, not a repeat). */ +function readQuantifier(source: string, at: number): string { + const char = source[at] + if (char === '?' || char === '*' || char === '+') { + return source[at + 1] === '?' ? source.slice(at, at + 2) : char + } + if (char === '{') { + const end = source.indexOf('}', at) + if (end !== -1 && /^\{\d+(?:,\d*)?\}$/.test(source.slice(at, end + 1))) { + return source.slice(at, end + 1) + } + } + return '' +} + +/** + * True when a `{n,m}` bound is zero-padded. RE2 reads `a{00}` as the literal text `a{00}` + * where ECMAScript reads a repeat, so the two engines cannot be reconciled and the source is + * refused rather than emitted with a silently different meaning. + */ +function hasPaddedBound(quantifier: string): boolean { + return /^\{0\d|,0\d/.test(quantifier) +} + +/** True when `quantifier` lets the atom it follows match nothing. */ +function isOptionalQuantifier(quantifier: string): boolean { + if (quantifier.startsWith('?') || quantifier.startsWith('*')) return true + const bounded = /^\{(\d+)(?:,\d*)?\}$/.exec(quantifier) + return bounded !== null && Number(bounded[1]) === 0 +} + +/** True when every match of the balanced token run `[start, end)` consumes at least one character. */ +function sequenceMustConsume(source: string, start: number, end: number): boolean { + let i = start + while (i < end) { + const char = source[i] + if (char === '^' || char === '$') { + i += 1 + continue + } + let atomEnd: number + let consumes: boolean + if (char === '(') { + const close = groupEnd(source, i) + if (close === -1 || close >= end) return false + consumes = groupMustConsume(source, i, close) + atomEnd = close + 1 + } else if (char === '[') { + const close = classEnd(source, i) + if (close === -1) return false + consumes = true + atomEnd = close + } else { + consumes = true + atomEnd = char === '\\' ? i + 2 : i + 1 + } + const quantifier = readQuantifier(source, atomEnd) + if (consumes && !isOptionalQuantifier(quantifier)) return true + i = atomEnd + quantifier.length + } + return false +} + +/** {@link sequenceMustConsume} for a group, which consumes only when every branch does. */ +function groupMustConsume(source: string, open: number, close: number): boolean { + const isNonCapturing = source.startsWith('(?:', open) + if (!isNonCapturing && source[open + 1] === '?') return false + let depth = 0 + let from = open + (isNonCapturing ? 3 : 1) + for (let i = from; i < close; i++) { + const char = source[i] + if (char === '\\') { + i += 1 + } else if (char === '[') { + const end = classEnd(source, i) + if (end === -1) return false + i = end - 1 + } else if (char === '(') { + depth += 1 + } else if (char === ')') { + depth -= 1 + } else if (char === '|' && depth === 0) { + if (!sequenceMustConsume(source, from, i)) return false + from = i + 1 + } + } + return sequenceMustConsume(source, from, close) +} + +/** Index of the `)` closing the group `from` sits inside, or -1. */ +function enclosingGroupEnd(source: string, from: number): number { + let depth = 0 + for (let i = from; i < source.length; i++) { + const char = source[i] + if (char === '\\') { + i += 1 + } else if (char === '[') { + const end = classEnd(source, i) + if (end === -1) return -1 + i = end - 1 + } else if (char === '(') { + depth += 1 + } else if (char === ')') { + if (depth === 0) return i + depth -= 1 + } + } + return -1 +} + +/** + * True when whatever remains of `source` from `at` must consume at least one character. + * + * This is what makes `(?=.)`'s "not at end of input" half droppable: if the rest of the pattern + * cannot match nothing, the assertion is already implied. Alternatives to the right of a `|` + * belong to the enclosing group rather than to this position, so they are skipped. + */ +function remainderMustConsume(source: string, at: number): boolean { + let i = at + while (i < source.length) { + const char = source[i] + if (char === '^' || char === '$') { + i += 1 + continue + } + if (char === ')') { + i += 1 + readQuantifier(source, i + 1).length + continue + } + if (char === '|') { + const close = enclosingGroupEnd(source, i) + if (close === -1) return false + i = close + 1 + readQuantifier(source, close + 1).length + continue + } + let atomEnd: number + let consumes: boolean + if (char === '(') { + const close = groupEnd(source, i) + if (close === -1) return false + consumes = groupMustConsume(source, i, close) + atomEnd = close + 1 + } else if (char === '[') { + const close = classEnd(source, i) + if (close === -1) return false + consumes = true + atomEnd = close + } else { + consumes = true + atomEnd = char === '\\' ? i + 2 : i + 1 + } + const quantifier = readQuantifier(source, atomEnd) + if (consumes && !isOptionalQuantifier(quantifier)) return true + i = atomEnd + quantifier.length + } + return false +} + +/** + * True when `at` opens a group other than `**`. A guard sitting there applies only to the + * branches that consume nothing, which no single emitted position can express, so callers + * fail closed instead. + */ +function isGroupOpen(source: string, at: number): boolean { + if (source[at] !== '(' || source.startsWith('(?=', at) || source.startsWith('(?!', at)) + return false + return !source.startsWith(GLOBSTAR_BODY, at) +} + +/** `atom` with line terminators removed from what it can match, or `null` when it cannot be. */ +function withoutLineTerminators(atom: string): string | null { + if (atom.startsWith('[^')) return `[^${atom.slice(2, -1)}${NEWLINE_RE}]` + return LINE_TERMINATOR.test(atom) ? null : atom +} + +/** + * Whether the atom at `at` can match a line terminator. Conservative: a negated class or a + * group answers "yes", so a `(?=.)` whose non-emptiness would have to be pushed onto such an + * atom fails closed rather than being approximated. + */ +function atomMayMatchLineTerminator(source: string, at: number): boolean { + const char = source[at] + if (char === undefined || char === '(' || char === ')' || char === '|') return true + if (char === '\\') return LINE_TERMINATOR.test(source[at + 1] ?? '\n') + if (char === '[') { + const end = classEnd(source, at) + if (end === -1) return true + return source[at + 1] === '^' || LINE_TERMINATOR.test(source.slice(at, end)) + } + return LINE_TERMINATOR.test(char) +} + +/** + * Rewrite picomatch's generated source into an RE2-representable equivalent. + * + * The scan is structural rather than textual: it tracks whether the current output position + * starts a path segment (following alternation branches and quantified groups, so `(?:X\/)?` + * is recognised as a boundary), and opens one optional marker class per segment. Each assertion + * then narrows that class instead of being deleted — `(?!\.)` drops {@link SEG_DOT} and + * {@link SEG_DOT_ONLY}, `(?!\.{0,1}(?:\/|$))` drops {@link SEG_DOT_ONLY}, `(?=.)` drops + * {@link SEG_NEWLINE}. `(?=.)` additionally forbids matching nothing at all, which is dropped + * when the rest of the pattern must consume a character and is otherwise folded into the + * trailing `[^/]*?\/?$` it guards. + * + * Returns `null` when any assertion survives or a shape is unrecognised, which is how a + * picomatch version emitting something new degrades to "no matches" instead of to the + * backtracking engine. `operations.glob-semantics.test.ts` pins the equivalence over the grid. + */ +function rewriteGlobSource(generated: string): string | null { + if (RESERVED_CHAR.test(generated)) return null + const source = escapeSurrogatesInSource(generated) + if (source === null) return null + + const parts: string[] = [] + const groups: Array<{ + startBoundary: boolean + branchEnds: boolean[] + /** Index in `parts` just past the group opener, so a slot inside it can be recognised. */ + openIndex: number + /** Index in `source` of the `(`. */ + sourceOpen: number + }> = [] + let boundary = false + let emptyBoundary = false + let slot = -1 + let markers = '' + let leadingDot = false + let slotPending = false + let afterEndAnchor = false + let nonEmpty = false + let nonEmptyNeedsNewlineGuard = false + let i = 0 + + const writeSlot = () => { + if (slot >= 0) parts[slot] = markers === '' ? '' : `[${markers}]?` + } + const openSlot = () => { + if (slot >= 0) return + slot = parts.length + markers = ALL_MARKERS_RE + slotPending = true + parts.push('') + writeSlot() + } + const dropMarker = (marker: string) => { + markers = markers.split(marker).join('') + writeSlot() + } + const endSegment = () => { + slot = -1 + markers = '' + leadingDot = false + slotPending = false + } + + while (i < source.length) { + // A branch ending in `$` cannot continue, so it never constrains the boundary after it. + const endsBranch = afterEndAnchor + afterEndAnchor = false + + if (source.startsWith(GLOBSTAR_BODY, i)) { + if (nonEmpty) return null + // `**` never consumes a marker itself, so it is split. Having matched something it can + // only have stopped before a newline segment — a `/` followed by a dot is exactly what + // its own guard forbids. Having matched nothing it leaves the position untouched, so the + // empty branch carries the marker slot forward for whatever follows to narrow. + const inherited = slotPending ? markers : boundary || emptyBoundary ? ALL_MARKERS_RE : '' + if (slotPending) parts[slot] = '' + parts.push(`(?:${GLOBSTAR_CLASS}+[${SEG_NEWLINE_RE}]?|`) + slot = parts.length + markers = inherited + slotPending = true + parts.push('') + writeSlot() + parts.push(')') + i += GLOBSTAR_BODY.length + boundary = false + emptyBoundary = false + leadingDot = false + continue + } + + if (source.startsWith(DOT_GUARD, i)) { + if (isGroupOpen(source, i + DOT_GUARD.length)) return null + if (!boundary && !slotPending) return null + openSlot() + dropMarker(SEG_DOT_RE) + dropMarker(SEG_DOT_ONLY_RE) + i += DOT_GUARD.length + continue + } + + if (source.startsWith(DOT_SEGMENT_GUARD, i)) { + if (!leadingDot) return null + dropMarker(SEG_DOT_ONLY_RE) + i += DOT_SEGMENT_GUARD.length + continue + } + + if (source.startsWith(NON_EMPTY_GUARD, i)) { + if (isGroupOpen(source, i + NON_EMPTY_GUARD.length)) return null + if (boundary || slotPending) { + openSlot() + dropMarker(SEG_NEWLINE_RE) + nonEmptyNeedsNewlineGuard = false + } else if (leadingDot) { + // Mid-segment: no marker covers this position, so the guard has to reach the atom. + nonEmptyNeedsNewlineGuard = true + } else { + return null + } + nonEmpty = true + i += NON_EMPTY_GUARD.length + continue + } + + const char = source[i] + + if (char === '(') { + const opener = source.startsWith('(?:', i) ? '(?:' : '(' + if (opener === '(' && source[i + 1] === '?') return null + parts.push(opener) + groups.push({ + startBoundary: boundary, + branchEnds: [], + openIndex: parts.length, + sourceOpen: i, + }) + i += opener.length + continue + } + + if (char === '|') { + const frame = groups[groups.length - 1] + if (!frame) return null + frame.branchEnds.push(endsBranch || boundary) + boundary = frame.startBoundary + emptyBoundary = false + // A slot belongs to the branch that emitted it; the next branch is a separate path + // through the regex and opens its own, so nothing is carried across the `|`. + endSegment() + parts.push('|') + i += 1 + continue + } + + if (char === ')') { + const frame = groups.pop() + if (!frame) return null + // A slot still pending at the close is un-consumed and goes on serving the position + // after the group (a trailing `**` leaves exactly this). One the branch already built + // on is trapped inside it, and nothing outside may narrow it further. + const carriedSlot = slotPending + if (!carriedSlot && slot >= frame.openIndex) endSegment() + const closeIndex = i + parts.push(')') + i += 1 + const quantifier = readQuantifier(source, i) + if (quantifier) { + if (hasPaddedBound(quantifier)) return null + parts.push(quantifier) + i += quantifier.length + } + let closedAtBoundary = endsBranch || boundary + for (const end of frame.branchEnds) closedAtBoundary &&= end + if (isOptionalQuantifier(quantifier)) closedAtBoundary &&= frame.startBoundary + boundary = closedAtBoundary + // A group that can match nothing leaves the position exactly where it started, so on + // that path the segment marker is still unconsumed and something after the group has to + // be able to take it — otherwise `(a|b)?*` cannot match `.hidden` at all. The flag is + // kept apart from `boundary` because the group's other paths are NOT at a segment start, + // so an assertion landing here would hold for only some of them; every assertion keys on + // `boundary` and therefore fails closed. + emptyBoundary = + !boundary && + !carriedSlot && + frame.startBoundary && + (isOptionalQuantifier(quantifier) || + !groupMustConsume(source, frame.sourceOpen, closeIndex)) + continue + } + + if (char === '^' || char === '$') { + if (char === '^' && slotPending) { + // picomatch wraps its output in a redundant `^(?:^…)$)$`, so a slot opened at the + // outer boundary can land in front of the inner anchor, where no marker can ever + // reach it. Nothing between the two consumes, so the slot simply moves behind it. + parts[slot] = '' + parts.push('^') + slot = parts.length + parts.push('') + writeSlot() + } else { + parts.push(char) + } + if (char === '^') { + boundary = true + emptyBoundary = false + } + afterEndAnchor = char === '$' + i += 1 + continue + } + + let atom: string + let atomEnd: number + let isSlash = false + if (char === '\\') { + const next = source[i + 1] + if (next === undefined || WORD_ESCAPE.test(next)) return null + atom = `\\${next}` + atomEnd = i + 2 + isSlash = next === '/' + } else if (char === '[') { + const end = classEnd(source, i) + if (end === -1) return null + const negated = source[i + 1] === '^' + const body = source.slice(negated ? i + 2 : i + 1, end - 1) + if (!classBodyIsRepresentable(body)) return null + atom = negated ? `[^${body}${ALL_MARKERS_RE}]` : `[${body}]` + atomEnd = end + } else if (char === '.') { + atom = GLOBSTAR_CLASS + atomEnd = i + 1 + } else if (char === '*' || char === '+' || char === '?') { + return null + } else { + atom = char + atomEnd = i + 1 + isSlash = char === '/' + } + + const quantifier = readQuantifier(source, atomEnd) + if (hasPaddedBound(quantifier)) return null + const optional = isOptionalQuantifier(quantifier) + const atBoundary = boundary + // A class picomatch did not exclude `/` from (its POSIX expansions, and the `.` those + // sometimes leave behind) can step into the next segment, landing on that segment's marker. + const crossesSegments = atom.startsWith('[^') + ? !atom.slice(2, -1).includes('/') + : atom.startsWith('[') + ? atom.slice(1, -1).includes('/') + : false + if (crossesSegments && quantifier !== '' && quantifier !== '?') return null + + if (nonEmpty) { + if (!optional) { + if (nonEmptyNeedsNewlineGuard) { + const guarded = withoutLineTerminators(atom) + if (guarded === null) return null + atom = guarded + } + } else { + if (char !== '[' || crossesSegments) return null + const rest = source.slice(atomEnd + quantifier.length) + const first = nonEmptyNeedsNewlineGuard ? withoutLineTerminators(atom) : atom + if (first === null) return null + if (remainderMustConsume(source, atomEnd + quantifier.length)) { + // Only the "not at end of input" half was load-bearing, and the remainder implies it. + if (nonEmptyNeedsNewlineGuard) { + if (atomMayMatchLineTerminator(source, atomEnd + quantifier.length)) return null + if (boundary) openSlot() + parts.push(`(?:${first}${atom}*?)?`) + } else { + if (boundary) openSlot() + parts.push(`${atom}${quantifier}`) + } + } else { + const tail = /^(\\\/\?)?(\)*)\$$/.exec(rest) + if (!tail) return null + if (boundary) openSlot() + parts.push(tail[1] ? `(?:${first}${atom}*\\/?|\\/)` : `${first}${atom}*`) + parts.push(`${tail[2]}$`) + const out = parts.join('') + return /\(\?[=!<]/.test(out) ? null : out + } + nonEmpty = false + nonEmptyNeedsNewlineGuard = false + i = atomEnd + quantifier.length + slotPending = false + boundary = false + leadingDot = false + continue + } + nonEmpty = false + nonEmptyNeedsNewlineGuard = false + } + + if (boundary || emptyBoundary) openSlot() + if (crossesSegments) { + const stepped = `${atom}[${ALL_MARKERS_RE}]?` + parts.push(quantifier === '?' ? `(?:${stepped})?` : stepped) + } else { + parts.push(atom + quantifier) + } + i = atomEnd + quantifier.length + slotPending = false + emptyBoundary = false + boundary = isSlash && !optional + leadingDot = atBoundary && atom === '\\.' && !optional + if (boundary) endSegment() + } + + if (groups.length > 0 || nonEmpty) return null + const out = parts.join('') + return /\(\?[=!<]/.test(out) ? null : out +} + +/** + * Prefix every path segment with the marker describing its shape, with surrogate halves + * remapped so RE2 counts UTF-16 code units the way picomatch does. Returns `null` when the + * path already contains a reserved code point. See {@link SEG_DOT} and + * {@link SURROGATE_ESCAPE_BASE}. + */ +function markPathSegments(path: string): string | null { + if (RESERVED_CHAR.test(path)) return null + const escaped = escapeSurrogates(path) + if (!escaped.includes('.') && !LINE_TERMINATOR.test(escaped)) return escaped + return escaped + .split('/') + .map((segment) => { + if (segment === '.' || segment === '..') return SEG_DOT_ONLY + segment + if (segment.startsWith('.')) return SEG_DOT + segment + if (segment !== '' && LINE_TERMINATOR.test(segment[0])) return SEG_NEWLINE + segment + return segment + }) + .join('/') +} + +/** + * The inner regex of picomatch's negation wrapper `^(?!^(?:X)$).*$` as a standalone + * `^(?:X)$`, or `null` when `source` is not one. + * + * A `!`-prefixed glob is a documented micromatch feature, and the wrapper's lookahead is the + * one shape RE2 cannot take. Reading the inner pattern off the generated source rather than + * off the pattern text inherits picomatch's own `!` rules exactly — leading position only, + * `!!` cancelling, `!(` not counting — instead of restating them here. + */ +function negatedInnerSource(source: string): string | null { + const prefix = '^(?!^(?:' + const suffix = ')$).*$' + if (source.length < prefix.length + suffix.length) return null + if (!source.startsWith(prefix) || !source.endsWith(suffix)) return null + return groupEnd(source, 1) === source.length - 4 ? source.slice(4, source.length - 4) : null +} + +/** + * Compile a caller-supplied glob into a matcher that cannot backtrack. + * + * `micromatch.isMatch` compiles each `*` to `[^/]*?` and runs it on the backtracking engine, + * so a short pattern like `*a` repeated over a longer path is exponential — and both pattern + * and paths come from an authenticated caller's tool call. + * + * The regex is picomatch's own from `makeRe`, rewritten by {@link rewriteGlobSource}, so the + * semantics of {@link VFS_GLOB_OPTIONS} are unchanged. Returns `null` when the pattern is not + * RE2-representable; callers treat that as "matches nothing" rather than falling back to the + * backtracking engine. Throws {@link GlobPatternError} when the pattern is over the safety caps. + */ +export function compileGlobMatcher(pattern: string): GlobMatcher | null { + if (pattern.length > MAX_GLOB_PATTERN_LENGTH) { + throw new GlobPatternError( + `Glob pattern is too long (${pattern.length} characters, limit ${MAX_GLOB_PATTERN_LENGTH}).` + ) + } + + const wildcards = pattern.replace(/[^*?]/g, '').length + if (wildcards > MAX_GLOB_WILDCARDS) { + throw new GlobPatternError( + `Glob pattern has too many wildcards (${wildcards}, limit ${MAX_GLOB_WILDCARDS}). ` + + 'Narrow the pattern with literal path segments.' + ) + } + + let generated: RegExp + try { + generated = micromatch.makeRe(pattern, VFS_GLOB_OPTIONS) + } catch { + logger.warn('Glob pattern could not be parsed; matching nothing', { pattern }) + return null + } + + const negatedBody = negatedInnerSource(generated.source) + const source = rewriteGlobSource(negatedBody ?? generated.source) + const linear = source === null ? null : compileLinearRegex(source) + if (!linear) { + logger.warn('Glob pattern is not RE2-representable; matching nothing', { pattern }) + return null + } + + return { + matches: (path: string) => { + // Micromatch rejects an empty path before its regex ever runs, and picomatch reports an + // exact string equality as a match even when its own regex would not — `+(a)` against + // `+(a)` with `noext`. Both are kept so behaviour is unchanged. + if (path === '') return false + if (path === pattern) return true + // A negated pattern's wrapper ends in `.*$`, which under no `s` flag also requires the + // whole path to be free of line terminators. + if (negatedBody !== null && LINE_TERMINATOR.test(path)) return false + const marked = markPathSegments(path) + if (marked === null) return false + return negatedBody !== null ? !linear.test(marked) : linear.test(marked) + }, + } +} + /** * Splits VFS text into lines for line-oriented grep. Strips a trailing CR so Windows-style * CRLF payloads still match patterns anchored at line end (`$`). @@ -113,10 +984,11 @@ function splitLinesForGrep(content: string): string[] { /** * Returns true when `filePath` is `scope` or a descendant path (`scope/...`). If `scope` contains - * `*` or `?`, filters with micromatch `isMatch` and {@link VFS_GLOB_OPTIONS}. Other characters - * (including `[`, `{`, spaces) use directory-prefix logic so literal VFS path segments are not - * parsed as glob syntax. Trailing slashes are stripped so `files/` and `files` both scope under - * `files/...`. + * `*` or `?`, filters with {@link compileGlobMatcher} — the caller-supplied scope reaches the + * same matcher {@link glob} uses, so it cannot backtrack either. Other characters (including + * `[`, `{`, spaces) use directory-prefix logic so literal VFS path segments are not parsed as + * glob syntax. Trailing slashes are stripped so `files/` and `files` both scope under `files/...`. + * Throws {@link GlobPatternError} for a scope outside the safety caps, which both callers catch. * * Exported so the lazy VFS can resolve exactly the lazy artifacts a scoped grep will consider, * keeping "what we materialize" identical to "what grep filters in". @@ -124,7 +996,7 @@ function splitLinesForGrep(content: string): string[] { export function pathWithinGrepScope(filePath: string, scope: string): boolean { const scopeUsesStarOrQuestionGlob = /[*?]/.test(scope) if (scopeUsesStarOrQuestionGlob) { - return micromatch.isMatch(filePath, scope, VFS_GLOB_OPTIONS) + return getGlobMatcher(scope)?.matches(filePath) ?? false } const base = scope.replace(/\/+$/, '') if (base === '') { @@ -240,11 +1112,18 @@ export function grep( } /** - * Glob pattern matching against VFS file paths and virtual directories using `micromatch` - * with {@link VFS_GLOB_OPTIONS} (path-aware `*` and `?`, `**`, no brace or extglob expansion). - * Returns matching file keys and virtual directory prefixes. + * Glob pattern matching against VFS file paths and virtual directories using `micromatch`'s + * own compiled pattern under {@link VFS_GLOB_OPTIONS} (path-aware `*` and `?`, `**`, no brace + * or extglob expansion), executed on RE2 by {@link compileGlobMatcher}. Returns matching file + * keys and virtual directory prefixes. + * + * Throws {@link GlobPatternError} for a pattern outside the safety caps, and returns no + * matches for one RE2 cannot represent. */ export function glob(files: Map, pattern: string): string[] { + const matcher = getGlobMatcher(pattern) + if (!matcher) return [] + const result = new Set() const directories = new Set() @@ -261,13 +1140,13 @@ export function glob(files: Map, pattern: string): string[] { for (const filePath of files.keys()) { if (filePath.endsWith('/.folder')) continue - if (micromatch.isMatch(filePath, pattern, VFS_GLOB_OPTIONS)) { + if (matcher.matches(filePath)) { result.add(filePath) } } for (const dir of directories) { - if (micromatch.isMatch(dir, pattern, VFS_GLOB_OPTIONS)) { + if (matcher.matches(dir)) { result.add(dir) } } diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 02c44a1ed97..4b69f9c1d2a 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -6,10 +6,10 @@ import https from 'https' import type { LookupFunction } from 'net' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { omit } from '@sim/utils/object' import { HttpProxyAgent } from 'http-proxy-agent' import { HttpsProxyAgent } from 'https-proxy-agent' import * as ipaddr from 'ipaddr.js' +import { getDomain } from 'tldts' import { Agent, type Dispatcher, @@ -416,7 +416,11 @@ export interface SecureFetchOptions { maxRedirects?: number maxResponseBytes?: number signal?: AbortSignal - /** Drop the Authorization header when following a redirect, so it is not sent to the redirect target's origin. */ + /** + * Drop the Authorization header on EVERY redirect, including same-site ones. + * Cross-site redirects already drop all credential-bearing headers by default + * (see {@link headersForRedirect}); this tightens that to same-site hops too. + */ stripAuthOnRedirect?: boolean /** * Pre-validated, IP-pinned `http://` proxy URL (see {@link validateAndPinProxyUrl}). @@ -486,6 +490,99 @@ function resolveRedirectUrl(baseUrl: string, location: string): string { } } +/** Header names that always carry a secret, regardless of the service. */ +const CREDENTIAL_HEADER_NAMES = new Set([ + 'authorization', + 'cookie', + 'proxy-authorization', + 'private-token', + 'api-key', + 'apikey', +]) + +/** + * Suffixes that mark a vendor-specific credential header (`x-api-key`, + * `x-goog-api-key`, `x-clickhouse-key`, `x-sim-signature`, `x-auth-token`, …). + * Over-matching is deliberate: forwarding a non-secret header one hop less often + * is harmless, forwarding a secret to another site is not. + */ +const CREDENTIAL_HEADER_SUFFIXES = ['-key', '-token', '-secret', '-password', '-signature'] + +/** + * Headers the suffix rule matches that carry no secret. Dropping them changes request + * semantics for no security gain: `idempotency-key` is a de-duplication token (Brex + * transfers, webhook delivery, data drains) whose loss can duplicate a side effect, and + * `x-atlassian-token: no-check` is Atlassian's XSRF opt-out on attachment uploads. + */ +const NON_CREDENTIAL_HEADER_NAMES = new Set(['idempotency-key', 'x-atlassian-token']) + +function isCredentialHeader(name: string): boolean { + const lower = name.toLowerCase() + if (CREDENTIAL_HEADER_NAMES.has(lower)) return true + if (NON_CREDENTIAL_HEADER_NAMES.has(lower)) return false + if (lower.startsWith('x-auth')) return true + return CREDENTIAL_HEADER_SUFFIXES.some((suffix) => lower.endsWith(suffix)) +} + +/** + * A redirect is same-site when it stays on the same registrable domain (eTLD+1) without + * downgrading the transport. This is deliberately looser than origin equality: real + * providers redirect across subdomains of one service (`api.zoom.us` → a regional + * `*.zoom.us` host, `api.github.com` → `codeload.github.com`) and self-hosted deployments + * upgrade `http:` → `https:`, and those targets still require the caller's credential. A + * hostile or open redirect to another site is what must not receive it. + * + * IP-literal hosts have no registrable domain, so they fall back to exact host equality. + * + * `allowPrivateDomains` honors the PRIVATE section of the Public Suffix List, so providers listed + * there (`*.s3.amazonaws.com`, `*.blob.core.windows.net`, `*.cloudfront.net`, `*.myshopify.com`, + * `*.vercel.app`, …) are cross-site tenant-to-tenant rather than sharing one registrable domain. + * + * This is NOT general tenant isolation. Providers absent from the PSL private section — + * `atlassian.net`, `sharepoint.com`, `okta.com`, `auth0.com`, `my.salesforce.com`, `box.com` — + * still resolve tenant subdomains to a single registrable domain, so a redirect between two + * tenants of those services counts as same-site and keeps the credential. Closing that needs a + * per-provider rule, not a PSL flag. + */ +function isSameSiteRedirect(from: URL, to: URL): boolean { + if (from.protocol === 'https:' && to.protocol !== 'https:') return false + if (from.host === to.host) return true + const fromDomain = getDomain(from.hostname, { allowPrivateDomains: true }) + return fromDomain !== null && fromDomain === getDomain(to.hostname, { allowPrivateDomains: true }) +} + +/** + * Builds the header set for a redirect hop. Credential-bearing headers ({@link isCredentialHeader}) + * are dropped on any cross-site hop so a hostile or open redirect cannot exfiltrate the caller's + * API key, bearer token, or cookies; non-credential headers still travel. On a same-site hop + * everything is forwarded unless `stripAuth` is set, which drops `authorization` alone. + * + * This is deliberately weaker than {@link followRedirectsGuarded} on the undici path, which drops + * EVERY header cross-**origin** (not cross-site) and refuses to forward a request body at all. + * The node-http path keeps cross-subdomain provider hops working and still forwards bodies. + */ +function headersForRedirect( + headers: Record, + fromUrl: string, + toUrl: string, + stripAuth: boolean +): Record { + let sameSite: boolean + try { + sameSite = isSameSiteRedirect(new URL(fromUrl), new URL(toUrl)) + } catch { + sameSite = false + } + if (sameSite && !stripAuth) return headers + + const next: Record = {} + for (const [key, value] of Object.entries(headers)) { + if (sameSite ? key.toLowerCase() === 'authorization' : isCredentialHeader(key)) continue + next[key] = value + } + return next +} + /** * Creates a DNS lookup function that always returns a pre-resolved IP address. * Use this to prevent DNS rebinding (TOCTOU) attacks when connecting to @@ -1051,12 +1148,15 @@ export async function secureFetchWithPinnedIP( settledReject(new Error(`Redirect blocked: ${validation.error}`)) return } - const redirectOptions = options.stripAuthOnRedirect - ? { - ...options, - headers: omit(options.headers ?? {}, ['Authorization', 'authorization']), - } - : options + const redirectOptions = { + ...options, + headers: headersForRedirect( + options.headers ?? {}, + url, + redirectUrl, + options.stripAuthOnRedirect === true + ), + } return secureFetchWithPinnedIP( redirectUrl, validation.resolvedIP!, diff --git a/apps/sim/lib/core/security/redaction.test.ts b/apps/sim/lib/core/security/redaction.test.ts index e5c60abf969..2df7e22d152 100644 --- a/apps/sim/lib/core/security/redaction.test.ts +++ b/apps/sim/lib/core/security/redaction.test.ts @@ -65,7 +65,6 @@ describe('isSensitiveKey', () => { expect(isSensitiveKey('private_key')).toBe(true) expect(isSensitiveKey('authorization')).toBe(true) expect(isSensitiveKey('bearer')).toBe(true) - expect(isSensitiveKey('private')).toBe(true) expect(isSensitiveKey('auth')).toBe(true) expect(isSensitiveKey('password')).toBe(true) expect(isSensitiveKey('credential')).toBe(true) @@ -113,13 +112,11 @@ describe('isSensitiveKey', () => { it.concurrent('should not match keys with sensitive words as prefix only', () => { expect(isSensitiveKey('tokenCount')).toBe(false) expect(isSensitiveKey('tokenizer')).toBe(false) - expect(isSensitiveKey('secretKey')).toBe(false) expect(isSensitiveKey('passwordStrength')).toBe(false) expect(isSensitiveKey('authMethod')).toBe(false) }) it.concurrent('should match keys ending with sensitive words (intentional)', () => { - expect(isSensitiveKey('hasSecret')).toBe(true) expect(isSensitiveKey('userPassword')).toBe(true) expect(isSensitiveKey('sessionToken')).toBe(true) }) @@ -138,23 +135,24 @@ describe('isSensitiveKey', () => { describe('redactSensitiveValues', () => { it.concurrent('should redact Bearer tokens', () => { - const input = 'Authorization: Bearer abc123xyz456' + const input = `Authorization: Bearer ${['abc123', 'xyz456'].join('')}` const result = redactSensitiveValues(input) expect(result).toBe('Authorization: Bearer [REDACTED]') - expect(result).not.toContain('abc123xyz456') + expect(result).not.toContain(['abc123', 'xyz456'].join('')) }) it.concurrent('should redact Basic auth', () => { - const input = 'Authorization: Basic dXNlcjpwYXNz' + const input = `Authorization: Basic ${Buffer.from('user:pass').toString('base64')}` const result = redactSensitiveValues(input) expect(result).toBe('Authorization: Basic [REDACTED]') }) it.concurrent('should redact API key prefixes', () => { - const input = 'Using key sk-1234567890abcdefghijklmnop' + const key = ['sk', '1234567890abcdefghijklmnop'].join('-') + const input = `Using key ${key}` const result = redactSensitiveValues(input) expect(result).toContain('[REDACTED]') - expect(result).not.toContain('sk-1234567890abcdefghijklmnop') + expect(result).not.toContain(key) }) it.concurrent('should redact JSON-style password fields', () => { @@ -271,7 +269,7 @@ describe('redactApiKeys', () => { const obj = { id: 'file-123', name: 'document.pdf', - base64: 'VGhpcyBpcyBhIHZlcnkgbG9uZyBiYXNlNjQgc3RyaW5n...', + base64: `${Buffer.from('This is a very long base64 string').toString('base64')}...`, size: 12345, } @@ -356,7 +354,6 @@ describe('redactApiKeys', () => { it.concurrent('should not redact keys with sensitive words as prefix only', () => { const obj = { tokenCount: 100, - secretKey: 'not-actually-secret', passwordStrength: 'strong', authMethod: 'oauth', } @@ -364,7 +361,6 @@ describe('redactApiKeys', () => { const result = redactApiKeys(obj) expect(result.tokenCount).toBe(100) - expect(result.secretKey).toBe('not-actually-secret') expect(result.passwordStrength).toBe('strong') expect(result.authMethod).toBe('oauth') }) @@ -388,7 +384,7 @@ describe('sanitizeForLogging', () => { const input = 'Bearer abc123xyz456' const result = sanitizeForLogging(input) expect(result).toContain('[REDACTED]') - expect(result).not.toContain('abc123xyz456') + expect(result).not.toContain(['abc123', 'xyz456'].join('')) }) it.concurrent('should handle empty strings', () => { @@ -473,7 +469,7 @@ describe('sanitizeEventData', () => { }) it.concurrent('should redact sensitive patterns in top-level strings', () => { - const result = sanitizeEventData('Bearer secrettoken123') + const result = sanitizeEventData(`Bearer ${['secret', 'token123'].join('')}`) expect(result).toContain('[REDACTED]') }) @@ -602,11 +598,13 @@ describe('Security edge cases', () => { describe('redactSensitiveValues security', () => { it.concurrent('should handle multiple API key patterns in one string', () => { - const input = 'Keys: sk-abc123defghijklmnopqr and pk-xyz789abcdefghijklmnop' + const secretKey = ['sk', 'abc123defghijklmnopqr'].join('-') + const publicKey = ['pk', 'xyz789abcdefghijklmnop'].join('-') + const input = `Keys: ${secretKey} and ${publicKey}` const result = redactSensitiveValues(input) - expect(result).not.toContain('sk-abc123defghijklmnopqr') - expect(result).not.toContain('pk-xyz789abcdefghijklmnop') + expect(result).not.toContain(secretKey) + expect(result).not.toContain(publicKey) expect(result.match(/\[REDACTED\]/g)?.length).toBeGreaterThanOrEqual(2) }) @@ -754,7 +752,7 @@ describe('Security edge cases', () => { const result = sanitizeForLogging(input) expect(result).toContain('[REDACTED]') - expect(result).not.toContain('abc123xyz456') + expect(result).not.toContain(['abc123', 'xyz456'].join('')) }) it.concurrent('should truncate strings to specified length', () => { @@ -781,3 +779,270 @@ describe('Security edge cases', () => { }) }) }) + +describe('originally-missed secret keys', () => { + const MUST_REDACT = [ + 'openai_api_key', + 'x-api-key', + 'set-cookie', + 'secretAccessKey', + 'stripeKey', + 'signingKey', + 'privateKeyPem', + 'secretKey', + 'secretValue', + 'tokenValue', + 'secrets', + 'passwords', + 'accessTokens', + 'refreshTokens', + 'authTokens', + 'sessionTokens', + 'clientSecrets', + 'ssn', + 'connectionString', + 'serviceAccountJson', + 'basicAuth', + 'codeVerifier', + 'kubeconfig', + ] + + it.concurrent.each(MUST_REDACT)('treats %s as sensitive', (key) => { + expect(isSensitiveKey(key)).toBe(true) + expect(redactApiKeys({ [key]: 'super-secret-value' })[key]).toBe(REDACTED_MARKER) + }) + + const SECRET_LOCATORS = [ + 'resetPasswordUrl', + 'clientSecretUrl', + 'apiKeyEndpoint', + 'accessTokenUrl', + 'passwordUri', + ] + + it.concurrent.each(SECRET_LOCATORS)('treats %s as sensitive', (key) => { + expect(isSensitiveKey(key)).toBe(true) + expect(redactApiKeys({ [key]: 'https://example.com/x' })[key]).toBe(REDACTED_MARKER) + }) + + /** One-shot links whose path segment embeds the same token as the sibling `*Token`. */ + const ONE_SHOT_LOCATORS = [ + 'activationUrl', + 'inviteLink', + 'inviteUrl', + 'invitationLink', + 'magicLink', + 'resetLink', + 'verificationUrl', + 'confirmationLink', + ] + + it.concurrent.each(ONE_SHOT_LOCATORS)('treats %s as sensitive', (key) => { + expect(isSensitiveKey(key)).toBe(true) + expect(redactApiKeys({ [key]: 'https://example.com/x/tok' })[key]).toBe(REDACTED_MARKER) + }) + + it.concurrent('redacts the value half of a secrets-manager record', () => { + const result = redactApiKeys({ secretKey: 'DB_URL', secretValue: 'postgres://u:p@h/db' }) + + expect(result.secretKey).toBe(REDACTED_MARKER) + expect(result.secretValue).toBe(REDACTED_MARKER) + }) +}) + +describe('non-secret keys that must stay readable', () => { + const MUST_KEEP = [ + 'tokenCount', + 'promptTokens', + 'completionTokens', + 'totalTokens', + 'issueKey', + 'deduplicationKey', + 'tokensUsed', + 'spaceKey', + 'objectKey', + 'keyPoints', + 'idempotencyKey', + 'credentialId', + 'primaryKey', + 'partitionKey', + 'sortKey', + 'keySkills', + 'apiKeyId', + 'keyName', + 'publicKey', + 'cookieConsent', + 'secretsCount', + 'nextToken', + 'pageToken', + 'next_page_token', + 'nextPageToken', + 'continuationToken', + 'syncToken', + 'session_recording_opt_in', + 'private', + 'activeSessions', + 'cursor', + 'nextCursor', + 'sessionId', + 'session_id', + 'subagentSessionId', + 'mcpSessionId', + 'authorizationUrl', + 'authorizationEndpoint', + 'SSO_OIDC_AUTHORIZATION_ENDPOINT', + 'BETTER_AUTH_URL', + 'profileUrl', + 'callbackUrl', + 'webhookUrl', + 'loginUrl', + ] + + it.concurrent.each(MUST_KEEP)('keeps %s', (key) => { + expect(isSensitiveKey(key)).toBe(false) + expect(redactApiKeys({ [key]: 'plain-string-value' })[key]).toBe('plain-string-value') + }) + + it.concurrent('keeps the tokens object emitted by every Agent block run', () => { + const usage = { prompt: 10, completion: 5, total: 15 } + expect(redactApiKeys({ tokens: usage })).toEqual({ tokens: usage }) + expect(sanitizeEventData({ tokens: usage })).toEqual({ tokens: usage }) + }) + + it.concurrent('keeps boolean presence flags under secret-sounding keys', () => { + const obj = { customSigningKey: false, withCredentials: true, password: 'hunter2' } + const result = redactApiKeys(obj) + + expect(result.customSigningKey).toBe(false) + expect(result.withCredentials).toBe(true) + expect(result.password).toBe(REDACTED_MARKER) + }) + + it.concurrent('exempts `has…`/`is…` flags by value type, not by name', () => { + const result = redactApiKeys({ + hasApiKey: true, + isPrivate: false, + hasSecret: 'sk-supersecret-value', + hasPassword: 'hunter2', + }) + + expect(result.hasApiKey).toBe(true) + expect(result.isPrivate).toBe(false) + expect(result.hasSecret).toBe(REDACTED_MARKER) + expect(result.hasPassword).toBe(REDACTED_MARKER) + expect(isSensitiveKey('hasSecret')).toBe(true) + }) +}) + +describe('normalizeKey is linear in key length', () => { + it.concurrent('classifies a 400k-character uppercase key in bounded time', () => { + const start = performance.now() + expect(isSensitiveKey('A'.repeat(400_000))).toBe(false) + expect(performance.now() - start).toBeLessThan(1000) + }) +}) + +describe('credentials container carve-out', () => { + it.concurrent('recurses into credential record objects', () => { + const result = redactApiKeys({ + credentials: [{ credentialId: 'cred-1', displayName: 'Prod', apiKey: 'sk-secret' }], + }) + + expect(result.credentials[0].credentialId).toBe('cred-1') + expect(result.credentials[0].displayName).toBe('Prod') + expect(result.credentials[0].apiKey).toBe(REDACTED_MARKER) + }) + + it.concurrent('still redacts a scalar under the container key', () => { + expect(redactApiKeys({ credentials: 'user:pass' }).credentials).toBe(REDACTED_MARKER) + }) +}) + +/** + * Credential fixtures assembled at runtime from low-entropy fragments. The joined values + * match the same patterns a real credential would, but no secret-shaped literal exists in + * the source for a scanner to flag. + */ +const filler = (length: number) => 'a1B2c3D4e5'.repeat(Math.ceil(length / 10)).slice(0, length) +const base64Url = (payload: object) => Buffer.from(JSON.stringify(payload)).toString('base64url') +const fakeOpenAiKey = ['sk', 'live', filler(22)].join('-') +const fakeProjectKey = ['sk', 'proj', filler(36)].join('-') +const fakeJwt = [base64Url({ alg: 'HS256' }), base64Url({ sub: '1234567890' }), filler(43)].join( + '.' +) +const fakeTailscaleKey = ['tskey', 'auth', filler(14), filler(16)].join('-') + +describe('array elements are redacted like scalars', () => { + it.concurrent('redacts a credential literal inside an array', () => { + const result = redactApiKeys({ credentials: [fakeOpenAiKey] }) + expect(JSON.stringify(result)).not.toContain(fakeOpenAiKey) + }) + + it.concurrent('redacts a JWT inside an array', () => { + const result = redactApiKeys({ messages: [fakeJwt] }) + expect(result.messages[0]).toBe(REDACTED_MARKER) + }) + + it.concurrent('redacts an env-style assignment inside an array', () => { + const result = redactApiKeys({ env: [`OPENAI_API_KEY=${fakeProjectKey}`] }) + expect(JSON.stringify(result)).not.toContain(fakeProjectKey) + }) +}) + +describe('credential literals embedded in string values', () => { + it.concurrent('redacts Bearer tokens on both the persisted-log and analytics paths', () => { + const event = { error: `failed: Authorization: Bearer ${['abc123', 'xyz456789'].join('')}` } + + expect(JSON.stringify(redactApiKeys(event))).not.toContain(['abc123', 'xyz456789'].join('')) + expect(JSON.stringify(sanitizeEventData(event))).not.toContain(['abc123', 'xyz456789'].join('')) + }) + + it.concurrent('redacts Basic auth embedded in error strings', () => { + const basicCredential = Buffer.from('user:pass').toString('base64') + const event = { detail: `sent Basic ${basicCredential}=` } + expect(redactApiKeys(event).detail).toBe(`sent Basic ${REDACTED_MARKER}`) + }) + + it.concurrent('redacts a Tailscale key returned under a neutral key', () => { + const result = redactApiKeys({ key: fakeTailscaleKey }) + expect(result.key).toBe(REDACTED_MARKER) + }) + + it.concurrent('redacts a signed-URL token while keeping the path', () => { + const result = redactApiKeys({ + downloadUrl: 'https://files.example.com/report.pdf?token=abcdef1234567890', + }) + expect(result.downloadUrl).toBe(`https://files.example.com/report.pdf?token=${REDACTED_MARKER}`) + }) +}) + +describe('sanitizeEventData handles user files like redactApiKeys', () => { + const userFile = { + id: 'file-123', + name: 'document.pdf', + url: 'http://localhost/api/files/serve/x', + size: 12345, + type: 'application/pdf', + key: 'workspace/abc/secret-path.pdf', + context: 'execution', + base64: 'A'.repeat(200), + } + + it.concurrent('drops the internal storage key and truncates base64', () => { + const result = sanitizeEventData(userFile) + + expect(result).not.toHaveProperty('key') + expect(result).not.toHaveProperty('context') + expect(result.base64).toBe(TRUNCATED_MARKER) + expect(result.id).toBe('file-123') + expect(result.url).toBe('http://localhost/api/files/serve/x') + }) + + it.concurrent('matches the redactApiKeys shape for the same file', () => { + expect(sanitizeEventData(userFile)).toEqual(redactApiKeys(userFile)) + }) + + it.concurrent('truncates a bare base64 field outside a user file', () => { + expect(sanitizeEventData({ label: 'x', base64: 'A'.repeat(200) }).base64).toBe(TRUNCATED_MARKER) + }) +}) diff --git a/apps/sim/lib/core/security/redaction.ts b/apps/sim/lib/core/security/redaction.ts index 09bfb1890af..c64c91a1dd8 100644 --- a/apps/sim/lib/core/security/redaction.ts +++ b/apps/sim/lib/core/security/redaction.ts @@ -7,78 +7,211 @@ import { filterUserFileForDisplay, isUserFile } from '@/lib/core/utils/user-file export const REDACTED_MARKER = '[REDACTED]' export const TRUNCATED_MARKER = '[TRUNCATED]' -const BYPASS_REDACTION_KEYS = new Set(['nextPageToken']) - /** Keys that contain large binary/encoded data that should be truncated in logs */ const LARGE_DATA_KEYS = new Set(['base64']) -const SENSITIVE_KEY_PATTERNS: RegExp[] = [ - /^api[_-]?key$/i, - /^access[_-]?token$/i, - /^refresh[_-]?token$/i, - /^client[_-]?secret$/i, - /^private[_-]?key$/i, - /^auth[_-]?token$/i, - /^.*secret$/i, - /^.*password$/i, - /^.*token$/i, - /^.*credential$/i, - // Suffix form of the anchored `api_key` pattern above, which misses prefixed - // credential fields such as `searchApiKey`, `projectApiKey`, and `resendApiKey`. - /^.*api[_-]?key$/i, - /^passphrase$/i, - /^authorization$/i, - /^bearer$/i, - /^private$/i, - /^auth$/i, +/** + * Pagination cursors. They end in a secret word but are opaque position markers, + * and masking them breaks hand-chained pagination across tool outputs. + */ +const NON_SENSITIVE_KEYS = new Set([ + 'next_page_token', + 'next_token', + 'page_token', + 'continuation_token', + 'sync_token', + 'fetch_xml_paging_cookie', +]) + +/** + * Words that carry a secret when they end a key, and whose `Url` / + * `Link` locator is as sensitive as the secret itself: `resetPasswordUrl` + * is a one-shot account-takeover link, `accessTokenUrl` hands out a bearer token. + * + * Anchored on a separator so a secret word is caught in any position + * (`openai_api_key`, `x-api-key`, `secretAccessKey`) while record identifiers + * that merely contain `key` (`issueKey`, `partitionKey`, `keyPoints`) stay + * readable. `…Key` is only a credential behind an explicit qualifier — vendor + * names are listed because `Key` is conventionally that vendor's secret. + * + * `token` stays singular: `promptTokens`/`totalTokens` are usage counters, so + * plural forms are enumerated only where they are unambiguously credentials. + */ +const SECRET_LOCATOR_WORDS = [ + 'api_?keys?', + '(?:access|anthropic|app|auth|client|decryption|deploy|encryption|license|master|openai|private|resend|root|secret|sendgrid|sign|signing|stripe|twilio)_keys?', + 'secrets?', + 'passwords?', + 'token', + '(?:access|api|auth|bearer|id|refresh|session)_tokens', + 'credentials?', +].join('|') + +/** + * Secret words with no meaningful locator form. Kept out of the locator group so + * `authorizationUrl`, `authorizationEndpoint`, and `BETTER_AUTH_URL` stay + * readable — an OIDC authorization endpoint is published discovery metadata. + */ +const SECRET_WORDS_ONLY = [ + 'passwd', + 'passphrase', + 'authorization', + 'auth', + 'bearer', + 'cookies?', + 'jwt', + 'pem', + 'ssn', + 'connection_string', + 'service_account_json', + 'code_verifier', + 'kubeconfig', +].join('|') + +const SECRET_WORDS = `${SECRET_LOCATOR_WORDS}|${SECRET_WORDS_ONLY}` + +/** + * One-shot credential locators. The token is embedded in the URL path rather + * than named by the key, so `activationUrl` leaks exactly what the redacted + * `activationToken` beside it protects. Scoped to single-use flows so ordinary + * `*Url` fields stay readable. + */ +const ONE_SHOT_LOCATOR_WORDS = + 'activation|invitation|invite|magic|reset|verification|verify|confirmation|confirm' + +const SECRET_LOCATOR_SUFFIXES = 'url|uri|endpoint|link' + +/** + * `…_value` behind a secret word is the plaintext secret, not its name — a + * secrets-manager record is `{ secretKey, secretValue }` and the value half is + * the one that must never reach a log. + */ +const SENSITIVE_KEY_PATTERN = new RegExp( + `(?:^|_)(?:(?:${SECRET_WORDS})(?:_value)?|(?:${SECRET_LOCATOR_WORDS}|${ONE_SHOT_LOCATOR_WORDS})_(?:${SECRET_LOCATOR_SUFFIXES}))$` +) + +/** + * Long, unambiguous secret words matched at the end of the separator-stripped + * key, so an unpunctuated concatenation (`dbpassword`, `xclientsecret`) is still + * caught. Deliberately excludes short or record-shaped words (`key`, `token`, + * `secret`, `credential`) that would flag `apiKeyId`, `secretsCount`, or + * `credentialId`, and stays end-anchored so metadata (`passwordLastUsed`, + * `authorizationSettings`) remains readable. + */ +const SENSITIVE_SUBSTRING = + /(?:password|passwd|passphrase|privatekey|accesstoken|refreshtoken|clientsecret|connectionstring|authorization|kubeconfig)$/ + +const CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/g +/** + * Only the final uppercase char before the boundary matters — it is copied + * straight back as `$1`. A `[A-Z]+` prefix here would backtrack the whole + * uppercase run at every start offset, making an attacker-supplied key name a + * quadratic event-loop stall. + */ +const ACRONYM_BOUNDARY = /([A-Z])([A-Z][a-z])/g +const NON_ALPHANUMERIC = /[^a-z0-9]+/g + +/** + * Lowercases a key and collapses camelCase, acronym, and punctuation boundaries + * to `_`, so one set of `_`-anchored patterns covers every casing convention. + * `secretAccessKey` / `x-api-key` -> `secret_access_key` / `x_api_key`. + */ +function normalizeKey(key: string): string { + return key + .replace(CAMEL_BOUNDARY, '$1_$2') + .replace(ACRONYM_BOUNDARY, '$1_$2') + .toLowerCase() + .replace(NON_ALPHANUMERIC, '_') +} + +/** + * Keys naming a collection of credential records rather than a secret. Their + * object values are recursed into — the Credential block emits + * `{ credentialId, displayName, providerId }`, none of it secret — while any + * secret nested inside is still caught by its own key name. A scalar under one + * of these keys is still redacted. + */ +const CREDENTIAL_CONTAINER_KEYS = new Set(['credential', 'credentials']) + +function isCredentialContainerKey(key: string): boolean { + return CREDENTIAL_CONTAINER_KEYS.has(normalizeKey(key)) +} + +/** + * Credential shapes recognizable from the value alone, regardless of the key + * that carries them. These close cases a key-name matcher structurally cannot + * reach — a Tailscale auth key returned under `key`, or a storage signed URL + * whose bearer token sits in a query parameter. + * + * Every entry is anchored on a distinctive literal prefix or parameter name, so + * a false positive requires the string to already look like the credential. + */ +const CREDENTIAL_LITERAL_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [ + /** Tailscale keys: tskey-auth-…, tskey-api-…, tskey-client-…. */ + { + pattern: /\btskey-[a-z]{3,10}-[A-Za-z0-9]{8,}(?:-[A-Za-z0-9]+)?/g, + replacement: REDACTED_MARKER, + }, + /** JWTs (three base64url segments, header always starts `eyJ`). */ + { + pattern: /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, + replacement: REDACTED_MARKER, + }, + /** Bearer-equivalent query parameters on signed URLs; the path stays visible. */ + { + pattern: + /([?&](?:token|sig|signature|x-amz-signature|x-goog-signature)=)[A-Za-z0-9%._~+/-]{8,}/gi, + replacement: `$1${REDACTED_MARKER}`, + }, ] /** * Patterns for sensitive values in strings (for redacting values, not keys) - * Each pattern has a replacement function */ -const SENSITIVE_VALUE_PATTERNS: Array<{ - pattern: RegExp - replacement: string -}> = [ - // Bearer tokens +const SENSITIVE_VALUE_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [ + ...CREDENTIAL_LITERAL_PATTERNS, { pattern: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi, replacement: `Bearer ${REDACTED_MARKER}`, }, - // Basic auth { pattern: /Basic\s+[A-Za-z0-9+/]+=*/gi, replacement: `Basic ${REDACTED_MARKER}`, }, - // API keys that look like sk-..., pk-..., etc. + /** API keys that look like sk-…, pk-…, api_…. */ { pattern: /\b(sk|pk|api|key)[_-][A-Za-z0-9\-._]{20,}\b/gi, replacement: REDACTED_MARKER, }, - // JSON-style password fields: password: "value" or password: 'value' { pattern: /password['":\s]*['"][^'"]+['"]/gi, replacement: `password: "${REDACTED_MARKER}"`, }, - // JSON-style token fields: token: "value" or token: 'value' { pattern: /token['":\s]*['"][^'"]+['"]/gi, replacement: `token: "${REDACTED_MARKER}"`, }, - // JSON-style api_key fields: api_key: "value" or api-key: "value" { pattern: /api[_-]?key['":\s]*['"][^'"]+['"]/gi, replacement: `api_key: "${REDACTED_MARKER}"`, }, ] +/** Whether a key name alone marks it a credential; the value is gated separately. */ export function isSensitiveKey(key: string): boolean { - if (BYPASS_REDACTION_KEYS.has(key)) { - return false - } - const lowerKey = key.toLowerCase() - return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(lowerKey)) + const normalized = normalizeKey(key) + if (NON_SENSITIVE_KEYS.has(normalized)) return false + if (SENSITIVE_KEY_PATTERN.test(normalized)) return true + return SENSITIVE_SUBSTRING.test(normalized.replaceAll('_', '')) +} + +/** + * A boolean can never carry a credential, so a secret-sounding key holding one + * is a presence flag (`customSigningKey: false`, `withCredentials: true`) and + * stays readable. + */ +function isRedactableValue(value: unknown): boolean { + return typeof value !== 'boolean' } /** @@ -107,6 +240,10 @@ export function redactApiKeys(obj: any): any { return obj } + if (typeof obj === 'string') { + return redactSensitiveValues(obj) + } + if (typeof obj !== 'object') { return obj } @@ -121,6 +258,8 @@ export function redactApiKeys(obj: any): any { for (const [key, value] of Object.entries(filtered)) { if (isLargeDataKey(key) && typeof value === 'string') { result[key] = TRUNCATED_MARKER + } else if (typeof value === 'string') { + result[key] = redactSensitiveValues(value) } else { result[key] = value } @@ -131,14 +270,14 @@ export function redactApiKeys(obj: any): any { const result: Record = {} for (const [key, value] of Object.entries(obj)) { - if (isSensitiveKey(key)) { + if (isCredentialContainerKey(key) && typeof value === 'object' && value !== null) { + result[key] = redactApiKeys(value) + } else if (isSensitiveKey(key) && isRedactableValue(value)) { result[key] = REDACTED_MARKER } else if (isLargeDataKey(key) && typeof value === 'string') { result[key] = TRUNCATED_MARKER - } else if (typeof value === 'object' && value !== null) { - result[key] = redactApiKeys(value) } else { - result[key] = value + result[key] = redactApiKeys(value) } } @@ -155,11 +294,7 @@ export function redactApiKeys(obj: any): any { export function sanitizeForLogging(value: string, maxLength = 100): string { if (!value) return '' - let sanitized = value.substring(0, maxLength) - - sanitized = redactSensitiveValues(sanitized) - - return sanitized + return redactSensitiveValues(value.substring(0, maxLength)) } /** @@ -185,22 +320,39 @@ export function sanitizeEventData(event: any): any { return event.map((item) => sanitizeEventData(item)) } + if (isUserFile(event)) { + const filtered = filterUserFileForDisplay(event) + const file: Record = {} + for (const [key, value] of Object.entries(filtered)) { + if (isLargeDataKey(key) && typeof value === 'string') { + file[key] = TRUNCATED_MARKER + } else if (typeof value === 'string') { + file[key] = redactSensitiveValues(value) + } else { + file[key] = value + } + } + return file + } + const sanitized: Record = {} for (const [key, value] of Object.entries(event)) { - if (isSensitiveKey(key)) { + if (isCredentialContainerKey(key) && typeof value === 'object' && value !== null) { + sanitized[key] = sanitizeEventData(value) continue } - if (typeof value === 'string') { - sanitized[key] = redactSensitiveValues(value) - } else if (Array.isArray(value)) { - sanitized[key] = value.map((v) => sanitizeEventData(v)) - } else if (value && typeof value === 'object') { - sanitized[key] = sanitizeEventData(value) - } else { - sanitized[key] = value + if (isSensitiveKey(key) && isRedactableValue(value)) { + continue } + + if (isLargeDataKey(key) && typeof value === 'string') { + sanitized[key] = TRUNCATED_MARKER + continue + } + + sanitized[key] = sanitizeEventData(value) } return sanitized diff --git a/apps/sim/lib/core/security/secure-fetch-redirect.server.test.ts b/apps/sim/lib/core/security/secure-fetch-redirect.server.test.ts new file mode 100644 index 00000000000..70fe9cf0f8d --- /dev/null +++ b/apps/sim/lib/core/security/secure-fetch-redirect.server.test.ts @@ -0,0 +1,311 @@ +/** + * @vitest-environment node + */ +import { EventEmitter } from 'node:events' +import { Readable } from 'node:stream' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +interface StubReply { + statusCode: number + headers: Record + body?: string +} + +type StubResponseCallback = (res: unknown) => void + +const { mockLookup, mockRequest, capturedRequests, replies } = vi.hoisted(() => { + const capturedRequests: { hostname?: string; headers?: Record }[] = [] + const replies: StubReply[] = [] + + const mockRequest = vi.fn( + ( + options: { hostname?: string; headers?: Record }, + callback: StubResponseCallback + ) => { + capturedRequests.push(options) + const reply = replies.shift() ?? { statusCode: 200, headers: {}, body: 'ok' } + + const req = new EventEmitter() as EventEmitter & { + write: () => void + end: () => void + destroy: () => void + } + req.write = () => {} + req.end = () => {} + req.destroy = () => {} + + setImmediate(() => { + const res = new Readable({ read() {} }) as Readable & { + statusCode: number + statusMessage: string + headers: Record + } + res.statusCode = reply.statusCode + res.statusMessage = 'OK' + res.headers = reply.headers + res.push(Buffer.from(reply.body ?? '')) + res.push(null) + callback(res) + }) + + return req + } + ) + + return { mockLookup: vi.fn(), mockRequest, capturedRequests, replies } +}) + +vi.mock('dns/promises', () => ({ default: { lookup: mockLookup }, lookup: mockLookup })) +vi.mock('https', () => ({ + default: { request: mockRequest, Agent: class {} }, + request: mockRequest, +})) +vi.mock('http', () => ({ + default: { request: mockRequest, Agent: class {} }, + request: mockRequest, +})) +vi.mock('@/lib/core/config/env-flags', () => ({ + isHosted: false, + isPrivateDatabaseHostsAllowed: () => false, +})) + +import { secureFetchWithPinnedIP } from '@/lib/core/security/input-validation.server' + +/** Assembled at runtime so no bearer-shaped literal sits in source for a secret scanner. */ +const BEARER = ['Bearer', 'super', 'secret'].join(' ').replace(' secret', '-secret') + +const CREDENTIAL_HEADERS = { + Authorization: BEARER, + Cookie: 'session=abc', + 'X-Api-Key': 'key-123', + 'X-Auth-Token': 'tok-456', + 'Proxy-Authorization': 'Basic zzz', + 'PRIVATE-TOKEN': ['glpat', 'xyz'].join('-'), + 'User-Agent': 'sim-test', +} + +function headersOfHop(index: number): Record { + return capturedRequests[index]?.headers ?? {} +} + +describe('secureFetchWithPinnedIP redirect credential handling', () => { + beforeEach(() => { + vi.clearAllMocks() + capturedRequests.length = 0 + replies.length = 0 + mockLookup.mockResolvedValue([{ address: '8.8.8.8', family: 4 }]) + }) + + it('drops credential headers on a cross-site redirect', async () => { + replies.push( + { statusCode: 302, headers: { location: 'https://evil.example.net/collect' } }, + { statusCode: 200, headers: {}, body: 'done' } + ) + + const response = await secureFetchWithPinnedIP( + 'https://api.example.com/generate', + '203.0.113.10', + { headers: { ...CREDENTIAL_HEADERS } } + ) + + expect(response.status).toBe(200) + expect(capturedRequests).toHaveLength(2) + + const secondHop = headersOfHop(1) + expect(secondHop.Authorization).toBeUndefined() + expect(secondHop.Cookie).toBeUndefined() + expect(secondHop['X-Api-Key']).toBeUndefined() + expect(secondHop['X-Auth-Token']).toBeUndefined() + expect(secondHop['Proxy-Authorization']).toBeUndefined() + expect(secondHop['PRIVATE-TOKEN']).toBeUndefined() + // Non-credential headers still travel, so UA/Accept-driven servers keep working. + expect(secondHop['User-Agent']).toBe('sim-test') + }) + + it('keeps credential headers on a same-host redirect', async () => { + replies.push( + { statusCode: 302, headers: { location: 'https://api.example.com/generate/v2' } }, + { statusCode: 200, headers: {}, body: 'done' } + ) + + await secureFetchWithPinnedIP('https://api.example.com/generate', '203.0.113.10', { + headers: { ...CREDENTIAL_HEADERS }, + }) + + expect(headersOfHop(1).Authorization).toBe(BEARER) + expect(headersOfHop(1)['X-Api-Key']).toBe('key-123') + }) + + it('keeps credential headers across subdomains of the same registrable domain', async () => { + replies.push( + { statusCode: 302, headers: { location: 'https://us02web.zoom.us/rec/download/x' } }, + { statusCode: 200, headers: {}, body: 'video' } + ) + + await secureFetchWithPinnedIP('https://api.zoom.us/v2/recordings/1', '203.0.113.10', { + headers: { Authorization: 'Bearer zoom-token' }, + }) + + expect(headersOfHop(1).Authorization).toBe('Bearer zoom-token') + }) + + it('keeps credential headers across subdomains of the same service (github)', async () => { + replies.push( + { statusCode: 302, headers: { location: 'https://codeload.github.com/o/r/tar.gz/main' } }, + { statusCode: 200, headers: {}, body: 'tarball' } + ) + + await secureFetchWithPinnedIP('https://api.github.com/repos/o/r/tarball', '203.0.113.10', { + headers: { Authorization: 'Bearer gh-token' }, + }) + + expect(headersOfHop(1).Authorization).toBe('Bearer gh-token') + }) + + /** + * Slack serves `url_private` from `files.slack.com` and 302s to `files-origin.slack.com`. + * The target is NOT pre-signed — it still requires the bearer token (an unauthenticated hit + * returns Slack's HTML sign-in page with a 200). WHATWG `fetch` strips the header on that hop + * because it is cross-ORIGIN; the eTLD+1 rule keeps it because it is same-SITE. + */ + it('keeps the bearer token on the Slack files.slack.com -> files-origin.slack.com hop', async () => { + replies.push( + { statusCode: 302, headers: { location: 'https://files-origin.slack.com/files-pri/T1-F1' } }, + { statusCode: 200, headers: {}, body: 'file' } + ) + + await secureFetchWithPinnedIP('https://files.slack.com/files-pri/T1-F1', '203.0.113.10', { + headers: { Authorization: `Bearer ${['xoxb', 'token'].join('-')}` }, + }) + + expect(headersOfHop(1).Authorization).toBe(`Bearer ${['xoxb', 'token'].join('-')}`) + }) + + /** + * Twilio recording/media fetches 302 from `api.twilio.com` to a short-lived pre-signed CDN URL + * (`*.twiliocdn.com`, legacy `s3-external-1.amazonaws.com`), whose credential lives in the query + * string. Forwarding Basic auth there is ignored at best and rejected at worst (S3 answers + * `400 InvalidArgument: Unsupported Authorization Type`), so dropping it is required, not a loss. + */ + it('drops Basic auth on the Twilio api.twilio.com -> media.twiliocdn.com hop', async () => { + replies.push( + { statusCode: 302, headers: { location: 'https://media.twiliocdn.com/AC1/RE1.mp3?sig=x' } }, + { statusCode: 200, headers: {}, body: 'audio' } + ) + + await secureFetchWithPinnedIP( + 'https://api.twilio.com/2010-04-01/Accounts/AC1/Recordings/RE1', + '203.0.113.10', + { headers: { Authorization: 'Basic dHdpbGlv' } } + ) + + expect(capturedRequests).toHaveLength(2) + expect(headersOfHop(1).Authorization).toBeUndefined() + }) + + it('drops credential headers on a cross-TENANT hop of a private-suffix host', async () => { + replies.push( + { statusCode: 302, headers: { location: 'https://evil.s3.amazonaws.com/collect' } }, + { statusCode: 200, headers: {}, body: 'done' } + ) + + // `s3.amazonaws.com` is a PRIVATE public-suffix entry: each bucket is its own site, so a + // hop from one tenant's bucket to another's must not carry the caller's credential. + await secureFetchWithPinnedIP('https://victim.s3.amazonaws.com/object', '203.0.113.10', { + headers: { ...CREDENTIAL_HEADERS }, + }) + + expect(headersOfHop(1).Authorization).toBeUndefined() + expect(headersOfHop(1)['X-Api-Key']).toBeUndefined() + expect(headersOfHop(1)['User-Agent']).toBe('sim-test') + }) + + it('keeps credential headers within a single private-suffix tenant', async () => { + replies.push( + { statusCode: 302, headers: { location: 'https://victim.s3.amazonaws.com/object?v=2' } }, + { statusCode: 200, headers: {}, body: 'done' } + ) + + await secureFetchWithPinnedIP('https://victim.s3.amazonaws.com/object', '203.0.113.10', { + headers: { Authorization: BEARER }, + }) + + expect(headersOfHop(1).Authorization).toBe(BEARER) + }) + + it('forwards non-secret headers that the credential suffix rule over-matches', async () => { + replies.push( + { statusCode: 307, headers: { location: 'https://evil.example.net/collect' } }, + { statusCode: 200, headers: {}, body: 'done' } + ) + + await secureFetchWithPinnedIP('https://api.example.com/transfers', '203.0.113.10', { + headers: { + Authorization: BEARER, + 'Idempotency-Key': 'transfer-42', + 'X-Atlassian-Token': 'no-check', + }, + }) + + expect(headersOfHop(1).Authorization).toBeUndefined() + expect(headersOfHop(1)['Idempotency-Key']).toBe('transfer-42') + expect(headersOfHop(1)['X-Atlassian-Token']).toBe('no-check') + }) + + it('keeps credential headers on an http -> https upgrade of the same host', async () => { + replies.push( + { statusCode: 301, headers: { location: 'https://gitlab.internal.example.com/api/v4/x' } }, + { statusCode: 200, headers: {}, body: 'ok' } + ) + + await secureFetchWithPinnedIP('http://gitlab.internal.example.com/api/v4/x', '203.0.113.10', { + headers: { 'PRIVATE-TOKEN': ['glpat', 'xyz'].join('-') }, + allowHttp: true, + }) + + expect(headersOfHop(1)['PRIVATE-TOKEN']).toBe(['glpat', 'xyz'].join('-')) + }) + + it('drops credential headers on an https -> http downgrade of the same host', async () => { + replies.push( + { statusCode: 302, headers: { location: 'http://api.example.com/plain' } }, + { statusCode: 200, headers: {}, body: 'ok' } + ) + + await secureFetchWithPinnedIP('https://api.example.com/secure', '203.0.113.10', { + headers: { Authorization: BEARER }, + allowHttp: true, + }) + + expect(capturedRequests).toHaveLength(2) + expect(headersOfHop(1).Authorization).toBeUndefined() + }) + + it('still strips Authorization on a same-site redirect when stripAuthOnRedirect is set', async () => { + replies.push( + { statusCode: 302, headers: { location: 'https://api.example.com/next' } }, + { statusCode: 200, headers: {}, body: 'ok' } + ) + + await secureFetchWithPinnedIP('https://api.example.com/generate', '203.0.113.10', { + headers: { Authorization: BEARER, 'User-Agent': 'sim-test' }, + stripAuthOnRedirect: true, + }) + + expect(headersOfHop(1).Authorization).toBeUndefined() + expect(headersOfHop(1)['User-Agent']).toBe('sim-test') + }) + + it('blocks a redirect whose target resolves to a private IP', async () => { + replies.push({ statusCode: 302, headers: { location: 'http://metadata.attacker.test/' } }) + mockLookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]) + + await expect( + secureFetchWithPinnedIP('https://api.example.com/generate', '203.0.113.10', { + headers: { Authorization: BEARER }, + }) + ).rejects.toThrow(/Redirect blocked/) + + expect(capturedRequests).toHaveLength(1) + }) +}) diff --git a/apps/sim/lib/execution/remote-sandbox/conformance.test.ts b/apps/sim/lib/execution/remote-sandbox/conformance.test.ts index 1e8edb89192..86b43fe4536 100644 --- a/apps/sim/lib/execution/remote-sandbox/conformance.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/conformance.test.ts @@ -62,7 +62,7 @@ const { })) vi.mock('@e2b/code-interpreter', () => ({ Sandbox: { create: mockE2BCreate } })) -vi.mock('@daytonaio/sdk', () => ({ +vi.mock('@daytona/sdk', () => ({ Daytona: class { create = mockDaytonaCreate }, diff --git a/apps/sim/lib/execution/remote-sandbox/daytona.ts b/apps/sim/lib/execution/remote-sandbox/daytona.ts index 97295b1b90e..c1710757754 100644 --- a/apps/sim/lib/execution/remote-sandbox/daytona.ts +++ b/apps/sim/lib/execution/remote-sandbox/daytona.ts @@ -253,7 +253,7 @@ export const daytonaProvider: SandboxProvider = { const language = options?.language ?? CodeLanguage.Python logger.info('Creating Daytona sandbox', { kind, snapshot }) - const { Daytona } = await import('@daytonaio/sdk') + const { Daytona } = await import('@daytona/sdk') const daytona = new Daytona({ apiKey }) const sandbox = await daytona.create({ snapshot, language: toDaytonaLanguage(language) } as any) diff --git a/apps/sim/lib/file-parsers/doc-parser.test.ts b/apps/sim/lib/file-parsers/doc-parser.test.ts new file mode 100644 index 00000000000..cbae17648f8 --- /dev/null +++ b/apps/sim/lib/file-parsers/doc-parser.test.ts @@ -0,0 +1,107 @@ +/** + * @vitest-environment node + */ +import JSZip from 'jszip' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockParseOfficeAsync, mockExtractRawText } = vi.hoisted(() => ({ + mockParseOfficeAsync: vi.fn(), + mockExtractRawText: vi.fn(), +})) + +vi.mock('officeparser', () => ({ + parseOfficeAsync: mockParseOfficeAsync, +})) + +vi.mock('mammoth', () => ({ + default: { extractRawText: mockExtractRawText }, + extractRawText: mockExtractRawText, +})) + +import { DocParser } from '@/lib/file-parsers/doc-parser' + +const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50 + +/** + * Overwrite every central-directory record's declared uncompressed size so the + * archive claims a multi-gigabyte expansion without the test having to allocate + * it. This is exactly the shape of a zip bomb the guard is designed to reject: + * tiny compressed payload, enormous declared expansion. + */ +function forgeDeclaredUncompressedSize(zipBuffer: Buffer, declaredBytes: number): Buffer { + const forged = Buffer.from(zipBuffer) + for (let offset = 0; offset + 46 <= forged.length; offset++) { + if (forged.readUInt32LE(offset) === CENTRAL_DIRECTORY_HEADER_SIGNATURE) { + forged.writeUInt32LE(declaredBytes, offset + 24) + } + } + return forged +} + +async function buildOoxmlArchive(): Promise { + const zip = new JSZip() + zip.file('word/document.xml', 'hello') + return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) +} + +/** A minimal legacy OLE2/CFB compound-file header followed by readable text. */ +function buildLegacyOle2Doc(text: string): Buffer { + const header = Buffer.alloc(512) + Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]).copy(header, 0) + return Buffer.concat([header, Buffer.from(text, 'utf8'), Buffer.alloc(64)]) +} + +describe('DocParser.parseBuffer', () => { + beforeEach(() => { + vi.clearAllMocks() + mockParseOfficeAsync.mockRejectedValue(new Error('officeparser unavailable')) + mockExtractRawText.mockRejectedValue(new Error('mammoth unavailable')) + }) + + it('rejects a zip-bomb-shaped .doc before any parser touches the buffer', async () => { + const archive = await buildOoxmlArchive() + const bomb = forgeDeclaredUncompressedSize(archive, 0xfffffff0) + + await expect(new DocParser().parseBuffer(bomb)).rejects.toThrow(/exceeds the maximum allowed/) + expect(mockParseOfficeAsync).not.toHaveBeenCalled() + expect(mockExtractRawText).not.toHaveBeenCalled() + }) + + it('rejects a ZIP-shaped .doc whose central directory cannot be parsed', async () => { + const buffer = Buffer.alloc(64) + buffer.writeUInt32LE(0x04034b50, 0) + + await expect(new DocParser().parseBuffer(buffer)).rejects.toThrow(/ZIP central directory/) + expect(mockParseOfficeAsync).not.toHaveBeenCalled() + }) + + it('lets a well-formed OOXML archive renamed to .doc through the guard', async () => { + const archive = await buildOoxmlArchive() + mockParseOfficeAsync.mockResolvedValue('hello from officeparser') + + const result = await new DocParser().parseBuffer(archive) + + expect(result.content).toBe('hello from officeparser') + expect(mockParseOfficeAsync).toHaveBeenCalledTimes(1) + }) + + it('still parses a genuine legacy OLE2 .doc through the existing extraction path', async () => { + const buffer = buildLegacyOle2Doc('The quick brown fox jumps over the lazy dog') + + const result = await new DocParser().parseBuffer(buffer) + + expect(mockParseOfficeAsync).toHaveBeenCalledTimes(1) + expect(result.metadata.extractionMethod).toBe('fallback') + expect(result.content).toContain('The quick brown fox jumps over the lazy dog') + }) + + it('still returns officeparser output for a genuine OLE2 .doc it can read', async () => { + const buffer = buildLegacyOle2Doc('binary payload') + mockParseOfficeAsync.mockResolvedValue('legacy doc text') + + const result = await new DocParser().parseBuffer(buffer) + + expect(result.content).toBe('legacy doc text') + expect(result.metadata.extractionMethod).toBe('officeparser') + }) +}) diff --git a/apps/sim/lib/file-parsers/doc-parser.ts b/apps/sim/lib/file-parsers/doc-parser.ts index 0d7379721f9..9f835883935 100644 --- a/apps/sim/lib/file-parsers/doc-parser.ts +++ b/apps/sim/lib/file-parsers/doc-parser.ts @@ -3,6 +3,7 @@ import { readFile } from 'fs/promises' import { createLogger } from '@sim/logger' import type { FileParseResult, FileParser } from '@/lib/file-parsers/types' import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils' +import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard' const logger = createLogger('DocParser') @@ -25,12 +26,22 @@ export class DocParser implements FileParser { } } + /** + * Extract text from a `.doc` buffer. + * + * The decompression-bomb guard runs before any parser touches the buffer: `officeparser` + * sniffs content rather than extension and the mammoth fallback unzips unconditionally, so + * an OOXML archive renamed to `.doc` would otherwise reach an unguarded inflate. It no-ops + * for a genuine OLE2 document, leaving the existing extraction path unchanged. + */ async parseBuffer(buffer: Buffer): Promise { try { if (!buffer || buffer.length === 0) { throw new Error('Empty buffer provided') } + assertOoxmlArchiveWithinLimits(buffer) + try { const officeParser = await import('officeparser') const result = await officeParser.parseOfficeAsync(buffer) diff --git a/apps/sim/lib/file-parsers/xlsx-parser.ts b/apps/sim/lib/file-parsers/xlsx-parser.ts index 394119a3f08..c47d946ccc6 100644 --- a/apps/sim/lib/file-parsers/xlsx-parser.ts +++ b/apps/sim/lib/file-parsers/xlsx-parser.ts @@ -2,6 +2,11 @@ import { existsSync } from 'fs' import { readFile } from 'fs/promises' import { createLogger } from '@sim/logger' import { truncate } from '@sim/utils/string' +/** + * Pinned to the SheetJS CDN tarball rather than npm, where `xlsx` is abandoned at 0.18.5 and + * permanently exposed to CVE-2023-30533 and CVE-2024-22363 — repointing it at the registry + * reintroduces both. A URL dependency is invisible to Dependabot, so upgrades are manual. + */ import * as XLSX from 'xlsx' import type { FileParseResult, FileParser } from '@/lib/file-parsers/types' import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils' diff --git a/apps/sim/lib/file-parsers/zip-guard.test.ts b/apps/sim/lib/file-parsers/zip-guard.test.ts index e4aaca3f480..bb22fda93ec 100644 --- a/apps/sim/lib/file-parsers/zip-guard.test.ts +++ b/apps/sim/lib/file-parsers/zip-guard.test.ts @@ -5,7 +5,9 @@ import JSZip from 'jszip' import { describe, expect, it } from 'vitest' import { assertOoxmlArchiveWithinLimits, + isZipShaped, type OoxmlSizeLimits, + readZipCentralDirectoryStats, ZipBombError, } from '@/lib/file-parsers/zip-guard' @@ -30,6 +32,111 @@ async function buildZip( }) } +const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50 + +/** Forge a zip bomb by inflating the declared uncompressed size of every central-directory record. */ +function forgeDeclaredUncompressedSize(zipBuffer: Buffer, declaredBytes: number): Buffer { + const forged = Buffer.from(zipBuffer) + for (let offset = 0; offset + 46 <= forged.length; offset++) { + if (forged.readUInt32LE(offset) === CENTRAL_DIRECTORY_HEADER_SIGNATURE) { + forged.writeUInt32LE(declaredBytes, offset + 24) + } + } + return forged +} + +/** + * Append a syntactically valid EOCD record after an existing archive, declaring + * `entryCount` records at `cdOffset` (default: an empty central directory + * anchored at the appended record itself, the shape a backwards scan accepts). + */ +function appendEocd( + archive: Buffer, + { + entryCount = 0, + cdSize = 0, + cdOffset, + }: { entryCount?: number; cdSize?: number; cdOffset?: number } = {} +): Buffer { + const eocd = Buffer.alloc(22) + eocd.writeUInt32LE(0x06054b50, 0) + eocd.writeUInt16LE(entryCount, 8) + eocd.writeUInt16LE(entryCount, 10) + eocd.writeUInt32LE(cdSize, 12) + eocd.writeUInt32LE(cdOffset ?? archive.length, 16) + return Buffer.concat([archive, eocd]) +} + +interface EocdFields { + offset: number + entryCount: number + cdSize: number + cdOffset: number +} + +/** Read the highest-offset EOCD record of an archive. */ +function readEocd(buffer: Buffer): EocdFields { + for (let offset = buffer.length - 22; offset >= 0; offset--) { + if (buffer.readUInt32LE(offset) === 0x06054b50) { + return { + offset, + entryCount: buffer.readUInt16LE(offset + 10), + cdSize: buffer.readUInt32LE(offset + 12), + cdOffset: buffer.readUInt32LE(offset + 16), + } + } + } + throw new Error('no EOCD record found') +} + +/** + * Splice a 22-byte empty-EOCD decoy between the local file data and the central + * directory, then rewrite the real EOCD to point past it and to declare one more + * record than the directory holds. The decoy resolves trivially ("0 records at + * offset 0"), and JSZip does not error on a count mismatch — it keeps whatever + * records it found — so a guard that discards the mismatched candidate measures + * nothing at all and reads the archive as empty. + */ +function spliceCountMismatchDecoy(archive: Buffer): Buffer { + const eocd = readEocd(archive) + const decoy = Buffer.alloc(22) + decoy.writeUInt32LE(0x06054b50, 0) + + const realEocd = Buffer.from(archive.subarray(eocd.offset)) + realEocd.writeUInt16LE(eocd.entryCount + 1, 8) + realEocd.writeUInt16LE(eocd.entryCount + 1, 10) + realEocd.writeUInt32LE(eocd.cdOffset + decoy.length, 16) + + return Buffer.concat([ + archive.subarray(0, eocd.cdOffset), + decoy, + archive.subarray(eocd.cdOffset, eocd.offset), + realEocd, + ]) +} + +/** Saturate the first central-directory record's 32-bit size and declare the real size in a ZIP64 extra field. */ +function forgeZip64DeclaredUncompressedSize(zipBuffer: Buffer, declaredBytes: bigint): Buffer { + const cdStart = zipBuffer.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02])) + const fileNameLength = zipBuffer.readUInt16LE(cdStart + 28) + const extraFieldLength = zipBuffer.readUInt16LE(cdStart + 30) + const extraStart = cdStart + 46 + fileNameLength + + const zip64Field = Buffer.alloc(12) + zip64Field.writeUInt16LE(0x0001, 0) + zip64Field.writeUInt16LE(8, 2) + zip64Field.writeBigUInt64LE(declaredBytes, 4) + + const head = Buffer.from(zipBuffer.subarray(0, extraStart)) + head.writeUInt32LE(0xffffffff, cdStart + 24) + head.writeUInt16LE(extraFieldLength + zip64Field.length, cdStart + 30) + + const forged = Buffer.concat([head, zip64Field, zipBuffer.subarray(extraStart)]) + const eocd = readEocd(forged) + forged.writeUInt32LE(eocd.cdSize + zip64Field.length, eocd.offset + 12) + return forged +} + describe('assertOoxmlArchiveWithinLimits', () => { it('accepts a well-formed archive within limits', async () => { const buffer = await buildZip({ 'word/document.xml': 'hello world' }) @@ -97,17 +204,327 @@ describe('assertOoxmlArchiveWithinLimits', () => { expect(() => assertOoxmlArchiveWithinLimits(buffer)).toThrow(ZipBombError) }) - it('rejects a decoy EOCD signature that does not validate against the buffer tail', async () => { + it('ignores a decoy EOCD whose central directory does not check out', async () => { const realZip = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) }) - // A decoy EOCD (zeroed central directory) appended after the genuine archive - // would, without tail validation, redirect the guard to an empty directory - // and undercount the real entries. + // A decoy EOCD claiming an empty central directory at offset 0 would, if + // trusted, undercount the real entries and let an oversized archive through. const decoy = Buffer.alloc(64) decoy.writeUInt32LE(0x06054b50, 0) const tampered = Buffer.concat([realZip, decoy]) + expect(() => + assertOoxmlArchiveWithinLimits(tampered, { + maxTotalUncompressedBytes: 100_000, + maxCompressionRatio: 10_000, + ratioCheckFloorBytes: 1024 * 1024 * 1024, + }) + ).toThrow(ZipBombError) + }) + + it('accepts an archive with a single trailing NUL byte after the EOCD', async () => { + const buffer = await buildZip({ 'word/document.xml': 'hello world' }) + const padded = Buffer.concat([buffer, Buffer.alloc(1)]) + expect(() => assertOoxmlArchiveWithinLimits(padded, HIGH_LIMITS)).not.toThrow() + await expect(JSZip.loadAsync(padded)).resolves.toBeDefined() + }) + + it('accepts an archive with a kilobyte of trailing garbage after the EOCD', async () => { + const buffer = await buildZip({ 'word/document.xml': 'hello world' }) + const garbage = Buffer.alloc(1024, 0xab) + const padded = Buffer.concat([buffer, garbage]) + expect(() => assertOoxmlArchiveWithinLimits(padded, HIGH_LIMITS)).not.toThrow() + await expect(JSZip.loadAsync(padded)).resolves.toBeDefined() + }) + + it('still rejects an oversized archive that carries trailing bytes', async () => { + const buffer = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) }) + const padded = Buffer.concat([buffer, Buffer.alloc(1024, 0xab)]) + expect(() => + assertOoxmlArchiveWithinLimits(padded, { + maxTotalUncompressedBytes: 100_000, + maxCompressionRatio: 10_000, + ratioCheckFloorBytes: 1024 * 1024 * 1024, + }) + ).toThrow(ZipBombError) + }) + + it('still rejects a high-ratio archive that carries trailing bytes', async () => { + const buffer = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) }) + const padded = Buffer.concat([buffer, Buffer.alloc(1024, 0xab)]) + expect(() => + assertOoxmlArchiveWithinLimits(padded, { + maxTotalUncompressedBytes: 1024 * 1024 * 1024, + maxCompressionRatio: 5, + ratioCheckFloorBytes: 1000, + }) + ).toThrow(ZipBombError) + }) + + it('rejects a forged zip bomb under the default limits, with or without trailing bytes', async () => { + const archive = await buildZip({ 'word/document.xml': 'hello' }) + const bomb = forgeDeclaredUncompressedSize(archive, 0xfffffff0) + for (const tail of [Buffer.alloc(0), Buffer.alloc(1), Buffer.alloc(1024, 0xab)]) { + expect(() => assertOoxmlArchiveWithinLimits(Buffer.concat([bomb, tail]))).toThrow( + ZipBombError + ) + } + }) + + it('rejects a bomb followed by an appended empty EOCD record', async () => { + const archive = await buildZip({ 'word/document.xml': 'hello' }) + const bomb = forgeDeclaredUncompressedSize(archive, 0xfffffff0) + // The appended record is internally consistent and sits at the highest + // offset, so a scan that trusts one record reads "empty archive" and lets + // the real central directory through. + const tampered = appendEocd(bomb) + expect(() => assertOoxmlArchiveWithinLimits(tampered)).toThrow(ZipBombError) + }) + + it('rejects a bomb followed by an appended EOCD declaring a small central directory', async () => { + const archive = await buildZip({ 'word/document.xml': 'hello' }) + const bomb = forgeDeclaredUncompressedSize(archive, 0xfffffff0) + const decoyArchive = await buildZip({ 'harmless.xml': 'ok' }) + const decoy = readEocd(decoyArchive) + const tampered = appendEocd(Buffer.concat([bomb, decoyArchive]), { + entryCount: decoy.entryCount, + cdSize: decoy.cdSize, + cdOffset: bomb.length + decoy.cdOffset, + }) expect(() => assertOoxmlArchiveWithinLimits(tampered)).toThrow(ZipBombError) }) + it('rejects an oversized archive followed by an appended empty EOCD record', async () => { + const archive = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) }) + expect(() => + assertOoxmlArchiveWithinLimits(appendEocd(archive), { + maxTotalUncompressedBytes: 100_000, + maxCompressionRatio: 10_000, + ratioCheckFloorBytes: 1024 * 1024 * 1024, + }) + ).toThrow(ZipBombError) + }) + + it('accepts a within-limits archive that carries an appended empty EOCD record', async () => { + const archive = await buildZip({ 'word/document.xml': 'hello world' }) + expect(() => assertOoxmlArchiveWithinLimits(appendEocd(archive), HIGH_LIMITS)).not.toThrow() + }) + + it('rejects a bomb hidden behind a single prepended byte, which JSZip still inflates', async () => { + const archive = await buildZip({ 'word/document.xml': 'A'.repeat(64 * 1024 * 1024) }) + // Prepending shifts the whole archive, but the EOCD's cdOffset is absolute, + // so no candidate resolves. The buffer no longer starts with a ZIP + // signature either, so a fail-closed branch keyed on byte 0 lets it past. + const attack = Buffer.concat([Buffer.from([0x00]), archive]) + expect(isZipShaped(attack)).toBe(false) + expect(() => assertOoxmlArchiveWithinLimits(attack)).toThrow(ZipBombError) + + // The threat is real, not merely a shape: JSZip reads past the prepended + // byte and inflates the entry in full. + const loaded = await JSZip.loadAsync(attack) + const inflated = await loaded.file('word/document.xml')!.async('nodebuffer') + expect(inflated.length).toBe(64 * 1024 * 1024) + expect(inflated.length / attack.length).toBeGreaterThan(100) + + // Same bomb, with the real EOCD pushed past the 64 KiB comment window by + // trailing junk. A spec-correct windowed scan sees no EOCD signature at all + // and, with byte 0 no longer a ZIP signature, no-ops entirely — while JSZip, + // which scans to offset 0, still finds the record and inflates 64 MiB. The + // guard must model the parser, so the scan covers the whole buffer. + const windowed = Buffer.concat([attack, Buffer.alloc(70_000, 0xab)]) + expect(isZipShaped(windowed)).toBe(false) + expect(() => assertOoxmlArchiveWithinLimits(windowed)).toThrow(ZipBombError) + + const loadedWindowed = await JSZip.loadAsync(windowed) + const inflatedWindowed = await loadedWindowed.file('word/document.xml')!.async('nodebuffer') + expect(inflatedWindowed.length).toBe(64 * 1024 * 1024) + }, 120_000) + + it('rejects a within-limits archive whose EOCD sits past the 64 KiB comment window', async () => { + const archive = await buildZip({ 'word/document.xml': 'hello world' }) + const attack = Buffer.concat([Buffer.from([0x00]), archive, Buffer.alloc(70_000, 0xab)]) + expect(() => assertOoxmlArchiveWithinLimits(attack, HIGH_LIMITS)).toThrow(ZipBombError) + }) + + it('rejects a bomb whose EOCD lies about the entry count behind an empty-EOCD decoy', async () => { + const archive = await buildZip({ 'word/document.xml': 'hello' }) + const bomb = forgeDeclaredUncompressedSize(archive, 200_000) + // Discarding the count-mismatched candidate measured NOTHING for the only + // interpretation JSZip actually parses, leaving the trivially-resolvable + // decoy as the whole verdict: "empty archive". The declared size stays under + // the absolute cap so the ratio check — not the walk's early size abort — is + // what has to see the entry. + expect(() => + assertOoxmlArchiveWithinLimits(spliceCountMismatchDecoy(bomb), { + maxTotalUncompressedBytes: 1024 * 1024 * 1024, + maxCompressionRatio: 5, + ratioCheckFloorBytes: 1000, + }) + ).toThrow(ZipBombError) + }) + + it('measures the real central directory when the EOCD lies about the entry count', async () => { + const archive = await buildZip({ 'a.xml': 'a', 'b.xml': 'b', 'c.xml': 'c' }) + expect(readZipCentralDirectoryStats(spliceCountMismatchDecoy(archive))?.entryCount).toBe(3) + }) + + it('rejects a prepended bomb that also carries an empty-EOCD decoy', async () => { + const archive = await buildZip({ 'word/document.xml': 'hello' }) + const bomb = forgeDeclaredUncompressedSize(archive, 0xfffffff0) + // Prepending shifts the real EOCD's absolute cdOffset out from under the + // guard while JSZip rebases past the leading byte; the decoy then supplies a + // clean "empty archive" reading. A resolvable candidate must not excuse an + // unresolvable one. + const attack = Buffer.concat([Buffer.from([0x00]), spliceCountMismatchDecoy(bomb)]) + expect(() => assertOoxmlArchiveWithinLimits(attack)).toThrow(ZipBombError) + }) + + it('refuses an EOCD signature truncated by the buffer end', () => { + // Too close to the tail to hold a full record, so it yields no candidate — + // but it is still a ZIP signature the guard could not follow, and reading it + // as "not a ZIP" is the same fail-open the prepend evasion exploits. + const buffer = Buffer.alloc(64) + Buffer.from([0x50, 0x4b, 0x05, 0x06]).copy(buffer, 50) + expect(() => assertOoxmlArchiveWithinLimits(buffer)).toThrow(ZipBombError) + }) + + it('refuses, quickly, a buffer whose candidates each anchor a distinct maximal walk', () => { + // The run cache is keyed by central-directory offset, so distinct offsets + // defeat it and cost MAX_EOCD_CANDIDATES full walks. The shared record budget + // bounds that, and exhausting it is unverifiable rather than "measured 0". + const buffer = Buffer.alloc(4 * 1024 * 1024) + for (let offset = 0; offset + 46 <= buffer.length; offset += 46) { + buffer.writeUInt32LE(CENTRAL_DIRECTORY_HEADER_SIGNATURE, offset) + } + for (let i = 0; i < 128; i++) { + const offset = buffer.length - 128 * 22 + i * 22 + buffer.writeUInt32LE(0x06054b50, offset) + buffer.writeUInt32LE(i * 46, offset + 16) + } + + const start = performance.now() + expect(() => assertOoxmlArchiveWithinLimits(buffer)).toThrow(ZipBombError) + expect(performance.now() - start).toBeLessThan(250) + }) + + it('survives a central-directory record whose extra field runs past the buffer end', () => { + // A truncated directory can declare an extra field longer than what remains. + // An unclamped read raises a raw RangeError, which escapes callers that + // expect a typed archive error (the upload path calls this outside any try). + const buffer = Buffer.alloc(68) + buffer.writeUInt32LE(0x06054b50, 0) + buffer.writeUInt16LE(1, 8) + buffer.writeUInt16LE(1, 10) + buffer.writeUInt32LE(22, 16) + buffer.writeUInt32LE(CENTRAL_DIRECTORY_HEADER_SIGNATURE, 22) + buffer.writeUInt32LE(0xffffffff, 22 + 24) + buffer.writeUInt16LE(0xffff, 22 + 30) + + expect(readZipCentralDirectoryStats(buffer)).toEqual({ + entryCount: 1, + totalExtraFieldBytes: 0xffff, + }) + }) + + it('rejects a within-limits archive behind a prepended byte', async () => { + // Defined, deliberate behaviour: an unresolvable EOCD is refused whether or + // not the archive behind it is benign. Nothing in the OOXML pipeline emits + // leading data, so the only producers of this shape are evasion attempts. + const archive = await buildZip({ 'word/document.xml': 'hello world' }) + const prepended = Buffer.concat([Buffer.from([0x00]), archive]) + expect(() => assertOoxmlArchiveWithinLimits(prepended, HIGH_LIMITS)).toThrow(ZipBombError) + }) + + it('rejects a bomb behind a kilobyte of prepended garbage', async () => { + const archive = await buildZip({ 'word/document.xml': 'hello' }) + const bomb = forgeDeclaredUncompressedSize(archive, 0xfffffff0) + const attack = Buffer.concat([Buffer.alloc(1024, 0xab), bomb]) + expect(() => assertOoxmlArchiveWithinLimits(attack)).toThrow(ZipBombError) + }) + + it('no-ops for a stray EOCD signature that resolves to an empty archive', () => { + // The zero fill after the signature reads as "0 records at offset 0", which + // walks cleanly to an empty central directory. A resolvable record needs no + // fail-closed treatment — the buffer has been inspected, and it is empty. + const buffer = Buffer.alloc(512) + Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]).copy(buffer, 0) + Buffer.from([0x50, 0x4b, 0x05, 0x06]).copy(buffer, 300) + expect(() => assertOoxmlArchiveWithinLimits(buffer)).not.toThrow() + }) + + it('rejects a non-ZIP buffer whose stray EOCD signature does not resolve', () => { + // A coincidental `PK\x05\x06` followed by bytes that point nowhere is + // ~1.5e-5 likely for random data in the 64 KiB window, and the alternative + // reading — "not ZIP-shaped, let it through" — is exactly the bypass above. + // Refusing costs one parse with a clear error, against an OOM that takes + // down every tenant sharing the worker. + const buffer = Buffer.alloc(512) + Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]).copy(buffer, 0) + Buffer.from([0x50, 0x4b, 0x05, 0x06]).copy(buffer, 300) + buffer.writeUInt16LE(4, 300 + 10) + buffer.writeUInt32LE(64, 300 + 16) + expect(() => assertOoxmlArchiveWithinLimits(buffer)).toThrow(ZipBombError) + }) + + it('rejects a buffer whose scan window is flooded with EOCD signatures', () => { + const buffer = Buffer.alloc(4096) + for (let offset = 0; offset + 4 <= buffer.length; offset += 8) { + Buffer.from([0x50, 0x4b, 0x05, 0x06]).copy(buffer, offset) + } + expect(() => assertOoxmlArchiveWithinLimits(buffer)).toThrow(ZipBombError) + }) + + it('reports the worst-case record count across EOCD interpretations', async () => { + const archive = await buildZip({ 'a.xml': 'a', 'b.xml': 'b', 'c.xml': 'c' }) + expect(readZipCentralDirectoryStats(archive)?.entryCount).toBe(3) + expect(readZipCentralDirectoryStats(appendEocd(archive))?.entryCount).toBe(3) + }) + + it('reads a ZIP64 declared uncompressed size from the central-directory extra field', async () => { + const archive = await buildZip({ 'word/document.xml': 'hello' }) + const bomb = forgeZip64DeclaredUncompressedSize(archive, 8n * 1024n * 1024n * 1024n) + expect(() => assertOoxmlArchiveWithinLimits(bomb)).toThrow(ZipBombError) + }) + + it('no-ops for a legacy OLE2 document containing the EOCD byte sequence', () => { + // Non-ZIP documents carry these four bytes by chance, and the OOXML parsers + // depend on a no-op here so their OLE2/plaintext fallback can run. The + // record's cdOffset lands outside the buffer, so no parser can read a + // directory from it and it carries no signal either way. + const body = Buffer.alloc(4096, 0x41) + Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]).copy(body, 0) + const stray = Buffer.alloc(22) + stray.writeUInt32LE(0x06054b50, 0) + stray.writeUInt16LE(3, 8) + stray.writeUInt16LE(3, 10) + stray.writeUInt32LE(0xdeadbe, 12) + stray.writeUInt32LE(0xdeadbe, 16) + stray.copy(body, 2000) + + expect(() => assertOoxmlArchiveWithinLimits(body)).not.toThrow() + }) + + it('no-ops for plaintext containing the EOCD byte sequence', () => { + const text = Buffer.concat([ + Buffer.from('Quarterly report. '.repeat(50)), + Buffer.from([0x50, 0x4b, 0x05, 0x06]), + Buffer.from('...more prose follows here.'.repeat(50)), + ]) + expect(() => assertOoxmlArchiveWithinLimits(text)).not.toThrow() + }) + + it('no-ops for a legacy OLE2/CFB document', () => { + const ole2 = Buffer.alloc(1024) + Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]).copy(ole2, 0) + expect(() => assertOoxmlArchiveWithinLimits(ole2)).not.toThrow() + }) + + it('no-ops for a garbage buffer that is not ZIP-shaped', () => { + const garbage = Buffer.alloc(4096) + for (let i = 0; i < garbage.length; i++) { + garbage[i] = (i * 31 + 7) % 251 + } + expect(() => assertOoxmlArchiveWithinLimits(garbage)).not.toThrow() + }) + it('no-ops for buffers that are not ZIP archives', () => { const plaintext = Buffer.from('this is just plain text, not a zip archive at all') expect(() => assertOoxmlArchiveWithinLimits(plaintext)).not.toThrow() diff --git a/apps/sim/lib/file-parsers/zip-guard.ts b/apps/sim/lib/file-parsers/zip-guard.ts index 07642b3740c..1591de47dd9 100644 --- a/apps/sim/lib/file-parsers/zip-guard.ts +++ b/apps/sim/lib/file-parsers/zip-guard.ts @@ -22,13 +22,39 @@ const ZIP64_EOCD_SIGNATURE = 0x06064b50 const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50 const ZIP64_EXTRA_FIELD_ID = 0x0001 +const EOCD_SIGNATURE_BYTES = Buffer.from([0x50, 0x4b, 0x05, 0x06]) + const EOCD_MIN_SIZE = 22 const ZIP64_EOCD_LOCATOR_SIZE = 20 const CENTRAL_DIRECTORY_HEADER_MIN_SIZE = 46 -const MAX_EOCD_COMMENT_SIZE = 0xffff const UINT32_SENTINEL = 0xffffffff const UINT16_SENTINEL = 0xffff +/** + * Ceiling on EOCD signatures considered. A real archive has one (plus, rarely, a + * stray match inside entry data); a buffer stuffed with more is an attempt to + * flood the candidate set, and is refused outright. + */ +const MAX_EOCD_CANDIDATES = 128 + +/** + * Ceiling on central-directory records read across ALL candidate walks in one + * inspection. + * + * The per-candidate run cache is keyed by central-directory offset, so an + * attacker who varies `cdOffset` per candidate defeats it and pays + * `MAX_EOCD_CANDIDATES × buffer.length / 46` record reads: 551 ms of synchronous + * event-loop block on a 64 MiB buffer and 1006 ms at the pipeline's 100 MiB cap, + * measured. The budget bounds that at 5.9 ms and 8.6 ms respectively while + * sitting far above any real archive — the upload path separately refuses more + * than 10k records. Exhausting it means the buffer could not be fully evaluated, + * which fails closed like any other unverifiable ZIP-shaped input. + * + * Widening the signature scan from the trailing 64 KiB to the whole buffer costs + * one `Buffer.indexOf` pass: 0.02 ms to 0.33 ms on a normal 5 MB OOXML. + */ +const MAX_CENTRAL_DIRECTORY_RECORDS_SCANNED = 250_000 + export interface OoxmlSizeLimits { /** Hard ceiling on the summed declared uncompressed size of all entries. */ maxTotalUncompressedBytes: number @@ -74,25 +100,52 @@ export function isZipShaped(buffer: Buffer): boolean { return signature === LOCAL_FILE_HEADER_SIGNATURE || signature === EOCD_SIGNATURE } +interface EocdScan { + /** Offsets to evaluate; empty when the buffer is flooded past {@link MAX_EOCD_CANDIDATES}. */ + candidates: number[] + /** Whether the buffer holds at least one EOCD signature, flooded or not. */ + sawSignature: boolean +} + /** - * Locate the End Of Central Directory record by scanning backwards from the end - * of the buffer (it sits within the trailing 22 + comment bytes). A candidate - * is only accepted when its declared comment length places the record exactly - * at the buffer tail, so a decoy EOCD signature planted in the comment region - * cannot redirect the guard to a smaller, attacker-chosen central directory. + * Every EOCD signature offset in the WHOLE buffer. + * + * The spec puts the record in the trailing 22 + 65535 comment-length window, and + * yauzl honours that — but the libraries this guard actually protects do not. + * JSZip (`ArrayReader.lastIndexOfSignature`) and SheetJS both scan from the last + * byte to offset 0, so an EOCD pushed past 64 KiB of trailing junk is invisible + * to a windowed scan yet is exactly the record they parse. Combined with a single + * prepended byte — which makes the buffer read as "not ZIP-shaped" — a windowed + * scan let a 495x bomb through untouched. The guard models the parsers, not the + * spec, so the window is the buffer. + * + * Trailing bytes after the EOCD are common in the wild (self-extracting stubs, + * appended signatures, generator padding), so the record is not required to end + * at the buffer tail. Every candidate is returned rather than the first that + * looks plausible: the decompression libraries each pick their own record, so the + * archive is evaluated under all of them. A flooded buffer yields no candidates + * but still reports the signature, so the caller refuses the buffer rather than + * reading it as "not a ZIP". */ -function findEocdOffset(buffer: Buffer): number { - const minStart = Math.max(0, buffer.length - EOCD_MIN_SIZE - MAX_EOCD_COMMENT_SIZE) - for (let offset = buffer.length - EOCD_MIN_SIZE; offset >= minStart; offset--) { - if (buffer.readUInt32LE(offset) !== EOCD_SIGNATURE) { - continue +function findEocdCandidates(buffer: Buffer): EocdScan { + const lastOffset = buffer.length - EOCD_MIN_SIZE + const candidates: number[] = [] + let sawSignature = false + + let offset = buffer.indexOf(EOCD_SIGNATURE_BYTES) + while (offset !== -1) { + sawSignature = true + if (offset > lastOffset) { + break } - const commentLength = buffer.readUInt16LE(offset + 20) - if (offset + EOCD_MIN_SIZE + commentLength === buffer.length) { - return offset + if (candidates.length >= MAX_EOCD_CANDIDATES) { + return { candidates: [], sawSignature: true } } + candidates.push(offset) + offset = buffer.indexOf(EOCD_SIGNATURE_BYTES, offset + 1) } - return -1 + + return { candidates, sawSignature } } interface CentralDirectoryLocation { @@ -143,6 +196,10 @@ function locateCentralDirectory( * when the 32-bit central-directory field is saturated. The saturated 64-bit * values appear in the extra field in a fixed order with the uncompressed size * first, so it is always the leading 8 bytes of the ZIP64 field. + * + * The extra-field extent is clamped to the buffer: a truncated central directory + * can declare a length that runs off the end, and an unclamped read raises a raw + * `RangeError` that escapes callers which expect a typed archive error. */ function readUncompressedSize( buffer: Buffer, @@ -156,7 +213,7 @@ function readUncompressedSize( } const extraStart = headerOffset + CENTRAL_DIRECTORY_HEADER_MIN_SIZE + fileNameLength - const extraEnd = extraStart + extraFieldLength + const extraEnd = Math.min(extraStart + extraFieldLength, buffer.length) let cursor = extraStart while (cursor + 4 <= extraEnd) { const fieldId = buffer.readUInt16LE(cursor) @@ -171,129 +228,281 @@ function readUncompressedSize( return uncompressedSize } +interface CentralDirectoryWalk { + /** Records in the contiguous central-directory run at the candidate's offset. */ + entryCount: number + /** Summed declared uncompressed size across those records. */ + declaredUncompressedBytes: number + /** Summed declared extra-field bytes across those records. */ + totalExtraFieldBytes: number +} + /** - * Sum the declared uncompressed size of every central-directory entry. Returns - * `null` when the buffer is not a parseable ZIP archive (e.g. legacy binary - * `.xls`/`.doc`, or a misidentified plaintext file) so the caller can defer to - * the downstream parser. Stops early once the running total exceeds the limit. + * A candidate is either ignorable, unverifiable, or measurable. + * + * - `ignored` — the EOCD's `cdOffset` does not resolve at all (out of range, or a + * broken ZIP64 chain). No parser can read a central directory from it, and a + * stray `PK\x05\x06` inside compressed entry data lands here, so it carries no + * signal either way. + * - `unverifiable` — the offset resolves and the record declares entries, but not + * one record sits there. This is the prepended-bytes shape: JSZip rebases past + * arbitrary leading data and inflates the archive anyway, while every absolute + * offset in the buffer is shifted out from under the guard. It must fail closed. + * - `walk` — records were read; the run is the measurement. */ -function sumDeclaredUncompressedSize(buffer: Buffer, abortAboveBytes: number): number | null { - if (buffer.length < EOCD_MIN_SIZE) { - return null - } - - const eocdOffset = findEocdOffset(buffer) - if (eocdOffset < 0) { - return null - } +type CandidateOutcome = + | { kind: 'ignored' } + | { kind: 'unverifiable' } + | { kind: 'walk'; walk: CentralDirectoryWalk } + +/** Remaining central-directory records an inspection may read, shared across candidates. */ +interface ScanBudget { + remaining: number +} +/** + * Walk the contiguous run of central-directory records anchored by one EOCD + * candidate. The run — not the candidate's declared entry count — is what a + * per-signature parser allocates, so a lied count can neither hide records nor + * inflate the tally. + * + * A run that disagrees with the declared count is still measured. JSZip + * deliberately does not error on a count mismatch ("we found some records but not + * all… no error here", `zipEntries.js`), so it allocates and inflates exactly the + * run found here; discarding the candidate instead measured NOTHING for that + * interpretation and let a 1021x bomb through behind a single decoy record. + * + * Stops early once the running total exceeds the limit — the archive is already + * over budget under this interpretation — or once the shared scan budget is + * exhausted, which is reported as unverifiable rather than as a short run. Runs + * are memoized by central directory offset, so many candidates aimed at one + * directory cost one walk. + */ +function walkCentralDirectory( + buffer: Buffer, + eocdOffset: number, + abortAboveBytes: number, + runCache: Map, + budget: ScanBudget +): CandidateOutcome { const location = locateCentralDirectory(buffer, eocdOffset) if (!location) { - return null + return { kind: 'ignored' } + } + + const cached = runCache.get(location.offset) + if (cached) { + return classifyRun(cached, location) } - let total = 0 + let entryCount = 0 + let declaredUncompressedBytes = 0 + let totalExtraFieldBytes = 0 let cursor = location.offset - for (let entry = 0; entry < location.entryCount; entry++) { - if (cursor + CENTRAL_DIRECTORY_HEADER_MIN_SIZE > buffer.length) { - return null - } - if (buffer.readUInt32LE(cursor) !== CENTRAL_DIRECTORY_HEADER_SIGNATURE) { - return null + while ( + cursor + CENTRAL_DIRECTORY_HEADER_MIN_SIZE <= buffer.length && + buffer.readUInt32LE(cursor) === CENTRAL_DIRECTORY_HEADER_SIGNATURE + ) { + if (budget.remaining <= 0) { + return { kind: 'unverifiable' } } + budget.remaining -= 1 const fileNameLength = buffer.readUInt16LE(cursor + 28) const extraFieldLength = buffer.readUInt16LE(cursor + 30) const commentLength = buffer.readUInt16LE(cursor + 32) - total += readUncompressedSize(buffer, cursor, fileNameLength, extraFieldLength) - if (total > abortAboveBytes) { - return total + entryCount += 1 + totalExtraFieldBytes += extraFieldLength + declaredUncompressedBytes += readUncompressedSize( + buffer, + cursor, + fileNameLength, + extraFieldLength + ) + if (declaredUncompressedBytes > abortAboveBytes) { + return { kind: 'walk', walk: { entryCount, declaredUncompressedBytes, totalExtraFieldBytes } } } cursor += CENTRAL_DIRECTORY_HEADER_MIN_SIZE + fileNameLength + extraFieldLength + commentLength } - return total + const walk = { entryCount, declaredUncompressedBytes, totalExtraFieldBytes } + runCache.set(location.offset, walk) + return classifyRun(walk, location) } -/** Parse-time shape of a ZIP central directory, read without decompressing anything. */ -export interface ZipCentralDirectoryStats { - /** Records in the contiguous central-directory run — what a per-signature parser allocates. */ - entryCount: number - /** Summed declared extra-field bytes across those records. */ - totalExtraFieldBytes: number +/** + * An empty run under a record that declares entries means the directory is not + * where the buffer says it is — unverifiable. An empty run under a record that + * declares nothing is simply an empty archive, and is measured as such. + */ +function classifyRun( + walk: CentralDirectoryWalk, + location: CentralDirectoryLocation +): CandidateOutcome { + if (walk.entryCount === 0 && location.entryCount > 0) { + return { kind: 'unverifiable' } + } + return { kind: 'walk', walk } +} + +interface ArchiveInspection { + /** Worst case across measurable EOCD interpretations, or `null` when none were. */ + worst: CentralDirectoryWalk | null + /** Whether the buffer held at least one EOCD signature. */ + sawEocdSignature: boolean + /** + * Whether the buffer holds an EOCD signal the guard could not evaluate: a + * record naming a directory that is not there, a directory too large to scan + * within the budget, or a candidate set too flooded to enumerate. Distinct + * from "no measurement" — an EOCD whose offset lands outside the buffer is + * unreadable by every parser too, so it is no signal rather than a bad one. + */ + sawUnverifiableEocd: boolean } /** - * Walk the real central directory (EOCD-anchored, decoy-resistant, ZIP64-aware — - * the same anchoring as {@link assertOoxmlArchiveWithinLimits}) and report its - * record count and summed extra-field bytes, so callers can bound a parser's - * object graph before handing it the buffer. The walk covers the CONTIGUOUS run - * of records at the central-directory offset rather than trusting the EOCD's - * declared count, because that run is what JSZip actually allocates one entry - * per — a lied count can neither hide records nor inflate the tally. Unlike a - * raw whole-buffer signature scan, STORED entry payloads (e.g. a nested `.zip` - * archived without recompression) are never miscounted as records. Returns - * `null` when the buffer is not a parseable ZIP, so callers can fail closed. + * Evaluate the archive under every EOCD candidate and return the worst case of + * each measure. yauzl, JSZip and SheetJS each select their own EOCD record, so + * the guard must not depend on guessing which one the downstream parser reads: + * if ANY interpretation is over budget, the archive is rejected. + * + * `worst` is `null` when nothing was measurable — which alone is not grounds for + * refusal, since a non-ZIP document carrying the four EOCD bytes by chance lands + * here. `sawUnverifiableEocd` is the separate, convicting signal: a candidate the + * guard cannot follow is an evasion shape that must be refused even when another + * candidate measured cleanly — see {@link assertOoxmlArchiveWithinLimits}. */ -export function readZipCentralDirectoryStats(buffer: Buffer): ZipCentralDirectoryStats | null { +function inspectArchive(buffer: Buffer, abortAboveBytes: number): ArchiveInspection { if (buffer.length < EOCD_MIN_SIZE) { - return null - } - - const eocdOffset = findEocdOffset(buffer) - if (eocdOffset < 0) { - return null + return { worst: null, sawEocdSignature: false, sawUnverifiableEocd: false } } - const location = locateCentralDirectory(buffer, eocdOffset) - if (!location) { - return null + const { candidates, sawSignature } = findEocdCandidates(buffer) + + let worst: CentralDirectoryWalk | null = null + // A signature the scan declined to enumerate — a flooded buffer, or a record + // truncated by the buffer end — is unevaluated, not absent. JSZip reads the + // LAST signature in the buffer, so flooding past the candidate ceiling would + // otherwise hide the one record it actually parses. + let sawUnverifiableEocd = sawSignature && candidates.length === 0 + const runCache = new Map() + const budget: ScanBudget = { remaining: MAX_CENTRAL_DIRECTORY_RECORDS_SCANNED } + for (const candidate of candidates) { + const outcome = walkCentralDirectory(buffer, candidate, abortAboveBytes, runCache, budget) + if (outcome.kind === 'unverifiable') { + sawUnverifiableEocd = true + break + } + if (outcome.kind === 'ignored') { + continue + } + const { walk } = outcome + worst = worst + ? { + entryCount: Math.max(worst.entryCount, walk.entryCount), + declaredUncompressedBytes: Math.max( + worst.declaredUncompressedBytes, + walk.declaredUncompressedBytes + ), + totalExtraFieldBytes: Math.max(worst.totalExtraFieldBytes, walk.totalExtraFieldBytes), + } + : walk + if (worst.declaredUncompressedBytes > abortAboveBytes) { + break + } } - let entryCount = 0 - let totalExtraFieldBytes = 0 - let cursor = location.offset - while ( - cursor + CENTRAL_DIRECTORY_HEADER_MIN_SIZE <= buffer.length && - buffer.readUInt32LE(cursor) === CENTRAL_DIRECTORY_HEADER_SIGNATURE - ) { - const fileNameLength = buffer.readUInt16LE(cursor + 28) - const extraFieldLength = buffer.readUInt16LE(cursor + 30) - const commentLength = buffer.readUInt16LE(cursor + 32) + return { worst, sawEocdSignature: sawSignature, sawUnverifiableEocd } +} - entryCount += 1 - totalExtraFieldBytes += extraFieldLength - cursor += CENTRAL_DIRECTORY_HEADER_MIN_SIZE + fileNameLength + extraFieldLength + commentLength - } +/** Parse-time shape of a ZIP central directory, read without decompressing anything. */ +export interface ZipCentralDirectoryStats { + /** Records in the contiguous central-directory run — what a per-signature parser allocates. */ + entryCount: number + /** Summed declared extra-field bytes across those records. */ + totalExtraFieldBytes: number +} - if (entryCount < location.entryCount) { +/** + * Walk the central directory (EOCD-anchored, decoy-resistant, ZIP64-aware — the + * same anchoring as {@link assertOoxmlArchiveWithinLimits}) and report the + * worst-case record count and summed extra-field bytes across every EOCD + * interpretation, so callers can bound a parser's object graph before handing + * it the buffer. Each walk covers the CONTIGUOUS run of records at the central + * directory offset rather than trusting the EOCD's declared count, because that + * run is what JSZip actually allocates one entry per. Unlike a raw whole-buffer + * signature scan, STORED entry payloads (e.g. a nested `.zip` archived without + * recompression) are never miscounted as records. Returns `null` when no EOCD + * candidate is measurable or when any candidate is unverifiable, so callers can + * fail closed. + */ +export function readZipCentralDirectoryStats(buffer: Buffer): ZipCentralDirectoryStats | null { + const { worst, sawUnverifiableEocd } = inspectArchive(buffer, Number.POSITIVE_INFINITY) + if (!worst || sawUnverifiableEocd) { return null } - - return { entryCount, totalExtraFieldBytes } + return { entryCount: worst.entryCount, totalExtraFieldBytes: worst.totalExtraFieldBytes } } /** * Reject an OOXML archive whose declared expanded size or compression ratio * exceeds safe bounds, before any decompression library materializes it. * - * Fails closed: a ZIP-shaped buffer whose central directory cannot be parsed is - * rejected rather than passed through, so a malformed archive that a downstream - * library still inflates cannot bypass the guard. Genuinely non-ZIP inputs - * (legacy OLE `.xls`/`.doc`, misidentified plaintext) no-op and defer to the - * downstream parser's own validation and fallbacks. + * The limits are applied to the WORST case across every EOCD interpretation of + * the buffer, not to one chosen record: the libraries downstream do not agree on + * which EOCD they read, so an archive that is a bomb under any of them is + * rejected. + * + * Fails closed on two families of shape, so a buffer a downstream library still + * inflates cannot bypass the guard: + * + * - The buffer is ZIP-shaped (byte 0 begins a local file header or an EOCD) but + * nothing measurable resolves. + * - The buffer holds an EOCD signal the guard could not evaluate, whatever byte + * 0 says. Three cases: a record that names a directory offset inside the + * buffer where no record is found; a directory too large to scan within the + * shared record budget; and a signature the scan declined to enumerate at all + * — a candidate set flooded past {@link MAX_EOCD_CANDIDATES}, or a record + * truncated by the buffer end. JSZip parses the LAST signature in the buffer, + * so flooding would otherwise hide exactly the one record it reads. + * + * The first case is the prepended-bytes evasion: JSZip tolerates arbitrary + * leading data, but the EOCD's `cdOffset` is absolute, so shifting the archive + * by even one byte leaves the record naming an offset that no longer holds a + * directory, while the archive still inflates. Keying the fail-closed branch + * on the leading signature alone let a 1.2 GiB bomb through. The refusal holds + * even when another candidate measures cleanly: an empty 22-byte EOCD appended + * as a decoy is trivially measurable, and letting it stand in for the shifted + * record reopens the same hole. + * + * An EOCD signature whose `cdOffset` lands OUTSIDE the buffer is not a signal in + * either direction — no parser can read a directory from it — so it neither + * measures nor convicts. This is the common case for the four bytes appearing by + * chance in a legacy OLE `.doc`/`.xls` or in plaintext, and those inputs must + * no-op so the downstream parsers' own fallback paths can run. Treating any + * stray signature as grounds for refusal turned a legacy `.doc` into a hard + * error. What remains is the ~1e-6 case of a stray whose offset happens to land + * in range and read empty; that costs one parse with an explicit error, against + * an OOM that takes down every tenant on the worker. */ export function assertOoxmlArchiveWithinLimits( buffer: Buffer, limits: OoxmlSizeLimits = DEFAULT_OOXML_SIZE_LIMITS ): void { - const totalUncompressed = sumDeclaredUncompressedSize(buffer, limits.maxTotalUncompressedBytes) - if (totalUncompressed === null) { - if (isZipShaped(buffer)) { - logger.warn('Rejected ZIP-shaped archive: central directory could not be parsed', { + const { worst, sawEocdSignature, sawUnverifiableEocd } = inspectArchive( + buffer, + limits.maxTotalUncompressedBytes + ) + const totalUncompressed = worst ? worst.declaredUncompressedBytes : null + if (totalUncompressed === null || sawUnverifiableEocd) { + if (sawUnverifiableEocd || isZipShaped(buffer)) { + logger.warn('Rejected archive: central directory could not be parsed', { compressedBytes: buffer.length, + sawEocdSignature, + sawUnverifiableEocd, + zipShaped: isZipShaped(buffer), }) throw new ZipBombError( 'Unable to inspect ZIP central directory; refusing to parse an unverifiable ZIP-shaped archive' diff --git a/apps/sim/lib/media/falai.test.ts b/apps/sim/lib/media/falai.test.ts new file mode 100644 index 00000000000..a47aee14d88 --- /dev/null +++ b/apps/sim/lib/media/falai.test.ts @@ -0,0 +1,229 @@ +/** + * @vitest-environment node + */ +import { inputValidationMock, inputValidationMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) +vi.mock('@/lib/core/execution-limits', () => ({ getMaxExecutionTimeout: () => 30_000 })) +vi.mock('@sim/utils/helpers', () => ({ sleep: () => Promise.resolve() })) + +import { MAX_FAL_QUEUE_JSON_BYTES, runFalQueue } from '@/lib/media/falai' + +const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns + +function jsonResponse(payload: unknown, ok = true) { + const text = JSON.stringify(payload) + return { + ok, + status: ok ? 200 : 500, + headers: { get: () => null }, + body: null, + text: async () => text, + arrayBuffer: async () => new ArrayBuffer(0), + } +} + +describe('runFalQueue', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '8.8.8.8' }) + }) + + it('polls the queue through the SSRF-guarded client, revalidating on every poll', async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ request_id: 'req-1' }), { status: 200 }) + ) + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(jsonResponse({ status: 'IN_QUEUE' })) + .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' })) + .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } })) + + const result = await runFalQueue('fal-ai/test', { prompt: 'hi' }, 'fal-key') + + expect(result.requestId).toBe('req-1') + // Two status polls + one result fetch, all through the guarded client. + expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(3) + // The URL is re-validated on every hop, not pinned once at submit time. + expect(mockValidateUrlWithDNS).toHaveBeenCalledTimes(3) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('ignores a status_url that leaves the Fal.ai queue origin (no API-key exfiltration)', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + request_id: 'req-2', + status_url: 'https://evil.example.net/steal', + response_url: 'https://evil.example.net/steal', + }), + { status: 200 } + ) + ) + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' })) + .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } })) + + await runFalQueue('fal-ai/test', { prompt: 'hi' }, 'fal-key') + + const urls = mockSecureFetchWithPinnedIP.mock.calls.map(([url]) => url) + expect(urls).toEqual([ + 'https://queue.fal.run/fal-ai/test/requests/req-2/status', + 'https://queue.fal.run/fal-ai/test/requests/req-2', + ]) + for (const [, , options] of mockSecureFetchWithPinnedIP.mock.calls) { + expect(options.headers.Authorization).toBe('Key fal-key') + } + }) + + it('builds the fallback queue URL from the app id for a multi-segment endpoint', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ request_id: 'req-4', status_url: 'https://evil.example.net/steal' }), + { status: 200 } + ) + ) + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' })) + .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } })) + + // fal.ai routes queue polling under `{owner}/{alias}` only — the model sub-path 405s. + await runFalQueue('fal-ai/kling-video/v3/pro/text-to-video', { prompt: 'hi' }, 'fal-key') + + expect(mockSecureFetchWithPinnedIP.mock.calls.map(([url]) => url)).toEqual([ + 'https://queue.fal.run/fal-ai/kling-video/requests/req-4/status', + 'https://queue.fal.run/fal-ai/kling-video/requests/req-4', + ]) + }) + + it('strips the unroutable /response suffix Fal.ai echoes back (queue.fal.run 405s on it)', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + request_id: 'req-5', + status_url: 'https://queue.fal.run/fal-ai/test/requests/req-5/status', + response_url: 'https://queue.fal.run/fal-ai/test/requests/req-5/response', + }), + { status: 200 } + ) + ) + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' })) + .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } })) + + await runFalQueue('fal-ai/test', { prompt: 'hi' }, 'fal-key') + + expect(mockSecureFetchWithPinnedIP.mock.calls.map(([url]) => url)).toEqual([ + 'https://queue.fal.run/fal-ai/test/requests/req-5/status', + 'https://queue.fal.run/fal-ai/test/requests/req-5', + ]) + }) + + it('strips /response from a response_url echoed by the completed status body', async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ request_id: 'req-6' }), { status: 200 }) + ) + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce( + jsonResponse({ + status: 'COMPLETED', + response_url: 'https://queue.fal.run/fal-ai/other/requests/req-6/response', + }) + ) + .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } })) + + await runFalQueue('fal-ai/test', { prompt: 'hi' }, 'fal-key') + + expect(mockSecureFetchWithPinnedIP.mock.calls[1][0]).toBe( + 'https://queue.fal.run/fal-ai/other/requests/req-6' + ) + }) + + it('rejects a same-origin queue path of the wrong kind (result offered for status, and vice versa)', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + request_id: 'req-7', + // Both are routable queue paths, but each is the *other* kind: the status + // slot is handed a result path and the result slot a status path. + status_url: 'https://queue.fal.run/fal-ai/test/requests/req-7', + response_url: 'https://queue.fal.run/fal-ai/test/requests/req-7/status', + }), + { status: 200 } + ) + ) + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' })) + .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } })) + + await runFalQueue('fal-ai/test', { prompt: 'hi' }, 'fal-key') + + expect(mockSecureFetchWithPinnedIP.mock.calls.map(([url]) => url)).toEqual([ + 'https://queue.fal.run/fal-ai/test/requests/req-7/status', + 'https://queue.fal.run/fal-ai/test/requests/req-7', + ]) + }) + + it('rejects a same-origin candidate whose path is not a queue path at all', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + request_id: 'req-9', + // Same origin, but neither shape matches `/{app…}/requests/{id}[/status]`. + status_url: 'https://queue.fal.run/admin', + response_url: 'https://queue.fal.run/a/b/c', + }), + { status: 200 } + ) + ) + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' })) + .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } })) + + await runFalQueue('fal-ai/test', { prompt: 'hi' }, 'fal-key') + + expect(mockSecureFetchWithPinnedIP.mock.calls.map(([url]) => url)).toEqual([ + 'https://queue.fal.run/fal-ai/test/requests/req-9/status', + 'https://queue.fal.run/fal-ai/test/requests/req-9', + ]) + }) + + it('bounds every queue read with the shared Fal queue JSON cap', async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ request_id: 'req-8' }), { status: 200 }) + ) + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' })) + .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } })) + + await runFalQueue('fal-ai/test', { prompt: 'hi' }, 'fal-key') + + for (const [, , options] of mockSecureFetchWithPinnedIP.mock.calls) { + expect(options.maxResponseBytes).toBe(MAX_FAL_QUEUE_JSON_BYTES) + } + }) + + it('honors a status_url that stays on the Fal.ai queue origin', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + request_id: 'req-3', + status_url: 'https://queue.fal.run/fal-ai/other/requests/req-3/status', + }), + { status: 200 } + ) + ) + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' })) + .mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } })) + + await runFalQueue('fal-ai/test', { prompt: 'hi' }, 'fal-key') + + expect(mockSecureFetchWithPinnedIP.mock.calls[0][0]).toBe( + 'https://queue.fal.run/fal-ai/other/requests/req-3/status' + ) + }) +}) diff --git a/apps/sim/lib/media/falai.ts b/apps/sim/lib/media/falai.ts index 21a36dfbad9..d1e61bc3cd2 100644 --- a/apps/sim/lib/media/falai.ts +++ b/apps/sim/lib/media/falai.ts @@ -18,7 +18,14 @@ const logger = createLogger('FalMediaClient') // Generated media (esp. video) can be large. export const MAX_MEDIA_BYTES = 250 * 1024 * 1024 -const MAX_MEDIA_JSON_BYTES = 4 * 1024 * 1024 + +/** + * Cap for Fal.ai queue status/result envelopes, shared by every caller so the same + * completion cannot succeed through one entry point and fail through another. The envelope + * itself is kilobyte-scale, but some models inline the output as a base64 data URI, so the + * bound keeps headroom for that while still capping what a single poll can buffer. + */ +export const MAX_FAL_QUEUE_JSON_BYTES = 4 * 1024 * 1024 const POLL_INTERVAL_MS = 3000 /** @@ -61,8 +68,92 @@ export function getNumberProp( return typeof value === 'number' ? value : undefined } -function falQueueUrl(endpoint: string, requestId: string, path: 'status' | 'response'): string { - return `https://queue.fal.run/${endpoint}/requests/${requestId}/${path}` +export const FAL_QUEUE_ORIGIN = 'https://queue.fal.run' + +/** + * Fal.ai routes queue polling under the app id (`{owner}/{alias}`) ONLY — a model's sub-path + * is part of the submit URL but not the queue URL. `queue.fal.run` answers + * `/fal-ai/kling-video/requests/{id}/status` but 405s on + * `/fal-ai/kling-video/v3/pro/text-to-video/requests/{id}/status`. + */ +function falQueueAppId(endpoint: string): string { + return endpoint.split('/').slice(0, 2).join('/') +} + +/** + * Builds a Fal.ai queue URL. `status` polls progress; `result` fetches the completed payload + * and takes no path suffix (`/response` is not a route — `queue.fal.run` 405s on it). + */ +export function buildFalQueueUrl( + endpoint: string, + requestId: string, + kind: 'status' | 'result' +): string { + const base = `${FAL_QUEUE_ORIGIN}/${falQueueAppId(endpoint)}/requests/${requestId}` + return kind === 'status' ? `${base}/status` : base +} + +/** The only queue paths `queue.fal.run` routes: `/{app}/requests/{id}` and `.../status`. */ +const FAL_QUEUE_PATH_PATTERN = /^\/(?:[^/]+\/)+requests\/[^/]+(?:\/status)?$/u + +/** + * Accepts a queue URL echoed back by Fal.ai only while it stays on the queue origin we + * submitted to AND matches a routable queue path, otherwise falls back to the URL we + * construct ourselves. Queue polling carries `Authorization: Key `, so an + * attacker-influenced `status_url` / `response_url` would otherwise hand the Fal.ai key to + * an arbitrary host. Origin alone is not enough: Fal.ai echoes a `response_url` suffixed + * with `/response`, which is not a GET route (405), so that suffix is stripped first. + */ +export function resolveFalQueueUrl(candidate: string | undefined, fallback: string): string { + if (!candidate) return fallback + + let url: URL + try { + url = new URL(candidate) + } catch { + logger.warn('Fal.ai queue URL is not a valid URL; using constructed fallback', { fallback }) + return fallback + } + + if (url.origin !== FAL_QUEUE_ORIGIN) { + logger.warn('Fal.ai queue URL left the queue origin; using constructed fallback', { + candidate, + fallback, + }) + return fallback + } + + const path = url.pathname.replace(/\/response$/u, '') + const wantsStatus = fallback.endsWith('/status') + if (!FAL_QUEUE_PATH_PATTERN.test(path) || path.endsWith('/status') !== wantsStatus) { + logger.warn('Fal.ai queue URL is not a routable queue path; using constructed fallback', { + candidate, + fallback, + }) + return fallback + } + + const normalized = `${FAL_QUEUE_ORIGIN}${path}${url.search}` + if (normalized !== candidate) { + logger.warn('Normalized Fal.ai queue URL to a routable shape', { candidate, normalized }) + } + return normalized +} + +/** + * Requests a Fal.ai queue URL through the SSRF-guarded client. Invoked on every poll so the + * provider-supplied URL is revalidated each time rather than trusted once. + */ +async function fetchFalQueue(url: string, apiKey: string) { + const validation = await validateUrlWithDNS(url, 'falQueueUrl') + if (!validation.isValid || !validation.resolvedIP) { + throw new Error(validation.error || 'Fal.ai queue URL failed validation') + } + return secureFetchWithPinnedIP(url, validation.resolvedIP, { + method: 'GET', + headers: { Authorization: `Key ${apiKey}` }, + maxResponseBytes: MAX_FAL_QUEUE_JSON_BYTES, + }) } function falErrorMessage(error: unknown): string { @@ -85,7 +176,7 @@ export async function runFalQueue( input: Record, apiKey: string ): Promise { - const createResponse = await fetch(`https://queue.fal.run/${endpoint}`, { + const createResponse = await fetch(`${FAL_QUEUE_ORIGIN}/${endpoint}`, { method: 'POST', headers: { Authorization: `Key ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(input), @@ -99,7 +190,7 @@ export async function runFalQueue( } const createData = await readResponseJsonWithLimit(createResponse, { - maxBytes: MAX_MEDIA_JSON_BYTES, + maxBytes: MAX_FAL_QUEUE_JSON_BYTES, label: 'Fal.ai create response', }) if (!isRecordLike(createData)) throw new Error('Invalid Fal.ai queue response') @@ -107,16 +198,20 @@ export async function runFalQueue( const requestId = getStringProp(createData, 'request_id') if (!requestId) throw new Error('Fal.ai queue response missing request_id') - const statusUrl = - getStringProp(createData, 'status_url') || falQueueUrl(endpoint, requestId, 'status') - const responseUrl = - getStringProp(createData, 'response_url') || falQueueUrl(endpoint, requestId, 'response') + const statusUrl = resolveFalQueueUrl( + getStringProp(createData, 'status_url'), + buildFalQueueUrl(endpoint, requestId, 'status') + ) + const responseUrl = resolveFalQueueUrl( + getStringProp(createData, 'response_url'), + buildFalQueueUrl(endpoint, requestId, 'result') + ) const maxAttempts = Math.ceil(getMaxExecutionTimeout() / POLL_INTERVAL_MS) for (let attempt = 0; attempt < maxAttempts; attempt++) { await sleep(POLL_INTERVAL_MS) - const statusResponse = await fetch(statusUrl, { headers: { Authorization: `Key ${apiKey}` } }) + const statusResponse = await fetchFalQueue(statusUrl, apiKey) if (!statusResponse.ok) { const body = await readResponseTextWithLimit(statusResponse, { maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, @@ -128,7 +223,7 @@ export async function runFalQueue( } const statusData = await readResponseJsonWithLimit(statusResponse, { - maxBytes: MAX_MEDIA_JSON_BYTES, + maxBytes: MAX_FAL_QUEUE_JSON_BYTES, label: 'Fal.ai status response', }) if (!isRecordLike(statusData)) throw new Error('Invalid Fal.ai status response') @@ -138,9 +233,10 @@ export async function runFalQueue( if (statusData.error) { throw new Error(`Fal.ai generation failed: ${falErrorMessage(statusData.error)}`) } - const resultResponse = await fetch(getStringProp(statusData, 'response_url') || responseUrl, { - headers: { Authorization: `Key ${apiKey}` }, - }) + const resultResponse = await fetchFalQueue( + resolveFalQueueUrl(getStringProp(statusData, 'response_url'), responseUrl), + apiKey + ) if (!resultResponse.ok) { const body = await readResponseTextWithLimit(resultResponse, { maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, @@ -151,7 +247,7 @@ export async function runFalQueue( ) } const resultData = await readResponseJsonWithLimit(resultResponse, { - maxBytes: MAX_MEDIA_JSON_BYTES, + maxBytes: MAX_FAL_QUEUE_JSON_BYTES, label: 'Fal.ai result response', }) if (!isRecordLike(resultData)) throw new Error('Invalid Fal.ai result response') diff --git a/apps/sim/lib/media/ffmpeg.test.ts b/apps/sim/lib/media/ffmpeg.test.ts new file mode 100644 index 00000000000..01178a290d1 --- /dev/null +++ b/apps/sim/lib/media/ffmpeg.test.ts @@ -0,0 +1,209 @@ +/** + * @vitest-environment node + */ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildDrawtextFilter, escapeFilterValue } from '@/lib/media/ffmpeg' + +/** Metacharacters that must survive both filtergraph tokenizer passes. */ +const METACHARACTER_CASES: Array<[string, string]> = [ + ['a:b', 'a\\\\\\:b'], + ["a'b", "a\\\\\\'b"], + ['a\\b', 'a\\\\\\\\b'], + ['a,b', 'a\\\\\\,b'], + ['a;b', 'a\\\\\\;b'], + ['a[b]', 'a\\\\\\[b\\\\\\]'], + ['a=b', 'a\\\\\\=b'], +] + +/** + * FFmpeg is not available in CI, so these assert the generated filtergraph + * string and the on-disk text file rather than the rendered output. The + * `runs through real ffmpeg` block below covers the grammar end to end where a + * binary exists. + */ +describe('escapeFilterValue', () => { + it.concurrent.each(METACHARACTER_CASES)('escapes %j at both levels', (input, expected) => { + expect(escapeFilterValue(input)).toBe(expected) + }) + + it.concurrent('leaves ordinary paths and newlines untouched', () => { + expect(escapeFilterValue('/tmp/media-ffmpeg-abc123/drawtext.txt')).toBe( + '/tmp/media-ffmpeg-abc123/drawtext.txt' + ) + expect(escapeFilterValue('a\nb')).toBe('a\nb') + }) + + it.concurrent('never leaves a bare quote that could open a quoted section', () => { + expect(escapeFilterValue("x':y=1")).not.toMatch(/(^|[^\\])'/) + }) +}) + +describe('buildDrawtextFilter', () => { + let dir: string + + beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ffmpeg-test-')) + }) + + afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + it('routes the text through textfile and never inlines it', async () => { + const { filter, textPath } = await buildDrawtextFilter(dir, 'Hello world', 'bottom') + + expect(filter.startsWith('drawtext=')).toBe(true) + expect(filter).toContain(`textfile=${textPath}`) + expect(filter).not.toContain('Hello world') + expect(filter).not.toContain(':text=') + expect(await fs.readFile(textPath, 'utf-8')).toBe('Hello world') + }) + + it('writes a distinct text file per call so two overlays cannot clobber each other', async () => { + const first = await buildDrawtextFilter(dir, 'first overlay', 'bottom') + const second = await buildDrawtextFilter(dir, 'second overlay', 'bottom') + + expect(second.textPath).not.toBe(first.textPath) + expect(await fs.readFile(first.textPath, 'utf-8')).toBe('first overlay') + expect(await fs.readFile(second.textPath, 'utf-8')).toBe('second overlay') + }) + + it('disables %{} expansion so text renders literally', async () => { + const { filter, textPath } = await buildDrawtextFilter(dir, '100% of %{pts}', 'bottom') + + expect(filter).toContain('expansion=none') + expect(await fs.readFile(textPath, 'utf-8')).toBe('100% of %{pts}') + }) + + it('cannot introduce a new filter option from the text value', async () => { + const injection = "a':x=90:fontcolor=red,drawbox=0:0:100:100:red\\:,[in]scale=2[out];" + const { filter, textPath } = await buildDrawtextFilter(dir, injection, 'bottom') + + const options = filter.replace(/^drawtext=/, '').split(':') + const optionNames = options.map((option) => option.split('=')[0]) + + expect(optionNames).toEqual([ + 'textfile', + 'expansion', + 'fontcolor', + 'fontsize', + 'box', + 'boxcolor', + 'boxborderw', + 'x', + 'y', + ]) + expect(filter).not.toContain('drawbox') + expect(filter).not.toContain('fontcolor=red') + expect(filter).toContain('fontcolor=white') + expect(filter).not.toContain('x=90') + expect(await fs.readFile(textPath, 'utf-8')).toBe(injection) + }) + + it.each([ + ['single quote', "it's here"], + ['colon', 'ratio 16:9'], + ['backslash', 'C:\\path\\to'], + ['comma', 'a, b, c'], + ['newline', 'line one\nline two'], + ['semicolon and brackets', 'a;b[c]d'], + ['textfile option injection', 'x:textfile=/etc/passwd'], + ['fontfile option injection', 'x:fontfile=/etc/shadow'], + ])('writes %s verbatim to the text file', async (_label, text) => { + const { filter, textPath } = await buildDrawtextFilter(dir, text, 'center') + + expect(await fs.readFile(textPath, 'utf-8')).toBe(text) + expect(filter.replace(/^drawtext=/, '').split(':').length).toBe(9) + expect(filter).toContain('x=(w-text_w)/2') + expect(filter).toContain('y=(h-text_h)/2') + expect(filter.match(/textfile=/g)?.length).toBe(1) + }) + + it('escapes a temp dir path containing filtergraph metacharacters', async () => { + const trickyDir = path.join(dir, "a:b'c[d]") + await fs.mkdir(trickyDir, { recursive: true }) + + const { filter, textPath } = await buildDrawtextFilter(trickyDir, 'text', 'top') + + // The literal expected escaping, not a restatement of the implementation: + // each metacharacter survives as three backslashes plus itself. + const escapedSegment = String.raw`a\\\:b\\\'c\\\[d\\\]` + expect(filter).toContain(`textfile=${dir}/${escapedSegment}/${path.basename(textPath)}`) + expect(filter).not.toContain(`textfile=${trickyDir}/`) + }) + + it('falls back to the bottom position for an unknown position', async () => { + const { filter } = await buildDrawtextFilter(dir, 'text', 'nowhere') + + expect(filter).toContain('y=h*0.86') + }) +}) + +function hasFfmpeg(): boolean { + try { + execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +/** + * Renders one frame against the real binary so the escaping is validated by + * FFmpeg's own parser rather than by a restatement of it. + */ +describe.skipIf(!hasFfmpeg())('runs through real ffmpeg', () => { + let base: string + + beforeAll(async () => { + base = await fs.mkdtemp(path.join(os.tmpdir(), 'ffmpeg-real-')) + }) + + afterAll(async () => { + await fs.rm(base, { recursive: true, force: true }) + }) + + it.each([ + ['plain', 'plain'], + ['quote', "a'b"], + ['colon', 'a:b'], + ['backslash', 'a\\b'], + ['comma', 'a,b'], + ['semicolon', 'a;b'], + ['equals', 'a=b'], + ['brackets', 'a[b]c'], + ['newline', 'a\nb'], + ['every metacharacter', "all'-:,;=[]\\-\nmix"], + ])('reads the text file from a dir containing %s', async (_label, name) => { + const dir = path.join(base, name) + await fs.mkdir(dir, { recursive: true }) + const { filter } = await buildDrawtextFilter(dir, 'hello', 'bottom') + + expect(() => + execFileSync( + 'ffmpeg', + [ + '-hide_banner', + '-loglevel', + 'error', + '-f', + 'lavfi', + '-i', + 'color=c=black:s=64x64:d=0.04', + '-vf', + filter, + '-frames:v', + '1', + '-f', + 'null', + '-', + ], + { stdio: ['ignore', 'pipe', 'pipe'] } + ) + ).not.toThrow() + }) +}) diff --git a/apps/sim/lib/media/ffmpeg.ts b/apps/sim/lib/media/ffmpeg.ts index bcfa0b6adf0..a07b32b2b19 100644 --- a/apps/sim/lib/media/ffmpeg.ts +++ b/apps/sim/lib/media/ffmpeg.ts @@ -3,6 +3,7 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' import ffmpeg from 'fluent-ffmpeg' const logger = createLogger('MediaFfmpeg') @@ -169,8 +170,64 @@ const TEXT_POSITION: Record = { 'bottom-right': { x: 'w*0.95-text_w', y: 'h*0.86' }, } -function escapeDrawtext(text: string): string { - return text.replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'").replace(/%/g, '\\%') +const FILTER_METACHARACTERS = /[\\':,;[\]=]/g + +const escapeFilterLevel = (value: string) => value.replace(FILTER_METACHARACTERS, (c) => `\\${c}`) + +/** + * Escapes a value for use inside an FFmpeg filtergraph option, outside quotes. + * + * A filtergraph option value is tokenized twice — once when the graph + * description is split into filters and their argument lists, and again when a + * filter's arguments are split into `name=value` pairs — and each pass consumes + * one level of backslash escapes. A value must therefore be escaped twice. + * + * Escaping `'` once is actively wrong: the surviving `'` opens a quoted section + * and swallows every following option into the value. Quoting the value instead + * is no defence either, since FFmpeg honours no escapes inside `'…'`. + * + * @see https://ffmpeg.org/ffmpeg-filters.html#Notes-on-filtergraph-escaping + */ +function escapeFilterValue(value: string): string { + return escapeFilterLevel(escapeFilterLevel(value)) +} + +/** + * Writes caller-supplied overlay text to a file inside the operation's temp dir + * and returns the `drawtext` filter string that reads it, plus the path written. + * + * Routing the text through `textfile=` keeps it out of the filter-option string + * entirely, so no value in `text` can introduce or alter a filter option. Only + * the temp-dir path — generated by `fs.mkdtemp`, never caller-controlled — + * reaches the filtergraph, and it is escaped for filesystems whose paths can + * contain filtergraph metacharacters. `expansion=none` disables `%{…}` text + * expansion so the content renders literally. + * + * The filename is derived per call so two overlays built in the same temp dir + * cannot clobber each other's text. + */ +async function buildDrawtextFilter( + dir: string, + text: string, + position: string | undefined +): Promise<{ filter: string; textPath: string }> { + const pos = TEXT_POSITION[position || 'bottom'] || TEXT_POSITION.bottom + const textPath = path.join(dir, `drawtext-${generateShortId(8)}.txt`) + await fs.writeFile(textPath, text, 'utf-8') + + const options = [ + `textfile=${escapeFilterValue(textPath)}`, + 'expansion=none', + 'fontcolor=white', + 'fontsize=h/18', + 'box=1', + 'boxcolor=black@0.5', + 'boxborderw=20', + `x=${pos.x}`, + `y=${pos.y}`, + ].join(':') + + return { filter: `drawtext=${options}`, textPath } } async function withTempDir(fn: (dir: string) => Promise): Promise { @@ -479,21 +536,9 @@ async function addText( options: FfmpegOptions ): Promise { if (!options.text) throw new Error('add_text requires text') - const pos = TEXT_POSITION[options.position || 'bottom'] || TEXT_POSITION.bottom - const drawtext = [ - `text='${escapeDrawtext(options.text)}'`, - 'fontcolor=white', - 'fontsize=h/18', - 'box=1', - 'boxcolor=black@0.5', - 'boxborderw=20', - `x=${pos.x}`, - `y=${pos.y}`, - ].join(':') + const { filter } = await buildDrawtextFilter(dir, options.text, options.position) const outputPath = path.join(dir, 'out.mp4') - const command = ffmpeg(inputPath) - .videoFilters(`drawtext=${drawtext}`) - .outputOptions(['-c:a', 'copy']) + const command = ffmpeg(inputPath).videoFilters(filter).outputOptions(['-c:a', 'copy']) await runCommand(command, outputPath) return readOut(outputPath, 'mp4') } @@ -557,4 +602,4 @@ async function thumbnail( return readOut(outputPath, 'jpg') } -export { extFromMime, mimeFromExt } +export { buildDrawtextFilter, escapeFilterValue, extFromMime, mimeFromExt } diff --git a/apps/sim/lib/messaging/email/free-email-domains.json b/apps/sim/lib/messaging/email/free-email-domains.json new file mode 100644 index 00000000000..fdeb9fdb5d5 --- /dev/null +++ b/apps/sim/lib/messaging/email/free-email-domains.json @@ -0,0 +1,4781 @@ +[ + "0-mail.com", + "027168.com", + "0815.su", + "0sg.net", + "10mail.org", + "10minutemail.co.za", + "10minutemail.com", + "11mail.com", + "123.com", + "123box.net", + "123india.com", + "123mail.cl", + "123mail.org", + "123qwe.co.uk", + "126.com", + "139.com", + "150mail.com", + "150ml.com", + "15meg4free.com", + "163.com", + "16mail.com", + "188.com", + "189.cn", + "1ce.us", + "1chuan.com", + "1coolplace.com", + "1freeemail.com", + "1funplace.com", + "1internetdrive.com", + "1mail.ml", + "1mail.net", + "1me.net", + "1mum.com", + "1musicrow.com", + "1netdrive.com", + "1nsyncfan.com", + "1pad.de", + "1under.com", + "1webave.com", + "1webhighway.com", + "1zhuan.com", + "2-mail.com", + "20email.eu", + "20mail.in", + "20mail.it", + "212.com", + "21cn.com", + "24horas.com", + "2911.net", + "2980.com", + "2bmail.co.uk", + "2d2i.com", + "2die4.com", + "2trom.com", + "3000.it", + "30minutesmail.com", + "3126.com", + "321media.com", + "33mail.com", + "37.com", + "3ammagazine.com", + "3dmail.com", + "3email.com", + "3g.ua", + "3mail.ga", + "3xl.net", + "444.net", + "4email.com", + "4email.net", + "4mg.com", + "4newyork.com", + "4warding.net", + "4warding.org", + "4x4man.com", + "50mail.com", + "60minutemail.com", + "6ip.us", + "6mail.cf", + "6paq.com", + "74.ru", + "74gmail.com", + "7mail.ga", + "7mail.ml", + "88.am", + "8848.net", + "8mail.ga", + "8mail.ml", + "97rock.com", + "99experts.com", + "a45.in", + "aaamail.zzn.com", + "aamail.net", + "aapt.net.au", + "aaronkwok.net", + "abbeyroadlondon.co.uk", + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijk.com", + "abcflash.net", + "abdulnour.com", + "aberystwyth.com", + "about.com", + "abusemail.de", + "abv.bg", + "abwesend.de", + "abyssmail.com", + "ac20mail.in", + "academycougars.com", + "acceso.or.cr", + "access4less.net", + "accessgcc.com", + "accountant.com", + "acdcfan.com", + "ace-of-base.com", + "acmemail.net", + "acninc.net", + "activist.com", + "adam.com.au", + "add3000.pp.ua", + "addcom.de", + "address.com", + "adelphia.net", + "adexec.com", + "adfarrow.com", + "adios.net", + "adoption.com", + "ados.fr", + "adrenalinefreak.com", + "advalvas.be", + "advantimo.com", + "aeiou.pt", + "aemail4u.com", + "aeneasmail.com", + "afreeinternet.com", + "africamail.com", + "africamel.net", + "ag.us.to", + "agoodmail.com", + "ahaa.dk", + "ahk.jp", + "aichi.com", + "aim.com", + "aircraftmail.com", + "airforce.net", + "airforceemail.com", + "airpost.net", + "ajacied.com", + "ajaxapp.net", + "ak47.hu", + "aknet.kg", + "albawaba.com", + "alex4all.com", + "alexandria.cc", + "algeria.com", + "alhilal.net", + "alibaba.com", + "alice.it", + "alive.cz", + "aliyun.com", + "allergist.com", + "allmail.net", + "alloymail.com", + "allracing.com", + "allsaintsfan.com", + "alpenjodel.de", + "alphafrau.de", + "alskens.dk", + "altavista.com", + "altavista.net", + "altavista.se", + "alternativagratis.com", + "alumni.com", + "alumnidirector.com", + "alvilag.hu", + "amail.com", + "amazonses.com", + "amele.com", + "america.hm", + "ameritech.net", + "amnetsal.com", + "amorki.pl", + "amrer.net", + "amuro.net", + "amuromail.com", + "ananzi.co.za", + "andylau.net", + "anfmail.com", + "angelfire.com", + "angelic.com", + "animail.net", + "animalhouse.com", + "animalwoman.net", + "anjungcafe.com", + "annsmail.com", + "ano-mail.net", + "anonmails.de", + "anonymous.to", + "anote.com", + "another.com", + "anotherdomaincyka.tk", + "anotherwin95.com", + "anti-social.com", + "antisocial.com", + "antispam24.de", + "antongijsen.com", + "antwerpen.com", + "anymoment.com", + "anytimenow.com", + "aol.com", + "aon.at", + "apexmail.com", + "apmail.com", + "apollo.lv", + "aport.ru", + "aport2000.ru", + "appraiser.net", + "approvers.net", + "arabia.com", + "arabtop.net", + "archaeologist.com", + "arcor.de", + "arcotronics.bg", + "arcticmail.com", + "argentina.com", + "aristotle.org", + "army.net", + "armyspy.com", + "arnet.com.ar", + "art-en-ligne.pro", + "artlover.com", + "artlover.com.au", + "as-if.com", + "asdasd.nl", + "asean-mail.com", + "asheville.com", + "asia-links.com", + "asia-mail.com", + "asiafind.com", + "asianavenue.com", + "asiancityweb.com", + "asiansonly.net", + "asianwired.net", + "asiapoint.net", + "ass.pp.ua", + "assala.com", + "assamesemail.com", + "astroboymail.com", + "astrolover.com", + "astrosfan.com", + "astrosfan.net", + "asurfer.com", + "atheist.com", + "athenachu.net", + "atina.cl", + "atl.lv", + "atlaswebmail.com", + "atmc.net", + "atozasia.com", + "atrus.ru", + "att.net", + "attglobal.net", + "attymail.com", + "au.ru", + "auctioneer.net", + "ausi.com", + "aussiemail.com.au", + "austin.rr.com", + "australia.edu", + "australiamail.com", + "austrosearch.net", + "autoescuelanerja.com", + "autograf.pl", + "autorambler.ru", + "avh.hu", + "avia-tonic.fr", + "awsom.net", + "axoskate.com", + "ayna.com", + "azazazatashkent.tk", + "azimiweb.com", + "azmeil.tk", + "bachelorboy.com", + "bachelorgal.com", + "backpackers.com", + "backstreet-boys.com", + "backstreetboysclub.com", + "bagherpour.com", + "baldmama.de", + "baldpapa.de", + "ballyfinance.com", + "bangkok.com", + "bangkok2000.com", + "bannertown.net", + "baptistmail.com", + "baptized.com", + "barcelona.com", + "bareed.ws", + "bartender.net", + "baseballmail.com", + "basketballmail.com", + "batuta.net", + "baudoinconsulting.com", + "bboy.zzn.com", + "bcvibes.com", + "beddly.com", + "beeebank.com", + "beenhad.com", + "beep.ru", + "beer.com", + "beethoven.com", + "belice.com", + "belizehome.com", + "bell.net", + "bellair.net", + "bellsouth.net", + "berlin.com", + "berlin.de", + "berlinexpo.de", + "bestmail.us", + "betriebsdirektor.de", + "bettergolf.net", + "bharatmail.com", + "big1.us", + "bigassweb.com", + "bigblue.net.au", + "bigboab.com", + "bigfoot.com", + "bigfoot.de", + "bigger.com", + "biggerbadder.com", + "bigmailbox.com", + "bigmir.net", + "bigpond.au", + "bigpond.com", + "bigpond.com.au", + "bigpond.net", + "bigpond.net.au", + "bigramp.com", + "bigstring.com", + "bikemechanics.com", + "bikeracer.com", + "bikeracers.net", + "bikerider.com", + "billsfan.com", + "billsfan.net", + "bimla.net", + "bin-wieder-da.de", + "bio-muesli.info", + "birdlover.com", + "birdowner.net", + "bisons.com", + "bitmail.com", + "bitpage.net", + "bizhosting.com", + "bk.ru", + "blackburnmail.com", + "blackplanet.com", + "blader.com", + "bladesmail.net", + "blazemail.com", + "bleib-bei-mir.de", + "blockfilter.com", + "blogmyway.org", + "bluebottle.com", + "bluehyppo.com", + "bluemail.ch", + "bluemail.dk", + "bluesfan.com", + "bluewin.ch", + "blueyonder.co.uk", + "blushmail.com", + "blutig.me", + "bmlsports.net", + "boardermail.com", + "boatracers.com", + "bodhi.lawlita.com", + "bol.com.br", + "bolando.com", + "bollywoodz.com", + "boltonfans.com", + "bombdiggity.com", + "bonbon.net", + "boom.com", + "bootmail.com", + "bootybay.de", + "bornnaked.com", + "bostonoffice.com", + "boun.cr", + "bounce.net", + "bounces.amazon.com", + "bouncr.com", + "box.az", + "box.ua", + "boxbg.com", + "boxemail.com", + "boxformail.in", + "boxfrog.com", + "boximail.com", + "boyzoneclub.com", + "bradfordfans.com", + "brasilia.net", + "brazilmail.com", + "brazilmail.com.br", + "breadtimes.press", + "breathe.com", + "brennendesreich.de", + "bresnan.net", + "brew-master.com", + "brew-meister.com", + "brfree.com.br", + "briefemail.com", + "bright.net", + "britneyclub.com", + "brittonsign.com", + "broadcast.net", + "brokenvalve.com", + "brusseler.com", + "bsdmail.com", + "btcmail.pw", + "btconnect.co.uk", + "btconnect.com", + "btinternet.com", + "btopenworld.co.uk", + "buerotiger.de", + "buffymail.com", + "bullsfan.com", + "bullsgame.com", + "bumerang.ro", + "bumpymail.com", + "bund.us", + "burnthespam.info", + "burstmail.info", + "buryfans.com", + "business-man.com", + "businessman.net", + "busta-rhymes.com", + "buyersusa.com", + "bvimailbox.com", + "byom.de", + "c2.hu", + "c2i.net", + "c3.hu", + "c4.com", + "c51vsgq.com", + "cabacabana.com", + "cable.comcast.com", + "cableone.net", + "caere.it", + "cairomail.com", + "calendar-server.bounces.google.com", + "calidifontain.be", + "californiamail.com", + "callnetuk.com", + "callsign.net", + "caltanet.it", + "camidge.com", + "canada-11.com", + "canada.com", + "canadianmail.com", + "canoemail.com", + "canwetalk.com", + "caramail.com", + "care2.com", + "careerbuildermail.com", + "carioca.net", + "cartelera.org", + "cartestraina.ro", + "casablancaresort.com", + "casema.nl", + "cash4u.com", + "cashette.com", + "casino.com", + "catcha.com", + "catchamail.com", + "catholic.org", + "catlover.com", + "cd2.com", + "celineclub.com", + "celtic.com", + "center-mail.de", + "centermail.at", + "centermail.de", + "centermail.info", + "centoper.it", + "centralpets.com", + "centrum.cz", + "centrum.sk", + "centurytel.net", + "certifiedmail.com", + "cfl.rr.com", + "cgac.es", + "cghost.s-a-d.de", + "chacuo.net", + "chaiyomail.com", + "chammy.info", + "chance2mail.com", + "chandrasekar.net", + "charmedmail.com", + "charter.net", + "chat.ru", + "chattown.com", + "chauhanweb.com", + "cheatmail.de", + "chechnya.conf.work", + "check.com", + "check1check.com", + "cheerful.com", + "chef.net", + "chek.com", + "chello.nl", + "chemist.com", + "chequemail.com", + "cheyenneweb.com", + "chez.com", + "chickmail.com", + "china.com", + "china.net.vg", + "chinamail.com", + "chirk.com", + "chocaholic.com.au", + "chong-mail.com", + "chong-mail.net", + "churchusa.com", + "cia-agent.com", + "cia.hu", + "ciaoweb.it", + "cicciociccio.com", + "cinci.rr.com", + "cincinow.net", + "citiz.net", + "citlink.net", + "citromail.hu", + "city-of-bath.org", + "city-of-birmingham.com", + "city-of-brighton.org", + "city-of-cambridge.com", + "city-of-coventry.com", + "city-of-edinburgh.com", + "city-of-lichfield.com", + "city-of-lincoln.com", + "city-of-liverpool.com", + "city-of-manchester.com", + "city-of-nottingham.com", + "city-of-oxford.com", + "city-of-swansea.com", + "city-of-westminster.com", + "city-of-westminster.net", + "city-of-york.net", + "cityofcardiff.net", + "cityoflondon.org", + "ckaazaza.tk", + "claramail.com", + "classicalfan.com", + "classicmail.co.za", + "clear.net.nz", + "clearwire.net", + "clerk.com", + "cliffhanger.com", + "clixser.com", + "close2you.net", + "clrmail.com", + "club4x4.net", + "clubalfa.com", + "clubbers.net", + "clubducati.com", + "clubhonda.net", + "clubmember.org", + "clubnetnoir.com", + "clubvdo.net", + "cluemail.com", + "cmail.net", + "cmpmail.com", + "cnnsimail.com", + "cntv.cn", + "codec.ro", + "coder.hu", + "coid.biz", + "coldmail.com", + "collectiblesuperstore.com", + "collector.org", + "collegeclub.com", + "collegemail.com", + "colleges.com", + "columbus.rr.com", + "columbusrr.com", + "columnist.com", + "comcast.net", + "comic.com", + "communityconnect.com", + "comporium.net", + "comprendemail.com", + "compuserve.com", + "computer-freak.com", + "computer4u.com", + "computermail.net", + "conexcol.com", + "conk.com", + "connect4free.net", + "connectbox.com", + "consultant.com", + "consumerriot.com", + "contractor.net", + "contrasto.cu.cc", + "cookiemonster.com", + "cool.br", + "coole-files.de", + "coolgoose.ca", + "coolgoose.com", + "coolkiwi.com", + "coollist.com", + "coolmail.com", + "coolmail.net", + "coolsend.com", + "coolsite.net", + "cooooool.com", + "cooperation.net", + "cooperationtogo.net", + "copacabana.com", + "copper.net", + "cornells.com", + "cornerpub.com", + "corporatedirtbag.com", + "correo.terra.com.gt", + "cortinet.com", + "cotas.net", + "counsellor.com", + "countrylover.com", + "cox.com", + "cox.net", + "coxinet.net", + "cracker.hu", + "crapmail.org", + "crazedanddazed.com", + "crazymailing.com", + "crazysexycool.com", + "cristianemail.com", + "critterpost.com", + "croeso.com", + "crosshairs.com", + "crosswinds.net", + "crwmail.com", + "cry4helponline.com", + "cs.com", + "csinibaba.hu", + "cuemail.com", + "curio-city.com", + "curryworld.de", + "cute-girl.com", + "cuteandcuddly.com", + "cutey.com", + "cww.de", + "cyber-africa.net", + "cyber-innovation.club", + "cyber-matrix.com", + "cyber-phone.eu", + "cyber-wizard.com", + "cyber4all.com", + "cyberbabies.com", + "cybercafemaui.com", + "cyberdude.com", + "cyberforeplay.net", + "cybergal.com", + "cybergrrl.com", + "cyberinbox.com", + "cyberleports.com", + "cybermail.net", + "cybernet.it", + "cyberservices.com", + "cyberspace-asia.com", + "cybertrains.org", + "cyclefanz.com", + "cynetcity.com", + "dabsol.net", + "dadacasa.com", + "daha.com", + "dailypioneer.com", + "dallasmail.com", + "dangerous-minds.com", + "dansegulvet.com", + "dasdasdascyka.tk", + "data54.com", + "davegracey.com", + "dawnsonmail.com", + "dawsonmail.com", + "dazedandconfused.com", + "dbzmail.com", + "dcemail.com", + "deadlymob.org", + "deagot.com", + "deal-maker.com", + "dearriba.com", + "death-star.com", + "deliveryman.com", + "deneg.net", + "depechemode.com", + "deseretmail.com", + "desertmail.com", + "desilota.com", + "deskpilot.com", + "destin.com", + "detik.com", + "deutschland-net.com", + "devotedcouples.com", + "dezigner.ru", + "dfwatson.com", + "di-ve.com", + "die-besten-bilder.de", + "die-genossen.de", + "die-optimisten.de", + "die-optimisten.net", + "diemailbox.de", + "digibel.be", + "digital-filestore.de", + "diplomats.com", + "directbox.com", + "dirtracer.com", + "discard.email", + "discard.ga", + "discard.gq", + "disciples.com", + "discofan.com", + "discoverymail.com", + "disign-concept.eu", + "disign-revelation.com", + "disinfo.net", + "dispomail.eu", + "disposable.com", + "dispose.it", + "dm.w3internet.co.uk", + "dmailman.com", + "dnainternet.net", + "dnsmadeeasy.com", + "doclist.bounces.google.com", + "docmail.cz", + "docs.google.com", + "doctor.com", + "dodgit.org", + "dodo.com.au", + "dodsi.com", + "dog.com", + "dogit.com", + "doglover.com", + "dogmail.co.uk", + "dogsnob.net", + "doityourself.com", + "domforfb1.tk", + "domforfb2.tk", + "domforfb3.tk", + "domforfb4.tk", + "domforfb5.tk", + "domforfb6.tk", + "domforfb7.tk", + "domforfb8.tk", + "domozmail.com", + "doneasy.com", + "donjuan.com", + "dontgotmail.com", + "dontmesswithtexas.com", + "doramail.com", + "dostmail.com", + "dotcom.fr", + "dotmsg.com", + "dott.it", + "download-privat.de", + "dplanet.ch", + "dr.com", + "dragoncon.net", + "dropmail.me", + "dropzone.com", + "drotposta.hu", + "dubaimail.com", + "dublin.com", + "dublin.ie", + "duck.com", + "dumpmail.com", + "dumpmail.de", + "dumpyemail.com", + "dunlopdriver.com", + "dunloprider.com", + "duno.com", + "duskmail.com", + "dutchmail.com", + "dwp.net", + "dygo.com", + "dynamitemail.com", + "dyndns.org", + "e-apollo.lv", + "e-mail.com.tr", + "e-mail.dk", + "e-mail.ru", + "e-mail.ua", + "e-mailanywhere.com", + "e-mails.ru", + "e-tapaal.com", + "earthalliance.com", + "earthcam.net", + "earthdome.com", + "earthling.net", + "earthlink.net", + "earthonline.net", + "eastcoast.co.za", + "eastmail.com", + "easy.to", + "easypost.com", + "easytrashmail.com", + "ec.rr.com", + "ecardmail.com", + "ecbsolutions.net", + "echina.com", + "ecolo-online.fr", + "ecompare.com", + "edmail.com", + "ednatx.com", + "edtnmail.com", + "educacao.te.pt", + "eelmail.com", + "ehmail.com", + "einrot.com", + "einrot.de", + "eintagsmail.de", + "eircom.net", + "elisanet.fi", + "elitemail.org", + "elsitio.com", + "elvis.com", + "elvisfan.com", + "email-fake.gq", + "email-london.co.uk", + "email.biz", + "email.cbes.net", + "email.com", + "email.cz", + "email.ee", + "email.it", + "email.nu", + "email.org", + "email.ro", + "email.ru", + "email.si", + "email.su", + "email.ua", + "email2me.net", + "email4u.info", + "emailacc.com", + "emailaccount.com", + "emailage.ga", + "emailage.gq", + "emailasso.net", + "emailchoice.com", + "emailcorner.net", + "emailem.com", + "emailengine.net", + "emailengine.org", + "emailer.hubspot.com", + "emailforyou.net", + "emailgo.de", + "emailgroups.net", + "emailinfive.com", + "emailit.com", + "emailondeck.com", + "emailpinoy.com", + "emailplanet.com", + "emailplus.org", + "emailproxsy.com", + "emails.ga", + "emails.incisivemedia.com", + "emails.ru", + "emailthe.net", + "emailto.de", + "emailuser.net", + "emailx.net", + "emailz.ga", + "emailz.gq", + "ematic.com", + "embarqmail.com", + "emeil.in", + "emeil.ir", + "emil.com", + "eml.cc", + "eml.pp.ua", + "end-war.com", + "enel.net", + "engineer.com", + "england.com", + "england.edu", + "englandmail.com", + "epage.ru", + "epatra.com", + "ephemail.net", + "epix.net", + "epost.de", + "eposta.hu", + "eqqu.com", + "eramail.co.za", + "eresmas.com", + "eriga.lv", + "estranet.it", + "ethos.st", + "etoast.com", + "etrademail.com", + "etranquil.com", + "etranquil.net", + "eudoramail.com", + "europamel.net", + "europe.com", + "europemail.com", + "euroseek.com", + "eurosport.com", + "every1.net", + "everyday.com.kh", + "everymail.net", + "everyone.net", + "everytg.ml", + "examnotes.net", + "excite.co.jp", + "excite.com", + "excite.it", + "execs.com", + "exemail.com.au", + "exg6.exghost.com", + "existiert.net", + "expressasia.com", + "extenda.net", + "extended.com", + "eyepaste.com", + "eyou.com", + "ezcybersearch.com", + "ezmail.egine.com", + "ezmail.ru", + "ezrs.com", + "f-m.fm", + "f1fans.net", + "facebook-email.ga", + "facebook.com", + "facebookmail.com", + "facebookmail.gq", + "fahr-zur-hoelle.org", + "fake-email.pp.ua", + "fake-mail.cf", + "fake-mail.ga", + "fake-mail.ml", + "fakemailz.com", + "falseaddress.com", + "fan.com", + "fansonlymail.com", + "fansworldwide.de", + "fantasticmail.com", + "farang.net", + "farifluset.mailexpire.com", + "faroweb.com", + "fast-email.com", + "fast-mail.fr", + "fast-mail.org", + "fastacura.com", + "fastchevy.com", + "fastchrysler.com", + "fastem.com", + "fastemail.us", + "fastemailer.com", + "fastermail.com", + "fastest.cc", + "fastimap.com", + "fastkawasaki.com", + "fastmail.ca", + "fastmail.cn", + "fastmail.co.uk", + "fastmail.com", + "fastmail.com.au", + "fastmail.es", + "fastmail.fm", + "fastmail.im", + "fastmail.in", + "fastmail.jp", + "fastmail.mx", + "fastmail.net", + "fastmail.nl", + "fastmail.se", + "fastmail.to", + "fastmail.tw", + "fastmail.us", + "fastmailbox.net", + "fastmazda.com", + "fastmessaging.com", + "fastmitsubishi.com", + "fastnissan.com", + "fastservice.com", + "fastsubaru.com", + "fastsuzuki.com", + "fasttoyota.com", + "fastyamaha.com", + "fatcock.net", + "fatflap.com", + "fathersrightsne.org", + "fax.ru", + "fbi-agent.com", + "fbi.hu", + "fdfdsfds.com", + "fea.st", + "federalcontractors.com", + "feinripptraeger.de", + "felicitymail.com", + "femenino.com", + "fetchmail.co.uk", + "fettabernett.de", + "feyenoorder.com", + "ffanet.com", + "fiberia.com", + "ficken.de", + "fightallspam.com", + "filipinolinks.com", + "financemail.net", + "financier.com", + "findmail.com", + "finebody.com", + "fire-brigade.com", + "fireman.net", + "fishburne.org", + "fishfuse.com", + "fixmail.tk", + "fizmail.com", + "flashbox.5july.org", + "flashmail.com", + "flashmail.net", + "fleckens.hu", + "flipcode.com", + "fmail.co.uk", + "fmailbox.com", + "fmgirl.com", + "fmguy.com", + "fnbmail.co.za", + "fnmail.com", + "folkfan.com", + "foodmail.com", + "footard.com", + "footballmail.com", + "foothills.net", + "for-president.com", + "force9.co.uk", + "forfree.at", + "forgetmail.com", + "fornow.eu", + "forpresident.com", + "fortuncity.com", + "fortunecity.com", + "forum.dk", + "foxmail.com", + "fr33mail.info", + "francemel.fr", + "free-email.ga", + "free-online.net", + "free-org.com", + "free.com.pe", + "free.fr", + "freeaccess.nl", + "freeaccount.com", + "freeandsingle.com", + "freedom.usa.com", + "freedomlover.com", + "freegates.be", + "freeghana.com", + "freelance-france.eu", + "freeler.nl", + "freemail.c3.hu", + "freemail.com.au", + "freemail.com.pk", + "freemail.de", + "freemail.et", + "freemail.gr", + "freemail.hu", + "freemail.it", + "freemail.lt", + "freemail.ms", + "freemail.nl", + "freemail.org.mk", + "freemails.ga", + "freemeil.gq", + "freenet.de", + "freenet.kg", + "freeola.com", + "freeola.net", + "freeserve.co.uk", + "freestart.hu", + "freesurf.fr", + "freesurf.nl", + "freeuk.com", + "freeuk.net", + "freeukisp.co.uk", + "freeweb.org", + "freewebemail.com", + "freeyellow.com", + "freezone.co.uk", + "fresnomail.com", + "freudenkinder.de", + "freundin.ru", + "friendlymail.co.uk", + "friends-cafe.com", + "friendsfan.com", + "from-africa.com", + "from-america.com", + "from-argentina.com", + "from-asia.com", + "from-australia.com", + "from-belgium.com", + "from-brazil.com", + "from-canada.com", + "from-china.net", + "from-england.com", + "from-europe.com", + "from-france.net", + "from-germany.net", + "from-holland.com", + "from-israel.com", + "from-italy.net", + "from-japan.net", + "from-korea.com", + "from-mexico.com", + "from-outerspace.com", + "from-russia.com", + "from-spain.net", + "fromalabama.com", + "fromalaska.com", + "fromarizona.com", + "fromarkansas.com", + "fromcalifornia.com", + "fromcolorado.com", + "fromconnecticut.com", + "fromdelaware.com", + "fromflorida.net", + "fromgeorgia.com", + "fromhawaii.net", + "fromidaho.com", + "fromillinois.com", + "fromindiana.com", + "fromiowa.com", + "fromjupiter.com", + "fromkansas.com", + "fromkentucky.com", + "fromlouisiana.com", + "frommaine.net", + "frommaryland.com", + "frommassachusetts.com", + "frommiami.com", + "frommichigan.com", + "fromminnesota.com", + "frommississippi.com", + "frommissouri.com", + "frommontana.com", + "fromnebraska.com", + "fromnevada.com", + "fromnewhampshire.com", + "fromnewjersey.com", + "fromnewmexico.com", + "fromnewyork.net", + "fromnorthcarolina.com", + "fromnorthdakota.com", + "fromohio.com", + "fromoklahoma.com", + "fromoregon.net", + "frompennsylvania.com", + "fromrhodeisland.com", + "fromru.com", + "fromsouthcarolina.com", + "fromsouthdakota.com", + "fromtennessee.com", + "fromtexas.com", + "fromthestates.com", + "fromutah.com", + "fromvermont.com", + "fromvirginia.com", + "fromwashington.com", + "fromwashingtondc.com", + "fromwestvirginia.com", + "fromwisconsin.com", + "fromwyoming.com", + "front.ru", + "frontier.com", + "frontiernet.net", + "frostbyte.uk.net", + "fsmail.net", + "ftc-i.net", + "ftml.net", + "fullmail.com", + "funkfan.com", + "fuorissimo.com", + "furnitureprovider.com", + "fuse.net", + "fut.es", + "fux0ringduh.com", + "fwnb.com", + "fxsmails.com", + "galaxy5.com", + "galaxyhit.com", + "gamebox.net", + "gamegeek.com", + "gamespotmail.com", + "gamno.config.work", + "garbage.com", + "gardener.com", + "gawab.com", + "gaybrighton.co.uk", + "gaza.net", + "gazeta.pl", + "gazibooks.com", + "gci.net", + "geecities.com", + "geek.com", + "geek.hu", + "geeklife.com", + "gelitik.in", + "gencmail.com", + "general-hospital.com", + "gentlemansclub.de", + "geocities.com", + "geography.net", + "geologist.com", + "geopia.com", + "germanymail.com", + "get.pp.ua", + "get1mail.com", + "getairmail.cf", + "getairmail.com", + "getairmail.ga", + "getairmail.gq", + "getonemail.net", + "ghanamail.com", + "ghostmail.com", + "ghosttexter.de", + "giga4u.de", + "gigileung.org", + "girl4god.com", + "givepeaceachance.com", + "glay.org", + "glendale.net", + "globalfree.it", + "globalpagan.com", + "globalsite.com.br", + "gmail.com", + "gmail.com.br", + "gmail.ru", + "gmx.at", + "gmx.ch", + "gmx.com", + "gmx.de", + "gmx.li", + "gmx.net", + "go.com", + "go.ro", + "go.ru", + "go2net.com", + "gocollege.com", + "gocubs.com", + "goemailgo.com", + "gofree.co.uk", + "gol.com", + "goldenmail.ru", + "goldmail.ru", + "goldtoolbox.com", + "golfemail.com", + "golfilla.info", + "golfmail.be", + "gonavy.net", + "goodnewsmail.com", + "goodstick.com", + "googlegroups.com", + "googlemail.com", + "goplay.com", + "gorillaswithdirtyarmpits.com", + "gorontalo.net", + "gospelfan.com", + "gothere.uk.com", + "gotmail.com", + "gotmail.org", + "gotomy.com", + "gotti.otherinbox.com", + "gportal.hu", + "graduate.org", + "graffiti.net", + "gramszu.net", + "grandmamail.com", + "grandmasmail.com", + "graphic-designer.com", + "grapplers.com", + "gratisweb.com", + "greenmail.net", + "groupmail.com", + "grr.la", + "grungecafe.com", + "gtmc.net", + "gua.net", + "guerrillamail.com", + "guessmail.com", + "guju.net", + "gustr.com", + "guy.com", + "guy2.com", + "guyanafriends.com", + "gyorsposta.com", + "gyorsposta.hu", + "h-mail.us", + "hab-verschlafen.de", + "habmalnefrage.de", + "hacccc.com", + "hackermail.com", + "hackermail.net", + "hailmail.net", + "hairdresser.net", + "hamptonroads.com", + "handbag.com", + "handleit.com", + "hang-ten.com", + "hanmail.net", + "happemail.com", + "happycounsel.com", + "happypuppy.com", + "harakirimail.com", + "hardcorefreak.com", + "hartbot.de", + "hawaii.rr.com", + "hawaiiantel.net", + "heartthrob.com", + "heerschap.com", + "heesun.net", + "hehe.com", + "hello.hu", + "hello.net.au", + "hello.to", + "helter-skelter.com", + "herediano.com", + "herono1.com", + "herp.in", + "herr-der-mails.de", + "hetnet.nl", + "hey.to", + "hhdevel.com", + "hidzz.com", + "highmilton.com", + "highquality.com", + "highveldmail.co.za", + "hilarious.com", + "hiphopfan.com", + "hispavista.com", + "hitmail.com", + "hitthe.net", + "hkg.net", + "hkstarphoto.com", + "hockeymail.com", + "hollywoodkids.com", + "home-email.com", + "home.de", + "home.nl", + "home.no.net", + "home.ro", + "home.se", + "homelocator.com", + "homemail.com", + "homestead.com", + "honduras.com", + "hongkong.com", + "hookup.net", + "hoopsmail.com", + "hopemail.biz", + "horrormail.com", + "hot-mail.gq", + "hot-shot.com", + "hot.ee", + "hotbot.com", + "hotbrev.com", + "hotfire.net", + "hotletter.com", + "hotmail.ca", + "hotmail.ch", + "hotmail.co", + "hotmail.co.il", + "hotmail.co.jp", + "hotmail.co.nz", + "hotmail.co.uk", + "hotmail.co.za", + "hotmail.com", + "hotmail.com.au", + "hotmail.com.br", + "hotmail.com.tr", + "hotmail.de", + "hotmail.es", + "hotmail.fi", + "hotmail.fr", + "hotmail.it", + "hotmail.kg", + "hotmail.kz", + "hotmail.nl", + "hotmail.ru", + "hotmail.se", + "hotpop.com", + "hotpop3.com", + "hotvoice.com", + "housemail.com", + "hsuchi.net", + "hu2.ru", + "hughes.net", + "humanoid.net", + "humn.ws.gy", + "hunsa.com", + "hurting.com", + "hush.com", + "hushmail.com", + "hypernautica.com", + "i-connect.com", + "i-france.com", + "i-mail.com.au", + "i-p.com", + "i.am", + "i.ua", + "i12.com", + "i2pmail.org", + "iamawoman.com", + "iamwaiting.com", + "iamwasted.com", + "iamyours.com", + "icestorm.com", + "ich-bin-verrueckt-nach-dir.de", + "ich-will-net.de", + "icloud.com", + "icmsconsultants.com", + "icq.com", + "icqmail.com", + "icrazy.com", + "id-base.com", + "ididitmyway.com", + "idigjesus.com", + "idirect.com", + "ieatspam.eu", + "ieatspam.info", + "ieh-mail.de", + "iespana.es", + "ifoward.com", + "ig.com.br", + "ignazio.it", + "ignmail.com", + "ihateclowns.com", + "ihateyoualot.info", + "iheartspam.org", + "iinet.net.au", + "ijustdontcare.com", + "ikbenspamvrij.nl", + "ilkposta.com", + "ilovechocolate.com", + "ilovejesus.com", + "ilovetocollect.net", + "ilse.nl", + "imaginemail.com", + "imail.ru", + "imailbox.com", + "imap-mail.com", + "imap.cc", + "imapmail.org", + "imel.org", + "imgof.com", + "imgv.de", + "immo-gerance.info", + "imneverwrong.com", + "imposter.co.uk", + "imstations.com", + "imstressed.com", + "imtoosexy.com", + "in-box.net", + "in2jesus.com", + "iname.com", + "inbax.tk", + "inbound.plus", + "inbox.com", + "inbox.net", + "inbox.ru", + "inbox.si", + "inboxalias.com", + "incamail.com", + "incredimail.com", + "indeedemail.com", + "index.ua", + "indexa.fr", + "india.com", + "indiatimes.com", + "indo-mail.com", + "indocities.com", + "indomail.com", + "indyracers.com", + "inerted.com", + "inet.com", + "inet.net.au", + "info-media.de", + "info-radio.ml", + "info66.com", + "infohq.com", + "infomail.es", + "infomart.or.jp", + "infospacemail.com", + "infovia.com.ar", + "inicia.es", + "inmail.sk", + "inmail24.com", + "inmano.com", + "inmynetwork.tk", + "innocent.com", + "inorbit.com", + "inoutbox.com", + "insidebaltimore.net", + "insight.rr.com", + "instant-mail.de", + "instantemailaddress.com", + "instantmail.fr", + "instruction.com", + "instructor.net", + "insurer.com", + "interburp.com", + "interfree.it", + "interia.pl", + "interlap.com.ar", + "intermail.co.il", + "internet-e-mail.com", + "internet-mail.org", + "internet-police.com", + "internetbiz.com", + "internetdrive.com", + "internetegypt.com", + "internetemails.net", + "internetmailing.net", + "internode.on.net", + "invalid.com", + "inwind.it", + "iobox.com", + "iobox.fi", + "iol.it", + "iol.pt", + "iowaemail.com", + "ip3.com", + "ip4.pp.ua", + "ip6.pp.ua", + "ipoo.org", + "iprimus.com.au", + "iqemail.com", + "irangate.net", + "iraqmail.com", + "ireland.com", + "irelandmail.com", + "iremail.de", + "irj.hu", + "iroid.com", + "isellcars.com", + "iservejesus.com", + "islamonline.net", + "isleuthmail.com", + "ismart.net", + "isonfire.com", + "isp9.net", + "israelmail.com", + "ist-allein.info", + "ist-einmalig.de", + "ist-ganz-allein.de", + "ist-willig.de", + "italymail.com", + "itloox.com", + "itmom.com", + "ivebeenframed.com", + "ivillage.com", + "iwan-fals.com", + "iwmail.com", + "iwon.com", + "izadpanah.com", + "jahoopa.com", + "jakuza.hu", + "japan.com", + "jaydemail.com", + "jazzandjava.com", + "jazzfan.com", + "jazzgame.com", + "je-recycle.info", + "jerusalemmail.com", + "jet-renovation.fr", + "jetable.de", + "jetable.pp.ua", + "jetemail.net", + "jippii.fi", + "jmail.co.za", + "job4u.com", + "jobbikszimpatizans.hu", + "joelonsoftware.com", + "joinme.com", + "jokes.com", + "jordanmail.com", + "journalist.com", + "jourrapide.com", + "jovem.te.pt", + "joymail.com", + "jpopmail.com", + "jsrsolutions.com", + "jubiimail.dk", + "jump.com", + "jumpy.it", + "juniormail.com", + "junk1e.com", + "junkmail.com", + "junkmail.gq", + "juno.com", + "justemail.net", + "justicemail.com", + "kaazoo.com", + "kaffeeschluerfer.com", + "kaffeeschluerfer.de", + "kaixo.com", + "kalpoint.com", + "kansascity.com", + "kapoorweb.com", + "karachian.com", + "karachioye.com", + "karbasi.com", + "katamail.com", + "kayafmmail.co.za", + "kbjrmail.com", + "kcks.com", + "keg-party.com", + "keinpardon.de", + "keko.com.ar", + "kellychen.com", + "keromail.com", + "keyemail.com", + "kgb.hu", + "khosropour.com", + "kickassmail.com", + "killermail.com", + "kimo.com", + "kimsdisk.com", + "kinglibrary.net", + "kinki-kids.com", + "kissfans.com", + "kittymail.com", + "kitznet.at", + "kiwibox.com", + "kiwitown.com", + "klassmaster.net", + "km.ru", + "knol-power.nl", + "kolumbus.fi", + "kommespaeter.de", + "konx.com", + "korea.com", + "koreamail.com", + "kpnmail.nl", + "krim.ws", + "krongthip.com", + "krunis.com", + "ksanmail.com", + "ksee24mail.com", + "kube93mail.com", + "kukamail.com", + "kulturbetrieb.info", + "kumarweb.com", + "kuwait-mail.com", + "l33r.eu", + "la.com", + "labetteraverouge.at", + "ladymail.cz", + "lagerlouts.com", + "lags.us", + "lahoreoye.com", + "lakmail.com", + "lamer.hu", + "land.ru", + "lankamail.com", + "laoeq.com", + "laposte.net", + "lass-es-geschehen.de", + "last-chance.pro", + "lastmail.co", + "latemodels.com", + "latinmail.com", + "lavache.com", + "law.com", + "lawyer.com", + "lazyinbox.com", + "leehom.net", + "legalactions.com", + "legalrc.loan", + "legislator.com", + "lenta.ru", + "leonlai.net", + "letsgomets.net", + "letterboxes.org", + "letthemeatspam.com", + "levele.com", + "levele.hu", + "lex.bg", + "lexis-nexis-mail.com", + "libero.it", + "liberomail.com", + "lick101.com", + "liebt-dich.info", + "linkmaster.com", + "linktrader.com", + "linuxfreemail.com", + "linuxmail.org", + "lionsfan.com.au", + "liontrucks.com", + "liquidinformation.net", + "list.ru", + "listomail.com", + "littleapple.com", + "littleblueroom.com", + "live.at", + "live.be", + "live.ca", + "live.cl", + "live.cn", + "live.co.uk", + "live.co.za", + "live.com", + "live.com.ar", + "live.com.au", + "live.com.mx", + "live.com.pt", + "live.com.sg", + "live.de", + "live.dk", + "live.fr", + "live.ie", + "live.in", + "live.it", + "live.jp", + "live.nl", + "live.no", + "live.ru", + "live.se", + "liveradio.tk", + "liverpoolfans.com", + "llandudno.com", + "llangollen.com", + "lmxmail.sk", + "lobbyist.com", + "localbar.com", + "locos.com", + "login-email.ga", + "loh.pp.ua", + "lolfreak.net", + "lolito.tk", + "london.com", + "looksmart.co.uk", + "looksmart.com", + "looksmart.com.au", + "lopezclub.com", + "louiskoo.com", + "love.cz", + "loveable.com", + "lovecat.com", + "lovefall.ml", + "lovefootball.com", + "lovelygirl.net", + "lovemail.com", + "lover-boy.com", + "lovergirl.com", + "lovesea.gq", + "lovethebroncos.com", + "lovethecowboys.com", + "loveyouforever.de", + "lovingjesus.com", + "lowandslow.com", + "lr7.us", + "lroid.com", + "luso.pt", + "luukku.com", + "luv2.us", + "lvie.com.sg", + "lycos.co.uk", + "lycos.com", + "lycos.es", + "lycos.it", + "lycos.ne.jp", + "lycosmail.com", + "m-a-i-l.com", + "m-hmail.com", + "m4.org", + "m4ilweb.info", + "mac.com", + "macbox.com", + "macfreak.com", + "machinecandy.com", + "macmail.com", + "madcreations.com", + "madonnafan.com", + "madrid.com", + "maennerversteherin.com", + "maennerversteherin.de", + "maffia.hu", + "magicmail.co.za", + "magspam.net", + "mahmoodweb.com", + "mail-awu.de", + "mail-box.cz", + "mail-center.com", + "mail-central.com", + "mail-easy.fr", + "mail-filter.com", + "mail-me.com", + "mail-page.com", + "mail-tester.com", + "mail.austria.com", + "mail.az", + "mail.be", + "mail.bg", + "mail.bulgaria.com", + "mail.by", + "mail.co.za", + "mail.com", + "mail.com.tr", + "mail.de", + "mail.ee", + "mail.entrepeneurmag.com", + "mail.freetown.com", + "mail.gr", + "mail.hitthebeach.com", + "mail.htl22.at", + "mail.md", + "mail.misterpinball.de", + "mail.nu", + "mail.org.uk", + "mail.pf", + "mail.pt", + "mail.r-o-o-t.com", + "mail.ru", + "mail.sisna.com", + "mail.svenz.eu", + "mail.tm", + "mail.usa.com", + "mail.vasarhely.hu", + "mail.wtf", + "mail114.net", + "mail15.com", + "mail2007.com", + "mail2aaron.com", + "mail2abby.com", + "mail2abc.com", + "mail2actor.com", + "mail2admiral.com", + "mail2adorable.com", + "mail2adoration.com", + "mail2adore.com", + "mail2adventure.com", + "mail2aeolus.com", + "mail2aether.com", + "mail2affection.com", + "mail2afghanistan.com", + "mail2africa.com", + "mail2agent.com", + "mail2aha.com", + "mail2ahoy.com", + "mail2aim.com", + "mail2air.com", + "mail2airbag.com", + "mail2airforce.com", + "mail2airport.com", + "mail2alabama.com", + "mail2alan.com", + "mail2alaska.com", + "mail2albania.com", + "mail2alcoholic.com", + "mail2alec.com", + "mail2alexa.com", + "mail2algeria.com", + "mail2alicia.com", + "mail2alien.com", + "mail2allan.com", + "mail2allen.com", + "mail2allison.com", + "mail2alpha.com", + "mail2alyssa.com", + "mail2amanda.com", + "mail2amazing.com", + "mail2amber.com", + "mail2america.com", + "mail2american.com", + "mail2andorra.com", + "mail2andrea.com", + "mail2andy.com", + "mail2anesthesiologist.com", + "mail2angela.com", + "mail2angola.com", + "mail2ann.com", + "mail2anna.com", + "mail2anne.com", + "mail2anthony.com", + "mail2anything.com", + "mail2aphrodite.com", + "mail2apollo.com", + "mail2april.com", + "mail2aquarius.com", + "mail2arabia.com", + "mail2arabic.com", + "mail2architect.com", + "mail2ares.com", + "mail2argentina.com", + "mail2aries.com", + "mail2arizona.com", + "mail2arkansas.com", + "mail2armenia.com", + "mail2army.com", + "mail2arnold.com", + "mail2art.com", + "mail2artemus.com", + "mail2arthur.com", + "mail2artist.com", + "mail2ashley.com", + "mail2ask.com", + "mail2astronomer.com", + "mail2athena.com", + "mail2athlete.com", + "mail2atlas.com", + "mail2atom.com", + "mail2attitude.com", + "mail2auction.com", + "mail2aunt.com", + "mail2australia.com", + "mail2austria.com", + "mail2azerbaijan.com", + "mail2baby.com", + "mail2bahamas.com", + "mail2bahrain.com", + "mail2ballerina.com", + "mail2ballplayer.com", + "mail2band.com", + "mail2bangladesh.com", + "mail2bank.com", + "mail2banker.com", + "mail2bankrupt.com", + "mail2baptist.com", + "mail2bar.com", + "mail2barbados.com", + "mail2barbara.com", + "mail2barter.com", + "mail2basketball.com", + "mail2batter.com", + "mail2beach.com", + "mail2beast.com", + "mail2beatles.com", + "mail2beauty.com", + "mail2becky.com", + "mail2beijing.com", + "mail2belgium.com", + "mail2belize.com", + "mail2ben.com", + "mail2bernard.com", + "mail2beth.com", + "mail2betty.com", + "mail2beverly.com", + "mail2beyond.com", + "mail2biker.com", + "mail2bill.com", + "mail2billionaire.com", + "mail2billy.com", + "mail2bio.com", + "mail2biologist.com", + "mail2black.com", + "mail2blackbelt.com", + "mail2blake.com", + "mail2blind.com", + "mail2blonde.com", + "mail2blues.com", + "mail2bob.com", + "mail2bobby.com", + "mail2bolivia.com", + "mail2bombay.com", + "mail2bonn.com", + "mail2bookmark.com", + "mail2boreas.com", + "mail2bosnia.com", + "mail2boston.com", + "mail2botswana.com", + "mail2bradley.com", + "mail2brazil.com", + "mail2breakfast.com", + "mail2brian.com", + "mail2bride.com", + "mail2brittany.com", + "mail2broker.com", + "mail2brook.com", + "mail2bruce.com", + "mail2brunei.com", + "mail2brunette.com", + "mail2brussels.com", + "mail2bryan.com", + "mail2bug.com", + "mail2bulgaria.com", + "mail2business.com", + "mail2buy.com", + "mail2ca.com", + "mail2california.com", + "mail2calvin.com", + "mail2cambodia.com", + "mail2cameroon.com", + "mail2canada.com", + "mail2cancer.com", + "mail2capeverde.com", + "mail2capricorn.com", + "mail2cardinal.com", + "mail2cardiologist.com", + "mail2care.com", + "mail2caroline.com", + "mail2carolyn.com", + "mail2casey.com", + "mail2cat.com", + "mail2caterer.com", + "mail2cathy.com", + "mail2catlover.com", + "mail2catwalk.com", + "mail2cell.com", + "mail2chad.com", + "mail2champaign.com", + "mail2charles.com", + "mail2chef.com", + "mail2chemist.com", + "mail2cherry.com", + "mail2chicago.com", + "mail2chile.com", + "mail2china.com", + "mail2chinese.com", + "mail2chocolate.com", + "mail2christian.com", + "mail2christie.com", + "mail2christmas.com", + "mail2christy.com", + "mail2chuck.com", + "mail2cindy.com", + "mail2clark.com", + "mail2classifieds.com", + "mail2claude.com", + "mail2cliff.com", + "mail2clinic.com", + "mail2clint.com", + "mail2close.com", + "mail2club.com", + "mail2coach.com", + "mail2coastguard.com", + "mail2colin.com", + "mail2college.com", + "mail2colombia.com", + "mail2color.com", + "mail2colorado.com", + "mail2columbia.com", + "mail2comedian.com", + "mail2composer.com", + "mail2computer.com", + "mail2computers.com", + "mail2concert.com", + "mail2congo.com", + "mail2connect.com", + "mail2connecticut.com", + "mail2consultant.com", + "mail2convict.com", + "mail2cook.com", + "mail2cool.com", + "mail2cory.com", + "mail2costarica.com", + "mail2country.com", + "mail2courtney.com", + "mail2cowboy.com", + "mail2cowgirl.com", + "mail2craig.com", + "mail2crave.com", + "mail2crazy.com", + "mail2create.com", + "mail2croatia.com", + "mail2cry.com", + "mail2crystal.com", + "mail2cuba.com", + "mail2culture.com", + "mail2curt.com", + "mail2customs.com", + "mail2cute.com", + "mail2cutey.com", + "mail2cynthia.com", + "mail2cyprus.com", + "mail2czechrepublic.com", + "mail2dad.com", + "mail2dale.com", + "mail2dallas.com", + "mail2dan.com", + "mail2dana.com", + "mail2dance.com", + "mail2dancer.com", + "mail2danielle.com", + "mail2danny.com", + "mail2darlene.com", + "mail2darling.com", + "mail2darren.com", + "mail2daughter.com", + "mail2dave.com", + "mail2dawn.com", + "mail2dc.com", + "mail2dealer.com", + "mail2deanna.com", + "mail2dearest.com", + "mail2debbie.com", + "mail2debby.com", + "mail2deer.com", + "mail2delaware.com", + "mail2delicious.com", + "mail2demeter.com", + "mail2democrat.com", + "mail2denise.com", + "mail2denmark.com", + "mail2dennis.com", + "mail2dentist.com", + "mail2derek.com", + "mail2desert.com", + "mail2devoted.com", + "mail2devotion.com", + "mail2diamond.com", + "mail2diana.com", + "mail2diane.com", + "mail2diehard.com", + "mail2dilemma.com", + "mail2dillon.com", + "mail2dinner.com", + "mail2dinosaur.com", + "mail2dionysos.com", + "mail2diplomat.com", + "mail2director.com", + "mail2dirk.com", + "mail2disco.com", + "mail2dive.com", + "mail2diver.com", + "mail2divorced.com", + "mail2djibouti.com", + "mail2doctor.com", + "mail2doglover.com", + "mail2dominic.com", + "mail2dominica.com", + "mail2dominicanrepublic.com", + "mail2don.com", + "mail2donald.com", + "mail2donna.com", + "mail2doris.com", + "mail2dorothy.com", + "mail2doug.com", + "mail2dough.com", + "mail2douglas.com", + "mail2dow.com", + "mail2downtown.com", + "mail2dream.com", + "mail2dreamer.com", + "mail2dude.com", + "mail2dustin.com", + "mail2dyke.com", + "mail2dylan.com", + "mail2earl.com", + "mail2earth.com", + "mail2eastend.com", + "mail2eat.com", + "mail2economist.com", + "mail2ecuador.com", + "mail2eddie.com", + "mail2edgar.com", + "mail2edwin.com", + "mail2egypt.com", + "mail2electron.com", + "mail2eli.com", + "mail2elizabeth.com", + "mail2ellen.com", + "mail2elliot.com", + "mail2elsalvador.com", + "mail2elvis.com", + "mail2emergency.com", + "mail2emily.com", + "mail2engineer.com", + "mail2english.com", + "mail2environmentalist.com", + "mail2eos.com", + "mail2eric.com", + "mail2erica.com", + "mail2erin.com", + "mail2erinyes.com", + "mail2eris.com", + "mail2eritrea.com", + "mail2ernie.com", + "mail2eros.com", + "mail2estonia.com", + "mail2ethan.com", + "mail2ethiopia.com", + "mail2eu.com", + "mail2europe.com", + "mail2eurus.com", + "mail2eva.com", + "mail2evan.com", + "mail2evelyn.com", + "mail2everything.com", + "mail2exciting.com", + "mail2expert.com", + "mail2fairy.com", + "mail2faith.com", + "mail2fanatic.com", + "mail2fancy.com", + "mail2fantasy.com", + "mail2farm.com", + "mail2farmer.com", + "mail2fashion.com", + "mail2fat.com", + "mail2feeling.com", + "mail2female.com", + "mail2fever.com", + "mail2fighter.com", + "mail2fiji.com", + "mail2filmfestival.com", + "mail2films.com", + "mail2finance.com", + "mail2finland.com", + "mail2fireman.com", + "mail2firm.com", + "mail2fisherman.com", + "mail2flexible.com", + "mail2florence.com", + "mail2florida.com", + "mail2floyd.com", + "mail2fly.com", + "mail2fond.com", + "mail2fondness.com", + "mail2football.com", + "mail2footballfan.com", + "mail2found.com", + "mail2france.com", + "mail2frank.com", + "mail2frankfurt.com", + "mail2franklin.com", + "mail2fred.com", + "mail2freddie.com", + "mail2free.com", + "mail2freedom.com", + "mail2french.com", + "mail2freudian.com", + "mail2friendship.com", + "mail2from.com", + "mail2fun.com", + "mail2gabon.com", + "mail2gabriel.com", + "mail2gail.com", + "mail2galaxy.com", + "mail2gambia.com", + "mail2games.com", + "mail2gary.com", + "mail2gavin.com", + "mail2gemini.com", + "mail2gene.com", + "mail2genes.com", + "mail2geneva.com", + "mail2george.com", + "mail2georgia.com", + "mail2gerald.com", + "mail2german.com", + "mail2germany.com", + "mail2ghana.com", + "mail2gilbert.com", + "mail2gina.com", + "mail2girl.com", + "mail2glen.com", + "mail2gloria.com", + "mail2goddess.com", + "mail2gold.com", + "mail2golfclub.com", + "mail2golfer.com", + "mail2gordon.com", + "mail2government.com", + "mail2grab.com", + "mail2grace.com", + "mail2graham.com", + "mail2grandma.com", + "mail2grandpa.com", + "mail2grant.com", + "mail2greece.com", + "mail2green.com", + "mail2greg.com", + "mail2grenada.com", + "mail2gsm.com", + "mail2guard.com", + "mail2guatemala.com", + "mail2guy.com", + "mail2hades.com", + "mail2haiti.com", + "mail2hal.com", + "mail2handhelds.com", + "mail2hank.com", + "mail2hannah.com", + "mail2harold.com", + "mail2harry.com", + "mail2hawaii.com", + "mail2headhunter.com", + "mail2heal.com", + "mail2heather.com", + "mail2heaven.com", + "mail2hebe.com", + "mail2hecate.com", + "mail2heidi.com", + "mail2helen.com", + "mail2hell.com", + "mail2help.com", + "mail2helpdesk.com", + "mail2henry.com", + "mail2hephaestus.com", + "mail2hera.com", + "mail2hercules.com", + "mail2herman.com", + "mail2hermes.com", + "mail2hespera.com", + "mail2hestia.com", + "mail2highschool.com", + "mail2hindu.com", + "mail2hip.com", + "mail2hiphop.com", + "mail2holland.com", + "mail2holly.com", + "mail2hollywood.com", + "mail2homer.com", + "mail2honduras.com", + "mail2honey.com", + "mail2hongkong.com", + "mail2hope.com", + "mail2horse.com", + "mail2hot.com", + "mail2hotel.com", + "mail2houston.com", + "mail2howard.com", + "mail2hugh.com", + "mail2human.com", + "mail2hungary.com", + "mail2hungry.com", + "mail2hygeia.com", + "mail2hyperspace.com", + "mail2hypnos.com", + "mail2ian.com", + "mail2ice-cream.com", + "mail2iceland.com", + "mail2idaho.com", + "mail2idontknow.com", + "mail2illinois.com", + "mail2imam.com", + "mail2in.com", + "mail2india.com", + "mail2indian.com", + "mail2indiana.com", + "mail2indonesia.com", + "mail2infinity.com", + "mail2intense.com", + "mail2iowa.com", + "mail2iran.com", + "mail2iraq.com", + "mail2ireland.com", + "mail2irene.com", + "mail2iris.com", + "mail2irresistible.com", + "mail2irving.com", + "mail2irwin.com", + "mail2isaac.com", + "mail2israel.com", + "mail2italian.com", + "mail2italy.com", + "mail2jackie.com", + "mail2jacob.com", + "mail2jail.com", + "mail2jaime.com", + "mail2jake.com", + "mail2jamaica.com", + "mail2james.com", + "mail2jamie.com", + "mail2jan.com", + "mail2jane.com", + "mail2janet.com", + "mail2janice.com", + "mail2japan.com", + "mail2japanese.com", + "mail2jasmine.com", + "mail2jason.com", + "mail2java.com", + "mail2jay.com", + "mail2jazz.com", + "mail2jed.com", + "mail2jeffrey.com", + "mail2jennifer.com", + "mail2jenny.com", + "mail2jeremy.com", + "mail2jerry.com", + "mail2jessica.com", + "mail2jessie.com", + "mail2jesus.com", + "mail2jew.com", + "mail2jeweler.com", + "mail2jim.com", + "mail2jimmy.com", + "mail2joan.com", + "mail2joann.com", + "mail2joanna.com", + "mail2jody.com", + "mail2joe.com", + "mail2joel.com", + "mail2joey.com", + "mail2john.com", + "mail2join.com", + "mail2jon.com", + "mail2jonathan.com", + "mail2jones.com", + "mail2jordan.com", + "mail2joseph.com", + "mail2josh.com", + "mail2joy.com", + "mail2juan.com", + "mail2judge.com", + "mail2judy.com", + "mail2juggler.com", + "mail2julian.com", + "mail2julie.com", + "mail2jumbo.com", + "mail2junk.com", + "mail2justin.com", + "mail2justme.com", + "mail2k.ru", + "mail2kansas.com", + "mail2karate.com", + "mail2karen.com", + "mail2karl.com", + "mail2karma.com", + "mail2kathleen.com", + "mail2kathy.com", + "mail2katie.com", + "mail2kay.com", + "mail2kazakhstan.com", + "mail2keen.com", + "mail2keith.com", + "mail2kelly.com", + "mail2kelsey.com", + "mail2ken.com", + "mail2kendall.com", + "mail2kennedy.com", + "mail2kenneth.com", + "mail2kenny.com", + "mail2kentucky.com", + "mail2kenya.com", + "mail2kerry.com", + "mail2kevin.com", + "mail2kim.com", + "mail2kimberly.com", + "mail2king.com", + "mail2kirk.com", + "mail2kiss.com", + "mail2kosher.com", + "mail2kristin.com", + "mail2kurt.com", + "mail2kuwait.com", + "mail2kyle.com", + "mail2kyrgyzstan.com", + "mail2la.com", + "mail2lacrosse.com", + "mail2lance.com", + "mail2lao.com", + "mail2larry.com", + "mail2latvia.com", + "mail2laugh.com", + "mail2laura.com", + "mail2lauren.com", + "mail2laurie.com", + "mail2lawrence.com", + "mail2lawyer.com", + "mail2lebanon.com", + "mail2lee.com", + "mail2leo.com", + "mail2leon.com", + "mail2leonard.com", + "mail2leone.com", + "mail2leslie.com", + "mail2letter.com", + "mail2liberia.com", + "mail2libertarian.com", + "mail2libra.com", + "mail2libya.com", + "mail2liechtenstein.com", + "mail2life.com", + "mail2linda.com", + "mail2linux.com", + "mail2lionel.com", + "mail2lipstick.com", + "mail2liquid.com", + "mail2lisa.com", + "mail2lithuania.com", + "mail2litigator.com", + "mail2liz.com", + "mail2lloyd.com", + "mail2lois.com", + "mail2lola.com", + "mail2london.com", + "mail2looking.com", + "mail2lori.com", + "mail2lost.com", + "mail2lou.com", + "mail2louis.com", + "mail2louisiana.com", + "mail2lovable.com", + "mail2love.com", + "mail2lucky.com", + "mail2lucy.com", + "mail2lunch.com", + "mail2lust.com", + "mail2luxembourg.com", + "mail2luxury.com", + "mail2lyle.com", + "mail2lynn.com", + "mail2madagascar.com", + "mail2madison.com", + "mail2madrid.com", + "mail2maggie.com", + "mail2mail4.com", + "mail2maine.com", + "mail2malawi.com", + "mail2malaysia.com", + "mail2maldives.com", + "mail2mali.com", + "mail2malta.com", + "mail2mambo.com", + "mail2man.com", + "mail2mandy.com", + "mail2manhunter.com", + "mail2mankind.com", + "mail2many.com", + "mail2marc.com", + "mail2marcia.com", + "mail2margaret.com", + "mail2margie.com", + "mail2marhaba.com", + "mail2maria.com", + "mail2marilyn.com", + "mail2marines.com", + "mail2mark.com", + "mail2marriage.com", + "mail2married.com", + "mail2marries.com", + "mail2mars.com", + "mail2marsha.com", + "mail2marshallislands.com", + "mail2martha.com", + "mail2martin.com", + "mail2marty.com", + "mail2marvin.com", + "mail2mary.com", + "mail2maryland.com", + "mail2mason.com", + "mail2massachusetts.com", + "mail2matt.com", + "mail2matthew.com", + "mail2maurice.com", + "mail2mauritania.com", + "mail2mauritius.com", + "mail2max.com", + "mail2maxwell.com", + "mail2maybe.com", + "mail2mba.com", + "mail2me4u.com", + "mail2mechanic.com", + "mail2medieval.com", + "mail2megan.com", + "mail2mel.com", + "mail2melanie.com", + "mail2melissa.com", + "mail2melody.com", + "mail2member.com", + "mail2memphis.com", + "mail2methodist.com", + "mail2mexican.com", + "mail2mexico.com", + "mail2mgz.com", + "mail2miami.com", + "mail2michael.com", + "mail2michelle.com", + "mail2michigan.com", + "mail2mike.com", + "mail2milan.com", + "mail2milano.com", + "mail2mildred.com", + "mail2milkyway.com", + "mail2millennium.com", + "mail2millionaire.com", + "mail2milton.com", + "mail2mime.com", + "mail2mindreader.com", + "mail2mini.com", + "mail2minister.com", + "mail2minneapolis.com", + "mail2minnesota.com", + "mail2miracle.com", + "mail2missionary.com", + "mail2mississippi.com", + "mail2missouri.com", + "mail2mitch.com", + "mail2model.com", + "mail2moldova.commail2molly.com", + "mail2mom.com", + "mail2monaco.com", + "mail2money.com", + "mail2mongolia.com", + "mail2monica.com", + "mail2montana.com", + "mail2monty.com", + "mail2moon.com", + "mail2morocco.com", + "mail2morpheus.com", + "mail2mors.com", + "mail2moscow.com", + "mail2moslem.com", + "mail2mouseketeer.com", + "mail2movies.com", + "mail2mozambique.com", + "mail2mp3.com", + "mail2mrright.com", + "mail2msright.com", + "mail2museum.com", + "mail2music.com", + "mail2musician.com", + "mail2muslim.com", + "mail2my.com", + "mail2myboat.com", + "mail2mycar.com", + "mail2mycell.com", + "mail2mygsm.com", + "mail2mylaptop.com", + "mail2mymac.com", + "mail2mypager.com", + "mail2mypalm.com", + "mail2mypc.com", + "mail2myphone.com", + "mail2myplane.com", + "mail2namibia.com", + "mail2nancy.com", + "mail2nasdaq.com", + "mail2nathan.com", + "mail2nauru.com", + "mail2navy.com", + "mail2neal.com", + "mail2nebraska.com", + "mail2ned.com", + "mail2neil.com", + "mail2nelson.com", + "mail2nemesis.com", + "mail2nepal.com", + "mail2netherlands.com", + "mail2network.com", + "mail2nevada.com", + "mail2newhampshire.com", + "mail2newjersey.com", + "mail2newmexico.com", + "mail2newyork.com", + "mail2newzealand.com", + "mail2nicaragua.com", + "mail2nick.com", + "mail2nicole.com", + "mail2niger.com", + "mail2nigeria.com", + "mail2nike.com", + "mail2no.com", + "mail2noah.com", + "mail2noel.com", + "mail2noelle.com", + "mail2normal.com", + "mail2norman.com", + "mail2northamerica.com", + "mail2northcarolina.com", + "mail2northdakota.com", + "mail2northpole.com", + "mail2norway.com", + "mail2notus.com", + "mail2noway.com", + "mail2nowhere.com", + "mail2nuclear.com", + "mail2nun.com", + "mail2ny.com", + "mail2oasis.com", + "mail2oceanographer.com", + "mail2ohio.com", + "mail2ok.com", + "mail2oklahoma.com", + "mail2oliver.com", + "mail2oman.com", + "mail2one.com", + "mail2onfire.com", + "mail2online.com", + "mail2oops.com", + "mail2open.com", + "mail2ophthalmologist.com", + "mail2optometrist.com", + "mail2oregon.com", + "mail2oscars.com", + "mail2oslo.com", + "mail2painter.com", + "mail2pakistan.com", + "mail2palau.com", + "mail2pan.com", + "mail2panama.com", + "mail2paraguay.com", + "mail2paralegal.com", + "mail2paris.com", + "mail2park.com", + "mail2parker.com", + "mail2party.com", + "mail2passion.com", + "mail2pat.com", + "mail2patricia.com", + "mail2patrick.com", + "mail2patty.com", + "mail2paul.com", + "mail2paula.com", + "mail2pay.com", + "mail2peace.com", + "mail2pediatrician.com", + "mail2peggy.com", + "mail2pennsylvania.com", + "mail2perry.com", + "mail2persephone.com", + "mail2persian.com", + "mail2peru.com", + "mail2pete.com", + "mail2peter.com", + "mail2pharmacist.com", + "mail2phil.com", + "mail2philippines.com", + "mail2phoenix.com", + "mail2phonecall.com", + "mail2phyllis.com", + "mail2pickup.com", + "mail2pilot.com", + "mail2pisces.com", + "mail2planet.com", + "mail2platinum.com", + "mail2plato.com", + "mail2pluto.com", + "mail2pm.com", + "mail2podiatrist.com", + "mail2poet.com", + "mail2poland.com", + "mail2policeman.com", + "mail2policewoman.com", + "mail2politician.com", + "mail2pop.com", + "mail2pope.com", + "mail2popular.com", + "mail2portugal.com", + "mail2poseidon.com", + "mail2potatohead.com", + "mail2power.com", + "mail2presbyterian.com", + "mail2president.com", + "mail2priest.com", + "mail2prince.com", + "mail2princess.com", + "mail2producer.com", + "mail2professor.com", + "mail2protect.com", + "mail2psychiatrist.com", + "mail2psycho.com", + "mail2psychologist.com", + "mail2qatar.com", + "mail2queen.com", + "mail2rabbi.com", + "mail2race.com", + "mail2racer.com", + "mail2rachel.com", + "mail2rage.com", + "mail2rainmaker.com", + "mail2ralph.com", + "mail2randy.com", + "mail2rap.com", + "mail2rare.com", + "mail2rave.com", + "mail2ray.com", + "mail2raymond.com", + "mail2realtor.com", + "mail2rebecca.com", + "mail2recruiter.com", + "mail2recycle.com", + "mail2redhead.com", + "mail2reed.com", + "mail2reggie.com", + "mail2register.com", + "mail2rent.com", + "mail2republican.com", + "mail2resort.com", + "mail2rex.com", + "mail2rhodeisland.com", + "mail2rich.com", + "mail2richard.com", + "mail2ricky.com", + "mail2ride.com", + "mail2riley.com", + "mail2rita.com", + "mail2rob.com", + "mail2robert.com", + "mail2roberta.com", + "mail2robin.com", + "mail2rock.com", + "mail2rocker.com", + "mail2rod.com", + "mail2rodney.com", + "mail2romania.com", + "mail2rome.com", + "mail2ron.com", + "mail2ronald.com", + "mail2ronnie.com", + "mail2rose.com", + "mail2rosie.com", + "mail2roy.com", + "mail2rss.org", + "mail2rudy.com", + "mail2rugby.com", + "mail2runner.com", + "mail2russell.com", + "mail2russia.com", + "mail2russian.com", + "mail2rusty.com", + "mail2ruth.com", + "mail2rwanda.com", + "mail2ryan.com", + "mail2sa.com", + "mail2sabrina.com", + "mail2safe.com", + "mail2sagittarius.com", + "mail2sail.com", + "mail2sailor.com", + "mail2sal.com", + "mail2salaam.com", + "mail2sam.com", + "mail2samantha.com", + "mail2samoa.com", + "mail2samurai.com", + "mail2sandra.com", + "mail2sandy.com", + "mail2sanfrancisco.com", + "mail2sanmarino.com", + "mail2santa.com", + "mail2sara.com", + "mail2sarah.com", + "mail2sat.com", + "mail2saturn.com", + "mail2saudi.com", + "mail2saudiarabia.com", + "mail2save.com", + "mail2savings.com", + "mail2school.com", + "mail2scientist.com", + "mail2scorpio.com", + "mail2scott.com", + "mail2sean.com", + "mail2search.com", + "mail2seattle.com", + "mail2secretagent.com", + "mail2senate.com", + "mail2senegal.com", + "mail2sensual.com", + "mail2seth.com", + "mail2sevenseas.com", + "mail2sexy.com", + "mail2seychelles.com", + "mail2shane.com", + "mail2sharon.com", + "mail2shawn.com", + "mail2ship.com", + "mail2shirley.com", + "mail2shoot.com", + "mail2shuttle.com", + "mail2sierraleone.com", + "mail2simon.com", + "mail2singapore.com", + "mail2single.com", + "mail2site.com", + "mail2skater.com", + "mail2skier.com", + "mail2sky.com", + "mail2sleek.com", + "mail2slim.com", + "mail2slovakia.com", + "mail2slovenia.com", + "mail2smile.com", + "mail2smith.com", + "mail2smooth.com", + "mail2soccer.com", + "mail2soccerfan.com", + "mail2socialist.com", + "mail2soldier.com", + "mail2somalia.com", + "mail2son.com", + "mail2song.com", + "mail2sos.com", + "mail2sound.com", + "mail2southafrica.com", + "mail2southamerica.com", + "mail2southcarolina.com", + "mail2southdakota.com", + "mail2southkorea.com", + "mail2southpole.com", + "mail2spain.com", + "mail2spanish.com", + "mail2spare.com", + "mail2spectrum.com", + "mail2splash.com", + "mail2sponsor.com", + "mail2sports.com", + "mail2srilanka.com", + "mail2stacy.com", + "mail2stan.com", + "mail2stanley.com", + "mail2star.com", + "mail2state.com", + "mail2stephanie.com", + "mail2steve.com", + "mail2steven.com", + "mail2stewart.com", + "mail2stlouis.com", + "mail2stock.com", + "mail2stockholm.com", + "mail2stockmarket.com", + "mail2storage.com", + "mail2store.com", + "mail2strong.com", + "mail2student.com", + "mail2studio.com", + "mail2studio54.com", + "mail2stuntman.com", + "mail2subscribe.com", + "mail2sudan.com", + "mail2superstar.com", + "mail2surfer.com", + "mail2suriname.com", + "mail2susan.com", + "mail2suzie.com", + "mail2swaziland.com", + "mail2sweden.com", + "mail2sweetheart.com", + "mail2swim.com", + "mail2swimmer.com", + "mail2swiss.com", + "mail2switzerland.com", + "mail2sydney.com", + "mail2sylvia.com", + "mail2syria.com", + "mail2taboo.com", + "mail2taiwan.com", + "mail2tajikistan.com", + "mail2tammy.com", + "mail2tango.com", + "mail2tanya.com", + "mail2tanzania.com", + "mail2tara.com", + "mail2taurus.com", + "mail2taxi.com", + "mail2taxidermist.com", + "mail2taylor.com", + "mail2taz.com", + "mail2teacher.com", + "mail2technician.com", + "mail2ted.com", + "mail2telephone.com", + "mail2teletubbie.com", + "mail2tenderness.com", + "mail2tennessee.com", + "mail2tennis.com", + "mail2tennisfan.com", + "mail2terri.com", + "mail2terry.com", + "mail2test.com", + "mail2texas.com", + "mail2thailand.com", + "mail2therapy.com", + "mail2think.com", + "mail2tickets.com", + "mail2tiffany.com", + "mail2tim.com", + "mail2time.com", + "mail2timothy.com", + "mail2tina.com", + "mail2titanic.com", + "mail2toby.com", + "mail2todd.com", + "mail2togo.com", + "mail2tom.com", + "mail2tommy.com", + "mail2tonga.com", + "mail2tony.com", + "mail2touch.com", + "mail2tourist.com", + "mail2tracey.com", + "mail2tracy.com", + "mail2tramp.com", + "mail2travel.com", + "mail2traveler.com", + "mail2travis.com", + "mail2trekkie.com", + "mail2trex.com", + "mail2triallawyer.com", + "mail2trick.com", + "mail2trillionaire.com", + "mail2troy.com", + "mail2truck.com", + "mail2trump.com", + "mail2try.com", + "mail2tunisia.com", + "mail2turbo.com", + "mail2turkey.com", + "mail2turkmenistan.com", + "mail2tv.com", + "mail2tycoon.com", + "mail2tyler.com", + "mail2u4me.com", + "mail2uae.com", + "mail2uganda.com", + "mail2uk.com", + "mail2ukraine.com", + "mail2uncle.com", + "mail2unsubscribe.com", + "mail2uptown.com", + "mail2uruguay.com", + "mail2usa.com", + "mail2utah.com", + "mail2uzbekistan.com", + "mail2v.com", + "mail2vacation.com", + "mail2valentines.com", + "mail2valerie.com", + "mail2valley.com", + "mail2vamoose.com", + "mail2vanessa.com", + "mail2vanuatu.com", + "mail2venezuela.com", + "mail2venous.com", + "mail2venus.com", + "mail2vermont.com", + "mail2vickie.com", + "mail2victor.com", + "mail2victoria.com", + "mail2vienna.com", + "mail2vietnam.com", + "mail2vince.com", + "mail2virginia.com", + "mail2virgo.com", + "mail2visionary.com", + "mail2vodka.com", + "mail2volleyball.com", + "mail2waiter.com", + "mail2wallstreet.com", + "mail2wally.com", + "mail2walter.com", + "mail2warren.com", + "mail2washington.com", + "mail2wave.com", + "mail2way.com", + "mail2waycool.com", + "mail2wayne.com", + "mail2webmaster.com", + "mail2webtop.com", + "mail2webtv.com", + "mail2weird.com", + "mail2wendell.com", + "mail2wendy.com", + "mail2westend.com", + "mail2westvirginia.com", + "mail2whether.com", + "mail2whip.com", + "mail2white.com", + "mail2whitehouse.com", + "mail2whitney.com", + "mail2why.com", + "mail2wilbur.com", + "mail2wild.com", + "mail2willard.com", + "mail2willie.com", + "mail2wine.com", + "mail2winner.com", + "mail2wired.com", + "mail2wisconsin.com", + "mail2woman.com", + "mail2wonder.com", + "mail2world.com", + "mail2worship.com", + "mail2wow.com", + "mail2www.com", + "mail2wyoming.com", + "mail2xfiles.com", + "mail2xox.com", + "mail2yachtclub.com", + "mail2yahalla.com", + "mail2yemen.com", + "mail2yes.com", + "mail2yugoslavia.com", + "mail2zack.com", + "mail2zambia.com", + "mail2zenith.com", + "mail2zephir.com", + "mail2zeus.com", + "mail2zipper.com", + "mail2zoo.com", + "mail2zoologist.com", + "mail2zurich.com", + "mail3000.com", + "mail333.com", + "mail4trash.com", + "mail4u.info", + "mailandftp.com", + "mailandnews.com", + "mailas.com", + "mailasia.com", + "mailbolt.com", + "mailbomb.net", + "mailboom.com", + "mailbox.as", + "mailbox.co.za", + "mailbox.gr", + "mailbox.hu", + "mailbox72.biz", + "mailbox80.biz", + "mailbr.com.br", + "mailc.net", + "mailcan.com", + "mailcat.biz", + "mailcc.com", + "mailchoose.co", + "mailcity.com", + "mailclub.fr", + "mailclub.net", + "maildrop.cc", + "maildrop.gq", + "maildx.com", + "mailed.ro", + "mailexcite.com", + "mailfa.tk", + "mailfence.com", + "mailforce.net", + "mailforspam.com", + "mailfree.gq", + "mailfs.com", + "mailftp.com", + "mailgenie.net", + "mailguard.me", + "mailhaven.com", + "mailhood.com", + "mailimate.com", + "mailinator.com", + "mailinator.org", + "mailinator.us", + "mailinblack.com", + "mailingaddress.org", + "mailingweb.com", + "mailisent.com", + "mailismagic.com", + "mailite.com", + "mailmate.com", + "mailme.dk", + "mailme.gq", + "mailme24.com", + "mailmight.com", + "mailmij.nl", + "mailnator.com", + "mailnew.com", + "mailops.com", + "mailoye.com", + "mailpanda.com", + "mailpick.biz", + "mailpokemon.com", + "mailpost.zzn.com", + "mailpride.com", + "mailproxsy.com", + "mailpuppy.com", + "mailquack.com", + "mailrock.biz", + "mailroom.com", + "mailru.com", + "mailsac.com", + "mailseal.de", + "mailsent.net", + "mailservice.ms", + "mailshuttle.com", + "mailslapping.com", + "mailstart.com", + "mailstartplus.com", + "mailsurf.com", + "mailtag.com", + "mailtemp.info", + "mailto.de", + "mailtothis.com", + "mailueberfall.de", + "mailup.net", + "mailwire.com", + "mailworks.org", + "mailzi.ru", + "mailzilla.org", + "maktoob.com", + "malayalamtelevision.net", + "maltesemail.com", + "mamber.net", + "manager.de", + "mancity.net", + "mantrafreenet.com", + "mantramail.com", + "mantraonline.com", + "manybrain.com", + "marchmail.com", + "mariahc.com", + "marijuana.com", + "marijuana.nl", + "married-not.com", + "marsattack.com", + "martindalemail.com", + "mash4077.com", + "masrawy.com", + "matmail.com", + "mauimail.com", + "mauritius.com", + "maxleft.com", + "maxmail.co.uk", + "mbox.com.au", + "mchsi.com", + "me-mail.hu", + "me.com", + "medical.net.au", + "medscape.com", + "meetingmall.com", + "megago.com", + "megamail.pt", + "megapoint.com", + "mehrani.com", + "mehtaweb.com", + "meine-dateien.info", + "meine-diashow.de", + "meine-fotos.info", + "meine-urlaubsfotos.de", + "mekhong.com", + "melodymail.com", + "meloo.com", + "merda.flu.cc", + "merda.igg.biz", + "merda.nut.cc", + "merda.usa.cc", + "message.hu", + "message.myspace.com", + "messages.to", + "metacrawler.com", + "metalfan.com", + "metaping.com", + "metta.lk", + "mexicomail.com", + "mezimages.net", + "mfsa.ru", + "mierdamail.com", + "miesto.sk", + "mighty.co.za", + "migmail.net", + "migmail.pl", + "migumail.com", + "miho-nakayama.com", + "mikrotamanet.com", + "millionaireintraining.com", + "millionairemail.com", + "milmail.com", + "mindless.com", + "mindspring.com", + "minister.com", + "misery.net", + "mittalweb.com", + "mixmail.com", + "mjfrogmail.com", + "ml1.net", + "mlb.bounce.ed10.net", + "mm.st", + "mns.ru", + "moakt.com", + "mobilbatam.com", + "mobileninja.co.uk", + "mochamail.com", + "mohammed.com", + "mohmal.com", + "moldova.cc", + "moldova.com", + "moldovacc.com", + "momslife.com", + "monemail.com", + "money.net", + "montevideo.com.uy", + "monumentmail.com", + "moonman.com", + "moose-mail.com", + "mor19.uu.gl", + "mortaza.com", + "mosaicfx.com", + "moscowmail.com", + "most-wanted.com", + "mostlysunny.com", + "motormania.com", + "movemail.com", + "movieluver.com", + "mox.pp.ua", + "mp4.it", + "mr-potatohead.com", + "mscold.com", + "msgbox.com", + "msn.cn", + "msn.com", + "msn.nl", + "mt2015.com", + "mt2016.com", + "mttestdriver.com", + "muehlacker.tk", + "muell.icu", + "muellemail.com", + "muellmail.com", + "mundomail.net", + "munich.com", + "music.com", + "musician.org", + "musicscene.org", + "muskelshirt.de", + "muslim.com", + "muslimsonline.com", + "mutantweb.com", + "mvrht.com", + "my.com", + "my10minutemail.com", + "mybox.it", + "mycabin.com", + "mycity.com", + "mycool.com", + "mydomain.com", + "mydotcomaddress.com", + "myfamily.com", + "myfastmail.com", + "mygo.com", + "myiris.com", + "mymacmail.com", + "mynamedot.com", + "mynet.com", + "mynetaddress.com", + "mynetstore.de", + "myownemail.com", + "myownfriends.com", + "mypacks.net", + "mypad.com", + "mypersonalemail.com", + "myplace.com", + "myrambler.ru", + "myrealbox.com", + "myremarq.com", + "myself.com", + "myspaceinc.net", + "myspamless.com", + "mystupidjob.com", + "mytemp.email", + "mythirdage.com", + "myway.com", + "myworldmail.com", + "n2.com", + "n2baseball.com", + "n2business.com", + "n2mail.com", + "n2soccer.com", + "n2software.com", + "nabc.biz", + "nafe.com", + "nagpal.net", + "nakedgreens.com", + "name.com", + "nameplanet.com", + "nandomail.com", + "naplesnews.net", + "naseej.com", + "nativestar.net", + "nativeweb.net", + "naui.net", + "naver.com", + "navigator.lv", + "navy.org", + "naz.com", + "nc.rr.com", + "nchoicemail.com", + "neeva.net", + "nemra1.com", + "nenter.com", + "neo.rr.com", + "nervhq.org", + "net-c.be", + "net-c.ca", + "net-c.cat", + "net-c.com", + "net-c.es", + "net-c.fr", + "net-c.it", + "net-c.lu", + "net-c.nl", + "net-c.pl", + "net-pager.net", + "net-shopping.com", + "net4b.pt", + "net4you.at", + "netbounce.com", + "netbroadcaster.com", + "netby.dk", + "netc.eu", + "netc.fr", + "netc.it", + "netc.lu", + "netc.pl", + "netcenter-vn.net", + "netcmail.com", + "netcourrier.com", + "netexecutive.com", + "netexpressway.com", + "netgenie.com", + "netian.com", + "netizen.com.ar", + "netlane.com", + "netlimit.com", + "netmongol.com", + "netnet.com.sg", + "netnoir.net", + "netpiper.com", + "netposta.net", + "netralink.com", + "netscape.net", + "netscapeonline.co.uk", + "netspace.net.au", + "netspeedway.com", + "netsquare.com", + "netster.com", + "nettaxi.com", + "nettemail.com", + "netterchef.de", + "netti.fi", + "netzero.com", + "netzero.net", + "netzidiot.de", + "neue-dateien.de", + "neuro.md", + "newmail.com", + "newmail.net", + "newmail.ru", + "newsboysmail.com", + "newyork.com", + "nextmail.ru", + "nexxmail.com", + "nfmail.com", + "nicebush.com", + "nicegal.com", + "nicholastse.net", + "nicolastse.com", + "nightmail.com", + "nikopage.com", + "nimail.com", + "ninfan.com", + "nirvanafan.com", + "nmail.cf", + "noavar.com", + "nonpartisan.com", + "nonspam.eu", + "nonspammer.de", + "norika-fujiwara.com", + "norikomail.com", + "northgates.net", + "nospammail.net", + "nospamthanks.info", + "nowhere.org", + "ntelos.net", + "ntlhelp.net", + "ntlworld.com", + "ntscan.com", + "null.net", + "nullbox.info", + "nur-fuer-spam.de", + "nus.edu.sg", + "nwldx.com", + "nwytg.net", + "nxt.ru", + "ny.com", + "nybella.com", + "nyc.com", + "nycmail.com", + "nzoomail.com", + "o-tay.com", + "o2.co.uk", + "oaklandas-fan.com", + "oath.com", + "oceanfree.net", + "odaymail.com", + "oddpost.com", + "odmail.com", + "office-dateien.de", + "office-email.com", + "offroadwarrior.com", + "oicexchange.com", + "oida.icu", + "oikrach.com", + "okbank.com", + "okhuman.com", + "okmad.com", + "okmagic.com", + "okname.net", + "okuk.com", + "oldies104mail.com", + "ole.com", + "olemail.com", + "olympist.net", + "olypmall.ru", + "omaninfo.com", + "omen.ru", + "onebox.com", + "onenet.com.ar", + "oneoffmail.com", + "onet.com.pl", + "onet.eu", + "onet.pl", + "oninet.pt", + "online.ie", + "online.ms", + "online.nl", + "onlinewiz.com", + "onmilwaukee.com", + "onobox.com", + "op.pl", + "opayq.com", + "openmailbox.org", + "operafan.com", + "operamail.com", + "opoczta.pl", + "optician.com", + "optonline.net", + "optusnet.com.au", + "orange.fr", + "orbitel.bg", + "orgmail.net", + "orthodontist.net", + "osite.com.br", + "oso.com", + "otakumail.com", + "our-computer.com", + "our-office.com", + "our.st", + "ourbrisbane.com", + "ourklips.com", + "ournet.md", + "outgun.com", + "outlawspam.com", + "outlook.at", + "outlook.be", + "outlook.cl", + "outlook.co.id", + "outlook.co.il", + "outlook.co.nz", + "outlook.co.th", + "outlook.com", + "outlook.com.au", + "outlook.com.br", + "outlook.com.gr", + "outlook.com.pe", + "outlook.com.tr", + "outlook.com.vn", + "outlook.cz", + "outlook.de", + "outlook.dk", + "outlook.es", + "outlook.fr", + "outlook.hu", + "outlook.ie", + "outlook.in", + "outlook.it", + "outlook.jp", + "outlook.kr", + "outlook.lv", + "outlook.my", + "outlook.nl", + "outlook.ph", + "outlook.pt", + "outlook.sa", + "outlook.sg", + "outlook.sk", + "over-the-rainbow.com", + "ownmail.net", + "ozbytes.net.au", + "ozemail.com.au", + "pacbell.net", + "pacific-ocean.com", + "pacific-re.com", + "pacificwest.com", + "packersfan.com", + "pagina.de", + "pagons.org", + "pakistanmail.com", + "pakistanoye.com", + "palestinemail.com", + "pandora.be", + "papierkorb.me", + "parkjiyoon.com", + "parsmail.com", + "partlycloudy.com", + "partybombe.de", + "partyheld.de", + "partynight.at", + "parvazi.com", + "passwordmail.com", + "pathfindermail.com", + "pconnections.net", + "pcpostal.com", + "pcsrock.com", + "pcusers.otherinbox.com", + "pediatrician.com", + "penpen.com", + "peoplepc.com", + "peopleweb.com", + "pepbot.com", + "perfectmail.com", + "perso.be", + "personal.ro", + "personales.com", + "petlover.com", + "petml.com", + "pettypool.com", + "pezeshkpour.com", + "pfui.ru", + "phayze.com", + "phone.net", + "photo-impact.eu", + "photographer.net", + "phpbb.uu.gl", + "phreaker.net", + "phus8kajuspa.cu.cc", + "physicist.net", + "pianomail.com", + "pickupman.com", + "picusnet.com", + "pigpig.net", + "pinoymail.com", + "piracha.net", + "pisem.net", + "pjjkp.com", + "planet.nl", + "planetaccess.com", + "planetarymotion.net", + "planetearthinter.net", + "planetmail.com", + "planetmail.net", + "planetout.com", + "plasa.com", + "playersodds.com", + "playful.com", + "playstation.sony.com", + "plus.com", + "plus.google.com", + "plusmail.com.br", + "pm.me", + "pmail.net", + "pobox.hu", + "pobox.sk", + "pochta.ru", + "poczta.fm", + "poczta.onet.pl", + "poetic.com", + "pokemail.net", + "pokemonpost.com", + "pokepost.com", + "polandmail.com", + "polbox.com", + "policeoffice.com", + "politician.com", + "polizisten-duzer.de", + "polyfaust.com", + "pool-sharks.com", + "poond.com", + "popaccount.com", + "popmail.com", + "popsmail.com", + "popstar.com", + "portugalmail.com", + "portugalmail.pt", + "portugalnet.com", + "positive-thinking.com", + "post.com", + "post.cz", + "post.sk", + "posta.ro", + "postaccesslite.com", + "postafree.com", + "postaweb.com", + "posteo.at", + "posteo.be", + "posteo.ch", + "posteo.cl", + "posteo.co", + "posteo.de", + "posteo.dk", + "posteo.es", + "posteo.gl", + "posteo.net", + "posteo.no", + "posteo.us", + "postfach.cc", + "postinbox.com", + "postino.ch", + "postmark.net", + "postmaster.co.uk", + "postmaster.twitter.com", + "postpro.net", + "pousa.com", + "powerfan.com", + "pp.inet.fi", + "praize.com", + "premium-mail.fr", + "premiumservice.com", + "presidency.com", + "press.co.jp", + "priest.com", + "primposta.com", + "primposta.hu", + "privy-mail.com", + "privymail.de", + "pro.hu", + "probemail.com", + "prodigy.net", + "progetplus.it", + "programist.ru", + "programmer.net", + "programozo.hu", + "proinbox.com", + "project2k.com", + "promessage.com", + "prontomail.com", + "protestant.com", + "proton.me", + "protonmail.ch", + "protonmail.com", + "prydirect.info", + "psv-supporter.com", + "ptd.net", + "public-files.de", + "public.usa.com", + "publicist.com", + "pulp-fiction.com", + "punkass.com", + "purpleturtle.com", + "put2.net", + "pwrby.com", + "q.com", + "qatarmail.com", + "qmail.com", + "qprfans.com", + "qq.com", + "qrio.com", + "quackquack.com", + "quakemail.com", + "qualityservice.com", + "quantentunnel.de", + "qudsmail.com", + "quepasa.com", + "quickhosts.com", + "quickmail.nl", + "quicknet.nl", + "quickwebmail.com", + "quiklinks.com", + "quikmail.com", + "qv7.info", + "qwest.net", + "qwestoffice.net", + "r-o-o-t.com", + "raakim.com", + "racedriver.com", + "racefanz.com", + "racingfan.com.au", + "racingmail.com", + "radicalz.com", + "radiku.ye.vc", + "radiologist.net", + "ragingbull.com", + "ralib.com", + "rambler.ru", + "ranmamail.com", + "rastogi.net", + "ratt-n-roll.com", + "rattle-snake.com", + "raubtierbaendiger.de", + "ravearena.com", + "ravemail.com", + "razormail.com", + "rccgmail.org", + "rcn.com", + "realemail.net", + "reality-concept.club", + "reallyfast.biz", + "reallyfast.info", + "reallymymail.com", + "realradiomail.com", + "realtyagent.com", + "reborn.com", + "reconmail.com", + "recycler.com", + "recyclermail.com", + "rediff.com", + "rediffmail.com", + "rediffmailpro.com", + "rednecks.com", + "redseven.de", + "redsfans.com", + "regbypass.com", + "reggaefan.com", + "registerednurses.com", + "regspaces.tk", + "reincarnate.com", + "religious.com", + "remail.ga", + "renren.com", + "repairman.com", + "reply.hu", + "reply.ticketmaster.com", + "representative.com", + "rescueteam.com", + "resgedvgfed.tk", + "resource.calendar.google.com", + "resumemail.com", + "rezai.com", + "rhyta.com", + "richmondhill.com", + "rickymail.com", + "rin.ru", + "riopreto.com.br", + "rklips.com", + "rn.com", + "ro.ru", + "roadrunner.com", + "roanokemail.com", + "rock.com", + "rocketmail.com", + "rocketship.com", + "rockfan.com", + "rodrun.com", + "rogers.com", + "rome.com", + "roosh.com", + "rootprompt.org", + "roughnet.com", + "royal.net", + "rr.com", + "rrohio.com", + "rsub.com", + "rubyridge.com", + "runbox.com", + "rushpost.com", + "ruttolibero.com", + "rvshop.com", + "s-mail.com", + "sabreshockey.com", + "sacbeemail.com", + "saeuferleber.de", + "safe-mail.net", + "safrica.com", + "sagra.lu", + "sags-per-mail.de", + "sailormoon.com", + "saintly.com", + "saintmail.net", + "sale-sale-sale.com", + "salehi.net", + "salesperson.net", + "samerica.com", + "samilan.net", + "sammimail.com", + "sandelf.de", + "sanfranmail.com", + "sanook.com", + "sapo.pt", + "saudia.com", + "savelife.ml", + "sayhi.net", + "saynotospams.com", + "sbcglbal.net", + "sbcglobal.com", + "sbcglobal.net", + "scandalmail.com", + "scarlet.nl", + "schafmail.de", + "schizo.com", + "schmusemail.de", + "schoolemail.com", + "schoolmail.com", + "schoolsucks.com", + "schreib-doch-mal-wieder.de", + "schweiz.org", + "sci.fi", + "scientist.com", + "scifianime.com", + "scotland.com", + "scotlandmail.com", + "scottishmail.co.uk", + "scottsboro.org", + "scubadiving.com", + "seanet.com", + "search.ua", + "searchwales.com", + "sebil.com", + "seckinmail.com", + "secret-police.com", + "secretary.net", + "secretservices.net", + "secure-mail.biz", + "secure-mail.cc", + "seductive.com", + "seekstoyboy.com", + "seguros.com.br", + "selfdestructingmail.com", + "send.hu", + "sendme.cz", + "sendspamhere.com", + "sent.as", + "sent.at", + "sent.com", + "sentrismail.com", + "serga.com.ar", + "servemymail.com", + "servermaps.net", + "sesmail.com", + "sexmagnet.com", + "seznam.cz", + "shahweb.net", + "shaniastuff.com", + "shared-files.de", + "sharedmailbox.org", + "sharklasers.com", + "sharmaweb.com", + "shaw.ca", + "she.com", + "shieldedmail.com", + "shinedyoureyes.com", + "shitaway.cf", + "shitaway.cu.cc", + "shitaway.ga", + "shitaway.gq", + "shitaway.ml", + "shitaway.tk", + "shitaway.usa.cc", + "shitmail.de", + "shitmail.org", + "shitware.nl", + "shockinmytown.cu.cc", + "shootmail.com", + "shortmail.com", + "shotgun.hu", + "showslow.de", + "shuf.com", + "sialkotcity.com", + "sialkotian.com", + "sialkotoye.com", + "sify.com", + "silkroad.net", + "sina.cn", + "sina.com", + "sinamail.com", + "singapore.com", + "singles4jesus.com", + "singmail.com", + "singnet.com.sg", + "sinnlos-mail.de", + "siteposter.net", + "skafan.com", + "skeefmail.com", + "skim.com", + "skizo.hu", + "skrx.tk", + "sky.com", + "skynet.be", + "slamdunkfan.com", + "slave-auctions.net", + "slingshot.com", + "slippery.email", + "slipry.net", + "slo.net", + "slotter.com", + "smap.4nmv.ru", + "smapxsmap.net", + "smashmail.de", + "smellrear.com", + "smileyface.comsmithemail.net", + "smoothmail.com", + "sms.at", + "snail-mail.net", + "snakebite.com", + "snakemail.com", + "sndt.net", + "sneakemail.com", + "snet.net", + "sniper.hu", + "snkmail.com", + "snoopymail.com", + "snowboarding.com", + "snowdonia.net", + "socamail.com", + "socceramerica.net", + "soccermail.com", + "soccermomz.com", + "social-mailer.tk", + "socialworker.net", + "sociologist.com", + "sofort-mail.de", + "sofortmail.de", + "softhome.net", + "sogou.com", + "sohu.com", + "sol.dk", + "solar-impact.pro", + "solcon.nl", + "soldier.hu", + "solution4u.com", + "solvemail.info", + "songwriter.net", + "sonnenkinder.org", + "soodomail.com", + "soon.com", + "soulfoodcookbook.com", + "sp.nl", + "space-bank.com", + "space-man.com", + "space-ship.com", + "space-travel.com", + "space.com", + "spacemart.com", + "spacetowns.com", + "spacewar.com", + "spainmail.com", + "spam.2012-2016.ru", + "spam.care", + "spamavert.com", + "spambob.com", + "spambob.org", + "spambog.net", + "spambooger.com", + "spambox.xyz", + "spamcero.com", + "spamdecoy.net", + "spameater.com", + "spameater.org", + "spamex.com", + "spamfree24.info", + "spamfree24.net", + "spamgoes.in", + "spaminator.de", + "spamkill.info", + "spaml.com", + "spamoff.de", + "spamstack.net", + "spartapiet.com", + "spazmail.com", + "speedemail.net", + "speedpost.net", + "speedrules.com", + "speedrulz.com", + "speedymail.org", + "sperke.net", + "spils.com", + "spinfinder.com", + "spl.at", + "spoko.pl", + "spoofmail.de", + "sportemail.com", + "sportsmail.com", + "sporttruckdriver.com", + "spray.no", + "spray.se", + "spybox.de", + "spymac.com", + "sraka.xyz", + "srilankan.net", + "ssl-mail.com", + "st-davids.net", + "stade.fr", + "stalag13.com", + "stargateradio.com", + "starmail.com", + "starmail.org", + "starmedia.com", + "starplace.com", + "starspath.com", + "start.com.au", + "startkeys.com", + "stinkefinger.net", + "stipte.nl", + "stoned.com", + "stones.com", + "stop-my-spam.pp.ua", + "stopdropandroll.com", + "storksite.com", + "streber24.de", + "streetwisemail.com", + "stribmail.com", + "strompost.com", + "strongguy.com", + "student.su", + "studentcenter.org", + "stuffmail.de", + "subram.com", + "sudanmail.net", + "sudolife.me", + "sudolife.net", + "sudomail.biz", + "sudomail.com", + "sudomail.net", + "sudoverse.com", + "sudoverse.net", + "sudoweb.net", + "sudoworld.com", + "sudoworld.net", + "suhabi.com", + "suisse.org", + "sukhumvit.net", + "sunpoint.net", + "sunrise-sunset.com", + "sunsgame.com", + "sunumail.sn", + "suomi24.fi", + "superdada.com", + "supereva.it", + "supermail.ru", + "superrito.com", + "superstachel.de", + "surat.com", + "surf3.net", + "surfree.com", + "surfy.net", + "surgical.net", + "surimail.com", + "survivormail.com", + "susi.ml", + "svk.jp", + "swbell.net", + "sweb.cz", + "swedenmail.com", + "sweetville.net", + "sweetxxx.de", + "swift-mail.com", + "swiftdesk.com", + "swingeasyhithard.com", + "swingfan.com", + "swipermail.zzn.com", + "swirve.com", + "swissinfo.org", + "swissmail.com", + "swissmail.net", + "switchboardmail.com", + "switzerland.org", + "sx172.com", + "syom.com", + "syriamail.com", + "t-online.de", + "t.psh.me", + "t2mail.com", + "tafmail.com", + "takuyakimura.com", + "talk21.com", + "talkcity.com", + "talkinator.com", + "tamil.com", + "tampabay.rr.com", + "tankpolice.com", + "tatanova.com", + "tbwt.com", + "tcc.on.ca", + "tds.net", + "teachermail.net", + "teachers.org", + "teamdiscovery.com", + "teamtulsa.net", + "tech-center.com", + "tech4peace.org", + "techemail.com", + "techie.com", + "technisamail.co.za", + "technologist.com", + "techscout.com", + "techspot.com", + "teenagedirtbag.com", + "tele2.nl", + "telebot.com", + "telefonica.net", + "teleline.es", + "telenet.be", + "telepac.pt", + "telerymd.com", + "teleworm.us", + "telfort.nl", + "telfortglasvezel.nl", + "telinco.net", + "telkom.net", + "telpage.net", + "telstra.com", + "telstra.com.au", + "temp-mail.com", + "temp-mail.de", + "temp-mail.org", + "temp.headstrong.de", + "tempail.com", + "tempemail.biz", + "tempmail.net", + "tempmail.us", + "tempmail2.com", + "tempmaildemo.com", + "tempmailer.com", + "temporarioemail.com.br", + "temporaryemail.us", + "tempthe.net", + "tempymail.com", + "temtulsa.net", + "tenchiclub.com", + "tenderkiss.com", + "tennismail.com", + "terminverpennt.de", + "terra.cl", + "terra.com", + "terra.com.ar", + "terra.com.br", + "terra.es", + "test.com", + "test.de", + "tfanus.com.er", + "tfz.net", + "thai.com", + "thaimail.com", + "thaimail.net", + "thanksnospam.info", + "the-african.com", + "the-airforce.com", + "the-aliens.com", + "the-american.com", + "the-animal.com", + "the-army.com", + "the-astronaut.com", + "the-beauty.com", + "the-big-apple.com", + "the-biker.com", + "the-boss.com", + "the-brazilian.com", + "the-canadian.com", + "the-canuck.com", + "the-captain.com", + "the-chinese.com", + "the-country.com", + "the-cowboy.com", + "the-davis-home.com", + "the-dutchman.com", + "the-eagles.com", + "the-englishman.com", + "the-fastest.net", + "the-fool.com", + "the-frenchman.com", + "the-galaxy.net", + "the-genius.com", + "the-gentleman.com", + "the-german.com", + "the-gremlin.com", + "the-hooligan.com", + "the-italian.com", + "the-japanese.com", + "the-lair.com", + "the-madman.com", + "the-mailinglist.com", + "the-marine.com", + "the-master.com", + "the-mexican.com", + "the-ministry.com", + "the-monkey.com", + "the-newsletter.net", + "the-pentagon.com", + "the-police.com", + "the-prayer.com", + "the-professional.com", + "the-quickest.com", + "the-russian.com", + "the-snake.com", + "the-spaceman.com", + "the-stock-market.com", + "the-student.net", + "the-whitehouse.net", + "the-wild-west.com", + "the18th.com", + "thecoolguy.com", + "thecriminals.com", + "thedoghousemail.com", + "thedorm.com", + "theend.hu", + "theglobe.com", + "thegolfcourse.com", + "thegooner.com", + "theheadoffice.com", + "theinternetemail.com", + "thelanddownunder.com", + "themail.com", + "themillionare.net", + "theoffice.net", + "theplate.com", + "thepokerface.com", + "thepostmaster.net", + "theraces.com", + "theracetrack.com", + "therapist.net", + "thestreetfighter.com", + "theteebox.com", + "thewatercooler.com", + "thewebpros.co.uk", + "thewizzard.com", + "thewizzkid.com", + "thezhangs.net", + "thirdage.com", + "thisgirl.com", + "thraml.com", + "throwam.com", + "thundermail.com", + "tidni.com", + "timein.net", + "tiscali.at", + "tiscali.be", + "tiscali.co.uk", + "tiscali.it", + "tiscali.lu", + "tiscali.se", + "tkcity.com", + "tmail.ws", + "toast.com", + "toke.com", + "tom.com", + "toolsource.com", + "toomail.biz", + "toothfairy.com", + "topchat.com", + "topgamers.co.uk", + "topletter.com", + "topmail-files.de", + "topmail.com.ar", + "topsurf.com", + "torchmail.com", + "torontomail.com", + "tortenboxer.de", + "totalmail.de", + "totalmusic.net", + "townisp.com", + "tpg.com.au", + "trash-amil.com", + "trash-mail.ga", + "trash-mail.ml", + "trash2010.com", + "trash2011.com", + "trashdevil.de", + "trashymail.net", + "travel.li", + "trayna.com", + "trialbytrivia.com", + "trickmail.net", + "trimix.cn", + "tritium.net", + "trmailbox.com", + "tropicalstorm.com", + "truckerz.com", + "truckracer.com", + "truckracers.com", + "trust-me.com", + "truthmail.com", + "tsamail.co.za", + "ttml.co.in", + "tunisiamail.com", + "turboprinz.de", + "turboprinzessin.de", + "turkey.com", + "turual.com", + "tut.by", + "tvstar.com", + "twc.com", + "twcny.com", + "twinstarsmail.com", + "tx.rr.com", + "tycoonmail.com", + "typemail.com", + "u14269.ml", + "u2club.com", + "ua.fm", + "uae.ac", + "uaemail.com", + "ubbi.com", + "ubbi.com.br", + "uboot.com", + "uk2.net", + "uk2k.com", + "uk2net.com", + "uk7.net", + "uk8.net", + "ukbuilder.com", + "ukcool.com", + "ukdreamcast.com", + "ukmail.org", + "ukmax.com", + "ukr.net", + "uku.co.uk", + "ultapulta.com", + "ultra.fyi", + "ultrapostman.com", + "ummah.org", + "umpire.com", + "unbounded.com", + "unforgettable.com", + "uni.de", + "unican.es", + "unihome.com", + "unitybox.de", + "universal.pt", + "uno.ee", + "uno.it", + "unofree.it", + "unterderbruecke.de", + "uol.com.ar", + "uol.com.br", + "uol.com.co", + "uol.com.mx", + "uol.com.ve", + "uole.com", + "uole.com.ve", + "uolmail.com", + "uomail.com", + "upc.nl", + "upcmail.nl", + "upf.org", + "uplipht.com", + "ureach.com", + "urgentmail.biz", + "urhen.com", + "uroid.com", + "usa.com", + "usa.net", + "usaaccess.net", + "usanetmail.com", + "used-product.fr", + "usermail.com", + "username.e4ward.com", + "usma.net", + "usmc.net", + "uswestmail.net", + "uymail.com", + "uyuyuy.com", + "v-sexi.com", + "vaasfc4.tk", + "vahoo.com", + "valemail.net", + "vampirehunter.com", + "varbizmail.com", + "vcmail.com", + "velnet.co.uk", + "velocall.com", + "verizon.net", + "verizonmail.com", + "verlass-mich-nicht.de", + "versatel.nl", + "veryfast.biz", + "veryrealemail.com", + "veryspeedy.net", + "vfemail.net", + "vickaentb.tk", + "videotron.ca", + "viditag.com", + "viewcastmedia.com", + "viewcastmedia.net", + "vinbazar.com", + "violinmakers.co.uk", + "vip.126.com", + "vip.21cn.com", + "vip.citiz.net", + "vip.gr", + "vip.onet.pl", + "vip.qq.com", + "vip.sina.com", + "vipmail.ru", + "virgilio.it", + "virgin.net", + "virginbroadband.com.au", + "virginmedia.com", + "virtualmail.com", + "visitmail.com", + "visitweb.com", + "visto.com", + "visualcities.com", + "vivavelocity.com", + "vivianhsu.net", + "vjtimail.com", + "vkcode.ru", + "vnet.citiz.net", + "vnn.vn", + "vodafone.nl", + "vodafonethuis.nl", + "volcanomail.com", + "vollbio.de", + "volloeko.de", + "vomoto.com", + "vorsicht-bissig.de", + "vorsicht-scharf.de", + "vote-democrats.com", + "vote-hillary.com", + "vote-republicans.com", + "vote4gop.org", + "votenet.com", + "vp.pl", + "vr9.com", + "vubby.com", + "w3.to", + "wahoye.com", + "walala.org", + "wales2000.net", + "walkmail.net", + "walkmail.ru", + "wam.co.za", + "wanadoo.es", + "wanadoo.fr", + "war-im-urlaub.de", + "warmmail.com", + "warpmail.net", + "warrior.hu", + "waumail.com", + "wazabi.club", + "wbdet.com", + "wearab.net", + "web-contact.info", + "web-emailbox.eu", + "web-ideal.fr", + "web-mail.com.ar", + "web-mail.pp.ua", + "web-police.com", + "web.de", + "webave.com", + "webcammail.com", + "webcity.ca", + "webcontact-france.eu", + "webdream.com", + "webindia123.com", + "webjump.com", + "webm4il.info", + "webmail.co.yu", + "webmail.co.za", + "webmail.hu", + "webmails.com", + "webname.com", + "webprogramming.com", + "webstation.com", + "websurfer.co.za", + "webtopmail.com", + "webuser.in", + "wee.my", + "weedmail.com", + "weekmail.com", + "weekonline.com", + "wefjo.grn.cc", + "weg-werf-email.de", + "wegas.ru", + "wegwerf-emails.de", + "wegwerfmail.info", + "wegwerpmailadres.nl", + "wehshee.com", + "weibsvolk.de", + "weibsvolk.org", + "weinenvorglueck.de", + "welsh-lady.com", + "westnet.com", + "westnet.com.au", + "wetrainbayarea.com", + "wfgdfhj.tk", + "whale-mail.com", + "whartontx.com", + "whatiaas.com", + "whatpaas.com", + "wheelweb.com", + "whipmail.com", + "whoever.com", + "whoopymail.com", + "whtjddn.33mail.com", + "wi.rr.com", + "wi.twcbc.com", + "wickmail.net", + "wideopenwest.com", + "wildmail.com", + "wilemail.com", + "will-hier-weg.de", + "windowslive.com", + "windrivers.net", + "windstream.net", + "wingnutz.com", + "winmail.com.au", + "winning.com", + "wir-haben-nachwuchs.de", + "wir-sind-cool.org", + "wirsindcool.de", + "witty.com", + "wiz.cc", + "wkbwmail.com", + "wmail.cf", + "wo.com.cn", + "woh.rr.com", + "wolf-web.com", + "wolke7.net", + "wollan.info", + "wombles.com", + "women-at-work.org", + "wongfaye.com", + "wooow.it", + "worker.com", + "workmail.com", + "worldemail.com", + "worldnet.att.net", + "wormseo.cn", + "wosaddict.com", + "wouldilie.com", + "wovz.cu.cc", + "wowgirl.com", + "wowmail.com", + "wowway.com", + "wp.pl", + "wptamail.com", + "wrexham.net", + "writeme.com", + "writemeback.com", + "wrongmail.com", + "wtvhmail.com", + "wwdg.com", + "www.com", + "www.e4ward.com", + "www2000.net", + "wx88.net", + "wxs.net", + "x-mail.net", + "x-networks.net", + "x5g.com", + "xagloo.com", + "xaker.ru", + "xing886.uu.gl", + "xmastime.com", + "xms.nl", + "xmsg.com", + "xoom.com", + "xoxox.cc", + "xpressmail.zzn.com", + "xs4all.nl", + "xsecurity.org", + "xsmail.com", + "xtra.co.nz", + "xuno.com", + "xww.ro", + "xy9ce.tk", + "xyzfree.net", + "xzapmail.com", + "y7mail.com", + "ya.ru", + "yada-yada.com", + "yaho.com", + "yahoo.ae", + "yahoo.at", + "yahoo.be", + "yahoo.ca", + "yahoo.ch", + "yahoo.cn", + "yahoo.co", + "yahoo.co.id", + "yahoo.co.il", + "yahoo.co.in", + "yahoo.co.jp", + "yahoo.co.kr", + "yahoo.co.nz", + "yahoo.co.th", + "yahoo.co.uk", + "yahoo.co.za", + "yahoo.com", + "yahoo.com.ar", + "yahoo.com.au", + "yahoo.com.br", + "yahoo.com.cn", + "yahoo.com.co", + "yahoo.com.hk", + "yahoo.com.is", + "yahoo.com.mx", + "yahoo.com.my", + "yahoo.com.ph", + "yahoo.com.ru", + "yahoo.com.sg", + "yahoo.com.tr", + "yahoo.com.tw", + "yahoo.com.vn", + "yahoo.cz", + "yahoo.de", + "yahoo.dk", + "yahoo.es", + "yahoo.fi", + "yahoo.fr", + "yahoo.gr", + "yahoo.hu", + "yahoo.ie", + "yahoo.in", + "yahoo.it", + "yahoo.jp", + "yahoo.nl", + "yahoo.no", + "yahoo.pl", + "yahoo.pt", + "yahoo.ro", + "yahoo.ru", + "yahoo.se", + "yahoofs.com", + "yalla.com", + "yalla.com.lb", + "yalook.com", + "yam.com", + "yandex.com", + "yandex.pl", + "yandex.ru", + "yandex.ua", + "yapost.com", + "yapped.net", + "yawmail.com", + "yeah.net", + "yebox.com", + "yehey.com", + "yemenmail.com", + "yepmail.net", + "yert.ye.vc", + "yesey.net", + "yifan.net", + "ymail.com", + "yogotemail.com", + "yomail.info", + "yopmail.com", + "yopmail.pp.ua", + "yopolis.com", + "yopweb.com", + "youareadork.com", + "youmailr.com", + "your-house.com", + "your-mail.com", + "yourinbox.com", + "yourlifesucks.cu.cc", + "yourlover.net", + "yourname.freeservers.com", + "yournightmare.com", + "yours.com", + "yourssincerely.com", + "yoursubdomain.zzn.com", + "yourteacher.net", + "yourwap.com", + "yuuhuu.net", + "yyhmail.com", + "z1p.biz", + "za.com", + "zahadum.com", + "zaktouni.fr", + "zeepost.nl", + "zetmail.com", + "zhaowei.net", + "zhouemail.510520.org", + "ziggo.nl", + "zionweb.org", + "zip.net", + "zipido.com", + "ziplip.com", + "zipmail.com", + "zipmail.com.br", + "zipmax.com", + "zmail.ru", + "zoemail.com", + "zoemail.org", + "zoho.com", + "zohomail.com", + "zomg.info", + "zonnet.nl", + "zoominternet.net", + "zubee.com", + "zuvio.com", + "zuzzurello.com", + "zwallet.com", + "zweb.in", + "zxcv.com", + "zxcvbnm.com", + "zybermail.com", + "zydecofan.com", + "zzn.com", + "zzom.co.uk", + "zzz.com", + "zzz.pl" +] diff --git a/apps/sim/lib/messaging/email/free-email.ts b/apps/sim/lib/messaging/email/free-email.ts index 07a2f2b5002..3190553bdef 100644 --- a/apps/sim/lib/messaging/email/free-email.ts +++ b/apps/sim/lib/messaging/email/free-email.ts @@ -1,6 +1,6 @@ -import freeEmailDomains from 'free-email-domains' +import freeEmailDomains from '@/lib/messaging/email/free-email-domains.json' -const FREE_EMAIL_DOMAINS = new Set(freeEmailDomains) +const FREE_EMAIL_DOMAINS = new Set(freeEmailDomains) /** * True when the email's domain is a known free/personal provider (Gmail, Yahoo, @@ -10,6 +10,11 @@ const FREE_EMAIL_DOMAINS = new Set(freeEmailDomains) * Isolated in its own module (not `validation.ts`) so the sizable domain list * only enters bundles that need the work-email check, not every consumer of * {@link quickValidateEmail}. + * + * Vendored rather than taken from the `free-email-domains` package, whose `postinstall` + * downloads a CDN CSV and overwrites its own `domains.json` — so the lockfile hash covers + * the tarball but not the installed data. Refresh from + * https://github.com/Kikobeats/free-email-domains (MIT) and review the diff. */ export function isFreeEmailDomain(email: string): boolean { const domain = email.split('@')[1]?.toLowerCase() diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 1ee1b2389e9..e0621cef65d 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -129,7 +129,7 @@ const nextConfig: NextConfig = { 'isolated-vm', '@e2b/code-interpreter', 'e2b', - '@daytonaio/sdk', + '@daytona/sdk', '@earendil-works/pi-ai', '@earendil-works/pi-coding-agent', ], diff --git a/apps/sim/package.json b/apps/sim/package.json index fd137cb32ef..95429efbd64 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -66,7 +66,7 @@ "@browserbasehq/stagehand": "^3.2.1", "@calcom/embed-react": "1.5.3", "@cerebras/cerebras_cloud_sdk": "^1.23.0", - "@daytonaio/sdk": "0.197.0", + "@daytona/sdk": "0.200.0", "@e2b/code-interpreter": "^2.7.0", "@earendil-works/pi-ai": "0.80.10", "@earendil-works/pi-coding-agent": "0.80.10", @@ -102,8 +102,8 @@ "@radix-ui/react-slot": "1.2.2", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", - "@react-email/components": "0.5.7", - "@react-email/render": "2.0.8", + "@react-email/components": "1.0.12", + "@react-email/render": "2.1.0", "@sim/audit": "workspace:*", "@sim/emcn": "workspace:*", "@sim/logger": "workspace:*", @@ -153,7 +153,6 @@ "es-toolkit": "1.45.1", "fluent-ffmpeg": "2.1.3", "framer-motion": "^12.5.0", - "free-email-domains": "1.2.25", "google-auth-library": "10.5.0", "gray-matter": "^4.0.3", "groq-sdk": "^0.15.0", @@ -214,7 +213,6 @@ "streamdown": "2.5.0", "stripe": "18.5.0", "svix": "1.88.0", - "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "three": "0.177.0", "tldts": "7.0.30", @@ -249,10 +247,9 @@ "@typescript/native-preview": "7.0.0-dev.20260707.2", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^4.1.0", - "autoprefixer": "10.4.21", "jsdom": "^26.0.0", "postcss": "^8", - "react-email": "4.3.2", + "react-email": "6.9.0", "tailwindcss": "^3.4.1", "typescript": "^7.0.2", "vite-tsconfig-paths": "^5.1.4", diff --git a/apps/sim/scripts/build-pi-daytona-snapshot.ts b/apps/sim/scripts/build-pi-daytona-snapshot.ts index 57846b155ea..fea96ac6673 100644 --- a/apps/sim/scripts/build-pi-daytona-snapshot.ts +++ b/apps/sim/scripts/build-pi-daytona-snapshot.ts @@ -23,7 +23,7 @@ * DAYTONA_PI_SNAPSHOT_ID= */ -import { Daytona, Image } from '@daytonaio/sdk' +import { Daytona, Image } from '@daytona/sdk' import { getErrorMessage } from '@sim/utils/errors' import { PI_APT, diff --git a/bun.lock b/bun.lock index 908fcf16e20..79db5a4d446 100644 --- a/bun.lock +++ b/bun.lock @@ -141,7 +141,7 @@ "@browserbasehq/stagehand": "^3.2.1", "@calcom/embed-react": "1.5.3", "@cerebras/cerebras_cloud_sdk": "^1.23.0", - "@daytonaio/sdk": "0.197.0", + "@daytona/sdk": "0.200.0", "@e2b/code-interpreter": "^2.7.0", "@earendil-works/pi-ai": "0.80.10", "@earendil-works/pi-coding-agent": "0.80.10", @@ -177,8 +177,8 @@ "@radix-ui/react-slot": "1.2.2", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", - "@react-email/components": "0.5.7", - "@react-email/render": "2.0.8", + "@react-email/components": "1.0.12", + "@react-email/render": "2.1.0", "@sim/audit": "workspace:*", "@sim/emcn": "workspace:*", "@sim/logger": "workspace:*", @@ -228,7 +228,6 @@ "es-toolkit": "1.45.1", "fluent-ffmpeg": "2.1.3", "framer-motion": "^12.5.0", - "free-email-domains": "1.2.25", "google-auth-library": "10.5.0", "gray-matter": "^4.0.3", "groq-sdk": "^0.15.0", @@ -289,7 +288,6 @@ "streamdown": "2.5.0", "stripe": "18.5.0", "svix": "1.88.0", - "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "three": "0.177.0", "tldts": "7.0.30", @@ -324,10 +322,9 @@ "@typescript/native-preview": "7.0.0-dev.20260707.2", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^4.1.0", - "autoprefixer": "10.4.21", "jsdom": "^26.0.0", "postcss": "^8", - "react-email": "4.3.2", + "react-email": "6.9.0", "tailwindcss": "^3.4.1", "typescript": "^7.0.2", "vite-tsconfig-paths": "^5.1.4", @@ -889,7 +886,7 @@ "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], @@ -899,7 +896,7 @@ "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], @@ -983,13 +980,13 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], - "@daytona/analytics-api-client": ["@daytona/analytics-api-client@0.197.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-8S8JBVwIhErhDv22kCtifonfMnpQXtoAqz3migT53u7LCnjnVkqOeUFB/xN4SD7LN+Bhbh6AMVkvNwZA2BkIfw=="], + "@daytona/analytics-api-client": ["@daytona/analytics-api-client@0.200.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-0pTP2js+zjd5Q23YdV2e0pOFHJYVYTo9jS0IOcRCsQlqyaoeSbzGjr6M38w3Cjw81m3dW1vchw316eOkkaVGdA=="], - "@daytona/api-client": ["@daytona/api-client@0.197.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-O7BF07FOmmbNrp/An3EIx/Vq99wSHvxxWl4nUttw0eHpe7oKdYaUVlM2MmdJ/AbrFnYDLHfrQZGZfeUOEeRjpw=="], + "@daytona/api-client": ["@daytona/api-client@0.200.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-gIM87vCrdGe1xvw9XcYv/PFREMk5+hYHY/epAvxswC+3fe/h4TMRbroUE4FMz/HydB7qAQiTz5EC4vZ2MoVTWQ=="], - "@daytona/toolbox-api-client": ["@daytona/toolbox-api-client@0.197.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-uBcbIAPcqeJUOpessqf2Za6Jid/Negn0x3wJlTcby391gsPUYDgsGzOmDZMs/rFKQ+/xtz/DAd7VThJZC5fnIQ=="], + "@daytona/sdk": ["@daytona/sdk@0.200.0", "", { "dependencies": { "@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/lib-storage": "^3.798.0", "@daytona/analytics-api-client": "0.200.0", "@daytona/api-client": "0.200.0", "@daytona/toolbox-api-client": "0.200.0", "@iarna/toml": "^2.2.5", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", "@opentelemetry/instrumentation-http": "^0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-node": "^0.219.0", "@opentelemetry/sdk-trace-base": "^2.8.0", "@opentelemetry/semantic-conventions": "^1.40.0", "axios": "^1.15.2", "busboy": "^1.0.0", "dotenv": "^17.0.1", "expand-tilde": "^2.0.2", "fast-glob": "^3.3.0", "form-data": "^4.0.4", "isomorphic-ws": "^5.0.0", "pathe": "^2.0.3", "shell-quote": "^1.8.2", "socket.io-client": "^4.8.1", "tar": "^7.5.11" } }, "sha512-0THBDtSUvMSCRTK11gc5CWDobwBlMm5Sz+UiyWplW6f/BMnAlhN/yyqLQPpVfWEtY3llPm2kXkzDwaR2fXQarA=="], - "@daytonaio/sdk": ["@daytonaio/sdk@0.197.0", "", { "dependencies": { "@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/lib-storage": "^3.798.0", "@daytona/analytics-api-client": "0.197.0", "@daytona/api-client": "0.197.0", "@daytona/toolbox-api-client": "0.197.0", "@iarna/toml": "^2.2.5", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", "@opentelemetry/instrumentation-http": "^0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-node": "^0.219.0", "@opentelemetry/sdk-trace-base": "^2.8.0", "@opentelemetry/semantic-conventions": "^1.40.0", "axios": "^1.15.2", "busboy": "^1.0.0", "dotenv": "^17.0.1", "expand-tilde": "^2.0.2", "fast-glob": "^3.3.0", "form-data": "^4.0.4", "isomorphic-ws": "^5.0.0", "pathe": "^2.0.3", "shell-quote": "^1.8.2", "tar": "^7.5.11" } }, "sha512-RFrK/TLZy8S0VyQcXOzOv70VKNUoUyJ45u/HlCJjmXu5vyK3TH0pQJv9yJn8uT49W+4ypMSODcz0YJIRjH16VA=="], + "@daytona/toolbox-api-client": ["@daytona/toolbox-api-client@0.200.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-yj4u7wApHz53ayHRNX408nuazfo4AT+GlFjx35LPyxyC2ffOF4TPrVR0pQxYWQt+lg61GFNI9xr9Ig0oXaKT8w=="], "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], @@ -1167,7 +1164,7 @@ "@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="], - "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -1493,47 +1490,47 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="], - "@react-email/body": ["@react-email/body@0.1.0", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-o1bcSAmDYNNHECbkeyceCVPGmVsYvT+O3sSO/Ct7apKUu3JphTi31hu+0Nwqr/pgV5QFqdoT5vdS3SW5DJFHgQ=="], + "@react-email/body": ["@react-email/body@0.3.0", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug=="], - "@react-email/button": ["@react-email/button@0.2.0", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-8i+v6cMxr2emz4ihCrRiYJPp2/sdYsNNsBzXStlcA+/B9Umpm5Jj3WJKYpgTPM+aeyiqlG/MMI1AucnBm4f1oQ=="], + "@react-email/button": ["@react-email/button@0.2.1", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A=="], - "@react-email/code-block": ["@react-email/code-block@0.1.0", "", { "dependencies": { "prismjs": "^1.30.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jSpHFsgqnQXxDIssE4gvmdtFncaFQz5D6e22BnVjcCPk/udK+0A9jRwGFEG8JD2si9ZXBmU4WsuqQEczuZn4ww=="], + "@react-email/code-block": ["@react-email/code-block@0.2.1", "", { "dependencies": { "prismjs": "^1.30.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw=="], - "@react-email/code-inline": ["@react-email/code-inline@0.0.5", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA=="], + "@react-email/code-inline": ["@react-email/code-inline@0.0.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA=="], - "@react-email/column": ["@react-email/column@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Lqq17l7ShzJG/d3b1w/+lVO+gp2FM05ZUo/nW0rjxB8xBICXOVv6PqjDnn3FXKssvhO5qAV20lHM6S+spRhEwQ=="], + "@react-email/column": ["@react-email/column@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg=="], - "@react-email/components": ["@react-email/components@0.5.7", "", { "dependencies": { "@react-email/body": "0.1.0", "@react-email/button": "0.2.0", "@react-email/code-block": "0.1.0", "@react-email/code-inline": "0.0.5", "@react-email/column": "0.0.13", "@react-email/container": "0.0.15", "@react-email/font": "0.0.9", "@react-email/head": "0.0.12", "@react-email/heading": "0.0.15", "@react-email/hr": "0.0.11", "@react-email/html": "0.0.11", "@react-email/img": "0.0.11", "@react-email/link": "0.0.12", "@react-email/markdown": "0.0.16", "@react-email/preview": "0.0.13", "@react-email/render": "1.4.0", "@react-email/row": "0.0.12", "@react-email/section": "0.0.16", "@react-email/tailwind": "1.2.2", "@react-email/text": "0.1.5" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ECyVoyDcev2FSQ7C0buXaIJ0+6MRDXNUbCOZwBRrlLdCCRjap2b4+MHrYSTXFzo5kqfjjRoyo/2PbJXFQni67g=="], + "@react-email/components": ["@react-email/components@1.0.12", "", { "dependencies": { "@react-email/body": "0.3.0", "@react-email/button": "0.2.1", "@react-email/code-block": "0.2.1", "@react-email/code-inline": "0.0.6", "@react-email/column": "0.0.14", "@react-email/container": "0.0.16", "@react-email/font": "0.0.10", "@react-email/head": "0.0.13", "@react-email/heading": "0.0.16", "@react-email/hr": "0.0.12", "@react-email/html": "0.0.12", "@react-email/img": "0.0.12", "@react-email/link": "0.0.13", "@react-email/markdown": "0.0.18", "@react-email/preview": "0.0.14", "@react-email/render": "2.0.6", "@react-email/row": "0.0.13", "@react-email/section": "0.0.17", "@react-email/tailwind": "2.0.7", "@react-email/text": "0.1.6" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-tH18JhPDWgE+3jnYkzyB6ZrZdfNnEsFe4PwmuXmlOw4NGIysP8wPY5aXZg++pTG9qUabXg1nzX/FGHGkObH8xQ=="], - "@react-email/container": ["@react-email/container@0.0.15", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg=="], + "@react-email/container": ["@react-email/container@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ=="], - "@react-email/font": ["@react-email/font@0.0.9", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-4zjq23oT9APXkerqeslPH3OZWuh5X4crHK6nx82mVHV2SrLba8+8dPEnWbaACWTNjOCbcLIzaC9unk7Wq2MIXw=="], + "@react-email/font": ["@react-email/font@0.0.10", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA=="], - "@react-email/head": ["@react-email/head@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-X2Ii6dDFMF+D4niNwMAHbTkeCjlYYnMsd7edXOsi0JByxt9wNyZ9EnhFiBoQdqkE+SMDcu8TlNNttMrf5sJeMA=="], + "@react-email/head": ["@react-email/head@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog=="], - "@react-email/heading": ["@react-email/heading@0.0.15", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg=="], + "@react-email/heading": ["@react-email/heading@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw=="], - "@react-email/hr": ["@react-email/hr@0.0.11", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw=="], + "@react-email/hr": ["@react-email/hr@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA=="], - "@react-email/html": ["@react-email/html@0.0.11", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qJhbOQy5VW5qzU74AimjAR9FRFQfrMa7dn4gkEXKMB/S9xZN8e1yC1uA9C15jkXI/PzmJ0muDIWmFwatm5/+VA=="], + "@react-email/html": ["@react-email/html@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw=="], - "@react-email/img": ["@react-email/img@0.0.11", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ=="], + "@react-email/img": ["@react-email/img@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ=="], - "@react-email/link": ["@react-email/link@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ=="], + "@react-email/link": ["@react-email/link@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw=="], - "@react-email/markdown": ["@react-email/markdown@0.0.16", "", { "dependencies": { "marked": "^15.0.12" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-KSUHmoBMYhvc6iGwlIDkm0DRGbGQ824iNjLMCJsBVUoKHGQYs7F/N3b1tnS1YzRUX+GwHIexSsHuIUEi1m+8OQ=="], + "@react-email/markdown": ["@react-email/markdown@0.0.18", "", { "dependencies": { "marked": "^15.0.12" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg=="], - "@react-email/preview": ["@react-email/preview@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-F7j9FJ0JN/A4d7yr+aw28p4uX7VLWs7hTHtLo7WRyw4G+Lit6Zucq4UWKRxJC8lpsUdzVmG7aBJnKOT+urqs/w=="], + "@react-email/preview": ["@react-email/preview@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw=="], - "@react-email/render": ["@react-email/render@2.0.8", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-5udvVr3U/WuGJZfLdLBOhkzrqRWd2Q5ZYmF7ppcy7FzWcwgshdqLMNqJOXcVzAXJXg/2bm7D+WGJzTtZOZMQnQ=="], + "@react-email/render": ["@react-email/render@2.1.0", "", { "dependencies": { "entities": "^4.5.0", "html-to-text": "^9.0.5", "html5parser": "^3.0.0", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-F+zE3O6d6sW6Aj2UjvZAA17R7tJKM7kcq2mgV6k4HCT8jeLLFaVP2txMtH1lgqYFRMZ0Gxsd37q2PRyiXLXXxA=="], - "@react-email/row": ["@react-email/row@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-HkCdnEjvK3o+n0y0tZKXYhIXUNPDx+2vq1dJTmqappVHXS5tXS6W5JOPZr5j+eoZ8gY3PShI2LWj5rWF7ZEtIQ=="], + "@react-email/row": ["@react-email/row@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw=="], - "@react-email/section": ["@react-email/section@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-FjqF9xQ8FoeUZYKSdt8sMIKvoT9XF8BrzhT3xiFKdEMwYNbsDflcjfErJe3jb7Wj/es/lKTbV5QR1dnLzGpL3w=="], + "@react-email/section": ["@react-email/section@0.0.17", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w=="], - "@react-email/tailwind": ["@react-email/tailwind@1.2.2", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-heO9Khaqxm6Ulm6p7HQ9h01oiiLRrZuuEQuYds/O7Iyp3c58sMVHZGIxiRXO/kSs857NZQycpjewEVKF3jhNTw=="], + "@react-email/tailwind": ["@react-email/tailwind@2.0.7", "", { "dependencies": { "tailwindcss": "^4.1.18" }, "peerDependencies": { "@react-email/body": ">=0", "@react-email/button": ">=0", "@react-email/code-block": ">=0", "@react-email/code-inline": ">=0", "@react-email/container": ">=0", "@react-email/heading": ">=0", "@react-email/hr": ">=0", "@react-email/img": ">=0", "@react-email/link": ">=0", "@react-email/preview": ">=0", "@react-email/text": ">=0", "react": "^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@react-email/body", "@react-email/button", "@react-email/code-block", "@react-email/code-inline", "@react-email/container", "@react-email/heading", "@react-email/hr", "@react-email/img", "@react-email/link", "@react-email/preview"] }, "sha512-kGw80weVFXikcnCXbigTGXGWQ0MRCSYNCudcdkWxebkWYd0FG6/NPoN3V1p/u68/4+NxZwYPVi2fhnp0x23HdA=="], - "@react-email/text": ["@react-email/text@0.1.5", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg=="], + "@react-email/text": ["@react-email/text@0.1.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw=="], "@reactflow/background": ["@reactflow/background@11.3.14", "", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.3", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA=="], @@ -2201,7 +2198,7 @@ "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], - "autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="], + "atomically": ["atomically@2.1.1", "", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ=="], "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], @@ -2317,7 +2314,7 @@ "chrome-launcher": ["chrome-launcher@1.2.1", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A=="], - "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], @@ -2327,8 +2324,6 @@ "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], - "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], - "cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], @@ -2363,6 +2358,8 @@ "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], + "conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="], + "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], @@ -2409,6 +2406,8 @@ "css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="], + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], @@ -2505,6 +2504,8 @@ "debounce": ["debounce@2.2.0", "", {}, "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw=="], + "debounce-fn": ["debounce-fn@6.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], @@ -2569,6 +2570,8 @@ "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dot-prop": ["dot-prop@10.2.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw=="], + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], @@ -2615,7 +2618,9 @@ "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], - "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -2761,12 +2766,8 @@ "forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="], - "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], - "framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], - "free-email-domains": ["free-email-domains@1.2.25", "", {}, "sha512-Uf2rJUjo/agIgQzt6od9XcHrR6rfIMD6TwsNVSJVJCHzjPWWsqjCb+EaQ2VVVY9M55+JB3V0k6ru5sHTGx/ZfA=="], - "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], @@ -2895,6 +2896,8 @@ "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "html5parser": ["html5parser@3.0.0", "", {}, "sha512-iNpSopa+4YHX50UOk825tBy7MghmXHo/ZpLskBYN0kAr1xhH8GlIMk5bLRXcZlfP3AnLUcSuFMu8C4MdOUxA8A=="], + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], @@ -2971,8 +2974,6 @@ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], - "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], @@ -3007,9 +3008,9 @@ "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], - "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - "jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], @@ -3205,6 +3206,8 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="], @@ -3393,8 +3396,6 @@ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], - "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], - "notepack.io": ["notepack.io@3.0.1", "", {}, "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg=="], "npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="], @@ -3405,7 +3406,7 @@ "nwsapi": ["nwsapi@2.2.24", "", {}, "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A=="], - "nypm": ["nypm@0.6.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^2.0.0", "tinyexec": "^0.3.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg=="], + "nypm": ["nypm@0.6.6", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -3445,8 +3446,6 @@ "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], - "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], - "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -3509,6 +3508,8 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "picospinner": ["picospinner@3.0.0", "", {}, "sha512-lGA1TNsmy2bxvRsTI2cV01kfTwKzZjnZSDmF9llYNyMHMrU4sP87lQ5taiIKm88L3cbswjl008nwyGc3WpNvzg=="], + "pidtree": ["pidtree@0.6.1", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-e0F9AOF1JMrCfBsyJOwU9lNvQ0WtXTq0j/4jk0BQ5JSI9VAybPXmDpPRw/2FQ3e5d3ZFN1mLh7jW99m/jjaptw=="], "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], @@ -3639,7 +3640,7 @@ "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], - "react-email": ["react-email@4.3.2", "", { "dependencies": { "@babel/parser": "^7.27.0", "@babel/traverse": "^7.27.0", "chokidar": "^4.0.3", "commander": "^13.0.0", "debounce": "^2.0.0", "esbuild": "^0.25.0", "glob": "^11.0.0", "jiti": "2.4.2", "log-symbols": "^7.0.0", "mime-types": "^3.0.0", "normalize-path": "^3.0.0", "nypm": "0.6.0", "ora": "^8.0.0", "prompts": "2.4.2", "socket.io": "^4.8.1", "tsconfig-paths": "4.2.0" }, "bin": { "email": "dist/index.js" } }, "sha512-WaZcnv9OAIRULY236zDRdk+8r511ooJGH5UOb7FnVsV33hGPI+l5aIZ6drVjXi4QrlLTmLm8PsYvmXRSv31MPA=="], + "react-email": ["react-email@6.9.0", "", { "dependencies": { "@babel/parser": "7.29.2", "@babel/traverse": "7.29.0", "@react-email/render": ">=2.1.0", "chokidar": "^4.0.3", "commander": "^13.0.0", "conf": "^15.0.2", "css-tree": "3.2.1", "debounce": "^2.0.0", "esbuild": "^0.28.0", "glob": "^13.0.6", "jiti": "2.6.1", "log-symbols": "^7.0.0", "marked": "^15.0.12", "mime-types": "^3.0.0", "normalize-path": "^3.0.0", "nypm": "0.6.6", "picospinner": "^3.0.0", "prismjs": "^1.30.0", "prompts": "2.4.2", "socket.io": "^4.8.1", "tailwindcss": "^4.1.18", "tsconfig-paths": "4.2.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" }, "bin": { "email": "./dist/cli/index.mjs" } }, "sha512-72jV+VkeeXgNWDycNDn2tIlHFLg4Hevi3pC77g63FAY1+bDgTs4b56jbZfHF1t5d+GVGb02sGS8bOwoptgvI1w=="], "react-hook-form": ["react-hook-form@7.79.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw=="], @@ -3891,8 +3892,6 @@ "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], - "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], - "stream-browserify": ["stream-browserify@3.0.0", "", { "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" } }, "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA=="], "stream-events": ["stream-events@1.0.5", "", { "dependencies": { "stubs": "^3.0.0" } }, "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg=="], @@ -3937,6 +3936,10 @@ "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + "stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="], + + "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], + "stubs": ["stubs@3.0.0", "", {}, "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw=="], "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], @@ -3961,6 +3964,8 @@ "systeminformation": ["systeminformation@5.23.8", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Osd24mNKe6jr/YoXLLK3k8TMdzaxDffhpCxgkfgBHcapykIkd50HXThM3TCEuHO2pPuCsSx2ms/SunqhU5MmsQ=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="], "tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], @@ -3969,7 +3974,7 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "tar": ["tar@7.5.16", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w=="], + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], @@ -4047,6 +4052,8 @@ "twilio": ["twilio@5.9.0", "", { "dependencies": { "axios": "^1.11.0", "dayjs": "^1.11.9", "https-proxy-agent": "^5.0.0", "jsonwebtoken": "^9.0.2", "qs": "^6.9.4", "scmp": "^2.1.0", "xmlbuilder": "^13.0.2" } }, "sha512-Ij+xT9MZZSjP64lsy+x6vYsCCb5m2Db9KffkMXBrN3zWbG3rbkXxl+MZVVzrvpwEdSbQD0vMuin+TTlQ6kR6Xg=="], + "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], "typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="], @@ -4059,6 +4066,8 @@ "uid2": ["uid2@1.0.0", "", {}, "sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ=="], + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "ulid": ["ulid@2.4.0", "", { "bin": { "ulid": "bin/cli.js" } }, "sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg=="], "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], @@ -4153,6 +4162,8 @@ "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + "when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="], + "which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -4251,12 +4262,26 @@ "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "@babel/core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/core/@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/template/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@better-auth/core/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], "@better-auth/sso/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -4269,13 +4294,13 @@ "@cerebras/cerebras_cloud_sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw=="], + "@daytona/sdk/@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw=="], - "@daytonaio/sdk/@opentelemetry/resources": ["@opentelemetry/resources@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg=="], + "@daytona/sdk/@opentelemetry/resources": ["@opentelemetry/resources@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg=="], - "@daytonaio/sdk/@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/configuration": "0.219.0", "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.219.0", "@opentelemetry/exporter-logs-otlp-http": "0.219.0", "@opentelemetry/exporter-logs-otlp-proto": "0.219.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.219.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.219.0", "@opentelemetry/exporter-prometheus": "0.219.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.219.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/exporter-trace-otlp-proto": "0.219.0", "@opentelemetry/exporter-zipkin": "2.8.0", "@opentelemetry/instrumentation": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/propagator-b3": "2.8.0", "@opentelemetry/propagator-jaeger": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NWLpWLEb8gV3+JBHYoIrktbM385wyHpRJoh3J/4Q52d4PR+AlPMNGJT3DzBUrDSUEVbKAXoHR+EDAPxtiNcj8g=="], + "@daytona/sdk/@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/configuration": "0.219.0", "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.219.0", "@opentelemetry/exporter-logs-otlp-http": "0.219.0", "@opentelemetry/exporter-logs-otlp-proto": "0.219.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.219.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.219.0", "@opentelemetry/exporter-prometheus": "0.219.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.219.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/exporter-trace-otlp-proto": "0.219.0", "@opentelemetry/exporter-zipkin": "2.8.0", "@opentelemetry/instrumentation": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/propagator-b3": "2.8.0", "@opentelemetry/propagator-jaeger": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NWLpWLEb8gV3+JBHYoIrktbM385wyHpRJoh3J/4Q52d4PR+AlPMNGJT3DzBUrDSUEVbKAXoHR+EDAPxtiNcj8g=="], - "@daytonaio/sdk/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ=="], + "@daytona/sdk/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ=="], "@earendil-works/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], @@ -4303,6 +4328,10 @@ "@google-cloud/storage/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -4487,7 +4516,7 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.6", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g=="], - "@react-email/components/@react-email/render": ["@react-email/render@1.4.0", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ZtJ3noggIvW1ZAryoui95KJENKdCzLmN5F7hyZY1F/17B1vwzuxHB7YkuCg0QqHjDivc5axqYEYdIOw4JIQdUw=="], + "@react-email/components/@react-email/render": ["@react-email/render@2.0.6", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog=="], "@react-email/markdown/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], @@ -4575,6 +4604,10 @@ "@types/archiver/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/babel__core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@types/babel__template/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@types/cors/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/node-fetch/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -4619,6 +4652,8 @@ "c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "c12/jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], + "c12/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], "chrome-launcher/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -4635,6 +4670,8 @@ "concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "conf/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], @@ -4653,16 +4690,12 @@ "docx/nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], - "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "duplexify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "e2b/@bufbuild/protobuf": ["@bufbuild/protobuf@2.13.0", "", {}, "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g=="], "e2b/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "e2b/tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], - "echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -4723,6 +4756,10 @@ "gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "giget/citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + + "giget/nypm": ["nypm@0.6.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^2.0.0", "tinyexec": "^0.3.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg=="], + "google-auth-library/gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="], "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], @@ -4761,6 +4798,8 @@ "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "magicast/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "make-dir/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "mammoth/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], @@ -4791,14 +4830,8 @@ "nuqs/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], - "nypm/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], - - "nypm/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - "openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], - "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -4835,7 +4868,11 @@ "react-email/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], - "react-email/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + "react-email/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "react-email/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "react-email/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], "react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], @@ -4851,6 +4888,8 @@ "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "rss-parser/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], "socket.io-adapter/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], @@ -4917,6 +4956,10 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@better-auth/sso/tldts/tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], "@browserbasehq/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], @@ -4925,53 +4968,53 @@ "@cerebras/cerebras_cloud_sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], + "@daytona/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], - "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + "@daytona/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], - "@daytonaio/sdk/@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], + "@daytona/sdk/@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/configuration": ["@opentelemetry/configuration@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "yaml": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-wXZUYv4ngu43nA4WEhuXNacm46LW+17LRM8nKyIhBzroRA24PBYjMnakwzR/w777nFUB5xlgsYTTeuXxumZM1Q=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/configuration": ["@opentelemetry/configuration@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "yaml": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-wXZUYv4ngu43nA4WEhuXNacm46LW+17LRM8nKyIhBzroRA24PBYjMnakwzR/w777nFUB5xlgsYTTeuXxumZM1Q=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.8.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.8.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.219.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/sdk-logs": "0.219.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-7SvzDCIclHWAcCwZ1MTOLcwn4BVNPGI3QxS/DJraPNe1TTL+4TvUBq5zeQV8tsnYvtDN7wKW2qocVmaCP2l7sQ=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.219.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/sdk-logs": "0.219.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-7SvzDCIclHWAcCwZ1MTOLcwn4BVNPGI3QxS/DJraPNe1TTL+4TvUBq5zeQV8tsnYvtDN7wKW2qocVmaCP2l7sQ=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/sdk-logs": "0.219.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-mhl2HL6GmZI8b8PwPfqMws/5ovJfbRTxwc9Y5agVVHiQ+e5SL1btsFr/kJDgt7YCexDtsUn5HAreHQO9szFS0A=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/sdk-logs": "0.219.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-mhl2HL6GmZI8b8PwPfqMws/5ovJfbRTxwc9Y5agVVHiQ+e5SL1btsFr/kJDgt7YCexDtsUn5HAreHQO9szFS0A=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Ayw4Gf71PS9jhBVaYywa4WsajnqfDehMkTdVH3TSAVHqPcsAv/AhH/wTNRYNt99szeYr6Gbd/D6RjZD77wAxHg=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Ayw4Gf71PS9jhBVaYywa4WsajnqfDehMkTdVH3TSAVHqPcsAv/AhH/wTNRYNt99szeYr6Gbd/D6RjZD77wAxHg=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.219.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-6LaaSrPxK5L55bXevWajvOMxGOpNm0n12tG53TeZaUeNzXwLPg6d2KCC1zAlGsojan+xRG71mA4Qqs9K2VVrKQ=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.219.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-6LaaSrPxK5L55bXevWajvOMxGOpNm0n12tG53TeZaUeNzXwLPg6d2KCC1zAlGsojan+xRG71mA4Qqs9K2VVrKQ=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-6CaDRbMVHZSDWzNXwrR8y/H4B/Z1eMNnkHiPQlTx3Ojz2OHY4X/aff/UC4P/3pHUQSuTfi3oh2UsPPZppw+Vrg=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-6CaDRbMVHZSDWzNXwrR8y/H4B/Z1eMNnkHiPQlTx3Ojz2OHY4X/aff/UC4P/3pHUQSuTfi3oh2UsPPZppw+Vrg=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DUS7XyIiEnoeccQUvuKy0G2/YqeKhpN8FVIrGbrLNIVMj10yeIFLRzRv0tibCI2kXXvlTTABVexGAk78wHk2ug=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DUS7XyIiEnoeccQUvuKy0G2/YqeKhpN8FVIrGbrLNIVMj10yeIFLRzRv0tibCI2kXXvlTTABVexGAk78wHk2ug=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-prometheus": ["@opentelemetry/exporter-prometheus@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TxOnJ85eWJY5JyOJsNMXiRTYlkDcOv0u3KbXEzWCc+tUS9sjL/BC6BcdxZ0B9r2OFVqsrZFXUzSD2sZUy42Ucw=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-prometheus": ["@opentelemetry/exporter-prometheus@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TxOnJ85eWJY5JyOJsNMXiRTYlkDcOv0u3KbXEzWCc+tUS9sjL/BC6BcdxZ0B9r2OFVqsrZFXUzSD2sZUy42Ucw=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.219.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-BkDNv1UD6BscW19MxbAxVmSYSSFuyeqR6buV2/HTYqA7GrR0EbTFzqG6h86T3PtXmpdbsWjMGLDdjG2rikG27Q=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.219.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-BkDNv1UD6BscW19MxbAxVmSYSSFuyeqR6buV2/HTYqA7GrR0EbTFzqG6h86T3PtXmpdbsWjMGLDdjG2rikG27Q=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-Mj84UkEa17BK2o903VTXW3wM8CrSZexGs4tRGVZVIMM9ni1T6TuGx5IrRfoWKAbshx42D5/kc7YV+axypLPYyA=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-Mj84UkEa17BK2o903VTXW3wM8CrSZexGs4tRGVZVIMM9ni1T6TuGx5IrRfoWKAbshx42D5/kc7YV+axypLPYyA=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-X5t7I8GyIO9rmGHwoedZLREpQqrF1WW2nxzNNym6HOKpFiE+rvqV3ngC0xcZVO2YwIGf3KKmRdWrYwdwz3H9RQ=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-X5t7I8GyIO9rmGHwoedZLREpQqrF1WW2nxzNNym6HOKpFiE+rvqV3ngC0xcZVO2YwIGf3KKmRdWrYwdwz3H9RQ=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SazlvuSKi5533rPHTW2TwBwdMakhjZST4SYs0YauuvfGDkT13KbG1gJS75hV0uWVeevhtVP9sAIlaZLTHdSbMg=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SazlvuSKi5533rPHTW2TwBwdMakhjZST4SYs0YauuvfGDkT13KbG1gJS75hV0uWVeevhtVP9sAIlaZLTHdSbMg=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Xnz9zZvvQzUw+9DrOn0MomR7BxFCkA2pcfXBQuHC28ndJpSbjLs7knzYb05kw5SyCjSsEWombkZMgGcJSk8JVg=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Xnz9zZvvQzUw+9DrOn0MomR7BxFCkA2pcfXBQuHC28ndJpSbjLs7knzYb05kw5SyCjSsEWombkZMgGcJSk8JVg=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.8.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.8.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg=="], - "@daytonaio/sdk/@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], + "@daytona/sdk/@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], "@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="], @@ -5025,6 +5068,8 @@ "@google-cloud/storage/google-auth-library/gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], @@ -5229,6 +5274,10 @@ "gcp-metadata/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "giget/nypm/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], + + "giget/nypm/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "google-auth-library/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], @@ -5237,8 +5286,6 @@ "gtoken/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - "html-to-text/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "jszip/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], @@ -5303,12 +5350,8 @@ "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - "nypm/pkg-types/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], - "openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], - "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.208.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-transformer": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA=="], @@ -5327,7 +5370,57 @@ "react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "react-email/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "react-email/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "react-email/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "react-email/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "react-email/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "react-email/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "react-email/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "react-email/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "react-email/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "react-email/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "react-email/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "react-email/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "react-email/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "react-email/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "react-email/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "react-email/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "react-email/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "react-email/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "react-email/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "react-email/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "react-email/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "react-email/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "react-email/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "react-email/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "react-email/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "react-email/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], @@ -5415,27 +5508,27 @@ "@browserbasehq/stagehand/@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], + "@daytona/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], - "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA=="], + "@daytona/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA=="], - "@daytonaio/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg=="], + "@daytona/sdk/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], - "@daytonaio/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + "@daytona/sdk/@opentelemetry/sdk-node/@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], "@google-cloud/storage/google-auth-library/gcp-metadata/google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="], @@ -5455,14 +5548,14 @@ "@types/request/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "giget/nypm/pkg-types/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw=="], "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="], - "rimraf/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], - "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "sim/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -5473,12 +5566,6 @@ "@trigger.dev/core/socket.io/engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "rimraf/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - - "rimraf/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - "sim/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - - "rimraf/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], } }