feat: add Landlock sandbox and capability dropping for Linux (#86)

* feat: add Landlock sandbox and capability dropping for Linux

- Restrict filesystem access to /proc only after initialization
- Block TCP bind/connect on kernel 6.4+ (network sandbox)
- Drop CAP_NET_RAW after pcap handle opened
- Add --no-sandbox and --sandbox-strict CLI options
- Show privilege info on non-Linux platforms in UI
- Add SECURITY.md documentation

* fix: remove unused set_sandbox_info and hide Landlock line on non-Linux

* fix: gate SandboxInfo to Linux only to fix clippy warnings

* fix: add is_admin() function for Windows builds

The Windows build was failing because ui.rs called crate::is_admin()
but the function didn't exist. Added the implementation using Windows
Security API to check if the process has elevated privileges.

Also added Win32_Security feature to windows crate dependencies.

* fix: add is_admin() to main.rs for Windows binary crate

The previous fix added is_admin() to lib.rs but ui.rs is compiled
as part of the binary crate (main.rs), not the library crate.
Added the function to main.rs so crate::is_admin() resolves correctly.
This commit is contained in:
Marco Cadetg
2025-12-06 17:50:21 +01:00
committed by GitHub
parent dd0b7e0923
commit 5a059a3a12
16 changed files with 1023 additions and 79 deletions

View File

@@ -9,7 +9,7 @@ This document describes the technical architecture and implementation details of
- [Platform-Specific Implementations](#platform-specific-implementations)
- [Performance Considerations](#performance-considerations)
- [Dependencies](#dependencies)
- [Security Considerations](#security-considerations)
- [Security](#security)
## Multi-threaded Architecture
@@ -315,78 +315,6 @@ RustNet is built with the following key dependencies:
- **ring** - Cryptographic operations (for TLS/SNI parsing)
- **aes** - AES encryption support (for protocol detection)
## Security Considerations
## Security
### Privileged Access
RustNet requires privileged access for packet capture:
- **Raw socket access** - Intercept network traffic at low level (read-only, non-promiscuous mode)
- **BPF device access** - Load packet filters into kernel
- **eBPF programs** - Optional kernel probes for enhanced process tracking (Linux only)
**Mitigation:**
- Use Linux capabilities instead of full root (CAP_NET_RAW for packet capture, CAP_BPF+CAP_PERFMON for eBPF)
- Use macOS group-based access (`access_bpf` group)
- Audit which users have packet capture permissions
- Operates in read-only mode - cannot modify or inject packets
### Read-Only Operation
The tool only monitors traffic; it does not:
- Modify packets
- Block connections
- Inject traffic
- Alter routing tables
- Change firewall rules
### Log File Privacy
Log files may contain sensitive information:
- IP addresses and ports
- Hostnames and SNI data
- Process names and PIDs
- DNS queries and responses
**Best Practices:**
- Disable logging by default (no `--log-level` flag)
- Secure log directory permissions
- Implement log rotation and retention policies
- Review logs for sensitive data before sharing
### No External Communication
RustNet operates entirely locally:
- No telemetry or analytics
- No network requests (except monitored traffic)
- No cloud services or remote APIs
- All data stays on your system
### eBPF Security
When using experimental eBPF support:
- Requires additional kernel capabilities (`CAP_BPF`, `CAP_PERFMON`)
- eBPF programs are verified by kernel before loading
- Limited to read-only operations (no packet modification)
- Automatically falls back to procfs if eBPF fails
### Audit and Compliance
For production environments:
- **Audit logging** of who runs RustNet with packet capture privileges
- **Network monitoring policies** and compliance with data protection regulations
- **User access reviews** for privileged network access
- **Automated capability management** via configuration management systems
### Threat Model
**What RustNet protects against:**
- Unauthorized users cannot capture packets without proper permissions
- Capability-based permissions limit blast radius of compromise
**What RustNet does NOT protect against:**
- Users with packet capture permissions can see all unencrypted traffic
- Root/Administrator users can modify RustNet or capture packets directly
- Physical access to the machine enables packet capture
- Network-level attacks (RustNet is a monitoring tool, not a security appliance)
For detailed permission setup and security best practices, see [INSTALL.md](INSTALL.md).
For security documentation including Landlock sandboxing, privilege requirements, and threat model, see [SECURITY.md](SECURITY.md).

42
Cargo.lock generated
View File

@@ -203,6 +203,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "caps"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd1ddba47aba30b6a889298ad0109c3b8dcb0e8fc993b459daa7067d46f865e0"
dependencies = [
"libc",
]
[[package]]
name = "cargo-platform"
version = "0.1.9"
@@ -699,6 +708,26 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1069,6 +1098,17 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "landlock"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49fefd6652c57d68aaa32544a4c0e642929725bdc1fd929367cdeb673ab81088"
dependencies = [
"enumflags2",
"libc",
"thiserror",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1766,6 +1806,7 @@ dependencies = [
"anyhow",
"arboard",
"bytes",
"caps",
"chrono",
"clap",
"clap_complete",
@@ -1775,6 +1816,7 @@ dependencies = [
"dashmap",
"dns-lookup",
"http_req",
"landlock",
"libbpf-cargo",
"libbpf-rs",
"libc",

View File

@@ -48,6 +48,8 @@ procfs = "0.18"
libbpf-rs = { version = "0.25", optional = true }
bytes = { version = "1.11", optional = true }
libc = { version = "0.2", optional = true }
landlock = { version = "0.4", optional = true }
caps = { version = "0.5", optional = true }
[target.'cfg(any(target_os = "macos", target_os = "freebsd"))'.dependencies]
libc = "0.2"
@@ -58,6 +60,7 @@ windows = { version = "0.62", features = [
"Win32_NetworkManagement_IpHelper",
"Win32_NetworkManagement_Ndis",
"Win32_Networking_WinSock",
"Win32_Security",
"Win32_System_LibraryLoader",
"Win32_System_Threading",
] }
@@ -80,6 +83,7 @@ windows = { version = "0.62", features = [
"Win32_NetworkManagement_IpHelper",
"Win32_NetworkManagement_Ndis",
"Win32_Networking_WinSock",
"Win32_Security",
"Win32_System_LibraryLoader",
"Win32_System_Threading",
] }
@@ -91,9 +95,11 @@ libbpf-cargo = { version = "0.25", optional = true }
# eBPF is enabled by default for enhanced performance on Linux.
# On non-Linux platforms, this feature has no effect as all eBPF code
# and dependencies are Linux-specific (guarded by target_os checks).
default = ["ebpf"]
# Landlock provides security sandboxing on Linux 5.13+.
default = ["ebpf", "landlock"]
linux-default = ["ebpf"] # Deprecated: kept for backwards compatibility
ebpf = ["libbpf-rs", "bytes", "libc", "dep:libbpf-cargo"]
landlock = ["dep:landlock", "dep:caps"]
# Minimal cross configuration to override dependency conflicts
[workspace.metadata.cross.build.env]

View File

@@ -26,6 +26,7 @@ A cross-platform network monitoring tool built with Rust. RustNet provides real-
- **Terminal User Interface**: Beautiful TUI built with ratatui with adjustable column widths
- **Multi-threaded Processing**: Concurrent packet processing for high performance
- **Optional Logging**: Detailed logging with configurable log levels (disabled by default)
- **Security Sandboxing**: Landlock-based filesystem/network restrictions on Linux 5.13+ (see [SECURITY.md](SECURITY.md))
<details>
<summary><b>eBPF Enhanced Process Identification (Linux Default)</b></summary>
@@ -232,6 +233,7 @@ See [USAGE.md](USAGE.md) for complete timeout details.
- **[INSTALL.md](INSTALL.md)** - Detailed installation instructions for all platforms, permission setup, and troubleshooting
- **[USAGE.md](USAGE.md)** - Complete usage guide including command-line options, filtering, sorting, and logging
- **[SECURITY.md](SECURITY.md)** - Security features including Landlock sandboxing and privilege management
- **[ARCHITECTURE.md](ARCHITECTURE.md)** - Technical architecture, platform implementations, and performance details
- **[PROFILING.md](PROFILING.md)** - Performance profiling guide with flamegraph setup and optimization tips
- **[ROADMAP.md](ROADMAP.md)** - Planned features and future improvements

157
SECURITY.md Normal file
View File

@@ -0,0 +1,157 @@
# Security
RustNet processes untrusted network data, making defense-in-depth security critical. This document describes the security measures implemented.
## Table of Contents
- [Landlock Sandboxing (Linux)](#landlock-sandboxing-linux)
- [Privilege Requirements](#privilege-requirements)
- [Read-Only Operation](#read-only-operation)
- [No External Communication](#no-external-communication)
- [Log File Privacy](#log-file-privacy)
- [eBPF Security](#ebpf-security)
- [Threat Model](#threat-model)
- [Audit and Compliance](#audit-and-compliance)
- [Reporting Security Issues](#reporting-security-issues)
## Landlock Sandboxing (Linux)
On Linux 5.13+, RustNet uses [Landlock](https://landlock.io/) to restrict its own capabilities after initialization. This limits the damage if a vulnerability in packet parsing is exploited.
### What Gets Restricted
| Restriction | Kernel Version | Description |
|-------------|----------------|-------------|
| Filesystem | 5.13+ | Only `/proc` readable (for process identification) |
| Network | 6.4+ | TCP bind/connect blocked (RustNet is passive) |
| Capabilities | Any | `CAP_NET_RAW` dropped after pcap socket opened |
### How It Works
1. **Initialization phase**: RustNet loads eBPF programs, opens packet capture handles, and creates log files
2. **Sandbox application**: After init, Landlock restricts filesystem and network access
3. **Capability drop**: `CAP_NET_RAW` is removed from the process (existing pcap socket remains valid)
### Security Benefits
If an attacker exploits a vulnerability in DPI/packet parsing:
- Cannot read arbitrary files (credentials, configs, etc.)
- Cannot write to filesystem (except configured log paths)
- Cannot make outbound TCP connections (data exfiltration blocked)
- Cannot bind TCP ports (reverse shell blocked)
- Cannot create new raw sockets (capability dropped)
### CLI Options
```
--no-sandbox Disable Landlock sandboxing
--sandbox-strict Require full sandbox enforcement or exit
```
### Graceful Degradation
- **Kernel < 5.13**: Sandboxing skipped, warning logged
- **Kernel 5.13-6.3**: Filesystem restrictions only
- **Kernel 6.4+**: Full filesystem + network restrictions
- **Docker**: May be blocked by seccomp; app continues normally
## Privilege Requirements
RustNet requires privileged access for packet capture:
| Platform | Requirement |
|----------|-------------|
| Linux | `CAP_NET_RAW` capability or root |
| macOS | Root or BPF group membership (`access_bpf` group) |
| Windows | Administrator (for Npcap) |
| FreeBSD | Root or BPF device access |
### Why Privileges Are Needed
- **Raw socket access** - Intercept network traffic at low level (read-only, non-promiscuous mode)
- **BPF device access** - Load packet filters into kernel
- **eBPF programs** - Optional kernel probes for enhanced process tracking (Linux only)
### Recommended: Capability-based Execution (Linux)
Instead of running as root, grant only the required capabilities:
```bash
# Modern Linux (5.8+): packet capture + eBPF
sudo setcap 'cap_net_raw,cap_bpf,cap_perfmon=eip' $(which rustnet)
# Legacy Linux (pre-5.8): packet capture + eBPF
sudo setcap 'cap_net_raw,cap_sys_admin=eip' $(which rustnet)
# Packet capture only (no eBPF process detection)
sudo setcap cap_net_raw=eip $(which rustnet)
```
After sandbox application, `CAP_NET_RAW` is dropped - the process retains only the minimum privileges needed.
## Read-Only Operation
RustNet only monitors traffic; it does not:
- Modify packets
- Block connections
- Inject traffic
- Alter routing tables
- Change firewall rules
The packet capture is opened in non-promiscuous, read-only mode.
## No External Communication
RustNet operates entirely locally:
- No telemetry or analytics
- No network requests (except monitored traffic)
- No cloud services or remote APIs
- All data stays on your system
## Log File Privacy
Log files may contain sensitive information:
- IP addresses and ports
- Hostnames and SNI data
- Process names and PIDs
- DNS queries and responses
**Best Practices:**
- Disable logging by default (no `--log-level` flag)
- Secure log directory permissions
- Implement log rotation and retention policies
- Review logs for sensitive data before sharing
## eBPF Security
When using eBPF for enhanced process detection (default on Linux):
- Requires additional kernel capabilities (`CAP_BPF`, `CAP_PERFMON`)
- eBPF programs are verified by kernel before loading
- Limited to read-only operations (no packet modification)
- Automatically falls back to procfs if eBPF fails
## Threat Model
**What RustNet protects against:**
- Unauthorized users cannot capture packets without proper permissions
- Capability-based permissions limit blast radius of compromise
- Landlock sandbox contains potential exploitation
**What RustNet does NOT protect against:**
- Users with packet capture permissions can see all unencrypted traffic
- Root/Administrator users can modify RustNet or capture packets directly
- Physical access to the machine enables packet capture
- Network-level attacks (RustNet is a monitoring tool, not a security appliance)
## Audit and Compliance
For production environments:
- **Audit logging** of who runs RustNet with packet capture privileges
- **Network monitoring policies** and compliance with data protection regulations
- **User access reviews** for privileged network access
- **Automated capability management** via configuration management systems
## Reporting Security Issues
Please report security vulnerabilities via GitHub Issues or contact the maintainers directly.

View File

@@ -78,6 +78,9 @@ Options:
-r, --refresh-interval <MILLISECONDS> UI refresh interval in milliseconds [default: 1000]
--no-dpi Disable deep packet inspection
-l, --log-level <LEVEL> Set the log level (if not provided, no logging will be enabled)
--json-log <FILE> Enable JSON logging of connection events to specified file
--no-sandbox Disable Landlock sandboxing (Linux only)
--sandbox-strict Require full sandbox enforcement or exit (Linux only)
-h, --help Print help
-V, --version Print version
```

View File

@@ -36,6 +36,22 @@ use crate::network::platform::WindowsStatsProvider as PlatformStatsProvider;
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
/// Sandbox status information for UI display
#[cfg(target_os = "linux")]
#[derive(Debug, Clone, Default)]
pub struct SandboxInfo {
/// Overall status description
pub status: String,
/// Whether CAP_NET_RAW was dropped
pub cap_dropped: bool,
/// Whether Landlock is available on this kernel
pub landlock_available: bool,
/// Whether Landlock filesystem restrictions are applied
pub fs_restricted: bool,
/// Whether Landlock network restrictions are applied
pub net_restricted: bool,
}
/// Global QUIC connection ID to connection key mapping
/// This allows tracking QUIC connections across connection ID changes
static QUIC_CONNECTION_MAPPING: LazyLock<Mutex<HashMap<String, String>>> =
@@ -207,6 +223,10 @@ pub struct App {
/// Interface rates (per-second rates)
interface_rates: Arc<DashMap<String, InterfaceRates>>,
/// Sandbox status (Linux Landlock)
#[cfg(target_os = "linux")]
sandbox_info: Arc<RwLock<SandboxInfo>>,
}
impl App {
@@ -231,6 +251,8 @@ impl App {
process_detection_method: Arc::new(RwLock::new(String::from("initializing..."))),
interface_stats: Arc::new(DashMap::new()),
interface_rates: Arc::new(DashMap::new()),
#[cfg(target_os = "linux")]
sandbox_info: Arc::new(RwLock::new(SandboxInfo::default())),
})
}
@@ -999,6 +1021,23 @@ impl App {
.unwrap_or_else(|_| String::from("unknown"))
}
/// Get sandbox status information
#[cfg(target_os = "linux")]
pub fn get_sandbox_info(&self) -> SandboxInfo {
self.sandbox_info
.read()
.map(|s| s.clone())
.unwrap_or_default()
}
/// Set sandbox status information
#[cfg(target_os = "linux")]
pub fn set_sandbox_info(&self, info: SandboxInfo) {
if let Ok(mut guard) = self.sandbox_info.write() {
*guard = info;
}
}
/// Get link layer information for the current interface
/// Returns (link_layer_type_name, is_tunnel)
pub fn get_link_layer_info(&self) -> (String, bool) {

View File

@@ -56,4 +56,17 @@ pub fn build_cli() -> Command {
.help("Enable JSON logging of connection events to specified file")
.required(false),
)
.arg(
Arg::new("no-sandbox")
.long("no-sandbox")
.help("Disable Landlock sandboxing (Linux only)")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("sandbox-strict")
.long("sandbox-strict")
.help("Require full sandbox enforcement or exit (Linux only)")
.action(clap::ArgAction::SetTrue)
.conflicts_with("no-sandbox"),
)
}

View File

@@ -7,3 +7,41 @@ pub mod config;
pub mod filter;
pub mod network;
pub mod ui;
/// Check if the current process is running with Administrator privileges (Windows only)
#[cfg(target_os = "windows")]
pub fn is_admin() -> bool {
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
unsafe {
let mut token_handle = HANDLE::default();
// Open the process token
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token_handle).is_err() {
return false;
}
let mut elevation = TOKEN_ELEVATION::default();
let mut return_length = 0u32;
// Get the elevation information
let result = GetTokenInformation(
token_handle,
TokenElevation,
Some(&mut elevation as *mut _ as *mut _),
std::mem::size_of::<TOKEN_ELEVATION>() as u32,
&mut return_length,
);
// Close the token handle
let _ = windows::Win32::Foundation::CloseHandle(token_handle);
if result.is_err() {
return false;
}
elevation.TokenIsElevated != 0
}
}

View File

@@ -73,10 +73,88 @@ fn main() -> Result<()> {
info!("Terminal UI initialized");
// Create and start the application
let mut app = app::App::new(config)?;
let mut app = app::App::new(config.clone())?;
app.start()?;
info!("Application started");
// Apply Landlock sandbox (Linux only)
// This must be done AFTER app.start() because:
// - eBPF programs need to be loaded first (access to /sys/kernel/btf)
// - Packet capture handles need to be opened first (access to /dev)
// - Log files need to be created first
#[cfg(all(target_os = "linux", feature = "landlock"))]
{
use network::platform::sandbox::{apply_sandbox, SandboxConfig, SandboxMode, SandboxStatus};
use std::path::PathBuf;
let sandbox_mode = if matches.get_flag("no-sandbox") {
SandboxMode::Disabled
} else if matches.get_flag("sandbox-strict") {
SandboxMode::Strict
} else {
SandboxMode::BestEffort
};
let mut write_paths = Vec::new();
// Add logs directory if logging is enabled
if matches.get_one::<String>("log-level").is_some() {
write_paths.push(PathBuf::from("logs"));
}
// Add JSON log path if specified
if let Some(json_log_path) = &config.json_log_file {
write_paths.push(PathBuf::from(json_log_path));
}
let sandbox_config = SandboxConfig {
mode: sandbox_mode,
block_network: true, // RustNet is passive, doesn't need TCP
write_paths,
};
match apply_sandbox(&sandbox_config) {
Ok(result) => {
// Update UI with sandbox status
let status_str = match result.status {
SandboxStatus::FullyEnforced => {
info!("Sandbox fully enforced: {}", result.message);
"Fully enforced"
}
SandboxStatus::PartiallyEnforced => {
info!("Sandbox partially enforced: {}", result.message);
"Partially enforced"
}
SandboxStatus::NotApplied => {
debug!("Sandbox not applied: {}", result.message);
"Not applied"
}
};
app.set_sandbox_info(app::SandboxInfo {
status: status_str.to_string(),
cap_dropped: result.cap_net_raw_dropped,
landlock_available: result.landlock_available,
fs_restricted: result.landlock_fs_applied,
net_restricted: result.landlock_net_applied,
});
}
Err(e) => {
if sandbox_mode == SandboxMode::Strict {
return Err(e.context("Sandbox enforcement required but failed"));
}
info!("Sandbox application error (non-strict mode): {}", e);
app.set_sandbox_info(app::SandboxInfo {
status: "Error".to_string(),
cap_dropped: false,
landlock_available: false,
fs_restricted: false,
net_restricted: false,
});
}
}
}
// Run the UI loop
let res = run_ui_loop(&mut terminal, &app);
@@ -654,3 +732,41 @@ fn check_dll_available(dll_name: &str) -> bool {
}
}
}
/// Check if the current process is running with Administrator privileges (Windows only)
#[cfg(target_os = "windows")]
fn is_admin() -> bool {
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
unsafe {
let mut token_handle = HANDLE::default();
// Open the process token
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token_handle).is_err() {
return false;
}
let mut elevation = TOKEN_ELEVATION::default();
let mut return_length = 0u32;
// Get the elevation information
let result = GetTokenInformation(
token_handle,
TokenElevation,
Some(&mut elevation as *mut _ as *mut _),
std::mem::size_of::<TOKEN_ELEVATION>() as u32,
&mut return_length,
);
// Close the token handle
let _ = windows::Win32::Foundation::CloseHandle(token_handle);
if result.is_err() {
return false;
}
elevation.TokenIsElevated != 0
}
}

View File

@@ -8,6 +8,9 @@ pub mod ebpf;
#[cfg(feature = "ebpf")]
mod enhanced;
#[cfg(feature = "landlock")]
pub mod sandbox;
pub use interface_stats::LinuxStatsProvider;
pub use process::LinuxProcessLookup;

View File

@@ -0,0 +1,85 @@
//! Linux capability management
//!
//! Handles dropping capabilities after they are no longer needed.
//! This follows the principle of least privilege - capabilities are
//! only held while necessary for initialization.
//!
//! # CAP_NET_RAW
//!
//! CAP_NET_RAW is required to create raw sockets for packet capture.
//! However, once the pcap handle is opened, the capability is no longer
//! needed and can be safely dropped. This prevents an attacker from
//! creating new raw sockets if they gain code execution.
//!
//! This is the same pattern used by `ping` and other network utilities.
use anyhow::{Context, Result};
use caps::{CapSet, Capability};
/// Drop CAP_NET_RAW from the current process
///
/// This removes CAP_NET_RAW from both the effective and permitted
/// capability sets. The existing pcap socket file descriptor remains
/// valid since the capability was only needed to create it.
///
/// # Returns
///
/// - `Ok(true)` if CAP_NET_RAW was dropped
/// - `Ok(false)` if CAP_NET_RAW was not held (nothing to drop)
/// - `Err` if dropping failed
pub fn drop_cap_net_raw() -> Result<bool> {
// Check if we have CAP_NET_RAW in the effective set
let has_cap = caps::has_cap(None, CapSet::Effective, Capability::CAP_NET_RAW)
.context("Failed to check CAP_NET_RAW in effective set")?;
if !has_cap {
log::debug!("CAP_NET_RAW not in effective set, nothing to drop");
return Ok(false);
}
// Drop from effective set first
caps::drop(None, CapSet::Effective, Capability::CAP_NET_RAW)
.context("Failed to drop CAP_NET_RAW from effective set")?;
log::debug!("Dropped CAP_NET_RAW from effective set");
// Also drop from permitted set to prevent re-acquiring
// This is optional but provides stronger security
if caps::has_cap(None, CapSet::Permitted, Capability::CAP_NET_RAW)
.unwrap_or(false)
{
if let Err(e) = caps::drop(None, CapSet::Permitted, Capability::CAP_NET_RAW) {
// Not fatal - we already dropped from effective
log::warn!("Could not drop CAP_NET_RAW from permitted set: {}", e);
} else {
log::debug!("Dropped CAP_NET_RAW from permitted set");
}
}
Ok(true)
}
/// Check if CAP_NET_RAW is currently held in the effective set
pub fn has_cap_net_raw() -> bool {
caps::has_cap(None, CapSet::Effective, Capability::CAP_NET_RAW).unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_has_cap_net_raw_does_not_panic() {
// This should not panic regardless of capabilities
let _ = has_cap_net_raw();
}
#[test]
fn test_drop_cap_net_raw_without_capability() {
// If we don't have CAP_NET_RAW, drop should return Ok(false)
// This test may behave differently depending on test environment
let result = drop_cap_net_raw();
// Should not error, just return whether it was dropped
assert!(result.is_ok());
}
}

View File

@@ -0,0 +1,209 @@
//! Landlock sandboxing implementation
//!
//! Landlock is a Linux Security Module (LSM) that allows unprivileged
//! processes to restrict their own ambient rights (filesystem access,
//! network access).
//!
//! # Kernel Requirements
//!
//! - Linux 5.13+: Filesystem access control (ABI v1)
//! - Linux 5.19+: File referring/REFER (ABI v2)
//! - Linux 6.2+: Truncate control (ABI v3)
//! - Linux 6.4+: Network TCP bind/connect (ABI v4)
//!
//! # What We Restrict
//!
//! - Filesystem: Only allow read access to `/proc` (needed for process lookup)
//! - Filesystem: Only allow write access to specified paths (logs)
//! - Network: Block TCP bind and connect (RustNet is passive)
use anyhow::{Context, Result};
use landlock::{
Access, AccessFs, AccessNet, BitFlags, LandlockStatus, PathBeneath, PathFd, Ruleset,
RulesetAttr, RulesetCreatedAttr, RulesetStatus, ABI,
};
use std::path::Path;
use super::{SandboxConfig, SandboxMode};
/// Result of Landlock application
pub struct LandlockResult {
/// Whether filesystem restrictions were applied
pub fs_applied: bool,
/// Whether network restrictions were applied
pub net_applied: bool,
/// Human-readable message
pub message: String,
}
/// Check if Landlock is available by attempting a test restriction
/// Note: This actually attempts to create a minimal ruleset to check
pub fn is_available() -> bool {
// Try to create a minimal ruleset - this will fail if Landlock isn't available
Ruleset::default()
.handle_access(AccessFs::Execute)
.and_then(|r| r.create())
.is_ok()
}
/// Apply Landlock restrictions based on configuration
pub fn apply_landlock(config: &SandboxConfig) -> Result<LandlockResult> {
// Check if disabled
if config.mode == SandboxMode::Disabled {
return Ok(LandlockResult {
fs_applied: false,
net_applied: false,
message: "Sandbox disabled".to_string(),
});
}
// Use ABI V4 which includes network support
// The crate will automatically handle compatibility with older kernels
let abi = ABI::V4;
// Build filesystem access rights for reading
// We need read access to /proc for process identification
let read_access = AccessFs::from_read(abi);
// Build filesystem access rights for writing
let write_access = AccessFs::from_all(abi);
// Start building the ruleset
let ruleset = Ruleset::default()
.handle_access(AccessFs::from_all(abi))
.context("Failed to handle filesystem access")?;
// Add network handling if requested
// This will be ignored on kernels that don't support it (< 6.4)
let ruleset = if config.block_network {
// Try to handle network access - ignore errors for older kernels
match ruleset
.handle_access(AccessNet::BindTcp)
.and_then(|r| r.handle_access(AccessNet::ConnectTcp))
{
Ok(r) => r,
Err(_) => {
// Network access handling failed, recreate without network
log::debug!("Network access handling not supported, continuing without");
Ruleset::default()
.handle_access(AccessFs::from_all(abi))
.context("Failed to handle filesystem access")?
}
}
} else {
ruleset
};
// Create the ruleset
let mut ruleset_created = ruleset.create().context("Failed to create Landlock ruleset")?;
// Add rule for /proc (read-only)
// This is required for process identification via procfs
if let Err(e) = add_path_rule(&mut ruleset_created, "/proc", read_access) {
log::warn!("Could not add /proc rule: {}", e);
}
// Add rules for write paths (logs, etc.)
for path in &config.write_paths {
if path.exists() {
if let Err(e) = add_path_rule(&mut ruleset_created, path, write_access) {
log::warn!("Could not add write rule for {:?}: {}", path, e);
}
} else {
// For paths that don't exist yet, try to add rule for parent directory
if let Some(parent) = path.parent()
&& parent.exists()
&& let Err(e) = add_path_rule(&mut ruleset_created, parent, write_access)
{
log::warn!("Could not add write rule for parent {:?}: {}", parent, e);
}
}
}
// If network blocking is enabled, we DON'T add any NetPort rules
// This means all TCP bind/connect operations are blocked by default
// (We're handling the access types but not allowing any ports)
// Apply the restrictions
let status = ruleset_created
.restrict_self()
.context("Failed to apply Landlock restrictions")?;
// Determine what was actually applied based on the returned status
let fs_applied = matches!(
status.ruleset,
RulesetStatus::FullyEnforced | RulesetStatus::PartiallyEnforced
);
// Check if network restrictions were actually applied
let net_applied = config.block_network
&& fs_applied
&& matches!(
status.landlock,
LandlockStatus::Available { effective_abi, .. } if effective_abi >= ABI::V4
);
let message = match (&status.ruleset, &status.landlock) {
(RulesetStatus::FullyEnforced, _) => "Landlock fully enforced".to_string(),
(RulesetStatus::PartiallyEnforced, _) => "Landlock partially enforced".to_string(),
(RulesetStatus::NotEnforced, LandlockStatus::NotEnabled) => {
"Landlock disabled in kernel".to_string()
}
(RulesetStatus::NotEnforced, LandlockStatus::NotImplemented) => {
"Landlock not implemented in kernel".to_string()
}
(RulesetStatus::NotEnforced, _) => "Landlock not enforced".to_string(),
};
log::info!("Landlock: {}", message);
log::info!(
"Landlock: filesystem={}, network={}, landlock_status={:?}",
fs_applied,
net_applied,
status.landlock
);
Ok(LandlockResult {
fs_applied,
net_applied,
message,
})
}
/// Add a rule for a path with the specified access rights
fn add_path_rule(
ruleset: &mut landlock::RulesetCreated,
path: impl AsRef<Path>,
access: BitFlags<AccessFs>,
) -> Result<()> {
let path = path.as_ref();
let fd = PathFd::new(path).with_context(|| format!("Failed to open {:?}", path))?;
ruleset
.add_rule(PathBeneath::new(fd, access))
.with_context(|| format!("Failed to add rule for {:?}", path))?;
log::debug!("Landlock: Added rule for {:?}", path);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_available_does_not_panic() {
// Should not panic regardless of kernel support
let _ = is_available();
}
#[test]
fn test_disabled_mode() {
let config = SandboxConfig {
mode: SandboxMode::Disabled,
block_network: true,
write_paths: vec![],
};
let result = apply_landlock(&config).unwrap();
assert!(!result.fs_applied);
assert!(!result.net_applied);
}
}

View File

@@ -0,0 +1,216 @@
//! Linux sandboxing support
//!
//! Provides Landlock-based sandboxing for restricting process capabilities
//! after initialization is complete. This is a defense-in-depth measure
//! that limits damage if the application (processing untrusted network data)
//! is compromised.
//!
//! # Security Model
//!
//! After sandboxing is applied:
//! - Filesystem: Only `/proc` readable, specified write paths writable
//! - Network: TCP bind/connect blocked (kernel 6.4+)
//! - Capabilities: CAP_NET_RAW dropped (cannot create new raw sockets)
//!
//! # Compatibility
//!
//! - Kernel 5.13+: Filesystem sandboxing
//! - Kernel 6.4+: Network sandboxing (TCP bind/connect)
//! - Older kernels: Graceful degradation (sandbox not applied)
#[cfg(feature = "landlock")]
mod capabilities;
#[cfg(feature = "landlock")]
mod landlock;
use std::path::PathBuf;
/// Sandbox enforcement mode
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SandboxMode {
/// Apply sandbox with best-effort (graceful degradation on older kernels)
#[default]
BestEffort,
/// Require full sandbox enforcement or fail
Strict,
/// Disable sandboxing entirely
Disabled,
}
/// Configuration for the sandbox
#[derive(Debug, Clone, Default)]
pub struct SandboxConfig {
/// Sandbox enforcement mode
pub mode: SandboxMode,
/// Block TCP bind/connect (recommended for passive monitors)
pub block_network: bool,
/// Paths that need write access (e.g., log files)
pub write_paths: Vec<PathBuf>,
}
/// Result of sandbox application
#[derive(Debug, Clone)]
pub struct SandboxResult {
/// Overall status
pub status: SandboxStatus,
/// Human-readable message
pub message: String,
/// Whether CAP_NET_RAW was dropped
pub cap_net_raw_dropped: bool,
/// Whether Landlock is available on this kernel
pub landlock_available: bool,
/// Whether Landlock filesystem restrictions were applied
pub landlock_fs_applied: bool,
/// Whether Landlock network restrictions were applied
pub landlock_net_applied: bool,
}
/// Status of sandbox application
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxStatus {
/// Sandbox fully enforced (all requested restrictions applied)
FullyEnforced,
/// Sandbox partially enforced (some features unavailable on this kernel)
PartiallyEnforced,
/// Sandbox not applied (disabled, or kernel doesn't support)
NotApplied,
}
/// Apply the sandbox with the given configuration
///
/// This should be called AFTER:
/// - eBPF programs are loaded
/// - Packet capture handles are opened
/// - Log files are created
///
/// # Returns
///
/// Returns `Ok(SandboxResult)` with details about what was applied.
/// In `Strict` mode, returns `Err` if full sandboxing cannot be achieved.
#[cfg(feature = "landlock")]
pub fn apply_sandbox(config: &SandboxConfig) -> anyhow::Result<SandboxResult> {
use anyhow::Context;
// Check Landlock availability upfront
let landlock_available = landlock::is_available();
// Handle disabled mode
if config.mode == SandboxMode::Disabled {
log::info!("Sandbox disabled by configuration");
return Ok(SandboxResult {
status: SandboxStatus::NotApplied,
message: "Sandbox disabled by configuration".to_string(),
cap_net_raw_dropped: false,
landlock_available,
landlock_fs_applied: false,
landlock_net_applied: false,
});
}
let mut result = SandboxResult {
status: SandboxStatus::FullyEnforced,
message: String::new(),
cap_net_raw_dropped: false,
landlock_available,
landlock_fs_applied: false,
landlock_net_applied: false,
};
let mut messages = Vec::new();
// Step 1: Drop CAP_NET_RAW capability
// This prevents creating new raw sockets for exfiltration
match capabilities::drop_cap_net_raw() {
Ok(dropped) => {
if dropped {
// Verify the drop actually worked
if capabilities::has_cap_net_raw() {
log::error!("CAP_NET_RAW drop reported success but capability still present!");
result.cap_net_raw_dropped = false;
messages.push("CAP_NET_RAW drop verification failed".to_string());
} else {
result.cap_net_raw_dropped = true;
messages.push("CAP_NET_RAW dropped".to_string());
log::info!("Dropped CAP_NET_RAW capability (verified)");
}
} else {
messages.push("CAP_NET_RAW was not held".to_string());
log::debug!("CAP_NET_RAW was not in effective set");
}
}
Err(e) => {
let msg = format!("Failed to drop CAP_NET_RAW: {}", e);
log::warn!("{}", msg);
messages.push(msg);
if config.mode == SandboxMode::Strict {
return Err(e).context("Strict mode requires CAP_NET_RAW to be droppable");
}
result.status = SandboxStatus::PartiallyEnforced;
}
}
// Step 2: Apply Landlock restrictions
match landlock::apply_landlock(config) {
Ok(ll_result) => {
result.landlock_fs_applied = ll_result.fs_applied;
result.landlock_net_applied = ll_result.net_applied;
if ll_result.fs_applied {
messages.push("Landlock filesystem restrictions applied".to_string());
}
if ll_result.net_applied {
messages.push("Landlock network restrictions applied".to_string());
}
if !ll_result.fs_applied && !ll_result.net_applied {
messages.push(format!("Landlock not applied: {}", ll_result.message));
if config.mode == SandboxMode::Strict {
return Err(anyhow::anyhow!(
"Strict mode requires Landlock support: {}",
ll_result.message
));
}
if result.status == SandboxStatus::FullyEnforced {
result.status = SandboxStatus::PartiallyEnforced;
}
} else if !ll_result.net_applied && config.block_network {
// Filesystem applied but network not available
if result.status == SandboxStatus::FullyEnforced {
result.status = SandboxStatus::PartiallyEnforced;
}
}
}
Err(e) => {
let msg = format!("Landlock application failed: {}", e);
log::warn!("{}", msg);
messages.push(msg);
if config.mode == SandboxMode::Strict {
return Err(e).context("Strict mode requires Landlock");
}
result.status = SandboxStatus::PartiallyEnforced;
}
}
// Determine final status
if !result.cap_net_raw_dropped && !result.landlock_fs_applied && !result.landlock_net_applied {
result.status = SandboxStatus::NotApplied;
}
result.message = messages.join("; ");
log::info!("Sandbox result: {:?} - {}", result.status, result.message);
Ok(result)
}
/// Stub implementation when Landlock feature is disabled
#[cfg(not(feature = "landlock"))]
pub fn apply_sandbox(_config: &SandboxConfig) -> anyhow::Result<SandboxResult> {
log::debug!("Landlock feature not compiled in");
Ok(SandboxResult {
status: SandboxStatus::NotApplied,
message: "Landlock feature not compiled in".to_string(),
cap_net_raw_dropped: false,
landlock_available: false,
landlock_fs_applied: false,
landlock_net_applied: false,
})
}

View File

@@ -24,6 +24,8 @@ mod windows;
pub use freebsd::{FreeBSDProcessLookup, FreeBSDStatsProvider, create_process_lookup};
#[cfg(target_os = "linux")]
pub use linux::{LinuxStatsProvider, create_process_lookup};
#[cfg(all(target_os = "linux", feature = "landlock"))]
pub use linux::sandbox;
#[cfg(target_os = "macos")]
pub use macos::{MacOSStatsProvider, create_process_lookup};
#[cfg(target_os = "windows")]

View File

@@ -710,6 +710,7 @@ fn draw_stats_panel(
Constraint::Length(10), // Connection stats (increased for interface line)
Constraint::Length(5), // Traffic stats
Constraint::Length(7), // Network stats (TCP analytics + header)
Constraint::Length(4), // Security stats (sandbox)
Constraint::Min(0), // Interface stats
])
.split(area);
@@ -845,6 +846,90 @@ fn draw_stats_panel(
.style(Style::default());
f.render_widget(network_stats, chunks[2]);
// Security statistics (sandbox) - Linux only shows Landlock info
#[cfg(target_os = "linux")]
let security_text: Vec<Line> = {
let sandbox_info = app.get_sandbox_info();
let status_style = match sandbox_info.status.as_str() {
"Fully enforced" => Style::default().fg(Color::Green),
"Partially enforced" => Style::default().fg(Color::Yellow),
"Not applied" | "Error" => Style::default().fg(Color::Red),
_ => Style::default(),
};
let mut features = Vec::new();
if sandbox_info.cap_dropped {
features.push("CAP_NET_RAW dropped");
}
if sandbox_info.fs_restricted {
features.push("FS restricted");
}
if sandbox_info.net_restricted {
features.push("Net blocked");
}
let available_indicator = if sandbox_info.landlock_available {
Span::styled(" [kernel supported]", Style::default().fg(Color::DarkGray))
} else {
Span::styled(" [kernel unsupported]", Style::default().fg(Color::DarkGray))
};
vec![
Line::from(vec![
Span::raw("Landlock: "),
Span::styled(sandbox_info.status.clone(), status_style),
available_indicator,
]),
Line::from(Span::styled(
if features.is_empty() {
"No restrictions active".to_string()
} else {
features.join(", ")
},
Style::default().fg(Color::Gray),
)),
]
};
// Non-Linux platforms: show privilege info without mentioning Landlock
#[cfg(all(unix, not(target_os = "linux")))]
let security_text: Vec<Line> = {
let uid = unsafe { libc::geteuid() };
let is_root = uid == 0;
if is_root {
vec![Line::from(Span::styled(
"Running as root (UID 0)",
Style::default().fg(Color::Yellow),
))]
} else {
vec![Line::from(Span::styled(
format!("Running as UID {}", uid),
Style::default().fg(Color::Green),
))]
}
};
#[cfg(target_os = "windows")]
let security_text: Vec<Line> = {
let is_elevated = crate::is_admin();
if is_elevated {
vec![Line::from(Span::styled(
"Running as Administrator",
Style::default().fg(Color::Yellow),
))]
} else {
vec![Line::from(Span::styled(
"Running as standard user",
Style::default().fg(Color::Green),
))]
}
};
let security_stats = Paragraph::new(security_text)
.block(Block::default().borders(Borders::ALL).title("Security"))
.style(Style::default());
f.render_widget(security_stats, chunks[3]);
// Interface statistics
let all_interface_stats = app.get_interface_stats();
let interface_rates = app.get_interface_rates();
@@ -885,7 +970,7 @@ fn draw_stats_panel(
// Calculate how many interfaces can fit in the available space
// Each interface takes 2 lines, and we need 2 lines for borders
// Reserve 1 line for the "... N more" message if needed
let available_height = chunks[3].height as usize;
let available_height = chunks[4].height as usize;
let lines_for_borders = 2;
let lines_per_interface = 2;
let lines_for_more_message = 1;
@@ -967,7 +1052,7 @@ fn draw_stats_panel(
.title("Interface Stats (press 'i')"),
)
.style(Style::default());
f.render_widget(interface_stats_widget, chunks[3]);
f.render_widget(interface_stats_widget, chunks[4]);
Ok(())
}