mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-01 07:53:42 +08:00
Establish the Phase 1 reference implementation proposed by RFC 0012 while retaining the existing Cargo and Mise workflows during evaluation. - pin Bazel 9.1.1 and configure Bzlmod, rules_rs, LLVM, protobuf, and Rust 1.95 toolchains - import third-party crates from Cargo metadata and propagate the workspace version into Bazel targets - add library, binary, proc-macro, unit-test, and integration-test targets across the supported Rust workspace crates and drivers - generate protobuf Rust sources and descriptor sets under Bazel while preserving Cargo-compatible generated-code imports - annotate aws-lc-sys and zstd-sys native dependencies, build Z3 4.15.2 from source, and generate z3-sys bindings - make CLI and procfs test fixtures available as explicit Bazel inputs without relying on fixed host binary paths - define optimized release targets for Linux x86_64 and aarch64 CLI, sandbox, and gateway binaries, plus macOS aarch64 artifacts - add Bazel, buildifier, and lcov to the Nix development environment RFC: 0012 (rfc12 branch) Refs: #2491 Signed-off-by: Simon Scatton <sscatton@nvidia.com>
1057 lines
38 KiB
Rust
1057 lines
38 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! Linux `/proc` filesystem reading for process identity.
|
|
//!
|
|
//! Provides functions to resolve binary paths and compute file hashes
|
|
//! for process-identity binding in the OPA proxy policy engine.
|
|
|
|
use miette::Result;
|
|
#[cfg(target_os = "linux")]
|
|
use std::collections::HashSet;
|
|
use std::net::SocketAddr;
|
|
#[cfg(target_os = "linux")]
|
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
|
use std::path::Path;
|
|
#[cfg(target_os = "linux")]
|
|
use std::path::PathBuf;
|
|
use tracing::debug;
|
|
|
|
/// Where a socket owner was discovered while scanning `/proc`.
|
|
#[cfg(target_os = "linux")]
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum SocketOwnerSource {
|
|
/// Owner was found in the entrypoint process tree at the given BFS depth.
|
|
Descendant { depth: usize },
|
|
/// Owner was found by scanning all of `/proc` after the descendant scan.
|
|
ProcFallback,
|
|
}
|
|
|
|
/// A process with an fd pointing at a target socket inode.
|
|
#[cfg(target_os = "linux")]
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct SocketOwner {
|
|
pub pid: u32,
|
|
pub source: SocketOwnerSource,
|
|
}
|
|
|
|
/// All process owners for a TCP peer socket.
|
|
#[cfg(target_os = "linux")]
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct TcpPeerSocketOwners {
|
|
pub inode: u64,
|
|
pub owners: Vec<SocketOwner>,
|
|
}
|
|
|
|
/// TCP endpoints for a workload connection accepted by the sandbox proxy.
|
|
///
|
|
/// `/proc/<pid>/net/tcp{,6}` reports the socket from the workload side, so its
|
|
/// local endpoint should match `workload` and its remote endpoint should match
|
|
/// `proxy`.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct WorkloadProxyTcpConnection {
|
|
pub workload: SocketAddr,
|
|
pub proxy: SocketAddr,
|
|
}
|
|
|
|
impl WorkloadProxyTcpConnection {
|
|
pub fn new(workload: SocketAddr, proxy: SocketAddr) -> Self {
|
|
Self { workload, proxy }
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for WorkloadProxyTcpConnection {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{} -> {}", self.workload, self.proxy)
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum ProcTcpAddressFamily {
|
|
Ipv4,
|
|
Ipv6,
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
impl ProcTcpAddressFamily {
|
|
fn table_name(self) -> &'static str {
|
|
match self {
|
|
Self::Ipv4 => "tcp",
|
|
Self::Ipv6 => "tcp6",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
struct DescendantPid {
|
|
pid: u32,
|
|
depth: usize,
|
|
}
|
|
|
|
/// Read the binary path of a process via `/proc/{pid}/exe` symlink.
|
|
///
|
|
/// Returns the canonical path to the executable that the process is running.
|
|
/// Fails hard if the exe symlink is not readable — we never fall back to
|
|
/// `/proc/{pid}/cmdline` because `argv[0]` is trivially spoofable by any
|
|
/// process and must not be used as a trusted identity source.
|
|
///
|
|
/// ### Unlinked binaries (`(deleted)` suffix)
|
|
///
|
|
/// When a running binary is unlinked from its filesystem path — the common
|
|
/// case is a hot-swap of `/opt/openshell/bin/openshell-sandbox` during a
|
|
/// development upgrade — the kernel appends the
|
|
/// literal string `" (deleted)"` to the `/proc/<pid>/exe` readlink target.
|
|
/// The raw tainted path (e.g. `"/opt/openshell/bin/openshell-sandbox (deleted)"`)
|
|
/// is not a real filesystem path: any downstream `stat()` fails with `ENOENT`.
|
|
///
|
|
/// We strip the suffix so callers see a clean, grep-friendly path suitable
|
|
/// for cache keys and log messages. The strip is guarded: we only strip when
|
|
/// `stat()` on the raw readlink target reports `NotFound`, so a live executable
|
|
/// whose basename literally ends with `" (deleted)"` is returned unchanged.
|
|
/// The comparison is done on raw bytes via `OsStrExt`, so filenames that are
|
|
/// not valid UTF-8 are still handled correctly. Exactly one kernel-added
|
|
/// suffix is stripped.
|
|
///
|
|
/// This does NOT claim the file at the stripped path is the same binary that
|
|
/// the process is executing — the on-disk inode may now be arbitrary. Callers
|
|
/// that need to verify the running binary's *contents* (for integrity
|
|
/// checking) should read the magic `/proc/<pid>/exe` symlink directly via
|
|
/// `File::open`, which procfs resolves to the live in-memory executable even
|
|
/// when the original inode has been unlinked.
|
|
///
|
|
/// If the readlink itself fails, ensure the proxy process has permission
|
|
/// to read `/proc/<pid>/exe` (e.g. same user, or `CAP_SYS_PTRACE`).
|
|
#[cfg(target_os = "linux")]
|
|
pub fn binary_path(pid: i32) -> Result<PathBuf> {
|
|
use std::ffi::OsString;
|
|
use std::io::ErrorKind;
|
|
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
|
|
|
const DELETED_SUFFIX: &[u8] = b" (deleted)";
|
|
|
|
let link = format!("/proc/{pid}/exe");
|
|
let target = std::fs::read_link(&link).map_err(|e| {
|
|
miette::miette!(
|
|
"Failed to read /proc/{pid}/exe: {e}. \
|
|
Cannot determine binary identity — denying request. \
|
|
Hint: the proxy may need CAP_SYS_PTRACE or to run as the same user."
|
|
)
|
|
})?;
|
|
|
|
// Only strip when the raw readlink target cannot be stat'd and its bytes
|
|
// end with the kernel-added suffix. This preserves live executables whose
|
|
// basename legitimately ends with " (deleted)" and handles non-UTF-8
|
|
// filenames correctly.
|
|
let raw_target_missing =
|
|
matches!(std::fs::metadata(&target), Err(err) if err.kind() == ErrorKind::NotFound);
|
|
|
|
let bytes = target.as_os_str().as_bytes();
|
|
if raw_target_missing && bytes.ends_with(DELETED_SUFFIX) {
|
|
let stripped = bytes[..bytes.len() - DELETED_SUFFIX.len()].to_vec();
|
|
return Ok(PathBuf::from(OsString::from_vec(stripped)));
|
|
}
|
|
|
|
Ok(target)
|
|
}
|
|
|
|
/// Resolve the binary path of the TCP peer inside a sandbox network namespace.
|
|
///
|
|
/// Uses `/proc/<entrypoint_pid>/net/tcp` to find the socket inode for the given
|
|
/// connection tuple, then scans the entrypoint process tree to find which PID
|
|
/// owns that socket, and finally reads `/proc/<pid>/exe` to get the binary path.
|
|
#[cfg(target_os = "linux")]
|
|
pub fn resolve_tcp_peer_binary(
|
|
entrypoint_pid: u32,
|
|
connection: WorkloadProxyTcpConnection,
|
|
) -> Result<PathBuf> {
|
|
let owner = resolve_single_tcp_peer_owner(entrypoint_pid, connection)?;
|
|
binary_path(owner.pid.cast_signed())
|
|
}
|
|
|
|
/// Resolve all process owners for the TCP peer inside a sandbox network namespace.
|
|
///
|
|
/// Multiple processes can legitimately hold the same socket inode after `fork()`
|
|
/// or fd passing. Callers that make security decisions must evaluate the full
|
|
/// owner set instead of selecting the first PID returned by `/proc` traversal.
|
|
#[cfg(target_os = "linux")]
|
|
pub fn resolve_tcp_peer_socket_owners(
|
|
entrypoint_pid: u32,
|
|
connection: WorkloadProxyTcpConnection,
|
|
) -> Result<TcpPeerSocketOwners> {
|
|
let inode = parse_proc_net_tcp(entrypoint_pid, connection)?;
|
|
let owners = find_socket_inode_owners(inode, entrypoint_pid)?;
|
|
Ok(TcpPeerSocketOwners { inode, owners })
|
|
}
|
|
|
|
/// Resolve exactly one owner for the TCP peer, failing closed on ambiguity.
|
|
#[cfg(target_os = "linux")]
|
|
fn resolve_single_tcp_peer_owner(
|
|
entrypoint_pid: u32,
|
|
connection: WorkloadProxyTcpConnection,
|
|
) -> Result<SocketOwner> {
|
|
let socket_owners = resolve_tcp_peer_socket_owners(entrypoint_pid, connection)?;
|
|
match socket_owners.owners.as_slice() {
|
|
[owner] => Ok(owner.clone()),
|
|
owners => {
|
|
let mut pids: Vec<u32> = owners.iter().map(|owner| owner.pid).collect();
|
|
pids.sort_unstable();
|
|
Err(miette::miette!(
|
|
"Ambiguous socket ownership for inode {}: PIDs [{}] all hold the same socket",
|
|
socket_owners.inode,
|
|
pids.iter()
|
|
.map(u32::to_string)
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Like `resolve_tcp_peer_binary`, but also returns the PID that owns the socket.
|
|
///
|
|
/// Needed for the ancestor walk: we must know the PID to walk `/proc/<pid>/status` `PPid` chain.
|
|
#[cfg(target_os = "linux")]
|
|
pub fn resolve_tcp_peer_identity(
|
|
entrypoint_pid: u32,
|
|
connection: WorkloadProxyTcpConnection,
|
|
) -> Result<(PathBuf, u32)> {
|
|
let owner = resolve_single_tcp_peer_owner(entrypoint_pid, connection)?;
|
|
let path = binary_path(owner.pid.cast_signed())?;
|
|
Ok((path, owner.pid))
|
|
}
|
|
|
|
/// Read the `PPid` (parent PID) from `/proc/<pid>/status`.
|
|
#[cfg(target_os = "linux")]
|
|
pub fn read_ppid(pid: u32) -> Option<u32> {
|
|
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
|
for line in status.lines() {
|
|
if let Some(rest) = line.strip_prefix("PPid:") {
|
|
return rest.trim().parse().ok();
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Walk the process tree upward from `pid`, collecting the binary path of each ancestor.
|
|
///
|
|
/// Stops at PID 1 (init), `stop_pid` (the entrypoint process), or after 64 ancestors
|
|
/// as a safety limit. The returned vec does NOT include `pid` itself — only its parents.
|
|
#[cfg(target_os = "linux")]
|
|
#[allow(clippy::similar_names)]
|
|
pub fn collect_ancestor_binaries(pid: u32, stop_pid: u32) -> Vec<PathBuf> {
|
|
const MAX_DEPTH: usize = 64;
|
|
let mut ancestors = Vec::new();
|
|
let mut current = pid;
|
|
|
|
for _ in 0..MAX_DEPTH {
|
|
let ppid = match read_ppid(current) {
|
|
Some(p) if p > 0 && p != current => p,
|
|
_ => break,
|
|
};
|
|
|
|
if let Ok(path) = binary_path(ppid.cast_signed()) {
|
|
ancestors.push(path);
|
|
}
|
|
|
|
// Stop if we've reached the entrypoint or init
|
|
if ppid == stop_pid || ppid == 1 {
|
|
break;
|
|
}
|
|
current = ppid;
|
|
}
|
|
|
|
ancestors
|
|
}
|
|
|
|
/// Extract absolute paths from `/proc/<pid>/cmdline`.
|
|
///
|
|
/// Reads the null-separated cmdline and returns any argv entries that look like
|
|
/// absolute paths (starting with `/`). This captures script paths that don't
|
|
/// appear in `/proc/<pid>/exe` — e.g. when `#!/usr/bin/env node` runs
|
|
/// `/usr/local/bin/claude`, the exe is `/usr/bin/node` but cmdline contains
|
|
/// `node\0/usr/local/bin/claude\0...`.
|
|
#[cfg(target_os = "linux")]
|
|
pub fn cmdline_absolute_paths(pid: u32) -> Vec<PathBuf> {
|
|
let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else {
|
|
return vec![];
|
|
};
|
|
cmdline
|
|
.split(|&b| b == 0)
|
|
.filter(|arg| arg.first() == Some(&b'/'))
|
|
.map(|arg| PathBuf::from(String::from_utf8_lossy(arg).into_owned()))
|
|
.collect()
|
|
}
|
|
|
|
/// Collect cmdline absolute paths for a PID and its ancestor chain.
|
|
///
|
|
/// Returns deduplicated absolute paths from `/proc/<pid>/cmdline` for the given
|
|
/// PID and each ancestor up to `stop_pid` / PID 1. Paths already present in
|
|
/// `exclude` (typically the exe-based paths) are omitted to avoid duplicates.
|
|
#[cfg(target_os = "linux")]
|
|
#[allow(clippy::similar_names)]
|
|
pub fn collect_cmdline_paths(pid: u32, stop_pid: u32, exclude: &[PathBuf]) -> Vec<PathBuf> {
|
|
const MAX_DEPTH: usize = 64;
|
|
let mut paths = Vec::new();
|
|
let mut current = pid;
|
|
|
|
// Collect from the immediate PID first
|
|
for p in cmdline_absolute_paths(current) {
|
|
if !exclude.contains(&p) && !paths.contains(&p) {
|
|
paths.push(p);
|
|
}
|
|
}
|
|
|
|
// Then walk ancestors (same traversal as collect_ancestor_binaries)
|
|
for _ in 0..MAX_DEPTH {
|
|
let ppid = match read_ppid(current) {
|
|
Some(p) if p > 0 && p != current => p,
|
|
_ => break,
|
|
};
|
|
|
|
for p in cmdline_absolute_paths(ppid) {
|
|
if !exclude.contains(&p) && !paths.contains(&p) {
|
|
paths.push(p);
|
|
}
|
|
}
|
|
|
|
if ppid == stop_pid || ppid == 1 {
|
|
break;
|
|
}
|
|
current = ppid;
|
|
}
|
|
|
|
paths
|
|
}
|
|
|
|
/// Parse `/proc/<pid>/net/tcp` (and `/proc/<pid>/net/tcp6`) to find the socket
|
|
/// inode for a complete local-to-remote TCP tuple.
|
|
///
|
|
/// Checks both IPv4 and IPv6 tables because some clients (notably gRPC C-core)
|
|
/// use `AF_INET6` sockets with IPv4-mapped addresses even for IPv4 connections.
|
|
///
|
|
/// Format of `/proc/net/tcp`:
|
|
/// ```text
|
|
/// sl local_address rem_address st tx_queue:rx_queue ... inode
|
|
/// 0: 0200C80A:8F4C 0100C80A:0C38 01 00000000:00000000 ... 12345
|
|
/// ```
|
|
/// - Addresses: hex IP (host byte order) `:` hex port
|
|
/// - State `01` = ESTABLISHED
|
|
/// - Inode is field index 9 (0-indexed)
|
|
#[cfg(target_os = "linux")]
|
|
fn parse_proc_net_tcp(pid: u32, connection: WorkloadProxyTcpConnection) -> Result<u64> {
|
|
// Check IPv4 first (most common), then IPv6.
|
|
for address_family in [ProcTcpAddressFamily::Ipv4, ProcTcpAddressFamily::Ipv6] {
|
|
let path = format!("/proc/{pid}/net/{}", address_family.table_name());
|
|
let Ok(content) = std::fs::read_to_string(&path) else {
|
|
continue;
|
|
};
|
|
|
|
if let Some(inode) = find_proc_net_tcp_inode(&content, address_family, connection)? {
|
|
return Ok(inode);
|
|
}
|
|
}
|
|
|
|
Err(miette::miette!(
|
|
"No ESTABLISHED TCP connection found for {connection} in /proc/{pid}/net/tcp{{,6}}"
|
|
))
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn find_proc_net_tcp_inode(
|
|
content: &str,
|
|
address_family: ProcTcpAddressFamily,
|
|
connection: WorkloadProxyTcpConnection,
|
|
) -> Result<Option<u64>> {
|
|
for line in content.lines().skip(1) {
|
|
let fields: Vec<&str> = line.split_whitespace().collect();
|
|
if fields.len() < 10 || fields[3] != "01" {
|
|
continue;
|
|
}
|
|
|
|
let Some(local_addr) = parse_proc_socket_addr(fields[1], address_family) else {
|
|
continue;
|
|
};
|
|
let Some(remote_addr) = parse_proc_socket_addr(fields[2], address_family) else {
|
|
continue;
|
|
};
|
|
|
|
if socket_addrs_match(local_addr, connection.workload)
|
|
&& socket_addrs_match(remote_addr, connection.proxy)
|
|
{
|
|
let inode: u64 = fields[9]
|
|
.parse()
|
|
.map_err(|_| miette::miette!("Failed to parse inode from {}", fields[9]))?;
|
|
if inode != 0 {
|
|
return Ok(Some(inode));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn parse_proc_socket_addr(value: &str, address_family: ProcTcpAddressFamily) -> Option<SocketAddr> {
|
|
let (address, port) = value.rsplit_once(':')?;
|
|
let port = u16::from_str_radix(port, 16).ok()?;
|
|
|
|
let ip = match address_family {
|
|
ProcTcpAddressFamily::Ipv4 => {
|
|
let address = u32::from_str_radix(address, 16).ok()?;
|
|
IpAddr::V4(Ipv4Addr::from(address.to_le_bytes()))
|
|
}
|
|
ProcTcpAddressFamily::Ipv6 => {
|
|
if address.len() != 32 {
|
|
return None;
|
|
}
|
|
let mut bytes = [0u8; 16];
|
|
for (index, chunk) in address.as_bytes().chunks_exact(8).enumerate() {
|
|
let chunk = std::str::from_utf8(chunk).ok()?;
|
|
let word = u32::from_str_radix(chunk, 16).ok()?;
|
|
bytes[index * 4..index * 4 + 4].copy_from_slice(&word.to_le_bytes());
|
|
}
|
|
IpAddr::V6(Ipv6Addr::from(bytes))
|
|
}
|
|
};
|
|
|
|
Some(SocketAddr::new(ip, port))
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn socket_addrs_match(left: SocketAddr, right: SocketAddr) -> bool {
|
|
left.port() == right.port() && normalize_ip(left.ip()) == normalize_ip(right.ip())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn normalize_ip(ip: IpAddr) -> IpAddr {
|
|
match ip {
|
|
IpAddr::V6(ipv6) => ipv6.to_ipv4_mapped().map_or(IpAddr::V6(ipv6), IpAddr::V4),
|
|
ipv4 @ IpAddr::V4(_) => ipv4,
|
|
}
|
|
}
|
|
|
|
/// Scan `/proc` to find every PID that owns a given socket inode.
|
|
///
|
|
/// First scans descendants of `entrypoint_pid` (most likely owners), then falls
|
|
/// back to scanning all of `/proc`. Requires `CAP_SYS_PTRACE` to read
|
|
/// `/proc/<pid>/fd/` for processes running as a different user.
|
|
#[cfg(target_os = "linux")]
|
|
fn find_socket_inode_owners(inode: u64, entrypoint_pid: u32) -> Result<Vec<SocketOwner>> {
|
|
let target = format!("socket:[{inode}]");
|
|
let mut owners = Vec::new();
|
|
let mut checked = HashSet::new();
|
|
|
|
// First: scan descendants of the entrypoint process
|
|
let descendants = collect_descendant_pids_with_depth(entrypoint_pid);
|
|
|
|
for descendant in &descendants {
|
|
checked.insert(descendant.pid);
|
|
if check_pid_fds(descendant.pid, &target) {
|
|
owners.push(SocketOwner {
|
|
pid: descendant.pid,
|
|
source: SocketOwnerSource::Descendant {
|
|
depth: descendant.depth,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// Fallback: scan all of /proc in case the process isn't in the tree
|
|
if let Ok(proc_dir) = std::fs::read_dir("/proc") {
|
|
let mut proc_pids = Vec::new();
|
|
for entry in proc_dir.flatten() {
|
|
let name = entry.file_name();
|
|
if let Ok(pid) = name.to_string_lossy().parse::<u32>() {
|
|
proc_pids.push(pid);
|
|
}
|
|
}
|
|
proc_pids.sort_unstable();
|
|
|
|
for pid in proc_pids {
|
|
if checked.contains(&pid) {
|
|
continue;
|
|
}
|
|
checked.insert(pid);
|
|
if check_pid_fds(pid, &target) {
|
|
owners.push(SocketOwner {
|
|
pid,
|
|
source: SocketOwnerSource::ProcFallback,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if !owners.is_empty() {
|
|
return Ok(owners);
|
|
}
|
|
|
|
Err(miette::miette!(
|
|
"No process found owning socket inode {} \
|
|
(scanned {} descendants of entrypoint PID {}). \
|
|
Hint: the container may need --cap-add=SYS_PTRACE to read /proc/<pid>/fd/ \
|
|
for processes running as a different user.",
|
|
inode,
|
|
descendants.len(),
|
|
entrypoint_pid
|
|
))
|
|
}
|
|
|
|
/// Check if a PID has an fd pointing to the given socket target string.
|
|
#[cfg(target_os = "linux")]
|
|
fn check_pid_fds(pid: u32, target: &str) -> bool {
|
|
let fd_dir = format!("/proc/{pid}/fd");
|
|
let Some(fds) = std::fs::read_dir(&fd_dir).ok() else {
|
|
return false;
|
|
};
|
|
for fd_entry in fds.flatten() {
|
|
if let Ok(link) = std::fs::read_link(fd_entry.path())
|
|
&& link.to_string_lossy() == target
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Collect all descendant PIDs of a root process using `/proc/<pid>/task/<tid>/children`.
|
|
///
|
|
/// Performs a BFS walk of the process tree. If `/proc/<pid>/task/<tid>/children`
|
|
/// is not available (requires `CONFIG_PROC_CHILDREN`), returns only the root PID.
|
|
#[cfg(all(test, target_os = "linux"))]
|
|
fn collect_descendant_pids(root_pid: u32) -> Vec<u32> {
|
|
collect_descendant_pids_with_depth(root_pid)
|
|
.into_iter()
|
|
.map(|descendant| descendant.pid)
|
|
.collect()
|
|
}
|
|
|
|
/// Collect descendant PIDs with BFS depth, deduping children reported by multiple tasks.
|
|
#[cfg(target_os = "linux")]
|
|
fn collect_descendant_pids_with_depth(root_pid: u32) -> Vec<DescendantPid> {
|
|
let mut pids = vec![DescendantPid {
|
|
pid: root_pid,
|
|
depth: 0,
|
|
}];
|
|
let mut seen = HashSet::from([root_pid]);
|
|
let mut i = 0;
|
|
while i < pids.len() {
|
|
let pid = pids[i].pid;
|
|
let child_depth = pids[i].depth + 1;
|
|
let task_dir = format!("/proc/{pid}/task");
|
|
if let Ok(tasks) = std::fs::read_dir(&task_dir) {
|
|
for task_entry in tasks.flatten() {
|
|
let children_path = task_entry.path().join("children");
|
|
if let Ok(children_str) = std::fs::read_to_string(&children_path) {
|
|
for child in children_str.split_whitespace() {
|
|
if let Ok(child_pid) = child.parse::<u32>()
|
|
&& seen.insert(child_pid)
|
|
{
|
|
pids.push(DescendantPid {
|
|
pid: child_pid,
|
|
depth: child_depth,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
pids
|
|
}
|
|
|
|
/// Compute the SHA256 hash of a file, returned as a hex-encoded string.
|
|
///
|
|
/// Used for binary integrity verification in the trust-on-first-use (TOFU)
|
|
/// model: the proxy hashes a binary on first network request and caches the
|
|
/// result. Subsequent requests from the same binary path must produce the
|
|
/// same hash, or the request is denied.
|
|
pub fn file_sha256(path: &Path) -> Result<String> {
|
|
use sha2::{Digest, Sha256};
|
|
use std::io::Read;
|
|
|
|
let start = std::time::Instant::now();
|
|
let mut file = std::fs::File::open(path)
|
|
.map_err(|e| miette::miette!("Failed to open {}: {e}", path.display()))?;
|
|
let mut hasher = Sha256::new();
|
|
let mut buf = vec![0u8; 65536].into_boxed_slice();
|
|
let mut total_read = 0u64;
|
|
loop {
|
|
let n = file
|
|
.read(&mut buf)
|
|
.map_err(|e| miette::miette!("Failed to read {}: {e}", path.display()))?;
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
total_read += n as u64;
|
|
hasher.update(&buf[..n]);
|
|
}
|
|
|
|
let hash = hasher.finalize();
|
|
debug!(
|
|
" file_sha256: {}ms size={} path={}",
|
|
start.elapsed().as_millis(),
|
|
total_read,
|
|
path.display()
|
|
);
|
|
Ok(hex::encode(hash))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::io::Write;
|
|
#[cfg(target_os = "linux")]
|
|
use std::os::unix::process::CommandExt as _;
|
|
|
|
/// Block until `/proc/<pid>/exe` points at `target`. `Command::spawn` returns
|
|
/// once the child is scheduled, not once it has completed `exec()`; on
|
|
/// contended runners the readlink can still show the parent (test harness)
|
|
/// binary for a brief window. Byte-level `starts_with` tolerates the kernel's
|
|
/// `" (deleted)"` suffix on unlinked executables.
|
|
#[cfg(target_os = "linux")]
|
|
fn wait_for_child_exec(pid: i32, target: &Path) {
|
|
use std::os::unix::ffi::OsStrExt as _;
|
|
let target_bytes = target.as_os_str().as_bytes();
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
|
loop {
|
|
if let Ok(link) = std::fs::read_link(format!("/proc/{pid}/exe"))
|
|
&& link.as_os_str().as_bytes().starts_with(target_bytes)
|
|
{
|
|
return;
|
|
}
|
|
assert!(
|
|
std::time::Instant::now() < deadline,
|
|
"child pid {pid} did not exec into {target:?} within 2s"
|
|
);
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
}
|
|
}
|
|
|
|
/// Retry `Command::spawn` on `ETXTBSY`. The kernel rejects `execve` when
|
|
/// `inode->i_writecount > 0`, and the release of that counter after the
|
|
/// writer fd is closed isn't synchronous with `close(2)` under contention —
|
|
/// so the very-next-instruction `execve` can still race it. Any other error
|
|
/// surfaces immediately.
|
|
#[cfg(target_os = "linux")]
|
|
fn spawn_retrying_on_etxtbsy(cmd: &mut std::process::Command) -> std::process::Child {
|
|
let mut attempts = 0;
|
|
loop {
|
|
match cmd.spawn() {
|
|
Ok(child) => return child,
|
|
Err(err)
|
|
if err.kind() == std::io::ErrorKind::ExecutableFileBusy && attempts < 20 =>
|
|
{
|
|
attempts += 1;
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
}
|
|
Err(err) => panic!("spawn failed after {attempts} ETXTBSY retries: {err}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn sleep_binary() -> PathBuf {
|
|
let path = std::env::var_os("PATH").expect("PATH should be set for tests");
|
|
std::env::split_paths(&path)
|
|
.map(|dir| dir.join("sleep"))
|
|
.find(|candidate| candidate.is_file())
|
|
.expect("sleep should be available on PATH")
|
|
}
|
|
|
|
#[test]
|
|
fn file_sha256_computes_correct_hash() {
|
|
let mut tmp = tempfile::NamedTempFile::new().unwrap();
|
|
tmp.write_all(b"hello world").unwrap();
|
|
tmp.flush().unwrap();
|
|
|
|
let hash = file_sha256(tmp.path()).unwrap();
|
|
// SHA256 of "hello world"
|
|
assert_eq!(
|
|
hash,
|
|
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn file_sha256_different_content_different_hash() {
|
|
let mut tmp1 = tempfile::NamedTempFile::new().unwrap();
|
|
tmp1.write_all(b"content a").unwrap();
|
|
tmp1.flush().unwrap();
|
|
|
|
let mut tmp2 = tempfile::NamedTempFile::new().unwrap();
|
|
tmp2.write_all(b"content b").unwrap();
|
|
tmp2.flush().unwrap();
|
|
|
|
let hash1 = file_sha256(tmp1.path()).unwrap();
|
|
let hash2 = file_sha256(tmp2.path()).unwrap();
|
|
assert_ne!(hash1, hash2);
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn binary_path_reads_current_process() {
|
|
let pid = std::process::id().cast_signed();
|
|
let path = binary_path(pid).unwrap();
|
|
// Should resolve to the test runner binary
|
|
assert!(path.exists());
|
|
}
|
|
|
|
/// Verify that an unlinked binary's path is returned without the
|
|
/// kernel's " (deleted)" suffix. This is the common case during a
|
|
/// `docker cp` hot-swap of the supervisor binary — before this strip,
|
|
/// callers that `stat()` the returned path get `ENOENT` and the
|
|
/// ancestor integrity check in the CONNECT proxy denies every request.
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn binary_path_strips_deleted_suffix() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
// Copy sleep to a temp path we control so we can unlink it.
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let exe_path = tmp.path().join("deleted-sleep");
|
|
std::fs::copy(sleep_binary(), &exe_path).unwrap();
|
|
std::fs::set_permissions(&exe_path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
|
|
// Spawn a child from the temp binary, then unlink it while the
|
|
// child is still running. The child keeps the exec mapping via
|
|
// `/proc/<pid>/exe`, but readlink will now return the tainted
|
|
// "<path> (deleted)" string.
|
|
let mut cmd = std::process::Command::new(&exe_path);
|
|
cmd.arg0("sleep");
|
|
cmd.arg("5");
|
|
let mut child = spawn_retrying_on_etxtbsy(&mut cmd);
|
|
let pid: i32 = child.id().cast_signed();
|
|
wait_for_child_exec(pid, &exe_path);
|
|
std::fs::remove_file(&exe_path).unwrap();
|
|
|
|
// Sanity check: the raw readlink should contain " (deleted)".
|
|
let raw = std::fs::read_link(format!("/proc/{pid}/exe"))
|
|
.unwrap()
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
assert!(
|
|
raw.ends_with(" (deleted)"),
|
|
"kernel should append ' (deleted)' to unlinked exe readlink; got {raw:?}"
|
|
);
|
|
|
|
// The public API should return the stripped path, not the tainted one.
|
|
let resolved = binary_path(pid).expect("binary_path should succeed for deleted binary");
|
|
assert_eq!(
|
|
resolved, exe_path,
|
|
"binary_path should strip the ' (deleted)' suffix"
|
|
);
|
|
let resolved_str = resolved.to_string_lossy();
|
|
assert!(
|
|
!resolved_str.contains("(deleted)"),
|
|
"stripped path must not contain '(deleted)'; got {resolved_str:?}"
|
|
);
|
|
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
}
|
|
|
|
/// A live executable whose basename literally ends with `" (deleted)"`
|
|
/// must be returned unchanged — we only strip when `stat()` reports
|
|
/// the raw readlink target missing. This guards against the trusted
|
|
/// identity source misattributing a running binary to a truncated
|
|
/// sibling path.
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn binary_path_preserves_live_deleted_basename() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
// Basename literally ends with " (deleted)" while the file is still
|
|
// on disk — a pathological but legal filename.
|
|
let exe_path = tmp.path().join("sleepy (deleted)");
|
|
std::fs::copy(sleep_binary(), &exe_path).unwrap();
|
|
std::fs::set_permissions(&exe_path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
|
|
let mut cmd = std::process::Command::new(&exe_path);
|
|
cmd.arg0("sleep");
|
|
cmd.arg("5");
|
|
let mut child = spawn_retrying_on_etxtbsy(&mut cmd);
|
|
let pid: i32 = child.id().cast_signed();
|
|
wait_for_child_exec(pid, &exe_path);
|
|
|
|
// File is still linked — binary_path must return the path unchanged,
|
|
// suffix and all.
|
|
let resolved = binary_path(pid).expect("binary_path should succeed for live binary");
|
|
assert_eq!(
|
|
resolved, exe_path,
|
|
"binary_path must NOT strip ' (deleted)' from a live executable's basename"
|
|
);
|
|
assert!(
|
|
resolved.to_string_lossy().ends_with(" (deleted)"),
|
|
"stripped path unexpectedly trimmed a real filename: {resolved:?}"
|
|
);
|
|
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
}
|
|
|
|
/// An unlinked executable whose filename contains non-UTF-8 bytes must
|
|
/// still strip exactly one kernel-added `" (deleted)"` suffix. We operate
|
|
/// on raw bytes via `OsStrExt`, so invalid UTF-8 is not a reason to skip
|
|
/// the strip and return a path that downstream `stat()` calls will reject.
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn binary_path_strips_suffix_for_non_utf8_filename() {
|
|
use std::ffi::OsString;
|
|
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
// 0xFF is not valid UTF-8. Build the filename on raw bytes.
|
|
let mut raw_name: Vec<u8> = b"badname-".to_vec();
|
|
raw_name.push(0xFF);
|
|
raw_name.extend_from_slice(b".bin");
|
|
let exe_path = tmp.path().join(OsString::from_vec(raw_name));
|
|
|
|
std::fs::copy(sleep_binary(), &exe_path).unwrap();
|
|
std::fs::set_permissions(&exe_path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
|
|
let mut cmd = std::process::Command::new(&exe_path);
|
|
cmd.arg0("sleep");
|
|
cmd.arg("5");
|
|
let mut child = spawn_retrying_on_etxtbsy(&mut cmd);
|
|
let pid: i32 = child.id().cast_signed();
|
|
wait_for_child_exec(pid, &exe_path);
|
|
std::fs::remove_file(&exe_path).unwrap();
|
|
|
|
// Sanity: raw readlink ends with " (deleted)" and is not valid UTF-8.
|
|
let raw = std::fs::read_link(format!("/proc/{pid}/exe")).unwrap();
|
|
let raw_bytes = raw.as_os_str().as_bytes();
|
|
assert!(
|
|
raw_bytes.ends_with(b" (deleted)"),
|
|
"kernel should append ' (deleted)' to unlinked exe readlink"
|
|
);
|
|
assert!(
|
|
std::str::from_utf8(raw_bytes).is_err(),
|
|
"test precondition: raw readlink must contain non-UTF-8 bytes"
|
|
);
|
|
|
|
let resolved =
|
|
binary_path(pid).expect("binary_path should succeed for non-UTF-8 unlinked path");
|
|
assert_eq!(
|
|
resolved, exe_path,
|
|
"binary_path must strip exactly one ' (deleted)' suffix for non-UTF-8 paths"
|
|
);
|
|
assert!(
|
|
!resolved.as_os_str().as_bytes().ends_with(b" (deleted)"),
|
|
"stripped path must not end with ' (deleted)'"
|
|
);
|
|
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn collect_descendants_includes_self() {
|
|
let pid = std::process::id();
|
|
let pids = collect_descendant_pids(pid);
|
|
assert!(pids.contains(&pid));
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn collect_descendants_dedupes_pids() {
|
|
let pid = std::process::id();
|
|
let pids = collect_descendant_pids(pid);
|
|
let unique = pids.iter().copied().collect::<HashSet<_>>();
|
|
assert_eq!(pids.len(), unique.len());
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn parse_proc_socket_addr_decodes_ipv4_and_mapped_ipv6() {
|
|
let ipv4 = parse_proc_socket_addr("0100007F:C350", ProcTcpAddressFamily::Ipv4).unwrap();
|
|
let mapped_ipv6 = parse_proc_socket_addr(
|
|
"0000000000000000FFFF00000100007F:C350",
|
|
ProcTcpAddressFamily::Ipv6,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(ipv4, "127.0.0.1:50000".parse().unwrap());
|
|
assert!(socket_addrs_match(ipv4, mapped_ipv6));
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn find_proc_net_tcp_inode_matches_complete_tuple() {
|
|
let content = "\
|
|
sl local_address rem_address st tx_queue:rx_queue tr:tm->when retrnsmt uid timeout inode\n\
|
|
0: 0100007F:C350 0100007F:1F91 01 00000000:00000000 00:00000000 00000000 1000 0 11111\n\
|
|
1: 0100007F:C350 0100007F:1F90 01 00000000:00000000 00:00000000 00000000 1000 0 22222\n";
|
|
let connection = WorkloadProxyTcpConnection::new(
|
|
"127.0.0.1:50000".parse().unwrap(),
|
|
"127.0.0.1:8080".parse().unwrap(),
|
|
);
|
|
|
|
let inode =
|
|
find_proc_net_tcp_inode(content, ProcTcpAddressFamily::Ipv4, connection).unwrap();
|
|
|
|
assert_eq!(inode, Some(22222));
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn resolve_tcp_peer_socket_owners_returns_all_forked_socket_holders() {
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::time::{Duration, Instant};
|
|
|
|
struct ChildGuard(libc::pid_t);
|
|
impl Drop for ChildGuard {
|
|
fn drop(&mut self) {
|
|
#[allow(unsafe_code)]
|
|
unsafe {
|
|
libc::kill(self.0, libc::SIGKILL);
|
|
libc::waitpid(self.0, std::ptr::null_mut(), 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
|
let proxy_addr = listener.local_addr().unwrap();
|
|
let stream = TcpStream::connect(proxy_addr).expect("connect");
|
|
let workload_addr = stream.local_addr().unwrap();
|
|
let connection = WorkloadProxyTcpConnection::new(workload_addr, proxy_addr);
|
|
let (_accepted, _) = listener.accept().expect("accept");
|
|
|
|
// libc/syscall FFI requires unsafe
|
|
#[allow(unsafe_code)]
|
|
let child_pid = unsafe { libc::fork() };
|
|
assert!(child_pid >= 0, "fork failed");
|
|
if child_pid == 0 {
|
|
// libc/syscall FFI requires unsafe
|
|
#[allow(unsafe_code)]
|
|
unsafe {
|
|
libc::sleep(30);
|
|
libc::_exit(0);
|
|
}
|
|
}
|
|
|
|
let _guard = ChildGuard(child_pid);
|
|
let child_pid_u32 = child_pid.cast_unsigned();
|
|
let entrypoint_pid = std::process::id();
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
let owners = loop {
|
|
let owners = resolve_tcp_peer_socket_owners(entrypoint_pid, connection)
|
|
.expect("resolve socket owners");
|
|
let owner_pids = owners
|
|
.owners
|
|
.iter()
|
|
.map(|owner| owner.pid)
|
|
.collect::<HashSet<_>>();
|
|
if owner_pids.contains(&entrypoint_pid) && owner_pids.contains(&child_pid_u32) {
|
|
break owners;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"timed out waiting for forked child to appear as a socket owner; got {owner_pids:?}"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
};
|
|
|
|
let owner_pids = owners
|
|
.owners
|
|
.iter()
|
|
.map(|owner| owner.pid)
|
|
.collect::<HashSet<_>>();
|
|
assert!(owner_pids.contains(&entrypoint_pid));
|
|
assert!(owner_pids.contains(&child_pid_u32));
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
#[allow(clippy::similar_names)]
|
|
fn read_ppid_returns_parent() {
|
|
let pid = std::process::id();
|
|
let ppid = read_ppid(pid);
|
|
assert!(ppid.is_some(), "Should be able to read PPid of self");
|
|
assert!(ppid.unwrap() > 0, "PPid should be > 0");
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn read_ppid_nonexistent_pid() {
|
|
// PID 0 is the kernel scheduler, reading its status should fail or return None
|
|
let result = read_ppid(999_999_999);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn collect_ancestor_binaries_returns_parents() {
|
|
let pid = std::process::id();
|
|
// stop_pid=1 means walk all the way up to init
|
|
let ancestors = collect_ancestor_binaries(pid, 1);
|
|
// We should have at least one ancestor (our parent process)
|
|
assert!(
|
|
!ancestors.is_empty(),
|
|
"Should have at least one ancestor binary"
|
|
);
|
|
// Each ancestor should be a real path
|
|
for path in &ancestors {
|
|
assert!(
|
|
!path.as_os_str().is_empty(),
|
|
"Ancestor path should not be empty"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
#[allow(clippy::similar_names)]
|
|
fn collect_ancestor_binaries_stops_at_stop_pid() {
|
|
let pid = std::process::id();
|
|
let ppid = read_ppid(pid).unwrap();
|
|
// If we set stop_pid to our direct parent, we should get exactly 1 ancestor
|
|
let ancestors = collect_ancestor_binaries(pid, ppid);
|
|
assert_eq!(ancestors.len(), 1, "Should stop at stop_pid (our parent)");
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn cmdline_absolute_paths_returns_paths() {
|
|
let pid = std::process::id();
|
|
let paths = cmdline_absolute_paths(pid);
|
|
// The test runner binary should appear as an absolute path in cmdline
|
|
assert!(
|
|
!paths.is_empty(),
|
|
"Should find at least one absolute path in cmdline"
|
|
);
|
|
for p in &paths {
|
|
assert!(
|
|
p.is_absolute(),
|
|
"All returned paths should be absolute: {}",
|
|
p.display()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn cmdline_absolute_paths_nonexistent_pid() {
|
|
let paths = cmdline_absolute_paths(999_999_999);
|
|
assert!(paths.is_empty());
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn collect_cmdline_paths_excludes_known() {
|
|
let pid = std::process::id();
|
|
let exe = binary_path(pid.cast_signed()).unwrap();
|
|
// When we exclude the exe path, it shouldn't appear in cmdline_paths
|
|
let paths = collect_cmdline_paths(pid, 1, std::slice::from_ref(&exe));
|
|
assert!(
|
|
!paths.contains(&exe),
|
|
"Should not contain excluded exe path"
|
|
);
|
|
}
|
|
}
|