Skip to main content

palisade_config/
config.rs

1//! Configuration mechanics for honeypot infrastructure.
2//!
3//! This module defines the **wiring** of your deception operation:
4//! - WHERE things run (paths, instances)
5//! - HOW things connect (I/O, logging)
6//! - WHAT capabilities are enabled
7//!
8//! This does NOT define decision-making (see [`crate::policy`]).
9
10use crate::defaults::{default_version, default_honeytoken_count, default_artifact_permissions, default_event_buffer_size, default_log_format, default_rotate_size, default_max_log_files, default_log_level};
11use crate::errors::{self, UnixPermissionError, PathValidationError, CollectionValidationError, RangeValidationError};
12use crate::tags::RootTag;
13use crate::timing::{enforce_operation_min_timing, TimingOperation};
14use crate::validation::ValidationMode;
15use crate::CONFIG_VERSION;
16use palisade_errors::Result;
17use serde::{Deserialize, Serialize};
18use std::path::{Path, PathBuf};
19use std::time::Instant;
20use zeroize::{Zeroize, ZeroizeOnDrop};
21
22/// Master configuration - the MECHANICS of your deception operation.
23#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
24pub struct Config {
25    /// Configuration schema version
26    #[serde(default = "default_version")]
27    pub version: u32,
28
29    /// Agent identity and runtime configuration
30    pub agent: AgentConfig,
31
32    /// Deception artifact configuration (contains secrets)
33    pub deception: DeceptionConfig,
34
35    /// Telemetry collection configuration
36    pub telemetry: TelemetryConfig,
37
38    /// Logging configuration
39    pub logging: LoggingConfig,
40}
41
42/// Agent identity and runtime configuration.
43#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
44pub struct AgentConfig {
45    /// Unique instance identifier (for correlation)
46    #[serde(skip, default)]
47    pub instance_id: ProtectedString,
48
49    /// Working directory for agent state
50    #[serde(skip, default)]
51    pub work_dir: ProtectedPath,
52
53    /// Optional environment label (dev, staging, prod)
54    #[serde(default)]
55    pub environment: Option<String>,
56
57    /// Hostname for tag derivation (defaults to system hostname)
58    #[serde(default)]
59    pub hostname: Option<String>,
60
61    // Serialization helpers (convert on load/save)
62    #[serde(rename = "instance_id")]
63    instance_id_raw: String,
64    #[serde(rename = "work_dir")]
65    work_dir_raw: String,
66}
67
68/// Deception artifact configuration.
69#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
70pub struct DeceptionConfig {
71    /// Paths where decoy files will be placed (immutable after load)
72    #[serde(default)]
73    #[zeroize(skip)]
74    pub decoy_paths: Box<[PathBuf]>,
75
76    /// Types of credentials to generate (aws, ssh, etc.) (immutable after load)
77    #[serde(deserialize_with = "deserialize_boxed_strings")]
78    pub credential_types: Box<[String]>,
79
80    /// Number of honeytokens to generate
81    #[serde(default = "default_honeytoken_count")]
82    pub honeytoken_count: usize,
83
84    /// Root cryptographic tag for tag derivation hierarchy
85    pub root_tag: RootTag,
86
87    /// Unix permissions for created artifacts (octal)
88    #[serde(default = "default_artifact_permissions")]
89    pub artifact_permissions: u32,
90}
91
92/// Deserialize Vec<String> to Box<[String]> for memory efficiency.
93fn deserialize_boxed_strings<'de, D>(deserializer: D) -> std::result::Result<Box<[String]>, D::Error>
94where
95    D: serde::Deserializer<'de>,
96{
97    let vec = Vec::<String>::deserialize(deserializer)?;
98    Ok(vec.into_boxed_slice())
99}
100
101/// Telemetry collection configuration.
102#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
103pub struct TelemetryConfig {
104    /// Paths to monitor for file access (immutable after load)
105    #[zeroize(skip)]
106    pub watch_paths: Box<[PathBuf]>,
107
108    /// Event buffer size (ring buffer capacity)
109    #[serde(default = "default_event_buffer_size")]
110    pub event_buffer_size: usize,
111
112    /// Enable syscall-level monitoring (high overhead)
113    #[serde(default)]
114    pub enable_syscall_monitor: bool,
115}
116
117/// Logging configuration.
118#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
119pub struct LoggingConfig {
120    /// Path to log file
121    #[zeroize(skip)]
122    pub log_path: PathBuf,
123
124    /// Log format (json or text)
125    #[serde(default = "default_log_format")]
126    pub format: LogFormat,
127
128    /// Rotate logs at this size (bytes)
129    #[serde(default = "default_rotate_size")]
130    pub rotate_size_bytes: u64,
131
132    /// Maximum number of rotated log files to keep
133    #[serde(default = "default_max_log_files")]
134    pub max_log_files: usize,
135
136    /// Minimum log level
137    #[serde(default = "default_log_level")]
138    pub level: LogLevel,
139}
140
141/// Log output format.
142#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
143#[serde(rename_all = "lowercase")]
144pub enum LogFormat {
145    /// JSON format (machine-parseable)
146    Json,
147    /// Plain text format (human-readable)
148    Text,
149}
150
151/// Log severity level.
152#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
153#[serde(rename_all = "UPPERCASE")]
154pub enum LogLevel {
155    /// Debug messages
156    Debug,
157    /// Informational messages
158    Info,
159    /// Warning messages
160    Warn,
161    /// Error messages
162    Error,
163}
164
165/// Protected string with automatic zeroization.
166#[derive(Zeroize, ZeroizeOnDrop, Default)]
167pub struct ProtectedString {
168    #[zeroize(skip)]
169    inner: String,
170}
171
172impl ProtectedString {
173    /// Create from string (takes ownership).
174    #[inline]
175    #[must_use] 
176    pub fn new(s: String) -> Self {
177        Self { inner: s }
178    }
179
180    /// Access the inner string by reference.
181    #[inline]
182    #[must_use] 
183    pub fn as_str(&self) -> &str {
184        &self.inner
185    }
186
187    /// Consume and return inner string.
188    #[inline]
189    #[must_use] 
190    pub fn into_inner(mut self) -> String {
191        std::mem::take(&mut self.inner)
192    }
193}
194
195impl std::fmt::Debug for ProtectedString {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        write!(f, "ProtectedString([REDACTED])")
198    }
199}
200
201/// Protected path with automatic zeroization.
202#[derive(Zeroize, ZeroizeOnDrop, Default)]
203pub struct ProtectedPath {
204    #[zeroize(skip)]
205    inner: PathBuf,
206}
207
208impl ProtectedPath {
209    /// Create from `PathBuf` (takes ownership).
210    #[inline]
211    #[must_use] 
212    pub fn new(path: PathBuf) -> Self {
213        Self { inner: path }
214    }
215
216    /// Access the inner path by reference.
217    #[inline]
218    #[must_use] 
219    pub fn as_path(&self) -> &Path {
220        &self.inner
221    }
222
223    /// Consume and return inner `PathBuf`.
224    #[inline]
225    #[must_use] 
226    pub fn into_inner(mut self) -> PathBuf {
227        std::mem::replace(&mut self.inner, PathBuf::new())
228    }
229}
230
231impl std::fmt::Debug for ProtectedPath {
232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233        write!(f, "ProtectedPath([REDACTED])")
234    }
235}
236
237impl Config {
238    /// Load configuration from TOML file with standard validation (async).
239    ///
240    /// # Errors
241    ///
242    /// Returns error if file cannot be read, TOML is invalid, or validation fails.
243    pub async fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
244        Self::from_file_with_mode(path, ValidationMode::Standard).await
245    }
246
247    /// Load configuration with specific validation mode (async to prevent thread exhaustion).
248    ///
249    /// # Errors
250    ///
251    /// Returns error if file cannot be read, TOML is invalid, or validation fails.
252    pub async fn from_file_with_mode<P: AsRef<Path>>(path: P, mode: ValidationMode) -> Result<Self> {
253        let started = Instant::now();
254        let path = path.as_ref();
255        let result = async {
256            // Platform-aware permission validation
257            Self::validate_file_permissions(path)?;
258
259            let contents = tokio::fs::read_to_string(path)
260                .await
261                .map_err(|e| errors::io_read_error("load_config", path, e))?;
262
263            let mut config: Config = toml::from_str(&contents).map_err(|e| {
264                let location = e
265                    .span().map_or_else(|| "unknown location".to_string(), |s| format!("line {}", contents[..s.start].matches('\n').count() + 1));
266
267                errors::parse_error(
268                    "parse_config_toml",
269                    format!("Invalid TOML syntax at {location}: {e}"),
270                )
271            })?;
272
273            // Convert raw fields to protected types
274            config.agent.instance_id =
275                ProtectedString::new(std::mem::take(&mut config.agent.instance_id_raw));
276            config.agent.work_dir =
277                ProtectedPath::new(PathBuf::from(std::mem::take(&mut config.agent.work_dir_raw)));
278
279            // Version validation
280            if config.version != CONFIG_VERSION {
281                let message = if config.version > CONFIG_VERSION {
282                    "Configuration version too new - upgrade agent"
283                } else {
284                    "Configuration version outdated - update config"
285                };
286
287                return Err(errors::version_error(
288                    "validate_config_version",
289                    config.version,
290                    CONFIG_VERSION,
291                    message,
292                ));
293            }
294
295            config.validate_with_mode(mode)?;
296
297            Ok(config)
298        }
299        .await;
300        enforce_operation_min_timing(started, TimingOperation::ConfigLoad);
301        result
302    }
303
304    /// Validate configuration with specific mode.
305    fn validate_with_mode(&self, mode: ValidationMode) -> Result<()> {
306        let started = Instant::now();
307        let result = (|| {
308            self.validate_agent()?;
309            self.validate_deception(mode)?;
310            self.validate_telemetry(mode)?;
311            self.validate_logging(mode)?;
312            Ok(())
313        })();
314        enforce_operation_min_timing(
315            started,
316            match mode {
317                ValidationMode::Standard => TimingOperation::ConfigValidateStandard,
318                ValidationMode::Strict => TimingOperation::ConfigValidateStrict,
319            },
320        );
321        result
322    }
323
324    /// Validate configuration (standard mode).
325    pub fn validate(&self) -> Result<()> {
326        self.validate_with_mode(ValidationMode::Standard)
327    }
328
329    #[cfg(unix)]
330    fn validate_file_permissions(path: &Path) -> Result<()> {
331        use std::os::unix::fs::PermissionsExt;
332
333        let metadata = std::fs::metadata(path)
334            .map_err(|e| errors::io_metadata_error("validate_config_file", path, e))?;
335
336        let mode = metadata.permissions().mode();
337
338        // Config file MUST NOT be world-readable or group-writable
339        if (mode & 0o077) != 0 {
340            return Err(UnixPermissionError::insecure_permissions(mode, "0o600"));
341        }
342
343        Ok(())
344    }
345
346    #[cfg(not(unix))]
347    fn validate_file_permissions(_path: &Path) -> Result<()> {
348        // Windows: Rely on NTFS ACLs (validated externally)
349        Ok(())
350    }
351
352    fn validate_agent(&self) -> Result<()> {
353        if self.agent.instance_id.as_str().is_empty() {
354            return Err(errors::missing_required(
355                "validate_agent",
356                "agent.instance_id",
357                "no_telemetry_correlation",
358            ));
359        }
360
361        if !self.agent.work_dir.as_path().is_absolute() {
362            return Err(PathValidationError::not_absolute(
363                "agent.work_dir",
364                "validate_agent",
365            ));
366        }
367
368        Ok(())
369    }
370
371    fn validate_deception(&self, mode: ValidationMode) -> Result<()> {
372        if self.deception.decoy_paths.is_empty() {
373            return Err(CollectionValidationError::empty(
374                "deception.decoy_paths",
375                "no_deception",
376                "validate_deception",
377            ));
378        }
379
380        for (idx, path) in self.deception.decoy_paths.iter().enumerate() {
381            if !path.is_absolute() {
382                return Err(PathValidationError::not_absolute(
383                    "deception.decoy_paths",
384                    "validate_deception",
385                ));
386            }
387
388            if mode == ValidationMode::Strict
389                && let Some(parent) = path.parent()
390                    && !parent.exists() {
391                        return Err(PathValidationError::parent_missing(
392                            "deception.decoy_paths",
393                            Some(idx),
394                            "validate_deception",
395                        ));
396                    }
397        }
398
399        if self.deception.credential_types.is_empty() {
400            return Err(CollectionValidationError::empty(
401                "deception.credential_types",
402                "no_credential_types",
403                "validate_deception",
404            ));
405        }
406
407        if self.deception.honeytoken_count == 0 || self.deception.honeytoken_count > 100 {
408            return Err(RangeValidationError::out_of_range(
409                "deception.honeytoken_count",
410                self.deception.honeytoken_count,
411                1,
412                100,
413                "validate_deception",
414            ));
415        }
416
417        if self.deception.artifact_permissions > 0o777 {
418            return Err(RangeValidationError::above_maximum(
419                "deception.artifact_permissions",
420                format!("{:o}", self.deception.artifact_permissions),
421                "0o777".to_string(),
422                "validate_deception",
423            ));
424        }
425
426        Ok(())
427    }
428
429    fn validate_telemetry(&self, mode: ValidationMode) -> Result<()> {
430        if self.telemetry.watch_paths.is_empty() {
431            return Err(CollectionValidationError::empty(
432                "telemetry.watch_paths",
433                "no_monitoring",
434                "validate_telemetry",
435            ));
436        }
437
438        for (idx, path) in self.telemetry.watch_paths.iter().enumerate() {
439            if !path.is_absolute() {
440                return Err(PathValidationError::not_absolute(
441                    "telemetry.watch_paths",
442                    "validate_telemetry",
443                ));
444            }
445
446            if mode == ValidationMode::Strict && !path.exists() {
447                return Err(PathValidationError::not_found(
448                    "telemetry.watch_paths",
449                    Some(idx),
450                    "validate_telemetry",
451                ));
452            }
453        }
454
455        if self.telemetry.event_buffer_size < 100 {
456            return Err(RangeValidationError::below_minimum(
457                "telemetry.event_buffer_size",
458                self.telemetry.event_buffer_size,
459                100,
460                "validate_telemetry",
461            ));
462        }
463
464        Ok(())
465    }
466
467    fn validate_logging(&self, mode: ValidationMode) -> Result<()> {
468        if !self.logging.log_path.is_absolute() {
469            return Err(PathValidationError::not_absolute(
470                "logging.log_path",
471                "validate_logging",
472            ));
473        }
474
475        if mode == ValidationMode::Strict
476            && let Some(parent) = self.logging.log_path.parent() {
477                if !parent.exists() {
478                    return Err(PathValidationError::parent_missing(
479                        "logging.log_path",
480                        None,
481                        "validate_logging",
482                    ));
483                }
484
485                let test_file = parent.join(".palisade-write-test");
486                std::fs::write(&test_file, b"test")
487                    .map_err(|e| errors::io_write_error("test_log_directory_write", &test_file, e))?;
488                let _ = std::fs::remove_file(&test_file);
489            }
490
491        if self.logging.rotate_size_bytes < 1024 * 1024 {
492            return Err(RangeValidationError::below_minimum(
493                "logging.rotate_size_bytes",
494                self.logging.rotate_size_bytes,
495                1024 * 1024,
496                "validate_logging",
497            ));
498        }
499
500        if self.logging.max_log_files == 0 {
501            return Err(errors::invalid_value(
502                "validate_logging",
503                "logging.max_log_files",
504                "logging.max_log_files cannot be zero",
505            ));
506        }
507
508        Ok(())
509    }
510
511    /// Get effective hostname for tag derivation (returns reference to avoid cloning).
512    #[must_use]
513    pub fn hostname(&self) -> std::borrow::Cow<'_, str> {
514        let started = Instant::now();
515        let hostname = if let Some(h) = &self.agent.hostname { std::borrow::Cow::Borrowed(h.as_str()) } else {
516            // Only allocate if we need to fetch system hostname
517            let system_hostname = hostname::get()
518                .ok()
519                .and_then(|h| h.into_string().ok())
520                .unwrap_or_else(|| "unknown-host".to_string());
521            std::borrow::Cow::Owned(system_hostname)
522        };
523        enforce_operation_min_timing(started, TimingOperation::ConfigHostname);
524        hostname
525    }
526}
527
528impl Default for Config {
529    fn default() -> Self {
530        let default_instance_id = hostname::get()
531            .ok()
532            .and_then(|h| h.into_string().ok())
533            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
534
535        Self {
536            version: CONFIG_VERSION,
537            agent: AgentConfig {
538                instance_id: ProtectedString::new(default_instance_id.clone()),
539                work_dir: ProtectedPath::new(PathBuf::from("/var/lib/palisade-agent")),
540                environment: None,
541                hostname: None,
542                instance_id_raw: default_instance_id,
543                work_dir_raw: "/var/lib/palisade-agent".to_string(),
544            },
545            deception: DeceptionConfig {
546                decoy_paths: vec![
547                    PathBuf::from("/tmp/.credentials"),
548                    PathBuf::from("/opt/.backup"),
549                ]
550                .into_boxed_slice(),
551                credential_types: vec!["aws".to_string(), "ssh".to_string()].into_boxed_slice(),
552                honeytoken_count: 5,
553                root_tag: RootTag::generate().expect("Failed to generate root tag - system entropy failure"),
554                artifact_permissions: 0o600,
555            },
556            telemetry: TelemetryConfig {
557                watch_paths: vec![PathBuf::from("/tmp")].into_boxed_slice(),
558                event_buffer_size: 10_000,
559                enable_syscall_monitor: false,
560            },
561            logging: LoggingConfig {
562                log_path: PathBuf::from("/var/log/palisade-agent.log"),
563                format: LogFormat::Json,
564                rotate_size_bytes: 100 * 1024 * 1024,
565                max_log_files: 10,
566                level: LogLevel::Info,
567            },
568        }
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    #[test]
577    fn default_config_validates() {
578        let config = Config::default();
579        assert!(config.validate().is_ok());
580    }
581
582    #[test]
583    fn hostname_fallback() {
584        let config = Config::default();
585        let hostname = config.hostname();
586        assert!(!hostname.is_empty());
587    }
588
589    #[test]
590    fn protected_string_redacts_in_debug() {
591        let protected = ProtectedString::new("secret123".to_string());
592        let debug = format!("{:?}", protected);
593        assert!(!debug.contains("secret123"));
594        assert!(debug.contains("REDACTED"));
595    }
596
597    #[test]
598    fn protected_path_redacts_in_debug() {
599        let protected = ProtectedPath::new(PathBuf::from("/etc/shadow"));
600        let debug = format!("{:?}", protected);
601        assert!(!debug.contains("shadow"));
602        assert!(debug.contains("REDACTED"));
603    }
604
605    #[test]
606    fn validation_catches_empty_instance_id() {
607        let mut config = Config::default();
608        config.agent.instance_id = ProtectedString::new(String::new());
609
610        let result = config.validate();
611        assert!(result.is_err());
612
613        if let Err(err) = result {
614            assert!(err.to_string().contains("Configuration"));
615        }
616    }
617
618    #[test]
619    fn validation_catches_relative_work_dir() {
620        let mut config = Config::default();
621        config.agent.work_dir = ProtectedPath::new(PathBuf::from("relative/path"));
622
623        let result = config.validate();
624        assert!(result.is_err());
625    }
626
627    #[test]
628    fn validation_catches_empty_decoy_paths() {
629        let mut config = Config::default();
630        config.deception.decoy_paths = Box::new([]);
631        assert!(config.validate().is_err());
632    }
633
634    #[test]
635    fn validation_catches_invalid_honeytoken_count() {
636        let mut config = Config::default();
637        config.deception.honeytoken_count = 0;
638        assert!(config.validate().is_err());
639
640        let mut config = Config::default();
641        config.deception.honeytoken_count = 101;
642        assert!(config.validate().is_err());
643    }
644}