diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 93efc43a6..bb15436d0 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -218,7 +218,7 @@ jobs: fetch-depth: 0 - name: TruffleHog Secret Scan - uses: trufflesecurity/trufflehog@v3.93.4 + uses: trufflesecurity/trufflehog@v3.93.6 with: path: ./ base: ${{ github.event.before }} diff --git a/apps/web/src/app/api/admin/audit-logs/export/route.ts b/apps/web/src/app/api/admin/audit-logs/export/route.ts index f5985f3cf..a9be427f1 100644 --- a/apps/web/src/app/api/admin/audit-logs/export/route.ts +++ b/apps/web/src/app/api/admin/audit-logs/export/route.ts @@ -10,36 +10,15 @@ import { sql, } from '@pagespace/db'; import { loggers } from '@pagespace/lib/server'; +import { escapeCSVField } from '@pagespace/lib/content'; import { withAdminAuth } from '@/lib/auth'; import { format } from 'date-fns'; -/** - * Escapes a value for CSV format - * - Wraps in quotes if contains comma, quote, or newline - * - Escapes quotes by doubling them - */ -function escapeCSVValue(value: unknown): string { - if (value === null || value === undefined) { - return ''; - } - - let stringValue: string; - - if (value instanceof Date) { - stringValue = format(value, "yyyy-MM-dd'T'HH:mm:ss.SSSxxx"); - } else if (typeof value === 'object') { - stringValue = JSON.stringify(value); - } else { - stringValue = String(value); - } - - // Check if escaping is needed - if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n') || stringValue.includes('\r')) { - // Escape quotes by doubling them and wrap in quotes - return `"${stringValue.replace(/"/g, '""')}"`; - } - - return stringValue; +function formatCSVValue(value: unknown): string { + if (value === null || value === undefined) return ''; + if (value instanceof Date) return format(value, "yyyy-MM-dd'T'HH:mm:ss.SSSxxx"); + if (typeof value === 'object') return JSON.stringify(value); + return String(value); } /** @@ -77,7 +56,7 @@ const CSV_HEADERS = [ * Converts a log entry to a CSV row */ function logToCSVRow(log: Record): string { - return CSV_HEADERS.map(header => escapeCSVValue(log[header])).join(','); + return CSV_HEADERS.map(header => escapeCSVField(formatCSVValue(log[header]))).join(','); } /** @@ -176,7 +155,7 @@ export const GET = withAdminAuth(async (_adminUser, request) => { async start(controller) { try { // Write CSV header - controller.enqueue(encoder.encode(CSV_HEADERS.join(',') + '\n')); + controller.enqueue(encoder.encode(CSV_HEADERS.map(escapeCSVField).join(',') + '\n')); let offset = 0; let hasMoreRecords = true; diff --git a/apps/web/src/app/api/admin/users/[userId]/gift-subscription/__tests__/route.security.test.ts b/apps/web/src/app/api/admin/users/[userId]/gift-subscription/__tests__/route.security.test.ts index fc0190c93..36b73e440 100644 --- a/apps/web/src/app/api/admin/users/[userId]/gift-subscription/__tests__/route.security.test.ts +++ b/apps/web/src/app/api/admin/users/[userId]/gift-subscription/__tests__/route.security.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { POST, DELETE } from '../route'; import { NextRequest } from 'next/server'; -import { db, users, subscriptions, eq } from '@pagespace/db'; +import { db, users, subscriptions, sessions, eq } from '@pagespace/db'; import { createId } from '@paralleldrive/cuid2'; import { updateUserRole } from '@/lib/auth/admin-role'; import { sessionService, generateCSRFToken } from '@pagespace/lib/auth'; @@ -129,13 +129,26 @@ describe('/api/admin/users/[userId]/gift-subscription - Security Tests', () => { }).returning(); regularUserId = regularUser.id; - // Create a session token for the admin user - adminSessionToken = await sessionService.createSession({ - userId: adminUserId, - type: 'user', - scopes: ['*'], - expiresInMs: 3600000, - }); + // Create a session token for the admin user. + // Retry to handle TOCTOU race between user insert and session insert + // when parallel test files share the same connection pool (see admin-role-version.test.ts). + let lastError: unknown; + for (let attempt = 0; attempt < 3; attempt++) { + try { + adminSessionToken = await sessionService.createSession({ + userId: adminUserId, + type: 'user', + scopes: ['*'], + expiresInMs: 3600000, + }); + lastError = null; + break; + } catch (error) { + lastError = error; + await new Promise(r => setTimeout(r, 50 * (attempt + 1))); + } + } + if (lastError) throw lastError; // Generate CSRF token bound to the session const sessionClaims = await sessionService.validateSession(adminSessionToken); @@ -146,9 +159,11 @@ describe('/api/admin/users/[userId]/gift-subscription - Security Tests', () => { }); afterEach(async () => { - // Clean up test data - wrapped to prevent cascading failures + // Clean up test data in FK-safe order to prevent cascading failures try { await db.delete(subscriptions).where(eq(subscriptions.userId, regularUserId)); + await db.delete(sessions).where(eq(sessions.userId, adminUserId)); + await db.delete(sessions).where(eq(sessions.userId, regularUserId)); await db.delete(users).where(eq(users.id, adminUserId)); await db.delete(users).where(eq(users.id, regularUserId)); } catch { diff --git a/apps/web/src/app/api/drives/[driveId]/integrations/audit/export/route.ts b/apps/web/src/app/api/drives/[driveId]/integrations/audit/export/route.ts index 9d0ea8744..2e585c265 100644 --- a/apps/web/src/app/api/drives/[driveId]/integrations/audit/export/route.ts +++ b/apps/web/src/app/api/drives/[driveId]/integrations/audit/export/route.ts @@ -3,11 +3,12 @@ import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; import { db, desc, integrationAuditLog } from '@pagespace/db'; import { loggers } from '@pagespace/lib/server'; import { getDriveAccess } from '@pagespace/lib/services/drive-service'; +import { generateCSV } from '@pagespace/lib/content'; import { format } from 'date-fns'; import { buildAuditLogWhereClause, parseAuditFilterParams } from '../audit-filters'; const AUTH_OPTIONS = { allow: ['session'] as const }; -const CSV_HEADER = [ +const CSV_HEADERS = [ 'Timestamp', 'Tool Name', 'Agent ID', @@ -17,26 +18,7 @@ const CSV_HEADER = [ 'Duration (ms)', 'Error Type', 'Error Message', -].join(','); - -function escapeCsvValue(value: string | number | null): string { - if (value === null || value === undefined) { - return ''; - } - - let stringValue = String(value); - - // Prevent spreadsheet formula injection when opening CSV in Excel/Sheets. - if (/^[\t\r ]*[=+\-@]/.test(stringValue)) { - stringValue = `'${stringValue}`; - } - - if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n') || stringValue.includes('\r')) { - return `"${stringValue.replace(/"/g, '""')}"`; - } - - return stringValue; -} +]; /** * GET /api/drives/[driveId]/integrations/audit/export @@ -71,22 +53,22 @@ export async function GET( limit: 10000, }); - const csvRows = logs.map((log) => { - const timestamp = log.createdAt ? format(new Date(log.createdAt), 'yyyy-MM-dd HH:mm:ss') : ''; - return [ - escapeCsvValue(timestamp), - escapeCsvValue(log.toolName), - escapeCsvValue(log.agentId), - escapeCsvValue(log.connectionId), - escapeCsvValue(log.success ? 'Success' : 'Failure'), - escapeCsvValue(log.responseCode), - escapeCsvValue(log.durationMs), - escapeCsvValue(log.errorType), - escapeCsvValue(log.errorMessage), - ].join(','); - }); + const csvData: string[][] = [ + CSV_HEADERS, + ...logs.map((log) => [ + log.createdAt ? format(new Date(log.createdAt), 'yyyy-MM-dd HH:mm:ss') : '', + log.toolName ?? '', + log.agentId ?? '', + log.connectionId ?? '', + log.success ? 'Success' : 'Failure', + log.responseCode != null ? String(log.responseCode) : '', + log.durationMs != null ? String(log.durationMs) : '', + log.errorType ?? '', + log.errorMessage ?? '', + ]), + ]; - const csv = `${CSV_HEADER}\n${csvRows.join('\n')}`; + const csv = generateCSV(csvData); return new Response(csv, { headers: { diff --git a/apps/web/src/app/api/pages/[pageId]/tasks/__tests__/route.test.ts b/apps/web/src/app/api/pages/[pageId]/tasks/__tests__/route.test.ts index 851f28655..2085fe283 100644 --- a/apps/web/src/app/api/pages/[pageId]/tasks/__tests__/route.test.ts +++ b/apps/web/src/app/api/pages/[pageId]/tasks/__tests__/route.test.ts @@ -6,6 +6,7 @@ import { NextResponse } from 'next/server'; vi.mock('@/lib/auth', () => ({ authenticateRequestWithOptions: vi.fn(), isAuthError: vi.fn((result) => 'error' in result), + checkMCPPageScope: vi.fn().mockResolvedValue(null), })); vi.mock('@pagespace/lib/server', () => ({ diff --git a/apps/web/src/components/integrations/IntegrationAuditLogPage.tsx b/apps/web/src/components/integrations/IntegrationAuditLogPage.tsx index 01b501841..5931697ac 100644 --- a/apps/web/src/components/integrations/IntegrationAuditLogPage.tsx +++ b/apps/web/src/components/integrations/IntegrationAuditLogPage.tsx @@ -45,9 +45,11 @@ interface IntegrationAuditLogPageProps { driveId: string; } +type SuccessFilter = 'all' | 'true' | 'false'; + interface FiltersState { connectionId: string; - success: string; // 'all' | 'true' | 'false' + success: SuccessFilter; dateFrom: Date | undefined; dateTo: Date | undefined; agentId: string; @@ -55,14 +57,19 @@ interface FiltersState { const PAGE_SIZE = 50; -function formatDateTime(dateString: string | null) { - if (!dateString) return 'N/A'; - return format(new Date(dateString), 'MMM d, yyyy HH:mm:ss'); +function filtersToSearchParams(filters: FiltersState): URLSearchParams { + const params = new URLSearchParams(); + if (filters.connectionId) params.set('connectionId', filters.connectionId); + if (filters.success !== 'all') params.set('success', filters.success); + if (filters.dateFrom) params.set('dateFrom', filters.dateFrom.toISOString()); + if (filters.dateTo) params.set('dateTo', filters.dateTo.toISOString()); + if (filters.agentId) params.set('agentId', filters.agentId); + return params; } -function formatDateShort(dateString: string | null) { +function formatDateTime(dateString: string | null) { if (!dateString) return 'N/A'; - return format(new Date(dateString), 'MMM d, yyyy'); + return format(new Date(dateString), 'MMM d, yyyy HH:mm:ss'); } export function IntegrationAuditLogPage({ driveId }: IntegrationAuditLogPageProps) { @@ -126,12 +133,7 @@ export function IntegrationAuditLogPage({ driveId }: IntegrationAuditLogPageProp const handleExport = useCallback(async () => { setExporting(true); try { - const params = new URLSearchParams(); - if (filters.connectionId) params.set('connectionId', filters.connectionId); - if (filters.success !== 'all') params.set('success', filters.success); - if (filters.dateFrom) params.set('dateFrom', filters.dateFrom.toISOString()); - if (filters.dateTo) params.set('dateTo', filters.dateTo.toISOString()); - if (filters.agentId) params.set('agentId', filters.agentId); + const params = filtersToSearchParams(filters); const response = await fetchWithAuth( `/api/drives/${driveId}/integrations/audit/export?${params.toString()}` @@ -276,7 +278,7 @@ export function IntegrationAuditLogPage({ driveId }: IntegrationAuditLogPageProp