feat(aria): Phase 5 — IOC feed refresh from OTX, MalwareBazaar, ThreatFox
CD - Develop / build-and-deploy (push) Failing after 52s

Adds automated daily IOC ingestion from three external threat feeds:
- AlienVault OTX: subscribed pulses + ATM-keyword searches (requires API key)
- MalwareBazaar (abuse.ch): ATM-tagged malware samples, no key required
- ThreatFox (abuse.ch): ATM malware family IOCs, no key required

Backend:
- IocFeedRun entity + V6 Flyway migration (ioc_feed_run table)
- OtxFeedClient / MalwareBazaarFeedClient / ThreatFoxFeedClient
- IocFeedService: upsert dedup + @Scheduled daily 03:00 refresh
- IocFeedController: GET /status, GET /runs, POST /refresh, POST /refresh/{feed}
- ThreatIoc.IocSource enum extended: OTX, MALWARE_BAZAAR, THREAT_FOX

Frontend:
- IocFeeds.svelte: feed status cards, run history table, manual refresh
- App.svelte: IOC Feeds nav item (📡), version string removed from sidebar
- api.ts: getFeedStatus, getFeedRuns, refreshAllFeeds, refreshFeed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 21:01:09 -04:00
parent d102a4bf61
commit bdc2504635
13 changed files with 1264 additions and 3 deletions
+12 -1
View File
@@ -4,6 +4,7 @@
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';
@@ -17,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 | '' = '';
@@ -97,6 +98,14 @@
{#if !sidebarCollapsed}<span>Device Posture</span>{/if}
</button>
</div>
<div class="nav-group">
<button class="nav-btn" class:active={currentView === 'feeds'}
on:click={() => navigate('feeds')} title="IOC Feeds">
<span class="nav-icon">📡</span>
{#if !sidebarCollapsed}<span>IOC Feeds</span>{/if}
</button>
</div>
</nav>
{#if !sidebarCollapsed && userInfo}
@@ -125,6 +134,8 @@
<PrecursorAlerts />
{:else if currentView === 'posture'}
<DevicePosture />
{:else if currentView === 'feeds'}
<IocFeeds />
{/if}
</main>
</div>
+591
View File
@@ -0,0 +1,591 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { ariaApi, type IocFeedStatus, type IocFeedRun } from '../lib/api';
import { addToast } from '../lib/stores';
import Pagination from './common/Pagination.svelte';
// ── State ─────────────────────────────────────────────────────────────
let feeds: IocFeedStatus[] = [];
let feedsLoading = true;
let runs: IocFeedRun[] = [];
let runsLoading = true;
let runsTotalElements = 0;
let runsTotalPages = 0;
let runsPage = 0;
const runsPageSize = 20;
let refreshingAll = false;
let refreshingFeed: Record<string, boolean> = {};
// ── Feed display config ───────────────────────────────────────────────
const FEED_META: Record<string, { label: string; icon: string; requiresKey: boolean }> = {
OTX: { label: 'AlienVault OTX', icon: '🔗', requiresKey: true },
MALWARE_BAZAAR: { label: 'MalwareBazaar', icon: '🦠', requiresKey: false },
THREAT_FOX: { label: 'ThreatFox', icon: '🦊', requiresKey: false },
};
// Ensure cards always render in a fixed order even if backend returns them in different order
const FEED_ORDER = ['OTX', 'MALWARE_BAZAAR', 'THREAT_FOX'];
$: orderedFeeds = FEED_ORDER.map(name => feeds.find(f => f.feedName === name) ?? {
feedName: name, enabled: false, configured: false,
} as IocFeedStatus);
// ── Data loading ──────────────────────────────────────────────────────
async function loadFeeds() {
feedsLoading = true;
try {
const res = await ariaApi.getFeedStatus();
feeds = res.data;
} catch {
addToast('error', 'Load Failed', 'Could not load feed status');
} finally {
feedsLoading = false;
}
}
async function loadRuns(page = 0) {
runsLoading = true;
try {
const res = await ariaApi.getFeedRuns({ page, size: runsPageSize });
runs = res.data.content;
runsTotalElements = res.data.page.totalElements;
runsTotalPages = res.data.page.totalPages;
runsPage = res.data.page.number;
} catch {
addToast('error', 'Load Failed', 'Could not load feed run history');
} finally {
runsLoading = false;
}
}
async function refresh() {
await Promise.all([loadFeeds(), loadRuns(runsPage)]);
}
// ── Refresh actions ───────────────────────────────────────────────────
async function refreshAll() {
refreshingAll = true;
try {
const res = await ariaApi.refreshAllFeeds();
addToast('success', 'Refresh Triggered', res.data.message || 'All feeds are refreshing');
setTimeout(() => refresh(), 2000);
} catch {
addToast('error', 'Refresh Failed', 'Could not trigger feed refresh');
} finally {
refreshingAll = false;
}
}
async function refreshSingle(feedName: string) {
refreshingFeed = { ...refreshingFeed, [feedName]: true };
try {
const res = await ariaApi.refreshFeed(feedName);
addToast('success', 'Refresh Triggered', res.data.message || `${feedName} feed is refreshing`);
setTimeout(() => refresh(), 2000);
} catch {
addToast('error', 'Refresh Failed', `Could not trigger ${feedName} refresh`);
} finally {
refreshingFeed = { ...refreshingFeed, [feedName]: false };
}
}
// ── Helpers ───────────────────────────────────────────────────────────
function statusClass(status: string | undefined): string {
if (!status) return 'never';
if (status === 'SUCCESS') return 'success';
if (status === 'FAILED') return 'failed';
if (status === 'RUNNING') return 'running';
return 'never';
}
function statusLabel(status: string | undefined): string {
if (!status) return 'Never run';
if (status === 'SUCCESS') return 'Success';
if (status === 'FAILED') return 'Failed';
if (status === 'RUNNING') return 'Running';
return status;
}
function relativeTime(iso: string | undefined): string {
if (!iso) return '—';
const diff = Date.now() - new Date(iso).getTime();
const sec = Math.floor(diff / 1000);
const min = Math.floor(sec / 60);
const hr = Math.floor(min / 60);
const day = Math.floor(hr / 24);
if (sec < 60) return 'just now';
if (min < 60) return `${min} minute${min !== 1 ? 's' : ''} ago`;
if (hr < 24) return `${hr} hour${hr !== 1 ? 's' : ''} ago`;
return `${day} day${day !== 1 ? 's' : ''} ago`;
}
function formatTs(iso: string | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
}
function duration(start: string, end: string | undefined): string {
if (!end) return '—';
const ms = new Date(end).getTime() - new Date(start).getTime();
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
}
function fmtCount(n: number | undefined): string {
if (n == null) return '—';
return n.toLocaleString();
}
// ── Auto-refresh every 30 s ───────────────────────────────────────────
let pollInterval: ReturnType<typeof setInterval>;
onMount(() => {
loadFeeds();
loadRuns(0);
pollInterval = setInterval(refresh, 30_000);
});
onDestroy(() => clearInterval(pollInterval));
</script>
<div class="page-wrapper">
<!-- Page header -->
<div class="header">
<div class="header-text">
<h1>IOC Feeds</h1>
<p>Automated threat intelligence ingestion — OTX, MalwareBazaar, ThreatFox</p>
</div>
<div class="header-controls">
<button
class="btn-refresh-all"
on:click={refreshAll}
disabled={refreshingAll}
title="Trigger full refresh of all feeds"
>
{#if refreshingAll}
<span class="spin">⟳</span> Refreshing…
{:else}
↻ Refresh All
{/if}
</button>
</div>
</div>
<!-- Feed cards -->
<div class="feeds-section">
{#if feedsLoading && feeds.length === 0}
<div class="feeds-loading">Loading feed status…</div>
{:else}
<div class="feed-cards">
{#each orderedFeeds as feed (feed.feedName)}
{@const meta = FEED_META[feed.feedName] ?? { label: feed.feedName, icon: '📡', requiresKey: false }}
<div class="feed-card" class:feed-disabled={!feed.enabled} class:feed-unconfigured={!feed.configured}>
<div class="feed-card-top">
<div class="feed-icon">{meta.icon}</div>
<div class="feed-info">
<div class="feed-name">{meta.label}</div>
<div class="feed-id">{feed.feedName}</div>
</div>
<span class="status-badge status-{statusClass(feed.lastRunStatus)}">
{statusLabel(feed.lastRunStatus)}
</span>
</div>
<div class="feed-meta-row">
{#if feed.configured}
<span class="config-tag config-ok">Configured</span>
{:else if meta.requiresKey}
<span class="config-tag config-warn">Not configured — API key required</span>
{:else}
<span class="config-tag config-ok">No key required</span>
{/if}
</div>
<div class="feed-stats">
<div class="feed-stat">
<span class="feed-stat-label">Last run</span>
<span class="feed-stat-value">{relativeTime(feed.lastRunAt)}</span>
</div>
<div class="feed-stat">
<span class="feed-stat-label">IOCs added</span>
<span class="feed-stat-value">{fmtCount(feed.lastIocsAdded)}</span>
</div>
<div class="feed-stat">
<span class="feed-stat-label">Total from feed</span>
<span class="feed-stat-value">{fmtCount(feed.totalIocsFromFeed)}</span>
</div>
</div>
<div class="feed-card-footer">
<button
class="btn-feed-refresh"
on:click={() => refreshSingle(feed.feedName)}
disabled={refreshingFeed[feed.feedName] || !feed.configured}
title={feed.configured ? 'Refresh this feed' : 'Feed not configured'}
>
{#if refreshingFeed[feed.feedName]}
<span class="spin">⟳</span> Refreshing…
{:else}
↻ Refresh
{/if}
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Run history table -->
<div class="runs-section">
<div class="runs-header">
<span class="runs-title">Run History</span>
<span class="runs-count">{runsTotalElements.toLocaleString()} run{runsTotalElements !== 1 ? 's' : ''}</span>
</div>
<div class="table-card">
<Pagination
page={runsPage}
totalPages={runsTotalPages}
totalElements={runsTotalElements}
size={runsPageSize}
on:pageChange={e => { runsPage = e.detail.page; loadRuns(runsPage); }}
/>
<div class="table-container">
{#if runsLoading}
<div class="table-msg">Loading run history…</div>
{:else if runs.length === 0}
<div class="table-msg">No feed runs recorded yet. Trigger a refresh to see results here.</div>
{:else}
<table>
<thead>
<tr>
<th>Feed</th>
<th>Started</th>
<th>Duration</th>
<th>Status</th>
<th>IOCs Added</th>
<th>IOCs Updated</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{#each runs as run (run.id)}
<tr>
<td>
<span class="feed-name-cell">
{FEED_META[run.feedName]?.icon ?? '📡'}
{FEED_META[run.feedName]?.label ?? run.feedName}
</span>
</td>
<td class="date-cell">{formatTs(run.startedAt)}</td>
<td class="mono-cell">{duration(run.startedAt, run.completedAt)}</td>
<td>
<span class="status-badge status-{statusClass(run.status)}">
{statusLabel(run.status)}
</span>
</td>
<td class="num-cell">{run.iocsAdded.toLocaleString()}</td>
<td class="num-cell">{run.iocsUpdated.toLocaleString()}</td>
<td class="error-cell">
{#if run.errorMessage}
<span class="error-text" title={run.errorMessage}>
{run.errorMessage.length > 60
? run.errorMessage.substring(0, 60) + '…'
: run.errorMessage}
</span>
{:else}
<span class="no-error">—</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</div>
</div>
<style>
/* ── Layout ──────────────────────────────────────────────────────────── */
.page-wrapper {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
padding: 24px 24px 0;
}
/* ── Header — dark navy gradient (matches all other ARIA pages) ───────── */
.header-controls { display: flex; align-items: center; gap: 0.75rem; flex-shrink: 0; }
.btn-refresh-all {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.45rem 1rem;
background: rgba(255,255,255,0.15);
border: 1px solid rgba(255,255,255,0.35);
color: white; border-radius: 6px;
font-size: 0.875rem; font-weight: 600;
cursor: pointer; transition: background 0.15s;
white-space: nowrap;
}
.btn-refresh-all:hover:not(:disabled) { background: rgba(255,255,255,0.28); }
.btn-refresh-all:disabled { opacity: 0.55; cursor: not-allowed; }
/* ── Feed cards section ──────────────────────────────────────────────── */
.feeds-section {
flex-shrink: 0;
padding: 16px 0 0;
}
.feeds-loading {
color: #6b7280;
font-size: var(--font-size-body-sm);
padding: 1rem 0;
}
.feed-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.feed-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
transition: box-shadow 0.15s;
}
.feed-card:hover { box-shadow: 0 2px 10px rgba(0,0,0,0.07); }
.feed-card.feed-unconfigured { border-color: #fde68a; }
.feed-card.feed-disabled { opacity: 0.65; }
.feed-card-top {
display: flex;
align-items: flex-start;
gap: 10px;
}
.feed-icon {
font-size: 1.6rem;
flex-shrink: 0;
line-height: 1;
margin-top: 2px;
}
.feed-info { flex: 1; min-width: 0; }
.feed-name {
font-size: 0.95rem;
font-weight: 700;
color: #111827;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.feed-id {
font-size: var(--font-size-tiny);
color: #9ca3af;
font-family: monospace;
margin-top: 1px;
}
/* ── Status badges ───────────────────────────────────────────────────── */
.status-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
font-size: 0.72rem;
font-weight: 700;
white-space: nowrap;
flex-shrink: 0;
}
.status-success { background: #dcfce7; color: #15803d; }
.status-failed { background: #fee2e2; color: #b91c1c; }
.status-running { background: #fef9c3; color: #854d0e; }
.status-never { background: #f3f4f6; color: #6b7280; }
/* ── Config tag ──────────────────────────────────────────────────────── */
.feed-meta-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.config-tag {
display: inline-block;
font-size: var(--font-size-tiny);
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
}
.config-ok { background: #dcfce7; color: #15803d; }
.config-warn { background: #fef3c7; color: #92400e; }
/* ── Feed stats ──────────────────────────────────────────────────────── */
.feed-stats {
display: flex;
gap: 16px;
border-top: 1px solid #f3f4f6;
padding-top: 10px;
}
.feed-stat {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.feed-stat-label {
font-size: var(--font-size-tiny);
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.4px;
font-weight: 600;
}
.feed-stat-value {
font-size: 0.875rem;
font-weight: 700;
color: #1f2937;
}
/* ── Per-card refresh button ─────────────────────────────────────────── */
.feed-card-footer {
border-top: 1px solid #f3f4f6;
padding-top: 10px;
}
.btn-feed-refresh {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.35rem 0.85rem;
background: #f1f5f9;
border: 1px solid #e2e8f0;
color: #374151;
border-radius: 6px;
font-size: 0.82rem; font-weight: 500;
cursor: pointer; transition: background 0.15s;
}
.btn-feed-refresh:hover:not(:disabled) { background: #e2e8f0; }
.btn-feed-refresh:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Run history section ─────────────────────────────────────────────── */
.runs-section {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
padding-top: 20px;
padding-bottom: 16px;
}
.runs-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
flex-shrink: 0;
}
.runs-title {
font-size: 0.9rem;
font-weight: 700;
color: #1f2937;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.runs-count {
font-size: var(--font-size-tiny);
color: #9ca3af;
font-weight: 500;
}
/* ── Table card — canonical ──────────────────────────────────────────── */
.table-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.table-container { flex: 1; overflow: auto; }
.table-msg { padding: 2rem; text-align: center; color: #6b7280; font-size: var(--font-size-body-sm); }
table { width: 100%; border-collapse: collapse; }
thead { background: #f9fafb; position: sticky; top: 0; z-index: 5; }
th {
color: #374151; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.3px;
font-weight: 600; border-bottom: 2px solid #e5e7eb; padding: 0.6rem 0.75rem;
text-align: left; white-space: nowrap;
}
td {
border-bottom: 1px solid #f3f4f6;
color: #1f2937;
padding: 0.55rem 0.75rem;
font-size: var(--font-size-label);
vertical-align: middle;
}
tbody tr:hover { background: #eff6ff; }
tbody tr:last-child td { border-bottom: none; }
.feed-name-cell { display: flex; align-items: center; gap: 6px; font-weight: 500; white-space: nowrap; }
.date-cell { white-space: nowrap; color: #6b7280; }
.mono-cell { font-family: monospace; font-size: 0.78rem; color: #4b5563; }
.num-cell { text-align: right; font-variant-numeric: tabular-nums; padding-right: 1.25rem; }
.error-cell { max-width: 260px; }
.error-text { color: #dc2626; font-size: 0.78rem; cursor: help; }
.no-error { color: #9ca3af; }
/* ── Spinner animation ───────────────────────────────────────────────── */
@keyframes spin { to { transform: rotate(360deg); } }
.spin { display: inline-block; animation: spin 0.8s linear infinite; }
/* ── Dark mode ───────────────────────────────────────────────────────── */
:global(.dark-mode) .feed-card {
background: #1f2937;
border-color: #374151;
}
:global(.dark-mode) .feed-card.feed-unconfigured { border-color: #78350f; }
:global(.dark-mode) .feed-name { color: #f3f4f6; }
:global(.dark-mode) .feed-stats { border-top-color: #374151; }
:global(.dark-mode) .feed-stat-value { color: #e5e7eb; }
:global(.dark-mode) .feed-card-footer { border-top-color: #374151; }
:global(.dark-mode) .feeds-loading { color: #9ca3af; }
:global(.dark-mode) .status-success { background: #052e16; color: #86efac; }
:global(.dark-mode) .status-failed { background: #450a0a; color: #fca5a5; }
:global(.dark-mode) .status-running { background: #422006; color: #fcd34d; }
:global(.dark-mode) .status-never { background: #1e293b; color: #94a3b8; }
:global(.dark-mode) .config-ok { background: #052e16; color: #86efac; }
:global(.dark-mode) .config-warn { background: #422006; color: #fcd34d; }
:global(.dark-mode) .btn-feed-refresh {
background: #374151; color: #e5e7eb; border-color: #4b5563;
}
:global(.dark-mode) .btn-feed-refresh:hover:not(:disabled) { background: #4b5563; }
:global(.dark-mode) .runs-title { color: #e5e7eb; }
:global(.dark-mode) .runs-count { color: #6b7280; }
:global(.dark-mode) .table-card { background: #1f2937; border-color: #374151; }
:global(.dark-mode) thead { background: #111827; }
:global(.dark-mode) th { background: #111827; color: #9ca3af; border-bottom-color: #374151; }
:global(.dark-mode) td { color: #e5e7eb; border-bottom-color: #374151; }
:global(.dark-mode) tbody tr { background: #1f2937; }
:global(.dark-mode) tbody tr:hover { background: #374151; }
:global(.dark-mode) .date-cell { color: #9ca3af; }
:global(.dark-mode) .mono-cell { color: #94a3b8; }
:global(.dark-mode) .error-text { color: #f87171; }
:global(.dark-mode) .no-error { color: #6b7280; }
:global(.dark-mode) .table-msg { color: #9ca3af; }
</style>
+34 -1
View File
@@ -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<T> {
};
}
export type IocFeedStatus = {
feedName: string;
enabled: boolean;
configured: boolean;
lastRunAt?: string;
lastRunStatus?: string;
lastIocsAdded?: number;
totalIocsFromFeed?: 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<DeviceSecurityPosture>(`/api/aria/posture/${deviceId}`),
getFeedStatus: () =>
api.get<IocFeedStatus[]>('/api/aria/feeds/status'),
getFeedRuns: (params?: { page?: number; size?: number }) =>
api.get<PageResponse<IocFeedRun>>('/api/aria/feeds/runs', { params }),
refreshAllFeeds: () =>
api.post<{ message: string }>('/api/aria/feeds/refresh'),
refreshFeed: (feedName: string) =>
api.post<{ message: string }>(`/api/aria/feeds/${feedName}/refresh`),
};