diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index 2f41ce2..342b1d2 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -130,6 +130,39 @@ function extractFinding(f) { // CVE list: vulnerabilities.vulnInfoList[].cve const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean); + // Workflow: flatten all distribution buckets, prioritise FP# over SYS# + const wfDist = f.workflowDistribution || {}; + const allWfEntries = [ + ...(wfDist.actionableWorkflows || []), + ...(wfDist.requestedWorkflows || []), + ...(wfDist.approvedWorkflows || []), + ...(wfDist.reworkedWorkflows || []), + ...(wfDist.rejectedWorkflows || []), + ...(wfDist.expiredWorkflows || []), + ...(wfDist.latestSystemWorkflows || []), + ]; + // FP# (False Positive tickets) take priority over SYS# (system workflows) + const fpEntry = allWfEntries.find(w => (w.generatedId || '').startsWith('FP#')); + const sysEntry = allWfEntries.find(w => (w.generatedId || '').startsWith('SYS#')); + const wfEntry = fpEntry || sysEntry || allWfEntries[0] || null; + + // If the distribution didn't surface an FP#, also check workflowGeneratedNames directly. + // (Some FP# tickets only appear in the names list without full state info.) + const generatedNames = f.workflowGeneratedNames || []; + const fpFromNames = !fpEntry + ? generatedNames.find(n => n.startsWith('FP#')) || null + : null; + + const workflow = wfEntry ? { + id: wfEntry.generatedId || '', + state: wfEntry.state || '', + type: wfEntry.type || wfEntry.acronym || '', + } : fpFromNames ? { + id: fpFromNames, + state: '', + type: 'FP', + } : null; + return { id: String(f.id), title: f.title || '', @@ -143,7 +176,8 @@ function extractFinding(f) { dueDate, lastFoundOn: f.lastFoundOn || '', buOwnership, - cves + cves, + workflow }; } diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index 37d4a92..b0345f7 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'; import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter } from 'lucide-react'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; -const STORAGE_KEY = 'steam_findings_columns_v1'; +const STORAGE_KEY = 'steam_findings_columns_v2'; // --------------------------------------------------------------------------- // Column definitions — source of truth for labels, sort behaviour, rendering @@ -17,8 +17,9 @@ const COLUMN_DEFS = { ipAddress: { label: 'IP Address', sortable: true, filterable: true }, dns: { label: 'DNS', sortable: true, filterable: true }, dueDate: { label: 'Due Date', sortable: true, filterable: true }, - slaStatus: { label: 'SLA', sortable: true, filterable: true }, + slaStatus: { label: 'SLA', sortable: true, filterable: true }, buOwnership: { label: 'BU', sortable: true, filterable: true }, + workflow: { label: 'Workflow', sortable: true, filterable: true }, lastFoundOn: { label: 'Last Found', sortable: true, filterable: true }, note: { label: 'Notes', sortable: false, filterable: false }, }; @@ -34,6 +35,7 @@ const DEFAULT_COLUMN_ORDER = [ { key: 'dueDate', visible: true }, { key: 'slaStatus', visible: true }, { key: 'buOwnership', visible: true }, + { key: 'workflow', visible: true }, { key: 'lastFoundOn', visible: true }, { key: 'note', visible: true }, ]; @@ -75,6 +77,7 @@ function getVal(finding, key) { case 'slaStatus': return finding.slaStatus ?? ''; case 'cves': return (finding.cves || []).length; // sort by CVE count case 'buOwnership': return finding.buOwnership ?? ''; + case 'workflow': return finding.workflow?.id ?? ''; case 'lastFoundOn': return finding.lastFoundOn ?? ''; case 'note': return finding.note ?? ''; default: return ''; @@ -87,6 +90,7 @@ function getVal(finding, key) { function getFilterVal(finding, key) { if (key === 'severity') return finding.vrrGroup || ''; if (key === 'cves') return (finding.cves || []).join(','); // not used directly; see multiValue logic + if (key === 'workflow') return finding.workflow?.id || ''; return String(getVal(finding, key) ?? ''); } @@ -121,6 +125,18 @@ function dueDateColor(dueDate) { return '#94A3B8'; } +function workflowStyle(state) { + switch ((state || '').toLowerCase()) { + case 'approved': return { bg: 'rgba(16,185,129,0.12)', border: 'rgba(16,185,129,0.35)', text: '#10B981' }; + case 'requested': return { bg: 'rgba(14,165,233,0.12)', border: 'rgba(14,165,233,0.35)', text: '#0EA5E9' }; + case 'actionable': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.35)', text: '#F59E0B' }; + case 'reworked': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.35)', text: '#F59E0B' }; + case 'rejected': return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.35)', text: '#EF4444' }; + case 'expired': return { bg: 'rgba(100,116,139,0.12)', border: 'rgba(100,116,139,0.3)', text: '#64748B' }; + default: return { bg: 'rgba(100,116,139,0.08)', border: 'rgba(100,116,139,0.2)', text: '#64748B' }; + } +} + function SortIcon({ colKey, sort }) { if (sort.field !== colKey) return ; return sort.dir === 'asc' @@ -553,6 +569,30 @@ function TableCell({ colKey, finding }) { ); } + case 'workflow': { + const wf = finding.workflow; + if (!wf || !wf.id) return —; + const ws = workflowStyle(wf.state); + return ( + + + {wf.id} + + {wf.state} + + + + ); + } case 'lastFoundOn': return (