Merge pull request '[session 1] Release 2026-07: aria — ingest noise filter + tomcat CVE fix (develop → main)' (#15) from develop into main
CD - Production / build-and-deploy (push) Has started running
CD - Production / build-and-deploy (push) Has started running
This commit was merged in pull request #15.
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>com.hiveops</groupId>
|
<groupId>com.hiveops</groupId>
|
||||||
<artifactId>hiveops-bom</artifactId>
|
<artifactId>hiveops-bom</artifactId>
|
||||||
<version>1.0.8</version>
|
<version>1.0.9</version>
|
||||||
<relativePath/>
|
<relativePath/>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import com.hiveops.aria.kafka.AriaThreatProducer;
|
|||||||
import com.hiveops.aria.repository.ThreatEventRepository;
|
import com.hiveops.aria.repository.ThreatEventRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -24,6 +26,12 @@ public class ThreatEventIngestionService {
|
|||||||
private final AriaThreatProducer producer;
|
private final AriaThreatProducer producer;
|
||||||
private final SequenceDetectionService sequenceDetectionService;
|
private final SequenceDetectionService sequenceDetectionService;
|
||||||
|
|
||||||
|
/** Drop events that classify as INFO with no IOC and no attack phase before persisting (aria#12). */
|
||||||
|
@Value("${aria.ingest.drop-unclassified-info:true}")
|
||||||
|
private boolean dropUnclassifiedInfo;
|
||||||
|
|
||||||
|
private final AtomicLong droppedNoiseCount = new AtomicLong();
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void ingest(ThreatEventIngestionRequest request) {
|
public void ingest(ThreatEventIngestionRequest request) {
|
||||||
for (ThreatEventItem item : request.getEvents()) {
|
for (ThreatEventItem item : request.getEvents()) {
|
||||||
@@ -39,6 +47,15 @@ public class ThreatEventIngestionService {
|
|||||||
private void processEvent(ThreatEventIngestionRequest request, ThreatEventItem item) {
|
private void processEvent(ThreatEventIngestionRequest request, ThreatEventItem item) {
|
||||||
ThreatEventClassifier.ClassificationResult result = classifier.classify(item);
|
ThreatEventClassifier.ClassificationResult result = classifier.classify(item);
|
||||||
|
|
||||||
|
if (dropUnclassifiedInfo && isUnclassifiedNoise(result)) {
|
||||||
|
long dropped = droppedNoiseCount.incrementAndGet();
|
||||||
|
if (dropped == 1 || dropped % 100_000 == 0) {
|
||||||
|
log.info("ARIA ingest filter: dropped {} unclassified INFO events (no IOC, no attack phase) since startup — latest signal={} device={} (aria#12)",
|
||||||
|
dropped, item.getSignalKey(), request.getDeviceAgentId());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ThreatEvent.EventType eventType;
|
ThreatEvent.EventType eventType;
|
||||||
try {
|
try {
|
||||||
eventType = ThreatEvent.EventType.valueOf(item.getEventType());
|
eventType = ThreatEvent.EventType.valueOf(item.getEventType());
|
||||||
@@ -76,6 +93,20 @@ public class ThreatEventIngestionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event that classifies as INFO with no matched IOC and no attack phase carries no
|
||||||
|
* detection value: it is never published to Kafka (only HIGH/CRITICAL are) and cannot
|
||||||
|
* contribute to a precursor sequence (SequenceDetectionService ignores null-phase events
|
||||||
|
* and requires a PHYSICAL_ACCESS phase). Persisting it only bloats threat_event — this is
|
||||||
|
* the Windows 4663 object-access flood (aria#12): agents send bare "4663", which misses the
|
||||||
|
* classifier's "EVENT_4663" case and lands in the INFO default branch. Drop it before save().
|
||||||
|
*/
|
||||||
|
private boolean isUnclassifiedNoise(ThreatEventClassifier.ClassificationResult result) {
|
||||||
|
return result.severity() == ThreatEvent.ThreatSeverity.INFO
|
||||||
|
&& result.matchedIoc() == null
|
||||||
|
&& result.attackPhase() == null;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean shouldPublish(ThreatEvent.ThreatSeverity severity) {
|
private boolean shouldPublish(ThreatEvent.ThreatSeverity severity) {
|
||||||
return severity == ThreatEvent.ThreatSeverity.CRITICAL
|
return severity == ThreatEvent.ThreatSeverity.CRITICAL
|
||||||
|| severity == ThreatEvent.ThreatSeverity.HIGH;
|
|| severity == ThreatEvent.ThreatSeverity.HIGH;
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ aria.internal.service-secret=${ARIA_SERVICE_SECRET:}
|
|||||||
# Sequence detection window (minutes)
|
# Sequence detection window (minutes)
|
||||||
aria.sequence.window.minutes=${ARIA_SEQUENCE_WINDOW_MINUTES:15}
|
aria.sequence.window.minutes=${ARIA_SEQUENCE_WINDOW_MINUTES:15}
|
||||||
|
|
||||||
|
# Ingest noise filter — drop events that classify as INFO with no IOC match and no attack
|
||||||
|
# phase before they hit threat_event (they never publish to Kafka and can't form a sequence).
|
||||||
|
# Kills the Windows 4663 object-access flood (aria#12). Set false to persist everything again.
|
||||||
|
aria.ingest.drop-unclassified-info=${ARIA_DROP_UNCLASSIFIED_INFO:true}
|
||||||
|
|
||||||
# IOC Feed Refresh
|
# IOC Feed Refresh
|
||||||
aria.feed.enabled=${ARIA_FEED_ENABLED:true}
|
aria.feed.enabled=${ARIA_FEED_ENABLED:true}
|
||||||
aria.feed.refresh.cron=${ARIA_FEED_REFRESH_CRON:0 0 3 * * *}
|
aria.feed.refresh.cron=${ARIA_FEED_REFRESH_CRON:0 0 3 * * *}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.hiveops.aria.service;
|
||||||
|
|
||||||
|
import com.hiveops.aria.dto.ThreatEventItem;
|
||||||
|
import com.hiveops.aria.entity.ThreatEvent.AttackPhase;
|
||||||
|
import com.hiveops.aria.entity.ThreatEvent.ThreatSeverity;
|
||||||
|
import com.hiveops.aria.repository.ThreatIocRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the classifier behaviour the ingest noise filter relies on (aria#12).
|
||||||
|
*
|
||||||
|
* <p>An event is "unclassified noise" — and therefore dropped before persistence — exactly when it
|
||||||
|
* classifies as INFO with no matched IOC and no attack phase. These tests lock in which signals fall
|
||||||
|
* into that bucket and which do not.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ThreatEventClassifierTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ThreatIocRepository iocRepository;
|
||||||
|
|
||||||
|
private ThreatEventClassifier classifier() {
|
||||||
|
return new ThreatEventClassifier(iocRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ThreatEventItem item(String signalKey, String eventType) {
|
||||||
|
ThreatEventItem item = new ThreatEventItem();
|
||||||
|
item.setSignalKey(signalKey);
|
||||||
|
item.setEventType(eventType);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isUnclassifiedNoise(ThreatEventClassifier.ClassificationResult r) {
|
||||||
|
return r.severity() == ThreatSeverity.INFO && r.matchedIoc() == null && r.attackPhase() == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bareWindowsEventId4663_isUnclassifiedNoise_andGetsDropped() {
|
||||||
|
// Agents send the bare number "4663", which misses the classifier's "EVENT_4663" case and
|
||||||
|
// lands in the INFO default branch — this is the 68M-row flood in aria#12.
|
||||||
|
var result = classifier().classify(item("4663", "OS_EVENT"));
|
||||||
|
|
||||||
|
assertThat(result.severity()).isEqualTo(ThreatSeverity.INFO);
|
||||||
|
assertThat(result.attackPhase()).isNull();
|
||||||
|
assertThat(result.matchedIoc()).isNull();
|
||||||
|
assertThat(isUnclassifiedNoise(result)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownSignal_isUnclassifiedNoise() {
|
||||||
|
var result = classifier().classify(item("SOMETHING_UNSEEN", "OS_EVENT"));
|
||||||
|
|
||||||
|
assertThat(isUnclassifiedNoise(result)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void properEvent4663_isClassified_andKept() {
|
||||||
|
// The correctly-formatted signal is HIGH / MALWARE_STAGING and must never be dropped.
|
||||||
|
var result = classifier().classify(item("EVENT_4663", "OS_EVENT"));
|
||||||
|
|
||||||
|
assertThat(result.severity()).isEqualTo(ThreatSeverity.HIGH);
|
||||||
|
assertThat(result.attackPhase()).isEqualTo(AttackPhase.MALWARE_STAGING);
|
||||||
|
assertThat(isUnclassifiedNoise(result)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void physicalSignal_isClassified_andKept() {
|
||||||
|
var result = classifier().classify(item("DOOR_OPEN", "PHYSICAL"));
|
||||||
|
|
||||||
|
assertThat(result.severity()).isEqualTo(ThreatSeverity.MEDIUM);
|
||||||
|
assertThat(result.attackPhase()).isEqualTo(AttackPhase.PHYSICAL_ACCESS);
|
||||||
|
assertThat(isUnclassifiedNoise(result)).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user