Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
37 changes: 8 additions & 29 deletions apps/web/src/app/api/admin/audit-logs/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -77,7 +56,7 @@ const CSV_HEADERS = [
* Converts a log entry to a CSV row
*/
function logToCSVRow(log: Record<string, unknown>): string {
return CSV_HEADERS.map(header => escapeCSVValue(log[header])).join(',');
return CSV_HEADERS.map(header => escapeCSVField(formatCSVValue(log[header]))).join(',');
}

/**
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
32 changes: 17 additions & 15 deletions apps/web/src/components/integrations/IntegrationAuditLogPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,24 +45,31 @@ 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;
}

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) {
Expand Down Expand Up @@ -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()}`
Expand Down Expand Up @@ -276,7 +278,7 @@ export function IntegrationAuditLogPage({ driveId }: IntegrationAuditLogPageProp
<Select
value={filters.success}
onValueChange={(value) => {
setFilters((prev) => ({ ...prev, success: value }));
setFilters((prev) => ({ ...prev, success: value as SuccessFilter }));
setCurrentPage(1);
}}
>
Expand All @@ -301,7 +303,7 @@ export function IntegrationAuditLogPage({ driveId }: IntegrationAuditLogPageProp
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{filters.dateFrom ? formatDateShort(filters.dateFrom.toISOString()) : 'From Date'}
{filters.dateFrom ? format(filters.dateFrom, 'MMM d, yyyy') : 'From Date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
Expand All @@ -328,7 +330,7 @@ export function IntegrationAuditLogPage({ driveId }: IntegrationAuditLogPageProp
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{filters.dateTo ? formatDateShort(filters.dateTo.toISOString()) : 'To Date'}
{filters.dateTo ? format(filters.dateTo, 'MMM d, yyyy') : 'To Date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
Expand Down
15 changes: 11 additions & 4 deletions packages/lib/src/content/export-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,19 @@ export function sanitizeFilename(filename: string): string {
* @param value - The value to escape
* @returns Escaped CSV field
*/
function escapeCSVField(value: string): string {
export function escapeCSVField(value: string): string {
let sanitized = value;

// Prevent spreadsheet formula injection when opening CSV in Excel/Sheets.
if (/^[\t\r ]*[=+\-@]/.test(sanitized)) {
sanitized = `'${sanitized}`;
}

// If the value contains comma, quote, or newline, wrap in quotes and escape internal quotes
if (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r')) {
return `"${value.replace(/"/g, '""')}"`;
if (sanitized.includes(',') || sanitized.includes('"') || sanitized.includes('\n') || sanitized.includes('\r')) {
return `"${sanitized.replace(/"/g, '""')}"`;
}
return value;
return sanitized;
}

/**
Expand Down
Loading