Skip to content

synguard and synapse_kmod

Velle Sinclair edited this page Jul 30, 2026 · 4 revisions

synguard and synapse_kmod

The security half of the system. synapse_kmod is a DKMS kernel module that kprobes syscalls and exports telemetry and AI scheduling hints through sysfs. synguard is the userspace monitor: it classifies those events, scores threats, and publishes verdicts on a feed that synui subscribes to live.

lsmod | grep synapse_kmod
cat /sys/kernel/synapse/status
systemctl status synguard

sysfs surface: /sys/kernel/synapse/syscall_log, ai_hints, stats, status, config, version.


Detection

  • Path / argument rules on syscalls.
  • Keylogger detection — alerts on /dev/input access.
  • Worm / trojan detection — a connect() probe recording destination IP:port, a netwatch per-PID fan-out alert (≥20 distinct hosts in 10s), persistence- write rules, and an AF_UNIX filter on socket().
  • Escalation rules for exec out of /proc and /dev (softened from deny to escalate, with /proc/self/exe explicitly allowed).

chibi has a read-only sentinel aspect that watches the same feed.


Enforcement — the two paths, and why they are not equivalent

There are two entirely different things synguard can do about a rule match, and the difference decides whether anything was actually prevented.

post-hoc kill in-kernel gate
Mechanism kprobe sees the syscall, daemon sends SIGKILL BPF-LSM file_open hook returns -EPERM
Timing after the fact before the file is opened
Prevents the access? no — it punishes it afterwards yes
Needs nothing --bpf-enforce, and a kernel with the BPF LSM active
synguard --mode enforce --bpf-enforce

Mode is separate from the gate. --mode decides whether a DENY verdict does anything at all; --bpf-enforce decides how:

Mode Meaning
audit log only, never block — the default, deliberately
enforce act on DENY verdicts
learning build a baseline profile, flag anomalies
lockdown block everything not explicitly allowed

Without --ai-enforce the classifier is advisory: a model verdict is clamped to alert and cannot reach a kill. Only rule verdicts can.

What the gate cannot do

  • It runs after the ordinary permission check. security_file_open is reached only once DAC has already said yes, so an open that fails with EACCES or ENOENT never reaches the gate at all. The gate can refuse an access that would otherwise have succeeded — it cannot "catch" one the kernel already refused.
  • The bpf LSM runs last in the stack and is additive. It can add a refusal; it cannot overrule an AppArmor or SELinux decision made earlier. Returning 0 means no opinion, not allow.
  • It cannot suspend a process. An LSM hook has no way to SIGSTOP, so quarantine is never lowered to the kernel — that verdict is userspace-only.
  • The deny target must exist. BPF never sees an open of a path that isn't there, and the post-hoc path sees it too late to matter. A rule pointed at a file that does not exist looks armed and does nothing.

A DENY that stands down

Enforcement declines to kill in exactly two cases, and both fail toward enforcing — when in doubt it still acts:

  1. The syscall failed. Nothing was accessed, so a kill prevents nothing.
  2. The gate is armed and live for that exact rule — the kernel already refused it, and a userspace kill only widens the blast radius.

An unknown outcome still kills, because "unknown" is not "failed". A rule the kernel was never given still kills, because then the post-hoc path is the only enforcement there is.

Detection is untouched in both cases: the alert still fires and the audit line is still written, with the reason enforcement stood down. Two counters make it visible rather than silent — failed-syscall-skips and kernel-enforced-skips in the stats line.

Why this matters. Before the kernel module reported the syscall's return value, an event said "this process opened this file" when what had happened was "it asked, and the kernel said no". Both were treated as an access. A process could be killed for an open it was never allowed to complete — and ld.so probes a nonexistent /etc/ld.so.preload on every exec, so a deny rule on that path is a rule against starting programs.

Setting up the in-kernel gate

1. Check the kernel already offers it. On stock Arch it does:

cat /sys/kernel/security/lsm
# capability,landlock,lockdown,yama,bpf   ← 'bpf' must be in this list

If bpf is absent, add it to the active LSM list on the kernel command line (lsm=capability,landlock,lockdown,yama,bpf) and reboot. It needs CONFIG_BPF_LSM=y and kernel BTF, both of which Arch's linux ships. bpf sits last in that list on purpose — the stack is additive, so this can add a refusal but never overrule an AppArmor or SELinux decision made earlier.

2. Know that nothing ships armed. SynapseOS installs 55 rules and not one deny or quarantine. The daemon runs --mode enforce, so acting is permitted; nothing loaded ever asks for it. As installed, synguard detects and alerts — it does not kill.

3. Write a rule the kernel can actually run. A BPF program gets map lookups and bounded string work — there is no fnmatch. synguard compiles each deny rule down to an exact string or a prefix and refuses anything it cannot express exactly, because approximating in either direction is a bug you would not find.

Enforceable in-kernel Userspace only
path /etc/ld.so.preload — exact path /tmp/*.sh* anywhere but the end
path /etc/profile.d/* — a directory, one level path /etc/*/shadow
comm sshd — exact path /dev/input/event?? and […] have no lowered form
comm python* — a prefix a deny rule with no path (it would match every open)

Four things must all be true, or the rule stays on the userspace path:

  • event open or event exec — the hooks are lsm/file_open and lsm/bprm_check_security. socket, ptrace, module, mount and setuid have no hook yet.
  • verdict deny, not quarantine — a hook can return -EPERM and nothing else.
  • the patterns lower (table above).
  • no earlier rule may pre-empt it. Rules are first-match-wins, and the kernel only knows the rules it was handed, so synguard refuses to lower a deny rule unless it can prove no earlier rule matches the same input. This is the part most likely to bite: the stock 00-base.rules opens with allow rules at priority 1.

event exec matches the binary, and comm is the process CALLING execve — the kernel does not install the new name until after the hook. So comm nginx + path /usr/bin/sh means "nginx ran a shell". A rule naming the target binary in comm never matches. An exec also opens its binary internally; synguard skips those (FMODE_EXEC) so an event open rule does not accidentally block execution.

4. Arm it. --bpf-enforce is deliberately not in the shipped unit — loading a policy and arming it are separate decisions, because this path prevents rather than reacts:

sudo systemctl edit synguard
# [Service]
# ExecStart=
# ExecStart=/usr/bin/synguard --foreground --mode enforce \
#     --rules /etc/synguard/rules.d/ --bpf-enforce

Without it, an enforceable rule is still compiled and loaded into the kernel maps — the daemon says so — but the gate stays shut.

5. Read back what actually happened. Every outcome has a distinct line:

journalctl -u synguard | grep bpf-lsm
Line Meaning
policy loaded — N enforceable rules lowered and in the maps
enforcement ARMED the gate is live
N rules loaded but NOT armed (--bpf-enforce …) loaded, gate shut
policy NOT loaded — rule '…': path pattern cannot be expressed in-kernel: "…" that rule named, userspace path unaffected
unavailable — synguard is detect-only this boot no BPF LSM on this kernel

The way out, if an armed rule locks you out of your own machine: add

synapse.bpf_enforce=0

to the kernel command line at the boot menu. synguard then never loads the BPF object and comes up detect-only. Do that before reaching for a live USB. The gate also fails open if synguard dies or wedges, and refuses to act for the first 30 seconds after it attaches, so a badly wrong rule still lets you log in.

A false positive costs a process tree. A kill takes the matched process and its descendants. Only PID 0/1, kernel threads and the SynapseOS daemons are protected — your desktop, editor and browser are not.

The authoritative long-form version ships on the system, with worked examples: /etc/synguard/rules.d/40-enforce.rules.example. It ends in .example, so nothing parses it until you copy it to .rules — and every rule in it is commented out.

Reading the stats line

synguard: stats — events=… rules=… ai=… ai-skipped=… denials=… alerts=…
  quarantines=… protected-skips=… stale-pid-skips=… failed-syscall-skips=…
  kernel-enforced-skips=… dropped=… suppressed=… lag=Nms/Nmax
  • dropped above zero means the ring overflowed and events were lost — a detector that misses what it was watching for reports nothing, which reads identically to a quiet system.
  • lag is how far behind the reader is. It exists because a blocking model call used to run inside the ring-drain loop and could stall it for seconds; classification now runs on its own worker thread, and the reader keeps draining.
  • denials counts verdicts, not preventions. Cross-check against kernel-enforced-skips to see how many were actually refused in-kernel.

Testing a deny rule without taking your session with it

Run the thing you expect to be denied as a transient unit, never from your own shell:

systemd-run --collect --pipe --wait -- cat /path/covered/by/the/rule

A DENY that resolves to a SIGKILL kills the process tree, and a shell you typed the command into is part of that tree. Testing this from a terminal is how you lose the terminal — and, if it was your login session, everything in it.


Anti-tamper hardening

  • Kernel-side protected-PID guard on ai_hints — previously anyone could write HINT pid=1 class=idle and DoS the system.
  • Probe self-integrity watchdog.
  • Lockdown self-pinrmmod returns EBUSY.
  • Tightened sysfs permissions; kptr_restrict raised 0 → 2.
  • An opt-in modules_disabled unit.

Why sig_enforce is not on by default here: with Secure Boot off — which is the shipped state — enabling module.sig_enforce=1 refuses to load unsigned out-of-tree modules, which on an NVIDIA machine means no GPU driver. syn-secureboot handles this properly — see Secure Boot.


The bug that should change how you read this code

For the entire life of the project, every path and argument rule was silently dead. ai was 0 throughout.

The kmod's kprobes read regs->di as the first syscall argument. But at a kprobe on a syscall entry point, regs->di is not the first argument — it's a pointer to the user's pt_regs. Every rule that inspected a path or an argument was therefore reading garbage, matching nothing, and reporting clean.

The fix (a6ce919, kmod + synguard pkgrel 6) was a syscall_uregs() deref. It resurrected detection wholesale and armed a pile of rules that had been dormant since they were written.

The lesson: a security monitor that reports "clean" is indistinguishable from a security monitor that is broken. If you add a rule, you must also add a positive test that makes it fire — otherwise you have no evidence it works, and silence will convince you everything is fine for months.


Other bugs worth knowing

The security feed leaked a client slot per departed subscriber. It wedged at 16 clients and then refused every new subscriber ("at capacity") while spamming the log about it forever. Fixed with a poll() reap and a 60s log throttle (pkgrel 3, 33d8272).

Stale DKMS modules survived kernel upgrades, failing to load with ENOEXEC while dkms status insisted all was well. See Development Notes — the short version is never trust dkms status; compare vermagic to uname -r.

Hardened systemd units didn't take effect because stale copies in /etc shadowed the packaged ones in /usr/lib. systemctl show -p FragmentPath.

See also: Secure Boot, Troubleshooting.

Clone this wiki locally