Skip to main content

palisade_config/
policy.rs

1//! Policy configuration for honeypot decision-making.
2
3use 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/// Policy configuration - the DECISION PLANE of your security operation.
14#[derive(Debug, Serialize, Deserialize)]
15pub struct PolicyConfig {
16    /// Policy schema version
17    #[serde(default = "default_policy_version")]
18    pub version: u32,
19
20    /// Scoring configuration
21    pub scoring: ScoringPolicy,
22
23    /// Response configuration
24    pub response: ResponsePolicy,
25
26    /// Deception detection patterns
27    pub deception: DeceptionPolicy,
28
29    /// Registered custom condition handlers (for validation)
30    #[serde(default)]
31    pub registered_custom_conditions: HashSet<String>,
32}
33
34/// Scoring policy for threat assessment.
35#[derive(Debug, Serialize, Deserialize)]
36pub struct ScoringPolicy {
37    /// Time window for event correlation (seconds)
38    #[serde(default = "default_correlation_window")]
39    pub correlation_window_secs: u64,
40
41    /// Confidence score threshold for alerting
42    #[serde(default = "default_alert_threshold")]
43    pub alert_threshold: f64,
44
45    /// Maximum events to retain in correlation window
46    #[serde(default = "default_max_events")]
47    pub max_events_in_memory: usize,
48
49    /// Enable time-of-day scoring adjustments
50    #[serde(default = "default_true")]
51    pub enable_time_scoring: bool,
52
53    /// Enable process ancestry tracking
54    #[serde(default = "default_true")]
55    pub enable_ancestry_tracking: bool,
56
57    /// Scoring weights for different signal types
58    #[serde(default)]
59    pub weights: ScoringWeights,
60
61    /// Business hours start (24-hour format)
62    #[serde(default = "default_business_hours_start")]
63    pub business_hours_start: u8,
64
65    /// Business hours end (24-hour format)
66    #[serde(default = "default_business_hours_end")]
67    pub business_hours_end: u8,
68}
69
70/// Scoring weights for threat signals.
71#[derive(Debug, Serialize, Deserialize)]
72pub struct ScoringWeights {
73    /// Base score for accessing deception artifact
74    #[serde(default = "default_artifact_access_weight")]
75    pub artifact_access: f64,
76
77    /// Additional score for suspicious process name
78    #[serde(default = "default_suspicious_process_weight")]
79    pub suspicious_process: f64,
80
81    /// Additional score for rapid enumeration
82    #[serde(default = "default_rapid_enum_weight")]
83    pub rapid_enumeration: f64,
84
85    /// Additional score for off-hours activity
86    #[serde(default = "default_off_hours_weight")]
87    pub off_hours_activity: f64,
88
89    /// Additional score for suspicious process ancestry
90    #[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/// Response policy for incident handling.
107#[derive(Debug, Serialize, Deserialize)]
108pub struct ResponsePolicy {
109    /// Response rules with conditions
110    pub rules: Vec<ResponseRule>,
111
112    /// Minimum time between responses (prevents alert storms)
113    #[serde(default = "default_cooldown")]
114    pub cooldown_secs: u64,
115
116    /// Maximum processes to kill per incident (safety limit)
117    #[serde(default = "default_max_kills")]
118    pub max_kills_per_incident: usize,
119
120    /// Dry-run mode (log actions but don't execute)
121    #[serde(default)]
122    pub dry_run: bool,
123}
124
125/// Response rule with conditional execution.
126#[derive(Debug, Serialize, Deserialize)]
127pub struct ResponseRule {
128    /// Severity level that triggers this rule
129    pub severity: Severity,
130
131    /// Conditions that must ALL be satisfied (AND logic)
132    #[serde(default)]
133    pub conditions: Vec<ResponseCondition>,
134
135    /// Action to execute
136    pub action: ActionType,
137}
138
139/// Response execution conditions.
140#[derive(Debug, Serialize, Deserialize)]
141#[serde(tag = "type", rename_all = "snake_case")]
142pub enum ResponseCondition {
143    /// Confidence score must exceed threshold
144    MinConfidence { threshold: f64 },
145
146    /// Process must not be child of specific parent
147    NotParentedBy { process_name: String },
148
149    /// Incident must involve multiple distinct signals
150    MinSignalTypes { count: usize },
151
152    /// Repeated incidents within time window
153    RepeatCount { count: usize, window_secs: u64 },
154
155    /// Current time must be within window (24-hour clock)
156    TimeWindow { start_hour: u8, end_hour: u8 },
157
158    /// Custom condition (MUST be pre-registered)
159    Custom {
160        name: String,
161        params: HashMap<String, String>,
162    },
163}
164
165/// Incident severity level.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
167#[serde(rename_all = "PascalCase")]
168pub enum Severity {
169    /// Low severity (informational)
170    Low,
171    /// Medium severity (warning)
172    Medium,
173    /// High severity (critical)
174    High,
175    /// Critical severity (emergency)
176    Critical,
177}
178
179impl Severity {
180    /// Determine severity from confidence score.
181    #[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/// Action type for incident response.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum ActionType {
210    /// Log the incident
211    Log,
212    /// Send alert to monitoring system
213    Alert,
214    /// Terminate the offending process
215    KillProcess,
216    /// Isolate the host from network
217    IsolateHost,
218    /// Execute custom script
219    CustomScript { path: PathBuf },
220}
221
222/// Deception detection policy.
223#[derive(Debug, Serialize, Deserialize)]
224pub struct DeceptionPolicy {
225    /// Process names that trigger elevated scoring (pre-normalized to lowercase, immutable)
226    #[serde(default, deserialize_with = "deserialize_lowercase_boxed")]
227    pub suspicious_processes: Box<[String]>,
228
229    /// File patterns that indicate reconnaissance (immutable)
230    #[serde(default, deserialize_with = "deserialize_boxed")]
231    pub suspicious_patterns: Box<[String]>,
232}
233
234/// Deserialize Vec<String>, normalize to lowercase, and convert to Box<[String]> for memory efficiency.
235fn 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
243/// Deserialize Vec<String> and convert to Box<[String]> for memory efficiency.
244fn 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    /// Load policy from TOML file (async to prevent thread exhaustion attacks).
254    ///
255    /// # Errors
256    ///
257    /// Returns error if file cannot be read, TOML is invalid, or validation fails.
258    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            // Version validation
271            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    /// Validate policy configuration.
300    pub fn validate(&self) -> Result<()> {
301        let started = Instant::now();
302        let result = (|| {
303            // Validate scoring policy
304            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            // CRITICAL: Prevent memory exhaustion and invalid zero-capacity buffers.
327            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            // Validate response policy
339            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            // Check for duplicate severity mappings
356            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                // Validate custom conditions against whitelist
363                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    /// Check if process name is suspicious (case-insensitive, optimized hot path).
378    ///
379    /// PERFORMANCE: This is called on EVERY process access event (thousands/sec under attack).
380    /// Uses case-insensitive contains without allocation via iterator chaining.
381    #[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}