Repository navigation
Scoring
Every process gets a risk score built from the signals it raises.
| Signal | Config key | Default weight |
|---|---|---|
| exec from a temp directory | tmp_exec |
25 |
memfd_create |
memfd_create |
20 |
| exec of a memfd | memfd_exec |
65 |
| ptrace of a system daemon | ptrace_system_daemon |
45 |
| egress scan | egress_scan |
40 |
The weights live in [scoring.weights]; see
configuration.
Raised when a process successfully executes a file that:
- sits on a tmpfs filesystem, such as
/dev/shm, a tmpfs/tmp, or any bind mount of a tmpfs, or - has a path starting with
/tmp/,/var/tmp/or/dev/shm/, which catches a disk-backed/tmp, or - has a path too long for the kernel to render at all. A path that can't be shown not to be in a temp directory scores as if it were.
The path is the file's real path, resolved in the kernel, not argv[0].
For a script, it's the script, not its interpreter.
An exec of a memfd is also on tmpfs. It scores once, as an exec of a memfd, not as both.
Raised on every call to memfd_create, whether or not it succeeds.
Raised when a process executes a file whose path is /memfd:<name>: a
file created with memfd_create that has no name on any filesystem.
This is how fileless malware runs. The weight is 65 so that
memfd_create plus the exec (20 + 65 = 85) clears the quarantine
threshold with room to spare for the decay between the two calls.
Raised when a process attaches to a system daemon with PTRACE_ATTACH
or PTRACE_SEIZE and the kernel allows it. A refused attach, such as
one aimed at another user's process, doesn't score. A target is a
system daemon when it is pid 1, or when its cgroup (from
/proc/<pid>/cgroup) is directly a system.slice/*.service unit.
aegisd keeps a sliding window per process (2 seconds by default) of its outbound TCP connects. The signal fires when, inside the window, either:
- the process has connected to 20 or more distinct (address, port) pairs, or
- 10 or more of its connects have failed.
It fires once on crossing, then stays quiet until both counts drop back
below their thresholds. IPv4-mapped IPv6 addresses count as the IPv4
address. UDP isn't observed. The window and both counts are in
[scoring.scan].
Each process holds one component per signal kind. When a signal is raised:
- Its contribution is the weight, or 0 if an allowlist forgives it (see allowlists).
- The component for that kind becomes the larger of its current,
decayed value and the new contribution. Repeats don't stack: ten
memfd_createcalls score 20, not 200. - The process's score is the sum of its components, each decayed to now.
Decay is exponential. A component of value c set at time t₀ is worth c × 0.5^((t − t₀) / half-life) at time t. The default half-life is 30 seconds, so 45 points of ptrace are worth 22.5 after 30 s and about 5.6 after 90 s.
When a process forks, the child starts with a copy of the parent's components, still decaying from when the parent got them. A recycled pid never inherits anything: processes are tracked by pid and start time.
| Score | Tier | Verdict |
|---|---|---|
| below 50 | log | none |
| 50 or more, below 80 | throttle | would_throttle |
| 80 or more | quarantine | would_quarantine |
The thresholds are score_threshold_throttle and
score_threshold_quarantine in [general]. In audit mode a verdict is
only recorded. In enforce mode the tier is acted on, and the verdict
becomes throttled or quarantined once that worked.
Log-tier decisions aren't written anywhere by default. Run aegisd with
--debug to see every one on stderr.
A process opens an incident when its own score, inherited components included, reaches the throttle tier. From then on, a process that scores belongs to that incident if it, or any of its ancestors, is already a member. The nearest member ancestor decides which incident.
Once a process is in an incident, its tier is the higher of:
- its own score, and
- the incident score: for each signal kind, the strongest current component across all live members, summed.
That's what catches a chain split across processes. Say a program
started from /tmp (25) forks a helper, then scans the network (40).
At 65 it opens a would_throttle incident. The helper was forked
before the scan, so it carries only the 25. When the helper attaches
to sshd (45), its own score is 70, but the incident score is
25 + 40 + 45 = 110, and the incident becomes would_quarantine.
A tree in which no single process reaches the throttle tier opens no
incident. pid 1 and pid 2 (kthreadd) never open or join one, so an
incident is never rooted at init. Incidents covers what
gets recorded.
An allowlist entry cancels a signal's whole weight: the evidence records a modifier equal to minus the base, and the contribution is 0. The signal still appears in the process's evidence.
-
Exec from a temp directory: the parent of the process doing the
exec is in
excluded_parent_processes, or the process's SELinux type is inexcluded_selinux_domains. -
memfd_create: the caller is inmemfd_callers, or its SELinux type is inexcluded_selinux_domains. -
Exec of a memfd: the binary doing the exec is in
memfd_callers. -
ptrace of a system daemon: the tracer is in
ptrace_tracers. -
Egress scan: the scanner is in
scan_binaries, runs as uid 0, and has a controlling terminal.
A path on a list only counts when that binary verifies: RPM-owned and unchanged, or matching a pin. Name the real file, not a symlink to it: a symlink never verifies. See verified executables.
For an exec, "the binary doing the exec" is the image before the exec,
not the file it starts. That's what lets runc run its own memfd copy
without letting anything else run one.
A verified binary can still be running someone else's code. So a process gets no allowlist modifier at all, the SELinux-domain one included, when:
- its environment at exec holds
LD_PRELOAD,LD_AUDITorLD_LIBRARY_PATH, as the kernel read it before the new image ran; - its environment couldn't be read, ran past the 32 KiB the kernel reads, or hadn't been read within 1 second (all of these fail closed); or
- a tracer in the host's pid namespace attached to it with ptrace and the attach succeeded. Its children inherit that at fork, and its next exec clears it.
For a temp-directory exec the parent's state counts too, since the parent is what earns the modifier. Withholding doesn't change a binary's verification, and the evidence says what was withheld and why:
memfd_create; allowlist withheld: LD_PRELOAD, LD_AUDIT or LD_LIBRARY_PATH is set
For the two exec signals, the file that was run follows in brackets.
On a host that sets LD_LIBRARY_PATH globally, every allowlisted tool
loses its modifiers this way. Expect incidents from tools such as
runc or dnf there, and look for allowlist withheld: in their
evidence. These checks still have gaps; the
threat model
explains how they can be beaten.
Verification takes a moment (an rpm query and a hash), and so does
the environment read. A signal that needs either is held for up to
6 seconds, then scored with whatever is known by then: unverified if
no verdict came, tainted if no environment did. Up to 64 signals are
held per process and 8,192 in all; past either cap they score at once.
These come from the end-to-end scenarios in tests/e2e/scenarios.toml,
run against a real Rocky 10 host. "throttle" and "quarantine" mean
would_throttle and would_quarantine.
| Scenario | What happened | Score | Verdict |
|---|---|---|---|
tmp-dropper |
a payload dropped in /tmp and run |
25 | none |
port-scan |
an egress scan of loopback | 40 | none |
memfd-exec |
memfd_create, then exec of it |
85 | quarantine |
ptrace-daemon |
/tmp strace copy traces sshd |
70 | throttle |
chain |
from /tmp: scan, then ptrace sshd |
110 | quarantine |
preload-memfd |
allowlisted memfd, LD_PRELOAD set |
85 | quarantine |
pinned-memfd |
the same without LD_PRELOAD
|
0 | none |
| benign | dnf, gdb, pip, podman, ssh login | < 50 | none |
Audit mode changes nothing on the host, which makes it a safe place to find out what's normal before you turn on enforce mode.
-
Install and start aegisd, then leave it for 48 to 72 hours. Cover whatever the host does weekly if you can: backups, deploys, patching.
-
List what it recorded:
aegisctl incidents list
-
For each incident, read the evidence:
aegisctl incidents show AEG-20261019-093000-0123456789abcdef
The
reasoncolumn says what fired and why, for exampleexec from a temp directory (/tmp/x). Each line undermembersends with that process's verification. Only binaries an allowlist or pin names are ever checked, so everything else showsunverified. -
Decide. If it's something you'd want to know about, leave it. If it's a legitimate tool, forgive it as narrowly as you can, in a drop-in such as
/etc/aegis-v2/allowlist.d/50-site.toml:- An RPM-owned binary: add its path to the matching list.
- A binary from outside the RPM database: add its path to the list
and pin its SHA-256 (
sha256sum /path/to/binary) under[allowlist.pinned]. - A confined service with its own SELinux type: add the type to
excluded_selinux_domains. Neverunconfined_t.
-
Check and reload:
aegisd --check-config && systemctl reload aegisd
Change weights or thresholds only when an allowlist can't express the fix, such as a host that legitimately scans all day. A lower weight applies to every process on the host, not just the one you meant.
A binary that verifies as modified is worth a closer look before you
allowlist anything: it's an RPM-owned file that no longer matches its
package, or a pinned file whose hash changed.
-
Weights that sum exactly to a threshold land just under it. Each
component decays a little between two events. Set
memfd_createto 25, and a temp-directory exec followed by amemfd_createscores a hair under 50, not 50. Leave a margin, asmemfd_execdoes. -
allowlist withheld:in an allowlisted tool's evidence means the tool ran with a loader variable, an unreadable environment, or after a ptrace attach. Find out which before you change anything. - A weight of 0 turns a signal off. Its evidence is still recorded if the process lands in an incident for another reason.
- A shorter half-life forgives slow, spread-out activity sooner; a longer one catches it but holds benign bursts against a process for longer.
From docs/scoring.md at v0.2.3.