Skip to main content

palisade_config/
validation.rs

1//! Configuration and policy diffing for change tracking.
2
3use crate::timing::{TimingOperation, enforce_operation_min_timing};
4use crate::{AgentError, Config, PolicyConfig, Result};
5use core::fmt::Write as _;
6use heapless::{String as HString, Vec as HVec};
7use std::path::Path;
8use std::time::Instant;
9
10/// Validation strictness level.
11#[derive(Debug, PartialEq, Eq)]
12pub enum ValidationMode {
13    /// Standard validation (format checks, no filesystem access)
14    Standard,
15
16    /// Strict validation (paths must exist, permissions verified)
17    Strict,
18}
19
20impl Default for ValidationMode {
21    fn default() -> Self {
22        Self::Standard
23    }
24}
25
26const CFG_VALIDATION_FAILED: u16 = 101;
27const MAX_CONFIG_DIFF_CHANGES: usize = 32;
28const MAX_POLICY_DIFF_CHANGES: usize = 32;
29const HASH_PREFIX_HEX_LEN: usize = 16;
30
31/// Fixed-capacity configuration diff report.
32pub type ConfigDiff<'a> = HVec<ConfigChange<'a>, MAX_CONFIG_DIFF_CHANGES>;
33
34/// Fixed-capacity policy diff report.
35pub type PolicyDiff<'a> = HVec<PolicyChange<'a>, MAX_POLICY_DIFF_CHANGES>;
36
37/// Configuration change detected during diff.
38#[derive(Debug, PartialEq, Eq)]
39pub enum ConfigChange<'a> {
40    /// Root tag changed (shows hash, not secret)
41    RootTagChanged {
42        /// Prefix hash of the previous root tag.
43        old_hash: HString<HASH_PREFIX_HEX_LEN>,
44        /// Prefix hash of the replacement root tag.
45        new_hash: HString<HASH_PREFIX_HEX_LEN>,
46    },
47
48    /// A decoy path was introduced in the candidate configuration.
49    PathAdded {
50        /// Path newly introduced in the candidate configuration.
51        path: &'a Path,
52    },
53
54    /// A decoy path was removed from the previous configuration.
55    PathRemoved {
56        /// Path removed from the previous configuration.
57        path: &'a Path,
58    },
59
60    /// Capability settings changed
61    CapabilitiesChanged {
62        /// Name of the capability field that changed.
63        field: &'static str,
64        /// Previous serialized value for the field.
65        old: bool,
66        /// New serialized value for the field.
67        new: bool,
68    },
69}
70
71/// Policy change detected during diff.
72#[derive(Debug, PartialEq)]
73pub enum PolicyChange<'a> {
74    /// Threshold value changed
75    ThresholdChanged {
76        /// Name of the threshold field that changed.
77        field: &'static str,
78        /// Previous threshold value.
79        old: f64,
80        /// New threshold value.
81        new: f64,
82    },
83
84    /// Response rules changed
85    ResponseRulesChanged {
86        /// Number of response rules before the change.
87        old_count: usize,
88        /// Number of response rules after the change.
89        new_count: usize,
90    },
91
92    /// Suspicious process pattern introduced in the new policy.
93    SuspiciousProcessAdded {
94        /// Pattern introduced in the new policy.
95        pattern: &'a str,
96    },
97
98    /// Suspicious process pattern removed from the previous policy.
99    SuspiciousProcessRemoved {
100        /// Pattern removed from the previous policy.
101        pattern: &'a str,
102    },
103}
104
105impl Config {
106    /// Diff configuration against another configuration.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error if the fixed-capacity diff report cannot represent all
111    /// detected changes.
112    pub fn diff<'a>(&'a self, other: &'a Config) -> Result<ConfigDiff<'a>> {
113        let started = Instant::now();
114        let result = (|| {
115            let mut changes = ConfigDiff::new();
116
117            // Compare root tags via hash (secure, no exposure)
118            if !self
119                .deception
120                .root_tag
121                .hash_eq_ct(&other.deception.root_tag)
122            {
123                push_config_change(
124                    &mut changes,
125                    ConfigChange::RootTagChanged {
126                        old_hash: hash_prefix_hex(&self.deception.root_tag.hash()[..8])?,
127                        new_hash: hash_prefix_hex(&other.deception.root_tag.hash()[..8])?,
128                    },
129                )?;
130            }
131
132            for path in &other.deception.decoy_paths {
133                if !self.deception.decoy_paths.iter().any(|current| current == path) {
134                    push_config_change(
135                        &mut changes,
136                        ConfigChange::PathAdded {
137                            path: path.as_path(),
138                        },
139                    )?;
140                }
141            }
142
143            for path in &self.deception.decoy_paths {
144                if !other.deception.decoy_paths.iter().any(|next| next == path) {
145                    push_config_change(
146                        &mut changes,
147                        ConfigChange::PathRemoved {
148                            path: path.as_path(),
149                        },
150                    )?;
151                }
152            }
153
154            if self.telemetry.enable_syscall_monitor != other.telemetry.enable_syscall_monitor {
155                push_config_change(
156                    &mut changes,
157                    ConfigChange::CapabilitiesChanged {
158                        field: "enable_syscall_monitor",
159                        old: self.telemetry.enable_syscall_monitor,
160                        new: other.telemetry.enable_syscall_monitor,
161                    },
162                )?;
163            }
164
165            Ok(changes)
166        })();
167
168        enforce_operation_min_timing(started, TimingOperation::ConfigDiff);
169        result
170    }
171}
172
173impl PolicyConfig {
174    /// Diff policy against another policy.
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if the fixed-capacity diff report cannot represent all
179    /// detected changes.
180    pub fn diff<'a>(&'a self, other: &'a PolicyConfig) -> Result<PolicyDiff<'a>> {
181        let started = Instant::now();
182        let result = (|| {
183            let mut changes = PolicyDiff::new();
184
185            if (self.scoring.alert_threshold - other.scoring.alert_threshold).abs() > 0.01 {
186                push_policy_change(
187                    &mut changes,
188                    PolicyChange::ThresholdChanged {
189                        field: "alert_threshold",
190                        old: self.scoring.alert_threshold,
191                        new: other.scoring.alert_threshold,
192                    },
193                )?;
194            }
195
196            if self.response.rules.len() != other.response.rules.len() {
197                push_policy_change(
198                    &mut changes,
199                    PolicyChange::ResponseRulesChanged {
200                        old_count: self.response.rules.len(),
201                        new_count: other.response.rules.len(),
202                    },
203                )?;
204            }
205
206            for pattern in &other.deception.suspicious_processes {
207                if !self
208                    .deception
209                    .suspicious_processes
210                    .iter()
211                    .any(|current| current == pattern)
212                {
213                    push_policy_change(
214                        &mut changes,
215                        PolicyChange::SuspiciousProcessAdded {
216                            pattern: pattern.as_str(),
217                        },
218                    )?;
219                }
220            }
221
222            for pattern in &self.deception.suspicious_processes {
223                if !other
224                    .deception
225                    .suspicious_processes
226                    .iter()
227                    .any(|next| next == pattern)
228                {
229                    push_policy_change(
230                        &mut changes,
231                        PolicyChange::SuspiciousProcessRemoved {
232                            pattern: pattern.as_str(),
233                        },
234                    )?;
235                }
236            }
237
238            Ok(changes)
239        })();
240
241        enforce_operation_min_timing(started, TimingOperation::PolicyDiff);
242        result
243    }
244}
245
246fn push_config_change<'a>(
247    changes: &mut ConfigDiff<'a>,
248    change: ConfigChange<'a>,
249) -> Result<()> {
250    changes.push(change).map_err(|_| {
251        AgentError::new(
252            CFG_VALIDATION_FAILED,
253            "Configuration validation failed",
254            "operation=diff_config; fixed-capacity diff buffer exhausted",
255            "config.diff",
256        )
257    })
258}
259
260fn push_policy_change<'a>(
261    changes: &mut PolicyDiff<'a>,
262    change: PolicyChange<'a>,
263) -> Result<()> {
264    changes.push(change).map_err(|_| {
265        AgentError::new(
266            CFG_VALIDATION_FAILED,
267            "Configuration validation failed",
268            "operation=diff_policy; fixed-capacity diff buffer exhausted",
269            "policy.diff",
270        )
271    })
272}
273
274fn hash_prefix_hex(bytes: &[u8]) -> Result<HString<HASH_PREFIX_HEX_LEN>> {
275    let mut out = HString::<HASH_PREFIX_HEX_LEN>::new();
276    for byte in bytes {
277        write!(&mut out, "{byte:02x}").map_err(|_| {
278            AgentError::new(
279                CFG_VALIDATION_FAILED,
280                "Configuration validation failed",
281                "operation=diff_config; fixed-capacity root-tag hash buffer exhausted",
282                "config.diff",
283            )
284        })?;
285    }
286    Ok(out)
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::tags::RootTag;
293
294    #[test]
295    fn test_config_diff_detects_root_tag_change() {
296        let config1 = Config::default();
297        let mut config2 = Config::default();
298        config2.deception.root_tag = RootTag::generate().expect("Failed to generate tag");
299
300        let changes = config1.diff(&config2).expect("diff");
301        assert!(!changes.is_empty());
302
303        if let Some(ConfigChange::RootTagChanged { old_hash, new_hash }) = changes.first() {
304            assert_eq!(old_hash.len(), 16);
305            assert_eq!(new_hash.len(), 16);
306            assert_ne!(old_hash, new_hash);
307        } else {
308            panic!("Expected RootTagChanged");
309        }
310    }
311
312    #[test]
313    fn test_policy_diff_detects_threshold_change() {
314        let mut policy1 = PolicyConfig::default();
315        let mut policy2 = PolicyConfig::default();
316
317        policy1.scoring.alert_threshold = 50.0;
318        policy2.scoring.alert_threshold = 75.0;
319
320        let changes = policy1.diff(&policy2).expect("diff");
321        assert!(!changes.is_empty());
322
323        if let Some(PolicyChange::ThresholdChanged { field, old, new }) = changes.first() {
324            assert_eq!(*field, "alert_threshold");
325            assert_eq!(*old, 50.0);
326            assert_eq!(*new, 75.0);
327        } else {
328            panic!("Expected ThresholdChanged");
329        }
330    }
331}