-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.rs
More file actions
978 lines (919 loc) · 45.2 KB
/
Copy pathmain.rs
File metadata and controls
978 lines (919 loc) · 45.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
//! eBPF programs for protector-agent (ADR-0014). `no_std`, built for the bpf target
//! with bpf-linker (see agent/README.md / the Dockerfile `ebpf` stage).
//!
//! All probes write into one ring buffer ([`EVENTS`]); every event begins with an
//! [`EventHeader`] whose `kind` discriminates the body, so userspace can drain a
//! single ring and dispatch by type. Adding a probe (secret-read, library-load) is a
//! new `KIND_*`, a new body type, and a new userspace decode arm — not a new ring or a
//! second drain loop.
//!
//! Phase-2 first probe: outbound connections. A kprobe on `security_socket_connect`
//! (an LSM hook stable across kernels) reads the IPv4 destination and emits a
//! [`ConnEvent`] (kind [`KIND_CONNECT`]). Observe-only; fail safe (a bad read drops the
//! event, never errors the probe).
#![no_std]
#![no_main]
// Kernel struct bindings (struct file/path/…) — minimal, hand-laid so each read field
// sits at its running-kernel byte offset. The offset is what the compiler bakes and the
// verifier checks, so it MUST track the kernel. See vmlinux.rs + docs/ebpf-
// testing-on-nodes.md.
mod vmlinux;
use aya_ebpf::{
helpers::bpf_ktime_get_ns,
helpers::gen::{
bpf_d_path, bpf_get_current_cgroup_id, bpf_probe_read_kernel, bpf_probe_read_kernel_str,
},
macros::{fentry, kprobe, map},
maps::{LruHashMap, PerCpuArray, RingBuf},
programs::{FEntryContext, ProbeContext},
};
// The event layouts + kind discriminators are shared verbatim with the userspace loader
// via this one crate, so the kernel↔userspace byte contract can't drift (ADR-0014). The
// dedup key/window/decision live here too so the kernel probe and the userspace
// tests share one definition and can't drift.
use protector_agent_common::{
should_coalesce, ConnEvent, ConnKey, EventHeader, ExecEvent, FileEvent, PrivEvent, ReadKey,
WriteKey, DEDUP_MAP_CAP, DEDUP_WINDOW_NS, KIND_CONNECT, KIND_EXEC, KIND_FILE_OPEN,
KIND_FILE_WRITE, KIND_LIBRARY_LOAD, KIND_MODULE_LOAD, KIND_PRIV_CHANGE, KIND_PTRACE_ATTACH,
PATH_CAP,
};
/// Ring buffer of behavioral events (all kinds) drained by userspace.
#[map]
static EVENTS: RingBuf = RingBuf::with_byte_size(256 * 1024, 0);
/// Count of events the kernel had to drop because [`EVENTS`] was full (a
/// `reserve` returning `None`). Ring-buffer loss is otherwise silent — this makes
/// it observable so userspace can surface it in the heartbeat. A
/// `PerCpuArray` with one slot: each CPU bumps its own counter with no atomics or
/// contention; userspace sums across CPUs for the cumulative total. Incremented
/// only at the two `EVENTS.reserve` failure sites via [`record_drop`].
#[map]
static DROPS: PerCpuArray<u64> = PerCpuArray::with_max_entries(1, 0);
/// Bump the per-CPU drop counter (slot 0). Called at every [`EVENTS`] reserve
/// failure. Verifier-safe: a single bounded array lookup + in-place increment, no
/// loops. A missing slot (can't happen for a 1-entry array) is a silent no-op.
fn record_drop() {
if let Some(slot) = DROPS.get_ptr_mut(0) {
unsafe { *slot += 1 };
}
}
/// Build the [`EventHeader`] common to every emitted event: the kind plus the current
/// task's pid and cgroup id, both captured AT EVENT TIME. The cgroup id comes
/// from the stable `bpf_get_current_cgroup_id()` helper (the cgroup v2 directory inode),
/// recorded while the process is still live so userspace can attribute it to a pod even
/// after the (often short-lived) process has exited — the exited-process race the
/// post-hoc `/proc/<pid>/cgroup` read can't win. Both calls are stable helpers usable in
/// kprobe and fentry programs alike. Verifier-safe: two helper calls, no loops, no reads.
fn make_header(kind: u32) -> EventHeader {
let pid = (aya_ebpf::helpers::bpf_get_current_pid_tgid() >> 32) as u32;
// SAFETY: `bpf_get_current_cgroup_id` is a stable helper with no arguments and no
// pointer use; it returns 0 if the current task has no cgroup v2 id (handled in
// userspace by falling back to the `/proc` read).
let cgroup_id = unsafe { bpf_get_current_cgroup_id() };
EventHeader {
kind,
pid,
cgroup_id,
}
}
/// In-kernel connect dedup map: `(pid, daddr, dport)` → last-emit time (ns).
/// Coalesces high-frequency *repeats* — a chatty process hammering the same destination —
/// at the source, so a suppressed connect never costs a ring-buffer slot (the volume
/// problem 's drop counter measures). LRU so a churn of distinct destinations can't
/// exhaust it: the coldest key is evicted and simply re-emits once. Connect is the
/// firehose probe; the other probes are already volume-bounded (in-kernel filtered to rare
/// events), so dedup is applied to connect only — the per-(pid, dest) case the ticket names.
#[map]
static CONN_SEEN: LruHashMap<ConnKey, u64> = LruHashMap::with_max_entries(DEDUP_MAP_CAP, 0);
/// Count of events coalesced (suppressed in-kernel) by a dedup map — connect repeats via
/// [`CONN_SEEN`] and file-write repeats via [`WRITE_SEEN`]. Same
/// per-CPU, one-slot shape as [`DROPS`]: each CPU bumps its own slot, no atomics; userspace
/// sums across CPUs and surfaces the cumulative total in the heartbeat, so the volume cut is
/// observable rather than invisible. Bumped only in [`record_coalesced`].
#[map]
static COALESCED: PerCpuArray<u64> = PerCpuArray::with_max_entries(1, 0);
/// Bump the per-CPU coalesced counter (slot 0). Called whenever the connect dedup map
/// suppresses a repeat. Verifier-safe: one bounded lookup + in-place increment, no loops.
fn record_coalesced() {
if let Some(slot) = COALESCED.get_ptr_mut(0) {
unsafe { *slot += 1 };
}
}
/// The connect dedup gate. Returns `true` if this connect to `key` should be
/// emitted, `false` if it's a repeat inside [`DEDUP_WINDOW_NS`] and was coalesced (the
/// counter is bumped here). On emit, stamps `now` so the next repeat is measured from it.
/// LRU insert can't fail meaningfully — if it ever did we fall through to emit (fail open:
/// never silently lose a real signal to a bookkeeping error). The first sighting of a key
/// (no entry) always emits.
fn allow_connect(key: &ConnKey) -> bool {
let now = unsafe { bpf_ktime_get_ns() };
if let Some(last) = CONN_SEEN.get_ptr_mut(key) {
// SAFETY: `last` points at this key's live slot; we read then overwrite it.
let last_ns = unsafe { *last };
if should_coalesce(last_ns, now, DEDUP_WINDOW_NS) {
record_coalesced();
return false;
}
unsafe { *last = now };
return true;
}
// First time we've seen this key (or it was LRU-evicted): record and emit.
let _ = CONN_SEEN.insert(key, &now, 0);
true
}
/// In-kernel file-write dedup map: `(pid, inode)` → last-emit time (ns).
/// File writes are high-frequency — a process appending to a log or rewriting a state file
/// hammers the SAME file — so coalescing repeats to the same `(pid, inode)` at the source
/// keeps a suppressed write from ever costing a ring-buffer slot (the volume problem the
/// ticket names). LRU, so a churn of distinct files can't exhaust it: the coldest key is
/// evicted and simply re-emits once. Same shape and window as the connect dedup.
#[map]
static WRITE_SEEN: LruHashMap<WriteKey, u64> = LruHashMap::with_max_entries(DEDUP_MAP_CAP, 0);
/// The file-write dedup gate, mirroring [`allow_connect`]. Returns `true` if this
/// write to `key` should be emitted, `false` if it's a repeat inside [`DEDUP_WINDOW_NS`] and
/// was coalesced (the shared [`COALESCED`] counter is bumped here). On emit, stamps `now` so
/// the next repeat is measured from it. Fail open: an insert that never fails falls through
/// to emit, so a bookkeeping error never silently loses a real signal. The first sighting of
/// a key (no entry) always emits.
fn allow_write(key: &WriteKey) -> bool {
let now = unsafe { bpf_ktime_get_ns() };
if let Some(last) = WRITE_SEEN.get_ptr_mut(key) {
// SAFETY: `last` points at this key's live slot; we read then overwrite it.
let last_ns = unsafe { *last };
if should_coalesce(last_ns, now, DEDUP_WINDOW_NS) {
record_coalesced();
return false;
}
unsafe { *last = now };
return true;
}
// First time we've seen this key (or it was LRU-evicted): record and emit.
let _ = WRITE_SEEN.insert(key, &now, 0);
true
}
/// In-kernel dedup map for the credential-basename read gate (security rework):
/// `(pid, inode)` → last-emit time (ns). Bounds a HIGH finding from security review: the
/// `try_file_open` widening past `is_tmpfs` to `SENSITIVE_CREDENTIAL_BASENAMES` had no
/// dedup, so a chatty reader of a matched basename (e.g. repeatedly opening `/etc/shadow`
/// or a `credentials` file) could flood the single shared ring and starve real exec/priv-
/// change/connect signals via `record_drop()` — a sensor-blinding primitive. Same shape,
/// sizing, and eviction style as [`WRITE_SEEN`].
#[map]
static CREDENTIAL_READ_SEEN: LruHashMap<ReadKey, u64> =
LruHashMap::with_max_entries(DEDUP_MAP_CAP, 0);
/// The credential-basename-read dedup gate (security rework), mirroring
/// [`allow_write`]. Returns `true` if this read of `key` should be emitted, `false` if
/// it's a repeat inside [`DEDUP_WINDOW_NS`] and was coalesced (the shared [`COALESCED`]
/// counter is bumped here). On emit, stamps `now` so the next repeat is measured from it.
/// Fail open: an insert that never fails falls through to emit, so a bookkeeping error
/// never silently loses a real signal. The first sighting of a key (no entry) always emits.
fn allow_credential_read(key: &ReadKey) -> bool {
let now = unsafe { bpf_ktime_get_ns() };
if let Some(last) = CREDENTIAL_READ_SEEN.get_ptr_mut(key) {
// SAFETY: `last` points at this key's live slot; we read then overwrite it.
let last_ns = unsafe { *last };
if should_coalesce(last_ns, now, DEDUP_WINDOW_NS) {
record_coalesced();
return false;
}
unsafe { *last = now };
return true;
}
// First time we've seen this key (or it was LRU-evicted): record and emit.
let _ = CREDENTIAL_READ_SEEN.insert(key, &now, 0);
true
}
/// In-kernel dedup map for the ptrace-attach probe: `pid` → last-emit time (ns).
/// `security_ptrace_access_check` fires on every PTRACE_MODE_ATTACH check — not just a
/// `ptrace(PTRACE_ATTACH/PTRACE_SEIZE)` syscall, but also `process_vm_readv`/
/// `process_vm_writev` (a debugger or monitoring tool reading another process's memory),
/// which a legitimate chatty caller can invoke in a tight loop. The dedup key is JUST the
/// attacking `pid` — no target (see [`try_ptrace_access_check`]'s doc for why the target
/// `task_struct` is never read): a repeat attach check from the SAME attacker inside the
/// window is the same "this pid is ptrace-attaching things" fact refreshed, not a new one.
/// Mirrors [`CREDENTIAL_READ_SEEN`]'s ring-DoS lesson — an unbounded fentry on a hook
/// with a legitimate high-frequency caller is exactly the shape that flooded the ring there.
#[map]
static PTRACE_SEEN: LruHashMap<u32, u64> = LruHashMap::with_max_entries(DEDUP_MAP_CAP, 0);
/// The ptrace-attach dedup gate, mirroring [`allow_credential_read`]. Returns
/// `true` if an attach check from `pid` should be emitted, `false` if it's a repeat inside
/// [`DEDUP_WINDOW_NS`] and was coalesced (the shared [`COALESCED`] counter is bumped here).
/// Fail open: an insert that never fails falls through to emit, so a bookkeeping error never
/// silently loses a real signal. The first sighting of a pid (or one LRU-evicted) always emits.
fn allow_ptrace(pid: u32) -> bool {
let now = unsafe { bpf_ktime_get_ns() };
if let Some(last) = PTRACE_SEEN.get_ptr_mut(&pid) {
// SAFETY: `last` points at this key's live slot; we read then overwrite it.
let last_ns = unsafe { *last };
if should_coalesce(last_ns, now, DEDUP_WINDOW_NS) {
record_coalesced();
return false;
}
unsafe { *last = now };
return true;
}
let _ = PTRACE_SEEN.insert(&pid, &now, 0);
true
}
// Minimal kernel sockaddr layout for the IPv4 case. We only touch the family and the
// `sockaddr_in` address/port; reads are bounds-checked by `bpf_probe_read_kernel`.
const AF_INET: u16 = 2;
#[repr(C)]
struct SockAddr {
sa_family: u16,
}
#[repr(C)]
struct SockAddrIn {
sin_family: u16,
sin_port: u16, // network byte order
sin_addr: u32, // network byte order
}
/// kprobe on `security_socket_connect(struct socket *, struct sockaddr *address, int)`.
#[kprobe]
pub fn connect(ctx: ProbeContext) -> u32 {
let _ = try_connect(&ctx);
0 // always 0 — observe-only, never perturb the syscall
}
fn try_connect(ctx: &ProbeContext) -> Result<(), i64> {
// 2nd arg is `struct sockaddr *address`.
let addr: *const SockAddr = ctx.arg(1).ok_or(1i64)?;
let mut family: u16 = 0;
let rc = unsafe {
bpf_probe_read_kernel(
&mut family as *mut u16 as *mut core::ffi::c_void,
core::mem::size_of::<u16>() as u32,
&(*addr).sa_family as *const u16 as *const core::ffi::c_void,
)
};
if rc != 0 || family != AF_INET {
return Ok(());
}
let sin = addr as *const SockAddrIn;
let mut daddr: u32 = 0;
let mut dport: u16 = 0;
unsafe {
bpf_probe_read_kernel(
&mut daddr as *mut u32 as *mut core::ffi::c_void,
core::mem::size_of::<u32>() as u32,
&(*sin).sin_addr as *const u32 as *const core::ffi::c_void,
);
bpf_probe_read_kernel(
&mut dport as *mut u16 as *mut core::ffi::c_void,
core::mem::size_of::<u16>() as u32,
&(*sin).sin_port as *const u16 as *const core::ffi::c_void,
);
}
let pid = (aya_ebpf::helpers::bpf_get_current_pid_tgid() >> 32) as u32;
let dport = u16::from_be(dport);
// coalesce high-frequency repeats in-kernel. A connect to the same
// (pid, daddr, dport) seen again within DEDUP_WINDOW_NS is suppressed here — it never
// reaches the ring buffer — cutting volume at the source rather than draining + dropping
// duplicates in userspace. The first sighting (and one per window thereafter) emits.
if !allow_connect(&ConnKey::new(pid, daddr, dport)) {
return Ok(());
}
if let Some(mut slot) = EVENTS.reserve::<ConnEvent>(0) {
slot.write(ConnEvent {
header: make_header(KIND_CONNECT),
daddr,
dport,
});
slot.submit(0);
} else {
record_drop(); // ring full — count the loss instead of silently skipping
}
Ok(())
}
/// tmpfs superblock magic. Kubernetes Secret / ConfigMap / projected volumes are all
/// tmpfs, so this is the in-kernel filter. It's broad (also /tmp, /dev/shm, emptyDir-
/// memory, SA tokens) but tmpfs *opens* are moderate volume — far below the full
/// file-open firehose — and the ENGINE narrows to real Secret mounts. We can't filter to
/// secrets precisely in-kernel: bpf_d_path returns the container-relative path, which has
/// no universal secret marker (see docs/ebpf-testing-on-nodes.md).
const TMPFS_MAGIC: u64 = 0x0102_1994;
/// A small, fixed allowlist of on-host credential-file BASENAMES (Retire-Falco
/// G3) — the cheap in-kernel volume gate that lets `try_file_open` widen past `is_tmpfs`
/// for a read that might be the host shadow/gshadow/sudoers file, an SSH private key, or a
/// cloud-provider credential file. These live on the container's ordinary rootfs
/// (overlayfs), not tmpfs, so `is_tmpfs` alone never sees them — but letting EVERY
/// non-tmpfs read through would reopen the full file-open firehose `is_tmpfs` exists to
/// avoid. This basename check is deliberately short and distinctive (unlike `is_tmpfs`,
/// which is broad by design) so it stays nowhere near that volume. The volume this list
/// lets past `is_tmpfs` is additionally bounded in-kernel by [`allow_credential_read`] (a
/// second HIGH finding: an unbounded matched-basename read let an attacker flood the
/// shared ring — see its doc comment).
///
/// This is NOT the security classification — same division of labor as the existing
/// tmpfs-scoped probe: the agent stays pure data, and the engine
/// (`engine::observe::host_credential_class`) makes the real "is this path a known
/// on-host credential path" call from the FULL path `bpf_d_path` returns below.
///
/// **KEEP IN SYNC with `engine::observe::host_credential_class`** (security rework, HIGH
/// finding): this list is the coarse in-kernel PRE-filter, that module makes the precise
/// security decision — a basename here that the engine no longer classifies as a
/// credential is pure ring pressure with zero detection value. `passwd` and `known_hosts`
/// were REMOVED (world-readable / hold no secret material, matching
/// `host_credential_class::EXACT_HOST_PATHS` dropping `passwd` and
/// `SSH_NON_CREDENTIAL_BASENAME` excluding `known_hosts`); `gshadow` was ADDED (matches
/// `EXACT_HOST_PATHS`). The coarse basename `credentials` deliberately stays even though
/// it's shared by every cloud provider's config dir — the engine does the precise
/// directory-paired path check (`CLOUD_CREDENTIAL_FILES`); the dedup gate above bounds how
/// often a match on it can cost a ring slot.
const SENSITIVE_CREDENTIAL_BASENAMES: &[&[u8]] = &[
b"shadow",
b"gshadow",
b"sudoers",
b"authorized_keys",
b"id_rsa",
b"id_dsa",
b"id_ecdsa",
b"id_ed25519",
b"credentials",
b"application_default_credentials.json",
b"azureProfile.json",
b"accessTokens.json",
b"msal_token_cache.json",
];
/// Read buffer for [`is_sensitive_credential_basename`] — sized to the longest entry in
/// [`SENSITIVE_CREDENTIAL_BASENAMES`] (`application_default_credentials.json`, 38 bytes)
/// plus room for the NUL terminator.
const CREDENTIAL_BASENAME_CAP: usize = 48;
/// `PROT_EXEC` — an executable memory mapping. The dynamic linker mmaps shared objects
/// (and the main binary) executable, so this distinguishes a code load from a data mmap.
const PROT_EXEC: u64 = 0x4;
/// open(2) access-mode mask and the read-only mode. `f_flags & O_ACCMODE != O_RDONLY` is a
/// write-intent open.
const O_ACCMODE: u64 = 0o3;
const O_RDONLY: u64 = 0o0;
/// `O_CREAT` (a new file) and `O_TRUNC` (an existing file truncated): both are write intent
/// even when the access mode alone wouldn't say so — drop-and-execute *creates* a file,
/// config tampering *truncates* one. Filtering to write intent keeps the read firehose off
/// the ring (the file-open volume is dominated by reads).
const O_CREAT: u64 = 0o100;
const O_TRUNC: u64 = 0o1000;
/// Whether an `open` with these `f_flags` is a **write**: a non-read-only access
/// mode, or a create/truncate. This is the in-kernel filter that keeps the (very high)
/// read-open volume off the ring buffer — only write-intent opens become FileWrite events.
fn is_write_open(flags: u64) -> bool {
(flags & O_ACCMODE) != O_RDONLY || (flags & (O_CREAT | O_TRUNC)) != 0
}
/// fentry on `security_file_open(struct file *file)` — the secret-read probe (ADR-0014).
/// For a tmpfs read, emits a [`FileEvent`] with the container-relative path via
/// `bpf_d_path`; the engine maps it to a SecretRead (or drops it). Filtering to tmpfs
/// in-kernel keeps the (very high) file-open volume off the ring buffer. widens
/// this past tmpfs for a small, fixed allowlist of on-host credential-file basenames (see
/// [`SENSITIVE_CREDENTIAL_BASENAMES`]), bounded by the [`allow_credential_read`] dedup gate
/// (security rework) so a chatty reader of a matched basename can't flood the ring — ON-NODE
/// LOAD VALIDATION PENDING for that widening (docs/ebpf-testing-on-nodes.md: this crate
/// can't be compiled or verifier-tested off the fleet). Observe-only.
#[fentry(function = "security_file_open")]
pub fn file_open(ctx: FEntryContext) -> u32 {
let _ = try_file_open(&ctx);
0
}
fn try_file_open(ctx: &FEntryContext) -> Result<(), i64> {
// security_file_open's first argument is `struct file *file`.
let file: *const vmlinux::file = unsafe { ctx.arg(0) };
if file.is_null() {
return Ok(());
}
if is_tmpfs(file) {
emit_file_path(file, KIND_FILE_OPEN);
return Ok(());
}
if is_sensitive_credential_basename(file) {
// security rework: dedup gate on (pid, inode) — a chatty reader of a
// matched basename (e.g. hammering `/etc/shadow` or a `credentials` file) must not
// be able to flood the single shared ring and starve real exec/priv-change/connect
// signals. A missing inode still emits (fail open, mirrors `try_file_write`): the
// dedup is a volume optimization, not a correctness gate.
let pid = (aya_ebpf::helpers::bpf_get_current_pid_tgid() >> 32) as u32;
if let Some(ino) = inode_ino(file) {
if !allow_credential_read(&ReadKey::new(pid, ino)) {
return Ok(());
}
}
emit_file_path(file, KIND_FILE_OPEN);
}
Ok(())
}
/// fentry on `security_file_open(struct file *file)` — the file-write probe (
/// ADR-0014). A SECOND program on the same LSM hook as the secret-read probe (aya loads
/// each program independently), filtered IN-KERNEL to write-intent opens so the read
/// firehose never reaches the ring. For a write it emits a [`FileEvent`] (kind
/// [`KIND_FILE_WRITE`]) with the file's path via `bpf_d_path` (allowed here — same hook the
/// secret-read probe d_paths from). Repeats to the same `(pid, inode)` inside the dedup
/// window are coalesced in-kernel ([`allow_write`]) so a chatty writer can't flood the ring.
/// Observe-only; a failed read drops the event, never errors the probe. NOTE: attaches to
/// the BTF-exported `security_file_open` (the `security_*` LSM symbol) — not a syscall
/// tracepoint — which is why the agent survives the arm64 kernel that a syscall-tracepoint
/// sensor cannot.
#[fentry(function = "security_file_open")]
pub fn file_write(ctx: FEntryContext) -> u32 {
let _ = try_file_write(&ctx);
0
}
fn try_file_write(ctx: &FEntryContext) -> Result<(), i64> {
// security_file_open's first argument is `struct file *file`.
let file: *const vmlinux::file = unsafe { ctx.arg(0) };
if file.is_null() {
return Ok(());
}
// Filter to write-intent opens in-kernel — the read firehose never reaches the ring.
let mut flags: u32 = 0;
unsafe {
if read_kernel(&mut flags, core::ptr::addr_of!((*file).f_flags)) != 0 {
return Ok(());
}
}
if !is_write_open(flags as u64) {
return Ok(());
}
// Coalesce repeat writes to the same (pid, inode) in-kernel. A write whose
// inode is unreadable still emits (fail open) — the dedup is a volume optimization, not
// a correctness gate, so a missing inode must never silently drop a real write.
let pid = (aya_ebpf::helpers::bpf_get_current_pid_tgid() >> 32) as u32;
if let Some(ino) = inode_ino(file) {
if !allow_write(&WriteKey::new(pid, ino)) {
return Ok(());
}
}
emit_file_path(file, KIND_FILE_WRITE);
Ok(())
}
/// fentry on `security_mmap_file(struct file *file, unsigned long prot, unsigned long
/// flags)` — the library-load probe (ADR-0014). An executable mmap of a file is the
/// dynamic linker loading a shared object (or the main binary); emit its name so
/// userspace can name the loaded library. Anonymous/non-exec mmaps are skipped.
#[fentry(function = "security_mmap_file")]
pub fn mmap_file(ctx: FEntryContext) -> u32 {
let _ = try_mmap_file(&ctx);
0
}
fn try_mmap_file(ctx: &FEntryContext) -> Result<(), i64> {
let file: *const vmlinux::file = unsafe { ctx.arg(0) };
if file.is_null() {
return Ok(()); // anonymous mapping — not a file/code load
}
let prot: u64 = unsafe { ctx.arg(1) };
if prot & PROT_EXEC == 0 {
return Ok(()); // not executable — a data mapping, not a code load
}
// NOT emit_file_path: bpf_d_path is rejected by the verifier in security_mmap_file
// (security_mmap_file isn't on the kernel's d_path allowlist, unlike
// security_file_open —). Userspace only needs the library *name*, which is the
// leaf basename, so read the dentry's d_name directly with bpf_probe_read_kernel.
emit_lib_name(file);
Ok(())
}
/// fentry on `security_task_fix_setuid(struct cred *new, const struct cred *old, int flags)`
/// — the privilege-change probe (ADR-0014). This LSM hook
/// runs on every credential change (setuid/setresuid/…), so we filter IN-KERNEL to the only
/// case worth a signal: a process *gaining* root (`new->uid.val == 0 && old->uid.val != 0`).
/// That keeps ring volume tiny and the signal meaningful — a non-root process becoming root.
/// Reads the cred `uid.val` fields with `bpf_probe_read_kernel` (never bpf_d_path —).
/// Observe-only; a failed read drops the event, never errors the probe.
#[fentry(function = "security_task_fix_setuid")]
pub fn fix_setuid(ctx: FEntryContext) -> u32 {
let _ = try_fix_setuid(&ctx);
0
}
fn try_fix_setuid(ctx: &FEntryContext) -> Result<(), i64> {
// arg0 = `struct cred *new`, arg1 = `const struct cred *old`.
let new: *const vmlinux::cred = unsafe { ctx.arg(0) };
let old: *const vmlinux::cred = unsafe { ctx.arg(1) };
if new.is_null() || old.is_null() {
return Ok(());
}
// cred->uid is a kuid_t { val: u32 } — chase to the u32 with bpf_probe_read_kernel.
let mut new_uid: u32 = 0;
let mut old_uid: u32 = 0;
unsafe {
if read_kernel(&mut new_uid, core::ptr::addr_of!((*new).uid.val)) != 0 {
return Ok(());
}
if read_kernel(&mut old_uid, core::ptr::addr_of!((*old).uid.val)) != 0 {
return Ok(());
}
}
// Emit ONLY on escalation to root: a non-root process becoming root. Lateral or
// de-escalating credential changes (the bulk of setuid traffic) are dropped here.
if !(new_uid == 0 && old_uid != 0) {
return Ok(());
}
if let Some(mut slot) = EVENTS.reserve::<PrivEvent>(0) {
slot.write(PrivEvent {
header: make_header(KIND_PRIV_CHANGE),
old_uid,
new_uid,
});
slot.submit(0);
} else {
record_drop(); // ring full — count the loss instead of silently skipping
}
Ok(())
}
/// fentry on `security_bprm_check(struct linux_binprm *bprm)` — the process-exec probe
/// (ADR-0014). This LSM hook fires on every `execve` once the new binary is
/// resolved, so `bprm->filename` is the path the kernel is about to exec. Emits an
/// [`ExecEvent`] (kind [`KIND_EXEC`]) carrying that path plus the anon-inode fact
/// (below); userspace turns it into a `ProcessExec`. Observe-only. NOTE: the
/// attach point is `security_bprm_check` (the exported LSM call, in BTF — like the other
/// `security_*` probes); the un-prefixed `bprm_check_security` is NOT a BTF function on
/// 6.8 (verified on-node: deploy). Attached via **fentry, not `lsm/*`**: the fleet
/// does not carry `bpf` in its active LSM list (`CONFIG_LSM` omits it, no `lsm=` on the
/// kernel cmdline — confirmed on-node over SSH on both arches), so an `lsm/` program would
/// never attach here; fentry on the `security_*` function works regardless of the active
/// LSM list, which is why every probe in this file uses it.
///
/// (fileless exec / memfd_create parity with Falco), Route A: an EARLIER version
/// of this signal classified the exec *path's shape* (`/dev/fd/<n>` etc.) — withdrawn by
/// security review, because the kernel synthesizes that identical string for a benign
/// `fexecve()` of an on-disk file too, and runc copies itself into a memfd and re-execs on
/// ~every container start (the CVE-2019-5736 mitigation), so path shape alone forged
/// corroboration on routine behavior. The real signal is the *inode*, not the path: a
/// memfd/anonymous-fd exec's backing file lives on a shmem/tmpfs superblock and/or is
/// unlinked (`i_nlink == 0`), which a normal on-disk, directory-linked executable is not.
/// [`exe_is_anon_inode`] reads `bprm->file->f_inode` to determine that, straight from the
/// kernel's own resolution — no path parsing at all.
#[fentry(function = "security_bprm_check")]
pub fn bprm_check(ctx: FEntryContext) -> u32 {
let _ = try_bprm_check(&ctx);
0
}
fn try_bprm_check(ctx: &FEntryContext) -> Result<(), i64> {
// security_bprm_check's first argument is `struct linux_binprm *bprm`.
let bprm: *const vmlinux::linux_binprm = unsafe { ctx.arg(0) };
if bprm.is_null() {
return Ok(());
}
emit_exec_path(bprm, exe_is_anon_inode(bprm));
Ok(())
}
/// Emit the exec'd binary's path (plus the anon-inode fact) as a [`KIND_EXEC`]
/// [`ExecEvent`]. `bprm->filename` is a kernel `char *` (the resolved exec path), so —
/// like the library-load probe — read the string directly with `bpf_probe_read_kernel_str`.
/// NOT `bpf_d_path`: `security_bprm_check` isn't on the kernel's d_path allowlist, so the
/// verifier would reject it.
fn emit_exec_path(bprm: *const vmlinux::linux_binprm, exe_anon_inode: bool) {
let mut ev = ExecEvent {
header: make_header(KIND_EXEC),
len: 0,
path: [0u8; PATH_CAP],
exe_anon_inode: exe_anon_inode as u8,
};
// Read the `char *filename` pointer out of the binprm, then the string it points to.
let mut name_ptr: *const u8 = core::ptr::null();
unsafe {
if read_kernel(&mut name_ptr, core::ptr::addr_of!((*bprm).filename).cast()) != 0
|| name_ptr.is_null()
{
return;
}
}
let n = unsafe {
bpf_probe_read_kernel_str(
ev.path.as_mut_ptr() as *mut core::ffi::c_void,
PATH_CAP as u32,
name_ptr as *const core::ffi::c_void,
)
};
if n <= 0 {
return;
}
ev.len = if (n as usize) < PATH_CAP {
n as u32
} else {
PATH_CAP as u32
};
if let Some(mut slot) = EVENTS.reserve::<ExecEvent>(0) {
slot.write(ev);
slot.submit(0);
} else {
record_drop(); // ring full — count the loss instead of silently skipping
}
}
/// Whether the exec'd binary's backing inode is anonymous (Route A): a
/// memfd/shmem-backed file (`inode->i_sb->s_magic` is the tmpfs magic — `memfd_create` is
/// shmem-backed under the hood) OR an unlinked file (`inode->i_nlink == 0` — covers a
/// memfd, which is never linked into any directory, AND the separate "delete the binary
/// while it's still executing" technique on a normal filesystem). Reads
/// `bprm->file->f_inode`: `bprm->file` is the ALREADY-OPENED executable (opened before
/// this hook fires — see the doc on [`bprm_check`]), so this is the SAME file the kernel
/// is about to run, not a TOCTOU-able separate lookup. A failed read = "not anonymous"
/// (fail closed on the flag, matching [`is_tmpfs`]/[`inode_ino`]'s existing convention).
///
/// PURE DATA: this reports a kernel fact only. Whether an anon-inode exec is
/// alarming — and the runc-memfd-reexec false-positive risk that makes this conservative
/// — is engine policy, not decided here.
fn exe_is_anon_inode(bprm: *const vmlinux::linux_binprm) -> bool {
unsafe {
let mut file: *mut vmlinux::file = core::ptr::null_mut();
if read_kernel(&mut file, core::ptr::addr_of!((*bprm).file)) != 0 || file.is_null() {
return false;
}
let Some(inode) = inode_of(file as *const vmlinux::file) else {
return false;
};
let mut nlink: u32 = 1; // fail closed: a failed read must not read as "unlinked"
if read_kernel(&mut nlink, core::ptr::addr_of!((*inode).i_nlink)) == 0 && nlink == 0 {
return true;
}
// Reuse the SAME `inode` already fetched above (not `is_tmpfs(file)`, which would
// re-walk `file->f_inode` a second time for the same fact).
superblock_magic(inode) == Some(TMPFS_MAGIC)
}
}
/// `PTRACE_MODE_ATTACH` (include/linux/ptrace.h) — set when the caller is asking to ATTACH
/// (`PTRACE_ATTACH`/`PTRACE_SEIZE`, or a `process_vm_readv`/`process_vm_writev` cross-process
/// memory access), as opposed to a `PTRACE_MODE_READ`-only check (e.g. every `/proc/<pid>/…`
/// stat, which fires constantly and carries no injection signal). Filtering to this bit
/// in-kernel is the FIRST volume cut on this hook — see [`try_ptrace_access_check`].
const PTRACE_MODE_ATTACH: u32 = 0x02;
/// fentry on `security_ptrace_access_check(struct task_struct *child, unsigned int mode)` —
/// the ptrace-attach probe (Retire-Falco G2). Falco fires critical on a ptrace
/// ATTACH: the classic process-injection primitive (debugger-attach, code injection via
/// `PTRACE_POKETEXT`, credential/memory scraping via `process_vm_readv`). This hook fires on
/// EVERY ptrace access check, including the read-only `PTRACE_MODE_READ` checks
/// `/proc/<pid>/…` triggers constantly, so [`try_ptrace_access_check`] filters in-kernel to
/// `mode & PTRACE_MODE_ATTACH` before touching anything else — an ATTACH request
/// specifically, not a read-only check — then further dedups per attacking pid
/// ([`allow_ptrace`]) so a legitimate chatty caller (a debugger single-stepping via repeated
/// `process_vm_readv`) can't flood the ring (the ring-DoS lesson).
///
/// No vmlinux struct read at all: `mode` is passed BY VALUE (a plain `unsigned int`
/// register), and the attacking workload is already fully identified by [`make_header`]'s
/// pid/cgroup. **DECISION:** the target `task_struct`'s pid is deliberately NOT
/// read — `struct task_struct` is enormous and its layout shifts heavily across kernel
/// configs/versions (far more volatile than the already-ON-NODE-PENDING `linux_binprm`/
/// `inode` offsets from), so adding that offset here would be a materially bigger
/// verifier-rejection risk for a field the corroboration predicate below doesn't need — the
/// attacking pid alone is enough to scope the Falco-parity signal to the foothold entry.
#[fentry(function = "security_ptrace_access_check")]
pub fn ptrace_access_check(ctx: FEntryContext) -> u32 {
let _ = try_ptrace_access_check(&ctx);
0
}
fn try_ptrace_access_check(ctx: &FEntryContext) -> Result<(), i64> {
// security_ptrace_access_check's 2nd argument is `unsigned int mode`.
let mode: u32 = unsafe { ctx.arg(1) };
if mode & PTRACE_MODE_ATTACH == 0 {
return Ok(()); // a read-only access check — not the attach signal
}
let pid = (aya_ebpf::helpers::bpf_get_current_pid_tgid() >> 32) as u32;
if !allow_ptrace(pid) {
return Ok(());
}
emit_fact(KIND_PTRACE_ATTACH);
Ok(())
}
/// `enum kernel_load_data_id`'s `LOADING_MODULE` value (`include/linux/kernel_read_file.h`):
/// the enum is a stable, list-ordered generator macro — `LOADING_UNKNOWN`(0),
/// `LOADING_FIRMWARE`(1), `LOADING_MODULE`(2), `LOADING_KEXEC_IMAGE`(3),
/// `LOADING_KEXEC_INITRAMFS`(4), `LOADING_POLICY`(5), `LOADING_X509_CERTIFICATE`(6),
/// `LOADING_MAX_ID`(7). Sourced from the SHARED table in `protector-agent-common`
/// (ADR-0014 amendment) rather than a bare literal, so the userspace loader's load-time
/// BTF preflight (`agent/protector-agent/src/preflight`) checks the SAME value against
/// each node's live BTF at every agent start. Unlike a struct offset, a wrong value here
/// is NOT verifier-checked — it's a plain integer compare, so a reorder (unlikely; this
/// list has been stable since its 5.x introduction) would misclassify silently rather than
/// fail loud, which is exactly why the preflight checks it explicitly and logs
/// expected-vs-actual on a mismatch rather than relying on this compile-time value alone.
const LOADING_MODULE: u32 = protector_agent_common::offsets::LOADING_MODULE_VALUE;
/// fentry on `security_kernel_load_data(enum kernel_load_data_id id, bool contents)` — the
/// kernel-module-load probe (Retire-Falco G2). Falco fires critical on
/// `init_module`/`finit_module`. `load_module()` (kernel/module/main.c) calls this hook
/// EARLY — before any parsing — on BOTH syscalls: `init_module`'s in-memory buffer AND
/// `finit_module`'s fd (which first reaches `security_kernel_read_file(id=READING_MODULE)`
/// to read the fd into that same buffer, then falls through to the same `load_module()` call
/// this probe hooks). One probe on `security_kernel_load_data` therefore covers both
/// syscalls, with no `struct file`/path chase at all: `id` and `contents` are passed BY
/// VALUE (plain scalars), so — like the ptrace probe above — this touches no vmlinux struct
/// offset whatsoever. Filters in-kernel to `id == LOADING_MODULE`: the SAME hook also fires
/// for firmware/kexec/policy/x509 loads, which are not the Falco-parity signal this closes.
/// No dedup gate (unlike ptrace/credential-read above): a real module load is RARE in a
/// normal container workload (no `modprobe`/`insmod` in the entrypoint) — high signal, low
/// volume by construction.
#[fentry(function = "security_kernel_load_data")]
pub fn kernel_load_data(ctx: FEntryContext) -> u32 {
let _ = try_kernel_load_data(&ctx);
0
}
fn try_kernel_load_data(ctx: &FEntryContext) -> Result<(), i64> {
// security_kernel_load_data's 1st argument is `enum kernel_load_data_id id`.
let id: u32 = unsafe { ctx.arg(0) };
if id != LOADING_MODULE {
return Ok(());
}
emit_fact(KIND_MODULE_LOAD);
Ok(())
}
/// Emit a bare [`EventHeader`]-only fact of `kind` — shared by the ptrace-attach and
/// module-load probes, whose entire signal IS the occurrence, attributed by
/// [`make_header`]'s pid/cgroup, with no further payload. Unlike every other emitter in this
/// file there is no body struct: the ring event for these two kinds IS the header, so
/// userspace's `decode` needs no kind-specific byte parse beyond the header it already reads.
fn emit_fact(kind: u32) {
if let Some(mut slot) = EVENTS.reserve::<EventHeader>(0) {
slot.write(make_header(kind));
slot.submit(0);
} else {
record_drop(); // ring full — count the loss instead of silently skipping
}
}
/// bpf_d_path the file's path into a [`FileEvent`] of `kind` and submit it. Shared by the
/// secret-read (file_open) probe — it needs the full path so the engine can match it to a
/// Secret mount. (Library-load uses [`emit_lib_name`]: bpf_d_path is disallowed in its hook.)
fn emit_file_path(file: *const vmlinux::file, kind: u32) {
let mut ev = FileEvent {
header: make_header(kind),
len: 0,
path: [0u8; PATH_CAP],
};
// &file->f_path. bpf_d_path needs the arg to resolve (against kernel BTF, at the baked
// offset) to a `struct path`; the verifier walks `file` at `f_path`'s offset and checks
// it lands on `path`. So `f_path`'s offset in vmlinux::file MUST match the running kernel
// — a stale offset lands elsewhere and is rejected ("R1 is of type file …").
let path_ptr = unsafe { core::ptr::addr_of!((*file).f_path) };
let n = unsafe {
bpf_d_path(
path_ptr as *mut _,
ev.path.as_mut_ptr() as *mut _,
PATH_CAP as u32,
)
};
if n <= 0 {
return;
}
ev.len = if (n as usize) < PATH_CAP {
n as u32
} else {
PATH_CAP as u32
};
if let Some(mut slot) = EVENTS.reserve::<FileEvent>(0) {
slot.write(ev);
slot.submit(0);
} else {
record_drop(); // ring full — count the loss instead of silently skipping
}
}
/// Emit the library *name* (leaf basename) of `file` as a [`KIND_LIBRARY_LOAD`] event.
/// The library-load probe can't use `bpf_d_path` (the verifier rejects it in the
/// security_mmap_file hook — not on the kernel's d_path allowlist;). Userspace only
/// needs the basename to name the library, which is the leaf dentry's `d_name`, so read it
/// directly with bpf_probe_read_kernel(_str) — allowed in any program type.
fn emit_lib_name(file: *const vmlinux::file) {
let mut ev = FileEvent {
header: make_header(KIND_LIBRARY_LOAD),
len: 0,
path: [0u8; PATH_CAP],
};
// file->f_path.dentry, then dentry->d_name.name (the basename byte pointer).
let mut dentry: *mut vmlinux::dentry = core::ptr::null_mut();
let mut name_ptr: *const u8 = core::ptr::null();
unsafe {
if read_kernel(&mut dentry, core::ptr::addr_of!((*file).f_path.dentry)) != 0
|| dentry.is_null()
{
return;
}
if read_kernel(
&mut name_ptr,
core::ptr::addr_of!((*dentry).d_name.name).cast(),
) != 0
|| name_ptr.is_null()
{
return;
}
}
// Copy the NUL-terminated basename into the event buffer (returns bytes incl. NUL).
let n = unsafe {
bpf_probe_read_kernel_str(
ev.path.as_mut_ptr() as *mut core::ffi::c_void,
PATH_CAP as u32,
name_ptr as *const core::ffi::c_void,
)
};
if n <= 0 {
return;
}
ev.len = if (n as usize) < PATH_CAP {
n as u32
} else {
PATH_CAP as u32
};
if let Some(mut slot) = EVENTS.reserve::<FileEvent>(0) {
slot.write(ev);
slot.submit(0);
} else {
record_drop();
}
}
/// Whether `file`'s leaf dentry name is one of [`SENSITIVE_CREDENTIAL_BASENAMES`]
/// — the cheap volume gate for `try_file_open`'s past-tmpfs widening. Reads the
/// dentry's `d_name` directly rather than `bpf_d_path`ing every non-tmpfs open, the same
/// allowed-anywhere pattern as [`emit_lib_name`]. A failed read = "not sensitive" (drop,
/// never a false allow).
fn is_sensitive_credential_basename(file: *const vmlinux::file) -> bool {
let mut dentry: *mut vmlinux::dentry = core::ptr::null_mut();
let mut name_ptr: *const u8 = core::ptr::null();
unsafe {
if read_kernel(&mut dentry, core::ptr::addr_of!((*file).f_path.dentry)) != 0
|| dentry.is_null()
{
return false;
}
if read_kernel(
&mut name_ptr,
core::ptr::addr_of!((*dentry).d_name.name).cast(),
) != 0
|| name_ptr.is_null()
{
return false;
}
}
let mut buf = [0u8; CREDENTIAL_BASENAME_CAP];
let n = unsafe {
bpf_probe_read_kernel_str(
buf.as_mut_ptr() as *mut core::ffi::c_void,
CREDENTIAL_BASENAME_CAP as u32,
name_ptr as *const core::ffi::c_void,
)
};
if n <= 0 {
return false;
}
// `n` counts the trailing NUL (see emit_lib_name); clamp defensively before slicing so
// a bigger-than-expected return can never index out of `buf`.
let len = (n as usize).min(CREDENTIAL_BASENAME_CAP).saturating_sub(1);
SENSITIVE_CREDENTIAL_BASENAMES
.iter()
.any(|&want| want == &buf[..len])
}
/// Read `file->f_inode` — the pointer chase every inode-fact reader below starts from
/// ([`is_tmpfs`], [`inode_ino`], [`exe_is_anon_inode`]). `None` on a failed read or a null
/// inode; every caller treats that as "the fact I wanted isn't available" (fail closed for
/// an alarm-shaped bool, fail open for the dedup key — each caller's own choice).
unsafe fn inode_of(file: *const vmlinux::file) -> Option<*mut vmlinux::inode> {
unsafe {
let mut inode: *mut vmlinux::inode = core::ptr::null_mut();
if read_kernel(&mut inode, core::ptr::addr_of!((*file).f_inode)) != 0 || inode.is_null() {
return None;
}
Some(inode)
}
}
/// Read `inode->i_sb->s_magic` — the superblock magic every magic-comparing reader below
/// starts from ([`is_tmpfs`], [`exe_is_anon_inode`]). `None` on any failed read.
unsafe fn superblock_magic(inode: *mut vmlinux::inode) -> Option<u64> {
unsafe {
let mut sb: *mut vmlinux::super_block = core::ptr::null_mut();
if read_kernel(&mut sb, core::ptr::addr_of!((*inode).i_sb)) != 0 || sb.is_null() {
return None;
}
let mut magic: u64 = 0;
if read_kernel(&mut magic, core::ptr::addr_of!((*sb).s_magic).cast()) != 0 {
return None;
}
Some(magic)
}
}
/// Whether `file` lives on a tmpfs — `file->f_inode->i_sb->s_magic == TMPFS_MAGIC`. The
/// pointer chase uses bpf_probe_read_kernel (fixed offsets from the node-BTF vmlinux),
/// the same safe pattern as the connect probe. A failed read = "not tmpfs" (drop).
fn is_tmpfs(file: *const vmlinux::file) -> bool {
unsafe {
let Some(inode) = inode_of(file) else {
return false;
};
superblock_magic(inode) == Some(TMPFS_MAGIC)
}
}
/// Read `file->f_inode->i_ino` — the inode number, the file-write dedup key's identity
/// . The pointer chase uses bpf_probe_read_kernel (fixed offsets from the node-BTF
/// vmlinux), the same safe pattern as [`is_tmpfs`]. `None` on any failed read — the caller
/// then emits without deduping (fail open), never dropping a real write for a bookkeeping miss.
fn inode_ino(file: *const vmlinux::file) -> Option<u64> {
unsafe {
let inode = inode_of(file)?;
let mut ino: u64 = 0;
if read_kernel(&mut ino, core::ptr::addr_of!((*inode).i_ino).cast()) != 0 {
return None;
}
Some(ino)
}
}
/// bpf_probe_read_kernel a `T` from kernel address `src` into `dst`. Returns 0 on success.
unsafe fn read_kernel<T>(dst: &mut T, src: *const T) -> i64 {
unsafe {
bpf_probe_read_kernel(
dst as *mut T as *mut core::ffi::c_void,
core::mem::size_of::<T>() as u32,
src as *const core::ffi::c_void,
)
}
}
#[cfg(not(test))]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
loop {}
}