- Suppression de /app/api/pdf-proxy/route.ts (endpoint inutilisé avec CORS *) - Suppression de /app/api/pdf-clean/route.ts (endpoint inutilisé avec CORS *) - Mise à jour du rapport d'audit de sécurité - Les PDFs sont désormais affichés via URLs présignées S3 directes
242 lines
8.8 KiB
TypeScript
242 lines
8.8 KiB
TypeScript
// app/api/cron/send-csp-report/route.ts
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { createSbServiceRole } from '@/lib/supabaseServer';
|
||
import { sendUniversalEmailV2 } from '@/lib/emailTemplateService';
|
||
|
||
export const dynamic = 'force-dynamic';
|
||
|
||
// Protection : Vérifier le token Vercel Cron
|
||
function isValidCronRequest(req: NextRequest): boolean {
|
||
const authHeader = req.headers.get('authorization');
|
||
const cronSecret = process.env.CRON_SECRET;
|
||
|
||
// En développement, accepter sans secret
|
||
if (process.env.NODE_ENV === 'development') {
|
||
return true;
|
||
}
|
||
|
||
// En production, vérifier le secret
|
||
if (!cronSecret) {
|
||
console.error('❌ CRON_SECRET non configuré');
|
||
return false;
|
||
}
|
||
|
||
return authHeader === `Bearer ${cronSecret}`;
|
||
}
|
||
|
||
interface ViolationSummary {
|
||
violated_directive: string;
|
||
violation_count: number;
|
||
unique_blocked_uris: number;
|
||
affected_pages: number;
|
||
examples: Array<{
|
||
blocked_uri: string;
|
||
document_uri: string;
|
||
count: number;
|
||
}>;
|
||
}
|
||
|
||
export async function GET(req: NextRequest) {
|
||
try {
|
||
// Vérification sécurité
|
||
if (!isValidCronRequest(req)) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||
}
|
||
|
||
const supabase = createSbServiceRole();
|
||
const today = new Date().toISOString().split('T')[0];
|
||
|
||
// Vérifier si un email a déjà été envoyé aujourd'hui
|
||
const { data: existingLog } = await supabase
|
||
.from('csp_email_logs')
|
||
.select('*')
|
||
.eq('report_date', today)
|
||
.maybeSingle();
|
||
|
||
if (existingLog) {
|
||
console.log('ℹ️ Email CSP déjà envoyé aujourd\'hui');
|
||
return NextResponse.json({
|
||
message: 'Email already sent today',
|
||
sent_at: existingLog.email_sent_at
|
||
});
|
||
}
|
||
|
||
// Récupérer les violations des dernières 24h
|
||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||
|
||
const { data: reports, error: reportsError } = await supabase
|
||
.from('csp_reports')
|
||
.select('*')
|
||
.gte('created_at', twentyFourHoursAgo)
|
||
.order('created_at', { ascending: false });
|
||
|
||
if (reportsError) {
|
||
throw reportsError;
|
||
}
|
||
|
||
// Si aucune violation, ne pas envoyer d'email
|
||
if (!reports || reports.length === 0) {
|
||
console.log('✅ Aucune violation CSP dans les dernières 24h');
|
||
return NextResponse.json({
|
||
message: 'No violations to report',
|
||
period: 'last 24 hours'
|
||
});
|
||
}
|
||
|
||
// Agréger les violations par directive
|
||
const violationsByDirective = new Map<string, ViolationSummary>();
|
||
|
||
reports.forEach(report => {
|
||
const directive = report.violated_directive;
|
||
|
||
if (!violationsByDirective.has(directive)) {
|
||
violationsByDirective.set(directive, {
|
||
violated_directive: directive,
|
||
violation_count: 0,
|
||
unique_blocked_uris: 0,
|
||
affected_pages: 0,
|
||
examples: []
|
||
});
|
||
}
|
||
|
||
const summary = violationsByDirective.get(directive)!;
|
||
summary.violation_count++;
|
||
});
|
||
|
||
// Calculer les statistiques détaillées pour chaque directive
|
||
for (const [directive, summary] of violationsByDirective.entries()) {
|
||
const directiveReports = reports.filter(r => r.violated_directive === directive);
|
||
|
||
// URIs bloquées uniques
|
||
const uniqueBlockedUris = new Set(directiveReports.map(r => r.blocked_uri || 'inline'));
|
||
summary.unique_blocked_uris = uniqueBlockedUris.size;
|
||
|
||
// Pages affectées uniques
|
||
const uniquePages = new Set(directiveReports.map(r => r.document_uri));
|
||
summary.affected_pages = uniquePages.size;
|
||
|
||
// Top 3 exemples avec comptage
|
||
const exampleCounts = new Map<string, { blocked_uri: string; document_uri: string; count: number }>();
|
||
|
||
directiveReports.forEach(r => {
|
||
const key = `${r.blocked_uri || 'inline'}|${r.document_uri}`;
|
||
if (!exampleCounts.has(key)) {
|
||
exampleCounts.set(key, {
|
||
blocked_uri: r.blocked_uri || 'inline',
|
||
document_uri: r.document_uri,
|
||
count: 0
|
||
});
|
||
}
|
||
exampleCounts.get(key)!.count++;
|
||
});
|
||
|
||
summary.examples = Array.from(exampleCounts.values())
|
||
.sort((a, b) => b.count - a.count)
|
||
.slice(0, 3);
|
||
}
|
||
|
||
const summaries = Array.from(violationsByDirective.values())
|
||
.sort((a, b) => b.violation_count - a.violation_count);
|
||
|
||
// Générer le contenu HTML du rapport
|
||
const reportHtml = generateReportHtml(summaries, reports.length);
|
||
|
||
// Envoyer l'email via le système universel
|
||
await sendUniversalEmailV2({
|
||
type: 'notification',
|
||
toEmail: 'paie@odentas.fr',
|
||
subject: `🔒 Rapport CSP quotidien - ${reports.length} violation${reports.length > 1 ? 's' : ''} détectée${reports.length > 1 ? 's' : ''}`,
|
||
data: {
|
||
firstName: 'Équipe',
|
||
organizationName: 'Odentas',
|
||
customMessage: reportHtml,
|
||
ctaUrl: 'https://espace-paie.odentas.fr/staff/csp-reports' // Pour future dashboard
|
||
}
|
||
});
|
||
|
||
// Logger l'envoi
|
||
await supabase
|
||
.from('csp_email_logs')
|
||
.insert({
|
||
report_date: today,
|
||
reports_count: reports.length,
|
||
unique_violations: summaries.length
|
||
});
|
||
|
||
console.log(`✅ Rapport CSP envoyé: ${reports.length} violations, ${summaries.length} directives`);
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
violations_count: reports.length,
|
||
unique_directives: summaries.length,
|
||
summaries
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('❌ Erreur envoi rapport CSP:', error);
|
||
return NextResponse.json({
|
||
error: 'Internal server error',
|
||
message: error instanceof Error ? error.message : 'Unknown error'
|
||
}, { status: 500 });
|
||
}
|
||
}
|
||
|
||
function generateReportHtml(summaries: ViolationSummary[], totalViolations: number): string {
|
||
let html = `
|
||
<div style="font-family: system-ui, -apple-system, sans-serif; padding: 20px; background-color: #f8fafc; border-radius: 8px;">
|
||
<h2 style="color: #1e293b; margin-top: 0;">Rapport CSP des dernières 24 heures</h2>
|
||
<p style="color: #64748b; margin-bottom: 24px;">
|
||
<strong>${totalViolations} violation${totalViolations > 1 ? 's' : ''}</strong> de Content Security Policy détectée${totalViolations > 1 ? 's' : ''}.
|
||
</p>
|
||
`;
|
||
|
||
summaries.forEach((summary, index) => {
|
||
html += `
|
||
<div style="background: white; padding: 16px; border-radius: 6px; margin-bottom: 16px; border-left: 4px solid ${index === 0 ? '#ef4444' : '#f59e0b'};">
|
||
<h3 style="color: #1e293b; margin-top: 0; font-size: 16px;">
|
||
${summary.violated_directive}
|
||
</h3>
|
||
<div style="color: #64748b; font-size: 14px; margin-bottom: 12px;">
|
||
<strong>${summary.violation_count}</strong> occurrence${summary.violation_count > 1 ? 's' : ''} •
|
||
<strong>${summary.unique_blocked_uris}</strong> ressource${summary.unique_blocked_uris > 1 ? 's' : ''} bloquée${summary.unique_blocked_uris > 1 ? 's' : ''} •
|
||
<strong>${summary.affected_pages}</strong> page${summary.affected_pages > 1 ? 's' : ''} affectée${summary.affected_pages > 1 ? 's' : ''}
|
||
</div>
|
||
|
||
<div style="background: #f8fafc; padding: 12px; border-radius: 4px; font-size: 13px;">
|
||
<strong style="color: #475569;">Exemples:</strong>
|
||
${summary.examples.map(ex => `
|
||
<div style="margin-top: 8px; padding: 8px; background: white; border-radius: 4px;">
|
||
<div style="color: #ef4444; font-family: monospace; font-size: 12px; word-break: break-all;">
|
||
${ex.blocked_uri}
|
||
</div>
|
||
<div style="color: #64748b; font-size: 11px; margin-top: 4px;">
|
||
Sur: ${ex.document_uri.replace('https://espace-paie.odentas.fr', '')}
|
||
${ex.count > 1 ? ` • <strong>${ex.count} fois</strong>` : ''}
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += `
|
||
<div style="margin-top: 24px; padding: 16px; background: #eff6ff; border-radius: 6px; font-size: 13px; color: #1e40af;">
|
||
<strong>📋 Prochaines étapes:</strong>
|
||
<ol style="margin: 8px 0 0 0; padding-left: 20px;">
|
||
<li>Analyser les directives violées les plus fréquentes</li>
|
||
<li>Identifier si ce sont des ressources légitimes ou des tentatives d'attaque</li>
|
||
<li>Ajuster la CSP en conséquence (ajouter les sources autorisées)</li>
|
||
<li>Après 5 jours d'observation, passer en mode "Enforce"</li>
|
||
</ol>
|
||
</div>
|
||
|
||
<div style="margin-top: 16px; font-size: 12px; color: #94a3b8; text-align: center;">
|
||
Ce rapport est généré quotidiennement en mode Report-Only.<br>
|
||
Les violations sont collectées mais n'affectent pas le fonctionnement de l'application.
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
return html;
|
||
}
|