diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000..2d39e36 --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1,43 @@ +# hiveops-aria frontend — CLAUDE.md + +## Before writing ANY new component + +Read an existing sibling component first. Mandatory. Use `IocManagement.svelte` or `DevicePosture.svelte` as the reference. + +## Canonical patterns (copy exactly, no variations) + +**Header** — text only, no buttons: +```svelte +
+
+

Page Title

+

Subtitle

+
+
+``` + +**Toolbar** — all buttons go here, never in the header: +```svelte +
+
+
N items
+
+ + +
+
+``` + +**Buttons** — always global classes, never custom button CSS: +- `btn btn-primary` / `btn btn-primary btn-sm` +- `btn btn-secondary` / `btn btn-secondary btn-sm` + +**Table** — inside `.table-wrapper > .table-container`: +- `thead` background: `#f9fafb`, sticky +- `th` color: `#374151`, uppercase, `font-size: 0.78rem` +- `td` color: `#1f2937` +- `tbody tr:hover` background: `#eff6ff` + +## Auto-poll +- Poll the STATUS endpoint only — never trigger a refresh from setInterval +- Use `onDestroy(() => clearInterval(interval))` always diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3258926..2a02ffb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { - "name": "hiveops-APPNAME-frontend", - "version": "1.0.1-dev", + "name": "hiveops-aria-frontend", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "hiveops-APPNAME-frontend", - "version": "1.0.1-dev", + "name": "hiveops-aria-frontend", + "version": "1.0.0", "dependencies": { "axios": "^1.6.0" }, diff --git a/frontend/package.json b/frontend/package.json index 9eb7bf5..8282984 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "hiveops-aria-frontend", - "version": "1.0.1-dev", + "version": "1.0.0", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 8ec7cbc..edc650d 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -4,14 +4,12 @@ import IocManagement from './components/IocManagement.svelte'; import PrecursorAlerts from './components/PrecursorAlerts.svelte'; import DevicePosture from './components/DevicePosture.svelte'; + import IocFeeds from './components/IocFeeds.svelte'; import Toast from './components/common/Toast.svelte'; import { onMount } from 'svelte'; import { authApi } from './lib/api'; import type { ThreatSeverity } from './lib/api'; - declare const __APP_VERSION__: string; - const appVersion: string = __APP_VERSION__; - let userInfo: { name?: string; email?: string; role?: string } | null = null; let sidebarCollapsed = localStorage.getItem('ariaSidebarCollapsed') === 'true'; @@ -20,7 +18,7 @@ localStorage.setItem('ariaSidebarCollapsed', String(sidebarCollapsed)); } - type View = 'dashboard' | 'events' | 'ioc' | 'sequences' | 'posture'; + type View = 'dashboard' | 'events' | 'ioc' | 'sequences' | 'posture' | 'feeds'; let currentView: View = 'dashboard'; let eventsInitialSeverity: ThreatSeverity | '' = ''; @@ -58,9 +56,6 @@
{/if} - {#if !sidebarCollapsed} - v{appVersion} - {/if} {#if !sidebarCollapsed && userInfo} @@ -131,6 +134,8 @@ {:else if currentView === 'posture'} + {:else if currentView === 'feeds'} + {/if} @@ -184,7 +189,6 @@ .aria-brand-text { display: flex; flex-direction: column; } .aria-name { font-size: 1.1rem; font-weight: 800; letter-spacing: 2px; color: white; } .aria-sub { font-size: 0.65rem; color: rgba(255,255,255,0.55); letter-spacing: 0.3px; margin-top: 1px; } - .sidebar-version { font-size: 0.7rem; color: rgba(255,255,255,0.4); padding-left: 2px; } .sidebar-nav { display: flex; flex-direction: column; padding: 0.75rem 0; flex: 1; } diff --git a/frontend/src/components/DevicePosture.svelte b/frontend/src/components/DevicePosture.svelte index 5da8610..1a79bc7 100644 --- a/frontend/src/components/DevicePosture.svelte +++ b/frontend/src/components/DevicePosture.svelte @@ -2,18 +2,13 @@ import { onMount, onDestroy } from 'svelte'; import { ariaApi, type DeviceSecurityPosture, type OsEolStatus } from '../lib/api'; import { addToast } from '../lib/stores'; - import Pagination from './common/Pagination.svelte'; let postures: DeviceSecurityPosture[] = []; let totalElements = 0; - let totalPages = 0; - let currentPage = 0; - const pageSize = 25; - let loading = true; // Filter sidebar - let filterSidebarCollapsed = false; + let filterSidebarCollapsed = true; let filterScore: '' | 'low' | 'medium' = ''; let filterEol: OsEolStatus | '' = ''; @@ -23,18 +18,56 @@ let panelOpen = false; let panelDevice: DeviceSecurityPosture | null = null; - async function load(page = 0) { + // Accordion — collapsed by default + let expandedInstitutions = new Set(); + + interface InstGroup { + key: string; + devices: DeviceSecurityPosture[]; + avgScore: number | null; + hasIssues: boolean; + } + + $: grouped = groupByInstitution(postures); + + function groupByInstitution(list: DeviceSecurityPosture[]): InstGroup[] { + const map = new Map(); + for (const d of list) { + const k = d.institutionKey ?? 'Unknown'; + if (!map.has(k)) map.set(k, []); + map.get(k)!.push(d); + } + return Array.from(map.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([key, devices]) => { + const scored = devices.filter(d => d.postureScore != null); + const avgScore = scored.length + ? Math.round(scored.reduce((s, d) => s + d.postureScore!, 0) / scored.length) + : null; + const hasIssues = devices.some(d => d.postureScore != null && d.postureScore < 80); + return { key, devices, avgScore, hasIssues }; + }); + } + + function toggleInstitution(key: string) { + if (expandedInstitutions.has(key)) { + expandedInstitutions.delete(key); + } else { + expandedInstitutions.add(key); + } + expandedInstitutions = expandedInstitutions; + } + + async function load() { loading = true; try { - const params: Record = { page, size: pageSize }; + const params: Record = { page: 0, size: 500 }; if (filterScore) params.scoreFilter = filterScore; if (filterEol) params.osEolStatus = filterEol; const res = await ariaApi.getPostures(params); postures = res.data.content; totalElements = res.data.page.totalElements; - totalPages = res.data.page.totalPages; - currentPage = res.data.page.number; } catch { addToast('error', 'Load Failed', 'Could not load device posture data'); } finally { @@ -45,8 +78,7 @@ function clearAllFilters() { filterScore = ''; filterEol = ''; - currentPage = 0; - load(0); + load(); } function openPanel(device: DeviceSecurityPosture) { @@ -105,13 +137,13 @@ async function doRefresh() { refreshing = true; - await load(currentPage); + await load(); refreshing = false; secondsLeft = REFRESH_SECONDS; } onMount(() => { - load(0); + load(); tickInterval = setInterval(() => { if (!autoRefreshEnabled) return; if (secondsLeft > 1) { @@ -168,7 +200,7 @@
-
+
{#if !filterSidebarCollapsed} Filters
@@ -193,7 +225,7 @@
{ currentPage = 0; load(0); }}> + on:change={() => load()}> @@ -224,14 +256,14 @@ Score: {filterScore === 'low' ? 'Low (<50)' : 'Below Good (<80)'} - + {/if} {#if filterEol} OS: {eolLabel(filterEol)} - + {/if}
@@ -245,12 +277,6 @@
- { currentPage = e.detail.page; load(currentPage); }} - on:pageSizeChange={e => { currentPage = 0; load(0); }} - /> -
{#if loading}
Loading…
@@ -274,46 +300,66 @@ - - {#each postures as device (device.deviceId)} - openPanel(device)}> - {device.deviceAgentId ?? device.deviceId} - {device.osVersion ?? '—'} - - - {eolLabel(device.osEolStatus)} - - - {boolCell(device.diskEncryptionEnabled)} - {boolCell(device.auditPolicyCompliant)} - {boolCell(device.softwareWhitelistEnabled)} - - {#if imageMismatch(device)} - Drift - {:else if device.goldImageHash} - Match - {:else} - - {/if} - - -
-
-
-
- - {device.postureScore ?? '—'} - + {#each grouped as group (group.key)} + + + toggleInstitution(group.key)}> + +
+ {expandedInstitutions.has(group.key) ? '▼' : '▶'} + {group.key} + {group.devices.length} device{group.devices.length !== 1 ? 's' : ''} + {#if group.avgScore != null} + avg {group.avgScore} + {/if} + {#if group.hasIssues} + ⚠ below 80 + {/if}
- {formatDate(device.lastPostureCheckAt)} - - - - {/each} - + {#if expandedInstitutions.has(group.key)} + {#each group.devices as device (device.deviceId)} + openPanel(device)}> + {device.deviceAgentId ?? device.deviceId} + {device.osVersion ?? '—'} + + + {eolLabel(device.osEolStatus)} + + + {boolCell(device.diskEncryptionEnabled)} + {boolCell(device.auditPolicyCompliant)} + {boolCell(device.softwareWhitelistEnabled)} + + {#if imageMismatch(device)} + Drift + {:else if device.goldImageHash} + Match + {:else} + + {/if} + + +
+
+
+
+ + {device.postureScore ?? '—'} + +
+ + {formatDate(device.lastPostureCheckAt)} + + + + + {/each} + {/if} + + {/each} {/if}
@@ -443,6 +489,8 @@ flex-direction: column; overflow: hidden; min-height: 0; + padding: 0.65rem; + gap: 0.5rem; } .content-frame { @@ -453,8 +501,9 @@ display: flex; flex-direction: column; min-height: 0; - margin-top: 8px; background: white; + padding: 1rem; + gap: 0.75rem; } /* Filter sidebar — canonical from template */ @@ -476,8 +525,6 @@ background: #eef2f7; flex-shrink: 0; min-height: 36px; } - .bar-filters-active { background: #dbeafe !important; border-bottom-color: #93c5fd !important; } - .bar-filters-active .filter-sidebar-title { color: #1e40af !important; } .toggle-bar-right { display: flex; align-items: center; gap: 0.35rem; margin-left: auto; } .filter-sidebar-title { font-size: var(--font-size-label); font-weight: 700; color: #374151; text-transform: uppercase; letter-spacing: 0.5px; } .sidebar-stat-pill { font-size: var(--font-size-tiny); background: #e0e7ff; border: 1px solid #c7d2fe; color: #3730a3; border-radius: 10px; padding: 1px 8px; white-space: nowrap; } @@ -497,7 +544,7 @@ /* Active filter chips */ .active-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; - margin-bottom: 8px; padding: 0.5rem 0.75rem; flex-shrink: 0; + padding: 0.4rem 0; flex-shrink: 0; } .active-filters-label { font-size: var(--font-size-tiny); font-weight: 600; color: #555; margin-right: 0.25rem; } .filter-tag { @@ -517,10 +564,10 @@ /* Toolbar */ .page-main { - flex: 1; overflow-y: auto; padding: 16px 24px 24px; + flex: 1; overflow-y: auto; min-width: 0; display: flex; flex-direction: column; } - .toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; flex-shrink: 0; } + .toolbar { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; } .toolbar-count { font-size: var(--font-size-body-sm); color: #6b7280; } .header-controls { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; } .btn-manual-refresh { @@ -644,12 +691,30 @@ } .check-divider { height: 1px; background: #f3f4f6; margin: 4px 0; } + /* Institution accordion header rows */ + tr.inst-header { cursor: pointer; } + tr.inst-header td { padding: 0; border-bottom: 1px solid #e5e7eb; } + tr.inst-header:hover td { background: #f1f5f9; } + .inst-header-inner { + display: flex; align-items: center; gap: 0.75rem; + padding: 0.55rem 0.75rem; + background: #f1f5f9; + font-size: 0.78rem; + } + .inst-chevron { color: #6b7280; font-size: 0.65rem; flex-shrink: 0; width: 10px; } + .inst-key { font-weight: 700; color: #1e3a5f; font-size: 0.82rem; letter-spacing: 0.2px; } + .inst-count { color: #6b7280; font-size: 0.75rem; } + .inst-avg { font-weight: 700; font-size: 0.75rem; } + .inst-warn { + font-size: 0.72rem; font-weight: 600; color: #b45309; + background: #fef3c7; border: 1px solid #fde68a; + border-radius: 10px; padding: 1px 8px; + } + /* Dark mode */ :global(.dark-mode) .content-frame { border-color: #374151; background: #1f2937; } :global(.dark-mode) .filter-sidebar { background: #1f2937; border-right-color: #374151; } :global(.dark-mode) .filter-sidebar-toggle-bar { background: #111827; border-bottom-color: #374151; } - :global(.dark-mode) .bar-filters-active { background: #1d4ed8 !important; border-bottom-color: #60a5fa !important; } - :global(.dark-mode) .bar-filters-active .filter-sidebar-title { color: #bfdbfe !important; } :global(.dark-mode) .filter-sidebar-title { color: #9ca3af; } :global(.dark-mode) .sidebar-toggle-btn { border-color: #4b5563; color: #9ca3af; } :global(.dark-mode) .sidebar-toggle-btn:hover { background: #374151; } @@ -684,6 +749,13 @@ :global(.dark-mode) .img-badge.drift { background: #450a0a; color: #fca5a5; } :global(.dark-mode) .img-badge.unknown { background: #1e293b; color: #94a3b8; } + :global(.dark-mode) .inst-header-inner { background: #1a2744; } + :global(.dark-mode) tr.inst-header:hover td { background: #243356; } + :global(.dark-mode) tr.inst-header td { border-bottom-color: #374151; } + :global(.dark-mode) .inst-key { color: #93c5fd; } + :global(.dark-mode) .inst-count { color: #6b7280; } + :global(.dark-mode) .inst-warn { background: #451a03; border-color: #78350f; color: #fbbf24; } + :global(.dark-mode) .panel { background: #1e293b; } :global(.dark-mode) .panel-footer { background: #162032; border-top-color: #334155; } :global(.dark-mode) .panel-score-row { background: #111827; } diff --git a/frontend/src/components/IocFeeds.svelte b/frontend/src/components/IocFeeds.svelte new file mode 100644 index 0000000..e82aa10 --- /dev/null +++ b/frontend/src/components/IocFeeds.svelte @@ -0,0 +1,415 @@ + + +
+ +
+
+

IOC Feeds

+

Automated threat intelligence — OTX, MalwareBazaar, ThreatFox

+
+
+ + +
+
+
+ {feeds.length} feed{feeds.length !== 1 ? 's' : ''} +
+
+ + +
+
+ + {#if feedsLoading && feeds.length === 0} +
Loading feed status…
+ {:else} +
+ {#each orderedFeeds as feed (feed.feedName)} + {@const meta = FEED_META[feed.feedName] ?? { label: feed.feedName, icon: '📡' }} +
+
+ {meta.icon} +
+
{meta.label}
+
{feed.feedName}
+
+ {statusLabel(feed.lastRunStatus)} +
+ +
+ {#if feed.configured} + Configured + {:else} + API key required + {/if} +
+ +
+
+ Last run + {relativeTime(feed.lastRunAt)} +
+
+ IOCs added + {feed.lastIocsAdded?.toLocaleString() ?? '—'} +
+
+ Updated + {feed.lastIocsUpdated?.toLocaleString() ?? '—'} +
+
+ + +
+ {/each} +
+ {/if} +
+ + +
+
+
+
+ {runsTotalElements.toLocaleString()} run{runsTotalElements !== 1 ? 's' : ''} +
+
+ + { runsPage = e.detail.page; loadRuns(runsPage); }} + /> + +
+ {#if runsLoading} +
Loading…
+ {:else if runs.length === 0} +
No feed runs yet. Click "Run All Feeds" to start.
+ {:else} + + + + + + + + + + + + + + {#each runs as run (run.id)} + + + + + + + + + + {/each} + +
FeedStartedDurationStatusAddedUpdatedError
+ + {FEED_META[run.feedName]?.icon ?? '📡'} + {FEED_META[run.feedName]?.label ?? run.feedName} + + {formatTs(run.startedAt)}{duration(run.startedAt, run.completedAt)}{statusLabel(run.status)}{run.iocsAdded.toLocaleString()}{run.iocsUpdated.toLocaleString()} + {#if run.errorMessage} + + {run.errorMessage.length > 60 ? run.errorMessage.substring(0, 60) + '…' : run.errorMessage} + + {:else} + + {/if} +
+ {/if} +
+
+
+ +
+ + diff --git a/frontend/src/components/IocManagement.svelte b/frontend/src/components/IocManagement.svelte index 57f7b3b..70cffbe 100644 --- a/frontend/src/components/IocManagement.svelte +++ b/frontend/src/components/IocManagement.svelte @@ -6,7 +6,28 @@ let iocs: ThreatIoc[] = []; let loading = false; let showInactive = false; - let typeFilter: IocType | '' = ''; + + // Filter sidebar + let filterSidebarCollapsed = true; + + // Multi-type filter — client-side + let selectedTypes = new Set(); + + $: hasActiveFilters = selectedTypes.size > 0; + + $: filteredIocs = selectedTypes.size > 0 + ? iocs.filter(i => selectedTypes.has(i.iocType)) + : iocs; + + function toggleTypeFilter(t: IocType) { + if (selectedTypes.has(t)) selectedTypes.delete(t); + else selectedTypes.add(t); + selectedTypes = selectedTypes; + } + + function clearAllFilters() { + selectedTypes = new Set(); + } let showPanel = false; let editingIoc: ThreatIoc | null = null; @@ -45,7 +66,6 @@ loading = true; try { const res = await ariaApi.getIocs({ - type: typeFilter || undefined, activeOnly: !showInactive, }); iocs = res.data; @@ -125,7 +145,8 @@ onMount(load); -
+
+

IOC Management

@@ -133,84 +154,141 @@
-
-
-
- {iocs.length} IOC{iocs.length !== 1 ? 's' : ''} +
-
- + +
+
+ {#if !filterSidebarCollapsed} + Filters +
+ {filteredIocs.length} + +
+ {:else} + + {/if} +
+ + {#if !filterSidebarCollapsed} +
+ {#if hasActiveFilters} + + {/if} + +
+ +
+ {#each IOC_TYPES as t} + + {/each} +
+
+ +
+ + +
- - -
-
- -
+ {/if}
-
- {#if loading} -
Loading…
- {:else if iocs.length === 0} -
No IOCs found.
- {:else} - - - - - - - - - - - - - - - - {#each iocs as ioc (ioc.id)} - - - - - - - - - - - - {/each} - -
TypeValueDescriptionSourceConfidenceAddedExpiresStatus
- {ioc.iocType.replace(/_/g,' ')} - - {ioc.value} - {ioc.description ?? '—'}{SRC_LABEL[ioc.source] ?? ioc.source} - {ioc.confidence} - {fmtDate(ioc.addedAt)}{fmtDate(ioc.expiresAt)} - {#if ioc.active} - Active - {:else} - Inactive - {/if} - - - {#if ioc.active} - - {/if} -
+ +
+ + {#if hasActiveFilters} +
+ Filters: + {#each [...selectedTypes] as t} + + + {t.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())} + + + {/each} +
{/if} + +
+
+ +
+ {filteredIocs.length} IOC{filteredIocs.length !== 1 ? 's' : ''} +
+ +
+
+ +
+
+ {#if loading} +
Loading…
+ {:else if filteredIocs.length === 0} +
{hasActiveFilters ? 'No IOCs match the selected filters.' : 'No IOCs found.'}
+ {:else} + + + + + + + + + + + + + + + + {#each filteredIocs as ioc (ioc.id)} + + + + + + + + + + + + {/each} + +
TypeValueDescriptionSourceConfidenceAddedExpiresStatus
+ {ioc.iocType.replace(/_/g,' ')} + + {ioc.value} + {ioc.description ?? '—'}{SRC_LABEL[ioc.source] ?? ioc.source} + {ioc.confidence} + {fmtDate(ioc.addedAt)}{fmtDate(ioc.expiresAt)} + {#if ioc.active} + Active + {:else} + Inactive + {/if} + + + {#if ioc.active} + + {/if} +
+ {/if} +
+
+ +
+
@@ -279,41 +357,74 @@ {/if} diff --git a/frontend/src/components/PrecursorAlerts.svelte b/frontend/src/components/PrecursorAlerts.svelte index 536ffd8..f5d99a3 100644 --- a/frontend/src/components/PrecursorAlerts.svelte +++ b/frontend/src/components/PrecursorAlerts.svelte @@ -14,7 +14,7 @@ let resolving: number | null = null; // Filter sidebar - let filterSidebarCollapsed = false; + let filterSidebarCollapsed = true; let filterStatus: 'active' | 'resolved' | 'all' = 'active'; let filterType: SequenceType | '' = ''; let filterDevice = ''; @@ -196,7 +196,7 @@
-
+
{#if !filterSidebarCollapsed} Filters
@@ -467,6 +467,8 @@ flex-direction: column; overflow: hidden; min-height: 0; + padding: 0.65rem; + gap: 0.5rem; } .content-frame { @@ -477,8 +479,9 @@ display: flex; flex-direction: column; min-height: 0; - margin-top: 8px; background: white; + padding: 1rem; + gap: 0.75rem; } /* Filter sidebar — canonical from template */ @@ -500,8 +503,6 @@ background: #eef2f7; flex-shrink: 0; min-height: 36px; } - .bar-filters-active { background: #dbeafe !important; border-bottom-color: #93c5fd !important; } - .bar-filters-active .filter-sidebar-title { color: #1e40af !important; } .toggle-bar-right { display: flex; align-items: center; gap: 0.35rem; margin-left: auto; } .filter-sidebar-title { font-size: var(--font-size-label); font-weight: 700; color: #374151; text-transform: uppercase; letter-spacing: 0.5px; } .sidebar-stat-pill { font-size: var(--font-size-tiny); background: #e0e7ff; border: 1px solid #c7d2fe; color: #3730a3; border-radius: 10px; padding: 1px 8px; white-space: nowrap; } @@ -523,7 +524,7 @@ /* Active filter chips */ .active-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; - margin-bottom: 8px; padding: 0.5rem 0.75rem; flex-shrink: 0; + padding: 0.4rem 0; flex-shrink: 0; } .active-filters-label { font-size: var(--font-size-tiny); font-weight: 600; color: #555; margin-right: 0.25rem; } .filter-tag { @@ -543,10 +544,10 @@ /* Toolbar */ .page-main { - flex: 1; overflow-y: auto; padding: 16px 24px 24px; + flex: 1; overflow-y: auto; min-width: 0; display: flex; flex-direction: column; } - .toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; flex-shrink: 0; } + .toolbar { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; } .toolbar-count { font-size: var(--font-size-body-sm); color: #6b7280; } .header-controls { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; } @@ -689,8 +690,6 @@ :global(.dark-mode) .content-frame { border-color: #374151; background: #1f2937; } :global(.dark-mode) .filter-sidebar { background: #1f2937; border-right-color: #374151; } :global(.dark-mode) .filter-sidebar-toggle-bar { background: #111827; border-bottom-color: #374151; } - :global(.dark-mode) .bar-filters-active { background: #1d4ed8 !important; border-bottom-color: #60a5fa !important; } - :global(.dark-mode) .bar-filters-active .filter-sidebar-title { color: #bfdbfe !important; } :global(.dark-mode) .filter-sidebar-title { color: #9ca3af; } :global(.dark-mode) .sidebar-toggle-btn { border-color: #4b5563; color: #9ca3af; } :global(.dark-mode) .sidebar-toggle-btn:hover { background: #374151; } diff --git a/frontend/src/components/ThreatEvents.svelte b/frontend/src/components/ThreatEvents.svelte index a71e82d..7bc0cfb 100644 --- a/frontend/src/components/ThreatEvents.svelte +++ b/frontend/src/components/ThreatEvents.svelte @@ -16,7 +16,7 @@ let severityFilter: ThreatSeverity | '' = initialSeverity; let deviceSearch = ''; let deviceSearchInput = ''; - let filterSidebarCollapsed = false; + let filterSidebarCollapsed = true; let searchTimer: ReturnType; $: hasActiveFilters = !!severityFilter || !!deviceSearch; @@ -121,7 +121,7 @@ onDestroy(() => clearInterval(tickInterval)); -
+

Threat Events

@@ -160,7 +160,7 @@
-
+
{#if !filterSidebarCollapsed} Filters
@@ -203,19 +203,19 @@
-
+
{#if hasActiveFilters}
Filters: {#if severityFilter} - + Severity: {severityFilter} {/if} {#if deviceSearch} - + Device: {deviceSearch} @@ -224,52 +224,58 @@
{/if} -
- { page = e.detail.page; load(); }} - on:pageSizeChange={e => { size = e.detail.size; page = 0; load(); }} /> -
- {#if loading} -
Loading…
- {:else if events.length === 0} -
No threat events found{hasActiveFilters ? ' matching current filters' : ''}.
- {:else} - - - - - - - - - - - - - {#each events as ev (ev.id)} +
+
+ {totalElements} event{totalElements !== 1 ? 's' : ''} +
+
+
+ { page = e.detail.page; load(); }} + on:pageSizeChange={e => { size = e.detail.size; page = 0; load(); }} /> +
+ {#if loading} +
Loading…
+ {:else if events.length === 0} +
No threat events found{hasActiveFilters ? ' matching current filters' : ''}.
+ {:else} +
SeveritySignalDeviceAttack PhaseIOC MatchDetected
+ - - - - - - + + + + + + - {/each} - -
- - {ev.severity} - - {fmtSignal(ev.signalKey)}{ev.deviceAgentId ?? '—'}{ev.attackPhase ? PHASE_LABEL[ev.attackPhase] ?? ev.attackPhase : '—'} - {#if ev.matchedIoc} - {ev.matchedIoc.iocType} - {ev.matchedIoc.value} - {:else} - - {/if} - {fmt(ev.detectedAt)}SeveritySignalDeviceAttack PhaseIOC MatchDetected
- {/if} + + + {#each events as ev (ev.id)} + + + + {ev.severity} + + + {fmtSignal(ev.signalKey)} + {ev.deviceAgentId ?? '—'} + {ev.attackPhase ? PHASE_LABEL[ev.attackPhase] ?? ev.attackPhase : '—'} + + {#if ev.matchedIoc} + {ev.matchedIoc.iocType} + {ev.matchedIoc.value} + {:else} + + {/if} + + {fmt(ev.detectedAt)} + + {/each} + + + {/if} +
@@ -277,7 +283,7 @@
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a3b6ad1..c68e460 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -29,7 +29,7 @@ export type ThreatSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'INFO'; export type EventType = 'PHYSICAL' | 'OS_EVENT' | 'FILE_SYSTEM' | 'COMPOSITE'; export type AttackPhase = 'PHYSICAL_ACCESS' | 'MALWARE_STAGING' | 'MALWARE_EXECUTION' | 'PERSISTENCE' | 'CLEANUP'; export type IocType = 'FILENAME' | 'MD5' | 'REGISTRY_KEY' | 'DIRECTORY' | 'SERVICE_NAME' | 'IP' | 'FILE_PATH'; -export type IocSource = 'FBI_FLASH' | 'FS_ISAC' | 'MANUAL'; +export type IocSource = 'FBI_FLASH' | 'FS_ISAC' | 'MANUAL' | 'OTX' | 'MALWARE_BAZAAR' | 'THREAT_FOX'; export type ConfidenceLevel = 'HIGH' | 'MEDIUM' | 'LOW'; // ── Entities ───────────────────────────────────────────────────────── @@ -115,6 +115,27 @@ export interface PageResponse { }; } +export type IocFeedStatus = { + feedName: string; + enabled: boolean; + configured: boolean; + lastRunAt?: string; + lastRunStatus?: string; + lastIocsAdded?: number; + lastIocsUpdated?: number; +}; + +export type IocFeedRun = { + id: number; + feedName: string; + startedAt: string; + completedAt?: string; + status: string; + iocsAdded: number; + iocsUpdated: number; + errorMessage?: string; +}; + export interface CreateIocRequest { iocType: IocType; value: string; @@ -158,4 +179,16 @@ export const ariaApi = { getPosture: (deviceId: number) => api.get(`/api/aria/posture/${deviceId}`), + + getFeedStatus: () => + api.get('/api/aria/feeds/status'), + + getFeedRuns: (params?: { page?: number; size?: number }) => + api.get>('/api/aria/feeds/runs', { params }), + + refreshAllFeeds: () => + api.post<{ message: string }>('/api/aria/feeds/refresh'), + + refreshFeed: (feedName: string) => + api.post<{ message: string }>(`/api/aria/feeds/refresh/${feedName}`), }; diff --git a/src/main/java/com/hiveops/aria/controller/IocFeedController.java b/src/main/java/com/hiveops/aria/controller/IocFeedController.java new file mode 100644 index 0000000..f29d6a5 --- /dev/null +++ b/src/main/java/com/hiveops/aria/controller/IocFeedController.java @@ -0,0 +1,84 @@ +package com.hiveops.aria.controller; + +import com.hiveops.aria.entity.IocFeedRun; +import com.hiveops.aria.repository.IocFeedRunRepository; +import com.hiveops.aria.service.IocFeedService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +@RestController +@RequestMapping("/api/aria/feeds") +@RequiredArgsConstructor +@Slf4j +public class IocFeedController { + + private final IocFeedService feedService; + private final IocFeedRunRepository feedRunRepository; + + private Thread feedThread(String name, Runnable task) { + Thread t = new Thread(task, name); + t.setUncaughtExceptionHandler((thread, ex) -> + log.error("IOC feed thread {} failed: {}", thread.getName(), ex.getMessage(), ex)); + return t; + } + + /** One status object per feed — array, matches IocFeedStatus[] in the frontend */ + @GetMapping("/status") + @PreAuthorize("hasRole('BCOS_ADMIN')") + public ResponseEntity>> status() { + List> result = new ArrayList<>(); + for (String name : feedService.feedNames()) { + Optional last = feedRunRepository.findTopByFeedNameOrderByStartedAtDesc(name); + Map entry = new LinkedHashMap<>(); + entry.put("feedName", name); + entry.put("configured", feedService.isConfigured(name)); + entry.put("enabled", true); + last.ifPresent(r -> { + entry.put("lastRunAt", r.getCompletedAt() != null ? r.getCompletedAt() : r.getStartedAt()); + entry.put("lastRunStatus", r.getStatus()); + entry.put("lastIocsAdded", r.getIocsAdded()); + entry.put("lastIocsUpdated", r.getIocsUpdated()); + }); + result.add(entry); + } + return ResponseEntity.ok(result); + } + + /** Paginated run history — returns Spring Page so the frontend gets content + page metadata */ + @GetMapping("/runs") + @PreAuthorize("hasRole('BCOS_ADMIN')") + public ResponseEntity> runs( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok( + feedRunRepository.findAllByOrderByStartedAtDesc(PageRequest.of(page, size)) + ); + } + + /** Trigger all feeds immediately */ + @PostMapping("/refresh") + @PreAuthorize("hasRole('BCOS_ADMIN')") + public ResponseEntity> refreshAll() { + feedThread("ioc-feed-manual-all", feedService::refreshAll).start(); + return ResponseEntity.accepted().body(Map.of("status", "refresh_started")); + } + + /** Trigger one specific feed */ + @PostMapping("/refresh/{feedName}") + @PreAuthorize("hasRole('BCOS_ADMIN')") + public ResponseEntity> refreshOne(@PathVariable String feedName) { + String upper = feedName.toUpperCase(); + if (!feedService.feedNames().contains(upper)) { + return ResponseEntity.badRequest().body(Map.of("error", "Unknown feed: " + feedName)); + } + feedThread("ioc-feed-manual-" + upper, () -> feedService.refreshFeed(upper)).start(); + return ResponseEntity.accepted().body(Map.of("status", "refresh_started", "feed", upper)); + } +} diff --git a/src/main/java/com/hiveops/aria/controller/ThreatEventController.java b/src/main/java/com/hiveops/aria/controller/ThreatEventController.java index aad00d8..ce2fc36 100644 --- a/src/main/java/com/hiveops/aria/controller/ThreatEventController.java +++ b/src/main/java/com/hiveops/aria/controller/ThreatEventController.java @@ -16,6 +16,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Map; @RestController @@ -50,9 +52,10 @@ public class ThreatEventController { @GetMapping("/stats") @PreAuthorize("hasRole('BCOS_ADMIN')") public ResponseEntity> getStats() { + Instant since24h = Instant.now().minus(24, ChronoUnit.HOURS); long totalEvents = eventRepository.count(); - long critical24h = eventRepository.countBySeverity(ThreatEvent.ThreatSeverity.CRITICAL); - long high24h = eventRepository.countBySeverity(ThreatEvent.ThreatSeverity.HIGH); + long critical24h = eventRepository.countBySeverityAndDetectedAtAfter(ThreatEvent.ThreatSeverity.CRITICAL, since24h); + long high24h = eventRepository.countBySeverityAndDetectedAtAfter(ThreatEvent.ThreatSeverity.HIGH, since24h); long activeIocs = iocRepository.findByActiveTrue().size(); long activeSequences = sequenceAlertRepository.countByResolvedAtIsNull(); diff --git a/src/main/java/com/hiveops/aria/controller/ThreatIocController.java b/src/main/java/com/hiveops/aria/controller/ThreatIocController.java index 70ba66d..955b5d0 100644 --- a/src/main/java/com/hiveops/aria/controller/ThreatIocController.java +++ b/src/main/java/com/hiveops/aria/controller/ThreatIocController.java @@ -52,6 +52,7 @@ public class ThreatIocController { .source(ThreatIoc.IocSource.MANUAL) .sourceRef(request.getSourceRef()) .confidence(request.getConfidence() != null ? request.getConfidence() : ThreatIoc.ConfidenceLevel.MEDIUM) + .reportedAt(request.getReportedAt()) .expiresAt(request.getExpiresAt()) .build(); return ResponseEntity.ok(iocRepository.save(ioc)); @@ -88,6 +89,7 @@ public class ThreatIocController { private String description; private String sourceRef; private ThreatIoc.ConfidenceLevel confidence; + private Instant reportedAt; private Instant expiresAt; } } diff --git a/src/main/java/com/hiveops/aria/entity/IocFeedRun.java b/src/main/java/com/hiveops/aria/entity/IocFeedRun.java new file mode 100644 index 0000000..c6bcf30 --- /dev/null +++ b/src/main/java/com/hiveops/aria/entity/IocFeedRun.java @@ -0,0 +1,45 @@ +package com.hiveops.aria.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table(name = "ioc_feed_run") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class IocFeedRun { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "feed_name", nullable = false) + private String feedName; + + @Column(name = "started_at", nullable = false) + @Builder.Default + private Instant startedAt = Instant.now(); + + @Column(name = "completed_at") + private Instant completedAt; + + @Column(nullable = false) + @Builder.Default + private String status = "RUNNING"; + + @Column(name = "iocs_added", nullable = false) + @Builder.Default + private int iocsAdded = 0; + + @Column(name = "iocs_updated", nullable = false) + @Builder.Default + private int iocsUpdated = 0; + + @Column(name = "error_message") + private String errorMessage; +} diff --git a/src/main/java/com/hiveops/aria/entity/ThreatIoc.java b/src/main/java/com/hiveops/aria/entity/ThreatIoc.java index d8be8fe..737654e 100644 --- a/src/main/java/com/hiveops/aria/entity/ThreatIoc.java +++ b/src/main/java/com/hiveops/aria/entity/ThreatIoc.java @@ -40,6 +40,9 @@ public class ThreatIoc { @Builder.Default private ConfidenceLevel confidence = ConfidenceLevel.HIGH; + @Column(name = "reported_at") + private Instant reportedAt; + @Column(name = "added_at", nullable = false) @Builder.Default private Instant addedAt = Instant.now(); @@ -56,7 +59,7 @@ public class ThreatIoc { } public enum IocSource { - FBI_FLASH, FS_ISAC, MANUAL + FBI_FLASH, FS_ISAC, MANUAL, OTX, MALWARE_BAZAAR, THREAT_FOX } public enum ConfidenceLevel { diff --git a/src/main/java/com/hiveops/aria/feed/MalwareBazaarFeedClient.java b/src/main/java/com/hiveops/aria/feed/MalwareBazaarFeedClient.java new file mode 100644 index 0000000..1960c47 --- /dev/null +++ b/src/main/java/com/hiveops/aria/feed/MalwareBazaarFeedClient.java @@ -0,0 +1,123 @@ +package com.hiveops.aria.feed; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hiveops.aria.entity.ThreatIoc; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +/** + * Fetches ATM-related malware samples from MalwareBazaar (abuse.ch). + * Requires an API key from https://auth.abuse.ch/ + */ +@Component +@Slf4j +public class MalwareBazaarFeedClient { + + private static final String API_URL = "https://mb-api.abuse.ch/api/v1/"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Value("${aria.feed.malware-bazaar.api-key:}") + private String apiKey; + + private static final DateTimeFormatter MB_DATE_FMT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(ZoneOffset.UTC); + + private final RestTemplate restTemplate = new RestTemplate(); + + public boolean isConfigured() { + return apiKey != null && !apiKey.isBlank(); + } + + public List fetch() { + if (!isConfigured()) { + log.info("MalwareBazaar: no API key configured, skipping"); + return List.of(); + } + List result = new ArrayList<>(); + + // Query recent samples tagged with ATM-related tags + for (String tag : List.of("ATM", "jackpotting", "NCR", "Diebold", "GreenDispenser", "Tyupkin")) { + result.addAll(queryByTag(tag)); + } + + log.info("MalwareBazaar: fetched {} raw indicators", result.size()); + return result; + } + + private List queryByTag(String tag) { + List out = new ArrayList<>(); + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.set("Auth-Key", apiKey); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("query", "get_taginfo"); + body.add("tag", tag); + body.add("limit", "100"); + + HttpEntity> entity = new HttpEntity<>(body, headers); + String response = restTemplate.postForObject(API_URL, entity, String.class); + + JsonNode root = MAPPER.readTree(response); + if (!"ok".equals(root.path("query_status").asText())) return out; + + for (JsonNode sample : root.path("data")) { + String sha256 = sample.path("sha256_hash").asText("").trim(); + String md5 = sample.path("md5_hash").asText("").trim(); + String name = sample.path("file_name").asText("").trim(); + String sigName = sample.path("signature").asText("Unknown"); + + Instant reportedAt = null; + String firstSeen = sample.path("first_seen").asText(""); + if (!firstSeen.isBlank()) { + try { reportedAt = MB_DATE_FMT.parse(firstSeen, Instant::from); } catch (DateTimeParseException ignored) {} + } + + if (!md5.isBlank()) { + out.add(ThreatIoc.builder() + .iocType(ThreatIoc.IocType.MD5) + .value(md5) + .description("MalwareBazaar tag:" + tag + " sig:" + sigName) + .source(ThreatIoc.IocSource.MALWARE_BAZAAR) + .sourceRef(sha256.isBlank() ? tag : sha256) + .confidence(ThreatIoc.ConfidenceLevel.HIGH) + .reportedAt(reportedAt) + .expiresAt(Instant.now().plus(365, ChronoUnit.DAYS)) + .build()); + } + if (!name.isBlank()) { + out.add(ThreatIoc.builder() + .iocType(ThreatIoc.IocType.FILENAME) + .value(name) + .description("MalwareBazaar tag:" + tag + " sig:" + sigName) + .source(ThreatIoc.IocSource.MALWARE_BAZAAR) + .sourceRef(sha256.isBlank() ? tag : sha256) + .confidence(ThreatIoc.ConfidenceLevel.MEDIUM) + .reportedAt(reportedAt) + .expiresAt(Instant.now().plus(365, ChronoUnit.DAYS)) + .build()); + } + } + } catch (Exception e) { + log.warn("MalwareBazaar: error querying tag {}: {}", tag, e.getMessage()); + } + return out; + } +} diff --git a/src/main/java/com/hiveops/aria/feed/OtxFeedClient.java b/src/main/java/com/hiveops/aria/feed/OtxFeedClient.java new file mode 100644 index 0000000..6ef4370 --- /dev/null +++ b/src/main/java/com/hiveops/aria/feed/OtxFeedClient.java @@ -0,0 +1,112 @@ +package com.hiveops.aria.feed; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hiveops.aria.entity.ThreatIoc; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +@Component +@Slf4j +public class OtxFeedClient { + + private static final String BASE_URL = "https://otx.alienvault.com/api/v1"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Value("${aria.feed.otx.api-key:}") + private String apiKey; + + private final RestTemplate restTemplate = new RestTemplate(); + + public boolean isConfigured() { + return apiKey != null && !apiKey.isBlank(); + } + + public List fetch() { + if (!isConfigured()) { + log.info("OTX: no API key configured, skipping"); + return List.of(); + } + + List result = new ArrayList<>(); + String since = Instant.now().minus(8, ChronoUnit.DAYS).toString(); + + // Subscribed pulses modified in the last 8 days + fetchPulses(BASE_URL + "/pulses/subscribed?modified_since=" + since + "&limit=200", result); + + // ATM-specific keyword searches + for (String query : List.of("atm+malware", "jackpotting", "atm+skimming")) { + fetchPulses(BASE_URL + "/search/pulses?q=" + query + "&limit=10", result); + } + + log.info("OTX: fetched {} raw indicators", result.size()); + return result; + } + + private void fetchPulses(String url, List out) { + try { + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.set("X-OTX-API-KEY", apiKey); + org.springframework.http.HttpEntity entity = new org.springframework.http.HttpEntity<>(headers); + + org.springframework.http.ResponseEntity response = + restTemplate.exchange(URI.create(url), + org.springframework.http.HttpMethod.GET, entity, String.class); + + JsonNode root = MAPPER.readTree(response.getBody()); + JsonNode results = root.path("results"); + if (results.isMissingNode()) results = root.path("pulse"); + + for (JsonNode pulse : results) { + JsonNode indicators = pulse.path("indicators"); + for (JsonNode ind : indicators) { + ThreatIoc ioc = mapIndicator(ind, pulse.path("name").asText("")); + if (ioc != null) out.add(ioc); + } + } + } catch (Exception e) { + log.warn("OTX: error fetching {}: {}", url, e.getMessage()); + } + } + + private ThreatIoc mapIndicator(JsonNode ind, String pulseName) { + String type = ind.path("type").asText(""); + String value = ind.path("indicator").asText("").trim(); + if (value.isBlank()) return null; + + ThreatIoc.IocType iocType = switch (type) { + case "FileHash-MD5" -> ThreatIoc.IocType.MD5; + case "FilePath" -> ThreatIoc.IocType.FILE_PATH; + case "IPv4" -> ThreatIoc.IocType.IP; + case "Win-Registry-Key" -> ThreatIoc.IocType.REGISTRY_KEY; + default -> null; + }; + if (iocType == null) return null; + + Instant reportedAt = null; + String created = ind.path("created").asText(""); + if (!created.isBlank()) { + try { reportedAt = Instant.parse(created); } catch (DateTimeParseException ignored) {} + } + + return ThreatIoc.builder() + .iocType(iocType) + .value(value) + .description("OTX pulse: " + pulseName) + .source(ThreatIoc.IocSource.OTX) + .sourceRef(pulseName) + .confidence(ThreatIoc.ConfidenceLevel.MEDIUM) + .reportedAt(reportedAt) + .expiresAt(Instant.now().plus(90, ChronoUnit.DAYS)) + .build(); + } +} diff --git a/src/main/java/com/hiveops/aria/feed/ThreatFoxFeedClient.java b/src/main/java/com/hiveops/aria/feed/ThreatFoxFeedClient.java new file mode 100644 index 0000000..d30a484 --- /dev/null +++ b/src/main/java/com/hiveops/aria/feed/ThreatFoxFeedClient.java @@ -0,0 +1,187 @@ +package com.hiveops.aria.feed; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hiveops.aria.entity.ThreatIoc; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Fetches ATM-related IOCs from ThreatFox (abuse.ch). + * Requires an API key from https://auth.abuse.ch/ + */ +@Component +@Slf4j +public class ThreatFoxFeedClient { + + private static final String API_URL = "https://threatfox-api.abuse.ch/api/v1/"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Value("${aria.feed.threat-fox.api-key:}") + private String apiKey; + + private static final DateTimeFormatter TF_DATE_FMT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(ZoneOffset.UTC); + + private final RestTemplate restTemplate = new RestTemplate(); + + public boolean isConfigured() { + return apiKey != null && !apiKey.isBlank(); + } + + public List fetch() { + if (!isConfigured()) { + log.info("ThreatFox: no API key configured, skipping"); + return List.of(); + } + List result = new ArrayList<>(); + + // Search by ATM-specific malware families + for (String malware : List.of("ATMii", "GreenDispenser", "Tyupkin", "Ploutus", "SUCEFUL", "Ripper")) { + result.addAll(queryByMalware(malware)); + } + + // Recent IOCs from last 7 days — filter for ATM relevance in caller + result.addAll(queryRecent()); + + log.info("ThreatFox: fetched {} raw indicators", result.size()); + return result; + } + + private List queryByMalware(String malware) { + List out = new ArrayList<>(); + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Auth-Key", apiKey); + + String body = MAPPER.writeValueAsString(Map.of( + "query", "iocs_by_malware_family", + "malware_family", malware, + "limit", 100 + )); + + HttpEntity entity = new HttpEntity<>(body, headers); + String response = restTemplate.postForObject(API_URL, entity, String.class); + + out.addAll(parseResponse(response, malware)); + } catch (Exception e) { + log.warn("ThreatFox: error querying malware {}: {}", malware, e.getMessage()); + } + return out; + } + + private List queryRecent() { + List out = new ArrayList<>(); + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Auth-Key", apiKey); + + String body = MAPPER.writeValueAsString(Map.of( + "query", "get_iocs", + "days", 7 + )); + + HttpEntity entity = new HttpEntity<>(body, headers); + String response = restTemplate.postForObject(API_URL, entity, String.class); + + JsonNode root = MAPPER.readTree(response); + if (!"ok".equals(root.path("query_status").asText())) return out; + + for (JsonNode ioc : root.path("data")) { + // Only include if tagged or malware family suggests ATM relevance + String malwareFamily = ioc.path("malware").asText("").toLowerCase(); + boolean atmRelated = malwareFamily.contains("atm") || malwareFamily.contains("diebold") + || malwareFamily.contains("ncr") || malwareFamily.contains("ploutus") + || malwareFamily.contains("tyupkin") || malwareFamily.contains("dispenser"); + if (!atmRelated) continue; + + ThreatIoc mapped = mapIoc(ioc, malwareFamily); + if (mapped != null) out.add(mapped); + } + } catch (Exception e) { + log.warn("ThreatFox: error querying recent IOCs: {}", e.getMessage()); + } + return out; + } + + private List parseResponse(String response, String malware) { + List out = new ArrayList<>(); + try { + JsonNode root = MAPPER.readTree(response); + if (!"ok".equals(root.path("query_status").asText())) return out; + for (JsonNode ioc : root.path("data")) { + ThreatIoc mapped = mapIoc(ioc, malware); + if (mapped != null) out.add(mapped); + } + } catch (Exception e) { + log.warn("ThreatFox: parse error for {}: {}", malware, e.getMessage()); + } + return out; + } + + private ThreatIoc mapIoc(JsonNode ioc, String malware) { + String iocType = ioc.path("ioc_type").asText("").toLowerCase(); + String value = ioc.path("ioc").asText("").trim(); + if (value.isBlank()) return null; + + ThreatIoc.IocType type = switch (iocType) { + case "md5_hash" -> ThreatIoc.IocType.MD5; + case "ip:port", "ip" -> { + // Strip port if present + String ip = value.contains(":") ? value.substring(0, value.lastIndexOf(':')) : value; + yield ThreatIoc.IocType.IP; + } + case "filepath" -> ThreatIoc.IocType.FILE_PATH; + case "filename" -> ThreatIoc.IocType.FILENAME; + default -> null; + }; + if (type == null) return null; + + // For IP:port, strip the port + if ("ip:port".equals(iocType) && value.contains(":")) { + value = value.substring(0, value.lastIndexOf(':')); + } + + String iocId = ioc.path("id").asText(malware); + String confidence = ioc.path("confidence_level").asText("50"); + int confidenceInt = 50; + try { confidenceInt = Integer.parseInt(confidence); } catch (NumberFormatException ignored) {} + + ThreatIoc.ConfidenceLevel level = confidenceInt >= 75 + ? ThreatIoc.ConfidenceLevel.HIGH + : confidenceInt >= 50 ? ThreatIoc.ConfidenceLevel.MEDIUM : ThreatIoc.ConfidenceLevel.LOW; + + Instant reportedAt = null; + String firstSeen = ioc.path("first_seen").asText(""); + if (!firstSeen.isBlank()) { + try { reportedAt = TF_DATE_FMT.parse(firstSeen, Instant::from); } catch (DateTimeParseException ignored) {} + } + + return ThreatIoc.builder() + .iocType(type) + .value(value) + .description("ThreatFox malware:" + malware) + .source(ThreatIoc.IocSource.THREAT_FOX) + .sourceRef(iocId) + .confidence(level) + .reportedAt(reportedAt) + .expiresAt(Instant.now().plus(180, ChronoUnit.DAYS)) + .build(); + } +} diff --git a/src/main/java/com/hiveops/aria/repository/IocFeedRunRepository.java b/src/main/java/com/hiveops/aria/repository/IocFeedRunRepository.java new file mode 100644 index 0000000..ca6fd43 --- /dev/null +++ b/src/main/java/com/hiveops/aria/repository/IocFeedRunRepository.java @@ -0,0 +1,20 @@ +package com.hiveops.aria.repository; + +import com.hiveops.aria.entity.IocFeedRun; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface IocFeedRunRepository extends JpaRepository { + + Page findAllByOrderByStartedAtDesc(Pageable pageable); + + Optional findTopByFeedNameOrderByStartedAtDesc(String feedName); + + @Query("SELECT COUNT(r) FROM IocFeedRun r WHERE r.feedName = :feedName AND r.status = 'RUNNING'") + long countRunning(@Param("feedName") String feedName); +} diff --git a/src/main/java/com/hiveops/aria/repository/ThreatEventRepository.java b/src/main/java/com/hiveops/aria/repository/ThreatEventRepository.java index 8087277..508ea8f 100644 --- a/src/main/java/com/hiveops/aria/repository/ThreatEventRepository.java +++ b/src/main/java/com/hiveops/aria/repository/ThreatEventRepository.java @@ -22,7 +22,7 @@ public interface ThreatEventRepository extends JpaRepository Page findBySeverityOrderByDetectedAtDesc(ThreatEvent.ThreatSeverity severity, Pageable pageable); - long countBySeverity(ThreatEvent.ThreatSeverity severity); + long countBySeverityAndDetectedAtAfter(ThreatEvent.ThreatSeverity severity, Instant since); Page findAllByOrderByDetectedAtDesc(Pageable pageable); } diff --git a/src/main/java/com/hiveops/aria/service/IocFeedService.java b/src/main/java/com/hiveops/aria/service/IocFeedService.java new file mode 100644 index 0000000..5da9f95 --- /dev/null +++ b/src/main/java/com/hiveops/aria/service/IocFeedService.java @@ -0,0 +1,129 @@ +package com.hiveops.aria.service; + +import com.hiveops.aria.entity.IocFeedRun; +import com.hiveops.aria.entity.ThreatIoc; +import com.hiveops.aria.feed.MalwareBazaarFeedClient; +import com.hiveops.aria.feed.OtxFeedClient; +import com.hiveops.aria.feed.ThreatFoxFeedClient; +import com.hiveops.aria.repository.IocFeedRunRepository; +import com.hiveops.aria.repository.ThreatIocRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class IocFeedService { + + private final OtxFeedClient otxClient; + private final MalwareBazaarFeedClient malwareBazaarClient; + private final ThreatFoxFeedClient threatFoxClient; + private final ThreatIocRepository iocRepository; + private final IocFeedRunRepository feedRunRepository; + + private static final Map FEED_SOURCES = Map.of( + "OTX", ThreatIoc.IocSource.OTX, + "MALWARE_BAZAAR", ThreatIoc.IocSource.MALWARE_BAZAAR, + "THREAT_FOX", ThreatIoc.IocSource.THREAT_FOX + ); + + @Scheduled(cron = "${aria.feed.refresh.cron:0 0 3 * * *}") + public void scheduledRefresh() { + log.info("IOC feed scheduled refresh starting"); + refreshAll(); + } + + public void refreshAll() { + for (String feedName : FEED_SOURCES.keySet()) { + try { + refreshFeed(feedName); + } catch (Exception e) { + log.error("IOC feed {} failed: {}", feedName, e.getMessage(), e); + } + } + } + + @Transactional + public IocFeedRun refreshFeed(String feedName) { + if (feedRunRepository.countRunning(feedName) > 0) { + log.info("IOC feed {} already running, skipping", feedName); + return feedRunRepository.findTopByFeedNameOrderByStartedAtDesc(feedName).orElseThrow(); + } + + IocFeedRun run = feedRunRepository.save(IocFeedRun.builder() + .feedName(feedName) + .startedAt(Instant.now()) + .build()); + + try { + List fetched = fetchFromFeed(feedName); + int[] counts = upsertIocs(fetched); + + run.setStatus("SUCCESS"); + run.setIocsAdded(counts[0]); + run.setIocsUpdated(counts[1]); + run.setCompletedAt(Instant.now()); + log.info("IOC feed {} complete: {} added, {} updated", feedName, counts[0], counts[1]); + } catch (Exception e) { + run.setStatus("ERROR"); + run.setErrorMessage(e.getMessage()); + run.setCompletedAt(Instant.now()); + log.error("IOC feed {} error: {}", feedName, e.getMessage(), e); + } + + return feedRunRepository.save(run); + } + + private List fetchFromFeed(String feedName) { + return switch (feedName) { + case "OTX" -> otxClient.fetch(); + case "MALWARE_BAZAAR" -> malwareBazaarClient.fetch(); + case "THREAT_FOX" -> threatFoxClient.fetch(); + default -> throw new IllegalArgumentException("Unknown feed: " + feedName); + }; + } + + private int[] upsertIocs(List iocs) { + int added = 0, updated = 0; + for (ThreatIoc ioc : iocs) { + if (ioc.getValue() == null || ioc.getValue().isBlank()) continue; + Optional existing = iocRepository + .findByActiveTrueAndIocTypeAndValueIgnoreCase(ioc.getIocType(), ioc.getValue()); + if (existing.isPresent()) { + ThreatIoc e = existing.get(); + e.setDescription(ioc.getDescription()); + e.setSourceRef(ioc.getSourceRef()); + e.setConfidence(ioc.getConfidence()); + e.setReportedAt(ioc.getReportedAt()); + e.setExpiresAt(ioc.getExpiresAt()); + iocRepository.save(e); + updated++; + } else { + iocRepository.save(ioc); + added++; + } + } + return new int[]{ added, updated }; + } + + public List feedNames() { + return List.of("OTX", "MALWARE_BAZAAR", "THREAT_FOX"); + } + + public boolean isConfigured(String feedName) { + return switch (feedName) { + case "OTX" -> otxClient.isConfigured(); + case "MALWARE_BAZAAR" -> malwareBazaarClient.isConfigured(); + case "THREAT_FOX" -> threatFoxClient.isConfigured(); + default -> false; + }; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 276a367..a7ed365 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -58,6 +58,13 @@ aria.internal.service-secret=${ARIA_SERVICE_SECRET:} # Sequence detection window (minutes) aria.sequence.window.minutes=${ARIA_SEQUENCE_WINDOW_MINUTES:15} +# IOC Feed Refresh +aria.feed.enabled=${ARIA_FEED_ENABLED:true} +aria.feed.refresh.cron=${ARIA_FEED_REFRESH_CRON:0 0 3 * * *} +aria.feed.otx.api-key=${ARIA_FEED_OTX_API_KEY:} +aria.feed.malware-bazaar.api-key=${ARIA_FEED_MALWARE_BAZAAR_API_KEY:} +aria.feed.threat-fox.api-key=${ARIA_FEED_THREAT_FOX_API_KEY:} + # Auto-response — disabled by default for safe initial deployment aria.autoresponse.shutdown.enabled=${ARIA_AUTORESPONSE_SHUTDOWN_ENABLED:false} diff --git a/src/main/resources/db/migration/V6__ioc_feed_run_table.sql b/src/main/resources/db/migration/V6__ioc_feed_run_table.sql new file mode 100644 index 0000000..8a0efb4 --- /dev/null +++ b/src/main/resources/db/migration/V6__ioc_feed_run_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE ioc_feed_run ( + id BIGSERIAL PRIMARY KEY, + feed_name VARCHAR(50) NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + status VARCHAR(20) NOT NULL DEFAULT 'RUNNING', + iocs_added INT NOT NULL DEFAULT 0, + iocs_updated INT NOT NULL DEFAULT 0, + error_message TEXT +); + +CREATE INDEX idx_ioc_feed_run_feed_name ON ioc_feed_run (feed_name); +CREATE INDEX idx_ioc_feed_run_started_at ON ioc_feed_run (started_at DESC); diff --git a/src/main/resources/db/migration/V7__add_ioc_reported_at.sql b/src/main/resources/db/migration/V7__add_ioc_reported_at.sql new file mode 100644 index 0000000..6001785 --- /dev/null +++ b/src/main/resources/db/migration/V7__add_ioc_reported_at.sql @@ -0,0 +1 @@ +ALTER TABLE threat_ioc ADD COLUMN reported_at TIMESTAMPTZ; diff --git a/src/main/resources/db/migration/V8__widen_source_ref_to_text.sql b/src/main/resources/db/migration/V8__widen_source_ref_to_text.sql new file mode 100644 index 0000000..281db0d --- /dev/null +++ b/src/main/resources/db/migration/V8__widen_source_ref_to_text.sql @@ -0,0 +1 @@ +ALTER TABLE threat_ioc ALTER COLUMN source_ref TYPE TEXT;