added antivirus and new sanitization for inputs

This commit is contained in:
laxmanhalaki 2026-02-24 19:47:06 +05:30
parent dfe94555ab
commit 32a486d6f4
7 changed files with 337 additions and 45 deletions

View File

@ -0,0 +1,194 @@
/**
* AntivirusScanStatus Component
* Displays the antivirus scan result badge/status for uploaded files.
* Shows ClamAV scan result and XSS content scan result.
*/
import React from 'react';
// ── Types ──
export interface ScanResultData {
malwareScan?: {
scanned: boolean;
isInfected: boolean;
skipped?: boolean;
virusNames?: string[];
scanDuration?: number;
error?: string;
};
contentScan?: {
scanned: boolean;
safe: boolean;
scanType: string;
severity: 'SAFE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
threats?: Array<{ description: string; severity: string }>;
patternsChecked: number;
};
scanEventId?: string;
}
interface AntivirusScanStatusProps {
scanResult?: ScanResultData;
compact?: boolean;
className?: string;
}
// ── Helpers ──
function getStatusColor(result?: ScanResultData): string {
if (!result) return '#94a3b8'; // gray — no scan data
// Check malware first
if (result.malwareScan?.isInfected) return '#ef4444'; // red
if (result.malwareScan?.error) return '#f59e0b'; // amber
// Then XSS
if (result.contentScan && !result.contentScan.safe) {
if (result.contentScan.severity === 'CRITICAL') return '#ef4444';
if (result.contentScan.severity === 'HIGH') return '#ef4444';
if (result.contentScan.severity === 'MEDIUM') return '#f59e0b';
return '#f59e0b';
}
// Skipped
if (result.malwareScan?.skipped) return '#94a3b8';
return '#22c55e'; // green — all clear
}
function getStatusIcon(result?: ScanResultData): string {
if (!result) return '⏳';
if (result.malwareScan?.isInfected) return '🛑';
if (result.contentScan && !result.contentScan.safe) return '⚠️';
if (result.malwareScan?.skipped) return '⏭️';
if (result.malwareScan?.error) return '❌';
if (result.malwareScan?.scanned && result.contentScan?.scanned) return '✅';
return '⏳';
}
function getStatusLabel(result?: ScanResultData): string {
if (!result) return 'Pending scan';
if (result.malwareScan?.isInfected) return 'Malware detected';
if (result.contentScan && !result.contentScan.safe) return 'Content threat detected';
if (result.malwareScan?.skipped) return 'Scan skipped';
if (result.malwareScan?.error) return 'Scan error';
if (result.malwareScan?.scanned && result.contentScan?.scanned) return 'Clean';
return 'Scanning…';
}
// ── Component ──
const AntivirusScanStatus: React.FC<AntivirusScanStatusProps> = ({
scanResult,
compact = false,
className = '',
}) => {
const color = getStatusColor(scanResult);
const icon = getStatusIcon(scanResult);
const label = getStatusLabel(scanResult);
// Compact mode: just a badge
if (compact) {
return (
<span
className={className}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: 500,
backgroundColor: `${color}15`,
color,
border: `1px solid ${color}30`,
}}
title={label}
>
<span style={{ fontSize: '11px' }}>{icon}</span>
{label}
</span>
);
}
// Full mode: detailed card
return (
<div
className={className}
style={{
border: `1px solid ${color}30`,
borderRadius: '8px',
padding: '12px 16px',
backgroundColor: `${color}08`,
fontSize: '13px',
}}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<span style={{ fontSize: '16px' }}>{icon}</span>
<span style={{ fontWeight: 600, color }}>{label}</span>
{scanResult?.malwareScan?.scanDuration && (
<span style={{ marginLeft: 'auto', fontSize: '11px', color: '#94a3b8' }}>
{scanResult.malwareScan.scanDuration}ms
</span>
)}
</div>
{/* Details */}
{scanResult && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{/* ClamAV Result */}
{scanResult.malwareScan?.scanned && (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: '#64748b' }}>
<span>🦠</span>
<span>
ClamAV:{' '}
{scanResult.malwareScan.isInfected
? `Infected — ${scanResult.malwareScan.virusNames?.join(', ')}`
: 'Clean'}
</span>
</div>
)}
{/* XSS Result */}
{scanResult.contentScan?.scanned && (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: '#64748b' }}>
<span>🔍</span>
<span>
Content scan ({scanResult.contentScan.scanType}):{' '}
{scanResult.contentScan.safe
? `Safe — ${scanResult.contentScan.patternsChecked} patterns checked`
: `${scanResult.contentScan.threats?.length || 0} threats found (${scanResult.contentScan.severity})`}
</span>
</div>
)}
{/* Threats list */}
{scanResult.contentScan?.threats && scanResult.contentScan.threats.length > 0 && (
<ul style={{ margin: '4px 0 0 24px', padding: 0, fontSize: '11px', color: '#ef4444' }}>
{scanResult.contentScan.threats.slice(0, 5).map((threat, i) => (
<li key={i}>
{threat.description} ({threat.severity})
</li>
))}
{scanResult.contentScan.threats.length > 5 && (
<li>and {scanResult.contentScan.threats.length - 5} more</li>
)}
</ul>
)}
{/* Scan event ID */}
{scanResult.scanEventId && (
<div style={{ fontSize: '10px', color: '#94a3b8', marginTop: '4px' }}>
Scan ID: {scanResult.scanEventId}
</div>
)}
</div>
)}
</div>
);
};
export default AntivirusScanStatus;

View File

@ -21,6 +21,7 @@ import { Badge } from '@/components/ui/badge';
import { CustomDatePicker } from '@/components/ui/date-picker'; import { CustomDatePicker } from '@/components/ui/date-picker';
import { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert, CheckCircle2, Eye, Download, IndianRupee } from 'lucide-react'; import { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert, CheckCircle2, Eye, Download, IndianRupee } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { handleSecurityError } from '@/utils/securityToast';
import '@/components/common/FilePreview/FilePreview.css'; import '@/components/common/FilePreview/FilePreview.css';
import './DealerCompletionDocumentsModal.css'; import './DealerCompletionDocumentsModal.css';
import { validateHSNSAC } from '@/utils/validationUtils'; import { validateHSNSAC } from '@/utils/validationUtils';
@ -553,7 +554,9 @@ export function DealerCompletionDocumentsModal({
onClose(); onClose();
} catch (error) { } catch (error) {
console.error('Failed to submit completion documents:', error); console.error('Failed to submit completion documents:', error);
toast.error('Failed to submit completion documents. Please try again.'); if (!handleSecurityError(error)) {
toast.error('Failed to submit completion documents. Please try again.');
}
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }

View File

@ -21,6 +21,7 @@ import { Badge } from '@/components/ui/badge';
import { CustomDatePicker } from '@/components/ui/date-picker'; import { CustomDatePicker } from '@/components/ui/date-picker';
import { Upload, Plus, Minus, X, Calendar, IndianRupee, CircleAlert, CheckCircle2, FileText, Eye, Download } from 'lucide-react'; import { Upload, Plus, Minus, X, Calendar, IndianRupee, CircleAlert, CheckCircle2, FileText, Eye, Download } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { handleSecurityError } from '@/utils/securityToast';
import { DocumentCard } from '@/components/workflow/DocumentUpload/DocumentCard'; import { DocumentCard } from '@/components/workflow/DocumentUpload/DocumentCard';
import { FilePreview } from '@/components/common/FilePreview/FilePreview'; import { FilePreview } from '@/components/common/FilePreview/FilePreview';
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi'; import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
@ -485,7 +486,9 @@ export function DealerProposalSubmissionModal({
onClose(); onClose();
} catch (error) { } catch (error) {
console.error('Failed to submit proposal:', error); console.error('Failed to submit proposal:', error);
toast.error('Failed to submit proposal. Please try again.'); if (!handleSecurityError(error)) {
toast.error('Failed to submit proposal. Please try again.');
}
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }

View File

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { uploadDocument } from '@/services/documentApi'; import { uploadDocument } from '@/services/documentApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi'; import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { handleSecurityError } from '@/utils/securityToast';
/** /**
* Custom Hook: useDocumentUpload * Custom Hook: useDocumentUpload
@ -201,8 +202,10 @@ export function useDocumentUpload(
} catch (error: any) { } catch (error: any) {
console.error('[useDocumentUpload] Upload error:', error); console.error('[useDocumentUpload] Upload error:', error);
// Error feedback with backend error message if available // Show security-specific red toast for scan errors, or generic error toast
toast.error(error?.response?.data?.error || 'Failed to upload document'); if (!handleSecurityError(error)) {
toast.error(error?.response?.data?.message || 'Failed to upload document');
}
} finally { } finally {
setUploadingDocument(false); setUploadingDocument(false);

View File

@ -184,7 +184,10 @@ export function OverviewTab({
{pauseInfo.pauseReason && ( {pauseInfo.pauseReason && (
<div> <div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Reason</label> <label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Reason</label>
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pauseReason}</p> <FormattedDescription
content={pauseInfo.pauseReason}
className="text-sm text-gray-900 mt-1"
/>
</div> </div>
)} )}
@ -331,19 +334,16 @@ export function OverviewTab({
{/* Conclusion Remark Section */} {/* Conclusion Remark Section */}
{needsClosure && ( {needsClosure && (
<Card data-testid="conclusion-remark-card"> <Card data-testid="conclusion-remark-card">
<CardHeader className={`bg-gradient-to-r border-b ${ <CardHeader className={`bg-gradient-to-r border-b ${request.status === 'rejected'
request.status === 'rejected'
? 'from-red-50 to-rose-50 border-red-200' ? 'from-red-50 to-rose-50 border-red-200'
: 'from-green-50 to-emerald-50 border-green-200' : 'from-green-50 to-emerald-50 border-green-200'
}`}> }`}>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div>
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${ <CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${request.status === 'rejected' ? 'text-red-700' : 'text-green-700'
request.status === 'rejected' ? 'text-red-700' : 'text-green-700' }`}>
}`}> <CheckCircle className={`w-5 h-5 ${request.status === 'rejected' ? 'text-red-600' : 'text-green-600'
<CheckCircle className={`w-5 h-5 ${ }`} />
request.status === 'rejected' ? 'text-red-600' : 'text-green-600'
}`} />
Conclusion Remark - Final Step Conclusion Remark - Final Step
</CardTitle> </CardTitle>
<CardDescription className="mt-1 text-xs sm:text-sm"> <CardDescription className="mt-1 text-xs sm:text-sm">
@ -365,7 +365,7 @@ export function OverviewTab({
{aiGenerated ? 'Regenerate' : 'Generate with AI'} {aiGenerated ? 'Regenerate' : 'Generate with AI'}
</Button> </Button>
{aiGenerated && !maxAttemptsReached && !generationFailed && ( {aiGenerated && !maxAttemptsReached && !generationFailed && (
<span className="text-[10px] text-gray-500 font-medium px-1"> <span className="text-[10px] text-gray-500 font-medium px-1">
{2 - generationAttempts} attempts remaining {2 - generationAttempts} attempts remaining
</span> </span>
)} )}

View File

@ -163,7 +163,14 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
</div> </div>
<div> <div>
<p className="text-xs text-gray-500 mb-1">Remarks</p> <p className="text-xs text-gray-500 mb-1">Remarks</p>
<p className="text-sm text-gray-700">{approver.remarks || '—'}</p> {approver.remarks ? (
<FormattedDescription
content={approver.remarks}
className="text-sm text-gray-700"
/>
) : (
<p className="text-sm text-gray-700"></p>
)}
</div> </div>
</div> </div>
))} ))}

View File

@ -0,0 +1,82 @@
/**
* Security Toast Helper
* Shows distinct, styled toast messages for antivirus/security scan errors.
* Each error type (malware, file validation, XSS, scan unavailable) gets
* its own clear message so the user knows exactly what happened.
*/
import { toast } from 'sonner';
// Security error codes returned by the backend
const SECURITY_ERROR_CODES = [
'MALWARE_DETECTED',
'FILE_VALIDATION_FAILED',
'CONTENT_THREAT_DETECTED',
'SCAN_UNAVAILABLE',
'SCAN_ERROR',
] as const;
type SecurityErrorCode = typeof SECURITY_ERROR_CODES[number];
interface SecurityErrorResponse {
error?: string;
message?: string;
details?: {
errors?: string[];
warnings?: string[];
scanEngine?: string;
signatures?: string[];
scanType?: string;
threats?: Array<{ description: string; severity: string }>;
};
scanEventId?: string;
}
// User-friendly titles for each error type
const ERROR_TITLES: Record<SecurityErrorCode, string> = {
MALWARE_DETECTED: '🛑 Malware Detected',
FILE_VALIDATION_FAILED: '⛔ File Rejected',
CONTENT_THREAT_DETECTED: '⚠️ Malicious Content Detected',
SCAN_UNAVAILABLE: '🔒 Security Scan Unavailable',
SCAN_ERROR: '❌ Security Scan Error',
};
/**
* Check if an API error response is a security/scan error.
* Returns true if it was handled (showed a toast), false otherwise.
*/
export function handleSecurityError(error: any): boolean {
const responseData: SecurityErrorResponse = error?.response?.data;
if (!responseData?.error) return false;
const errorCode = responseData.error as SecurityErrorCode;
if (!SECURITY_ERROR_CODES.includes(errorCode)) return false;
const title = ERROR_TITLES[errorCode] || 'Security Error';
const message = responseData.message || 'File was blocked by security scan';
// Build detail text
let detailText = '';
if (responseData.details) {
if (responseData.details.signatures?.length) {
detailText = `Virus: ${responseData.details.signatures.join(', ')}`;
} else if (responseData.details.errors?.length) {
detailText = responseData.details.errors[0] || '';
} else if (responseData.details.threats?.length) {
detailText = responseData.details.threats.map(t => t.description).join(', ');
}
}
// Show custom styled toast
toast.error(title, {
description: detailText || message,
duration: 8000,
style: {
background: '#fef2f2',
border: '1px solid #fca5a5',
color: '#991b1b',
},
});
return true;
}