Skip to content

Commit c845579

Browse files
author
vsilent
committed
tests, ip_ban engine implemented, frontend dashboard improvements
1 parent ca0944f commit c845579

34 files changed

+2029
-257
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
DROP INDEX IF EXISTS idx_ip_offenses_last_seen;
2+
DROP INDEX IF EXISTS idx_ip_offenses_status;
3+
DROP INDEX IF EXISTS idx_ip_offenses_ip;
4+
DROP TABLE IF EXISTS ip_offenses;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
CREATE TABLE IF NOT EXISTS ip_offenses (
2+
id TEXT PRIMARY KEY,
3+
ip_address TEXT NOT NULL,
4+
source_type TEXT NOT NULL,
5+
container_id TEXT,
6+
offense_count INTEGER NOT NULL DEFAULT 1,
7+
first_seen TEXT NOT NULL,
8+
last_seen TEXT NOT NULL,
9+
blocked_until TEXT,
10+
status TEXT NOT NULL DEFAULT 'Active',
11+
reason TEXT NOT NULL,
12+
metadata TEXT,
13+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
14+
);
15+
16+
CREATE INDEX IF NOT EXISTS idx_ip_offenses_ip ON ip_offenses(ip_address);
17+
CREATE INDEX IF NOT EXISTS idx_ip_offenses_status ON ip_offenses(status);
18+
CREATE INDEX IF NOT EXISTS idx_ip_offenses_last_seen ON ip_offenses(last_seen);

src/api/alerts.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ pub async fn get_alert_stats(pool: web::Data<DbPool>) -> impl Responder {
5050
"total_count": stats.total_count,
5151
"new_count": stats.new_count,
5252
"acknowledged_count": stats.acknowledged_count,
53-
"resolved_count": stats.resolved_count
53+
"resolved_count": stats.resolved_count,
54+
"false_positive_count": stats.false_positive_count
5455
})),
5556
Err(e) => {
5657
log::error!("Failed to get alert stats: {}", e);
@@ -59,7 +60,8 @@ pub async fn get_alert_stats(pool: web::Data<DbPool>) -> impl Responder {
5960
"total_count": 0,
6061
"new_count": 0,
6162
"acknowledged_count": 0,
62-
"resolved_count": 0
63+
"resolved_count": 0,
64+
"false_positive_count": 0
6365
}))
6466
}
6567
}

src/correlator/engine.rs

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,68 @@
11
//! Event correlation engine
22
3+
use crate::events::security::SecurityEvent;
34
use anyhow::Result;
5+
use chrono::Duration;
6+
use std::collections::HashMap;
7+
8+
#[derive(Debug, Clone)]
9+
pub struct CorrelatedEventGroup {
10+
pub correlation_key: String,
11+
pub events: Vec<SecurityEvent>,
12+
}
413

514
/// Event correlation engine
615
pub struct CorrelationEngine {
7-
// TODO: Implement in TASK-017
16+
window: Duration,
817
}
918

1019
impl CorrelationEngine {
1120
pub fn new() -> Result<Self> {
12-
Ok(Self {})
21+
Ok(Self {
22+
window: Duration::minutes(5),
23+
})
24+
}
25+
26+
pub fn correlate(&self, events: &[SecurityEvent]) -> Vec<CorrelatedEventGroup> {
27+
let mut grouped: HashMap<String, Vec<SecurityEvent>> = HashMap::new();
28+
29+
for event in events {
30+
if let Some(key) = self.correlation_key(event) {
31+
grouped.entry(key).or_default().push(event.clone());
32+
}
33+
}
34+
35+
grouped
36+
.into_iter()
37+
.filter_map(|(correlation_key, mut grouped_events)| {
38+
grouped_events.sort_by_key(SecurityEvent::timestamp);
39+
let first = grouped_events.first()?.timestamp();
40+
let last = grouped_events.last()?.timestamp();
41+
if grouped_events.len() >= 2 && (last - first) <= self.window {
42+
Some(CorrelatedEventGroup {
43+
correlation_key,
44+
events: grouped_events,
45+
})
46+
} else {
47+
None
48+
}
49+
})
50+
.collect()
51+
}
52+
53+
fn correlation_key(&self, event: &SecurityEvent) -> Option<String> {
54+
match event {
55+
SecurityEvent::Syscall(event) => Some(format!("pid:{}", event.pid)),
56+
SecurityEvent::Container(event) => Some(format!("container:{}", event.container_id)),
57+
SecurityEvent::Network(event) => event
58+
.container_id
59+
.as_ref()
60+
.map(|container_id| format!("container:{container_id}")),
61+
SecurityEvent::Alert(event) => event
62+
.source_event_id
63+
.as_ref()
64+
.map(|source_event_id| format!("source:{source_event_id}")),
65+
}
1366
}
1467
}
1568

@@ -18,3 +71,63 @@ impl Default for CorrelationEngine {
1871
Self::new().unwrap()
1972
}
2073
}
74+
75+
#[cfg(test)]
76+
mod tests {
77+
use super::*;
78+
use crate::events::security::{ContainerEvent, ContainerEventType, SecurityEvent};
79+
use crate::events::syscall::{SyscallEvent, SyscallType};
80+
use chrono::{Duration, Utc};
81+
82+
#[test]
83+
fn test_correlates_syscall_events_by_pid_within_window() {
84+
let engine = CorrelationEngine::new().unwrap();
85+
let now = Utc::now();
86+
let events = vec![
87+
SecurityEvent::Syscall(SyscallEvent::new(4242, 1000, SyscallType::Execve, now)),
88+
SecurityEvent::Syscall(SyscallEvent::new(
89+
4242,
90+
1000,
91+
SyscallType::Open,
92+
now + Duration::seconds(10),
93+
)),
94+
SecurityEvent::Syscall(SyscallEvent::new(7, 1000, SyscallType::Execve, now)),
95+
];
96+
97+
let groups = engine.correlate(&events);
98+
assert_eq!(groups.len(), 1);
99+
assert_eq!(groups[0].correlation_key, "pid:4242");
100+
assert_eq!(groups[0].events.len(), 2);
101+
}
102+
103+
#[test]
104+
fn test_correlates_container_events_by_container_id() {
105+
let engine = CorrelationEngine::new().unwrap();
106+
let now = Utc::now();
107+
let events = vec![
108+
SecurityEvent::Container(ContainerEvent {
109+
container_id: "container-1".into(),
110+
event_type: ContainerEventType::Start,
111+
timestamp: now,
112+
details: None,
113+
}),
114+
SecurityEvent::Container(ContainerEvent {
115+
container_id: "container-1".into(),
116+
event_type: ContainerEventType::Stop,
117+
timestamp: now + Duration::seconds(30),
118+
details: Some("manual stop".into()),
119+
}),
120+
SecurityEvent::Container(ContainerEvent {
121+
container_id: "container-2".into(),
122+
event_type: ContainerEventType::Start,
123+
timestamp: now,
124+
details: None,
125+
}),
126+
];
127+
128+
let groups = engine.correlate(&events);
129+
assert_eq!(groups.len(), 1);
130+
assert_eq!(groups[0].correlation_key, "container:container-1");
131+
assert_eq!(groups[0].events.len(), 2);
132+
}
133+
}

src/database/connection.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,37 @@ pub fn init_database(pool: &DbPool) -> Result<()> {
188188
[],
189189
);
190190

191+
conn.execute(
192+
"CREATE TABLE IF NOT EXISTS ip_offenses (
193+
id TEXT PRIMARY KEY,
194+
ip_address TEXT NOT NULL,
195+
source_type TEXT NOT NULL,
196+
container_id TEXT,
197+
offense_count INTEGER NOT NULL DEFAULT 1,
198+
first_seen TEXT NOT NULL,
199+
last_seen TEXT NOT NULL,
200+
blocked_until TEXT,
201+
status TEXT NOT NULL DEFAULT 'Active',
202+
reason TEXT NOT NULL,
203+
metadata TEXT,
204+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
205+
)",
206+
[],
207+
)?;
208+
209+
let _ = conn.execute(
210+
"CREATE INDEX IF NOT EXISTS idx_ip_offenses_ip ON ip_offenses(ip_address)",
211+
[],
212+
);
213+
let _ = conn.execute(
214+
"CREATE INDEX IF NOT EXISTS idx_ip_offenses_status ON ip_offenses(status)",
215+
[],
216+
);
217+
let _ = conn.execute(
218+
"CREATE INDEX IF NOT EXISTS idx_ip_offenses_last_seen ON ip_offenses(last_seen)",
219+
[],
220+
);
221+
191222
Ok(())
192223
}
193224

src/database/events.rs

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,55 @@
11
//! Security events database operations
22
3+
use crate::events::security::SecurityEvent;
34
use anyhow::Result;
5+
use chrono::{DateTime, Utc};
6+
use std::sync::{Arc, RwLock};
47

58
/// Events database manager
69
pub struct EventsDb {
7-
// TODO: Implement
10+
events: Arc<RwLock<Vec<SecurityEvent>>>,
811
}
912

1013
impl EventsDb {
1114
pub fn new() -> Result<Self> {
12-
Ok(Self {})
15+
Ok(Self {
16+
events: Arc::new(RwLock::new(Vec::new())),
17+
})
18+
}
19+
20+
pub fn insert(&self, event: SecurityEvent) -> Result<()> {
21+
self.events.write().unwrap().push(event);
22+
Ok(())
23+
}
24+
25+
pub fn list(&self) -> Result<Vec<SecurityEvent>> {
26+
Ok(self.events.read().unwrap().clone())
27+
}
28+
29+
pub fn events_since(&self, since: DateTime<Utc>) -> Result<Vec<SecurityEvent>> {
30+
Ok(self
31+
.events
32+
.read()
33+
.unwrap()
34+
.iter()
35+
.filter(|event| event.timestamp() >= since)
36+
.cloned()
37+
.collect())
38+
}
39+
40+
pub fn events_for_pid(&self, pid: u32) -> Result<Vec<SecurityEvent>> {
41+
Ok(self
42+
.events
43+
.read()
44+
.unwrap()
45+
.iter()
46+
.filter(|event| event.pid() == Some(pid))
47+
.cloned()
48+
.collect())
49+
}
50+
51+
pub fn len(&self) -> usize {
52+
self.events.read().unwrap().len()
1353
}
1454
}
1555

@@ -18,3 +58,75 @@ impl Default for EventsDb {
1858
Self::new().unwrap()
1959
}
2060
}
61+
62+
#[cfg(test)]
63+
mod tests {
64+
use super::*;
65+
use crate::events::security::{
66+
AlertEvent, AlertSeverity, AlertType, ContainerEvent, ContainerEventType,
67+
};
68+
use crate::events::syscall::{SyscallEvent, SyscallType};
69+
use chrono::{Duration, Utc};
70+
71+
#[test]
72+
fn test_events_db_stores_and_queries_events_since_timestamp() {
73+
let db = EventsDb::new().unwrap();
74+
let old_time = Utc::now() - Duration::minutes(10);
75+
let recent_time = Utc::now();
76+
77+
db.insert(SecurityEvent::Alert(AlertEvent {
78+
alert_type: AlertType::ThreatDetected,
79+
severity: AlertSeverity::High,
80+
message: "old event".into(),
81+
timestamp: old_time,
82+
source_event_id: None,
83+
}))
84+
.unwrap();
85+
db.insert(SecurityEvent::Alert(AlertEvent {
86+
alert_type: AlertType::AnomalyDetected,
87+
severity: AlertSeverity::Critical,
88+
message: "recent event".into(),
89+
timestamp: recent_time,
90+
source_event_id: None,
91+
}))
92+
.unwrap();
93+
94+
let recent = db.events_since(Utc::now() - Duration::minutes(1)).unwrap();
95+
assert_eq!(recent.len(), 1);
96+
match &recent[0] {
97+
SecurityEvent::Alert(event) => assert_eq!(event.message, "recent event"),
98+
other => panic!("unexpected event: {other:?}"),
99+
}
100+
}
101+
102+
#[test]
103+
fn test_events_db_filters_events_by_pid() {
104+
let db = EventsDb::new().unwrap();
105+
db.insert(SecurityEvent::Syscall(SyscallEvent::new(
106+
42,
107+
1000,
108+
SyscallType::Execve,
109+
Utc::now(),
110+
)))
111+
.unwrap();
112+
db.insert(SecurityEvent::Container(ContainerEvent {
113+
container_id: "container-1".into(),
114+
event_type: ContainerEventType::Start,
115+
timestamp: Utc::now(),
116+
details: None,
117+
}))
118+
.unwrap();
119+
db.insert(SecurityEvent::Syscall(SyscallEvent::new(
120+
7,
121+
1000,
122+
SyscallType::Open,
123+
Utc::now(),
124+
)))
125+
.unwrap();
126+
127+
let pid_events = db.events_for_pid(42).unwrap();
128+
assert_eq!(pid_events.len(), 1);
129+
assert_eq!(pid_events[0].pid(), Some(42));
130+
assert_eq!(db.len(), 3);
131+
}
132+
}

src/database/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
33
pub mod baselines;
44
pub mod connection;
5+
pub mod events;
56
pub mod models;
67
pub mod repositories;
78

89
pub use baselines::*;
910
pub use connection::{create_pool, init_database, DbPool};
11+
pub use events::*;
1012
pub use models::*;
1113
pub use repositories::alerts::*;
14+
pub use repositories::offenses::*;
1215

1316
/// Marker struct for module tests
1417
pub struct DatabaseMarker;

src/database/repositories/alerts.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub struct AlertStats {
2121
pub new_count: i64,
2222
pub acknowledged_count: i64,
2323
pub resolved_count: i64,
24+
pub false_positive_count: i64,
2425
}
2526

2627
/// Severity breakdown for open security alerts.
@@ -263,12 +264,18 @@ pub async fn get_alert_stats(pool: &DbPool) -> Result<AlertStats> {
263264
[],
264265
|row| row.get(0),
265266
)?;
267+
let false_positive: i64 = conn.query_row(
268+
"SELECT COUNT(*) FROM alerts WHERE status = 'FalsePositive'",
269+
[],
270+
|row| row.get(0),
271+
)?;
266272

267273
Ok(AlertStats {
268274
total_count: total,
269275
new_count: new,
270276
acknowledged_count: ack,
271277
resolved_count: resolved,
278+
false_positive_count: false_positive,
272279
})
273280
}
274281

0 commit comments

Comments
 (0)