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