Add weekly vulnerability report upload feature
Implements a comprehensive system for uploading and processing weekly vulnerability reports that automatically splits multiple CVE IDs in a single cell into separate rows for easier filtering and analysis. Backend Changes: - Add weekly_reports table with migration - Create Excel processor helper using Python child_process - Implement API routes for upload, list, download, delete - Mount routes in server.js after multer initialization - Move split_cve_report.py to backend/scripts/ Frontend Changes: - Add WeeklyReportModal component with phase-based UI - Add "Weekly Report" button next to NVD Sync - Integrate modal into App.js with state management - Display existing reports with current report indicator - Download buttons for original and processed files Features: - Upload .xlsx files (editor/admin only) - Automatic CVE ID splitting via Python script - Store metadata in database + files on filesystem - Auto-archive previous reports (mark one as current) - Download both original and processed versions - Audit logging for all operations - Security: file validation, auth checks, path sanitization Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
291
frontend/src/components/WeeklyReportModal.js
Normal file
291
frontend/src/components/WeeklyReportModal.js
Normal file
@@ -0,0 +1,291 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Loader, AlertCircle, CheckCircle, Upload as UploadIcon, Download, Star } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
export default function WeeklyReportModal({ onClose }) {
|
||||
const [phase, setPhase] = useState('idle'); // idle, uploading, processing, success, error
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [result, setResult] = useState(null);
|
||||
const [existingReports, setExistingReports] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Fetch existing reports on mount
|
||||
useEffect(() => {
|
||||
fetchExistingReports();
|
||||
}, []);
|
||||
|
||||
const fetchExistingReports = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/weekly-reports`, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error('Failed to fetch reports');
|
||||
const data = await response.json();
|
||||
setExistingReports(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching reports:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
if (!file.name.endsWith('.xlsx')) {
|
||||
setError('Please select an Excel file (.xlsx)');
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
setPhase('uploading');
|
||||
setUploadProgress(0);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
|
||||
try {
|
||||
setUploadProgress(50); // Simulated progress
|
||||
setPhase('processing');
|
||||
|
||||
const response = await fetch(`${API_BASE}/weekly-reports/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setResult(data);
|
||||
setPhase('success');
|
||||
|
||||
// Refresh the list of existing reports
|
||||
await fetchExistingReports();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setPhase('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (id, type) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/weekly-reports/${id}/download/${type}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `vulnerability_report_${type}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (err) {
|
||||
console.error('Error downloading file:', err);
|
||||
setError(`Failed to download ${type} file`);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setPhase('idle');
|
||||
setSelectedFile(null);
|
||||
setUploadProgress(0);
|
||||
setResult(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Weekly Vulnerability Report</h2>
|
||||
<button onClick={onClose} className="modal-close">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="modal-body">
|
||||
{/* Idle Phase - File Selection */}
|
||||
{phase === 'idle' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||
Upload Excel File (.xlsx)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx"
|
||||
onChange={handleFileSelect}
|
||||
className="intel-input w-full"
|
||||
/>
|
||||
{selectedFile && (
|
||||
<p className="mt-2 text-sm" style={{ color: '#10B981' }}>
|
||||
Selected: {selectedFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile}
|
||||
className={`intel-button w-full ${selectedFile ? 'intel-button-success' : 'opacity-50 cursor-not-allowed'}`}
|
||||
>
|
||||
<UploadIcon className="w-4 h-4 mr-2" />
|
||||
Upload & Process
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||
<p style={{ color: '#FCA5A5' }}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploading Phase */}
|
||||
{phase === 'uploading' && (
|
||||
<div className="text-center py-8">
|
||||
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
||||
<p style={{ color: '#94A3B8' }}>Uploading file...</p>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2 mt-4">
|
||||
<div
|
||||
className="h-2 rounded-full transition-all"
|
||||
style={{ width: `${uploadProgress}%`, background: '#0EA5E9' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processing Phase */}
|
||||
{phase === 'processing' && (
|
||||
<div className="text-center py-8">
|
||||
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
||||
<p style={{ color: '#94A3B8' }}>Processing vulnerability report...</p>
|
||||
<p className="text-sm mt-2" style={{ color: '#64748B' }}>Splitting CVE IDs into separate rows</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Phase */}
|
||||
{phase === 'success' && result && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 p-4 rounded" style={{ background: 'rgba(16, 185, 129, 0.1)', border: '1px solid #10B981' }}>
|
||||
<CheckCircle className="w-6 h-6" style={{ color: '#10B981' }} />
|
||||
<div>
|
||||
<p className="font-medium" style={{ color: '#34D399' }}>Upload Successful!</p>
|
||||
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>
|
||||
Original: {result.original_rows} rows → Processed: {result.processed_rows} rows
|
||||
<span className="ml-2" style={{ color: '#10B981' }}>
|
||||
(+{result.processed_rows - result.original_rows} rows from splitting CVEs)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleDownload(result.id, 'original')}
|
||||
className="intel-button flex-1"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download Original
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownload(result.id, 'processed')}
|
||||
className="intel-button intel-button-success flex-1"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download Processed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button onClick={resetForm} className="intel-button w-full">
|
||||
Upload Another Report
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Phase */}
|
||||
{phase === 'error' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 p-4 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||
<div>
|
||||
<p className="font-medium" style={{ color: '#FCA5A5' }}>Upload Failed</p>
|
||||
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={resetForm} className="intel-button w-full">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing Reports Section */}
|
||||
{(phase === 'idle' || phase === 'success') && existingReports.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium mb-4" style={{ color: '#94A3B8' }}>
|
||||
Previous Reports
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{existingReports.map((report) => (
|
||||
<div
|
||||
key={report.id}
|
||||
className="intel-card p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{report.is_current && (
|
||||
<Star className="w-4 h-4 fill-current" style={{ color: '#F59E0B' }} />
|
||||
)}
|
||||
<p className="font-medium" style={{ color: report.is_current ? '#F59E0B' : '#94A3B8' }}>
|
||||
{report.week_label}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm" style={{ color: '#64748B' }}>
|
||||
{new Date(report.upload_date).toLocaleDateString()} •
|
||||
{report.row_count_original} → {report.row_count_processed} rows
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleDownload(report.id, 'original')}
|
||||
className="intel-button intel-button-small"
|
||||
title="Download Original"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownload(report.id, 'processed')}
|
||||
className="intel-button intel-button-success intel-button-small"
|
||||
title="Download Processed"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user