From 1a47e2dea9f43c2c2525110cdf947fab4222dc0a Mon Sep 17 00:00:00 2001 From: Johannes Date: Tue, 16 Jun 2026 20:45:19 -0400 Subject: [PATCH] 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;