- Add shell_script_arg() method for bash/sh/cmd -c scripts - Allow > < redirects in shell scripts (blocked in regular args) - Allow && || command chaining in shell scripts - Update safe_sh_command functions to use shell_script_arg - Update run_commands, start, and LLM server commands - Block dangerous patterns: backticks, path traversal - Fix struct field mismatches and type errors
1202 lines
36 KiB
Rust
1202 lines
36 KiB
Rust
use chrono::{DateTime, Duration, Timelike, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use tokio::sync::RwLock;
|
|
use tracing::{info, warn};
|
|
use uuid::Uuid;
|
|
|
|
const DEFAULT_BRUTE_FORCE_THRESHOLD: u32 = 5;
|
|
const DEFAULT_BRUTE_FORCE_WINDOW_SECONDS: i64 = 300;
|
|
const DEFAULT_LOCKOUT_DURATION_MINUTES: i64 = 30;
|
|
const DEFAULT_ANOMALY_THRESHOLD: f64 = 3.0;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SecurityMonitoringConfig {
|
|
pub enabled: bool,
|
|
pub brute_force_threshold: u32,
|
|
pub brute_force_window_seconds: i64,
|
|
pub lockout_duration_minutes: i64,
|
|
pub anomaly_detection_enabled: bool,
|
|
pub anomaly_threshold_stddev: f64,
|
|
pub geo_anomaly_detection: bool,
|
|
pub impossible_travel_detection: bool,
|
|
pub max_travel_speed_kmh: f64,
|
|
pub alert_on_critical: bool,
|
|
pub alert_on_high: bool,
|
|
pub retention_hours: u32,
|
|
}
|
|
|
|
impl Default for SecurityMonitoringConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
brute_force_threshold: DEFAULT_BRUTE_FORCE_THRESHOLD,
|
|
brute_force_window_seconds: DEFAULT_BRUTE_FORCE_WINDOW_SECONDS,
|
|
lockout_duration_minutes: DEFAULT_LOCKOUT_DURATION_MINUTES,
|
|
anomaly_detection_enabled: true,
|
|
anomaly_threshold_stddev: DEFAULT_ANOMALY_THRESHOLD,
|
|
geo_anomaly_detection: true,
|
|
impossible_travel_detection: true,
|
|
max_travel_speed_kmh: 1000.0,
|
|
alert_on_critical: true,
|
|
alert_on_high: true,
|
|
retention_hours: 168,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum SecurityEventType {
|
|
LoginAttempt,
|
|
LoginSuccess,
|
|
LoginFailure,
|
|
PasswordReset,
|
|
MfaChallenge,
|
|
MfaFailure,
|
|
SessionCreated,
|
|
SessionRevoked,
|
|
PermissionDenied,
|
|
RateLimitExceeded,
|
|
SuspiciousActivity,
|
|
BruteForceDetected,
|
|
AccountLocked,
|
|
IpBlocked,
|
|
GeoAnomalyDetected,
|
|
ImpossibleTravel,
|
|
NewDeviceLogin,
|
|
ApiKeyUsed,
|
|
PrivilegeEscalation,
|
|
DataExfiltration,
|
|
}
|
|
|
|
impl SecurityEventType {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::LoginAttempt => "login_attempt",
|
|
Self::LoginSuccess => "login_success",
|
|
Self::LoginFailure => "login_failure",
|
|
Self::PasswordReset => "password_reset",
|
|
Self::MfaChallenge => "mfa_challenge",
|
|
Self::MfaFailure => "mfa_failure",
|
|
Self::SessionCreated => "session_created",
|
|
Self::SessionRevoked => "session_revoked",
|
|
Self::PermissionDenied => "permission_denied",
|
|
Self::RateLimitExceeded => "rate_limit_exceeded",
|
|
Self::SuspiciousActivity => "suspicious_activity",
|
|
Self::BruteForceDetected => "brute_force_detected",
|
|
Self::AccountLocked => "account_locked",
|
|
Self::IpBlocked => "ip_blocked",
|
|
Self::GeoAnomalyDetected => "geo_anomaly_detected",
|
|
Self::ImpossibleTravel => "impossible_travel",
|
|
Self::NewDeviceLogin => "new_device_login",
|
|
Self::ApiKeyUsed => "api_key_used",
|
|
Self::PrivilegeEscalation => "privilege_escalation",
|
|
Self::DataExfiltration => "data_exfiltration",
|
|
}
|
|
}
|
|
|
|
pub fn severity(&self) -> AlertSeverity {
|
|
match self {
|
|
Self::BruteForceDetected
|
|
| Self::AccountLocked
|
|
| Self::PrivilegeEscalation
|
|
| Self::DataExfiltration => AlertSeverity::Critical,
|
|
Self::LoginFailure
|
|
| Self::MfaFailure
|
|
| Self::PermissionDenied
|
|
| Self::ImpossibleTravel
|
|
| Self::GeoAnomalyDetected => AlertSeverity::High,
|
|
Self::RateLimitExceeded
|
|
| Self::SuspiciousActivity
|
|
| Self::IpBlocked
|
|
| Self::NewDeviceLogin => AlertSeverity::Medium,
|
|
_ => AlertSeverity::Low,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
|
|
pub enum AlertSeverity {
|
|
Low,
|
|
Medium,
|
|
High,
|
|
Critical,
|
|
}
|
|
|
|
impl AlertSeverity {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Low => "low",
|
|
Self::Medium => "medium",
|
|
Self::High => "high",
|
|
Self::Critical => "critical",
|
|
}
|
|
}
|
|
|
|
pub fn score(&self) -> u8 {
|
|
match self {
|
|
Self::Low => 25,
|
|
Self::Medium => 50,
|
|
Self::High => 75,
|
|
Self::Critical => 100,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SecurityEvent {
|
|
pub id: Uuid,
|
|
pub timestamp: DateTime<Utc>,
|
|
pub event_type: SecurityEventType,
|
|
pub severity: AlertSeverity,
|
|
pub user_id: Option<Uuid>,
|
|
pub ip_address: Option<String>,
|
|
pub user_agent: Option<String>,
|
|
pub location: Option<GeoLocation>,
|
|
pub device_fingerprint: Option<String>,
|
|
pub details: HashMap<String, serde_json::Value>,
|
|
pub request_id: Option<String>,
|
|
}
|
|
|
|
impl SecurityEvent {
|
|
pub fn new(event_type: SecurityEventType) -> Self {
|
|
Self {
|
|
id: Uuid::new_v4(),
|
|
timestamp: Utc::now(),
|
|
event_type,
|
|
severity: event_type.severity(),
|
|
user_id: None,
|
|
ip_address: None,
|
|
user_agent: None,
|
|
location: None,
|
|
device_fingerprint: None,
|
|
details: HashMap::new(),
|
|
request_id: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_user(mut self, user_id: Uuid) -> Self {
|
|
self.user_id = Some(user_id);
|
|
self
|
|
}
|
|
|
|
pub fn with_ip(mut self, ip: String) -> Self {
|
|
self.ip_address = Some(ip);
|
|
self
|
|
}
|
|
|
|
pub fn with_user_agent(mut self, ua: String) -> Self {
|
|
self.user_agent = Some(ua);
|
|
self
|
|
}
|
|
|
|
pub fn with_location(mut self, location: GeoLocation) -> Self {
|
|
self.location = Some(location);
|
|
self
|
|
}
|
|
|
|
pub fn with_device(mut self, fingerprint: String) -> Self {
|
|
self.device_fingerprint = Some(fingerprint);
|
|
self
|
|
}
|
|
|
|
pub fn with_detail(mut self, key: &str, value: serde_json::Value) -> Self {
|
|
self.details.insert(key.to_string(), value);
|
|
self
|
|
}
|
|
|
|
pub fn with_request_id(mut self, request_id: String) -> Self {
|
|
self.request_id = Some(request_id);
|
|
self
|
|
}
|
|
|
|
pub fn is_critical(&self) -> bool {
|
|
self.severity == AlertSeverity::Critical
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct GeoLocation {
|
|
pub country: Option<String>,
|
|
pub region: Option<String>,
|
|
pub city: Option<String>,
|
|
pub latitude: Option<f64>,
|
|
pub longitude: Option<f64>,
|
|
pub timezone: Option<String>,
|
|
}
|
|
|
|
impl GeoLocation {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
country: None,
|
|
region: None,
|
|
city: None,
|
|
latitude: None,
|
|
longitude: None,
|
|
timezone: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_country(mut self, country: &str) -> Self {
|
|
self.country = Some(country.to_string());
|
|
self
|
|
}
|
|
|
|
pub fn with_city(mut self, city: &str) -> Self {
|
|
self.city = Some(city.to_string());
|
|
self
|
|
}
|
|
|
|
pub fn with_coordinates(mut self, lat: f64, lon: f64) -> Self {
|
|
self.latitude = Some(lat);
|
|
self.longitude = Some(lon);
|
|
self
|
|
}
|
|
|
|
pub fn distance_km(&self, other: &GeoLocation) -> Option<f64> {
|
|
match (self.latitude, self.longitude, other.latitude, other.longitude) {
|
|
(Some(lat1), Some(lon1), Some(lat2), Some(lon2)) => {
|
|
Some(haversine_distance(lat1, lon1, lat2, lon2))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for GeoLocation {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LoginAttemptRecord {
|
|
pub user_id: Option<Uuid>,
|
|
pub ip_address: String,
|
|
pub timestamp: DateTime<Utc>,
|
|
pub success: bool,
|
|
pub user_agent: Option<String>,
|
|
pub location: Option<GeoLocation>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LockoutRecord {
|
|
pub identifier: String,
|
|
pub locked_at: DateTime<Utc>,
|
|
pub expires_at: DateTime<Utc>,
|
|
pub reason: String,
|
|
pub attempt_count: u32,
|
|
}
|
|
|
|
impl LockoutRecord {
|
|
pub fn is_expired(&self) -> bool {
|
|
Utc::now() > self.expires_at
|
|
}
|
|
|
|
pub fn remaining_time(&self) -> Duration {
|
|
if self.is_expired() {
|
|
Duration::zero()
|
|
} else {
|
|
self.expires_at - Utc::now()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SecurityAlert {
|
|
pub id: Uuid,
|
|
pub timestamp: DateTime<Utc>,
|
|
pub severity: AlertSeverity,
|
|
pub title: String,
|
|
pub description: String,
|
|
pub event_ids: Vec<Uuid>,
|
|
pub user_id: Option<Uuid>,
|
|
pub ip_address: Option<String>,
|
|
pub acknowledged: bool,
|
|
pub acknowledged_by: Option<Uuid>,
|
|
pub acknowledged_at: Option<DateTime<Utc>>,
|
|
pub resolved: bool,
|
|
pub resolved_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
impl SecurityAlert {
|
|
pub fn new(severity: AlertSeverity, title: &str, description: &str) -> Self {
|
|
Self {
|
|
id: Uuid::new_v4(),
|
|
timestamp: Utc::now(),
|
|
severity,
|
|
title: title.to_string(),
|
|
description: description.to_string(),
|
|
event_ids: Vec::new(),
|
|
user_id: None,
|
|
ip_address: None,
|
|
acknowledged: false,
|
|
acknowledged_by: None,
|
|
acknowledged_at: None,
|
|
resolved: false,
|
|
resolved_at: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_event(mut self, event_id: Uuid) -> Self {
|
|
self.event_ids.push(event_id);
|
|
self
|
|
}
|
|
|
|
pub fn with_events(mut self, event_ids: Vec<Uuid>) -> Self {
|
|
self.event_ids.extend(event_ids);
|
|
self
|
|
}
|
|
|
|
pub fn with_user(mut self, user_id: Uuid) -> Self {
|
|
self.user_id = Some(user_id);
|
|
self
|
|
}
|
|
|
|
pub fn with_ip(mut self, ip: String) -> Self {
|
|
self.ip_address = Some(ip);
|
|
self
|
|
}
|
|
|
|
pub fn acknowledge(&mut self, by: Uuid) {
|
|
self.acknowledged = true;
|
|
self.acknowledged_by = Some(by);
|
|
self.acknowledged_at = Some(Utc::now());
|
|
}
|
|
|
|
pub fn resolve(&mut self) {
|
|
self.resolved = true;
|
|
self.resolved_at = Some(Utc::now());
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct UserSecurityProfile {
|
|
pub user_id: Uuid,
|
|
pub known_ips: Vec<String>,
|
|
pub known_devices: Vec<String>,
|
|
pub known_locations: Vec<GeoLocation>,
|
|
pub last_login: Option<DateTime<Utc>>,
|
|
pub last_location: Option<GeoLocation>,
|
|
pub login_times: Vec<u32>,
|
|
pub risk_score: f64,
|
|
pub is_locked: bool,
|
|
pub lock_expires_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
impl UserSecurityProfile {
|
|
pub fn new(user_id: Uuid) -> Self {
|
|
Self {
|
|
user_id,
|
|
known_ips: Vec::new(),
|
|
known_devices: Vec::new(),
|
|
known_locations: Vec::new(),
|
|
last_login: None,
|
|
last_location: None,
|
|
login_times: Vec::new(),
|
|
risk_score: 0.0,
|
|
is_locked: false,
|
|
lock_expires_at: None,
|
|
}
|
|
}
|
|
|
|
pub fn is_known_ip(&self, ip: &str) -> bool {
|
|
self.known_ips.contains(&ip.to_string())
|
|
}
|
|
|
|
pub fn is_known_device(&self, device: &str) -> bool {
|
|
self.known_devices.contains(&device.to_string())
|
|
}
|
|
|
|
pub fn add_known_ip(&mut self, ip: &str) {
|
|
if !self.is_known_ip(ip) {
|
|
self.known_ips.push(ip.to_string());
|
|
if self.known_ips.len() > 100 {
|
|
self.known_ips.remove(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn add_known_device(&mut self, device: &str) {
|
|
if !self.is_known_device(device) {
|
|
self.known_devices.push(device.to_string());
|
|
if self.known_devices.len() > 50 {
|
|
self.known_devices.remove(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn record_login(&mut self, location: Option<GeoLocation>) {
|
|
let now = Utc::now();
|
|
self.last_login = Some(now);
|
|
self.login_times.push(now.hour());
|
|
if self.login_times.len() > 1000 {
|
|
self.login_times.remove(0);
|
|
}
|
|
if let Some(loc) = location {
|
|
self.last_location = Some(loc.clone());
|
|
self.known_locations.push(loc);
|
|
if self.known_locations.len() > 50 {
|
|
self.known_locations.remove(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn is_unusual_login_time(&self, hour: u32) -> bool {
|
|
if self.login_times.len() < 10 {
|
|
return false;
|
|
}
|
|
|
|
let count = self.login_times.iter().filter(|&&h| h == hour).count();
|
|
let percentage = count as f64 / self.login_times.len() as f64;
|
|
|
|
percentage < 0.01
|
|
}
|
|
|
|
pub fn lock(&mut self, duration: Duration) {
|
|
self.is_locked = true;
|
|
self.lock_expires_at = Some(Utc::now() + duration);
|
|
}
|
|
|
|
pub fn unlock(&mut self) {
|
|
self.is_locked = false;
|
|
self.lock_expires_at = None;
|
|
}
|
|
|
|
pub fn check_lock_status(&mut self) -> bool {
|
|
if self.is_locked {
|
|
if let Some(expires) = self.lock_expires_at {
|
|
if Utc::now() > expires {
|
|
self.unlock();
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
}
|
|
|
|
pub struct SecurityMonitor {
|
|
config: SecurityMonitoringConfig,
|
|
events: Arc<RwLock<Vec<SecurityEvent>>>,
|
|
login_attempts: Arc<RwLock<HashMap<String, Vec<LoginAttemptRecord>>>>,
|
|
lockouts: Arc<RwLock<HashMap<String, LockoutRecord>>>,
|
|
alerts: Arc<RwLock<Vec<SecurityAlert>>>,
|
|
user_profiles: Arc<RwLock<HashMap<Uuid, UserSecurityProfile>>>,
|
|
blocked_ips: Arc<RwLock<HashMap<String, DateTime<Utc>>>>,
|
|
}
|
|
|
|
impl SecurityMonitor {
|
|
pub fn new(config: SecurityMonitoringConfig) -> Self {
|
|
Self {
|
|
config,
|
|
events: Arc::new(RwLock::new(Vec::new())),
|
|
login_attempts: Arc::new(RwLock::new(HashMap::new())),
|
|
lockouts: Arc::new(RwLock::new(HashMap::new())),
|
|
alerts: Arc::new(RwLock::new(Vec::new())),
|
|
user_profiles: Arc::new(RwLock::new(HashMap::new())),
|
|
blocked_ips: Arc::new(RwLock::new(HashMap::new())),
|
|
}
|
|
}
|
|
|
|
pub fn with_defaults() -> Self {
|
|
Self::new(SecurityMonitoringConfig::default())
|
|
}
|
|
|
|
pub async fn record_event(&self, event: SecurityEvent) {
|
|
if !self.config.enabled {
|
|
return;
|
|
}
|
|
|
|
let should_alert = match event.severity {
|
|
AlertSeverity::Critical => self.config.alert_on_critical,
|
|
AlertSeverity::High => self.config.alert_on_high,
|
|
_ => false,
|
|
};
|
|
|
|
if should_alert {
|
|
self.create_alert_from_event(&event).await;
|
|
}
|
|
|
|
let mut events = self.events.write().await;
|
|
events.push(event);
|
|
|
|
if events.len() > 100_000 {
|
|
events.remove(0);
|
|
}
|
|
}
|
|
|
|
pub async fn record_login_attempt(
|
|
&self,
|
|
user_id: Option<Uuid>,
|
|
ip: &str,
|
|
success: bool,
|
|
user_agent: Option<&str>,
|
|
location: Option<GeoLocation>,
|
|
) -> Option<SecurityAlert> {
|
|
if !self.config.enabled {
|
|
return None;
|
|
}
|
|
|
|
let record = LoginAttemptRecord {
|
|
user_id,
|
|
ip_address: ip.to_string(),
|
|
timestamp: Utc::now(),
|
|
success,
|
|
user_agent: user_agent.map(String::from),
|
|
location: location.clone(),
|
|
};
|
|
|
|
let key = user_id
|
|
.map(|id| id.to_string())
|
|
.unwrap_or_else(|| ip.to_string());
|
|
|
|
let mut attempts = self.login_attempts.write().await;
|
|
let user_attempts = attempts.entry(key.clone()).or_default();
|
|
user_attempts.push(record);
|
|
|
|
let window_start = Utc::now() - Duration::seconds(self.config.brute_force_window_seconds);
|
|
user_attempts.retain(|a| a.timestamp > window_start);
|
|
|
|
let failed_count = user_attempts.iter().filter(|a| !a.success).count() as u32;
|
|
|
|
drop(attempts);
|
|
|
|
let event_type = if success {
|
|
SecurityEventType::LoginSuccess
|
|
} else {
|
|
SecurityEventType::LoginFailure
|
|
};
|
|
|
|
let mut event = SecurityEvent::new(event_type).with_ip(ip.to_string());
|
|
|
|
if let Some(uid) = user_id {
|
|
event = event.with_user(uid);
|
|
}
|
|
|
|
if let Some(ua) = user_agent {
|
|
event = event.with_user_agent(ua.to_string());
|
|
}
|
|
|
|
if let Some(loc) = location.clone() {
|
|
event = event.with_location(loc);
|
|
}
|
|
|
|
self.record_event(event).await;
|
|
|
|
if !success && failed_count >= self.config.brute_force_threshold {
|
|
return self.handle_brute_force(&key, ip, user_id).await;
|
|
}
|
|
|
|
if success {
|
|
if let Some(uid) = user_id {
|
|
self.check_login_anomalies(uid, ip, location).await;
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
async fn handle_brute_force(
|
|
&self,
|
|
key: &str,
|
|
ip: &str,
|
|
user_id: Option<Uuid>,
|
|
) -> Option<SecurityAlert> {
|
|
let lockout = LockoutRecord {
|
|
identifier: key.to_string(),
|
|
locked_at: Utc::now(),
|
|
expires_at: Utc::now() + Duration::minutes(self.config.lockout_duration_minutes),
|
|
reason: "Brute force attack detected".to_string(),
|
|
attempt_count: self.config.brute_force_threshold,
|
|
};
|
|
|
|
{
|
|
let mut lockouts = self.lockouts.write().await;
|
|
lockouts.insert(key.to_string(), lockout);
|
|
}
|
|
|
|
{
|
|
let mut blocked = self.blocked_ips.write().await;
|
|
blocked.insert(
|
|
ip.to_string(),
|
|
Utc::now() + Duration::minutes(self.config.lockout_duration_minutes),
|
|
);
|
|
}
|
|
|
|
if let Some(uid) = user_id {
|
|
let mut profiles = self.user_profiles.write().await;
|
|
let profile = profiles
|
|
.entry(uid)
|
|
.or_insert_with(|| UserSecurityProfile::new(uid));
|
|
profile.lock(Duration::minutes(self.config.lockout_duration_minutes));
|
|
}
|
|
|
|
let mut event = SecurityEvent::new(SecurityEventType::BruteForceDetected)
|
|
.with_ip(ip.to_string())
|
|
.with_detail(
|
|
"threshold",
|
|
serde_json::json!(self.config.brute_force_threshold),
|
|
);
|
|
|
|
if let Some(uid) = user_id {
|
|
event = event.with_user(uid);
|
|
}
|
|
|
|
self.record_event(event.clone()).await;
|
|
|
|
warn!(
|
|
"Brute force attack detected for {} from IP {}",
|
|
key, ip
|
|
);
|
|
|
|
let alert = SecurityAlert::new(
|
|
AlertSeverity::Critical,
|
|
"Brute Force Attack Detected",
|
|
&format!(
|
|
"Multiple failed login attempts detected for {}. Account locked for {} minutes.",
|
|
key, self.config.lockout_duration_minutes
|
|
),
|
|
)
|
|
.with_event(event.id)
|
|
.with_ip(ip.to_string());
|
|
|
|
let alert_with_user = if let Some(uid) = user_id {
|
|
alert.with_user(uid)
|
|
} else {
|
|
alert
|
|
};
|
|
|
|
let mut alerts = self.alerts.write().await;
|
|
alerts.push(alert_with_user.clone());
|
|
|
|
Some(alert_with_user)
|
|
}
|
|
|
|
async fn check_login_anomalies(
|
|
&self,
|
|
user_id: Uuid,
|
|
ip: &str,
|
|
location: Option<GeoLocation>,
|
|
) {
|
|
if !self.config.anomaly_detection_enabled {
|
|
return;
|
|
}
|
|
|
|
let is_new_ip = {
|
|
let profiles = self.user_profiles.read().await;
|
|
profiles
|
|
.get(&user_id)
|
|
.map(|p| !p.is_known_ip(ip))
|
|
.unwrap_or(true)
|
|
};
|
|
|
|
if is_new_ip {
|
|
let event = SecurityEvent::new(SecurityEventType::NewDeviceLogin)
|
|
.with_user(user_id)
|
|
.with_ip(ip.to_string())
|
|
.with_detail("reason", serde_json::json!("new_ip"));
|
|
|
|
self.record_event(event).await;
|
|
|
|
let mut profiles = self.user_profiles.write().await;
|
|
let profile = profiles
|
|
.entry(user_id)
|
|
.or_insert_with(|| UserSecurityProfile::new(user_id));
|
|
profile.add_known_ip(ip);
|
|
}
|
|
|
|
let mut profiles = self.user_profiles.write().await;
|
|
let profile = profiles
|
|
.entry(user_id)
|
|
.or_insert_with(|| UserSecurityProfile::new(user_id));
|
|
|
|
if self.config.impossible_travel_detection {
|
|
if let (Some(last_loc), Some(current_loc)) =
|
|
(profile.last_location.as_ref(), location.as_ref())
|
|
{
|
|
if let Some(last_login) = profile.last_login {
|
|
if let Some(distance) = last_loc.distance_km(current_loc) {
|
|
let time_diff = (Utc::now() - last_login).num_hours().max(1) as f64;
|
|
let speed = distance / time_diff;
|
|
|
|
if speed > self.config.max_travel_speed_kmh {
|
|
let event = SecurityEvent::new(SecurityEventType::ImpossibleTravel)
|
|
.with_user(user_id)
|
|
.with_ip(ip.to_string())
|
|
.with_location(current_loc.clone())
|
|
.with_detail("distance_km", serde_json::json!(distance))
|
|
.with_detail("speed_kmh", serde_json::json!(speed));
|
|
|
|
let event_to_record = event;
|
|
drop(profiles);
|
|
self.record_event(event_to_record).await;
|
|
|
|
warn!(
|
|
"Impossible travel detected for user {}: {} km in {} hours",
|
|
user_id, distance, time_diff
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if self.config.geo_anomaly_detection {
|
|
if let Some(current_loc) = location.as_ref() {
|
|
if let Some(ref country) = current_loc.country {
|
|
let known_countries: Vec<String> = profile
|
|
.known_locations
|
|
.iter()
|
|
.filter_map(|l| l.country.clone())
|
|
.collect();
|
|
|
|
if !known_countries.is_empty() && !known_countries.contains(country) {
|
|
let event = SecurityEvent::new(SecurityEventType::GeoAnomalyDetected)
|
|
.with_user(user_id)
|
|
.with_ip(ip.to_string())
|
|
.with_location(current_loc.clone())
|
|
.with_detail("new_country", serde_json::json!(country));
|
|
|
|
drop(profiles);
|
|
self.record_event(event).await;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let profile = profiles
|
|
.entry(user_id)
|
|
.or_insert_with(|| UserSecurityProfile::new(user_id));
|
|
profile.record_login(location);
|
|
}
|
|
|
|
pub async fn is_locked(&self, identifier: &str) -> bool {
|
|
let lockouts = self.lockouts.read().await;
|
|
if let Some(lockout) = lockouts.get(identifier) {
|
|
!lockout.is_expired()
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
pub async fn is_ip_blocked(&self, ip: &str) -> bool {
|
|
let blocked = self.blocked_ips.read().await;
|
|
if let Some(expires) = blocked.get(ip) {
|
|
Utc::now() < *expires
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
pub async fn get_lockout_info(&self, identifier: &str) -> Option<LockoutRecord> {
|
|
let lockouts = self.lockouts.read().await;
|
|
lockouts.get(identifier).cloned()
|
|
}
|
|
|
|
pub async fn unlock(&self, identifier: &str) -> bool {
|
|
let mut lockouts = self.lockouts.write().await;
|
|
lockouts.remove(identifier).is_some()
|
|
}
|
|
|
|
pub async fn unblock_ip(&self, ip: &str) -> bool {
|
|
let mut blocked = self.blocked_ips.write().await;
|
|
blocked.remove(ip).is_some()
|
|
}
|
|
|
|
pub async fn block_ip(&self, ip: &str, duration: Duration, reason: &str) {
|
|
let mut blocked = self.blocked_ips.write().await;
|
|
blocked.insert(ip.to_string(), Utc::now() + duration);
|
|
|
|
let event = SecurityEvent::new(SecurityEventType::IpBlocked)
|
|
.with_ip(ip.to_string())
|
|
.with_detail("reason", serde_json::json!(reason))
|
|
.with_detail("duration_minutes", serde_json::json!(duration.num_minutes()));
|
|
|
|
drop(blocked);
|
|
self.record_event(event).await;
|
|
|
|
info!("IP {} blocked for {} minutes: {}", ip, duration.num_minutes(), reason);
|
|
}
|
|
|
|
async fn create_alert_from_event(&self, event: &SecurityEvent) {
|
|
let alert = SecurityAlert::new(
|
|
event.severity,
|
|
&format!("Security Event: {}", event.event_type.as_str()),
|
|
&format!(
|
|
"{} event detected{}{}",
|
|
event.event_type.as_str(),
|
|
event
|
|
.user_id
|
|
.map(|id| format!(" for user {}", id))
|
|
.unwrap_or_default(),
|
|
event
|
|
.ip_address
|
|
.as_ref()
|
|
.map(|ip| format!(" from IP {}", ip))
|
|
.unwrap_or_default()
|
|
),
|
|
)
|
|
.with_event(event.id);
|
|
|
|
let alert_with_user = if let Some(uid) = event.user_id {
|
|
alert.with_user(uid)
|
|
} else {
|
|
alert
|
|
};
|
|
|
|
let alert_with_ip = if let Some(ref ip) = event.ip_address {
|
|
alert_with_user.with_ip(ip.clone())
|
|
} else {
|
|
alert_with_user
|
|
};
|
|
|
|
let mut alerts = self.alerts.write().await;
|
|
alerts.push(alert_with_ip);
|
|
}
|
|
|
|
pub async fn get_alerts(&self, unacknowledged_only: bool, limit: usize) -> Vec<SecurityAlert> {
|
|
let alerts = self.alerts.read().await;
|
|
|
|
let filtered: Vec<SecurityAlert> = if unacknowledged_only {
|
|
alerts.iter().filter(|a| !a.acknowledged).cloned().collect()
|
|
} else {
|
|
alerts.clone()
|
|
};
|
|
|
|
filtered.into_iter().rev().take(limit).collect()
|
|
}
|
|
|
|
pub async fn acknowledge_alert(&self, alert_id: Uuid, by: Uuid) -> bool {
|
|
let mut alerts = self.alerts.write().await;
|
|
if let Some(alert) = alerts.iter_mut().find(|a| a.id == alert_id) {
|
|
alert.acknowledge(by);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
pub async fn resolve_alert(&self, alert_id: Uuid) -> bool {
|
|
let mut alerts = self.alerts.write().await;
|
|
if let Some(alert) = alerts.iter_mut().find(|a| a.id == alert_id) {
|
|
alert.resolve();
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
pub async fn get_user_profile(&self, user_id: Uuid) -> Option<UserSecurityProfile> {
|
|
let profiles = self.user_profiles.read().await;
|
|
profiles.get(&user_id).cloned()
|
|
}
|
|
|
|
pub async fn get_recent_events(
|
|
&self,
|
|
event_type: Option<SecurityEventType>,
|
|
user_id: Option<Uuid>,
|
|
limit: usize,
|
|
) -> Vec<SecurityEvent> {
|
|
let events = self.events.read().await;
|
|
|
|
let filtered: Vec<SecurityEvent> = events
|
|
.iter()
|
|
.filter(|e| {
|
|
if let Some(et) = event_type {
|
|
if e.event_type != et {
|
|
return false;
|
|
}
|
|
}
|
|
if let Some(uid) = user_id {
|
|
if e.user_id != Some(uid) {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
|
|
filtered.into_iter().rev().take(limit).collect()
|
|
}
|
|
|
|
pub async fn cleanup_old_data(&self) -> usize {
|
|
let cutoff = Utc::now() - Duration::hours(self.config.retention_hours as i64);
|
|
let mut total_cleaned = 0;
|
|
|
|
{
|
|
let mut events = self.events.write().await;
|
|
let initial = events.len();
|
|
events.retain(|e| e.timestamp > cutoff);
|
|
total_cleaned += initial - events.len();
|
|
}
|
|
|
|
{
|
|
let mut attempts = self.login_attempts.write().await;
|
|
for records in attempts.values_mut() {
|
|
let initial = records.len();
|
|
records.retain(|r| r.timestamp > cutoff);
|
|
total_cleaned += initial - records.len();
|
|
}
|
|
}
|
|
|
|
{
|
|
let mut lockouts = self.lockouts.write().await;
|
|
let initial = lockouts.len();
|
|
lockouts.retain(|_, l| !l.is_expired());
|
|
total_cleaned += initial - lockouts.len();
|
|
}
|
|
|
|
{
|
|
let mut blocked = self.blocked_ips.write().await;
|
|
let initial = blocked.len();
|
|
blocked.retain(|_, expires| Utc::now() < *expires);
|
|
total_cleaned += initial - blocked.len();
|
|
}
|
|
|
|
if total_cleaned > 0 {
|
|
info!("Cleaned up {} old security monitoring records", total_cleaned);
|
|
}
|
|
|
|
total_cleaned
|
|
}
|
|
|
|
pub fn config(&self) -> &SecurityMonitoringConfig {
|
|
&self.config
|
|
}
|
|
}
|
|
|
|
fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
|
|
const EARTH_RADIUS_KM: f64 = 6371.0;
|
|
|
|
let lat1_rad = lat1.to_radians();
|
|
let lat2_rad = lat2.to_radians();
|
|
let delta_lat = (lat2 - lat1).to_radians();
|
|
let delta_lon = (lon2 - lon1).to_radians();
|
|
|
|
let a = (delta_lat / 2.0).sin().powi(2)
|
|
+ lat1_rad.cos() * lat2_rad.cos() * (delta_lon / 2.0).sin().powi(2);
|
|
let c = 2.0 * a.sqrt().asin();
|
|
|
|
EARTH_RADIUS_KM * c
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_record_login_success() {
|
|
let monitor = SecurityMonitor::with_defaults();
|
|
let user_id = Uuid::new_v4();
|
|
|
|
let alert = monitor
|
|
.record_login_attempt(Some(user_id), "192.168.1.1", true, Some("TestAgent"), None)
|
|
.await;
|
|
|
|
assert!(alert.is_none());
|
|
|
|
let events = monitor.get_recent_events(Some(SecurityEventType::LoginSuccess), None, 10).await;
|
|
assert!(!events.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_brute_force_detection() {
|
|
let mut config = SecurityMonitoringConfig::default();
|
|
config.brute_force_threshold = 3;
|
|
let monitor = SecurityMonitor::new(config);
|
|
let user_id = Uuid::new_v4();
|
|
|
|
for _ in 0..2 {
|
|
let alert = monitor
|
|
.record_login_attempt(Some(user_id), "10.0.0.1", false, None, None)
|
|
.await;
|
|
assert!(alert.is_none());
|
|
}
|
|
|
|
let alert = monitor
|
|
.record_login_attempt(Some(user_id), "10.0.0.1", false, None, None)
|
|
.await;
|
|
|
|
assert!(alert.is_some());
|
|
assert_eq!(alert.unwrap().severity, AlertSeverity::Critical);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_lockout() {
|
|
let mut config = SecurityMonitoringConfig::default();
|
|
config.brute_force_threshold = 2;
|
|
let monitor = SecurityMonitor::new(config);
|
|
|
|
let user_id = Uuid::new_v4();
|
|
let identifier = user_id.to_string();
|
|
|
|
monitor
|
|
.record_login_attempt(Some(user_id), "10.0.0.1", false, None, None)
|
|
.await;
|
|
monitor
|
|
.record_login_attempt(Some(user_id), "10.0.0.1", false, None, None)
|
|
.await;
|
|
|
|
assert!(monitor.is_locked(&identifier).await);
|
|
|
|
monitor.unlock(&identifier).await;
|
|
assert!(!monitor.is_locked(&identifier).await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ip_blocking() {
|
|
let monitor = SecurityMonitor::with_defaults();
|
|
|
|
monitor
|
|
.block_ip("1.2.3.4", Duration::minutes(30), "Test block")
|
|
.await;
|
|
|
|
assert!(monitor.is_ip_blocked("1.2.3.4").await);
|
|
assert!(!monitor.is_ip_blocked("5.6.7.8").await);
|
|
|
|
monitor.unblock_ip("1.2.3.4").await;
|
|
assert!(!monitor.is_ip_blocked("1.2.3.4").await);
|
|
}
|
|
|
|
#[test]
|
|
fn test_security_event_creation() {
|
|
let event = SecurityEvent::new(SecurityEventType::LoginFailure)
|
|
.with_user(Uuid::new_v4())
|
|
.with_ip("192.168.1.1".into())
|
|
.with_detail("reason", serde_json::json!("invalid_password"));
|
|
|
|
assert_eq!(event.event_type, SecurityEventType::LoginFailure);
|
|
assert!(event.user_id.is_some());
|
|
assert!(event.ip_address.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_alert_creation() {
|
|
let mut alert = SecurityAlert::new(
|
|
AlertSeverity::High,
|
|
"Test Alert",
|
|
"Test description",
|
|
);
|
|
|
|
assert!(!alert.acknowledged);
|
|
assert!(!alert.resolved);
|
|
|
|
alert.acknowledge(Uuid::new_v4());
|
|
assert!(alert.acknowledged);
|
|
|
|
alert.resolve();
|
|
assert!(alert.resolved);
|
|
}
|
|
|
|
#[test]
|
|
fn test_geo_location_distance() {
|
|
let loc1 = GeoLocation::new()
|
|
.with_coordinates(40.7128, -74.0060);
|
|
let loc2 = GeoLocation::new()
|
|
.with_coordinates(51.5074, -0.1278);
|
|
|
|
let distance = loc1.distance_km(&loc2).unwrap();
|
|
assert!(distance > 5500.0 && distance < 5600.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_haversine_distance() {
|
|
let distance = haversine_distance(40.7128, -74.0060, 51.5074, -0.1278);
|
|
assert!(distance > 5500.0 && distance < 5600.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_user_security_profile() {
|
|
let user_id = Uuid::new_v4();
|
|
let mut profile = UserSecurityProfile::new(user_id);
|
|
|
|
assert!(!profile.is_known_ip("192.168.1.1"));
|
|
|
|
profile.add_known_ip("192.168.1.1");
|
|
assert!(profile.is_known_ip("192.168.1.1"));
|
|
|
|
profile.lock(Duration::minutes(30));
|
|
assert!(profile.check_lock_status());
|
|
|
|
profile.unlock();
|
|
assert!(!profile.check_lock_status());
|
|
}
|
|
|
|
#[test]
|
|
fn test_lockout_record() {
|
|
let lockout = LockoutRecord {
|
|
identifier: "test".into(),
|
|
locked_at: Utc::now(),
|
|
expires_at: Utc::now() + Duration::minutes(30),
|
|
reason: "Test".into(),
|
|
attempt_count: 5,
|
|
};
|
|
|
|
assert!(!lockout.is_expired());
|
|
assert!(lockout.remaining_time() > Duration::zero());
|
|
}
|
|
|
|
#[test]
|
|
fn test_event_severity_mapping() {
|
|
assert_eq!(
|
|
SecurityEventType::BruteForceDetected.severity(),
|
|
AlertSeverity::Critical
|
|
);
|
|
assert_eq!(
|
|
SecurityEventType::LoginFailure.severity(),
|
|
AlertSeverity::High
|
|
);
|
|
assert_eq!(
|
|
SecurityEventType::LoginSuccess.severity(),
|
|
AlertSeverity::Low
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_alert_acknowledgment() {
|
|
let monitor = SecurityMonitor::with_defaults();
|
|
|
|
let event = SecurityEvent::new(SecurityEventType::BruteForceDetected);
|
|
monitor.record_event(event).await;
|
|
|
|
let alerts = monitor.get_alerts(true, 10).await;
|
|
assert!(!alerts.is_empty());
|
|
|
|
let alert_id = alerts[0].id;
|
|
let admin_id = Uuid::new_v4();
|
|
|
|
assert!(monitor.acknowledge_alert(alert_id, admin_id).await);
|
|
|
|
let unack_alerts = monitor.get_alerts(true, 10).await;
|
|
assert!(unack_alerts.iter().all(|a| a.id != alert_id));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cleanup() {
|
|
let mut config = SecurityMonitoringConfig::default();
|
|
config.retention_hours = 0;
|
|
let monitor = SecurityMonitor::new(config);
|
|
|
|
let event = SecurityEvent::new(SecurityEventType::LoginSuccess);
|
|
monitor.record_event(event).await;
|
|
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
|
|
|
let cleaned = monitor.cleanup_old_data().await;
|
|
assert!(cleaned >= 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_alert_severity_score() {
|
|
assert_eq!(AlertSeverity::Low.score(), 25);
|
|
assert_eq!(AlertSeverity::Medium.score(), 50);
|
|
assert_eq!(AlertSeverity::High.score(), 75);
|
|
assert_eq!(AlertSeverity::Critical.score(), 100);
|
|
}
|
|
}
|