Merge pull request 'fix(feed): widen source_ref from VARCHAR(100) to TEXT' (#1) from develop into main
CD - Production / build-and-deploy (push) Failing after 10m38s
CD - Production / build-and-deploy (push) Failing after 10m38s
fix(feed): widen source_ref from VARCHAR(100) to TEXT
This commit was merged in pull request #1.
This commit is contained in:
@@ -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
|
||||
<div class="header">
|
||||
<div class="header-text">
|
||||
<h1>Page Title</h1>
|
||||
<p>Subtitle</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Toolbar** — all buttons go here, never in the header:
|
||||
```svelte
|
||||
<div class="form-card">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left"><span class="count">N items</span></div>
|
||||
<div class="toolbar-right">
|
||||
<button class="btn btn-secondary btn-sm">Refresh</button>
|
||||
<button class="btn btn-primary btn-sm">+ Add</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**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
|
||||
Generated
+4
-4
@@ -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"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "hiveops-aria-frontend",
|
||||
"version": "1.0.1-dev",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+12
-8
@@ -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 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !sidebarCollapsed}
|
||||
<span class="sidebar-version">v{appVersion}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
@@ -103,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}
|
||||
@@ -131,6 +134,8 @@
|
||||
<PrecursorAlerts />
|
||||
{:else if currentView === 'posture'}
|
||||
<DevicePosture />
|
||||
{:else if currentView === 'feeds'}
|
||||
<IocFeeds />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
interface InstGroup {
|
||||
key: string;
|
||||
devices: DeviceSecurityPosture[];
|
||||
avgScore: number | null;
|
||||
hasIssues: boolean;
|
||||
}
|
||||
|
||||
$: grouped = groupByInstitution(postures);
|
||||
|
||||
function groupByInstitution(list: DeviceSecurityPosture[]): InstGroup[] {
|
||||
const map = new Map<string, DeviceSecurityPosture[]>();
|
||||
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<string, any> = { page, size: pageSize };
|
||||
const params: Record<string, any> = { 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 @@
|
||||
<!-- LEFT: Collapsible filter sidebar -->
|
||||
<div class="filter-sidebar" class:sidebar-collapsed={filterSidebarCollapsed}>
|
||||
|
||||
<div class="filter-sidebar-toggle-bar" class:bar-filters-active={hasActiveFilters}>
|
||||
<div class="filter-sidebar-toggle-bar">
|
||||
{#if !filterSidebarCollapsed}
|
||||
<span class="filter-sidebar-title">Filters</span>
|
||||
<div class="toggle-bar-right">
|
||||
@@ -193,7 +225,7 @@
|
||||
<div class="filter-section">
|
||||
<label class="filter-section-label">Score</label>
|
||||
<select class="filter-select" bind:value={filterScore}
|
||||
on:change={() => { currentPage = 0; load(0); }}>
|
||||
on:change={() => load()}>
|
||||
<option value="">All Scores</option>
|
||||
<option value="low">Low (<50)</option>
|
||||
<option value="medium">Below Good (<80)</option>
|
||||
@@ -203,7 +235,7 @@
|
||||
<div class="filter-section">
|
||||
<label class="filter-section-label">OS Status</label>
|
||||
<select class="filter-select" bind:value={filterEol}
|
||||
on:change={() => { currentPage = 0; load(0); }}>
|
||||
on:change={() => load()}>
|
||||
<option value="">All</option>
|
||||
<option value="EOL">EOL</option>
|
||||
<option value="SUPPORTED">Supported</option>
|
||||
@@ -224,14 +256,14 @@
|
||||
<span class="filter-tag" style="border-color: #d97706">
|
||||
<span class="filter-tag-dot" style="background-color: #d97706"></span>
|
||||
Score: {filterScore === 'low' ? 'Low (<50)' : 'Below Good (<80)'}
|
||||
<button class="filter-tag-remove" on:click={() => { filterScore = ''; currentPage = 0; load(0); }}>×</button>
|
||||
<button class="filter-tag-remove" on:click={() => { filterScore = ''; load(); }}>×</button>
|
||||
</span>
|
||||
{/if}
|
||||
{#if filterEol}
|
||||
<span class="filter-tag" style="border-color: {filterEol === 'EOL' ? '#dc2626' : filterEol === 'SUPPORTED' ? '#16a34a' : '#6b7280'}">
|
||||
<span class="filter-tag-dot" style="background-color: {filterEol === 'EOL' ? '#dc2626' : filterEol === 'SUPPORTED' ? '#16a34a' : '#6b7280'}"></span>
|
||||
OS: {eolLabel(filterEol)}
|
||||
<button class="filter-tag-remove" on:click={() => { filterEol = ''; currentPage = 0; load(0); }}>×</button>
|
||||
<button class="filter-tag-remove" on:click={() => { filterEol = ''; load(); }}>×</button>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -245,12 +277,6 @@
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<Pagination
|
||||
page={currentPage} {totalPages} {totalElements} size={pageSize}
|
||||
on:pageChange={e => { currentPage = e.detail.page; load(currentPage); }}
|
||||
on:pageSizeChange={e => { currentPage = 0; load(0); }}
|
||||
/>
|
||||
|
||||
<div class="table-container">
|
||||
{#if loading}
|
||||
<div class="table-msg">Loading…</div>
|
||||
@@ -274,46 +300,66 @@
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each postures as device (device.deviceId)}
|
||||
<tr on:click={() => openPanel(device)}>
|
||||
<td class="mono-cell">{device.deviceAgentId ?? device.deviceId}</td>
|
||||
<td class="os-cell">{device.osVersion ?? '—'}</td>
|
||||
<td>
|
||||
<span class="eol-badge eol-{(device.osEolStatus ?? 'UNKNOWN').toLowerCase()}">
|
||||
{eolLabel(device.osEolStatus)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="bool-cell {boolClass(device.diskEncryptionEnabled)}">{boolCell(device.diskEncryptionEnabled)}</td>
|
||||
<td class="bool-cell {boolClass(device.auditPolicyCompliant)}">{boolCell(device.auditPolicyCompliant)}</td>
|
||||
<td class="bool-cell {boolClass(device.softwareWhitelistEnabled)}">{boolCell(device.softwareWhitelistEnabled)}</td>
|
||||
<td>
|
||||
{#if imageMismatch(device)}
|
||||
<span class="img-badge drift">Drift</span>
|
||||
{:else if device.goldImageHash}
|
||||
<span class="img-badge ok">Match</span>
|
||||
{:else}
|
||||
<span class="img-badge unknown">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<div class="score-wrap">
|
||||
<div class="score-bar">
|
||||
<div class="score-fill score-{scoreColor(device.postureScore)}"
|
||||
style="width: {device.postureScore ?? 0}%"></div>
|
||||
</div>
|
||||
<span class="score-text score-text-{scoreColor(device.postureScore)}">
|
||||
{device.postureScore ?? '—'}
|
||||
</span>
|
||||
{#each grouped as group (group.key)}
|
||||
<tbody>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
||||
<tr class="inst-header" on:click={() => toggleInstitution(group.key)}>
|
||||
<td colspan="10">
|
||||
<div class="inst-header-inner">
|
||||
<span class="inst-chevron">{expandedInstitutions.has(group.key) ? '▼' : '▶'}</span>
|
||||
<span class="inst-key">{group.key}</span>
|
||||
<span class="inst-count">{group.devices.length} device{group.devices.length !== 1 ? 's' : ''}</span>
|
||||
{#if group.avgScore != null}
|
||||
<span class="inst-avg score-text-{scoreColor(group.avgScore)}">avg {group.avgScore}</span>
|
||||
{/if}
|
||||
{#if group.hasIssues}
|
||||
<span class="inst-warn">⚠ below 80</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="date-cell">{formatDate(device.lastPostureCheckAt)}</td>
|
||||
<td class="actions" on:click|stopPropagation>
|
||||
<button class="btn-sm btn-secondary-sm" on:click={() => openPanel(device)}>Detail</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
{#if expandedInstitutions.has(group.key)}
|
||||
{#each group.devices as device (device.deviceId)}
|
||||
<tr on:click={() => openPanel(device)}>
|
||||
<td class="mono-cell">{device.deviceAgentId ?? device.deviceId}</td>
|
||||
<td class="os-cell">{device.osVersion ?? '—'}</td>
|
||||
<td>
|
||||
<span class="eol-badge eol-{(device.osEolStatus ?? 'UNKNOWN').toLowerCase()}">
|
||||
{eolLabel(device.osEolStatus)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="bool-cell {boolClass(device.diskEncryptionEnabled)}">{boolCell(device.diskEncryptionEnabled)}</td>
|
||||
<td class="bool-cell {boolClass(device.auditPolicyCompliant)}">{boolCell(device.auditPolicyCompliant)}</td>
|
||||
<td class="bool-cell {boolClass(device.softwareWhitelistEnabled)}">{boolCell(device.softwareWhitelistEnabled)}</td>
|
||||
<td>
|
||||
{#if imageMismatch(device)}
|
||||
<span class="img-badge drift">Drift</span>
|
||||
{:else if device.goldImageHash}
|
||||
<span class="img-badge ok">Match</span>
|
||||
{:else}
|
||||
<span class="img-badge unknown">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<div class="score-wrap">
|
||||
<div class="score-bar">
|
||||
<div class="score-fill score-{scoreColor(device.postureScore)}"
|
||||
style="width: {device.postureScore ?? 0}%"></div>
|
||||
</div>
|
||||
<span class="score-text score-text-{scoreColor(device.postureScore)}">
|
||||
{device.postureScore ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="date-cell">{formatDate(device.lastPostureCheckAt)}</td>
|
||||
<td class="actions" on:click|stopPropagation>
|
||||
<button class="btn-sm btn-secondary-sm" on:click={() => openPanel(device)}>Detail</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
{/each}
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
<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';
|
||||
|
||||
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> = {};
|
||||
|
||||
const FEED_META: Record<string, { label: string; icon: string }> = {
|
||||
OTX: { label: 'AlienVault OTX', icon: '🔗' },
|
||||
MALWARE_BAZAAR: { label: 'MalwareBazaar', icon: '🦠' },
|
||||
THREAT_FOX: { label: 'ThreatFox', icon: '🦊' },
|
||||
};
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
async function loadFeeds() {
|
||||
feedsLoading = true;
|
||||
try {
|
||||
const res = await ariaApi.getFeedStatus();
|
||||
feeds = Array.isArray(res.data) ? 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 });
|
||||
const d = res.data as any;
|
||||
runs = d.content ?? (Array.isArray(d) ? d : []);
|
||||
runsTotalElements = d.page?.totalElements ?? runs.length;
|
||||
runsTotalPages = d.page?.totalPages ?? 1;
|
||||
runsPage = d.page?.number ?? page;
|
||||
} catch {
|
||||
addToast('error', 'Load Failed', 'Could not load run history');
|
||||
} finally {
|
||||
runsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const res = await ariaApi.getFeedStatus();
|
||||
feeds = Array.isArray(res.data) ? res.data : [];
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
refreshingAll = true;
|
||||
try {
|
||||
await ariaApi.refreshAllFeeds();
|
||||
addToast('success', 'Refresh Triggered', 'All feeds are refreshing');
|
||||
setTimeout(() => { loadFeeds(); loadRuns(runsPage); }, 3000);
|
||||
} catch {
|
||||
addToast('error', 'Refresh Failed', 'Could not trigger feed refresh');
|
||||
} finally {
|
||||
refreshingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSingle(feedName: string) {
|
||||
refreshingFeed = { ...refreshingFeed, [feedName]: true };
|
||||
try {
|
||||
await ariaApi.refreshFeed(feedName);
|
||||
addToast('success', 'Refresh Triggered', `${FEED_META[feedName]?.label ?? feedName} is refreshing`);
|
||||
setTimeout(() => { loadFeeds(); loadRuns(runsPage); }, 3000);
|
||||
} catch {
|
||||
addToast('error', 'Refresh Failed', `Could not refresh ${feedName}`);
|
||||
} finally {
|
||||
refreshingFeed = { ...refreshingFeed, [feedName]: false };
|
||||
}
|
||||
}
|
||||
|
||||
function statusClass(s?: string) {
|
||||
if (s === 'SUCCESS') return 'success';
|
||||
if (s === 'ERROR') return 'error';
|
||||
if (s === 'RUNNING') return 'running';
|
||||
return 'never';
|
||||
}
|
||||
|
||||
function statusLabel(s?: string) {
|
||||
if (s === 'SUCCESS') return 'Success';
|
||||
if (s === 'ERROR') return 'Error';
|
||||
if (s === 'RUNNING') return 'Running';
|
||||
return 'Never run';
|
||||
}
|
||||
|
||||
function relativeTime(iso?: string) {
|
||||
if (!iso) return '—';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const min = Math.floor(diff / 60000);
|
||||
const hr = Math.floor(min / 60);
|
||||
const day = Math.floor(hr / 24);
|
||||
if (min < 1) return 'just now';
|
||||
if (min < 60) return `${min}m ago`;
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
return `${day}d ago`;
|
||||
}
|
||||
|
||||
function formatTs(iso?: 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) {
|
||||
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`;
|
||||
}
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval>;
|
||||
|
||||
onMount(() => {
|
||||
loadFeeds();
|
||||
loadRuns(0);
|
||||
pollInterval = setInterval(pollStatus, 30_000);
|
||||
});
|
||||
|
||||
onDestroy(() => clearInterval(pollInterval));
|
||||
</script>
|
||||
|
||||
<div class="page-wrapper">
|
||||
|
||||
<div class="header">
|
||||
<div class="header-text">
|
||||
<h1>IOC Feeds</h1>
|
||||
<p>Automated threat intelligence — OTX, MalwareBazaar, ThreatFox</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feed cards -->
|
||||
<div class="form-card feeds-card">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="count">{feeds.length} feed{feeds.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<button class="btn btn-secondary btn-sm" on:click={() => { loadFeeds(); loadRuns(runsPage); }}>
|
||||
Refresh
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" on:click={refreshAll} disabled={refreshingAll}>
|
||||
{refreshingAll ? 'Running…' : 'Run All Feeds'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if feedsLoading && feeds.length === 0}
|
||||
<div class="loading-msg">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: '📡' }}
|
||||
<div class="feed-card" class:unconfigured={!feed.configured}>
|
||||
<div class="feed-card-top">
|
||||
<span class="feed-icon">{meta.icon}</span>
|
||||
<div class="feed-info">
|
||||
<div class="feed-name">{meta.label}</div>
|
||||
<div class="feed-key">{feed.feedName}</div>
|
||||
</div>
|
||||
<span class="badge badge-{statusClass(feed.lastRunStatus)}">{statusLabel(feed.lastRunStatus)}</span>
|
||||
</div>
|
||||
|
||||
<div class="feed-tag-row">
|
||||
{#if feed.configured}
|
||||
<span class="tag tag-ok">Configured</span>
|
||||
{:else}
|
||||
<span class="tag tag-warn">API key required</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="feed-stats">
|
||||
<div class="feed-stat">
|
||||
<span class="stat-label">Last run</span>
|
||||
<span class="stat-value">{relativeTime(feed.lastRunAt)}</span>
|
||||
</div>
|
||||
<div class="feed-stat">
|
||||
<span class="stat-label">IOCs added</span>
|
||||
<span class="stat-value">{feed.lastIocsAdded?.toLocaleString() ?? '—'}</span>
|
||||
</div>
|
||||
<div class="feed-stat">
|
||||
<span class="stat-label">Updated</span>
|
||||
<span class="stat-value">{feed.lastIocsUpdated?.toLocaleString() ?? '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feed-card-footer">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
on:click={() => refreshSingle(feed.feedName)}
|
||||
disabled={refreshingFeed[feed.feedName] || !feed.configured}
|
||||
>
|
||||
{refreshingFeed[feed.feedName] ? 'Running…' : '↻ Run Feed'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Run history -->
|
||||
<div class="table-section">
|
||||
<div class="table-wrapper">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="count">{runsTotalElements.toLocaleString()} run{runsTotalElements !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-empty">Loading…</div>
|
||||
{:else if runs.length === 0}
|
||||
<div class="table-empty">No feed runs yet. Click "Run All Feeds" to start.</div>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feed</th>
|
||||
<th>Started</th>
|
||||
<th>Duration</th>
|
||||
<th>Status</th>
|
||||
<th class="num-col">Added</th>
|
||||
<th class="num-col">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="muted">{formatTs(run.startedAt)}</td>
|
||||
<td class="mono">{duration(run.startedAt, run.completedAt)}</td>
|
||||
<td><span class="badge badge-{statusClass(run.status)}">{statusLabel(run.status)}</span></td>
|
||||
<td class="num-col">{run.iocsAdded.toLocaleString()}</td>
|
||||
<td class="num-col">{run.iocsUpdated.toLocaleString()}</td>
|
||||
<td>
|
||||
{#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="muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-wrapper {
|
||||
display: flex; flex-direction: column;
|
||||
height: 100%; overflow: hidden;
|
||||
padding: 24px 24px 0;
|
||||
}
|
||||
|
||||
/* Feed cards block */
|
||||
.feeds-card { flex-shrink: 0; margin-bottom: 20px; }
|
||||
|
||||
.toolbar {
|
||||
display: flex; align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.toolbar-left { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.toolbar-right { display: flex; gap: 0.5rem; }
|
||||
|
||||
.count { font-size: 0.85rem; color: #6b7280; font-weight: 500; }
|
||||
|
||||
.loading-msg { padding: 1.5rem 16px; color: #6b7280; font-size: 0.9rem; }
|
||||
|
||||
.feed-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.feed-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.feed-card.unconfigured { border-color: #fde68a; background: #fffbeb; }
|
||||
|
||||
.feed-card-top { display: flex; align-items: flex-start; gap: 10px; }
|
||||
.feed-icon { font-size: 1.5rem; flex-shrink: 0; line-height: 1; margin-top: 1px; }
|
||||
.feed-info { flex: 1; min-width: 0; }
|
||||
.feed-name { font-size: 0.9rem; font-weight: 700; color: #111827; }
|
||||
.feed-key { font-size: 0.72rem; color: #9ca3af; font-family: monospace; margin-top: 1px; }
|
||||
|
||||
/* Status badges */
|
||||
.badge {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 2px 9px; border-radius: 20px;
|
||||
font-size: 0.72rem; font-weight: 700;
|
||||
white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.badge-success { background: #dcfce7; color: #15803d; }
|
||||
.badge-error { background: #fee2e2; color: #b91c1c; }
|
||||
.badge-running { background: #fef9c3; color: #854d0e; }
|
||||
.badge-never { background: #f3f4f6; color: #6b7280; }
|
||||
|
||||
/* Config tag */
|
||||
.feed-tag-row { display: flex; gap: 6px; }
|
||||
.tag { font-size: 0.72rem; font-weight: 600; padding: 2px 7px; border-radius: 4px; }
|
||||
.tag-ok { background: #dcfce7; color: #15803d; }
|
||||
.tag-warn { background: #fef3c7; color: #92400e; }
|
||||
|
||||
/* Stats row */
|
||||
.feed-stats {
|
||||
display: flex; gap: 12px;
|
||||
border-top: 1px solid #f3f4f6; padding-top: 8px;
|
||||
}
|
||||
.feed-stat { display: flex; flex-direction: column; gap: 1px; flex: 1; }
|
||||
.stat-label { font-size: 0.68rem; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.4px; font-weight: 600; }
|
||||
.stat-value { font-size: 0.85rem; font-weight: 700; color: #1f2937; }
|
||||
|
||||
.feed-card-footer { border-top: 1px solid #f3f4f6; padding-top: 8px; }
|
||||
|
||||
/* Run history table */
|
||||
.table-section {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
overflow: hidden; min-height: 0; padding-bottom: 16px;
|
||||
}
|
||||
|
||||
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; white-space: nowrap;
|
||||
}
|
||||
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; }
|
||||
.muted { color: #6b7280; }
|
||||
.mono { font-family: monospace; font-size: 0.78rem; color: #4b5563; }
|
||||
.num-col { text-align: right; padding-right: 1.25rem; font-variant-numeric: tabular-nums; min-width: 60px; }
|
||||
.error-text { color: #dc2626; font-size: 0.78rem; cursor: help; white-space: normal; max-width: 260px; }
|
||||
|
||||
/* Dark mode */
|
||||
:global(.dark-mode) .feed-card { background: #1e293b; border-color: #334155; }
|
||||
:global(.dark-mode) .feed-card.unconfigured { border-color: #78350f; background: #1c1208; }
|
||||
:global(.dark-mode) .feed-name { color: #f1f5f9; }
|
||||
:global(.dark-mode) .stat-value { color: #e2e8f0; }
|
||||
:global(.dark-mode) .feed-stats,
|
||||
:global(.dark-mode) .feed-card-footer { border-top-color: #334155; }
|
||||
|
||||
:global(.dark-mode) .badge-success { background: #052e16; color: #86efac; }
|
||||
:global(.dark-mode) .badge-error { background: #450a0a; color: #fca5a5; }
|
||||
:global(.dark-mode) .badge-running { background: #422006; color: #fcd34d; }
|
||||
:global(.dark-mode) .badge-never { background: #1e293b; color: #94a3b8; }
|
||||
:global(.dark-mode) .tag-ok { background: #052e16; color: #86efac; }
|
||||
:global(.dark-mode) .tag-warn { background: #422006; color: #fcd34d; }
|
||||
:global(.dark-mode) .toolbar { border-bottom-color: #334155; }
|
||||
:global(.dark-mode) .muted { color: #94a3b8; }
|
||||
:global(.dark-mode) .mono { color: #94a3b8; }
|
||||
:global(.dark-mode) .error-text { color: #f87171; }
|
||||
:global(.dark-mode) thead { background: #111827; }
|
||||
:global(.dark-mode) th { 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; }
|
||||
</style>
|
||||
@@ -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<IocType>();
|
||||
|
||||
$: 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);
|
||||
</script>
|
||||
|
||||
<div class="page-wrap">
|
||||
<div class="page-wrapper">
|
||||
|
||||
<div class="header">
|
||||
<div class="header-text">
|
||||
<h1>IOC Management</h1>
|
||||
@@ -133,84 +154,141 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="count">{iocs.length} IOC{iocs.length !== 1 ? 's' : ''}</span>
|
||||
<div class="page-body">
|
||||
|
||||
<div class="type-filter">
|
||||
<select bind:value={typeFilter} on:change={() => { load(); }}>
|
||||
<option value="">All Types</option>
|
||||
{#each IOC_TYPES as t}
|
||||
<option value={t}>{t.replace(/_/g, ' ')}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<!-- LEFT: Filter sidebar -->
|
||||
<div class="filter-sidebar" class:sidebar-collapsed={filterSidebarCollapsed}>
|
||||
<div class="filter-sidebar-toggle-bar">
|
||||
{#if !filterSidebarCollapsed}
|
||||
<span class="filter-sidebar-title">Filters</span>
|
||||
<div class="toggle-bar-right">
|
||||
<span class="sidebar-stat-pill">{filteredIocs.length}</span>
|
||||
<button class="sidebar-toggle-btn"
|
||||
on:click={() => filterSidebarCollapsed = true}
|
||||
title="Collapse filters">◀</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="sidebar-toggle-btn"
|
||||
on:click={() => filterSidebarCollapsed = false}
|
||||
title="Expand filters">▶</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !filterSidebarCollapsed}
|
||||
<div class="filter-sidebar-content">
|
||||
{#if hasActiveFilters}
|
||||
<button class="sidebar-clear-all-btn" on:click={clearAllFilters}>✕ Clear all</button>
|
||||
{/if}
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-section-label">IOC Type</label>
|
||||
<div class="type-pills">
|
||||
{#each IOC_TYPES as t}
|
||||
<button class="type-pill" class:active={selectedTypes.has(t)}
|
||||
on:click={() => toggleTypeFilter(t)}>
|
||||
{t.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-section-label">Visibility</label>
|
||||
<label class="inactive-toggle">
|
||||
<input type="checkbox" bind:checked={showInactive} on:change={load} />
|
||||
Show inactive
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="inactive-toggle">
|
||||
<input type="checkbox" bind:checked={showInactive} on:change={load} />
|
||||
Show inactive
|
||||
</label>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<button class="btn btn-primary btn-sm" on:click={openAdd}>+ Add IOC</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
{#if loading}
|
||||
<div class="table-msg">Loading…</div>
|
||||
{:else if iocs.length === 0}
|
||||
<div class="table-msg">No IOCs found.</div>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Value</th>
|
||||
<th>Description</th>
|
||||
<th>Source</th>
|
||||
<th>Confidence</th>
|
||||
<th>Added</th>
|
||||
<th>Expires</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each iocs as ioc (ioc.id)}
|
||||
<tr class:inactive-row={!ioc.active}>
|
||||
<td>
|
||||
<span class="type-badge">{ioc.iocType.replace(/_/g,' ')}</span>
|
||||
</td>
|
||||
<td>
|
||||
<code class="ioc-value" title={ioc.value}>{ioc.value}</code>
|
||||
</td>
|
||||
<td class="desc-cell" title={ioc.description ?? ''}>{ioc.description ?? '—'}</td>
|
||||
<td class="src-cell">{SRC_LABEL[ioc.source] ?? ioc.source}</td>
|
||||
<td>
|
||||
<span class="conf-badge" style="color:{CONF_COLOR[ioc.confidence]}">{ioc.confidence}</span>
|
||||
</td>
|
||||
<td class="date-cell">{fmtDate(ioc.addedAt)}</td>
|
||||
<td class="date-cell">{fmtDate(ioc.expiresAt)}</td>
|
||||
<td>
|
||||
{#if ioc.active}
|
||||
<span class="badge badge-active">Active</span>
|
||||
{:else}
|
||||
<span class="badge badge-inactive">Inactive</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn-sm btn-edit" on:click={() => openEdit(ioc)}>Edit</button>
|
||||
{#if ioc.active}
|
||||
<button class="btn-sm btn-delete" on:click={() => deactivate(ioc)}>Deactivate</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- RIGHT: chips + content -->
|
||||
<div class="page-right">
|
||||
|
||||
{#if hasActiveFilters}
|
||||
<div class="active-filters">
|
||||
<span class="active-filters-label">Filters:</span>
|
||||
{#each [...selectedTypes] as t}
|
||||
<span class="filter-tag">
|
||||
<span class="filter-tag-dot"></span>
|
||||
{t.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
|
||||
<button class="filter-tag-remove" on:click={() => toggleTypeFilter(t)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="content-frame">
|
||||
<div class="page-main">
|
||||
|
||||
<div class="toolbar">
|
||||
<span class="toolbar-count">{filteredIocs.length} IOC{filteredIocs.length !== 1 ? 's' : ''}</span>
|
||||
<div class="toolbar-right">
|
||||
<button class="btn btn-primary btn-sm" on:click={openAdd}>+ Add IOC</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="table-container">
|
||||
{#if loading}
|
||||
<div class="table-msg">Loading…</div>
|
||||
{:else if filteredIocs.length === 0}
|
||||
<div class="table-msg">{hasActiveFilters ? 'No IOCs match the selected filters.' : 'No IOCs found.'}</div>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Value</th>
|
||||
<th>Description</th>
|
||||
<th>Source</th>
|
||||
<th>Confidence</th>
|
||||
<th>Added</th>
|
||||
<th>Expires</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredIocs as ioc (ioc.id)}
|
||||
<tr class:inactive-row={!ioc.active}>
|
||||
<td>
|
||||
<span class="type-badge">{ioc.iocType.replace(/_/g,' ')}</span>
|
||||
</td>
|
||||
<td>
|
||||
<code class="ioc-value" title={ioc.value}>{ioc.value}</code>
|
||||
</td>
|
||||
<td class="desc-cell" title={ioc.description ?? ''}>{ioc.description ?? '—'}</td>
|
||||
<td class="src-cell">{SRC_LABEL[ioc.source] ?? ioc.source}</td>
|
||||
<td>
|
||||
<span class="conf-badge" style="color:{CONF_COLOR[ioc.confidence]}">{ioc.confidence}</span>
|
||||
</td>
|
||||
<td class="date-cell">{fmtDate(ioc.addedAt)}</td>
|
||||
<td class="date-cell">{fmtDate(ioc.expiresAt)}</td>
|
||||
<td>
|
||||
{#if ioc.active}
|
||||
<span class="badge badge-active">Active</span>
|
||||
{:else}
|
||||
<span class="badge badge-inactive">Inactive</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn-sm btn-edit" on:click={() => openEdit(ioc)}>Edit</button>
|
||||
{#if ioc.active}
|
||||
<button class="btn-sm btn-delete" on:click={() => deactivate(ioc)}>Deactivate</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,41 +357,74 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page-wrap {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Page layout */
|
||||
.page-wrapper {
|
||||
display: flex; flex-direction: column;
|
||||
height: 100%; overflow: hidden;
|
||||
padding: 24px 24px 0;
|
||||
}
|
||||
.page-body {
|
||||
display: flex; flex: 1;
|
||||
overflow: hidden; min-height: 0;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.page-right {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
overflow: hidden; min-height: 0;
|
||||
padding: 0.65rem; gap: 0.5rem;
|
||||
}
|
||||
.content-frame {
|
||||
flex: 1; border: 1px solid #e5e7eb; border-radius: 8px;
|
||||
overflow: hidden; display: flex; flex-direction: column;
|
||||
min-height: 0; background: white;
|
||||
padding: 1rem; gap: 0.75rem;
|
||||
}
|
||||
.page-main {
|
||||
flex: 1; overflow-y: auto;
|
||||
min-width: 0; display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
/* Filter sidebar */
|
||||
.filter-sidebar {
|
||||
width: 220px; min-width: 220px;
|
||||
background: #f8f9fa; border-right: 1px solid #dee2e6;
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease, min-width 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-sidebar.sidebar-collapsed { width: 40px; min-width: 40px; }
|
||||
|
||||
.toolbar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 1rem; gap: 0.75rem;
|
||||
.filter-sidebar-toggle-bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
background: #eef2f7;
|
||||
flex-shrink: 0; min-height: 36px;
|
||||
}
|
||||
.toolbar-left { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.toolbar-right { display: flex; gap: 0.5rem; }
|
||||
.count { color: #6b7280; font-size: var(--font-size-body-sm); white-space: nowrap; }
|
||||
.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; }
|
||||
.sidebar-toggle-btn { background: none; border: 1px solid #d1d5db; border-radius: 4px; padding: 1px 6px; cursor: pointer; font-size: 0.82rem; color: #6b7280; line-height: 1.4; flex-shrink: 0; margin-left: auto; }
|
||||
.sidebar-toggle-btn:hover { background: #e5e7eb; border-color: #9ca3af; }
|
||||
|
||||
.type-filter select {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: var(--font-size-caption);
|
||||
background: white;
|
||||
color: #374151;
|
||||
.filter-sidebar-content { display: flex; flex-direction: column; gap: 0.75rem; padding: 0.75rem 0.65rem; overflow-y: auto; flex: 1; }
|
||||
.filter-section { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.filter-section-label { font-size: var(--font-size-tiny); font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
.sidebar-clear-all-btn { border: none; background: #fee2e2; color: #dc2626; border-radius: 4px; padding: 0.3rem 0.6rem; cursor: pointer; font-size: var(--font-size-tiny); font-weight: 600; text-align: left; }
|
||||
.sidebar-clear-all-btn:hover { background: #fecaca; }
|
||||
|
||||
.type-pills { display: flex; flex-direction: column; gap: 4px; }
|
||||
.type-pill {
|
||||
padding: 5px 10px; border-radius: 6px;
|
||||
border: 1px solid #d1d5db; background: white;
|
||||
font-size: var(--font-size-caption); font-weight: 500;
|
||||
cursor: pointer; color: #374151; text-align: left;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.type-pill:hover { border-color: #3b82f6; color: #3b82f6; background: #eff6ff; }
|
||||
.type-pill.active { background: #2563eb; color: white; border-color: #2563eb; }
|
||||
|
||||
.inactive-toggle {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
@@ -321,49 +432,54 @@
|
||||
}
|
||||
.inactive-toggle input { cursor: pointer; }
|
||||
|
||||
.table-wrapper {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
|
||||
border: 1px solid #e5e7eb;
|
||||
min-height: 0;
|
||||
/* Active filter chips */
|
||||
.active-filters {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem;
|
||||
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 {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.3rem 0.6rem; background: white;
|
||||
border: 1px solid #ddd; border-left: 3px solid #2563eb;
|
||||
border-radius: 4px; font-size: var(--font-size-tiny); font-weight: 500; color: #333;
|
||||
}
|
||||
.filter-tag-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #2563eb; flex-shrink: 0; }
|
||||
.filter-tag-remove {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px; border: none; background: #e5e7eb;
|
||||
border-radius: 50%; cursor: pointer; font-size: var(--font-size-tiny);
|
||||
color: #666; line-height: 1; padding: 0; margin-left: 0.15rem;
|
||||
}
|
||||
.filter-tag-remove:hover { background: #f44336; color: white; }
|
||||
|
||||
/* Toolbar */
|
||||
.toolbar { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
|
||||
.toolbar-right { display: flex; gap: 0.5rem; }
|
||||
.toolbar-count { color: #6b7280; font-size: var(--font-size-body-sm); }
|
||||
|
||||
/* Table */
|
||||
.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 { position: sticky; top: 0; z-index: 5; }
|
||||
thead { background: #f9fafb; position: sticky; top: 0; z-index: 5; }
|
||||
th {
|
||||
background: #f9fafb; color: #374151;
|
||||
font-size: 0.78rem; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.3px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
padding: 0.6rem 0.75rem; text-align: left;
|
||||
}
|
||||
td {
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
color: #1f2937; font-size: var(--font-size-label);
|
||||
padding: 0.55rem 0.75rem;
|
||||
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; }
|
||||
.inactive-row { opacity: 0.55; }
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.68rem; font-weight: 700;
|
||||
padding: 1px 6px; border-radius: 3px;
|
||||
background: #dbeafe; color: #1e40af;
|
||||
font-family: monospace; white-space: nowrap;
|
||||
}
|
||||
|
||||
.ioc-value {
|
||||
font-family: monospace; font-size: 0.78rem;
|
||||
color: #111827; display: block;
|
||||
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
.type-badge { display: inline-block; font-size: 0.68rem; font-weight: 700; padding: 1px 6px; border-radius: 3px; background: #dbeafe; color: #1e40af; font-family: monospace; white-space: nowrap; }
|
||||
.ioc-value { font-family: monospace; font-size: 0.78rem; color: #111827; display: block; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.desc-cell { color: #6b7280; max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.src-cell { color: #6b7280; white-space: nowrap; font-size: var(--font-size-caption); }
|
||||
.date-cell { color: #6b7280; white-space: nowrap; }
|
||||
@@ -380,28 +496,17 @@
|
||||
.btn-delete { background: #fee2e2; color: #b91c1c; }
|
||||
.btn-delete:hover { background: #fecaca; }
|
||||
|
||||
/* Panel */
|
||||
.panel-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.35);
|
||||
z-index: 999; cursor: pointer;
|
||||
}
|
||||
/* Slide-in panel */
|
||||
.panel-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35); z-index: 999; cursor: pointer; }
|
||||
.panel {
|
||||
position: fixed; top: 0; right: 0;
|
||||
width: 480px; height: 100vh;
|
||||
background: white; z-index: 1000;
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: -4px 0 20px rgba(0,0,0,0.15);
|
||||
animation: panelIn 0.25s ease; overflow: hidden;
|
||||
}
|
||||
@keyframes panelIn {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
position: fixed; top: 0; right: 0; width: 480px; height: 100vh;
|
||||
background: white; z-index: 1000; display: flex; flex-direction: column;
|
||||
box-shadow: -4px 0 20px rgba(0,0,0,0.15); animation: panelIn 0.25s ease; overflow: hidden;
|
||||
}
|
||||
@keyframes panelIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
||||
.panel-header {
|
||||
background: linear-gradient(180deg, #081651 0%, #1c49b8 100%);
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
|
||||
padding: 1rem 1.25rem; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
|
||||
}
|
||||
.panel-title { color: white; font-size: 1rem; font-weight: 700; }
|
||||
.panel-subtitle { color: rgba(255,255,255,0.65); font-size: 0.78rem; margin-top: 2px; }
|
||||
@@ -412,42 +517,48 @@
|
||||
flex-shrink: 0; transition: background 0.15s;
|
||||
}
|
||||
.panel-close:hover { background: rgba(255,255,255,0.3); }
|
||||
|
||||
.panel-body {
|
||||
flex: 1; overflow-y: auto; padding: 1.5rem;
|
||||
display: flex; flex-direction: column; gap: 1rem;
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
border-top: 1px solid #e5e7eb; padding: 1rem 1.5rem;
|
||||
display: flex; justify-content: flex-end; gap: 0.75rem;
|
||||
flex-shrink: 0; background: #f9fafb;
|
||||
}
|
||||
.panel-body { flex: 1; overflow-y: auto; padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; }
|
||||
.panel-footer { border-top: 1px solid #e5e7eb; padding: 1rem 1.5rem; display: flex; justify-content: flex-end; gap: 0.75rem; flex-shrink: 0; background: #f9fafb; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.field label { font-size: var(--font-size-label); font-weight: 600; color: #374151; }
|
||||
.field input, .field select, .field textarea {
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid #d1d5db; border-radius: 6px;
|
||||
font-size: var(--font-size-body-sm); color: #111827;
|
||||
font-family: inherit;
|
||||
padding: 0.5rem 0.65rem; border: 1px solid #d1d5db; border-radius: 6px;
|
||||
font-size: var(--font-size-body-sm); color: #111827; font-family: inherit;
|
||||
}
|
||||
.field input:focus, .field select:focus, .field textarea:focus {
|
||||
outline: none; border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59,130,246,0.15);
|
||||
outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.15);
|
||||
}
|
||||
.field textarea { resize: vertical; }
|
||||
.field select:disabled { background: #f3f4f6; color: #6b7280; }
|
||||
|
||||
/* Dark mode */
|
||||
:global(.dark-mode) .form-card { background: #1f2937; border-color: #374151; }
|
||||
:global(.dark-mode) .count { color: #9ca3af; }
|
||||
:global(.dark-mode) .type-filter select { background: #1e293b; border-color: #4b5563; color: #e2e8f0; }
|
||||
:global(.dark-mode) .inactive-toggle { color: #9ca3af; }
|
||||
: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) .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) .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; }
|
||||
:global(.dark-mode) .sidebar-stat-pill { background: #1e2d4a; border-color: #3730a3; color: #93c5fd; }
|
||||
:global(.dark-mode) .sidebar-clear-all-btn { background: #3b1a1a; color: #f87171; }
|
||||
:global(.dark-mode) .type-pill { background: #1e293b; border-color: #334155; color: #94a3b8; }
|
||||
:global(.dark-mode) .type-pill:hover { background: #1e2d4a; border-color: #3b82f6; color: #93c5fd; }
|
||||
:global(.dark-mode) .type-pill.active { background: #2563eb; color: white; border-color: #2563eb; }
|
||||
:global(.dark-mode) .inactive-toggle { color: #9ca3af; }
|
||||
|
||||
:global(.dark-mode) .active-filters-label { color: #9ca3af; }
|
||||
:global(.dark-mode) .filter-tag { background: #1f2937; border-color: #374151; border-left-color: #3b82f6; color: #e5e5e5; }
|
||||
:global(.dark-mode) .filter-tag-remove { background: #374151; color: #e5e7eb; }
|
||||
|
||||
:global(.dark-mode) .toolbar-count { color: #9ca3af; }
|
||||
:global(.dark-mode) .table-card { background: #1f2937; }
|
||||
: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) .table-msg { color: #9ca3af; }
|
||||
|
||||
:global(.dark-mode) .ioc-value { color: #e5e7eb; }
|
||||
:global(.dark-mode) .desc-cell { color: #9ca3af; }
|
||||
:global(.dark-mode) .src-cell { color: #9ca3af; }
|
||||
@@ -458,11 +569,11 @@
|
||||
:global(.dark-mode) .btn-edit:hover { background: #4b5563; }
|
||||
:global(.dark-mode) .btn-delete { background: #3b1a1a; color: #f87171; }
|
||||
:global(.dark-mode) .btn-delete:hover { background: #4c1d1d; }
|
||||
|
||||
:global(.dark-mode) .panel { background: #1e293b; }
|
||||
:global(.dark-mode) .panel-footer { background: #162032; border-color: #334155; }
|
||||
:global(.dark-mode) .field label { color: #e2e8f0; }
|
||||
:global(.dark-mode) .field input, :global(.dark-mode) .field select, :global(.dark-mode) .field textarea {
|
||||
background: #283040; border-color: #4b5563; color: #e2e8f0;
|
||||
}
|
||||
:global(.dark-mode) .table-msg { color: #9ca3af; }
|
||||
</style>
|
||||
|
||||
@@ -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 @@
|
||||
<!-- LEFT: Collapsible filter sidebar -->
|
||||
<div class="filter-sidebar" class:sidebar-collapsed={filterSidebarCollapsed}>
|
||||
|
||||
<div class="filter-sidebar-toggle-bar" class:bar-filters-active={hasActiveFilters}>
|
||||
<div class="filter-sidebar-toggle-bar">
|
||||
{#if !filterSidebarCollapsed}
|
||||
<span class="filter-sidebar-title">Filters</span>
|
||||
<div class="toggle-bar-right">
|
||||
@@ -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; }
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
let severityFilter: ThreatSeverity | '' = initialSeverity;
|
||||
let deviceSearch = '';
|
||||
let deviceSearchInput = '';
|
||||
let filterSidebarCollapsed = false;
|
||||
let filterSidebarCollapsed = true;
|
||||
let searchTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
$: hasActiveFilters = !!severityFilter || !!deviceSearch;
|
||||
@@ -121,7 +121,7 @@
|
||||
onDestroy(() => clearInterval(tickInterval));
|
||||
</script>
|
||||
|
||||
<div class="page-wrap">
|
||||
<div class="page-wrapper">
|
||||
<div class="header">
|
||||
<div class="header-text">
|
||||
<h1>Threat Events</h1>
|
||||
@@ -160,7 +160,7 @@
|
||||
<div class="page-body">
|
||||
<!-- Filter sidebar -->
|
||||
<div class="filter-sidebar" class:sidebar-collapsed={filterSidebarCollapsed}>
|
||||
<div class="filter-sidebar-toggle-bar" class:bar-filters-active={hasActiveFilters}>
|
||||
<div class="filter-sidebar-toggle-bar">
|
||||
{#if !filterSidebarCollapsed}
|
||||
<span class="filter-sidebar-title">Filters</span>
|
||||
<div class="toggle-bar-right">
|
||||
@@ -203,19 +203,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="page-main">
|
||||
<div class="page-right">
|
||||
{#if hasActiveFilters}
|
||||
<div class="active-filters">
|
||||
<span class="active-filters-label">Filters:</span>
|
||||
{#if severityFilter}
|
||||
<span class="filter-tag" style="border-color:{SEV_COLOR[severityFilter]}">
|
||||
<span class="filter-tag" style="border-left-color:{SEV_COLOR[severityFilter]}">
|
||||
<span class="filter-tag-dot" style="background:{SEV_COLOR[severityFilter]}"></span>
|
||||
Severity: {severityFilter}
|
||||
<button class="filter-tag-remove" on:click={() => { severityFilter = ''; page = 0; load(); }}>×</button>
|
||||
</span>
|
||||
{/if}
|
||||
{#if deviceSearch}
|
||||
<span class="filter-tag" style="border-color:#6b7280">
|
||||
<span class="filter-tag" style="border-left-color:#6b7280">
|
||||
<span class="filter-tag-dot" style="background:#6b7280"></span>
|
||||
Device: {deviceSearch}
|
||||
<button class="filter-tag-remove" on:click={() => { deviceSearch = ''; deviceSearchInput = ''; page = 0; load(); }}>×</button>
|
||||
@@ -224,52 +224,58 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="table-card">
|
||||
<Pagination {page} {totalPages} {totalElements} {size}
|
||||
on:pageChange={e => { page = e.detail.page; load(); }}
|
||||
on:pageSizeChange={e => { size = e.detail.size; page = 0; load(); }} />
|
||||
<div class="table-container">
|
||||
{#if loading}
|
||||
<div class="table-loading">Loading…</div>
|
||||
{:else if events.length === 0}
|
||||
<div class="table-empty">No threat events found{hasActiveFilters ? ' matching current filters' : ''}.</div>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Severity</th>
|
||||
<th>Signal</th>
|
||||
<th>Device</th>
|
||||
<th>Attack Phase</th>
|
||||
<th>IOC Match</th>
|
||||
<th>Detected</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each events as ev (ev.id)}
|
||||
<div class="content-frame">
|
||||
<div class="toolbar">
|
||||
<span class="toolbar-count">{totalElements} event{totalElements !== 1 ? 's' : ''}</span>
|
||||
<div class="toolbar-right"></div>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<Pagination {page} {totalPages} {totalElements} {size}
|
||||
on:pageChange={e => { page = e.detail.page; load(); }}
|
||||
on:pageSizeChange={e => { size = e.detail.size; page = 0; load(); }} />
|
||||
<div class="table-container">
|
||||
{#if loading}
|
||||
<div class="table-loading">Loading…</div>
|
||||
{:else if events.length === 0}
|
||||
<div class="table-empty">No threat events found{hasActiveFilters ? ' matching current filters' : ''}.</div>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="sev-badge" style="background:{SEV_COLOR[ev.severity]}20;color:{SEV_COLOR[ev.severity]};border:1px solid {SEV_COLOR[ev.severity]}40">
|
||||
{ev.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td class="signal-cell" title={ev.signalKey}>{fmtSignal(ev.signalKey)}</td>
|
||||
<td class="mono-cell">{ev.deviceAgentId ?? '—'}</td>
|
||||
<td>{ev.attackPhase ? PHASE_LABEL[ev.attackPhase] ?? ev.attackPhase : '—'}</td>
|
||||
<td class="ioc-cell" title={ev.matchedIoc?.value ?? ''}>
|
||||
{#if ev.matchedIoc}
|
||||
<span class="ioc-type-badge">{ev.matchedIoc.iocType}</span>
|
||||
<span class="ioc-val">{ev.matchedIoc.value}</span>
|
||||
{:else}
|
||||
<span class="muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="date-cell">{fmt(ev.detectedAt)}</td>
|
||||
<th>Severity</th>
|
||||
<th>Signal</th>
|
||||
<th>Device</th>
|
||||
<th>Attack Phase</th>
|
||||
<th>IOC Match</th>
|
||||
<th>Detected</th>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each events as ev (ev.id)}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="sev-badge" style="background:{SEV_COLOR[ev.severity]}20;color:{SEV_COLOR[ev.severity]};border:1px solid {SEV_COLOR[ev.severity]}40">
|
||||
{ev.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td class="signal-cell" title={ev.signalKey}>{fmtSignal(ev.signalKey)}</td>
|
||||
<td class="mono-cell">{ev.deviceAgentId ?? '—'}</td>
|
||||
<td>{ev.attackPhase ? PHASE_LABEL[ev.attackPhase] ?? ev.attackPhase : '—'}</td>
|
||||
<td class="ioc-cell" title={ev.matchedIoc?.value ?? ''}>
|
||||
{#if ev.matchedIoc}
|
||||
<span class="ioc-type-badge">{ev.matchedIoc.iocType}</span>
|
||||
<span class="ioc-val">{ev.matchedIoc.value}</span>
|
||||
{:else}
|
||||
<span class="muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="date-cell">{fmt(ev.detectedAt)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,7 +283,7 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-wrap {
|
||||
.page-wrapper {
|
||||
display: flex; flex-direction: column;
|
||||
height: 100%; overflow: hidden;
|
||||
padding: 24px 24px 0;
|
||||
@@ -304,7 +310,7 @@
|
||||
:global(.pulsing) { animation: countdown-pulse 0.8s ease-in-out infinite; }
|
||||
|
||||
.page-body {
|
||||
display: flex; flex: 1; overflow: hidden; min-height: 0;
|
||||
display: flex; flex: 1; overflow: hidden; min-height: 0; margin: 0 0 16px;
|
||||
}
|
||||
|
||||
/* Filter sidebar */
|
||||
@@ -344,15 +350,19 @@
|
||||
.sidebar-clear-all-btn:hover { background: #fecaca; }
|
||||
|
||||
/* Active filters */
|
||||
.active-filters { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.active-filters-label { font-size: var(--font-size-tiny); font-weight: 600; color: #6b7280; }
|
||||
.filter-tag { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 4px; border-left: 4px solid; background: white; font-size: var(--font-size-tiny); border: 1px solid; }
|
||||
.filter-tag-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||
.filter-tag-remove { background: none; border: none; cursor: pointer; color: #9ca3af; font-size: 0.9rem; padding: 0 0 0 2px; line-height: 1; }
|
||||
.filter-tag-remove:hover { color: #374151; }
|
||||
.active-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; 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 { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.6rem; background: white; border: 1px solid #ddd; border-left-width: 3px; border-radius: 4px; font-size: var(--font-size-tiny); font-weight: 500; color: #333; }
|
||||
.filter-tag-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.filter-tag-remove { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border: none; background: #e5e7eb; border-radius: 50%; cursor: pointer; font-size: var(--font-size-tiny); color: #666; line-height: 1; padding: 0; margin-left: 0.15rem; }
|
||||
.filter-tag-remove:hover { background: #f44336; color: white; }
|
||||
|
||||
/* Content */
|
||||
.page-main { flex: 1; overflow-y: auto; padding: 16px 24px 24px; min-width: 0; display: flex; flex-direction: column; }
|
||||
.page-right { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0; padding: 0.65rem; gap: 0.5rem; }
|
||||
.content-frame { flex: 1; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; min-height: 0; background: white; padding: 1rem; gap: 0.75rem; }
|
||||
.toolbar { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
|
||||
.toolbar-count { font-size: var(--font-size-body-sm); color: #6b7280; }
|
||||
.toolbar-right { display: flex; gap: 0.5rem; }
|
||||
|
||||
.table-card {
|
||||
background: white;
|
||||
@@ -437,5 +447,10 @@
|
||||
:global(.dark-mode) .date-cell { color: #9ca3af; }
|
||||
:global(.dark-mode) .table-loading, :global(.dark-mode) .table-empty { color: #9ca3af; }
|
||||
:global(.dark-mode) .ioc-type-badge { background: #1e3a5f; color: #93c5fd; }
|
||||
:global(.dark-mode) .filter-tag { background: #1e293b; }
|
||||
:global(.dark-mode) .content-frame { border-color: #374151; background: #1f2937; }
|
||||
:global(.dark-mode) .toolbar-count { color: #9ca3af; }
|
||||
:global(.dark-mode) .active-filters { background: #111827; border-color: #374151; }
|
||||
:global(.dark-mode) .active-filters-label { color: #9ca3af; }
|
||||
:global(.dark-mode) .filter-tag { background: #1f2937; border-color: #374151; color: #e5e5e5; }
|
||||
:global(.dark-mode) .filter-tag-remove { background: #374151; color: #e5e7eb; }
|
||||
</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 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;
|
||||
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<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/refresh/${feedName}`),
|
||||
};
|
||||
|
||||
@@ -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<List<Map<String, Object>>> status() {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (String name : feedService.feedNames()) {
|
||||
Optional<IocFeedRun> last = feedRunRepository.findTopByFeedNameOrderByStartedAtDesc(name);
|
||||
Map<String, Object> 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<Page<IocFeedRun>> 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<Map<String, String>> 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<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));
|
||||
}
|
||||
feedThread("ioc-feed-manual-" + upper, () -> feedService.refreshFeed(upper)).start();
|
||||
return ResponseEntity.accepted().body(Map.of("status", "refresh_started", "feed", upper));
|
||||
}
|
||||
}
|
||||
@@ -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<Map<String, Object>> 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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ThreatIoc> fetch() {
|
||||
if (!isConfigured()) {
|
||||
log.info("MalwareBazaar: no API key configured, skipping");
|
||||
return List.of();
|
||||
}
|
||||
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);
|
||||
headers.set("Auth-Key", apiKey);
|
||||
|
||||
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");
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<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;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<ThreatIoc> fetch() {
|
||||
if (!isConfigured()) {
|
||||
log.info("ThreatFox: no API key configured, skipping");
|
||||
return List.of();
|
||||
}
|
||||
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);
|
||||
headers.set("Auth-Key", apiKey);
|
||||
|
||||
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);
|
||||
headers.set("Auth-Key", apiKey);
|
||||
|
||||
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;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<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(@Param("feedName") String feedName);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public interface ThreatEventRepository extends JpaRepository<ThreatEvent, Long>
|
||||
|
||||
Page<ThreatEvent> findBySeverityOrderByDetectedAtDesc(ThreatEvent.ThreatSeverity severity, Pageable pageable);
|
||||
|
||||
long countBySeverity(ThreatEvent.ThreatSeverity severity);
|
||||
long countBySeverityAndDetectedAtAfter(ThreatEvent.ThreatSeverity severity, Instant since);
|
||||
|
||||
Page<ThreatEvent> findAllByOrderByDetectedAtDesc(Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -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<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.setReportedAt(ioc.getReportedAt());
|
||||
e.setExpiresAt(ioc.getExpiresAt());
|
||||
iocRepository.save(e);
|
||||
updated++;
|
||||
} else {
|
||||
iocRepository.save(ioc);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
return new int[]{ added, updated };
|
||||
}
|
||||
|
||||
public List<String> 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE threat_ioc ADD COLUMN reported_at TIMESTAMPTZ;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE threat_ioc ALTER COLUMN source_ref TYPE TEXT;
|
||||
Reference in New Issue
Block a user