Skip to content

Commit db5e355

Browse files
RoyLinRoyLin
authored andcommitted
feat(ssl): opt-in OpenSSL content capture (#7) — prompts/responses via uprobes
A3S_OBSERVER_SSL=1 attaches uprobes to SSL_write / SSL_read and emits AgentEvent::SslContent with request (prompt) / response (completion) plaintext. Deliberately outside the language-agnostic core (a uprobe binds to OpenSSL symbols, captures real content, off by default) — a Rule 2 extension. eBPF uprobes + SslEvent + AgentEvent::SslContent + opt-in collector attach/drain. Build-validated on KVM; runtime validation pending a non-prod VM (content capture is gated off the shared prod node).
1 parent 396702b commit db5e355

5 files changed

Lines changed: 197 additions & 7 deletions

File tree

CHANGELOG.md

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

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

5+
## [Unreleased]
6+
7+
### Added
8+
9+
- **SSL/TLS content capture (#7)** — the long-deferred opt-in OpenSSL uprobe extension.
10+
`A3S_OBSERVER_SSL=1` attaches uprobes to `SSL_write` / `SSL_read` and emits `SslContent`
11+
events with the request (prompt) / response (completion) **plaintext**. This is deliberately
12+
outside the universal core (Rule 2): a uprobe binds to a library symbol, so it is **not**
13+
language-agnostic (OpenSSL only — Python `requests`/`httpx`, Node, curl …, not Go
14+
`crypto/tls`), and it captures real content, so it is **off by default**. Build-validated;
15+
runtime validation pending a non-prod VM (content capture is gated off the shared prod node).
16+
517
## [0.4.0] — file-access intervention
618

719
### Added

a3s-observer-collector/src/main.rs

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ use a3s_observer::{
1010
read_ppid, AgentEvent, EnrichedEvent, Exporter, Identity, IdentityResolver, JsonExporter,
1111
KubeResolver, LogExporter, Provider, ServiceClassifier, SniClassifier,
1212
};
13-
use a3s_observer_common::{ConnectEvent, DnsEvent, ExecEvent, FileEvent, LlmEvent, TlsEvent};
13+
use a3s_observer_common::{
14+
ConnectEvent, DnsEvent, ExecEvent, FileEvent, LlmEvent, SslEvent, TlsEvent,
15+
};
1416
use anyhow::Context as _;
1517
use aya::{
1618
maps::{PerCpuArray, RingBuf},
17-
programs::TracePoint,
19+
programs::{TracePoint, UProbe},
1820
Ebpf,
1921
};
2022
use std::collections::HashMap;
@@ -33,7 +35,9 @@ async fn main() -> anyhow::Result<()> {
3335
"a3s-observer-collector {} — language-agnostic eBPF observability for AI agents\n\n\
3436
Run as root / CAP_BPF+CAP_PERFMON (Linux). Configure via env:\n \
3537
A3S_OBSERVER_JSON=1 emit NDJSON (default: human-readable log)\n \
36-
A3S_OBSERVER_FILES=1 also capture file writes (high-volume; off by default)",
38+
A3S_OBSERVER_FILES=1 also capture file writes (high-volume; off by default)\n \
39+
A3S_OBSERVER_SSL=1 also capture OpenSSL plaintext — prompts/responses \
40+
(uprobe, OpenSSL-only, off by default; or set a libssl path)",
3741
env!("CARGO_PKG_VERSION")
3842
);
3943
return Ok(());
@@ -83,6 +87,34 @@ async fn main() -> anyhow::Result<()> {
8387
anyhow::bail!("no eBPF probes could be attached");
8488
}
8589

90+
// Opt-in OpenSSL content capture (uprobes). Off by default — it captures real plaintext
91+
// (prompts/completions) and binds to OpenSSL only. A3S_OBSERVER_SSL=1, or set it to a
92+
// libssl path on distros where the default below is wrong.
93+
if let Some(val) = std::env::var("A3S_OBSERVER_SSL")
94+
.ok()
95+
.filter(|v| !v.is_empty())
96+
{
97+
let lib = if val.contains('/') {
98+
val
99+
} else {
100+
"/usr/lib/x86_64-linux-gnu/libssl.so.3".to_string()
101+
};
102+
let mut ssl_ok = 0;
103+
for (prog, sym) in [
104+
("ssl_write", "SSL_write"),
105+
("ssl_read_enter", "SSL_read"),
106+
("ssl_read_exit", "SSL_read"),
107+
] {
108+
match attach_uprobe(&mut ebpf, prog, sym, &lib) {
109+
Ok(()) => ssl_ok += 1,
110+
Err(e) => {
111+
tracing::warn!(probe = prog, lib = %lib, error = %e, "SSL uprobe failed to attach")
112+
}
113+
}
114+
}
115+
tracing::info!(lib = %lib, attached = ssl_ok, "A3S_OBSERVER_SSL: OpenSSL content capture (uprobes)");
116+
}
117+
86118
// A3S_OBSERVER_JSON=1 → NDJSON (pipe to vector/Loki/jq); otherwise human-readable log.
87119
let exporter: Box<dyn Exporter> = if std::env::var_os("A3S_OBSERVER_JSON").is_some() {
88120
Box::new(JsonExporter)
@@ -117,6 +149,11 @@ async fn main() -> anyhow::Result<()> {
117149
ebpf.take_map("LLM_EVENTS")
118150
.context("`LLM_EVENTS` missing")?,
119151
)?;
152+
// Opt-in OpenSSL content ring; stays empty unless A3S_OBSERVER_SSL attached the uprobes.
153+
let mut ssl_ring = RingBuf::try_from(
154+
ebpf.take_map("SSL_EVENTS")
155+
.context("`SSL_EVENTS` missing")?,
156+
)?;
120157
// Cumulative count of events dropped because a ring was full (data-loss visibility).
121158
let drops: PerCpuArray<_, u64> =
122159
PerCpuArray::try_from(ebpf.take_map("DROPS").context("`DROPS` missing")?)?;
@@ -149,6 +186,7 @@ async fn main() -> anyhow::Result<()> {
149186
dns = stats.dns,
150187
file = stats.file,
151188
llm = stats.llm,
189+
ssl = stats.ssl,
152190
dropped,
153191
"a3s-observer: events in the last 60s (dropped = cumulative ring-full)"
154192
);
@@ -275,6 +313,23 @@ async fn main() -> anyhow::Result<()> {
275313
}
276314
}
277315
}
316+
while let Some(item) = ssl_ring.next() {
317+
if let Some(ev) = read_pod::<SslEvent>(&item) {
318+
let len = (ev.len as usize).min(ev.data.len());
319+
let content = String::from_utf8_lossy(&ev.data[..len]).into_owned();
320+
if !content.is_empty() {
321+
emit(exporter.as_ref(), &mut stats, EnrichedEvent {
322+
identity: identity_for(&resolver, ev.pid, &ev.comm),
323+
provider: None,
324+
event: AgentEvent::SslContent {
325+
pid: ev.pid,
326+
is_read: ev.is_read != 0,
327+
content,
328+
},
329+
});
330+
}
331+
}
332+
}
278333
}
279334
}
280335
}
@@ -284,6 +339,7 @@ async fn main() -> anyhow::Result<()> {
284339
dns = stats.dns,
285340
file = stats.file,
286341
llm = stats.llm,
342+
ssl = stats.ssl,
287343
"a3s-observer-collector: stopped (final window)"
288344
);
289345
Ok(())
@@ -303,6 +359,17 @@ fn attach(ebpf: &mut Ebpf, prog: &str, category: &str, name: &str) -> anyhow::Re
303359
Ok(())
304360
}
305361

362+
fn attach_uprobe(ebpf: &mut Ebpf, prog: &str, sym: &str, target: &str) -> anyhow::Result<()> {
363+
let p: &mut UProbe = ebpf
364+
.program_mut(prog)
365+
.with_context(|| format!("`{prog}` program not found"))?
366+
.try_into()?;
367+
p.load()?;
368+
p.attach(Some(sym), 0, target, None)
369+
.with_context(|| format!("attach uprobe {sym} in {target}"))?;
370+
Ok(())
371+
}
372+
306373
fn read_pod<T: Copy>(item: &[u8]) -> Option<T> {
307374
(item.len() >= core::mem::size_of::<T>())
308375
.then(|| unsafe { core::ptr::read_unaligned(item.as_ptr() as *const T) })
@@ -339,6 +406,7 @@ struct Stats {
339406
dns: u64,
340407
file: u64,
341408
llm: u64,
409+
ssl: u64,
342410
}
343411

344412
/// Export an event and count it by kind for the throughput report.
@@ -349,6 +417,7 @@ fn emit(exporter: &dyn Exporter, stats: &mut Stats, ev: EnrichedEvent) {
349417
AgentEvent::Dns { .. } => stats.dns += 1,
350418
AgentEvent::FileAccess { .. } => stats.file += 1,
351419
AgentEvent::LlmCall { .. } => stats.llm += 1,
420+
AgentEvent::SslContent { .. } => stats.ssl += 1,
352421
}
353422
exporter.export(&ev);
354423
}

a3s-observer-common/src/lib.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,23 @@ pub struct LlmEvent {
8989
pub ttft_ns: u64, // ClientHello → first response byte; 0 = no response seen
9090
pub comm: [u8; 16],
9191
}
92+
93+
/// A plaintext snapshot from a TLS connection, captured by **uprobes** on OpenSSL
94+
/// `SSL_write` / `SSL_read` — the OPT-IN content extension (LLM prompt / completion bodies).
95+
///
96+
/// Unlike every other probe this is **not** language-agnostic: a uprobe binds to a specific
97+
/// library symbol, so this covers **OpenSSL only** (Python `requests`/`httpx`, Node, curl, …),
98+
/// not Go's `crypto/tls` or BoringSSL. It also captures real request/response content, so it is
99+
/// **off by default** (`A3S_OBSERVER_SSL=1`) and must run where that's acceptable. This is why
100+
/// it lives outside the universal core — see Rule 2 (minimal core + external extensions).
101+
pub const SSL_SNAP_LEN: usize = 1024;
102+
103+
#[repr(C)]
104+
#[derive(Clone, Copy)]
105+
pub struct SslEvent {
106+
pub pid: u32,
107+
pub is_read: u32, // 0 = SSL_write (request / prompt), 1 = SSL_read (response / completion)
108+
pub len: u32, // bytes captured into `data` (<= SSL_SNAP_LEN)
109+
pub comm: [u8; 16],
110+
pub data: [u8; SSL_SNAP_LEN],
111+
}

a3s-observer-ebpf/src/main.rs

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,18 @@
22
#![no_main]
33

44
use a3s_observer_common::{
5-
ConnectEvent, DnsEvent, ExecEvent, FileEvent, LlmEvent, TlsEvent, DNS_SNAP_LEN, PATH_SNAP_LEN,
6-
TLS_SNAP_LEN,
5+
ConnectEvent, DnsEvent, ExecEvent, FileEvent, LlmEvent, SslEvent, TlsEvent, DNS_SNAP_LEN,
6+
PATH_SNAP_LEN, SSL_SNAP_LEN, TLS_SNAP_LEN,
77
};
88
use aya_ebpf::{
99
helpers::gen::bpf_probe_read_user,
1010
helpers::{
1111
bpf_get_current_comm, bpf_get_current_pid_tgid, bpf_get_current_uid_gid, bpf_ktime_get_ns,
1212
bpf_probe_read_user_buf, bpf_probe_read_user_str_bytes,
1313
},
14-
macros::{cgroup_sock_addr, map, tracepoint},
14+
macros::{cgroup_sock_addr, map, tracepoint, uprobe, uretprobe},
1515
maps::{ring_buf::RingBufEntry, HashMap, PerCpuArray, RingBuf},
16-
programs::{SockAddrContext, TracePointContext},
16+
programs::{ProbeContext, RetProbeContext, SockAddrContext, TracePointContext},
1717
};
1818

1919
#[map]
@@ -53,6 +53,15 @@ static LLM_SOCKS: HashMap<u64, LlmStat> = HashMap::with_max_entries(4096, 0);
5353
#[map]
5454
static READ_FD: HashMap<u64, u32> = HashMap::with_max_entries(10240, 0);
5555

56+
// Opt-in OpenSSL content (uprobe). Bigger ring — payloads are up to SSL_SNAP_LEN each.
57+
#[map]
58+
static SSL_EVENTS: RingBuf = RingBuf::with_byte_size(512 * 1024, 0);
59+
60+
// SSL_read entry saves the caller's buffer ptr by tid so the uretprobe (which knows how many
61+
// bytes were decrypted into it) can snapshot the plaintext.
62+
#[map]
63+
static SSL_READ_BUF: HashMap<u64, u64> = HashMap::with_max_entries(10240, 0);
64+
5665
#[repr(C)]
5766
#[derive(Clone, Copy)]
5867
struct LlmStat {
@@ -196,6 +205,76 @@ fn try_tls(ctx: &TracePointContext) -> Result<u32, i64> {
196205
Ok(0)
197206
}
198207

208+
// ---- OPT-IN OpenSSL content (uprobes on SSL_write / SSL_read) ----
209+
//
210+
// NOT language-agnostic (a uprobe binds to OpenSSL symbols) and captures real plaintext, so the
211+
// collector only attaches these when A3S_OBSERVER_SSL=1. SSL_write(ssl, buf, num): the request
212+
// plaintext is in `buf` at entry. SSL_read(ssl, buf, num): `buf` is filled during the call, so
213+
// snapshot it at return, with the byte count from the return value.
214+
215+
#[uprobe]
216+
pub fn ssl_write(ctx: ProbeContext) -> u32 {
217+
// SSL_write(ssl, buf, num): read args as raw register values (pointers carried as u64).
218+
let buf = ctx.arg::<u64>(1).unwrap_or(0);
219+
let num = ctx.arg::<u64>(2).unwrap_or(0);
220+
emit_ssl(buf as *const u8, num, 0)
221+
}
222+
223+
#[uprobe]
224+
pub fn ssl_read_enter(ctx: ProbeContext) -> u32 {
225+
let buf = ctx.arg::<u64>(1).unwrap_or(0);
226+
if buf != 0 {
227+
let tid = unsafe { bpf_get_current_pid_tgid() };
228+
let _ = SSL_READ_BUF.insert(&tid, &buf, 0);
229+
}
230+
0
231+
}
232+
233+
#[uretprobe]
234+
pub fn ssl_read_exit(ctx: RetProbeContext) -> u32 {
235+
let tid = unsafe { bpf_get_current_pid_tgid() };
236+
let ret = ctx.ret::<i32>().unwrap_or(0) as i64; // SSL_read return value = bytes decrypted
237+
let buf = unsafe { SSL_READ_BUF.get(&tid) }.copied();
238+
let _ = SSL_READ_BUF.remove(&tid);
239+
if ret <= 0 {
240+
return 0;
241+
}
242+
match buf {
243+
Some(addr) => emit_ssl(addr as *const u8, ret as u64, 1),
244+
None => 0,
245+
}
246+
}
247+
248+
fn emit_ssl(buf: *const u8, len: u64, is_read: u32) -> u32 {
249+
if buf.is_null() || len == 0 {
250+
return 0;
251+
}
252+
let Some(mut entry) = reserve_or_drop::<SslEvent>(&SSL_EVENTS) else {
253+
return 0;
254+
};
255+
let ev = entry.as_mut_ptr();
256+
unsafe {
257+
(*ev).pid = (bpf_get_current_pid_tgid() >> 32) as u32;
258+
(*ev).is_read = is_read;
259+
(*ev).comm = bpf_get_current_comm().unwrap_or_default();
260+
// n <= SSL_SNAP_LEN (data capacity) and n <= len (bytes actually written/read).
261+
let n: u32 = if len > SSL_SNAP_LEN as u64 {
262+
SSL_SNAP_LEN as u32
263+
} else {
264+
len as u32
265+
};
266+
(*ev).len = n;
267+
(*ev).data = [0u8; SSL_SNAP_LEN];
268+
let _ = bpf_probe_read_user(
269+
(*ev).data.as_mut_ptr() as *mut core::ffi::c_void,
270+
n,
271+
buf as *const core::ffi::c_void,
272+
);
273+
}
274+
entry.submit(0);
275+
0
276+
}
277+
199278
// ---- outbound connection peer (sys_enter_connect) ----
200279

201280
#[tracepoint]

src/model.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ pub enum AgentEvent {
4444
},
4545
/// A DNS query — a hostname the process resolved (`sys_enter_sendto` to :53).
4646
Dns { pid: u32, query: String },
47+
/// Plaintext from a TLS connection, captured by the **opt-in** OpenSSL uprobe extension
48+
/// (`A3S_OBSERVER_SSL=1`): the request (prompt) or response (completion) body. OpenSSL
49+
/// only (not language-agnostic), off by default. `content` is a UTF-8-lossy snapshot,
50+
/// truncated to the kernel snapshot length.
51+
SslContent {
52+
pid: u32,
53+
/// true = response (`SSL_read`, completion); false = request (`SSL_write`, prompt).
54+
is_read: bool,
55+
content: String,
56+
},
4757
}
4858

4959
/// An [`AgentEvent`] tagged with the resolved [`Identity`] and, for LLM calls, the

0 commit comments

Comments
 (0)