Skip to content

Commit a7b1775

Browse files
authored
Merge pull request #172 from ErenAri/feat/aegis-next-p3
feat: denyComm, EnforceCapable probing, rate-limit enforcement
2 parents caab223 + 1bc1d6f commit a7b1775

32 files changed

Lines changed: 1138 additions & 58 deletions

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,45 @@ The operator includes a built-in web console for monitoring policy status, daemo
125125
- Zero JavaScript framework dependencies
126126
- Embedded in the operator binary via `go:embed`
127127

128+
## aegis-next Prototype
129+
130+
The `prototype/aegis-next/` directory contains a research prototype that
131+
explores **post-2024 BPF primitives** — features no shipping security agent
132+
uses as load-bearing infrastructure today. It is an isolated playground:
133+
not linked into the mainline daemon, not shipped in release packages, and
134+
gated behind `cmake -DBUILD_AEGIS_NEXT=ON`.
135+
136+
**Core idea:** an 82 MiB BPF arena map (`BPF_MAP_TYPE_ARENA`, kernel 6.9+)
137+
replaces per-event perfbuf/ringbuf round-trips. Nine LSM hooks write 80-byte
138+
provenance nodes directly into the arena. Userspace `mmap(2)`s the arena
139+
read-only and walks the slot array on demand — zero `bpf_map_lookup_elem`
140+
syscalls for graph traversal.
141+
142+
Key capabilities (all fully implemented):
143+
144+
| Phase | Highlights |
145+
|-------|------------|
146+
| **P1 — Arena Infrastructure** | O(1) hash table (64K buckets, 8-step probe), path slab (4K x 256B), network 5-tuple slab (4K x 48B), namespace awareness, ringbuf hybrid alerts |
147+
| **P2 — Enforcement** | Policy-driven deny/quarantine/kill, 9 LSM hooks, in-kernel quarantine bridge to sched_ext, tiered CPU scheduling (throttle/pin/starve), BPF self-protection |
148+
| **P3 — Production Readiness** | Ringbuf-only fallback (kernel < 6.9), 39 GTest cases, JSONL export with rotation, runtime feature probing, arena pre-fault |
149+
| **P4 — Beyond State of the Art** | In-kernel binary authorization (fsverity), user_ringbuf zero-copy policy reload, per-cgroup rate limiting with auto-quarantine, file security labeling (`bpf_set_dentry_xattr`), targeted signal delivery (`bpf_send_signal_task`) |
150+
151+
**No other open-source agent** combines arena-backed provenance graphs,
152+
sched_ext quarantine, and in-kernel binary authorization in a single BPF
153+
program.
154+
155+
Build and run:
156+
```bash
157+
cmake -DBUILD_AEGIS_NEXT=ON -S . -B build && cmake --build build --target aegisbpf-next
158+
sudo ./build/prototype/aegisbpf-next attach # arena + 9 LSM hooks
159+
sudo ./build/prototype/aegisbpf-next status # feature probes + utilization
160+
sudo ./build/prototype/aegisbpf-next policy load examples/policy.rules
161+
```
162+
163+
Full documentation: [`prototype/aegis-next/README.md`](prototype/aegis-next/README.md) |
164+
Roadmap status: [`prototype/aegis-next/ROADMAP.md`](prototype/aegis-next/ROADMAP.md) |
165+
Architecture: [`prototype/aegis-next/ARCHITECTURE.md`](prototype/aegis-next/ARCHITECTURE.md)
166+
128167
## Comparison with Other Tools
129168

130169
### Architecture & feature matrix

bpf/aegis_common.h

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,20 @@ struct {
465465
__type(value, __u8);
466466
} deny_path_map SEC(".maps");
467467

468+
/* Deny-by-comm map: blocks execution of binaries whose basename
469+
* matches a configured command name. Key is the 16-byte comm
470+
* string (TASK_COMM_LEN), value is unused. */
471+
#define MAX_DENY_COMM_ENTRIES 1024
472+
struct deny_comm_key {
473+
char comm[16]; /* TASK_COMM_LEN */
474+
};
475+
struct {
476+
__uint(type, BPF_MAP_TYPE_HASH);
477+
__uint(max_entries, MAX_DENY_COMM_ENTRIES);
478+
__type(key, struct deny_comm_key);
479+
__type(value, __u8);
480+
} deny_comm_map SEC(".maps");
481+
468482
struct {
469483
__uint(type, BPF_MAP_TYPE_PERCPU_HASH);
470484
__uint(max_entries, MAX_DENY_CGROUP_STATS_ENTRIES);

bpf/aegis_exec.bpf.h

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,61 @@ int BPF_PROG(handle_bprm_check_security, struct linux_binprm *bprm)
182182
return 0;
183183
}
184184

185+
/* deny_comm check: extract basename from bprm->filename and look up
186+
* in the deny_comm_map. This runs unconditionally — independent of
187+
* exec identity mode — so that comm-based deny works as a standalone
188+
* feature (e.g. blocking known crypto-miners by name). */
189+
{
190+
const char *fn_ptr = BPF_CORE_READ(bprm, filename);
191+
if (fn_ptr) {
192+
char fn[64] = {};
193+
long fn_len = bpf_probe_read_kernel_str(fn, sizeof(fn), fn_ptr);
194+
if (fn_len > 0) {
195+
int base_off = 0;
196+
#pragma unroll
197+
for (int i = 0; i < (int)sizeof(fn); ++i) {
198+
if (fn[i] == '\0')
199+
break;
200+
if (fn[i] == '/')
201+
base_off = i + 1;
202+
}
203+
struct deny_comm_key ck = {};
204+
/* Copy basename into key. Buffer is zero-initialized and
205+
* null-terminated, so a fixed 15-byte copy captures the
206+
* comm name (TASK_COMM_LEN - 1). Clamp base_off so we
207+
* never read past the 64-byte fn buffer. */
208+
if (base_off > 49)
209+
base_off = 49;
210+
__builtin_memcpy(ck.comm, &fn[base_off], 15);
211+
if (bpf_map_lookup_elem(&deny_comm_map, &ck)) {
212+
__u32 _pid = bpf_get_current_pid_tgid() >> 32;
213+
struct task_struct *_task = bpf_get_current_task_btf();
214+
__u8 _audit = get_effective_audit_mode();
215+
216+
increment_block_stats();
217+
218+
struct event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
219+
if (e) {
220+
e->type = EVENT_BLOCK;
221+
fill_block_event_process_info(&e->block, _pid, _task);
222+
e->block.cgid = bpf_get_current_cgroup_id();
223+
__builtin_memcpy(e->block.comm, ck.comm, 16);
224+
e->block.ino = 0;
225+
e->block.dev = 0;
226+
__builtin_memset(e->block.path, 0, sizeof(e->block.path));
227+
set_action_string(e->block.action, _audit, 0);
228+
bpf_ringbuf_submit(e, 0);
229+
}
230+
231+
if (!_audit) {
232+
record_hook_latency(HOOK_BPRM_CHECK, _start_ns);
233+
return -EPERM;
234+
}
235+
}
236+
}
237+
}
238+
}
239+
185240
if (!exec_identity_mode_enabled()) {
186241
record_hook_latency(HOOK_BPRM_CHECK, _start_ns);
187242
return 0;

docs/BPF_MAP_SCHEMA.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,29 @@ struct path_key {
186186
};
187187
```
188188

189+
### `deny_comm_map`
190+
191+
| Property | Value |
192+
|----------------|-------|
193+
| Type | `BPF_MAP_TYPE_HASH` |
194+
| Key | `struct deny_comm_key` (16 bytes) |
195+
| Value | `__u8` (presence flag) |
196+
| Max entries | 1,024 |
197+
| Pin path | `/sys/fs/bpf/aegisbpf/deny_comm` |
198+
| Access | BPF: read; Userspace: read/write |
199+
| Lifecycle | Managed by policy apply (shadow map swap) |
200+
201+
Process comm-name deny list. Checked in `bprm_check_security` by extracting
202+
the basename from `bprm->filename` and looking it up in this map. Entries are
203+
null-padded to `TASK_COMM_LEN` (16 bytes). Maximum comm length is 15 characters.
204+
205+
**Key struct:**
206+
```c
207+
struct deny_comm_key {
208+
char comm[16]; /* TASK_COMM_LEN */
209+
};
210+
```
211+
189212
---
190213

191214
## Network Deny Rules
@@ -926,6 +949,7 @@ struct forensic_event {
926949
| `survival_allowlist` | 16 + 1 = 17 B | 256 | ~4 KB |
927950
| `deny_inode_map` | 16 + 1 = 17 B | 65,536 | ~1.1 MB |
928951
| `deny_path_map` | 256 + 1 = 257 B | 16,384 | ~4.2 MB |
952+
| `deny_comm_map` | 16 + 1 = 17 B | 1,024 | ~17 KB |
929953
| `deny_ipv4` | 4 + 1 = 5 B | 65,536 | ~327 KB |
930954
| `deny_ipv6` | 16 + 1 = 17 B | 65,536 | ~1.1 MB |
931955
| `deny_port` | 4 + 1 = 5 B | 4,096 | ~20 KB |

helm/aegisbpf/templates/operator-deployment.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ spec:
2727
runAsNonRoot: true
2828
seccompProfile:
2929
type: RuntimeDefault
30+
{{- with .Values.operator.topologySpreadConstraints }}
31+
topologySpreadConstraints:
32+
{{- toYaml . | nindent 8 }}
33+
{{- end }}
3034
containers:
3135
- name: operator
3236
image: "{{ .Values.operator.image.repository }}:{{ .Values.operator.image.tag }}"

helm/aegisbpf/values.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,14 @@ operator:
261261
pullPolicy: IfNotPresent
262262

263263
replicas: 1
264+
# Pod topology spread for HA (effective when replicas > 1).
265+
topologySpreadConstraints:
266+
- maxSkew: 1
267+
topologyKey: kubernetes.io/hostname
268+
whenUnsatisfiable: ScheduleAnyway
269+
labelSelector:
270+
matchLabels:
271+
app.kubernetes.io/component: operator
264272

265273
leaderElect: true
266274
enableIdentityResolution: true

operator/cmd/operator/main.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,9 @@ func main() {
9191
eventBroker := console.NewBroker()
9292

9393
if err := (&controllers.AegisPolicyReconciler{
94-
Client: mgr.GetClient(),
95-
Scheme: mgr.GetScheme(),
94+
Client: mgr.GetClient(),
95+
Scheme: mgr.GetScheme(),
96+
Publisher: eventBroker,
9697
}).SetupWithManager(mgr); err != nil {
9798
logger.Error(err, "Unable to create AegisPolicy controller")
9899
os.Exit(1)
@@ -109,16 +110,18 @@ func main() {
109110
}
110111

111112
if err := (&controllers.AegisClusterPolicyReconciler{
112-
Client: mgr.GetClient(),
113-
Scheme: mgr.GetScheme(),
113+
Client: mgr.GetClient(),
114+
Scheme: mgr.GetScheme(),
115+
Publisher: eventBroker,
114116
}).SetupWithManager(mgr); err != nil {
115117
logger.Error(err, "Unable to create AegisClusterPolicy controller")
116118
os.Exit(1)
117119
}
118120

119121
if err := (&controllers.MergedPolicyReconciler{
120-
Client: mgr.GetClient(),
121-
Scheme: mgr.GetScheme(),
122+
Client: mgr.GetClient(),
123+
Scheme: mgr.GetScheme(),
124+
Publisher: eventBroker,
122125
}).SetupWithManager(mgr); err != nil {
123126
logger.Error(err, "Unable to create MergedPolicy controller")
124127
os.Exit(1)

operator/config/rbac/role.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ rules:
2121
- apiGroups: [""]
2222
resources: ["namespaces"]
2323
verbs: ["get", "list", "create"]
24+
# Nodes for EnforceCapable probing
25+
- apiGroups: [""]
26+
resources: ["nodes"]
27+
verbs: ["get", "list", "watch"]
2428
# Pods for identity resolution
2529
- apiGroups: [""]
2630
resources: ["pods"]

operator/controllers/aegisclusterpolicy_controller.go

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ const (
3030
// AegisClusterPolicyReconciler reconciles AegisClusterPolicy objects.
3131
type AegisClusterPolicyReconciler struct {
3232
client.Client
33-
Scheme *runtime.Scheme
33+
Scheme *runtime.Scheme
34+
Publisher EventPublisher // optional: pushes SSE events to console
3435
}
3536

3637
// +kubebuilder:rbac:groups=aegisbpf.io,resources=aegisclusterpolicies,verbs=get;list;watch;update;patch
@@ -71,7 +72,7 @@ func (r *AegisClusterPolicyReconciler) Reconcile(ctx context.Context, req ctrl.R
7172
}
7273
}
7374

74-
// Translate CRD → INI.
75+
// Translate CRD → INI (mainline daemon).
7576
result, err := policy.TranslateToINI(acp.Spec)
7677
if err != nil {
7778
logger.Error(err, "Failed to translate cluster policy")
@@ -80,8 +81,19 @@ func (r *AegisClusterPolicyReconciler) Reconcile(ctx context.Context, req ctrl.R
8081
fmt.Sprintf("Translation failed: %v", err))
8182
return r.updateStatus(ctx, &acp, "Error", fmt.Sprintf("Translation failed: %v", err), "")
8283
}
84+
85+
// Translate CRD → aegis-next line-based policy.
86+
nextResult, err := policy.TranslateToAegisNext(acp.Spec)
87+
if err != nil {
88+
logger.Error(err, "Failed to translate aegis-next cluster policy")
89+
markPolicyInvalid(&acp.Status, acp.Generation,
90+
v1alpha1.ReasonTranslationFailed,
91+
fmt.Sprintf("aegis-next translation failed: %v", err))
92+
return r.updateStatus(ctx, &acp, "Error", fmt.Sprintf("aegis-next translation failed: %v", err), "")
93+
}
94+
8395
markPolicyValid(&acp.Status, acp.Generation)
84-
markEnforceCapableUnknown(&acp.Status, acp.Generation)
96+
probeEnforceCapable(ctx, r.Client, &acp.Status, acp.Generation)
8597
if acp.Spec.Selector != nil {
8698
markLegacySelectorDeprecated(&acp.Status, acp.Generation)
8799
} else {
@@ -91,6 +103,7 @@ func (r *AegisClusterPolicyReconciler) Reconcile(ctx context.Context, req ctrl.R
91103
logger.Info("Translated cluster policy",
92104
"name", acp.Name,
93105
"hash", result.SHA256[:12],
106+
"nextHash", nextResult.SHA256[:12],
94107
"mode", acp.Spec.Mode,
95108
)
96109

@@ -111,9 +124,11 @@ func (r *AegisClusterPolicyReconciler) Reconcile(ctx context.Context, req ctrl.R
111124
},
112125
},
113126
Data: map[string]string{
114-
PolicyDataKey: result.INI,
115-
PolicyHashKey: result.SHA256,
116-
PolicyModeKey: acp.Spec.Mode,
127+
PolicyDataKey: result.INI,
128+
PolicyHashKey: result.SHA256,
129+
PolicyModeKey: acp.Spec.Mode,
130+
NextPolicyDataKey: nextResult.INI,
131+
NextPolicyHashKey: nextResult.SHA256,
117132
},
118133
}
119134

@@ -132,7 +147,7 @@ func (r *AegisClusterPolicyReconciler) Reconcile(ctx context.Context, req ctrl.R
132147
} else if err != nil {
133148
return ctrl.Result{}, err
134149
} else {
135-
if existing.Data[PolicyHashKey] != result.SHA256 {
150+
if existing.Data[PolicyHashKey] != result.SHA256 || existing.Data[NextPolicyHashKey] != nextResult.SHA256 {
136151
existing.Data = cm.Data
137152
existing.Labels = cm.Labels
138153
existing.Annotations = cm.Annotations
@@ -146,6 +161,9 @@ func (r *AegisClusterPolicyReconciler) Reconcile(ctx context.Context, req ctrl.R
146161
}
147162

148163
markReady(&acp.Status, acp.Generation, "Cluster policy translated and ConfigMap written")
164+
if r.Publisher != nil {
165+
r.Publisher.PublishReconcile("AegisClusterPolicy", acp.Name, "Applied", "Cluster policy applied successfully")
166+
}
149167
return r.updateStatus(ctx, &acp, "Applied", "Cluster policy applied successfully", result.SHA256)
150168
}
151169

operator/controllers/aegispolicy_controller.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ const (
4949
// AegisPolicyReconciler reconciles AegisPolicy objects.
5050
type AegisPolicyReconciler struct {
5151
client.Client
52-
Scheme *runtime.Scheme
52+
Scheme *runtime.Scheme
53+
Publisher EventPublisher // optional: pushes SSE events to console
5354
}
5455

5556
// +kubebuilder:rbac:groups=aegisbpf.io,resources=aegispolicies,verbs=get;list;watch;update;patch
@@ -119,7 +120,7 @@ func (r *AegisPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
119120
}
120121

121122
markPolicyValid(&ap.Status, ap.Generation)
122-
markEnforceCapableUnknown(&ap.Status, ap.Generation)
123+
probeEnforceCapable(ctx, r.Client, &ap.Status, ap.Generation)
123124
if ap.Spec.Selector != nil {
124125
markLegacySelectorDeprecated(&ap.Status, ap.Generation)
125126
} else {
@@ -200,6 +201,9 @@ func (r *AegisPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
200201
policyReconcileTotal.WithLabelValues("success").Inc()
201202
activePolicies.Inc()
202203
markReady(&ap.Status, ap.Generation, "Policy translated and ConfigMap written")
204+
if r.Publisher != nil {
205+
r.Publisher.PublishReconcile("AegisPolicy", ap.Namespace+"/"+ap.Name, "Applied", "Policy applied successfully")
206+
}
203207
return r.updateStatus(ctx, &ap, "Applied", "Policy applied successfully", result.SHA256)
204208
}
205209

0 commit comments

Comments
 (0)