envseal 0.3.12

Write-only secret vault with process-level access control — post-agent secret management
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
//! Process hardening, ptrace-scope checks, `memfd_secret` capability probe,
//! and the self-`LD_PRELOAD` startup detection.
//!
//! These run once at envseal startup (and are also used by `doctor`) to
//! make the process resistant to memory inspection by other same-UID
//! processes and to refuse to run if the loader has already injected an
//! attacker-controlled library.

use crate::error::Error;

/// Set process-level security flags to harden against memory attacks,
/// side-channel attacks, and privilege escalation.
///
/// Applied defenses (Linux):
/// - `PR_SET_DUMPABLE(0)` — prevents `ptrace` attachment by non-root
///   processes and disables core dumps. Also makes `/proc/<pid>/mem`
///   unreadable by same-UID processes.
/// - `RLIMIT_CORE = 0` — belt-and-suspenders core dump prevention. Even if
///   dumpable gets re-enabled, no core file will be written.
/// - `PR_SET_NO_NEW_PRIVS` — prevents gaining new privileges via `execve`
///   (blocks suid/sgid escalation from this process tree).
/// - Active-debugger detection via `/proc/self/status` `TracerPid` —
///   `exit(101)` if an attached tracer is detected at startup.
///
/// Called once at process startup.
pub fn harden_process() {
    #[cfg(target_os = "linux")]
    unsafe {
        libc::prctl(libc::PR_SET_DUMPABLE, 0);

        let rlimit = libc::rlimit {
            rlim_cur: 0,
            rlim_max: 0,
        };
        libc::setrlimit(libc::RLIMIT_CORE, &rlimit);

        libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);

        // Pre-Boot Debugger Detection (anti-`strace`/`gdb` dumping).
        // PR_SET_DUMPABLE(0) only prevents NEW attachments; existing
        // tracers are not detached. If an attacker launched
        // `strace envseal`, abort.
        if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
            for line in status.lines() {
                if let Some(pid_str) = line.strip_prefix("TracerPid:\t") {
                    if pid_str != "0" {
                        eprintln!("\n🚨 FATAL: Security compromised. Active debugger (ptrace) detected on envseal process. Aborting to protect vault.");
                        std::process::exit(101);
                    }
                }
            }
        }
    }

    #[cfg(windows)]
    {
        let _ = harden_process_dacl_windows();
    }
}

/// Windows: rewrite the current process's DACL to deny memory-read /
/// memory-write / handle-duplication / remote-thread-creation to the
/// process owner. Without this, the default Windows process DACL grants
/// the user FULL access to their own processes, so any same-user binary
/// can `OpenProcess(PROCESS_VM_READ, ...)` envseal and `ReadProcessMemory`
/// the unlocked master key out — defeating the entire CTF claim on Windows.
///
/// What we KEEP for the owner (so the user can still observe / kill
/// envseal normally):
///   - `PROCESS_TERMINATE`         (0x0001) — user can taskkill envseal
///   - `PROCESS_QUERY_INFORMATION` (0x0400) — basic Get*Info
///   - `PROCESS_QUERY_LIMITED_INFORMATION` (0x1000) — Task Manager
///   - `SYNCHRONIZE`               (0x00100000) — wait/join
///   - `READ_CONTROL`              (0x00020000) — read DACL itself
///
/// What we REMOVE for the owner:
///   - `PROCESS_VM_READ`           (0x0010) — `ReadProcessMemory`
///   - `PROCESS_VM_WRITE`          (0x0020) — `WriteProcessMemory`
///   - `PROCESS_VM_OPERATION`      (0x0008) — `Virtual{Alloc,Protect}Ex`
///   - `PROCESS_DUP_HANDLE`        (0x0040) — handle theft
///   - `PROCESS_CREATE_THREAD`     (0x0002) — `CreateRemoteThread`
///   - `PROCESS_SET_INFORMATION`   (0x0200)
///   - `PROCESS_SUSPEND_RESUME`    (0x0800)
///
/// SYSTEM keeps full access (so admin tools can still kill / inspect for
/// legitimate forensics) and DELETE — denial of those would break Windows'
/// own process-cleanup machinery.
///
/// The DACL applies to handles obtained AFTER this call. Any handle a
/// caller already holds is unaffected — but the only way for an attacker
/// to have such a handle is to have called `OpenProcess` before envseal
/// finished startup, which is racy and unrealistic for a passively
/// installed envseal binary.
#[cfg(windows)]
fn harden_process_dacl_windows() -> Result<(), Error> {
    use windows_sys::Win32::Foundation::LocalFree;
    use windows_sys::Win32::Security::Authorization::{
        ConvertStringSecurityDescriptorToSecurityDescriptorW, SetSecurityInfo, SE_KERNEL_OBJECT,
    };
    use windows_sys::Win32::Security::{
        GetSecurityDescriptorDacl, ACL, DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR,
    };
    use windows_sys::Win32::System::Threading::GetCurrentProcess;

    // SDDL_REVISION_1 is the only revision Windows currently accepts.
    const SDDL_REVISION_1: u32 = 1;

    // SDDL string:
    //   D:                       — start DACL
    //   (A;;0x1F0FFF;;;SY)       — Allow SYSTEM PROCESS_ALL_ACCESS (legacy)
    //   (A;;0x00121401;;;OW)     — Allow OWNER only:
    //                                 0x0001 TERMINATE
    //                                 0x0400 QUERY_INFORMATION
    //                                 0x1000 QUERY_LIMITED_INFORMATION
    //                                 0x00100000 SYNCHRONIZE
    //                                 0x00020000 READ_CONTROL
    //                              ⇒ 0x00121401
    //   Notably absent from OW: VM_READ (0x10), VM_WRITE (0x20),
    //   VM_OPERATION (0x08), DUP_HANDLE (0x40), CREATE_THREAD (0x02),
    //   SET_INFORMATION (0x200), SUSPEND_RESUME (0x800).
    let sddl: Vec<u16> = "D:(A;;0x1F0FFF;;;SY)(A;;0x00121401;;;OW)\0"
        .encode_utf16()
        .collect();

    let mut psd: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
    let ok = unsafe {
        ConvertStringSecurityDescriptorToSecurityDescriptorW(
            sddl.as_ptr(),
            SDDL_REVISION_1,
            &mut psd,
            std::ptr::null_mut(),
        )
    };
    if ok == 0 || psd.is_null() {
        return Err(Error::CryptoFailure(
            "harden_process_dacl: SDDL parse failed".to_string(),
        ));
    }

    // Extract the DACL pointer from the parsed SD.
    let mut dacl_present: i32 = 0;
    let mut dacl_ptr: *mut ACL = std::ptr::null_mut();
    let mut dacl_defaulted: i32 = 0;
    let got = unsafe {
        GetSecurityDescriptorDacl(psd, &mut dacl_present, &mut dacl_ptr, &mut dacl_defaulted)
    };
    if got == 0 || dacl_present == 0 || dacl_ptr.is_null() {
        unsafe {
            LocalFree(psd.cast());
        }
        return Err(Error::CryptoFailure(
            "harden_process_dacl: GetSecurityDescriptorDacl failed".to_string(),
        ));
    }

    // Apply to the current process. SetSecurityInfo with
    // DACL_SECURITY_INFORMATION replaces the existing DACL with ours.
    let rc = unsafe {
        SetSecurityInfo(
            GetCurrentProcess().cast(),
            SE_KERNEL_OBJECT,
            DACL_SECURITY_INFORMATION,
            std::ptr::null_mut(),
            std::ptr::null_mut(),
            dacl_ptr,
            std::ptr::null_mut(),
        )
    };

    unsafe {
        LocalFree(psd.cast());
    }

    if rc != 0 {
        return Err(Error::CryptoFailure(format!(
            "harden_process_dacl: SetSecurityInfo failed (Win32 error {rc})"
        )));
    }
    Ok(())
}

/// Probe whether `OpenProcess(PROCESS_VM_READ, ...)` against this
/// process from the current user context would succeed. Used by
/// `ctf doctor` to verify `harden_process_dacl_windows` actually
/// took effect — a positive `bool` means the DACL hardening is on
/// and external `ReadProcessMemory` is closed off.
#[cfg(windows)]
#[must_use]
pub fn vm_read_access_blocked() -> bool {
    use windows_sys::Win32::Foundation::CloseHandle;
    use windows_sys::Win32::System::Threading::{
        GetCurrentProcessId, OpenProcess, PROCESS_VM_READ,
    };

    unsafe {
        let pid = GetCurrentProcessId();
        let h = OpenProcess(PROCESS_VM_READ, 0, pid);
        if h.is_null() {
            true
        } else {
            CloseHandle(h);
            false
        }
    }
}

/// Off-Windows stub for [`vm_read_access_blocked`]. Always returns
/// `false` because Linux/macOS use different memory-isolation
/// primitives (`ptrace_scope`, `PR_SET_DUMPABLE`, hardened runtime) which
/// the doctor surfaces separately.
#[cfg(not(windows))]
#[must_use]
pub fn vm_read_access_blocked() -> bool {
    false
}

/// macOS: detect whether the running binary has the **hardened
/// runtime** flag set in its code-signature. Hardened runtime is
/// what makes `task_for_pid()` from a same-user process fail by
/// default — without it (unsigned / dev builds), any same-user
/// attacker can attach to envseal and read its address space, which
/// collapses the CTF claim on macOS the same way an unhardened
/// Windows DACL does there.
///
/// Returns `Ok(())` if the running binary is signed with the
/// hardened-runtime flag, `Err(detail)` otherwise.
///
/// Implementation: read the Mach-O `LC_CODE_SIGNATURE` segment from
/// `/proc/self/exe`-equivalent (`std::env::current_exe()`) and check
/// the `CS_HARD` (0x100) flag in the code-signing blob. If anything
/// fails to parse, return `Err` rather than misreporting `Ok` —
/// failing-closed keeps the doctor honest.
///
/// On non-macOS platforms this is a no-op returning `Ok(())`; the
/// doctor surfaces it via `DoctorCheck::NotApplicable` separately.
#[cfg(target_os = "macos")]
pub fn check_macos_hardened_runtime() -> Result<(), String> {
    const CS_VALID: u32 = 0x0000_0001;
    const CS_HARD: u32 = 0x0000_0100;
    const CSOPS_GET_FLAGS: i32 = 0;

    extern "C" {
        // libsystem_kernel: int csops(pid_t pid, unsigned int ops,
        //                              void *useraddr, size_t usersize);
        fn csops(pid: libc::pid_t, ops: u32, useraddr: *mut u32, usersize: libc::size_t) -> i32;
    }

    let mut flags: u32 = 0;
    let rc = unsafe {
        csops(
            0, // 0 = current process
            CSOPS_GET_FLAGS as u32,
            &mut flags as *mut u32,
            std::mem::size_of::<u32>(),
        )
    };
    if rc != 0 {
        return Err(format!(
            "csops(GET_FLAGS) returned {} — running binary is unsigned, \
             so hardened runtime cannot be engaged. task_for_pid is \
             permitted from any same-user process and an attacker can \
             read envseal's address space. Sign the release artifact \
             with `codesign -o runtime` and notarize.",
            rc
        ));
    }
    if flags & CS_VALID == 0 {
        return Err(
            "code signature is not valid — task_for_pid is unrestricted. \
             Sign the binary and notarize."
                .to_string(),
        );
    }
    if flags & CS_HARD == 0 {
        return Err(format!(
            "code signature is valid but the CS_HARD (hardened runtime) \
             flag is NOT set (flags=0x{:08x}). task_for_pid still works \
             from same-user processes — sign with `codesign -o runtime` \
             and re-notarize.",
            flags
        ));
    }
    Ok(())
}

/// Off-macOS stub. The `ctf doctor` surfaces this as
/// `DoctorCheck::NotApplicable` rather than `Ok` so the verdict
/// reads honestly on Linux / Windows.
#[cfg(not(target_os = "macos"))]
#[allow(clippy::missing_errors_doc)]
pub fn check_macos_hardened_runtime() -> Result<(), String> {
    Ok(())
}

/// Apply `MADV_DONTDUMP` to a memory region containing sensitive data.
///
/// Prevents the region from appearing in core dumps, even if
/// `PR_SET_DUMPABLE` gets re-enabled by an attacker. Used on key material
/// and passphrase buffers.
#[cfg(target_os = "linux")]
pub fn mark_dontdump(ptr: *const u8, len: usize) {
    unsafe {
        libc::madvise(ptr as *mut libc::c_void, len, libc::MADV_DONTDUMP);
    }
}

/// Test whether `memfd_secret()` is available on this kernel.
///
/// Returns `true` if the syscall succeeds (Linux 5.14+ with `CONFIG_SECRETMEM`).
/// Used by `doctor` to report memory protection level.
#[must_use]
pub fn test_memfd_secret() -> bool {
    #[cfg(all(
        target_os = "linux",
        any(target_arch = "x86_64", target_arch = "aarch64")
    ))]
    {
        const SYS_MEMFD_SECRET: libc::c_long = 447;
        let fd = unsafe { libc::syscall(SYS_MEMFD_SECRET, 0_u32) };
        if fd >= 0 {
            #[allow(clippy::cast_possible_truncation)]
            unsafe {
                libc::close(fd as i32);
            }
            return true;
        }
        false
    }

    #[cfg(not(all(
        target_os = "linux",
        any(target_arch = "x86_64", target_arch = "aarch64")
    )))]
    {
        false
    }
}

/// Signal-emitting variant of [`check_ptrace_scope`]. Returns a
/// single-element `Vec<Signal>` when the kernel permits same-UID
/// ptrace attach, or empty when protection is in place. Registered
/// in `DETECTORS` so the signal flows through the unified policy /
/// Decision pipeline like every other detector rather than being a
/// one-off `Vec<String>` returned from `startup_audit`.
#[must_use]
pub fn assess_ptrace_signals(_ctx: &super::DetectorContext) -> Vec<super::Signal> {
    #[cfg(target_os = "linux")]
    {
        if let Some(detail) = check_ptrace_scope() {
            return vec![super::Signal::new(
                super::SignalId::new("process.ptrace.permissive"),
                super::Category::EnvironmentInjection,
                super::Severity::Warn,
                "ptrace_scope is permissive",
                detail,
                "set kernel.yama.ptrace_scope to at least 1 (sudo sysctl …)",
            )];
        }
    }
    Vec::new()
}

/// Signal-emitting variant of [`check_self_preload`]. Maps a
/// detected `LD_PRELOAD` / `LD_AUDIT` to a `Critical` signal so the
/// default policy table converts it to `Action::Block` regardless
/// of tier — proceeding with a preloaded library would defeat the
/// entire trust boundary.
#[must_use]
pub fn assess_preload_signals(_ctx: &super::DetectorContext) -> Vec<super::Signal> {
    #[cfg(target_os = "linux")]
    {
        if let Err(e) = check_self_preload() {
            return vec![super::Signal::new(
                super::SignalId::new("process.preload.detected"),
                super::Category::EnvironmentInjection,
                super::Severity::Critical,
                "process started with library injection",
                e.to_string(),
                "restart envseal in a clean environment without LD_PRELOAD / LD_AUDIT",
            )];
        }
    }
    Vec::new()
}

/// Detect if `LD_PRELOAD` or similar was active when THIS process started.
///
/// We read `/proc/self/environ` (the REAL environment at exec time) rather
/// than `std::env` (which can be modified after startup). If a library was
/// injected into envseal itself, it's already too late for in-process
/// defenses — but we can detect and abort.
///
/// # Errors
/// Returns [`Error::EnvironmentCompromised`] if `LD_PRELOAD` or `LD_AUDIT`
/// is set in the real environment.
pub fn check_self_preload() -> Result<(), Error> {
    #[cfg(target_os = "linux")]
    {
        if let Ok(environ) = std::fs::read("/proc/self/environ") {
            let entries: Vec<&[u8]> = environ.split(|&b| b == 0).collect();
            for entry in entries {
                let entry_str = String::from_utf8_lossy(entry);
                if entry_str.starts_with("LD_PRELOAD=") || entry_str.starts_with("LD_AUDIT=") {
                    return Err(Error::EnvironmentCompromised(format!(
                        "this process was started with {entry_str}. \
                         a malicious library may be loaded in this process's memory. \
                         refusing to handle secrets. restart envseal without LD_PRELOAD."
                    )));
                }
            }
        }
    }
    Ok(())
}

/// Check the system's ptrace scope and return a warning if it's too permissive.
///
/// `ptrace_scope` = 0 means any same-UID process can ptrace any other,
/// which allows memory extraction even with `PR_SET_DUMPABLE(0)` on some
/// kernel configurations.
#[must_use]
pub fn check_ptrace_scope() -> Option<String> {
    #[cfg(target_os = "linux")]
    {
        match std::fs::read_to_string("/proc/sys/kernel/yama/ptrace_scope") {
            Ok(scope) => {
                let scope = scope.trim();
                if scope == "0" {
                    return Some(
                        "ptrace_scope is 0 (permissive): any same-UID process can attach to \
                         envseal and read secrets from memory. set ptrace_scope to 1 or higher: \
                         sudo sysctl kernel.yama.ptrace_scope=1"
                            .to_string(),
                    );
                }
                None
            }
            Err(_) => None,
        }
    }

    #[cfg(not(target_os = "linux"))]
    {
        None
    }
}