From fd1306a2ad2587ab3c3357e6a59bd4245e7d9804 Mon Sep 17 00:00:00 2001 From: Aliyu Habibu Date: Thu, 18 Jun 2026 00:44:49 +0100 Subject: [PATCH 1/3] feat(threat-hunting): implement proactive threat hunting workspace - Add ThreatHuntingSearch with text search and filters - Add EventExplorer with severity stats and detail panel - Add InvestigationWorkspace with notes and close flow - Add types, mock data, tests, and styling - Update jest config to include apps/web tests --- apps/web/app/threat-hunting/EventExplorer.css | 300 ++++++++++++++++++ apps/web/app/threat-hunting/EventExplorer.tsx | 154 +++++++++ .../threat-hunting/InvestigationWorkspace.css | 248 +++++++++++++++ .../threat-hunting/InvestigationWorkspace.tsx | 159 ++++++++++ .../threat-hunting/ThreatHuntingSearch.css | 278 ++++++++++++++++ .../threat-hunting/ThreatHuntingSearch.tsx | 179 +++++++++++ apps/web/app/threat-hunting/page.spec.tsx | 78 +++++ apps/web/app/threat-hunting/page.tsx | 122 +++++++ .../web/app/threat-hunting/threat-hunting.css | 75 +++++ apps/web/app/threat-hunting/types.ts | 172 ++++++++++ jest.dashboard.config.js | 7 +- 11 files changed, 1771 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/threat-hunting/EventExplorer.css create mode 100644 apps/web/app/threat-hunting/EventExplorer.tsx create mode 100644 apps/web/app/threat-hunting/InvestigationWorkspace.css create mode 100644 apps/web/app/threat-hunting/InvestigationWorkspace.tsx create mode 100644 apps/web/app/threat-hunting/ThreatHuntingSearch.css create mode 100644 apps/web/app/threat-hunting/ThreatHuntingSearch.tsx create mode 100644 apps/web/app/threat-hunting/page.spec.tsx create mode 100644 apps/web/app/threat-hunting/page.tsx create mode 100644 apps/web/app/threat-hunting/threat-hunting.css create mode 100644 apps/web/app/threat-hunting/types.ts diff --git a/apps/web/app/threat-hunting/EventExplorer.css b/apps/web/app/threat-hunting/EventExplorer.css new file mode 100644 index 0000000..d92dde8 --- /dev/null +++ b/apps/web/app/threat-hunting/EventExplorer.css @@ -0,0 +1,300 @@ +.ee-container { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.ee-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; +} + +.ee-header-left { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.ee-section-title { + font-size: 1.25rem; + font-weight: 700; + color: #f1f5f9; + margin: 0; +} + +.ee-section-desc { + font-size: 0.8125rem; + color: #64748b; + margin: 0; +} + +.ee-stats-row { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.ee-stat { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + background: #1e293b; + border: 1px solid #334155; + border-radius: 0.5rem; + font-size: 0.8125rem; +} + +.ee-stat-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.ee-stat-dot--critical { background: #ef4444; } +.ee-stat-dot--high { background: #f97316; } +.ee-stat-dot--medium { background: #f59e0b; } +.ee-stat-dot--low { background: #3b82f6; } + +.ee-stat-count { + font-weight: 600; + color: #f1f5f9; +} + +.ee-stat-label { + color: #94a3b8; +} + +.ee-table-card { + background: #1e293b; + border: 1px solid #334155; + border-radius: 0.75rem; + overflow: hidden; +} + +.ee-table { + width: 100%; + border-collapse: collapse; + text-align: left; +} + +.ee-table th { + padding: 0.875rem 1.25rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; + font-weight: 600; + border-bottom: 1px solid #334155; + background: rgba(0, 0, 0, 0.1); +} + +.ee-table td { + padding: 0.875rem 1.25rem; + font-size: 0.875rem; + border-bottom: 1px solid rgba(51, 65, 85, 0.3); + vertical-align: middle; +} + +.ee-table tbody tr { + transition: background 0.15s ease; + cursor: pointer; +} + +.ee-table tbody tr:hover { + background: rgba(255, 255, 255, 0.03); +} + +.ee-table tbody tr:last-child td { + border-bottom: none; +} + +.ee-table tbody tr.ee-row--selected { + background: rgba(99, 102, 241, 0.1); + border-left: 3px solid #6366f1; +} + +.ee-severity-badge { + display: inline-block; + padding: 0.2rem 0.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; +} + +.ee-severity-badge--critical { + background: rgba(239, 68, 68, 0.15); + color: #fca5a5; +} + +.ee-severity-badge--high { + background: rgba(249, 115, 22, 0.15); + color: #fdba74; +} + +.ee-severity-badge--medium { + background: rgba(245, 158, 11, 0.15); + color: #fcd34d; +} + +.ee-severity-badge--low { + background: rgba(59, 130, 246, 0.15); + color: #93c5fd; +} + +.ee-status-badge { + display: inline-block; + padding: 0.2rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.6875rem; + font-weight: 600; + text-transform: capitalize; +} + +.ee-status-badge--open { + background: rgba(34, 197, 94, 0.15); + color: #86efac; +} + +.ee-status-badge--investigating { + background: rgba(251, 191, 36, 0.15); + color: #fcd34d; +} + +.ee-status-badge--resolved { + background: rgba(99, 102, 241, 0.15); + color: #a5b4fc; +} + +.ee-status-badge--dismissed { + background: rgba(100, 116, 139, 0.15); + color: #94a3b8; +} + +.ee-signature-cell { + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.8125rem; + color: #e2e8f0; +} + +.ee-chain-badge { + display: inline-block; + padding: 0.2rem 0.5rem; + background: #0f172a; + border: 1px solid #334155; + border-radius: 0.25rem; + font-size: 0.75rem; + color: #94a3b8; +} + +.ee-risk-bar { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.ee-risk-track { + width: 60px; + height: 6px; + background: #0f172a; + border-radius: 9999px; + overflow: hidden; +} + +.ee-risk-fill { + height: 100%; + border-radius: 9999px; + transition: width 0.4s ease; +} + +.ee-risk-fill--critical { background: #ef4444; } +.ee-risk-fill--high { background: #f97316; } +.ee-risk-fill--medium { background: #f59e0b; } +.ee-risk-fill--low { background: #3b82f6; } + +.ee-risk-score { + font-size: 0.8125rem; + font-weight: 600; + color: #e2e8f0; + min-width: 2rem; +} + +.ee-detail-panel { + background: #1e293b; + border: 1px solid #334155; + border-radius: 0.75rem; + padding: 1.5rem; +} + +.ee-detail-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.ee-detail-title { + font-size: 1rem; + font-weight: 600; + color: #f1f5f9; + margin: 0; +} + +.ee-detail-investigate-btn { + padding: 0.5rem 1rem; + background: #6366f1; + color: #fff; + border: none; + border-radius: 0.5rem; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease; + font-family: inherit; +} + +.ee-detail-investigate-btn:hover { + background: #4f46e5; +} + +.ee-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.ee-detail-field { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.ee-detail-field-label { + font-size: 0.75rem; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 500; +} + +.ee-detail-field-value { + font-size: 0.875rem; + color: #e2e8f0; + font-family: 'SF Mono', 'Fira Code', monospace; +} + +.ee-empty { + text-align: center; + padding: 4rem 1rem; + color: #64748b; +} + +.ee-empty-icon { + font-size: 2.5rem; + margin-bottom: 0.75rem; + opacity: 0.4; +} diff --git a/apps/web/app/threat-hunting/EventExplorer.tsx b/apps/web/app/threat-hunting/EventExplorer.tsx new file mode 100644 index 0000000..3564ff1 --- /dev/null +++ b/apps/web/app/threat-hunting/EventExplorer.tsx @@ -0,0 +1,154 @@ +import React, { useMemo } from 'react'; +import { ThreatEvent, Severity } from './types'; +import './EventExplorer.css'; + +interface Props { + events: ThreatEvent[]; + onInvestigate: (event: ThreatEvent) => void; + selectedEvent: ThreatEvent | null; + onSelectEvent: (event: ThreatEvent | null) => void; +} + +function riskLevel(score: number): Severity { + if (score >= 85) return 'critical'; + if (score >= 70) return 'high'; + if (score >= 50) return 'medium'; + return 'low'; +} + +export const EventExplorer: React.FC = ({ events, onInvestigate, selectedEvent, onSelectEvent }) => { + const stats = useMemo(() => { + const counts = { critical: 0, high: 0, medium: 0, low: 0 }; + events.forEach(e => { + if (e.severity === 'critical') counts.critical++; + else if (e.severity === 'high') counts.high++; + else if (e.severity === 'medium') counts.medium++; + else counts.low++; + }); + return counts; + }, [events]); + + const sorted = useMemo( + () => [...events].sort((a, b) => b.riskScore - a.riskScore), + [events], + ); + + return ( +
+
+
+

Event Explorer

+

Browse and analyze detected security events ({events.length} total)

+
+
+ {(Object.entries(stats) as [Severity, number][]).map(([severity, count]) => ( +
+ + {count} + {severity} +
+ ))} +
+
+ +
+ + + + + + + + + + + + + {sorted.map(event => ( + onSelectEvent(selectedEvent?.id === event.id ? null : event)} + > + + + + + + + + ))} + +
TimestampSeveritySignatureChainRiskStatus
{event.timestamp} + + {event.severity} + + {event.signature} + {event.chain} + +
+
+
+
+ {event.riskScore} +
+
+ + {event.status} + +
+
+ + {selectedEvent ? ( +
+
+

+ {selectedEvent.signature} — {selectedEvent.chain} +

+ +
+
+
+ Transaction Hash + {selectedEvent.transactionHash} +
+
+ From Address + {selectedEvent.fromAddress} +
+
+ To Address + {selectedEvent.toAddress} +
+
+ Risk Score + {selectedEvent.riskScore}/100 +
+
+ Status + {selectedEvent.status} +
+
+ Description + {selectedEvent.description} +
+
+
+ ) : ( +
+
+ + + +
+

Select an event to view details

+
+ )} +
+ ); +}; diff --git a/apps/web/app/threat-hunting/InvestigationWorkspace.css b/apps/web/app/threat-hunting/InvestigationWorkspace.css new file mode 100644 index 0000000..e28f965 --- /dev/null +++ b/apps/web/app/threat-hunting/InvestigationWorkspace.css @@ -0,0 +1,248 @@ +.iw-container { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.iw-header-text { + font-size: 1.25rem; + font-weight: 700; + color: #f1f5f9; + margin: 0 0 0.25rem; +} + +.iw-header-desc { + font-size: 0.8125rem; + color: #64748b; + margin: 0; +} + +.iw-empty { + text-align: center; + padding: 4rem 1rem; + color: #64748b; +} + +.iw-empty-icon { + font-size: 2.5rem; + margin-bottom: 0.75rem; + opacity: 0.4; +} + +.iw-empty-text { + font-size: 0.9375rem; + margin: 0 0 0.25rem; +} + +.iw-empty-hint { + font-size: 0.8125rem; + margin: 0; +} + +.iw-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.iw-item { + background: #1e293b; + border: 1px solid #334155; + border-radius: 0.75rem; + overflow: hidden; + transition: border-color 0.2s ease; +} + +.iw-item:hover { + border-color: #475569; +} + +.iw-item-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.25rem; + cursor: pointer; + gap: 1rem; +} + +.iw-item-title { + font-size: 0.9375rem; + font-weight: 600; + color: #f1f5f9; + margin: 0; +} + +.iw-item-badges { + display: flex; + gap: 0.5rem; + align-items: center; + flex-shrink: 0; +} + +.iw-item-badge { + padding: 0.2rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.6875rem; + font-weight: 600; + text-transform: capitalize; +} + +.iw-item-badge--open { + background: rgba(34, 197, 94, 0.15); + color: #86efac; +} + +.iw-item-badge--closed { + background: rgba(100, 116, 139, 0.15); + color: #94a3b8; +} + +.iw-item-meta { + font-size: 0.75rem; + color: #64748b; +} + +.iw-item-chevron { + color: #64748b; + transition: transform 0.2s ease; + display: flex; +} + +.iw-item-chevron--open { + transform: rotate(180deg); +} + +.iw-item-body { + padding: 0 1.25rem 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.iw-events-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.iw-event-ref { + padding: 0.25rem 0.5rem; + background: rgba(99, 102, 241, 0.1); + border: 1px solid rgba(99, 102, 241, 0.2); + border-radius: 0.25rem; + font-size: 0.75rem; + color: #a5b4fc; + font-family: 'SF Mono', 'Fira Code', monospace; +} + +.iw-notes-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.iw-notes-title { + font-size: 0.8125rem; + font-weight: 600; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 0; +} + +.iw-note { + background: #0f172a; + border: 1px solid #334155; + border-radius: 0.5rem; + padding: 0.875rem; +} + +.iw-note-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.375rem; +} + +.iw-note-author { + font-size: 0.8125rem; + font-weight: 600; + color: #e2e8f0; +} + +.iw-note-time { + font-size: 0.6875rem; + color: #64748b; +} + +.iw-note-content { + font-size: 0.875rem; + color: #cbd5e1; + margin: 0; + line-height: 1.5; +} + +.iw-add-note { + display: flex; + gap: 0.5rem; +} + +.iw-note-input { + flex: 1; + padding: 0.625rem 0.875rem; + background: #0f172a; + border: 1px solid #334155; + border-radius: 0.5rem; + color: #f8fafc; + font-size: 0.8125rem; + font-family: inherit; + outline: none; + resize: vertical; + min-height: 2.5rem; +} + +.iw-note-input:focus { + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); +} + +.iw-note-btn { + padding: 0.5rem 1rem; + background: #6366f1; + color: #fff; + border: none; + border-radius: 0.5rem; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease; + font-family: inherit; + align-self: flex-end; +} + +.iw-note-btn:hover { + background: #4f46e5; +} + +.iw-note-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.iw-close-btn { + padding: 0.375rem 0.75rem; + background: transparent; + border: 1px solid #ef4444; + color: #f87171; + border-radius: 0.375rem; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; +} + +.iw-close-btn:hover { + background: rgba(239, 68, 68, 0.1); +} diff --git a/apps/web/app/threat-hunting/InvestigationWorkspace.tsx b/apps/web/app/threat-hunting/InvestigationWorkspace.tsx new file mode 100644 index 0000000..fa84e70 --- /dev/null +++ b/apps/web/app/threat-hunting/InvestigationWorkspace.tsx @@ -0,0 +1,159 @@ +import React, { useState } from 'react'; +import { Investigation, ThreatEvent } from './types'; +import './InvestigationWorkspace.css'; + +interface Props { + investigations: Investigation[]; + events: ThreatEvent[]; + onAddNote: (investigationId: string, content: string) => void; + onCloseInvestigation: (investigationId: string) => void; +} + +export const InvestigationWorkspace: React.FC = ({ + investigations, + events, + onAddNote, + onCloseInvestigation, +}) => { + const [expandedId, setExpandedId] = useState(null); + const [noteInputs, setNoteInputs] = useState>({}); + + const toggleExpand = (id: string) => { + setExpandedId(prev => (prev === id ? null : id)); + }; + + const getEventById = (eventId: string): ThreatEvent | undefined => { + return events.find(e => e.id === eventId); + }; + + const handleAddNote = (investigationId: string) => { + const content = noteInputs[investigationId]?.trim(); + if (!content) return; + onAddNote(investigationId, content); + setNoteInputs(prev => ({ ...prev, [investigationId]: '' })); + }; + + const handleNoteKeyDown = (e: React.KeyboardEvent, investigationId: string) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleAddNote(investigationId); + } + }; + + if (investigations.length === 0) { + return ( +
+
+
+ + + +
+

No investigations yet

+

Select an event and start an investigation to begin tracking threats

+
+
+ ); + } + + const sorted = [...investigations].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + return ( +
+

Investigation Workspace

+

Track and manage your security investigations ({investigations.length} total)

+ +
+ {sorted.map(inv => { + const isExpanded = expandedId === inv.id; + return ( +
+
toggleExpand(inv.id)} role="button" tabIndex={0} onKeyDown={e => e.key === 'Enter' && toggleExpand(inv.id)}> +
+

{inv.title}

+
+ Created {new Date(inv.createdAt).toLocaleString()} · {inv.notes.length} note{inv.notes.length !== 1 ? 's' : ''} +
+
+
+ {inv.status} + + + + + +
+
+ + {isExpanded && ( +
+
+ Related Events +
+ {inv.eventIds.map(eventId => { + const evt = getEventById(eventId); + return ( + + {evt ? `${evt.signature} (${evt.chain})` : eventId} + + ); + })} +
+
+ +
+ Investigation Notes + {inv.notes.map(note => ( +
+
+ {note.author} + {new Date(note.createdAt).toLocaleString()} +
+

{note.content}

+
+ ))} + {inv.notes.length === 0 && ( +

+ No notes yet. Add your first observation below. +

+ )} +
+ +
+