Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ its first tagged release.
## [Unreleased]

### Added
- **IPv6 kernel egress filtering** — the eBPF guard now enforces default-deny on
IPv6 too (new `ALLOW6` map, v6 destination parsing), closing the bypass where
a tool could exfiltrate over IPv6. Loopback (`::1`) and infrastructure prefixes
(link-local `fe80::/10`, multicast `ff00::/8`) always pass. `allow`/`allow-domain`,
the admin socket, and policy lowering all accept v4 and v6.
- **Proxy parse benchmarks + evidence-backed metrics** — criterion
micro-benchmarks for the per-response guard cost (`extract` per provider +
SSE reassembly) alongside the existing policy bench; the README metrics
Expand Down
9 changes: 5 additions & 4 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,11 @@ sudo pasu-daemon --policy rules.yaml --cgroup-path /sys/fs/cgroup/my-agent
sudo pasu-egress --cgroup-path /sys/fs/cgroup/my-agent --allow-domain api.openai.com
```

Allow rules with an IPv4 become static entries, and exact hostnames are resolved
(and re-resolved). Suffix patterns (`.openai.com`) can't be lowered to the kernel
yet and are only reported — DNS-response sniffing will close that. The kernel
side is default-deny, so lowering is only ever *narrower* than the policy.
Allow rules with an IPv4/IPv6 literal become static entries, and exact hostnames
are resolved (and re-resolved, both families). Suffix patterns (`.openai.com`)
can't be lowered to the kernel yet and are only reported — DNS-response sniffing
will close that. The kernel side is default-deny for **both v4 and v6**, so
lowering is only ever *narrower* than the policy.

Add `--admin-socket /run/pasu.sock` to inspect and edit the live guard without a
restart (this is what the UI talks to):
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ sudo pasu-daemon --policy rules.yaml --cgroup-path /sys/fs/cgroup/my-agent
sudo pasu-egress --cgroup-path /sys/fs/cgroup/my-agent --allow-domain api.openai.com
```

IPv4 allow는 정적 항목이 되고, 정확한 호스트명은 주기적으로 재해석됩니다. 접미 패턴(`.openai.com`)은 아직 커널로 내려가지 못해 리포트만 남깁니다 — DNS 응답 스니핑이 들어오면 해결됩니다. 커널은 기본 차단이라, 변환 결과는 정책보다 좁아질 뿐 넓어지지 않습니다.
IPv4/IPv6 리터럴 allow는 정적 항목이 되고, 정확한 호스트명은 주기적으로 재해석됩니다(양 계열). 접미 패턴(`.openai.com`)은 아직 커널로 내려가지 못해 리포트만 남깁니다 — DNS 응답 스니핑이 들어오면 해결됩니다. 커널은 **v4·v6 모두 기본 차단**이라, 변환 결과는 정책보다 좁아질 뿐 넓어지지 않습니다.

`--admin-socket /run/pasu.sock`을 붙이면 재시작 없이 실행 중인 가드를 들여다보고 수정할 수 있습니다 (UI도 이 소켓을 씁니다).

Expand Down
6 changes: 5 additions & 1 deletion crates/pasu-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,23 @@ fn lower(args: &RunArgs, cgroup_path: PathBuf) -> anyhow::Result<GuardConfig> {
for ip in &allowlist.ips {
println!(" kernel allow ip {ip}");
}
for ip6 in &allowlist.ips6 {
println!(" kernel allow ip {ip6}");
}
for d in &allowlist.domains {
println!(" kernel allow domain {d}");
}
for s in &allowlist.skipped {
println!(" hook-layer only {} ({})", s.rule, s.reason);
}
if allowlist.ips.is_empty() && allowlist.domains.is_empty() {
if allowlist.ips.is_empty() && allowlist.ips6.is_empty() && allowlist.domains.is_empty() {
println!(" (no kernel-expressible allow rules: everything is dropped)");
}

Ok(GuardConfig {
cgroup_path,
allow: allowlist.ips,
allow6: allowlist.ips6,
allow_domain: allowlist.domains,
refresh_secs: args.refresh_secs,
admin_socket: args.admin_socket.clone(),
Expand Down
6 changes: 5 additions & 1 deletion crates/pasu-daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,23 @@ fn load(opt: Opt) -> anyhow::Result<GuardConfig> {
for ip in &allowlist.ips {
println!(" kernel allow ip {ip}");
}
for ip6 in &allowlist.ips6 {
println!(" kernel allow ip {ip6}");
}
for d in &allowlist.domains {
println!(" kernel allow domain {d}");
}
for s in &allowlist.skipped {
println!(" hook-layer only {} ({})", s.rule, s.reason);
}
if allowlist.ips.is_empty() && allowlist.domains.is_empty() {
if allowlist.ips.is_empty() && allowlist.ips6.is_empty() && allowlist.domains.is_empty() {
println!(" (no kernel-expressible allow rules: everything is dropped)");
}

Ok(GuardConfig {
cgroup_path: opt.cgroup_path,
allow: allowlist.ips,
allow6: allowlist.ips6,
allow_domain: allowlist.domains,
refresh_secs: opt.refresh_secs,
admin_socket: opt.admin_socket,
Expand Down
47 changes: 38 additions & 9 deletions crates/pasu-ebpf/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ use aya_ebpf::{
};
use aya_log_ebpf::info;

// M1: default-deny allowlist. IPv4 destinations allowed to egress (host byte order)
// are injected from user space (control plane); anything else is dropped.
// default-deny allowlist. Destinations allowed to egress are injected from user
// space (control plane); anything else is dropped. IPv4 keys are host-order u32;
// IPv6 keys are the 16-byte address as a big-endian u128 (matches u128::from(Ipv6Addr)).
//
// Because unlisted traffic is dropped, this MUST be attached to a dedicated cgroup,
// NEVER the root cgroup (that would cut the host's own egress, including SSH).
#[map]
static ALLOW: HashMap<u32, u8> = HashMap::with_max_entries(1024, 0);
#[map]
static ALLOW6: HashMap<u128, u8> = HashMap::with_max_entries(1024, 0);

#[cgroup_skb]
pub fn pasu_egress(ctx: SkBuffContext) -> i32 {
Expand All @@ -27,13 +30,18 @@ pub fn pasu_egress(ctx: SkBuffContext) -> i32 {
fn try_pasu_egress(ctx: SkBuffContext) -> Result<i32, ()> {
// cgroup_skb egress: the packet begins at the IPv4 header (L3, no ethernet frame).
// byte 0 = version/IHL; bytes 16..20 = destination address.
let ver_ihl: u8 = ctx.load(0).map_err(|_| ())?;
if ver_ihl >> 4 != 4 {
// Non-IPv4 (IPv6, etc.) passes — M1 scope is IPv4. (IPv6 egress control is
// out of scope for now; documented as a known gap.)
return Ok(1);
let version: u8 = ctx.load::<u8>(0).map_err(|_| ())? >> 4;
match version {
4 => try_v4(&ctx),
6 => try_v6(&ctx),
// Neither IPv4 nor IPv6 (ARP already handled below L3; anything else) →
// drop under default-deny (fail-closed).
_ => Ok(0),
}
}

fn try_v4(ctx: &SkBuffContext) -> Result<i32, ()> {
// IPv4 header (L3, no ethernet frame): bytes 16..20 = destination address.
let dst_be: u32 = ctx.load(16).map_err(|_| ())?;
let dst = u32::from_be(dst_be); // host byte order, matches u32::from(Ipv4Addr)

Expand All @@ -42,12 +50,33 @@ fn try_pasu_egress(ctx: SkBuffContext) -> Result<i32, ()> {
if dst >> 24 == 127 {
return Ok(1);
}

if unsafe { ALLOW.get(&dst) }.is_some() {
return Ok(1); // allowlisted → pass
}
info!(ctx, "pasu: dropped IPv4 egress (dst not in ALLOW map)");
Ok(0) // default-deny → drop
}

fn try_v6(ctx: &SkBuffContext) -> Result<i32, ()> {
// IPv6 header (L3): bytes 24..40 = 16-byte destination address (network order).
// Load as two u64 halves to keep the verifier happy (64-bit ops only).
let hi = u64::from_be(ctx.load::<u64>(24).map_err(|_| ())?);
let lo = u64::from_be(ctx.load::<u64>(32).map_err(|_| ())?);

// Infrastructure prefixes always pass — dropping them breaks basic v6
// operation (NDP, on-link), same spirit as the v4 loopback exception:
// ::1 loopback
// fe80::/10 link-local (NDP, router)
// ff00::/8 multicast (NDP solicitations, etc.)
if (hi == 0 && lo == 1) || (hi >> 54 == 0x3FA) || (hi >> 56 == 0xff) {
return Ok(1);
}

info!(&ctx, "pasu: dropped egress (dst not in ALLOW map)");
let key: u128 = ((hi as u128) << 64) | (lo as u128); // == u128::from(Ipv6Addr)
if unsafe { ALLOW6.get(&key) }.is_some() {
return Ok(1); // allowlisted → pass
}
info!(ctx, "pasu: dropped IPv6 egress (dst not in ALLOW6 map)");
Ok(0) // default-deny → drop
}

Expand Down
37 changes: 24 additions & 13 deletions crates/pasu-egress/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,20 @@
//! The socket only mediates requests; the eBPF `ALLOW` map is owned by the guard
//! loop, which applies commands one at a time (no shared mutable eBPF state).

use std::net::Ipv4Addr;
use std::net::IpAddr;

use tokio::sync::oneshot;

/// A parsed admin request.
#[derive(Debug, PartialEq)]
pub enum Request {
Status,
Allow(Ipv4Addr),
Deny(Ipv4Addr),
Allow(IpAddr),
Deny(IpAddr),
}

/// Parse one request line. Case-insensitive verb; a single IPv4 argument for
/// `allow`/`deny`.
/// Parse one request line. Case-insensitive verb; a single IP (v4 or v6)
/// argument for `allow`/`deny`.
pub fn parse_request(line: &str) -> Result<Request, String> {
let mut parts = line.split_whitespace();
let verb = parts.next().unwrap_or_default().to_ascii_lowercase();
Expand All @@ -34,10 +34,10 @@ pub fn parse_request(line: &str) -> Result<Request, String> {
"allow" | "deny" => {
let arg = parts
.next()
.ok_or_else(|| format!("{verb}: missing <ipv4>"))?;
let ip: Ipv4Addr = arg
.ok_or_else(|| format!("{verb}: missing <ip>"))?;
let ip: IpAddr = arg
.parse()
.map_err(|_| format!("{verb}: invalid ipv4 `{arg}`"))?;
.map_err(|_| format!("{verb}: invalid ip `{arg}`"))?;
Ok(if verb == "allow" {
Request::Allow(ip)
} else {
Expand All @@ -64,8 +64,8 @@ pub struct Status {
/// A command handed to the guard loop, with a channel for its reply.
pub enum Command {
Status(oneshot::Sender<Status>),
Allow(Ipv4Addr, oneshot::Sender<Result<(), String>>),
Deny(Ipv4Addr, oneshot::Sender<Result<(), String>>),
Allow(IpAddr, oneshot::Sender<Result<(), String>>),
Deny(IpAddr, oneshot::Sender<Result<(), String>>),
}

#[cfg(test)]
Expand All @@ -82,19 +82,30 @@ mod tests {
fn parses_allow_deny() {
assert_eq!(
parse_request("allow 1.2.3.4").unwrap(),
Request::Allow(Ipv4Addr::new(1, 2, 3, 4))
Request::Allow("1.2.3.4".parse().unwrap())
);
assert_eq!(
parse_request("DENY 10.0.0.1").unwrap(),
Request::Deny(Ipv4Addr::new(10, 0, 0, 1))
Request::Deny("10.0.0.1".parse().unwrap())
);
}

#[test]
fn parses_ipv6() {
assert_eq!(
parse_request("allow 2606:4700:4700::1111").unwrap(),
Request::Allow("2606:4700:4700::1111".parse().unwrap())
);
assert_eq!(
parse_request("deny ::1").unwrap(),
Request::Deny("::1".parse().unwrap())
);
}

#[test]
fn rejects_bad_input() {
assert!(parse_request("allow").is_err()); // missing arg
assert!(parse_request("allow not-an-ip").is_err());
assert!(parse_request("allow ::1").is_err()); // ipv4 only
assert!(parse_request("bogus").is_err());
assert!(parse_request("").is_err());
}
Expand Down
Loading
Loading