@@ -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+ } ;
1416use anyhow:: Context as _;
1517use aya:: {
1618 maps:: { PerCpuArray , RingBuf } ,
17- programs:: TracePoint ,
19+ programs:: { TracePoint , UProbe } ,
1820 Ebpf ,
1921} ;
2022use 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+
306373fn 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}
0 commit comments