From 43b56fb85966020de9b9b300ac567a49b6136f98 Mon Sep 17 00:00:00 2001 From: Johannes Date: Mon, 15 Jun 2026 20:03:14 -0400 Subject: [PATCH 01/15] fix(frontend): remove version string from sidebar, bump to v1.0.0 Co-Authored-By: Claude Sonnet 4.6 --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- frontend/src/App.svelte | 7 ------- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3258926..2a02ffb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { - "name": "hiveops-APPNAME-frontend", - "version": "1.0.1-dev", + "name": "hiveops-aria-frontend", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "hiveops-APPNAME-frontend", - "version": "1.0.1-dev", + "name": "hiveops-aria-frontend", + "version": "1.0.0", "dependencies": { "axios": "^1.6.0" }, diff --git a/frontend/package.json b/frontend/package.json index 9eb7bf5..8282984 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "hiveops-aria-frontend", - "version": "1.0.1-dev", + "version": "1.0.0", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 8ec7cbc..c0daa11 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -9,9 +9,6 @@ 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'; @@ -58,9 +55,6 @@ {/if} - {#if !sidebarCollapsed} - v{appVersion} - {/if} {#if !sidebarCollapsed && userInfo} @@ -125,6 +134,8 @@ {:else if currentView === 'posture'} + {:else if currentView === 'feeds'} + {/if} diff --git a/frontend/src/components/IocFeeds.svelte b/frontend/src/components/IocFeeds.svelte new file mode 100644 index 0000000..995dfc5 --- /dev/null +++ b/frontend/src/components/IocFeeds.svelte @@ -0,0 +1,591 @@ + + +
+ + +
+
+

IOC Feeds

+

Automated threat intelligence ingestion β€” OTX, MalwareBazaar, ThreatFox

+
+
+ +
+
+ + +
+ {#if feedsLoading && feeds.length === 0} +
Loading feed status…
+ {:else} +
+ {#each orderedFeeds as feed (feed.feedName)} + {@const meta = FEED_META[feed.feedName] ?? { label: feed.feedName, icon: 'πŸ“‘', requiresKey: false }} +
+
+
{meta.icon}
+
+
{meta.label}
+
{feed.feedName}
+
+ + {statusLabel(feed.lastRunStatus)} + +
+ +
+ {#if feed.configured} + Configured + {:else if meta.requiresKey} + Not configured β€” API key required + {:else} + No key required + {/if} +
+ +
+
+ Last run + {relativeTime(feed.lastRunAt)} +
+
+ IOCs added + {fmtCount(feed.lastIocsAdded)} +
+
+ Total from feed + {fmtCount(feed.totalIocsFromFeed)} +
+
+ + +
+ {/each} +
+ {/if} +
+ + +
+
+ Run History + {runsTotalElements.toLocaleString()} run{runsTotalElements !== 1 ? 's' : ''} +
+ +
+ { runsPage = e.detail.page; loadRuns(runsPage); }} + /> + +
+ {#if runsLoading} +
Loading run history…
+ {:else if runs.length === 0} +
No feed runs recorded yet. Trigger a refresh to see results here.
+ {:else} + + + + + + + + + + + + + + {#each runs as run (run.id)} + + + + + + + + + + {/each} + +
FeedStartedDurationStatusIOCs AddedIOCs UpdatedError
+ + {FEED_META[run.feedName]?.icon ?? 'πŸ“‘'} + {FEED_META[run.feedName]?.label ?? run.feedName} + + {formatTs(run.startedAt)}{duration(run.startedAt, run.completedAt)} + + {statusLabel(run.status)} + + {run.iocsAdded.toLocaleString()}{run.iocsUpdated.toLocaleString()} + {#if run.errorMessage} + + {run.errorMessage.length > 60 + ? run.errorMessage.substring(0, 60) + '…' + : run.errorMessage} + + {:else} + β€” + {/if} +
+ {/if} +
+
+
+ +
+ + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a3b6ad1..697cc37 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -29,7 +29,7 @@ export type ThreatSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'INFO'; export type EventType = 'PHYSICAL' | 'OS_EVENT' | 'FILE_SYSTEM' | 'COMPOSITE'; export type AttackPhase = 'PHYSICAL_ACCESS' | 'MALWARE_STAGING' | 'MALWARE_EXECUTION' | 'PERSISTENCE' | 'CLEANUP'; export type IocType = 'FILENAME' | 'MD5' | 'REGISTRY_KEY' | 'DIRECTORY' | 'SERVICE_NAME' | 'IP' | 'FILE_PATH'; -export type IocSource = 'FBI_FLASH' | 'FS_ISAC' | 'MANUAL'; +export type IocSource = 'FBI_FLASH' | 'FS_ISAC' | 'MANUAL' | 'OTX' | 'MALWARE_BAZAAR' | 'THREAT_FOX'; export type ConfidenceLevel = 'HIGH' | 'MEDIUM' | 'LOW'; // ── Entities ───────────────────────────────────────────────────────── @@ -115,6 +115,27 @@ export interface PageResponse { }; } +export type IocFeedStatus = { + feedName: string; + enabled: boolean; + configured: boolean; + lastRunAt?: string; + lastRunStatus?: string; + lastIocsAdded?: number; + totalIocsFromFeed?: number; +}; + +export type IocFeedRun = { + id: number; + feedName: string; + startedAt: string; + completedAt?: string; + status: string; + iocsAdded: number; + iocsUpdated: number; + errorMessage?: string; +}; + export interface CreateIocRequest { iocType: IocType; value: string; @@ -158,4 +179,16 @@ export const ariaApi = { getPosture: (deviceId: number) => api.get(`/api/aria/posture/${deviceId}`), + + getFeedStatus: () => + api.get('/api/aria/feeds/status'), + + getFeedRuns: (params?: { page?: number; size?: number }) => + api.get>('/api/aria/feeds/runs', { params }), + + refreshAllFeeds: () => + api.post<{ message: string }>('/api/aria/feeds/refresh'), + + refreshFeed: (feedName: string) => + api.post<{ message: string }>(`/api/aria/feeds/${feedName}/refresh`), }; diff --git a/src/main/java/com/hiveops/aria/controller/IocFeedController.java b/src/main/java/com/hiveops/aria/controller/IocFeedController.java new file mode 100644 index 0000000..77db5a4 --- /dev/null +++ b/src/main/java/com/hiveops/aria/controller/IocFeedController.java @@ -0,0 +1,67 @@ +package com.hiveops.aria.controller; + +import com.hiveops.aria.entity.IocFeedRun; +import com.hiveops.aria.repository.IocFeedRunRepository; +import com.hiveops.aria.service.IocFeedService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@RestController +@RequestMapping("/api/aria/feeds") +@RequiredArgsConstructor +public class IocFeedController { + + private final IocFeedService feedService; + private final IocFeedRunRepository feedRunRepository; + + /** Latest run per feed β€” used by the status cards in the UI */ + @GetMapping("/status") + @PreAuthorize("hasRole('BCOS_ADMIN')") + public ResponseEntity> status() { + List names = feedService.feedNames(); + Map result = new java.util.LinkedHashMap<>(); + for (String name : names) { + Optional last = feedRunRepository.findTopByFeedNameOrderByStartedAtDesc(name); + result.put(name, last.orElse(null)); + } + return ResponseEntity.ok(result); + } + + /** Recent run history β€” default 50 rows */ + @GetMapping("/runs") + @PreAuthorize("hasRole('BCOS_ADMIN')") + public ResponseEntity> runs( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + return ResponseEntity.ok( + feedRunRepository.findAllByOrderByStartedAtDesc(PageRequest.of(page, size)).getContent() + ); + } + + /** Trigger all feeds immediately */ + @PostMapping("/refresh") + @PreAuthorize("hasRole('BCOS_ADMIN')") + public ResponseEntity> refreshAll() { + new Thread(() -> feedService.refreshAll(), "ioc-feed-manual-all").start(); + return ResponseEntity.accepted().body(Map.of("status", "refresh_started")); + } + + /** Trigger one specific feed */ + @PostMapping("/refresh/{feedName}") + @PreAuthorize("hasRole('BCOS_ADMIN')") + public ResponseEntity> refreshOne(@PathVariable String feedName) { + String upper = feedName.toUpperCase(); + if (!feedService.feedNames().contains(upper)) { + return ResponseEntity.badRequest().body(Map.of("error", "Unknown feed: " + feedName)); + } + new Thread(() -> feedService.refreshFeed(upper), "ioc-feed-manual-" + upper).start(); + return ResponseEntity.accepted().body(Map.of("status", "refresh_started", "feed", upper)); + } +} diff --git a/src/main/java/com/hiveops/aria/entity/IocFeedRun.java b/src/main/java/com/hiveops/aria/entity/IocFeedRun.java new file mode 100644 index 0000000..c6bcf30 --- /dev/null +++ b/src/main/java/com/hiveops/aria/entity/IocFeedRun.java @@ -0,0 +1,45 @@ +package com.hiveops.aria.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table(name = "ioc_feed_run") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class IocFeedRun { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "feed_name", nullable = false) + private String feedName; + + @Column(name = "started_at", nullable = false) + @Builder.Default + private Instant startedAt = Instant.now(); + + @Column(name = "completed_at") + private Instant completedAt; + + @Column(nullable = false) + @Builder.Default + private String status = "RUNNING"; + + @Column(name = "iocs_added", nullable = false) + @Builder.Default + private int iocsAdded = 0; + + @Column(name = "iocs_updated", nullable = false) + @Builder.Default + private int iocsUpdated = 0; + + @Column(name = "error_message") + private String errorMessage; +} diff --git a/src/main/java/com/hiveops/aria/entity/ThreatIoc.java b/src/main/java/com/hiveops/aria/entity/ThreatIoc.java index d8be8fe..62fdd1d 100644 --- a/src/main/java/com/hiveops/aria/entity/ThreatIoc.java +++ b/src/main/java/com/hiveops/aria/entity/ThreatIoc.java @@ -56,7 +56,7 @@ public class ThreatIoc { } public enum IocSource { - FBI_FLASH, FS_ISAC, MANUAL + FBI_FLASH, FS_ISAC, MANUAL, OTX, MALWARE_BAZAAR, THREAT_FOX } public enum ConfidenceLevel { diff --git a/src/main/java/com/hiveops/aria/feed/MalwareBazaarFeedClient.java b/src/main/java/com/hiveops/aria/feed/MalwareBazaarFeedClient.java new file mode 100644 index 0000000..9493f79 --- /dev/null +++ b/src/main/java/com/hiveops/aria/feed/MalwareBazaarFeedClient.java @@ -0,0 +1,95 @@ +package com.hiveops.aria.feed; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hiveops.aria.entity.ThreatIoc; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +/** + * Fetches ATM-related malware samples from MalwareBazaar (abuse.ch) β€” no API key required. + */ +@Component +@Slf4j +public class MalwareBazaarFeedClient { + + private static final String API_URL = "https://mb-api.abuse.ch/api/v1/"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final RestTemplate restTemplate = new RestTemplate(); + + public List fetch() { + List result = new ArrayList<>(); + + // Query recent samples tagged with ATM-related tags + for (String tag : List.of("ATM", "jackpotting", "NCR", "Diebold", "GreenDispenser", "Tyupkin")) { + result.addAll(queryByTag(tag)); + } + + log.info("MalwareBazaar: fetched {} raw indicators", result.size()); + return result; + } + + private List queryByTag(String tag) { + List out = new ArrayList<>(); + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("query", "get_taginfo"); + body.add("tag", tag); + body.add("limit", "100"); + + HttpEntity> entity = new HttpEntity<>(body, headers); + String response = restTemplate.postForObject(API_URL, entity, String.class); + + JsonNode root = MAPPER.readTree(response); + if (!"ok".equals(root.path("query_status").asText())) return out; + + for (JsonNode sample : root.path("data")) { + String sha256 = sample.path("sha256_hash").asText("").trim(); + String md5 = sample.path("md5_hash").asText("").trim(); + String name = sample.path("file_name").asText("").trim(); + String sigName = sample.path("signature").asText("Unknown"); + + if (!md5.isBlank()) { + out.add(ThreatIoc.builder() + .iocType(ThreatIoc.IocType.MD5) + .value(md5) + .description("MalwareBazaar tag:" + tag + " sig:" + sigName) + .source(ThreatIoc.IocSource.MALWARE_BAZAAR) + .sourceRef(sha256.isBlank() ? tag : sha256) + .confidence(ThreatIoc.ConfidenceLevel.HIGH) + .expiresAt(Instant.now().plus(365, ChronoUnit.DAYS)) + .build()); + } + if (!name.isBlank()) { + out.add(ThreatIoc.builder() + .iocType(ThreatIoc.IocType.FILENAME) + .value(name) + .description("MalwareBazaar tag:" + tag + " sig:" + sigName) + .source(ThreatIoc.IocSource.MALWARE_BAZAAR) + .sourceRef(sha256.isBlank() ? tag : sha256) + .confidence(ThreatIoc.ConfidenceLevel.MEDIUM) + .expiresAt(Instant.now().plus(365, ChronoUnit.DAYS)) + .build()); + } + } + } catch (Exception e) { + log.warn("MalwareBazaar: error querying tag {}: {}", tag, e.getMessage()); + } + return out; + } +} diff --git a/src/main/java/com/hiveops/aria/feed/OtxFeedClient.java b/src/main/java/com/hiveops/aria/feed/OtxFeedClient.java new file mode 100644 index 0000000..6ec625b --- /dev/null +++ b/src/main/java/com/hiveops/aria/feed/OtxFeedClient.java @@ -0,0 +1,104 @@ +package com.hiveops.aria.feed; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hiveops.aria.entity.ThreatIoc; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +@Component +@Slf4j +public class OtxFeedClient { + + private static final String BASE_URL = "https://otx.alienvault.com/api/v1"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Value("${aria.feed.otx.api-key:}") + private String apiKey; + + private final RestTemplate restTemplate = new RestTemplate(); + + public boolean isConfigured() { + return apiKey != null && !apiKey.isBlank(); + } + + public List fetch() { + if (!isConfigured()) { + log.info("OTX: no API key configured, skipping"); + return List.of(); + } + + List result = new ArrayList<>(); + String since = Instant.now().minus(8, ChronoUnit.DAYS).toString(); + + // Subscribed pulses modified in the last 8 days + fetchPulses(BASE_URL + "/pulses/subscribed?modified_since=" + since + "&limit=200", result); + + // ATM-specific keyword searches + for (String query : List.of("atm+malware", "jackpotting", "atm+skimming")) { + fetchPulses(BASE_URL + "/search/pulses?q=" + query + "&limit=10", result); + } + + log.info("OTX: fetched {} raw indicators", result.size()); + return result; + } + + private void fetchPulses(String url, List out) { + try { + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.set("X-OTX-API-KEY", apiKey); + org.springframework.http.HttpEntity entity = new org.springframework.http.HttpEntity<>(headers); + + org.springframework.http.ResponseEntity response = + restTemplate.exchange(URI.create(url), + org.springframework.http.HttpMethod.GET, entity, String.class); + + JsonNode root = MAPPER.readTree(response.getBody()); + JsonNode results = root.path("results"); + if (results.isMissingNode()) results = root.path("pulse"); + + for (JsonNode pulse : results) { + JsonNode indicators = pulse.path("indicators"); + for (JsonNode ind : indicators) { + ThreatIoc ioc = mapIndicator(ind, pulse.path("name").asText("")); + if (ioc != null) out.add(ioc); + } + } + } catch (Exception e) { + log.warn("OTX: error fetching {}: {}", url, e.getMessage()); + } + } + + private ThreatIoc mapIndicator(JsonNode ind, String pulseName) { + String type = ind.path("type").asText(""); + String value = ind.path("indicator").asText("").trim(); + if (value.isBlank()) return null; + + ThreatIoc.IocType iocType = switch (type) { + case "FileHash-MD5" -> ThreatIoc.IocType.MD5; + case "FilePath" -> ThreatIoc.IocType.FILE_PATH; + case "IPv4" -> ThreatIoc.IocType.IP; + case "Win-Registry-Key" -> ThreatIoc.IocType.REGISTRY_KEY; + default -> null; + }; + if (iocType == null) return null; + + return ThreatIoc.builder() + .iocType(iocType) + .value(value) + .description("OTX pulse: " + pulseName) + .source(ThreatIoc.IocSource.OTX) + .sourceRef(pulseName) + .confidence(ThreatIoc.ConfidenceLevel.MEDIUM) + .expiresAt(Instant.now().plus(90, ChronoUnit.DAYS)) + .build(); + } +} diff --git a/src/main/java/com/hiveops/aria/feed/ThreatFoxFeedClient.java b/src/main/java/com/hiveops/aria/feed/ThreatFoxFeedClient.java new file mode 100644 index 0000000..a3b3766 --- /dev/null +++ b/src/main/java/com/hiveops/aria/feed/ThreatFoxFeedClient.java @@ -0,0 +1,159 @@ +package com.hiveops.aria.feed; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hiveops.aria.entity.ThreatIoc; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Fetches ATM-related IOCs from ThreatFox (abuse.ch) β€” no API key required. + */ +@Component +@Slf4j +public class ThreatFoxFeedClient { + + private static final String API_URL = "https://threatfox-api.abuse.ch/api/v1/"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final RestTemplate restTemplate = new RestTemplate(); + + public List fetch() { + List result = new ArrayList<>(); + + // Search by ATM-specific malware families + for (String malware : List.of("ATMii", "GreenDispenser", "Tyupkin", "Ploutus", "SUCEFUL", "Ripper")) { + result.addAll(queryByMalware(malware)); + } + + // Recent IOCs from last 7 days β€” filter for ATM relevance in caller + result.addAll(queryRecent()); + + log.info("ThreatFox: fetched {} raw indicators", result.size()); + return result; + } + + private List queryByMalware(String malware) { + List out = new ArrayList<>(); + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + String body = MAPPER.writeValueAsString(Map.of( + "query", "iocs_by_malware_family", + "malware_family", malware, + "limit", 100 + )); + + HttpEntity entity = new HttpEntity<>(body, headers); + String response = restTemplate.postForObject(API_URL, entity, String.class); + + out.addAll(parseResponse(response, malware)); + } catch (Exception e) { + log.warn("ThreatFox: error querying malware {}: {}", malware, e.getMessage()); + } + return out; + } + + private List queryRecent() { + List out = new ArrayList<>(); + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + String body = MAPPER.writeValueAsString(Map.of( + "query", "get_iocs", + "days", 7 + )); + + HttpEntity entity = new HttpEntity<>(body, headers); + String response = restTemplate.postForObject(API_URL, entity, String.class); + + JsonNode root = MAPPER.readTree(response); + if (!"ok".equals(root.path("query_status").asText())) return out; + + for (JsonNode ioc : root.path("data")) { + // Only include if tagged or malware family suggests ATM relevance + String malwareFamily = ioc.path("malware").asText("").toLowerCase(); + boolean atmRelated = malwareFamily.contains("atm") || malwareFamily.contains("diebold") + || malwareFamily.contains("ncr") || malwareFamily.contains("ploutus") + || malwareFamily.contains("tyupkin") || malwareFamily.contains("dispenser"); + if (!atmRelated) continue; + + ThreatIoc mapped = mapIoc(ioc, malwareFamily); + if (mapped != null) out.add(mapped); + } + } catch (Exception e) { + log.warn("ThreatFox: error querying recent IOCs: {}", e.getMessage()); + } + return out; + } + + private List parseResponse(String response, String malware) { + List out = new ArrayList<>(); + try { + JsonNode root = MAPPER.readTree(response); + if (!"ok".equals(root.path("query_status").asText())) return out; + for (JsonNode ioc : root.path("data")) { + ThreatIoc mapped = mapIoc(ioc, malware); + if (mapped != null) out.add(mapped); + } + } catch (Exception e) { + log.warn("ThreatFox: parse error for {}: {}", malware, e.getMessage()); + } + return out; + } + + private ThreatIoc mapIoc(JsonNode ioc, String malware) { + String iocType = ioc.path("ioc_type").asText("").toLowerCase(); + String value = ioc.path("ioc").asText("").trim(); + if (value.isBlank()) return null; + + ThreatIoc.IocType type = switch (iocType) { + case "md5_hash" -> ThreatIoc.IocType.MD5; + case "ip:port", "ip" -> { + // Strip port if present + String ip = value.contains(":") ? value.substring(0, value.lastIndexOf(':')) : value; + yield ThreatIoc.IocType.IP; + } + case "filepath" -> ThreatIoc.IocType.FILE_PATH; + case "filename" -> ThreatIoc.IocType.FILENAME; + default -> null; + }; + if (type == null) return null; + + // For IP:port, strip the port + if ("ip:port".equals(iocType) && value.contains(":")) { + value = value.substring(0, value.lastIndexOf(':')); + } + + String iocId = ioc.path("id").asText(malware); + String confidence = ioc.path("confidence_level").asText("50"); + int confidenceInt = 50; + try { confidenceInt = Integer.parseInt(confidence); } catch (NumberFormatException ignored) {} + + ThreatIoc.ConfidenceLevel level = confidenceInt >= 75 + ? ThreatIoc.ConfidenceLevel.HIGH + : confidenceInt >= 50 ? ThreatIoc.ConfidenceLevel.MEDIUM : ThreatIoc.ConfidenceLevel.LOW; + + return ThreatIoc.builder() + .iocType(type) + .value(value) + .description("ThreatFox malware:" + malware) + .source(ThreatIoc.IocSource.THREAT_FOX) + .sourceRef(iocId) + .confidence(level) + .expiresAt(Instant.now().plus(180, ChronoUnit.DAYS)) + .build(); + } +} diff --git a/src/main/java/com/hiveops/aria/repository/IocFeedRunRepository.java b/src/main/java/com/hiveops/aria/repository/IocFeedRunRepository.java new file mode 100644 index 0000000..7683f4e --- /dev/null +++ b/src/main/java/com/hiveops/aria/repository/IocFeedRunRepository.java @@ -0,0 +1,19 @@ +package com.hiveops.aria.repository; + +import com.hiveops.aria.entity.IocFeedRun; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface IocFeedRunRepository extends JpaRepository { + + Page findAllByOrderByStartedAtDesc(Pageable pageable); + + Optional findTopByFeedNameOrderByStartedAtDesc(String feedName); + + @Query("SELECT COUNT(r) FROM IocFeedRun r WHERE r.feedName = :feedName AND r.status = 'RUNNING'") + long countRunning(String feedName); +} diff --git a/src/main/java/com/hiveops/aria/service/IocFeedService.java b/src/main/java/com/hiveops/aria/service/IocFeedService.java new file mode 100644 index 0000000..39cf322 --- /dev/null +++ b/src/main/java/com/hiveops/aria/service/IocFeedService.java @@ -0,0 +1,119 @@ +package com.hiveops.aria.service; + +import com.hiveops.aria.entity.IocFeedRun; +import com.hiveops.aria.entity.ThreatIoc; +import com.hiveops.aria.feed.MalwareBazaarFeedClient; +import com.hiveops.aria.feed.OtxFeedClient; +import com.hiveops.aria.feed.ThreatFoxFeedClient; +import com.hiveops.aria.repository.IocFeedRunRepository; +import com.hiveops.aria.repository.ThreatIocRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class IocFeedService { + + private final OtxFeedClient otxClient; + private final MalwareBazaarFeedClient malwareBazaarClient; + private final ThreatFoxFeedClient threatFoxClient; + private final ThreatIocRepository iocRepository; + private final IocFeedRunRepository feedRunRepository; + + private static final Map FEED_SOURCES = Map.of( + "OTX", ThreatIoc.IocSource.OTX, + "MALWARE_BAZAAR", ThreatIoc.IocSource.MALWARE_BAZAAR, + "THREAT_FOX", ThreatIoc.IocSource.THREAT_FOX + ); + + @Scheduled(cron = "${aria.feed.refresh.cron:0 0 3 * * *}") + public void scheduledRefresh() { + log.info("IOC feed scheduled refresh starting"); + refreshAll(); + } + + public void refreshAll() { + for (String feedName : FEED_SOURCES.keySet()) { + try { + refreshFeed(feedName); + } catch (Exception e) { + log.error("IOC feed {} failed: {}", feedName, e.getMessage(), e); + } + } + } + + @Transactional + public IocFeedRun refreshFeed(String feedName) { + if (feedRunRepository.countRunning(feedName) > 0) { + log.info("IOC feed {} already running, skipping", feedName); + return feedRunRepository.findTopByFeedNameOrderByStartedAtDesc(feedName).orElseThrow(); + } + + IocFeedRun run = feedRunRepository.save(IocFeedRun.builder() + .feedName(feedName) + .startedAt(Instant.now()) + .build()); + + try { + List fetched = fetchFromFeed(feedName); + int[] counts = upsertIocs(fetched); + + run.setStatus("SUCCESS"); + run.setIocsAdded(counts[0]); + run.setIocsUpdated(counts[1]); + run.setCompletedAt(Instant.now()); + log.info("IOC feed {} complete: {} added, {} updated", feedName, counts[0], counts[1]); + } catch (Exception e) { + run.setStatus("ERROR"); + run.setErrorMessage(e.getMessage()); + run.setCompletedAt(Instant.now()); + log.error("IOC feed {} error: {}", feedName, e.getMessage(), e); + } + + return feedRunRepository.save(run); + } + + private List fetchFromFeed(String feedName) { + return switch (feedName) { + case "OTX" -> otxClient.fetch(); + case "MALWARE_BAZAAR" -> malwareBazaarClient.fetch(); + case "THREAT_FOX" -> threatFoxClient.fetch(); + default -> throw new IllegalArgumentException("Unknown feed: " + feedName); + }; + } + + private int[] upsertIocs(List iocs) { + int added = 0, updated = 0; + for (ThreatIoc ioc : iocs) { + if (ioc.getValue() == null || ioc.getValue().isBlank()) continue; + Optional existing = iocRepository + .findByActiveTrueAndIocTypeAndValueIgnoreCase(ioc.getIocType(), ioc.getValue()); + if (existing.isPresent()) { + ThreatIoc e = existing.get(); + e.setDescription(ioc.getDescription()); + e.setSourceRef(ioc.getSourceRef()); + e.setConfidence(ioc.getConfidence()); + e.setExpiresAt(ioc.getExpiresAt()); + iocRepository.save(e); + updated++; + } else { + iocRepository.save(ioc); + added++; + } + } + return new int[]{ added, updated }; + } + + public List feedNames() { + return List.copyOf(FEED_SOURCES.keySet()); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 276a367..ec5701e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -58,6 +58,11 @@ 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:} + # Auto-response β€” disabled by default for safe initial deployment aria.autoresponse.shutdown.enabled=${ARIA_AUTORESPONSE_SHUTDOWN_ENABLED:false} diff --git a/src/main/resources/db/migration/V6__ioc_feed_run_table.sql b/src/main/resources/db/migration/V6__ioc_feed_run_table.sql new file mode 100644 index 0000000..8a0efb4 --- /dev/null +++ b/src/main/resources/db/migration/V6__ioc_feed_run_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE ioc_feed_run ( + id BIGSERIAL PRIMARY KEY, + feed_name VARCHAR(50) NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + status VARCHAR(20) NOT NULL DEFAULT 'RUNNING', + iocs_added INT NOT NULL DEFAULT 0, + iocs_updated INT NOT NULL DEFAULT 0, + error_message TEXT +); + +CREATE INDEX idx_ioc_feed_run_feed_name ON ioc_feed_run (feed_name); +CREATE INDEX idx_ioc_feed_run_started_at ON ioc_feed_run (started_at DESC); From 583aff9c2d0db363dca6fb61ed964e3c2e33c2c0 Mon Sep 17 00:00:00 2001 From: Johannes Date: Mon, 15 Jun 2026 21:59:50 -0400 Subject: [PATCH 04/15] =?UTF-8?q?fix(aria):=20feed=20clients=20=E2=80=94?= =?UTF-8?q?=20@Param=20binding,=20auth=20keys,=20thread=20error=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IocFeedRunRepository: add @Param("feedName") to countRunning JPQL query (missing binding silently crashed background threads) - IocFeedController: uncaught exception handler on feed threads so failures appear in logs instead of disappearing silently - MalwareBazaarFeedClient/ThreatFoxFeedClient: abuse.ch now requires API key (auth.abuse.ch); add Auth-Key header + isConfigured() skip guard - application.properties: add aria.feed.malware-bazaar.api-key and aria.feed.threat-fox.api-key properties Co-Authored-By: Claude Sonnet 4.6 --- .../aria/controller/IocFeedController.java | 13 +++++++++++-- .../aria/feed/MalwareBazaarFeedClient.java | 16 +++++++++++++++- .../hiveops/aria/feed/ThreatFoxFeedClient.java | 17 ++++++++++++++++- .../aria/repository/IocFeedRunRepository.java | 3 ++- src/main/resources/application.properties | 2 ++ 5 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/hiveops/aria/controller/IocFeedController.java b/src/main/java/com/hiveops/aria/controller/IocFeedController.java index 77db5a4..98e84a7 100644 --- a/src/main/java/com/hiveops/aria/controller/IocFeedController.java +++ b/src/main/java/com/hiveops/aria/controller/IocFeedController.java @@ -4,6 +4,7 @@ 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.PageRequest; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -16,8 +17,16 @@ import java.util.Optional; @RestController @RequestMapping("/api/aria/feeds") @RequiredArgsConstructor +@Slf4j public class IocFeedController { + 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; + } + private final IocFeedService feedService; private final IocFeedRunRepository feedRunRepository; @@ -49,7 +58,7 @@ public class IocFeedController { @PostMapping("/refresh") @PreAuthorize("hasRole('BCOS_ADMIN')") public ResponseEntity> refreshAll() { - new Thread(() -> feedService.refreshAll(), "ioc-feed-manual-all").start(); + feedThread("ioc-feed-manual-all", feedService::refreshAll).start(); return ResponseEntity.accepted().body(Map.of("status", "refresh_started")); } @@ -61,7 +70,7 @@ public class IocFeedController { if (!feedService.feedNames().contains(upper)) { return ResponseEntity.badRequest().body(Map.of("error", "Unknown feed: " + feedName)); } - new Thread(() -> feedService.refreshFeed(upper), "ioc-feed-manual-" + upper).start(); + feedThread("ioc-feed-manual-" + upper, () -> feedService.refreshFeed(upper)).start(); return ResponseEntity.accepted().body(Map.of("status", "refresh_started", "feed", upper)); } } diff --git a/src/main/java/com/hiveops/aria/feed/MalwareBazaarFeedClient.java b/src/main/java/com/hiveops/aria/feed/MalwareBazaarFeedClient.java index 9493f79..9864bac 100644 --- a/src/main/java/com/hiveops/aria/feed/MalwareBazaarFeedClient.java +++ b/src/main/java/com/hiveops/aria/feed/MalwareBazaarFeedClient.java @@ -4,6 +4,7 @@ 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; @@ -18,7 +19,8 @@ import java.util.ArrayList; import java.util.List; /** - * Fetches ATM-related malware samples from MalwareBazaar (abuse.ch) β€” no API key required. + * Fetches ATM-related malware samples from MalwareBazaar (abuse.ch). + * Requires an API key from https://auth.abuse.ch/ */ @Component @Slf4j @@ -27,9 +29,20 @@ 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 final RestTemplate restTemplate = new RestTemplate(); + public boolean isConfigured() { + return apiKey != null && !apiKey.isBlank(); + } + public List fetch() { + if (!isConfigured()) { + log.info("MalwareBazaar: no API key configured, skipping"); + return List.of(); + } List result = new ArrayList<>(); // Query recent samples tagged with ATM-related tags @@ -46,6 +59,7 @@ public class MalwareBazaarFeedClient { try { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.set("Auth-Key", apiKey); MultiValueMap body = new LinkedMultiValueMap<>(); body.add("query", "get_taginfo"); diff --git a/src/main/java/com/hiveops/aria/feed/ThreatFoxFeedClient.java b/src/main/java/com/hiveops/aria/feed/ThreatFoxFeedClient.java index a3b3766..8448ca1 100644 --- a/src/main/java/com/hiveops/aria/feed/ThreatFoxFeedClient.java +++ b/src/main/java/com/hiveops/aria/feed/ThreatFoxFeedClient.java @@ -4,6 +4,7 @@ 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; @@ -17,7 +18,8 @@ import java.util.List; import java.util.Map; /** - * Fetches ATM-related IOCs from ThreatFox (abuse.ch) β€” no API key required. + * Fetches ATM-related IOCs from ThreatFox (abuse.ch). + * Requires an API key from https://auth.abuse.ch/ */ @Component @Slf4j @@ -26,9 +28,20 @@ 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 final RestTemplate restTemplate = new RestTemplate(); + public boolean isConfigured() { + return apiKey != null && !apiKey.isBlank(); + } + public List fetch() { + if (!isConfigured()) { + log.info("ThreatFox: no API key configured, skipping"); + return List.of(); + } List result = new ArrayList<>(); // Search by ATM-specific malware families @@ -48,6 +61,7 @@ public class ThreatFoxFeedClient { 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", @@ -70,6 +84,7 @@ public class ThreatFoxFeedClient { try { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Auth-Key", apiKey); String body = MAPPER.writeValueAsString(Map.of( "query", "get_iocs", diff --git a/src/main/java/com/hiveops/aria/repository/IocFeedRunRepository.java b/src/main/java/com/hiveops/aria/repository/IocFeedRunRepository.java index 7683f4e..ca6fd43 100644 --- a/src/main/java/com/hiveops/aria/repository/IocFeedRunRepository.java +++ b/src/main/java/com/hiveops/aria/repository/IocFeedRunRepository.java @@ -5,6 +5,7 @@ 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; @@ -15,5 +16,5 @@ public interface IocFeedRunRepository extends JpaRepository { Optional findTopByFeedNameOrderByStartedAtDesc(String feedName); @Query("SELECT COUNT(r) FROM IocFeedRun r WHERE r.feedName = :feedName AND r.status = 'RUNNING'") - long countRunning(String feedName); + long countRunning(@Param("feedName") String feedName); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ec5701e..a7ed365 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -62,6 +62,8 @@ aria.sequence.window.minutes=${ARIA_SEQUENCE_WINDOW_MINUTES:15} 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} From 571d88db4d7a6ecfe191ed6c45142b3167d55b29 Mon Sep 17 00:00:00 2001 From: Johannes Date: Mon, 15 Jun 2026 22:41:21 -0400 Subject: [PATCH 05/15] =?UTF-8?q?fix(aria):=20IOC=20Feeds=20=E2=80=94=20re?= =?UTF-8?q?store=20app,=20fix=20API=20shapes,=20canonical=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: IocFeeds.svelte called feeds.find() on a Map (backend returned Map not List) β†’ reactive statement crash β†’ entire Svelte tree broken, all sidebar nav dead. Backend: - /feeds/status now returns List with feedName/configured/enabled/ lastRunAt/lastRunStatus/lastIocsAdded/lastIocsUpdated fields - /feeds/runs now returns full Page (not stripped .getContent()) - IocFeedService.isConfigured(feedName) added for per-feed key check - Feed order fixed to [OTX, MALWARE_BAZAAR, THREAT_FOX] (deterministic) Frontend: - loadFeeds: Array.isArray guard prevents crash if shape is ever wrong - loadRuns: safe fallback for both Page and plain-array responses - api.ts refreshFeed URL fixed: /feeds/refresh/{name} not /feeds/{name}/refresh - IocFeedStatus type: added lastIocsUpdated - IocFeeds.svelte: Refresh All moved from header to toolbar; all buttons use canonical btn/btn-primary/btn-secondary/btn-sm classes; pollInterval now calls pollStatus (status-only poll) not full refresh trigger Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/IocFeeds.svelte | 543 ++++++------------ frontend/src/lib/api.ts | 4 +- .../aria/controller/IocFeedController.java | 40 +- .../hiveops/aria/service/IocFeedService.java | 11 +- 4 files changed, 209 insertions(+), 389 deletions(-) diff --git a/frontend/src/components/IocFeeds.svelte b/frontend/src/components/IocFeeds.svelte index 995dfc5..422d2c6 100644 --- a/frontend/src/components/IocFeeds.svelte +++ b/frontend/src/components/IocFeeds.svelte @@ -4,7 +4,6 @@ import { addToast } from '../lib/stores'; import Pagination from './common/Pagination.svelte'; - // ── State ───────────────────────────────────────────────────────────── let feeds: IocFeedStatus[] = []; let feedsLoading = true; @@ -18,26 +17,23 @@ let refreshingAll = false; let refreshingFeed: Record = {}; - // ── Feed display config ─────────────────────────────────────────────── - const FEED_META: Record = { - OTX: { label: 'AlienVault OTX', icon: 'πŸ”—', requiresKey: true }, - MALWARE_BAZAAR: { label: 'MalwareBazaar', icon: '🦠', requiresKey: false }, - THREAT_FOX: { label: 'ThreatFox', icon: '🦊', requiresKey: false }, + const FEED_META: Record = { + OTX: { label: 'AlienVault OTX', icon: 'πŸ”—' }, + MALWARE_BAZAAR: { label: 'MalwareBazaar', icon: '🦠' }, + THREAT_FOX: { label: 'ThreatFox', icon: '🦊' }, }; - // Ensure cards always render in a fixed order even if backend returns them in different order const FEED_ORDER = ['OTX', 'MALWARE_BAZAAR', 'THREAT_FOX']; - $: orderedFeeds = FEED_ORDER.map(name => feeds.find(f => f.feedName === name) ?? { - feedName: name, enabled: false, configured: false, - } as IocFeedStatus); + $: orderedFeeds = FEED_ORDER.map(name => + feeds.find(f => f.feedName === name) ?? { feedName: name, enabled: false, configured: false } as IocFeedStatus + ); - // ── Data loading ────────────────────────────────────────────────────── async function loadFeeds() { feedsLoading = true; try { const res = await ariaApi.getFeedStatus(); - feeds = res.data; + feeds = Array.isArray(res.data) ? res.data : []; } catch { addToast('error', 'Load Failed', 'Could not load feed status'); } finally { @@ -49,28 +45,31 @@ runsLoading = true; try { const res = await ariaApi.getFeedRuns({ page, size: runsPageSize }); - runs = res.data.content; - runsTotalElements = res.data.page.totalElements; - runsTotalPages = res.data.page.totalPages; - runsPage = res.data.page.number; + 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 feed run history'); + addToast('error', 'Load Failed', 'Could not load run history'); } finally { runsLoading = false; } } - async function refresh() { - await Promise.all([loadFeeds(), loadRuns(runsPage)]); + async function pollStatus() { + try { + const res = await ariaApi.getFeedStatus(); + feeds = Array.isArray(res.data) ? res.data : []; + } catch { /* silent */ } } - // ── Refresh actions ─────────────────────────────────────────────────── async function refreshAll() { refreshingAll = true; try { - const res = await ariaApi.refreshAllFeeds(); - addToast('success', 'Refresh Triggered', res.data.message || 'All feeds are refreshing'); - setTimeout(() => refresh(), 2000); + 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 { @@ -81,54 +80,48 @@ async function refreshSingle(feedName: string) { refreshingFeed = { ...refreshingFeed, [feedName]: true }; try { - const res = await ariaApi.refreshFeed(feedName); - addToast('success', 'Refresh Triggered', res.data.message || `${feedName} feed is refreshing`); - setTimeout(() => refresh(), 2000); + 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 trigger ${feedName} refresh`); + addToast('error', 'Refresh Failed', `Could not refresh ${feedName}`); } finally { refreshingFeed = { ...refreshingFeed, [feedName]: false }; } } - // ── Helpers ─────────────────────────────────────────────────────────── - function statusClass(status: string | undefined): string { - if (!status) return 'never'; - if (status === 'SUCCESS') return 'success'; - if (status === 'FAILED') return 'failed'; - if (status === 'RUNNING') return 'running'; + function statusClass(s?: string) { + if (s === 'SUCCESS') return 'success'; + if (s === 'ERROR') return 'error'; + if (s === 'RUNNING') return 'running'; return 'never'; } - function statusLabel(status: string | undefined): string { - if (!status) return 'Never run'; - if (status === 'SUCCESS') return 'Success'; - if (status === 'FAILED') return 'Failed'; - if (status === 'RUNNING') return 'Running'; - return status; + function 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 | undefined): string { + function relativeTime(iso?: string) { if (!iso) return 'β€”'; const diff = Date.now() - new Date(iso).getTime(); - const sec = Math.floor(diff / 1000); - const min = Math.floor(sec / 60); - const hr = Math.floor(min / 60); - const day = Math.floor(hr / 24); - if (sec < 60) return 'just now'; - if (min < 60) return `${min} minute${min !== 1 ? 's' : ''} ago`; - if (hr < 24) return `${hr} hour${hr !== 1 ? 's' : ''} ago`; - return `${day} day${day !== 1 ? 's' : ''} ago`; + 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 | undefined): string { + function formatTs(iso?: string) { if (!iso) return 'β€”'; - return new Date(iso).toLocaleString('en-US', { - month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' - }); + return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } - function duration(start: string, end: string | undefined): string { + 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`; @@ -136,18 +129,12 @@ return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`; } - function fmtCount(n: number | undefined): string { - if (n == null) return 'β€”'; - return n.toLocaleString(); - } - - // ── Auto-refresh every 30 s ─────────────────────────────────────────── let pollInterval: ReturnType; onMount(() => { loadFeeds(); loadRuns(0); - pollInterval = setInterval(refresh, 30_000); + pollInterval = setInterval(pollStatus, 30_000); }); onDestroy(() => clearInterval(pollInterval)); @@ -155,85 +142,75 @@
-

IOC Feeds

-

Automated threat intelligence ingestion β€” OTX, MalwareBazaar, ThreatFox

-
-
- +

Automated threat intelligence β€” OTX, MalwareBazaar, ThreatFox

-
+
+
+
+ {feeds.length} feed{feeds.length !== 1 ? 's' : ''} +
+
+ + +
+
+ {#if feedsLoading && feeds.length === 0} -
Loading feed status…
+
Loading feed status…
{:else}
{#each orderedFeeds as feed (feed.feedName)} - {@const meta = FEED_META[feed.feedName] ?? { label: feed.feedName, icon: 'πŸ“‘', requiresKey: false }} -
+ {@const meta = FEED_META[feed.feedName] ?? { label: feed.feedName, icon: 'πŸ“‘' }} +
-
{meta.icon}
+ {meta.icon}
{meta.label}
-
{feed.feedName}
+
{feed.feedName}
- - {statusLabel(feed.lastRunStatus)} - + {statusLabel(feed.lastRunStatus)}
-
+
{#if feed.configured} - Configured - {:else if meta.requiresKey} - Not configured β€” API key required + Configured {:else} - No key required + API key required {/if}
- Last run - {relativeTime(feed.lastRunAt)} + Last run + {relativeTime(feed.lastRunAt)}
- IOCs added - {fmtCount(feed.lastIocsAdded)} + IOCs added + {feed.lastIocsAdded?.toLocaleString() ?? 'β€”'}
- Total from feed - {fmtCount(feed.totalIocsFromFeed)} + Updated + {feed.lastIocsUpdated?.toLocaleString() ?? 'β€”'}
@@ -242,14 +219,15 @@ {/if}
- -
-
- Run History - {runsTotalElements.toLocaleString()} run{runsTotalElements !== 1 ? 's' : ''} -
+ +
+
+
+
+ {runsTotalElements.toLocaleString()} run{runsTotalElements !== 1 ? 's' : ''} +
+
-
{#if runsLoading} -
Loading run history…
+
Loading…
{:else if runs.length === 0} -
No feed runs recorded yet. Trigger a refresh to see results here.
+
No feed runs yet. Click "Run All Feeds" to start.
{:else} @@ -271,8 +249,8 @@ - - + + @@ -285,24 +263,18 @@ {FEED_META[run.feedName]?.label ?? run.feedName} - - + + + + + - - - @@ -317,275 +289,106 @@ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 697cc37..c68e460 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -122,7 +122,7 @@ export type IocFeedStatus = { lastRunAt?: string; lastRunStatus?: string; lastIocsAdded?: number; - totalIocsFromFeed?: number; + lastIocsUpdated?: number; }; export type IocFeedRun = { @@ -190,5 +190,5 @@ export const ariaApi = { api.post<{ message: string }>('/api/aria/feeds/refresh'), refreshFeed: (feedName: string) => - api.post<{ message: string }>(`/api/aria/feeds/${feedName}/refresh`), + api.post<{ message: string }>(`/api/aria/feeds/refresh/${feedName}`), }; diff --git a/src/main/java/com/hiveops/aria/controller/IocFeedController.java b/src/main/java/com/hiveops/aria/controller/IocFeedController.java index 98e84a7..f29d6a5 100644 --- a/src/main/java/com/hiveops/aria/controller/IocFeedController.java +++ b/src/main/java/com/hiveops/aria/controller/IocFeedController.java @@ -5,14 +5,13 @@ 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.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; @RestController @RequestMapping("/api/aria/feeds") @@ -20,6 +19,9 @@ import java.util.Optional; @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) -> @@ -27,30 +29,36 @@ public class IocFeedController { return t; } - private final IocFeedService feedService; - private final IocFeedRunRepository feedRunRepository; - - /** Latest run per feed β€” used by the status cards in the UI */ + /** One status object per feed β€” array, matches IocFeedStatus[] in the frontend */ @GetMapping("/status") @PreAuthorize("hasRole('BCOS_ADMIN')") - public ResponseEntity> status() { - List names = feedService.feedNames(); - Map result = new java.util.LinkedHashMap<>(); - for (String name : names) { + public ResponseEntity>> status() { + List> result = new ArrayList<>(); + for (String name : feedService.feedNames()) { Optional last = feedRunRepository.findTopByFeedNameOrderByStartedAtDesc(name); - result.put(name, last.orElse(null)); + Map entry = new LinkedHashMap<>(); + entry.put("feedName", name); + entry.put("configured", feedService.isConfigured(name)); + entry.put("enabled", true); + last.ifPresent(r -> { + entry.put("lastRunAt", r.getCompletedAt() != null ? r.getCompletedAt() : r.getStartedAt()); + entry.put("lastRunStatus", r.getStatus()); + entry.put("lastIocsAdded", r.getIocsAdded()); + entry.put("lastIocsUpdated", r.getIocsUpdated()); + }); + result.add(entry); } return ResponseEntity.ok(result); } - /** Recent run history β€” default 50 rows */ + /** Paginated run history β€” returns Spring Page so the frontend gets content + page metadata */ @GetMapping("/runs") @PreAuthorize("hasRole('BCOS_ADMIN')") - public ResponseEntity> runs( + public ResponseEntity> runs( @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "50") int size) { + @RequestParam(defaultValue = "20") int size) { return ResponseEntity.ok( - feedRunRepository.findAllByOrderByStartedAtDesc(PageRequest.of(page, size)).getContent() + feedRunRepository.findAllByOrderByStartedAtDesc(PageRequest.of(page, size)) ); } diff --git a/src/main/java/com/hiveops/aria/service/IocFeedService.java b/src/main/java/com/hiveops/aria/service/IocFeedService.java index 39cf322..192f644 100644 --- a/src/main/java/com/hiveops/aria/service/IocFeedService.java +++ b/src/main/java/com/hiveops/aria/service/IocFeedService.java @@ -114,6 +114,15 @@ public class IocFeedService { } public List feedNames() { - return List.copyOf(FEED_SOURCES.keySet()); + 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; + }; } } From 75ff2b92fd1d5905c60ca27bd8808ef601572562 Mon Sep 17 00:00:00 2001 From: Johannes Date: Mon, 15 Jun 2026 22:44:51 -0400 Subject: [PATCH 06/15] chore(aria): add frontend CLAUDE.md with mandatory UI pattern rules --- frontend/CLAUDE.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 frontend/CLAUDE.md diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000..2d39e36 --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1,43 @@ +# hiveops-aria frontend β€” CLAUDE.md + +## Before writing ANY new component + +Read an existing sibling component first. Mandatory. Use `IocManagement.svelte` or `DevicePosture.svelte` as the reference. + +## Canonical patterns (copy exactly, no variations) + +**Header** β€” text only, no buttons: +```svelte +
+
+

Page Title

+

Subtitle

+
+
+``` + +**Toolbar** β€” all buttons go here, never in the header: +```svelte +
+
+
N items
+
+ + +
+
+``` + +**Buttons** β€” always global classes, never custom button CSS: +- `btn btn-primary` / `btn btn-primary btn-sm` +- `btn btn-secondary` / `btn btn-secondary btn-sm` + +**Table** β€” inside `.table-wrapper > .table-container`: +- `thead` background: `#f9fafb`, sticky +- `th` color: `#374151`, uppercase, `font-size: 0.78rem` +- `td` color: `#1f2937` +- `tbody tr:hover` background: `#eff6ff` + +## Auto-poll +- Poll the STATUS endpoint only β€” never trigger a refresh from setInterval +- Use `onDestroy(() => clearInterval(interval))` always From e288bcdfe9fb852f0f72772d712bd14c03c3a815 Mon Sep 17 00:00:00 2001 From: Johannes Date: Mon, 15 Jun 2026 22:52:11 -0400 Subject: [PATCH 07/15] =?UTF-8?q?fix(aria):=20IOC=20Feeds=20table=20?= =?UTF-8?q?=E2=80=94=20canonical=20column=20CSS=20matching=20DevicePosture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/IocFeeds.svelte | 29 +++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/IocFeeds.svelte b/frontend/src/components/IocFeeds.svelte index 422d2c6..e82aa10 100644 --- a/frontend/src/components/IocFeeds.svelte +++ b/frontend/src/components/IocFeeds.svelte @@ -368,11 +368,26 @@ overflow: hidden; min-height: 0; padding-bottom: 16px; } - .feed-name-cell { display: flex; align-items: center; gap: 6px; font-weight: 500; white-space: nowrap; } + 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; } - .num-col { text-align: right; padding-right: 1.25rem; font-variant-numeric: tabular-nums; } - .error-text { color: #dc2626; font-size: 0.78rem; cursor: help; } + .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; } @@ -390,5 +405,11 @@ :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; } From 87ad18571141136e1efb26d0dd246e63334664c8 Mon Sep 17 00:00:00 2001 From: Johannes Date: Tue, 16 Jun 2026 17:43:10 -0400 Subject: [PATCH 08/15] feat(aria): group Device Posture table by institution with collapsible accordion All institutions expanded by default. Header row shows device count, avg posture score, and a warning badge when any device scores below 80. Fetches all records in one call (size=500) instead of paginating. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/DevicePosture.svelte | 199 +++++++++++++------ 1 file changed, 139 insertions(+), 60 deletions(-) diff --git a/frontend/src/components/DevicePosture.svelte b/frontend/src/components/DevicePosture.svelte index d424622..dcb0c92 100644 --- a/frontend/src/components/DevicePosture.svelte +++ b/frontend/src/components/DevicePosture.svelte @@ -2,14 +2,9 @@ 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; - let pageSize = 25; - let loading = true; // Filter sidebar @@ -23,18 +18,62 @@ let panelOpen = false; let panelDevice: DeviceSecurityPosture | null = null; - async function load(page = 0) { + // Accordion β€” all expanded by default + let expandedInstitutions = new Set(); + let allLoaded = false; + + interface InstGroup { + key: string; + devices: DeviceSecurityPosture[]; + avgScore: number | null; + hasIssues: boolean; + } + + $: grouped = groupByInstitution(postures); + + function groupByInstitution(list: DeviceSecurityPosture[]): InstGroup[] { + const map = new Map(); + for (const d of list) { + const k = d.institutionKey ?? 'Unknown'; + if (!map.has(k)) map.set(k, []); + map.get(k)!.push(d); + } + return Array.from(map.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([key, devices]) => { + const scored = devices.filter(d => d.postureScore != null); + const avgScore = scored.length + ? Math.round(scored.reduce((s, d) => s + d.postureScore!, 0) / scored.length) + : null; + const hasIssues = devices.some(d => d.postureScore != null && d.postureScore < 80); + return { key, devices, avgScore, hasIssues }; + }); + } + + function toggleInstitution(key: string) { + if (expandedInstitutions.has(key)) { + expandedInstitutions.delete(key); + } else { + expandedInstitutions.add(key); + } + expandedInstitutions = expandedInstitutions; + } + + async function load() { loading = true; try { - const params: Record = { page, size: pageSize }; + const params: Record = { page: 0, size: 500 }; if (filterScore) params.scoreFilter = filterScore; if (filterEol) params.osEolStatus = filterEol; const res = await ariaApi.getPostures(params); postures = res.data.content; totalElements = res.data.page.totalElements; - totalPages = res.data.page.totalPages; - currentPage = res.data.page.number; + + if (!allLoaded) { + expandedInstitutions = new Set(postures.map(d => d.institutionKey ?? 'Unknown')); + allLoaded = true; + } } catch { addToast('error', 'Load Failed', 'Could not load device posture data'); } finally { @@ -45,8 +84,7 @@ function clearAllFilters() { filterScore = ''; filterEol = ''; - currentPage = 0; - load(0); + load(); } function openPanel(device: DeviceSecurityPosture) { @@ -105,13 +143,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) { @@ -193,7 +231,7 @@
{ currentPage = 0; load(0); }}> + on:change={() => load()}> @@ -224,14 +262,14 @@ Score: {filterScore === 'low' ? 'Low (<50)' : 'Below Good (<80)'} - + {/if} {#if filterEol} OS: {eolLabel(filterEol)} - + {/if}
@@ -245,12 +283,6 @@
- { currentPage = e.detail.page; load(currentPage); }} - on:pageSizeChange={e => { pageSize = e.detail.size; currentPage = 0; load(0); }} - /> -
{#if loading}
Loading…
@@ -274,46 +306,66 @@
- - {#each postures as device (device.deviceId)} - openPanel(device)}> - - - - - - - - + + toggleInstitution(group.key)}> + - - - {/each} - + {#if expandedInstitutions.has(group.key)} + {#each group.devices as device (device.deviceId)} + openPanel(device)}> + + + + + + + + + + + + {/each} + {/if} + + {/each}
Started Duration StatusIOCs AddedIOCs UpdatedAddedUpdated Error
{formatTs(run.startedAt)}{duration(run.startedAt, run.completedAt)}{formatTs(run.startedAt)}{duration(run.startedAt, run.completedAt)}{statusLabel(run.status)}{run.iocsAdded.toLocaleString()}{run.iocsUpdated.toLocaleString()} - - {statusLabel(run.status)} - - {run.iocsAdded.toLocaleString()}{run.iocsUpdated.toLocaleString()} {#if run.errorMessage} - {run.errorMessage.length > 60 - ? run.errorMessage.substring(0, 60) + '…' - : run.errorMessage} + {run.errorMessage.length > 60 ? run.errorMessage.substring(0, 60) + '…' : run.errorMessage} {:else} - β€” + β€” {/if}
{device.deviceAgentId ?? device.deviceId}{device.osVersion ?? 'β€”'} - - {eolLabel(device.osEolStatus)} - - {boolCell(device.diskEncryptionEnabled)}{boolCell(device.auditPolicyCompliant)}{boolCell(device.softwareWhitelistEnabled)} - {#if imageMismatch(device)} - Drift - {:else if device.goldImageHash} - Match - {:else} - β€” - {/if} - -
-
-
-
- - {device.postureScore ?? 'β€”'} - + {#each grouped as group (group.key)} +
+
+ {expandedInstitutions.has(group.key) ? 'β–Ό' : 'β–Ά'} + {group.key} + {group.devices.length} device{group.devices.length !== 1 ? 's' : ''} + {#if group.avgScore != null} + avg {group.avgScore} + {/if} + {#if group.hasIssues} + ⚠ below 80 + {/if}
{formatDate(device.lastPostureCheckAt)} - -
{device.deviceAgentId ?? device.deviceId}{device.osVersion ?? 'β€”'} + + {eolLabel(device.osEolStatus)} + + {boolCell(device.diskEncryptionEnabled)}{boolCell(device.auditPolicyCompliant)}{boolCell(device.softwareWhitelistEnabled)} + {#if imageMismatch(device)} + Drift + {:else if device.goldImageHash} + Match + {:else} + β€” + {/if} + +
+
+
+
+ + {device.postureScore ?? 'β€”'} + +
+
{formatDate(device.lastPostureCheckAt)} + +
{/if}
@@ -644,6 +696,26 @@ } .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; } @@ -684,6 +756,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; } From 1a47e2dea9f43c2c2525110cdf947fab4222dc0a Mon Sep 17 00:00:00 2001 From: Johannes Date: Tue, 16 Jun 2026 20:45:19 -0400 Subject: [PATCH 09/15] feat(aria): IOC reported_at, threat stats 24h window, filter sidebar, posture UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add reported_at field to threat_ioc (V7 migration) β€” tracks when the source originally published the IOC, distinct from addedAt (ingestion time); wired into OTX (created field), MalwareBazaar and ThreatFox (first_seen field), and the manual IOC creation endpoint - Fix threat event stats to use a 24h time window for critical/high counts (countBySeverityAndDetectedAtAfter) instead of all-time totals - Rebuild IOC Management with canonical filter sidebar: collapsible left sidebar with per-type toggle pills, active filter chips with individual removal - DevicePosture: collapse institutions and filter sidebar by default Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/DevicePosture.svelte | 10 +- frontend/src/components/IocManagement.svelte | 469 +++++++++++------- .../controller/ThreatEventController.java | 7 +- .../aria/controller/ThreatIocController.java | 2 + .../com/hiveops/aria/entity/ThreatIoc.java | 3 + .../aria/feed/MalwareBazaarFeedClient.java | 14 + .../com/hiveops/aria/feed/OtxFeedClient.java | 8 + .../aria/feed/ThreatFoxFeedClient.java | 13 + .../repository/ThreatEventRepository.java | 2 +- .../hiveops/aria/service/IocFeedService.java | 1 + .../db/migration/V7__add_ioc_reported_at.sql | 1 + 11 files changed, 341 insertions(+), 189 deletions(-) create mode 100644 src/main/resources/db/migration/V7__add_ioc_reported_at.sql diff --git a/frontend/src/components/DevicePosture.svelte b/frontend/src/components/DevicePosture.svelte index dcb0c92..4757833 100644 --- a/frontend/src/components/DevicePosture.svelte +++ b/frontend/src/components/DevicePosture.svelte @@ -8,7 +8,7 @@ let loading = true; // Filter sidebar - let filterSidebarCollapsed = false; + let filterSidebarCollapsed = true; let filterScore: '' | 'low' | 'medium' = ''; let filterEol: OsEolStatus | '' = ''; @@ -18,9 +18,8 @@ let panelOpen = false; let panelDevice: DeviceSecurityPosture | null = null; - // Accordion β€” all expanded by default + // Accordion β€” collapsed by default let expandedInstitutions = new Set(); - let allLoaded = false; interface InstGroup { key: string; @@ -69,11 +68,6 @@ const res = await ariaApi.getPostures(params); postures = res.data.content; totalElements = res.data.page.totalElements; - - if (!allLoaded) { - expandedInstitutions = new Set(postures.map(d => d.institutionKey ?? 'Unknown')); - allLoaded = true; - } } catch { addToast('error', 'Load Failed', 'Could not load device posture data'); } finally { diff --git a/frontend/src/components/IocManagement.svelte b/frontend/src/components/IocManagement.svelte index 57f7b3b..20a4f67 100644 --- a/frontend/src/components/IocManagement.svelte +++ b/frontend/src/components/IocManagement.svelte @@ -6,7 +6,28 @@ let iocs: ThreatIoc[] = []; let loading = false; let showInactive = false; - let typeFilter: IocType | '' = ''; + + // Filter sidebar + let filterSidebarCollapsed = true; + + // Multi-type filter β€” client-side + let selectedTypes = new Set(); + + $: hasActiveFilters = selectedTypes.size > 0; + + $: filteredIocs = selectedTypes.size > 0 + ? iocs.filter(i => selectedTypes.has(i.iocType)) + : iocs; + + function toggleTypeFilter(t: IocType) { + if (selectedTypes.has(t)) selectedTypes.delete(t); + else selectedTypes.add(t); + selectedTypes = selectedTypes; + } + + function clearAllFilters() { + selectedTypes = new Set(); + } let showPanel = false; let editingIoc: ThreatIoc | null = null; @@ -45,7 +66,6 @@ loading = true; try { const res = await ariaApi.getIocs({ - type: typeFilter || undefined, activeOnly: !showInactive, }); iocs = res.data; @@ -125,7 +145,8 @@ onMount(load); -
+
+

IOC Management

@@ -133,84 +154,141 @@
-
-
-
- {iocs.length} IOC{iocs.length !== 1 ? 's' : ''} +
-
- + +
+
+ {#if !filterSidebarCollapsed} + Filters +
+ {filteredIocs.length} + +
+ {:else} + + {/if} +
+ + {#if !filterSidebarCollapsed} +
+ {#if hasActiveFilters} + + {/if} + +
+ +
+ {#each IOC_TYPES as t} + + {/each} +
+
+ +
+ + +
- - -
-
- -
+ {/if}
-
- {#if loading} -
Loading…
- {:else if iocs.length === 0} -
No IOCs found.
- {:else} - - - - - - - - - - - - - - - - {#each iocs as ioc (ioc.id)} - - - - - - - - - - - - {/each} - -
TypeValueDescriptionSourceConfidenceAddedExpiresStatus
- {ioc.iocType.replace(/_/g,' ')} - - {ioc.value} - {ioc.description ?? 'β€”'}{SRC_LABEL[ioc.source] ?? ioc.source} - {ioc.confidence} - {fmtDate(ioc.addedAt)}{fmtDate(ioc.expiresAt)} - {#if ioc.active} - Active - {:else} - Inactive - {/if} - - - {#if ioc.active} - - {/if} -
+ +
+ + {#if hasActiveFilters} +
+ Filters: + {#each [...selectedTypes] as t} + + + {t.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())} + + + {/each} +
{/if} + +
+
+ +
+ {filteredIocs.length} IOC{filteredIocs.length !== 1 ? 's' : ''} +
+ +
+
+ +
+
+ {#if loading} +
Loading…
+ {:else if filteredIocs.length === 0} +
{hasActiveFilters ? 'No IOCs match the selected filters.' : 'No IOCs found.'}
+ {:else} + + + + + + + + + + + + + + + + {#each filteredIocs as ioc (ioc.id)} + + + + + + + + + + + + {/each} + +
TypeValueDescriptionSourceConfidenceAddedExpiresStatus
+ {ioc.iocType.replace(/_/g,' ')} + + {ioc.value} + {ioc.description ?? 'β€”'}{SRC_LABEL[ioc.source] ?? ioc.source} + {ioc.confidence} + {fmtDate(ioc.addedAt)}{fmtDate(ioc.expiresAt)} + {#if ioc.active} + Active + {:else} + Inactive + {/if} + + + {#if ioc.active} + + {/if} +
+ {/if} +
+
+ +
+
@@ -279,41 +357,74 @@ {/if} diff --git a/src/main/java/com/hiveops/aria/controller/ThreatEventController.java b/src/main/java/com/hiveops/aria/controller/ThreatEventController.java index aad00d8..ce2fc36 100644 --- a/src/main/java/com/hiveops/aria/controller/ThreatEventController.java +++ b/src/main/java/com/hiveops/aria/controller/ThreatEventController.java @@ -16,6 +16,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Map; @RestController @@ -50,9 +52,10 @@ public class ThreatEventController { @GetMapping("/stats") @PreAuthorize("hasRole('BCOS_ADMIN')") public ResponseEntity> getStats() { + Instant since24h = Instant.now().minus(24, ChronoUnit.HOURS); long totalEvents = eventRepository.count(); - long critical24h = eventRepository.countBySeverity(ThreatEvent.ThreatSeverity.CRITICAL); - long high24h = eventRepository.countBySeverity(ThreatEvent.ThreatSeverity.HIGH); + long critical24h = eventRepository.countBySeverityAndDetectedAtAfter(ThreatEvent.ThreatSeverity.CRITICAL, since24h); + long high24h = eventRepository.countBySeverityAndDetectedAtAfter(ThreatEvent.ThreatSeverity.HIGH, since24h); long activeIocs = iocRepository.findByActiveTrue().size(); long activeSequences = sequenceAlertRepository.countByResolvedAtIsNull(); diff --git a/src/main/java/com/hiveops/aria/controller/ThreatIocController.java b/src/main/java/com/hiveops/aria/controller/ThreatIocController.java index 70ba66d..955b5d0 100644 --- a/src/main/java/com/hiveops/aria/controller/ThreatIocController.java +++ b/src/main/java/com/hiveops/aria/controller/ThreatIocController.java @@ -52,6 +52,7 @@ public class ThreatIocController { .source(ThreatIoc.IocSource.MANUAL) .sourceRef(request.getSourceRef()) .confidence(request.getConfidence() != null ? request.getConfidence() : ThreatIoc.ConfidenceLevel.MEDIUM) + .reportedAt(request.getReportedAt()) .expiresAt(request.getExpiresAt()) .build(); return ResponseEntity.ok(iocRepository.save(ioc)); @@ -88,6 +89,7 @@ public class ThreatIocController { private String description; private String sourceRef; private ThreatIoc.ConfidenceLevel confidence; + private Instant reportedAt; private Instant expiresAt; } } diff --git a/src/main/java/com/hiveops/aria/entity/ThreatIoc.java b/src/main/java/com/hiveops/aria/entity/ThreatIoc.java index 62fdd1d..737654e 100644 --- a/src/main/java/com/hiveops/aria/entity/ThreatIoc.java +++ b/src/main/java/com/hiveops/aria/entity/ThreatIoc.java @@ -40,6 +40,9 @@ public class ThreatIoc { @Builder.Default private ConfidenceLevel confidence = ConfidenceLevel.HIGH; + @Column(name = "reported_at") + private Instant reportedAt; + @Column(name = "added_at", nullable = false) @Builder.Default private Instant addedAt = Instant.now(); diff --git a/src/main/java/com/hiveops/aria/feed/MalwareBazaarFeedClient.java b/src/main/java/com/hiveops/aria/feed/MalwareBazaarFeedClient.java index 9864bac..1960c47 100644 --- a/src/main/java/com/hiveops/aria/feed/MalwareBazaarFeedClient.java +++ b/src/main/java/com/hiveops/aria/feed/MalwareBazaarFeedClient.java @@ -14,6 +14,9 @@ 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; @@ -32,6 +35,9 @@ public class MalwareBazaarFeedClient { @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() { @@ -78,6 +84,12 @@ public class MalwareBazaarFeedClient { 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) @@ -86,6 +98,7 @@ public class MalwareBazaarFeedClient { .source(ThreatIoc.IocSource.MALWARE_BAZAAR) .sourceRef(sha256.isBlank() ? tag : sha256) .confidence(ThreatIoc.ConfidenceLevel.HIGH) + .reportedAt(reportedAt) .expiresAt(Instant.now().plus(365, ChronoUnit.DAYS)) .build()); } @@ -97,6 +110,7 @@ public class MalwareBazaarFeedClient { .source(ThreatIoc.IocSource.MALWARE_BAZAAR) .sourceRef(sha256.isBlank() ? tag : sha256) .confidence(ThreatIoc.ConfidenceLevel.MEDIUM) + .reportedAt(reportedAt) .expiresAt(Instant.now().plus(365, ChronoUnit.DAYS)) .build()); } diff --git a/src/main/java/com/hiveops/aria/feed/OtxFeedClient.java b/src/main/java/com/hiveops/aria/feed/OtxFeedClient.java index 6ec625b..6ef4370 100644 --- a/src/main/java/com/hiveops/aria/feed/OtxFeedClient.java +++ b/src/main/java/com/hiveops/aria/feed/OtxFeedClient.java @@ -10,6 +10,7 @@ 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; @@ -91,6 +92,12 @@ public class OtxFeedClient { }; 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) @@ -98,6 +105,7 @@ public class OtxFeedClient { .source(ThreatIoc.IocSource.OTX) .sourceRef(pulseName) .confidence(ThreatIoc.ConfidenceLevel.MEDIUM) + .reportedAt(reportedAt) .expiresAt(Instant.now().plus(90, ChronoUnit.DAYS)) .build(); } diff --git a/src/main/java/com/hiveops/aria/feed/ThreatFoxFeedClient.java b/src/main/java/com/hiveops/aria/feed/ThreatFoxFeedClient.java index 8448ca1..d30a484 100644 --- a/src/main/java/com/hiveops/aria/feed/ThreatFoxFeedClient.java +++ b/src/main/java/com/hiveops/aria/feed/ThreatFoxFeedClient.java @@ -12,6 +12,9 @@ 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; @@ -31,6 +34,9 @@ public class ThreatFoxFeedClient { @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() { @@ -161,6 +167,12 @@ public class ThreatFoxFeedClient { ? 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) @@ -168,6 +180,7 @@ public class ThreatFoxFeedClient { .source(ThreatIoc.IocSource.THREAT_FOX) .sourceRef(iocId) .confidence(level) + .reportedAt(reportedAt) .expiresAt(Instant.now().plus(180, ChronoUnit.DAYS)) .build(); } diff --git a/src/main/java/com/hiveops/aria/repository/ThreatEventRepository.java b/src/main/java/com/hiveops/aria/repository/ThreatEventRepository.java index 8087277..508ea8f 100644 --- a/src/main/java/com/hiveops/aria/repository/ThreatEventRepository.java +++ b/src/main/java/com/hiveops/aria/repository/ThreatEventRepository.java @@ -22,7 +22,7 @@ public interface ThreatEventRepository extends JpaRepository Page findBySeverityOrderByDetectedAtDesc(ThreatEvent.ThreatSeverity severity, Pageable pageable); - long countBySeverity(ThreatEvent.ThreatSeverity severity); + long countBySeverityAndDetectedAtAfter(ThreatEvent.ThreatSeverity severity, Instant since); Page findAllByOrderByDetectedAtDesc(Pageable pageable); } diff --git a/src/main/java/com/hiveops/aria/service/IocFeedService.java b/src/main/java/com/hiveops/aria/service/IocFeedService.java index 192f644..5da9f95 100644 --- a/src/main/java/com/hiveops/aria/service/IocFeedService.java +++ b/src/main/java/com/hiveops/aria/service/IocFeedService.java @@ -102,6 +102,7 @@ public class IocFeedService { e.setDescription(ioc.getDescription()); e.setSourceRef(ioc.getSourceRef()); e.setConfidence(ioc.getConfidence()); + e.setReportedAt(ioc.getReportedAt()); e.setExpiresAt(ioc.getExpiresAt()); iocRepository.save(e); updated++; diff --git a/src/main/resources/db/migration/V7__add_ioc_reported_at.sql b/src/main/resources/db/migration/V7__add_ioc_reported_at.sql new file mode 100644 index 0000000..6001785 --- /dev/null +++ b/src/main/resources/db/migration/V7__add_ioc_reported_at.sql @@ -0,0 +1 @@ +ALTER TABLE threat_ioc ADD COLUMN reported_at TIMESTAMPTZ; From 7ea07440dcf2b28c7122de9f4b949081b7e95d53 Mon Sep 17 00:00:00 2001 From: Johannes Date: Tue, 16 Jun 2026 20:51:24 -0400 Subject: [PATCH 10/15] fix(aria): collapse filter sidebar by default on all pages PrecursorAlerts and ThreatEvents were still starting expanded. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/PrecursorAlerts.svelte | 2 +- frontend/src/components/ThreatEvents.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/PrecursorAlerts.svelte b/frontend/src/components/PrecursorAlerts.svelte index 536ffd8..c29c2e3 100644 --- a/frontend/src/components/PrecursorAlerts.svelte +++ b/frontend/src/components/PrecursorAlerts.svelte @@ -14,7 +14,7 @@ let resolving: number | null = null; // Filter sidebar - let filterSidebarCollapsed = false; + let filterSidebarCollapsed = true; let filterStatus: 'active' | 'resolved' | 'all' = 'active'; let filterType: SequenceType | '' = ''; let filterDevice = ''; diff --git a/frontend/src/components/ThreatEvents.svelte b/frontend/src/components/ThreatEvents.svelte index a71e82d..b336e1a 100644 --- a/frontend/src/components/ThreatEvents.svelte +++ b/frontend/src/components/ThreatEvents.svelte @@ -16,7 +16,7 @@ let severityFilter: ThreatSeverity | '' = initialSeverity; let deviceSearch = ''; let deviceSearchInput = ''; - let filterSidebarCollapsed = false; + let filterSidebarCollapsed = true; let searchTimer: ReturnType; $: hasActiveFilters = !!severityFilter || !!deviceSearch; From b151bf112c309044efd0ecbbf7b4304b6fefd4a9 Mon Sep 17 00:00:00 2001 From: Johannes Date: Tue, 16 Jun 2026 21:16:37 -0400 Subject: [PATCH 11/15] style: standardize ThreatEvents filter chip CSS to canonical template Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/ThreatEvents.svelte | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/ThreatEvents.svelte b/frontend/src/components/ThreatEvents.svelte index b336e1a..5d94acf 100644 --- a/frontend/src/components/ThreatEvents.svelte +++ b/frontend/src/components/ThreatEvents.svelte @@ -344,12 +344,12 @@ .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; margin-bottom: 8px; padding: 0.5rem 0.75rem; 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; } @@ -437,5 +437,8 @@ :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) .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; } From 4dc3636f7023ab08c2f63abae33f77914d0162e3 Mon Sep 17 00:00:00 2001 From: Johannes Date: Tue, 16 Jun 2026 21:30:31 -0400 Subject: [PATCH 12/15] style: align all aria pages to canonical TemplatePage layout - page-right/content-frame/active-filters now match TemplatePage exactly - active-filters gets the blue tinted background box (#f0f4ff, border #d0d9f0) - content-frame uses padding+gap instead of margin-top - page-right gets padding:0.65rem + gap:0.5rem - ThreatEvents restructured with content-frame wrapper and toolbar-count - IocManagement .count renamed to .toolbar-count Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/DevicePosture.svelte | 12 +- frontend/src/components/IocManagement.svelte | 17 +-- .../src/components/PrecursorAlerts.svelte | 12 +- frontend/src/components/ThreatEvents.svelte | 116 ++++++++++-------- 4 files changed, 90 insertions(+), 67 deletions(-) diff --git a/frontend/src/components/DevicePosture.svelte b/frontend/src/components/DevicePosture.svelte index 4757833..efee1d3 100644 --- a/frontend/src/components/DevicePosture.svelte +++ b/frontend/src/components/DevicePosture.svelte @@ -489,6 +489,8 @@ flex-direction: column; overflow: hidden; min-height: 0; + padding: 0.65rem; + gap: 0.5rem; } .content-frame { @@ -499,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 */ @@ -543,7 +546,8 @@ /* 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; + margin-bottom: 1rem; padding: 0.6rem 1rem; + background: #f0f4ff; border: 1px solid #d0d9f0; border-radius: 8px; flex-shrink: 0; } .active-filters-label { font-size: var(--font-size-tiny); font-weight: 600; color: #555; margin-right: 0.25rem; } .filter-tag { @@ -563,10 +567,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 { diff --git a/frontend/src/components/IocManagement.svelte b/frontend/src/components/IocManagement.svelte index 20a4f67..aabf27d 100644 --- a/frontend/src/components/IocManagement.svelte +++ b/frontend/src/components/IocManagement.svelte @@ -223,7 +223,7 @@
- {filteredIocs.length} IOC{filteredIocs.length !== 1 ? 's' : ''} + {filteredIocs.length} IOC{filteredIocs.length !== 1 ? 's' : ''}
@@ -371,14 +371,16 @@ .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; margin-top: 8px; background: white; + min-height: 0; background: white; + padding: 1rem; gap: 0.75rem; } .page-main { - flex: 1; overflow-y: auto; padding: 16px 24px 24px; + flex: 1; overflow-y: auto; min-width: 0; display: flex; flex-direction: column; } @@ -435,7 +437,8 @@ /* Active filter chips */ .active-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; - margin-bottom: 0; padding: 0.5rem 0.75rem; flex-shrink: 0; + margin-bottom: 1rem; padding: 0.6rem 1rem; + background: #f0f4ff; border: 1px solid #d0d9f0; border-radius: 8px; flex-shrink: 0; } .active-filters-label { font-size: var(--font-size-tiny); font-weight: 600; color: #555; margin-right: 0.25rem; } .filter-tag { @@ -454,9 +457,9 @@ .filter-tag-remove:hover { background: #f44336; color: white; } /* Toolbar */ - .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-right { display: flex; gap: 0.5rem; } - .count { color: #6b7280; font-size: var(--font-size-body-sm); } + .toolbar-count { color: #6b7280; font-size: var(--font-size-body-sm); } /* Table */ .table-card { @@ -552,7 +555,7 @@ :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) .count { color: #9ca3af; } + :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; } diff --git a/frontend/src/components/PrecursorAlerts.svelte b/frontend/src/components/PrecursorAlerts.svelte index c29c2e3..bfdb60d 100644 --- a/frontend/src/components/PrecursorAlerts.svelte +++ b/frontend/src/components/PrecursorAlerts.svelte @@ -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 */ @@ -523,7 +526,8 @@ /* 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; + margin-bottom: 1rem; padding: 0.6rem 1rem; + background: #f0f4ff; border: 1px solid #d0d9f0; border-radius: 8px; 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 +547,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; } diff --git a/frontend/src/components/ThreatEvents.svelte b/frontend/src/components/ThreatEvents.svelte index 5d94acf..19d8ed0 100644 --- a/frontend/src/components/ThreatEvents.svelte +++ b/frontend/src/components/ThreatEvents.svelte @@ -121,7 +121,7 @@ onDestroy(() => clearInterval(tickInterval)); -
+

Threat Events

@@ -203,19 +203,19 @@
-
+
{#if hasActiveFilters}
Filters: {#if severityFilter} - + Severity: {severityFilter} {/if} {#if deviceSearch} - + Device: {deviceSearch} @@ -224,52 +224,58 @@
{/if} -
- { page = e.detail.page; load(); }} - on:pageSizeChange={e => { size = e.detail.size; page = 0; load(); }} /> -
- {#if loading} -
Loading…
- {:else if events.length === 0} -
No threat events found{hasActiveFilters ? ' matching current filters' : ''}.
- {:else} - - - - - - - - - - - - - {#each events as ev (ev.id)} +
+
+ {totalElements} event{totalElements !== 1 ? 's' : ''} +
+
+
+ { page = e.detail.page; load(); }} + on:pageSizeChange={e => { size = e.detail.size; page = 0; load(); }} /> +
+ {#if loading} +
Loading…
+ {:else if events.length === 0} +
No threat events found{hasActiveFilters ? ' matching current filters' : ''}.
+ {:else} +
SeveritySignalDeviceAttack PhaseIOC MatchDetected
+ - - - - - - + + + + + + - {/each} - -
- - {ev.severity} - - {fmtSignal(ev.signalKey)}{ev.deviceAgentId ?? 'β€”'}{ev.attackPhase ? PHASE_LABEL[ev.attackPhase] ?? ev.attackPhase : 'β€”'} - {#if ev.matchedIoc} - {ev.matchedIoc.iocType} - {ev.matchedIoc.value} - {:else} - β€” - {/if} - {fmt(ev.detectedAt)}SeveritySignalDeviceAttack PhaseIOC MatchDetected
- {/if} + + + {#each events as ev (ev.id)} + + + + {ev.severity} + + + {fmtSignal(ev.signalKey)} + {ev.deviceAgentId ?? 'β€”'} + {ev.attackPhase ? PHASE_LABEL[ev.attackPhase] ?? ev.attackPhase : 'β€”'} + + {#if ev.matchedIoc} + {ev.matchedIoc.iocType} + {ev.matchedIoc.value} + {:else} + β€” + {/if} + + {fmt(ev.detectedAt)} + + {/each} + + + {/if} +
@@ -277,7 +283,7 @@