Skip to content

Commit 47c1be8

Browse files
RoyLinRoyLin
authored andcommitted
docs+polish: SECURITY.md, enforcement.md reflects shipped guards, clean eBPF unsafe
- SECURITY.md: disclosure policy + cosign signature verification. - docs/enforcement.md: 'what's shipped' (egress connect4 v0.3.0, file fanotify v0.4.0) + staged-plan status; the file guard is fanotify, not LSM-BPF (bpf-lsm not enabled). - Removed 8 redundant unsafe blocks in the eBPF probes (clean build). - README: Security pointer.
1 parent 405a560 commit 47c1be8

5 files changed

Lines changed: 78 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
All notable changes to a3s-observer will be documented in this file.
44

5+
## [Unreleased]
6+
7+
### Added / Changed
8+
9+
- `SECURITY.md` — vulnerability disclosure policy + image-signature verification.
10+
- `docs/enforcement.md` reflects the **shipped** guards (egress `cgroup/connect4` v0.3.0, file
11+
fanotify `FAN_OPEN_PERM` v0.4.0); the file guard uses fanotify, not LSM-BPF, because `bpf`
12+
isn't in this kernel's `lsm=` set.
13+
- Removed redundant `unsafe` blocks in the eBPF probes (clean build).
14+
515
## [0.5.1] — production hardening
616

717
### Changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,11 @@ a3s-observer → NDJSON → OTel Collector (filelog → OTLP) → your bac
134134

135135
Rust + [Aya](https://aya-rs.dev).
136136

137+
## Security
138+
139+
Privileged component — see [SECURITY.md](SECURITY.md) for the disclosure policy and how to
140+
verify a release image's signature (cosign / Sigstore).
141+
137142
## License
138143

139144
MIT

SECURITY.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Security Policy
2+
3+
a3s-observer runs **privileged** — it loads eBPF and, opt-in, attaches uprobes / fanotify — so
4+
it sits inside the trust boundary. Please report vulnerabilities responsibly.
5+
6+
## Reporting
7+
8+
Open a **private security advisory** on the repository (Security → Advisories → *Report a
9+
vulnerability*). Do **not** file a public issue for a vulnerability. We aim to acknowledge
10+
within 72 hours.
11+
12+
## Sensitive surfaces
13+
14+
- **Privileged probe load.** The collector needs root / `CAP_BPF`+`CAP_PERFMON`; treat the
15+
binary and image as privileged components.
16+
- **Content capture (`A3S_OBSERVER_SSL=1`).** Off by default. When on, it captures TLS
17+
**plaintext** (prompts/completions) via OpenSSL uprobes — sensitive data. Enable only where
18+
capturing that content is acceptable, and secure the NDJSON sink accordingly.
19+
- **Enforcement (`a3s-observer-enforce` / `a3s-observer-fileguard`).** Opt-in. A bad policy can
20+
block legitimate egress / file access; the default is fail-open, and policies should be
21+
validated on a non-prod box (`scripts/validate-enforcement.sh`).
22+
23+
## Supply chain
24+
25+
Release images are pushed to GHCR, scanned (Trivy), keyless-signed (cosign / Sigstore), and
26+
carry SLSA build provenance + an SBOM. Verify a signature:
27+
28+
```bash
29+
cosign verify ghcr.io/a3s-lab/observer:<tag> \
30+
--certificate-identity-regexp 'https://github.com/A3S-Lab/Observer/.*' \
31+
--certificate-oidc-issuer https://token.actions.githubusercontent.com
32+
```

a3s-observer-ebpf/src/main.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,10 @@ fn try_tls(ctx: &TracePointContext) -> Result<u32, i64> {
147147
let buf: *const u8 = unsafe { ctx.read_at(24)? };
148148
let count: u64 = unsafe { ctx.read_at(32)? };
149149
let fd: u64 = unsafe { ctx.read_at(16)? };
150-
let pid = (unsafe { bpf_get_current_pid_tgid() } >> 32) as u32;
150+
let pid = (bpf_get_current_pid_tgid() >> 32) as u32;
151151
let key = sock_key(pid, fd);
152152
// Already tracking this LLM socket → this write is request payload; accumulate + done.
153-
if let Some(stat) = unsafe { LLM_SOCKS.get_ptr_mut(&key) } {
153+
if let Some(stat) = LLM_SOCKS.get_ptr_mut(&key) {
154154
unsafe {
155155
(*stat).req_bytes = (*stat).req_bytes.saturating_add(count);
156156
}
@@ -224,15 +224,15 @@ pub fn ssl_write(ctx: ProbeContext) -> u32 {
224224
pub fn ssl_read_enter(ctx: ProbeContext) -> u32 {
225225
let buf = ctx.arg::<u64>(1).unwrap_or(0);
226226
if buf != 0 {
227-
let tid = unsafe { bpf_get_current_pid_tgid() };
227+
let tid = bpf_get_current_pid_tgid();
228228
let _ = SSL_READ_BUF.insert(&tid, &buf, 0);
229229
}
230230
0
231231
}
232232

233233
#[uretprobe]
234234
pub fn ssl_read_exit(ctx: RetProbeContext) -> u32 {
235-
let tid = unsafe { bpf_get_current_pid_tgid() };
235+
let tid = bpf_get_current_pid_tgid();
236236
let ret = ctx.ret::<i32>().unwrap_or(0) as i64; // SSL_read return value = bytes decrypted
237237
let buf = unsafe { SSL_READ_BUF.get(&tid) }.copied();
238238
let _ = SSL_READ_BUF.remove(&tid);
@@ -511,7 +511,7 @@ fn on_read_enter(ctx: &TracePointContext) -> u32 {
511511
let Ok(fd) = (unsafe { ctx.read_at::<u64>(16) }) else {
512512
return 0;
513513
};
514-
let tgid = unsafe { bpf_get_current_pid_tgid() };
514+
let tgid = bpf_get_current_pid_tgid();
515515
let key = sock_key((tgid >> 32) as u32, fd);
516516
// Stash only for tracked LLM sockets — keeps this node-wide hot path cheap.
517517
if unsafe { LLM_SOCKS.get(&key) }.is_some() {
@@ -521,7 +521,7 @@ fn on_read_enter(ctx: &TracePointContext) -> u32 {
521521
}
522522

523523
fn on_read_exit(ctx: &TracePointContext) -> u32 {
524-
let tgid = unsafe { bpf_get_current_pid_tgid() };
524+
let tgid = bpf_get_current_pid_tgid();
525525
let Some(&fd) = (unsafe { READ_FD.get(&tgid) }) else {
526526
return 0;
527527
};
@@ -534,7 +534,7 @@ fn on_read_exit(ctx: &TracePointContext) -> u32 {
534534
return 0;
535535
}
536536
let key = sock_key((tgid >> 32) as u32, fd as u64);
537-
if let Some(stat) = unsafe { LLM_SOCKS.get_ptr_mut(&key) } {
537+
if let Some(stat) = LLM_SOCKS.get_ptr_mut(&key) {
538538
unsafe {
539539
(*stat).resp_bytes = (*stat).resp_bytes.saturating_add(ret as u64);
540540
if (*stat).first_resp_ns == 0 {
@@ -551,7 +551,7 @@ pub fn sock_close(ctx: TracePointContext) -> u32 {
551551
let Ok(fd) = (unsafe { ctx.read_at::<u64>(16) }) else {
552552
return 0;
553553
};
554-
let pid = (unsafe { bpf_get_current_pid_tgid() } >> 32) as u32;
554+
let pid = (bpf_get_current_pid_tgid() >> 32) as u32;
555555
let key = sock_key(pid, fd);
556556
let Some(&stat) = (unsafe { LLM_SOCKS.get(&key) }) else {
557557
return 0; // not an LLM socket

docs/enforcement.md

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@
33
a3s-observer **observes**. This is the design for *optional* intervention (block / redirect),
44
kept strictly separate so the observer core stays passive and safe.
55

6+
## What's shipped
7+
8+
Two guards are implemented and KVM-validated, each driven by an **external policy file**:
9+
10+
| Guard | Binary | Mechanism | Denies (returns `EPERM`) | Validated |
11+
|---|---|---|---|---|
12+
| **Egress** | `a3s-observer-enforce` | eBPF `cgroup/connect4` | `connect()` to policy IPs — cgroup-scoped, fail-open | v0.3.0 |
13+
| **File** | `a3s-observer-fileguard` | fanotify `FAN_OPEN_PERM` | `open()` of policy-listed files | v0.4.0 |
14+
15+
The file guard uses **fanotify**, not LSM-BPF: `bpf` is not in this kernel's active `lsm=` set
16+
(LSM-BPF would need a custom boot cmdline), and fanotify is stock-kernel + userspace-driven —
17+
the same external-policy model. **Exec** blocking (LSM `bprm`) is the remaining item. The
18+
sections below are the broader design these two were built from.
19+
620
## Why separate (first principles)
721

822
Observation is passive and read-only — tracepoints → ring buffers **cannot block**.
@@ -64,16 +78,15 @@ Default policy = `AllowAll` (fail-open — never break an agent unless a rule op
6478

6579
Every deny is *also* emitted as an observed event, so enforcement is auditable.
6680

67-
## Staged plan (same shape the observer was built)
81+
## Staged plan — status
6882

69-
1. Contract + this design + `Policy` / `Verdict` (the seam).
70-
2. PoC: TC-egress **SNI allowlist** — drop the ClientHello to non-approved providers, driven
71-
by a policy map. Validate on a **non-prod box**.
72-
3. PoC: LSM `file_open` deny for a path policy.
73-
4. Control API for the out-of-process policy path (the language-agnostic external engine).
74-
5. Harden: fail-safe semantics, per-cgroup scoping, audit-every-deny.
83+
1. ✅ Contract + design + `Policy` / `Verdict` seam.
84+
2.**Egress** deny (`cgroup/connect4`, by IP/host), cgroup-scoped, fail-open — v0.3.0.
85+
3.**File** deny (fanotify `FAN_OPEN_PERM`, by path) — v0.4.0.
86+
4.**Exec** deny (LSM `bprm`) — needs `bpf` in the kernel's `lsm=` set (custom-cmdline box).
87+
5. ◻ Control API for the out-of-process path (today: the policy file; later a socket / gRPC).
7588

76-
> Enforcement must be validated on a **non-prod box** — blocking real syscalls/egress on a
77-
> shared prod node is unacceptable. The validation is codified in
89+
> Enforcement is validated on a **non-prod box** — blocking real syscalls/egress on a shared
90+
> prod node is unacceptable. The egress check is codified in
7891
> [`scripts/validate-enforcement.sh`](../scripts/validate-enforcement.sh) (egress block →
79-
> control connects → scoping → fail-open); run it there, then tag v0.3.0.
92+
> control connects → scoping → fail-open); both shipped guards were KVM-validated this way.

0 commit comments

Comments
 (0)