envseal 0.3.13

Write-only secret vault with process-level access control — post-agent secret management
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
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
//! 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(target_os = "macos")]
    {
        // macOS active-debugger detection. Mirror of the Linux
        // TracerPid path: refuse to run if a debugger is already
        // attached. `sysctl kern.proc.pid.<self>` populates a
        // `kinfo_proc` whose `kp_proc.p_flag` carries `P_TRACED`
        // (= 0x800) when a tracer attached pre-exec.
        if macos_traced() {
            eprintln!("\n🚨 FATAL: Security compromised. Active debugger (ptrace/sysdiagnose) detected on envseal process. Aborting to protect vault.");
            std::process::exit(101);
        }

        // H6 follow-up closed: enforce CS_HARD (hardened runtime)
        // at startup, not just as a doctor diagnostic. Without
        // hardened runtime, any same-user process can call
        // `task_for_pid` on us and read our address space —
        // including the unlocked master key after passphrase
        // entry. The release-build invariant is "envseal does not
        // run on macOS without hardened runtime."
        //
        // Debug builds and the explicit dev escape hatch
        // `ENVSEAL_ALLOW_UNHARDENED_MACOS=1` are exempt: contributors
        // can't `codesign -o runtime` locally without an Apple
        // Developer ID, and forcing every dev build to fail would
        // break the test suite. The escape hatch is logged loudly
        // so a user who set it accidentally sees the warning.
        if let Err(detail) = check_macos_hardened_runtime() {
            let dev_override = std::env::var("ENVSEAL_ALLOW_UNHARDENED_MACOS")
                .is_ok_and(|v| !v.is_empty() && v != "0" && v != "false");
            if dev_override {
                eprintln!(
                    "envseal: WARNING — hardened-runtime check failed ({detail}); \
                     ENVSEAL_ALLOW_UNHARDENED_MACOS is set, allowing this run. \
                     Production builds MUST be signed with `codesign -o runtime`."
                );
            } else if cfg!(debug_assertions) {
                eprintln!("envseal: hardened-runtime check skipped (debug build): {detail}");
            } else {
                eprintln!(
                    "\n🚨 FATAL: macOS hardened runtime is not engaged ({detail}). \
                     Without it, any same-user process can read envseal's memory \
                     via task_for_pid and exfiltrate the unlocked master key. \
                     Re-sign with `codesign -o runtime` and re-notarize, or set \
                     ENVSEAL_ALLOW_UNHARDENED_MACOS=1 if this is an intentional \
                     development run."
                );
                std::process::exit(102);
            }
        }
    }

    #[cfg(windows)]
    {
        let _ = harden_process_dacl_windows();
        // Windows active-debugger detection. `IsDebuggerPresent()`
        // tests the PEB's `BeingDebugged` flag — set by the kernel
        // for any user-mode debugger attach. `CheckRemoteDebuggerPresent`
        // catches kernel-mode debuggers that don't flip BeingDebugged
        // (rare but real). Either signal aborts the process.
        if windows_debugger_present() {
            eprintln!("\n🚨 FATAL: Security compromised. Active debugger detected on envseal process. Aborting to protect vault.");
            std::process::exit(101);
        }
    }
}

/// macOS active-tracer probe via `sysctl(KERN_PROC, KERN_PROC_PID, …)`.
/// Returns `true` if `P_TRACED` is set in the process's `p_flag`.
#[cfg(target_os = "macos")]
fn macos_traced() -> bool {
    /// `kinfo_proc::kp_proc::p_flag & P_TRACED`. Defined in
    /// `<sys/proc.h>`; libc's binding is `libc::P_TRACED` but its
    /// shape varies by libc version, so we hard-code the canonical
    /// XNU constant value rather than risk a missing symbol.
    const P_TRACED: i32 = 0x0000_0800;

    /// `KERN_PROC_PID = 1` selects "look up by pid".
    const KERN_PROC_PID: libc::c_int = 1;

    let mut mib: [libc::c_int; 4] = [
        libc::CTL_KERN,
        libc::KERN_PROC,
        KERN_PROC_PID,
        // SAFETY: getpid is async-signal-safe and returns our own pid.
        unsafe { libc::getpid() },
    ];
    // SAFETY: kinfo_proc is the kernel-defined layout the sysctl
    // populates. We zero-initialize, ask the kernel to fill it in,
    // and read p_flag — never deref past the populated bytes.
    let mut info: libc::kinfo_proc = unsafe { std::mem::zeroed() };
    let mut size = std::mem::size_of::<libc::kinfo_proc>();
    let rc = unsafe {
        libc::sysctl(
            mib.as_mut_ptr(),
            4,
            std::ptr::addr_of_mut!(info).cast::<libc::c_void>(),
            &mut size,
            std::ptr::null_mut(),
            0,
        )
    };
    if rc != 0 {
        // sysctl failure: do NOT block. The Linux path is the
        // authoritative anti-debug; a probe failure here is an
        // unusual macOS configuration, not an attacker.
        return false;
    }
    (info.kp_proc.p_flag & P_TRACED) != 0
}

/// Windows active-debugger probe. Combines `IsDebuggerPresent`
/// (user-mode debuggers, sets PEB.BeingDebugged) with
/// `CheckRemoteDebuggerPresent` (kernel-mode debuggers that bypass
/// the PEB). Either positive → caller aborts.
#[cfg(windows)]
fn windows_debugger_present() -> bool {
    use windows_sys::Win32::System::Diagnostics::Debug::{
        CheckRemoteDebuggerPresent, IsDebuggerPresent,
    };
    use windows_sys::Win32::System::Threading::GetCurrentProcess;

    // SAFETY: IsDebuggerPresent is a parameter-less Win32 syscall
    // that touches only the current PEB; safe to call any time.
    if unsafe { IsDebuggerPresent() } != 0 {
        return true;
    }
    // SAFETY: CheckRemoteDebuggerPresent on the current process
    // handle (a pseudo-handle, no resource to leak). The bool out
    // param is stack-resident and zero-initialized.
    let mut remote: i32 = 0;
    let _ = unsafe { CheckRemoteDebuggerPresent(GetCurrentProcess(), &mut remote) };
    remote != 0
}

/// 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=")
                    || entry_str.starts_with("LD_PROFILE=")
                    || entry_str.starts_with("GLIBC_TUNABLES=")
                {
                    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."
                    )));
                }
            }
        }
    }
    #[cfg(target_os = "macos")]
    {
        for (key, _value) in std::env::vars() {
            if key == "DYLD_INSERT_LIBRARIES" || key == "DYLD_LIBRARY_PATH" {
                return Err(Error::EnvironmentCompromised(format!(
                    "this process was started with {key}. \
                     a malicious library may be loaded in this process's memory. \
                     refusing to handle secrets. restart envseal without DYLD_INSERT_LIBRARIES."
                )));
            }
        }
    }
    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
    }
}

#[cfg(test)]
mod check_self_preload_tests {
    use super::check_self_preload;

    #[test]
    #[cfg(target_os = "linux")]
    fn detects_ld_profile() {
        std::env::set_var("LD_PROFILE", "/tmp/fake.so");
        let result = check_self_preload();
        std::env::remove_var("LD_PROFILE");
        assert!(
            matches!(result, Err(Error::EnvironmentCompromised(_))),
            "LD_PROFILE should trigger EnvironmentCompromised: {:?}",
            result
        );
    }

    #[test]
    #[cfg(target_os = "linux")]
    fn detects_glibc_tunables() {
        std::env::set_var("GLIBC_TUNABLES", "glibc.rtld.nns=1");
        let result = check_self_preload();
        std::env::remove_var("GLIBC_TUNABLES");
        assert!(
            matches!(result, Err(Error::EnvironmentCompromised(_))),
            "GLIBC_TUNABLES should trigger EnvironmentCompromised: {:?}",
            result
        );
    }

    #[test]
    #[cfg(target_os = "macos")]
    fn detects_dyld_insert_libraries() {
        std::env::set_var("DYLD_INSERT_LIBRARIES", "/tmp/evil.dylib");
        let result = check_self_preload();
        std::env::remove_var("DYLD_INSERT_LIBRARIES");
        assert!(
            matches!(result, Err(Error::EnvironmentCompromised(_))),
            "DYLD_INSERT_LIBRARIES should trigger EnvironmentCompromised: {:?}",
            result
        );
    }

    #[test]
    fn clean_env_passes() {
        // Ensure that without the hostile vars the function succeeds.
        // On Linux this reads /proc/self/environ; on macOS it checks
        // DYLD vars; on other platforms it is a no-op.
        let result = check_self_preload();
        assert!(
            result.is_ok(),
            "check_self_preload should succeed in a clean test environment: {result:?}",
        );
    }
}