1use crate::defaults::{default_policy_version, default_correlation_window, default_alert_threshold, default_max_events, default_true, default_business_hours_start, default_business_hours_end, default_artifact_access_weight, default_suspicious_process_weight, default_rapid_enum_weight, default_off_hours_weight, default_ancestry_suspicious_weight, default_cooldown, default_max_kills};
4use crate::errors::{self, PolicyValidationError, RangeValidationError};
5use crate::timing::{enforce_operation_min_timing, TimingOperation};
6use crate::POLICY_VERSION;
7use palisade_errors::Result;
8use serde::{Deserialize, Deserializer, Serialize};
9use std::collections::{HashMap, HashSet};
10use std::path::PathBuf;
11use std::time::Instant;
12
13#[derive(Debug, Serialize, Deserialize)]
15pub struct PolicyConfig {
16 #[serde(default = "default_policy_version")]
18 pub version: u32,
19
20 pub scoring: ScoringPolicy,
22
23 pub response: ResponsePolicy,
25
26 pub deception: DeceptionPolicy,
28
29 #[serde(default)]
31 pub registered_custom_conditions: HashSet<String>,
32}
33
34#[derive(Debug, Serialize, Deserialize)]
36pub struct ScoringPolicy {
37 #[serde(default = "default_correlation_window")]
39 pub correlation_window_secs: u64,
40
41 #[serde(default = "default_alert_threshold")]
43 pub alert_threshold: f64,
44
45 #[serde(default = "default_max_events")]
47 pub max_events_in_memory: usize,
48
49 #[serde(default = "default_true")]
51 pub enable_time_scoring: bool,
52
53 #[serde(default = "default_true")]
55 pub enable_ancestry_tracking: bool,
56
57 #[serde(default)]
59 pub weights: ScoringWeights,
60
61 #[serde(default = "default_business_hours_start")]
63 pub business_hours_start: u8,
64
65 #[serde(default = "default_business_hours_end")]
67 pub business_hours_end: u8,
68}
69
70#[derive(Debug, Serialize, Deserialize)]
72pub struct ScoringWeights {
73 #[serde(default = "default_artifact_access_weight")]
75 pub artifact_access: f64,
76
77 #[serde(default = "default_suspicious_process_weight")]
79 pub suspicious_process: f64,
80
81 #[serde(default = "default_rapid_enum_weight")]
83 pub rapid_enumeration: f64,
84
85 #[serde(default = "default_off_hours_weight")]
87 pub off_hours_activity: f64,
88
89 #[serde(default = "default_ancestry_suspicious_weight")]
91 pub ancestry_suspicious: f64,
92}
93
94impl Default for ScoringWeights {
95 fn default() -> Self {
96 Self {
97 artifact_access: 50.0,
98 suspicious_process: 30.0,
99 rapid_enumeration: 20.0,
100 off_hours_activity: 15.0,
101 ancestry_suspicious: 10.0,
102 }
103 }
104}
105
106#[derive(Debug, Serialize, Deserialize)]
108pub struct ResponsePolicy {
109 pub rules: Vec<ResponseRule>,
111
112 #[serde(default = "default_cooldown")]
114 pub cooldown_secs: u64,
115
116 #[serde(default = "default_max_kills")]
118 pub max_kills_per_incident: usize,
119
120 #[serde(default)]
122 pub dry_run: bool,
123}
124
125#[derive(Debug, Serialize, Deserialize)]
127pub struct ResponseRule {
128 pub severity: Severity,
130
131 #[serde(default)]
133 pub conditions: Vec<ResponseCondition>,
134
135 pub action: ActionType,
137}
138
139#[derive(Debug, Serialize, Deserialize)]
141#[serde(tag = "type", rename_all = "snake_case")]
142pub enum ResponseCondition {
143 MinConfidence { threshold: f64 },
145
146 NotParentedBy { process_name: String },
148
149 MinSignalTypes { count: usize },
151
152 RepeatCount { count: usize, window_secs: u64 },
154
155 TimeWindow { start_hour: u8, end_hour: u8 },
157
158 Custom {
160 name: String,
161 params: HashMap<String, String>,
162 },
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
167#[serde(rename_all = "PascalCase")]
168pub enum Severity {
169 Low,
171 Medium,
173 High,
175 Critical,
177}
178
179impl Severity {
180 #[must_use]
182 pub fn from_score(score: f64) -> Self {
183 if score >= 80.0 {
184 Self::Critical
185 } else if score >= 60.0 {
186 Self::High
187 } else if score >= 40.0 {
188 Self::Medium
189 } else {
190 Self::Low
191 }
192 }
193}
194
195impl std::fmt::Display for Severity {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 match self {
198 Self::Low => write!(f, "Low"),
199 Self::Medium => write!(f, "Medium"),
200 Self::High => write!(f, "High"),
201 Self::Critical => write!(f, "Critical"),
202 }
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum ActionType {
210 Log,
212 Alert,
214 KillProcess,
216 IsolateHost,
218 CustomScript { path: PathBuf },
220}
221
222#[derive(Debug, Serialize, Deserialize)]
224pub struct DeceptionPolicy {
225 #[serde(default, deserialize_with = "deserialize_lowercase_boxed")]
227 pub suspicious_processes: Box<[String]>,
228
229 #[serde(default, deserialize_with = "deserialize_boxed")]
231 pub suspicious_patterns: Box<[String]>,
232}
233
234fn deserialize_lowercase_boxed<'de, D>(deserializer: D) -> std::result::Result<Box<[String]>, D::Error>
236where
237 D: Deserializer<'de>,
238{
239 let vec = Vec::<String>::deserialize(deserializer)?;
240 Ok(vec.into_iter().map(|s| s.to_lowercase()).collect())
241}
242
243fn deserialize_boxed<'de, D>(deserializer: D) -> std::result::Result<Box<[String]>, D::Error>
245where
246 D: Deserializer<'de>,
247{
248 let vec = Vec::<String>::deserialize(deserializer)?;
249 Ok(vec.into_boxed_slice())
250}
251
252impl PolicyConfig {
253 pub async fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
259 let started = Instant::now();
260 let path = path.as_ref();
261 let result = async {
262 let contents = tokio::fs::read_to_string(path)
263 .await
264 .map_err(|e| errors::io_read_error("load_policy", path, e))?;
265
266 let policy: PolicyConfig = toml::from_str(&contents).map_err(|e| {
267 errors::parse_error("parse_policy_toml", format!("Policy TOML syntax error: {e}"))
268 })?;
269
270 if policy.version > POLICY_VERSION {
272 return Err(errors::version_error(
273 "validate_policy_version",
274 policy.version,
275 POLICY_VERSION,
276 format!(
277 "Policy version too new (agent: {}, policy: {}). Upgrade agent",
278 POLICY_VERSION, policy.version
279 ),
280 ));
281 }
282
283 if policy.version < POLICY_VERSION {
284 eprintln!(
285 "WARNING: Policy version is older (policy: {}, agent: {}). Consider updating.",
286 policy.version, POLICY_VERSION
287 );
288 }
289
290 policy.validate()?;
291
292 Ok(policy)
293 }
294 .await;
295 enforce_operation_min_timing(started, TimingOperation::PolicyLoad);
296 result
297 }
298
299 pub fn validate(&self) -> Result<()> {
301 let started = Instant::now();
302 let result = (|| {
303 if !(0.0..=100.0).contains(&self.scoring.alert_threshold) {
305 return Err(RangeValidationError::out_of_range(
306 "scoring.alert_threshold",
307 self.scoring.alert_threshold,
308 0.0,
309 100.0,
310 "validate_policy_scoring",
311 ));
312 }
313
314 if self.scoring.correlation_window_secs == 0
315 || self.scoring.correlation_window_secs > 3600
316 {
317 return Err(RangeValidationError::out_of_range(
318 "scoring.correlation_window_secs",
319 self.scoring.correlation_window_secs,
320 1,
321 3600,
322 "validate_policy_scoring",
323 ));
324 }
325
326 if self.scoring.max_events_in_memory == 0 || self.scoring.max_events_in_memory > 100_000
328 {
329 return Err(RangeValidationError::out_of_range(
330 "scoring.max_events_in_memory",
331 self.scoring.max_events_in_memory,
332 1,
333 100_000,
334 "validate_policy_scoring",
335 ));
336 }
337
338 if self.response.rules.is_empty() {
340 return Err(errors::missing_required(
341 "validate_policy_response",
342 "response.rules",
343 "no_response_actions",
344 ));
345 }
346
347 if self.response.cooldown_secs == 0 {
348 return Err(errors::invalid_value(
349 "validate_policy_response",
350 "response.cooldown_secs",
351 "response.cooldown_secs cannot be zero",
352 ));
353 }
354
355 let mut seen = HashSet::new();
357 for rule in &self.response.rules {
358 if !seen.insert(rule.severity) {
359 return Err(PolicyValidationError::duplicate_severity(&rule.severity.to_string()));
360 }
361
362 for condition in &rule.conditions {
364 if let ResponseCondition::Custom { name, .. } = condition
365 && !self.registered_custom_conditions.contains(name) {
366 return Err(PolicyValidationError::unregistered_condition(name));
367 }
368 }
369 }
370
371 Ok(())
372 })();
373 enforce_operation_min_timing(started, TimingOperation::PolicyValidate);
374 result
375 }
376
377 #[inline]
382 #[must_use]
383 pub fn is_suspicious_process(&self, name: &str) -> bool {
384 let started = Instant::now();
385 let found = self
386 .deception
387 .suspicious_processes
388 .iter()
389 .any(|pattern| contains_ascii_case_insensitive(name, pattern.as_str()));
390 enforce_operation_min_timing(started, TimingOperation::PolicySuspiciousCheckLegacy);
391 found
392 }
393}
394
395#[inline]
396fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
397 if needle.is_empty() {
398 return true;
399 }
400 let h = haystack.as_bytes();
401 let n = needle.as_bytes();
402 if n.len() > h.len() {
403 return false;
404 }
405 for start in 0..=(h.len() - n.len()) {
406 let mut matched = true;
407 for i in 0..n.len() {
408 if !h[start + i].eq_ignore_ascii_case(&n[i]) {
409 matched = false;
410 break;
411 }
412 }
413 if matched {
414 return true;
415 }
416 }
417 false
418}
419
420impl Default for PolicyConfig {
421 fn default() -> Self {
422 Self {
423 version: POLICY_VERSION,
424 scoring: ScoringPolicy {
425 correlation_window_secs: 300,
426 alert_threshold: 50.0,
427 max_events_in_memory: 10_000,
428 enable_time_scoring: true,
429 enable_ancestry_tracking: true,
430 weights: ScoringWeights::default(),
431 business_hours_start: 9,
432 business_hours_end: 17,
433 },
434 response: ResponsePolicy {
435 rules: vec![
436 ResponseRule {
437 severity: Severity::Low,
438 conditions: vec![],
439 action: ActionType::Log,
440 },
441 ResponseRule {
442 severity: Severity::Medium,
443 conditions: vec![],
444 action: ActionType::Alert,
445 },
446 ResponseRule {
447 severity: Severity::High,
448 conditions: vec![ResponseCondition::MinConfidence { threshold: 70.0 }],
449 action: ActionType::KillProcess,
450 },
451 ResponseRule {
452 severity: Severity::Critical,
453 conditions: vec![
454 ResponseCondition::MinConfidence { threshold: 85.0 },
455 ResponseCondition::MinSignalTypes { count: 2 },
456 ],
457 action: ActionType::IsolateHost,
458 },
459 ],
460 cooldown_secs: 60,
461 max_kills_per_incident: 10,
462 dry_run: false,
463 },
464 deception: DeceptionPolicy {
465 suspicious_processes: vec![
466 "mimikatz".to_string(),
467 "procdump".to_string(),
468 "lazagne".to_string(),
469 ]
470 .into_boxed_slice(),
471 suspicious_patterns: Box::new([]),
472 },
473 registered_custom_conditions: HashSet::new(),
474 }
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[tokio::test]
483 async fn test_default_policy_validates() {
484 let policy = PolicyConfig::default();
485 assert!(policy.validate().is_ok());
486 }
487
488 #[test]
489 fn test_severity_from_score() {
490 assert_eq!(Severity::from_score(90.0), Severity::Critical);
491 assert_eq!(Severity::from_score(70.0), Severity::High);
492 assert_eq!(Severity::from_score(50.0), Severity::Medium);
493 assert_eq!(Severity::from_score(30.0), Severity::Low);
494 }
495
496 #[test]
497 fn test_suspicious_process_case_insensitive() {
498 let policy = PolicyConfig::default();
499
500 assert!(policy.is_suspicious_process("MIMIKATZ.exe"));
501 assert!(policy.is_suspicious_process("mimikatz"));
502 assert!(policy.is_suspicious_process("MiMiKaTz"));
503 assert!(!policy.is_suspicious_process("firefox"));
504 }
505
506 #[test]
507 fn test_custom_condition_validation() {
508 let mut policy = PolicyConfig::default();
509 policy.response.rules.retain(|r| r.severity != Severity::Medium);
510
511 policy.response.rules.push(ResponseRule {
512 severity: Severity::Medium,
513 conditions: vec![ResponseCondition::Custom {
514 name: "unregistered".to_string(),
515 params: HashMap::new(),
516 }],
517 action: ActionType::Log,
518 });
519
520 assert!(policy.validate().is_err());
521
522 policy.registered_custom_conditions.insert("unregistered".to_string());
523 assert!(policy.validate().is_ok());
524 }
525
526 #[test]
527 fn test_max_events_validation() {
528 let mut policy = PolicyConfig::default();
529 policy.scoring.max_events_in_memory = 150_000;
530 assert!(policy.validate().is_err());
531
532 policy.scoring.max_events_in_memory = 50_000;
533 assert!(policy.validate().is_ok());
534 }
535}