feat(aria): Phase 5 — IOC feed refresh from OTX, MalwareBazaar, ThreatFox
CD - Develop / build-and-deploy (push) Failing after 52s
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:
+12
-1
@@ -4,6 +4,7 @@
|
|||||||
import IocManagement from './components/IocManagement.svelte';
|
import IocManagement from './components/IocManagement.svelte';
|
||||||
import PrecursorAlerts from './components/PrecursorAlerts.svelte';
|
import PrecursorAlerts from './components/PrecursorAlerts.svelte';
|
||||||
import DevicePosture from './components/DevicePosture.svelte';
|
import DevicePosture from './components/DevicePosture.svelte';
|
||||||
|
import IocFeeds from './components/IocFeeds.svelte';
|
||||||
import Toast from './components/common/Toast.svelte';
|
import Toast from './components/common/Toast.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { authApi } from './lib/api';
|
import { authApi } from './lib/api';
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
localStorage.setItem('ariaSidebarCollapsed', String(sidebarCollapsed));
|
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 currentView: View = 'dashboard';
|
||||||
let eventsInitialSeverity: ThreatSeverity | '' = '';
|
let eventsInitialSeverity: ThreatSeverity | '' = '';
|
||||||
|
|
||||||
@@ -97,6 +98,14 @@
|
|||||||
{#if !sidebarCollapsed}<span>Device Posture</span>{/if}
|
{#if !sidebarCollapsed}<span>Device Posture</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</nav>
|
||||||
|
|
||||||
{#if !sidebarCollapsed && userInfo}
|
{#if !sidebarCollapsed && userInfo}
|
||||||
@@ -125,6 +134,8 @@
|
|||||||
<PrecursorAlerts />
|
<PrecursorAlerts />
|
||||||
{:else if currentView === 'posture'}
|
{:else if currentView === 'posture'}
|
||||||
<DevicePosture />
|
<DevicePosture />
|
||||||
|
{:else if currentView === 'feeds'}
|
||||||
|
<IocFeeds />
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
@@ -29,7 +29,7 @@ export type ThreatSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'INFO';
|
|||||||
export type EventType = 'PHYSICAL' | 'OS_EVENT' | 'FILE_SYSTEM' | 'COMPOSITE';
|
export type EventType = 'PHYSICAL' | 'OS_EVENT' | 'FILE_SYSTEM' | 'COMPOSITE';
|
||||||
export type AttackPhase = 'PHYSICAL_ACCESS' | 'MALWARE_STAGING' | 'MALWARE_EXECUTION' | 'PERSISTENCE' | 'CLEANUP';
|
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 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';
|
export type ConfidenceLevel = 'HIGH' | 'MEDIUM' | 'LOW';
|
||||||
|
|
||||||
// ── Entities ─────────────────────────────────────────────────────────
|
// ── 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 {
|
export interface CreateIocRequest {
|
||||||
iocType: IocType;
|
iocType: IocType;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -158,4 +179,16 @@ export const ariaApi = {
|
|||||||
|
|
||||||
getPosture: (deviceId: number) =>
|
getPosture: (deviceId: number) =>
|
||||||
api.get<DeviceSecurityPosture>(`/api/aria/posture/${deviceId}`),
|
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`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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 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.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/aria/feeds")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class IocFeedController {
|
||||||
|
|
||||||
|
private final IocFeedService feedService;
|
||||||
|
private final IocFeedRunRepository feedRunRepository;
|
||||||
|
|
||||||
|
/** Latest run per feed — used by the status cards in the UI */
|
||||||
|
@GetMapping("/status")
|
||||||
|
@PreAuthorize("hasRole('BCOS_ADMIN')")
|
||||||
|
public ResponseEntity<Map<String, Object>> status() {
|
||||||
|
List<String> names = feedService.feedNames();
|
||||||
|
Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||||
|
for (String name : names) {
|
||||||
|
Optional<IocFeedRun> last = feedRunRepository.findTopByFeedNameOrderByStartedAtDesc(name);
|
||||||
|
result.put(name, last.orElse(null));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recent run history — default 50 rows */
|
||||||
|
@GetMapping("/runs")
|
||||||
|
@PreAuthorize("hasRole('BCOS_ADMIN')")
|
||||||
|
public ResponseEntity<List<IocFeedRun>> runs(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "50") int size) {
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
feedRunRepository.findAllByOrderByStartedAtDesc(PageRequest.of(page, size)).getContent()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger all feeds immediately */
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
@PreAuthorize("hasRole('BCOS_ADMIN')")
|
||||||
|
public ResponseEntity<Map<String, String>> refreshAll() {
|
||||||
|
new Thread(() -> feedService.refreshAll(), "ioc-feed-manual-all").start();
|
||||||
|
return ResponseEntity.accepted().body(Map.of("status", "refresh_started"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger one specific feed */
|
||||||
|
@PostMapping("/refresh/{feedName}")
|
||||||
|
@PreAuthorize("hasRole('BCOS_ADMIN')")
|
||||||
|
public ResponseEntity<Map<String, String>> refreshOne(@PathVariable String feedName) {
|
||||||
|
String upper = feedName.toUpperCase();
|
||||||
|
if (!feedService.feedNames().contains(upper)) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Unknown feed: " + feedName));
|
||||||
|
}
|
||||||
|
new Thread(() -> feedService.refreshFeed(upper), "ioc-feed-manual-" + upper).start();
|
||||||
|
return ResponseEntity.accepted().body(Map.of("status", "refresh_started", "feed", upper));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ public class ThreatIoc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public enum IocSource {
|
public enum IocSource {
|
||||||
FBI_FLASH, FS_ISAC, MANUAL
|
FBI_FLASH, FS_ISAC, MANUAL, OTX, MALWARE_BAZAAR, THREAT_FOX
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ConfidenceLevel {
|
public enum ConfidenceLevel {
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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.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.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches ATM-related malware samples from MalwareBazaar (abuse.ch) — no API key required.
|
||||||
|
*/
|
||||||
|
@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();
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate = new RestTemplate();
|
||||||
|
|
||||||
|
public List<ThreatIoc> fetch() {
|
||||||
|
List<ThreatIoc> 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<ThreatIoc> queryByTag(String tag) {
|
||||||
|
List<ThreatIoc> out = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||||
|
|
||||||
|
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
|
||||||
|
body.add("query", "get_taginfo");
|
||||||
|
body.add("tag", tag);
|
||||||
|
body.add("limit", "100");
|
||||||
|
|
||||||
|
HttpEntity<MultiValueMap<String, String>> 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");
|
||||||
|
|
||||||
|
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)
|
||||||
|
.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)
|
||||||
|
.expiresAt(Instant.now().plus(365, ChronoUnit.DAYS))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("MalwareBazaar: error querying tag {}: {}", tag, e.getMessage());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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.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<ThreatIoc> fetch() {
|
||||||
|
if (!isConfigured()) {
|
||||||
|
log.info("OTX: no API key configured, skipping");
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ThreatIoc> 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<ThreatIoc> out) {
|
||||||
|
try {
|
||||||
|
org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
|
||||||
|
headers.set("X-OTX-API-KEY", apiKey);
|
||||||
|
org.springframework.http.HttpEntity<Void> entity = new org.springframework.http.HttpEntity<>(headers);
|
||||||
|
|
||||||
|
org.springframework.http.ResponseEntity<String> 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;
|
||||||
|
|
||||||
|
return ThreatIoc.builder()
|
||||||
|
.iocType(iocType)
|
||||||
|
.value(value)
|
||||||
|
.description("OTX pulse: " + pulseName)
|
||||||
|
.source(ThreatIoc.IocSource.OTX)
|
||||||
|
.sourceRef(pulseName)
|
||||||
|
.confidence(ThreatIoc.ConfidenceLevel.MEDIUM)
|
||||||
|
.expiresAt(Instant.now().plus(90, ChronoUnit.DAYS))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
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.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.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches ATM-related IOCs from ThreatFox (abuse.ch) — no API key required.
|
||||||
|
*/
|
||||||
|
@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();
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate = new RestTemplate();
|
||||||
|
|
||||||
|
public List<ThreatIoc> fetch() {
|
||||||
|
List<ThreatIoc> 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<ThreatIoc> queryByMalware(String malware) {
|
||||||
|
List<ThreatIoc> out = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
|
String body = MAPPER.writeValueAsString(Map.of(
|
||||||
|
"query", "iocs_by_malware_family",
|
||||||
|
"malware_family", malware,
|
||||||
|
"limit", 100
|
||||||
|
));
|
||||||
|
|
||||||
|
HttpEntity<String> 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<ThreatIoc> queryRecent() {
|
||||||
|
List<ThreatIoc> out = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
|
String body = MAPPER.writeValueAsString(Map.of(
|
||||||
|
"query", "get_iocs",
|
||||||
|
"days", 7
|
||||||
|
));
|
||||||
|
|
||||||
|
HttpEntity<String> 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<ThreatIoc> parseResponse(String response, String malware) {
|
||||||
|
List<ThreatIoc> 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;
|
||||||
|
|
||||||
|
return ThreatIoc.builder()
|
||||||
|
.iocType(type)
|
||||||
|
.value(value)
|
||||||
|
.description("ThreatFox malware:" + malware)
|
||||||
|
.source(ThreatIoc.IocSource.THREAT_FOX)
|
||||||
|
.sourceRef(iocId)
|
||||||
|
.confidence(level)
|
||||||
|
.expiresAt(Instant.now().plus(180, ChronoUnit.DAYS))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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 java.util.Optional;
|
||||||
|
|
||||||
|
public interface IocFeedRunRepository extends JpaRepository<IocFeedRun, Long> {
|
||||||
|
|
||||||
|
Page<IocFeedRun> findAllByOrderByStartedAtDesc(Pageable pageable);
|
||||||
|
|
||||||
|
Optional<IocFeedRun> findTopByFeedNameOrderByStartedAtDesc(String feedName);
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(r) FROM IocFeedRun r WHERE r.feedName = :feedName AND r.status = 'RUNNING'")
|
||||||
|
long countRunning(String feedName);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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<String, ThreatIoc.IocSource> 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<ThreatIoc> 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<ThreatIoc> 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<ThreatIoc> iocs) {
|
||||||
|
int added = 0, updated = 0;
|
||||||
|
for (ThreatIoc ioc : iocs) {
|
||||||
|
if (ioc.getValue() == null || ioc.getValue().isBlank()) continue;
|
||||||
|
Optional<ThreatIoc> 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.setExpiresAt(ioc.getExpiresAt());
|
||||||
|
iocRepository.save(e);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
iocRepository.save(ioc);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new int[]{ added, updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> feedNames() {
|
||||||
|
return List.copyOf(FEED_SOURCES.keySet());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,11 @@ aria.internal.service-secret=${ARIA_SERVICE_SECRET:}
|
|||||||
# Sequence detection window (minutes)
|
# Sequence detection window (minutes)
|
||||||
aria.sequence.window.minutes=${ARIA_SEQUENCE_WINDOW_MINUTES:15}
|
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:}
|
||||||
|
|
||||||
# Auto-response — disabled by default for safe initial deployment
|
# Auto-response — disabled by default for safe initial deployment
|
||||||
aria.autoresponse.shutdown.enabled=${ARIA_AUTORESPONSE_SHUTDOWN_ENABLED:false}
|
aria.autoresponse.shutdown.enabled=${ARIA_AUTORESPONSE_SHUTDOWN_ENABLED:false}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user