assay_core/
incident.rs

1use anyhow::{Context, Result, anyhow};
2use assay_common::exports::{EventRecordExport, ProcessTreeExport};
3use serde::Serialize;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7#[cfg(unix)]
8use std::os::unix::fs::PermissionsExt;
9#[cfg(unix)]
10use std::os::unix::io::{AsRawFd, FromRawFd};
11#[cfg(unix)]
12use nix::fcntl::{open, openat, OFlag, renameat};
13#[cfg(unix)]
14use nix::sys::stat::{Mode, fchmod};
15
16use uuid::Uuid;
17
18#[cfg(not(unix))]
19trait PermissionsExt {} // Stub
20
21#[derive(Debug, Serialize)]
22pub struct IncidentBundle {
23    pub metadata: IncidentMetadata,
24    pub tree: ProcessTreeExport,
25    pub events: Vec<EventRecordExport>,
26}
27
28#[derive(Debug, Serialize)]
29pub struct IncidentMetadata {
30    pub timestamp: String,
31    pub session_id: String,
32    pub kernel_version: String,
33    pub assay_version: String,
34}
35
36pub struct IncidentBuilder {
37    bundle: IncidentBundle,
38}
39
40impl IncidentBuilder {
41    pub fn new(session_id: String) -> Self {
42        let now = chrono::Utc::now().to_rfc3339();
43
44        // Simple kernel version retrieval
45        let kernel_version = std::fs::read_to_string("/proc/version")
46            .unwrap_or_else(|_| "unknown".to_string())
47            .trim()
48            .to_string();
49
50        Self {
51            bundle: IncidentBundle {
52                metadata: IncidentMetadata {
53                    timestamp: now,
54                    session_id,
55                    kernel_version,
56                    assay_version: env!("CARGO_PKG_VERSION").to_string(),
57                },
58                tree: ProcessTreeExport::default(),
59                events: Vec::new(),
60            }
61        }
62    }
63
64    pub fn with_tree(mut self, tree: ProcessTreeExport) -> Self {
65        self.bundle.tree = tree;
66        self
67    }
68
69    pub fn with_events(mut self, events: Vec<EventRecordExport>) -> Self {
70        self.bundle.events = events;
71        self
72    }
73
74    /// Writes the bundle atomically to the specified directory.
75    /// Creates a temp file, sets permissions (0600), then moves it.
76    /// Enforces 0700 on parent directory for security.
77    /// Secure Atomic Write (SOTA P0)
78    /// Uses low-level O_NOFOLLOW/openat to prevent symlink attacks.
79    /// Enforces 0700 on directory and 0600 on file.
80    #[cfg(unix)]
81    pub fn atomic_write(&self, output_dir: &Path) -> Result<PathBuf> {
82        let dir_path_str = output_dir.to_str().ok_or_else(|| anyhow!("Invalid path"))?;
83
84        // 1. Ensure directory exists
85        if !output_dir.exists() {
86             fs::create_dir_all(output_dir).context("Failed to create output dir")?;
87             let mut perms = fs::metadata(output_dir)?.permissions();
88             perms.set_mode(0o700);
89             fs::set_permissions(output_dir, perms).context("Failed to secure new output dir")?;
90        }
91
92        // 2. Open Directory securely (O_DIRECTORY | O_NOFOLLOW)
93        let dir_raw_fd = open(
94            dir_path_str,
95            OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW,
96            Mode::empty()
97        ).context("Failed to open output directory securely")?;
98
99        // SAFETY: We wrap the raw FD immediately to ensure RAII closure.
100        let dir_file = unsafe { std::fs::File::from_raw_fd(dir_raw_fd) };
101
102        // 3. Verify Directory Permissions (fstat on fd)
103        let dir_meta = dir_file.metadata()?;
104        let current_mode = dir_meta.permissions().mode();
105        if (current_mode & 0o777) != 0o700 {
106            // P0: Enforce 0700 always (via fd to avoid TOCTOU)
107            fchmod(dir_file.as_raw_fd(), Mode::from_bits_truncate(0o700))
108                .context("Failed to fchmod output directory")?;
109        }
110
111        // SOTA: Guaranteed unique filename to prevent overwrites (collision free)
112        let suffix = Uuid::new_v4().simple().to_string();
113        let filename = format!("incident_{}_{}.json", self.bundle.metadata.session_id, suffix);
114        let tmp_filename = format!(".tmp_{}", filename);
115
116        let content = serde_json::to_string_pretty(&self.bundle)
117            .context("Failed to serialize incident bundle")?;
118
119        // 4. Open Temp File (openat relative to dir_fd, O_CREAT|O_EXCL|O_NOFOLLOW, 0600)
120        let tmp_fd = openat(
121            dir_file.as_raw_fd(),
122            tmp_filename.as_str(),
123            OFlag::O_CREAT | OFlag::O_WRONLY | OFlag::O_EXCL | OFlag::O_NOFOLLOW,
124            Mode::from_bits_truncate(0o600)
125        ).context("Failed to create temp file securely")?;
126
127        let mut tmp_file = unsafe { std::fs::File::from_raw_fd(tmp_fd) };
128
129        // 5. Write and Fsync
130        use std::io::Write;
131        tmp_file.write_all(content.as_bytes())?;
132        tmp_file.sync_all()?;
133
134        // 6. Atomic Rename (renameat)
135        renameat(
136            Some(dir_file.as_raw_fd()),
137            tmp_filename.as_str(),
138            Some(dir_file.as_raw_fd()),
139            filename.as_str()
140        ).context("Failed to rename atomic file")?;
141
142        // 7. Sync Parent Directory
143        dir_file.sync_all()?;
144
145        Ok(output_dir.join(filename))
146    }
147
148    #[cfg(not(unix))]
149    pub fn atomic_write(&self, _output_dir: &Path) -> Result<PathBuf> {
150        Err(anyhow!("Incident bundles only supported on Unix"))
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use std::os::unix::fs::PermissionsExt;
158
159    #[test]
160    fn test_atomic_write_security() -> Result<()> {
161        let temp_dir = tempfile::tempdir()?;
162        let builder = IncidentBuilder::new("test-session".to_string());
163
164        // Write info
165        let path = builder.atomic_write(temp_dir.path())?;
166
167        // Check 1: File exists
168        assert!(path.exists());
169        assert!(path.file_name().unwrap().to_str().unwrap().contains("test-session"));
170
171        // Check 2: Permissions (0600)
172        let perms = fs::metadata(&path)?.permissions();
173        let mode = perms.mode() & 0o777;
174        assert_eq!(mode, 0o600, "Incident bundle permissions must be 0600");
175
176        // Check 3: Content
177        let content = fs::read_to_string(&path)?;
178        let json: serde_json::Value = serde_json::from_str(&content)?;
179        assert_eq!(json["metadata"]["session_id"], "test-session");
180
181        Ok(())
182    }
183}