triage: port the remaining 12 false-positive rules (full 18-rule engine)
Jaime Fournier
5addf0811d097f71d48c236801dce10378654b43
--- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage). | `analytics` grouping + `group_incidents` | `jsecmon/analytics.ss` | ✅ **untyped layer** — per-host accumulation/sort/top-N driving the risk-score kernel, plus incident dedup/collapse; secmon analytics vectors pass (`make analytics-check`) | | `storage::detect_lolbin_cmdline` + `detect_dga_domain` | `jsecmon/detect.ss` | ✅ **untyped layer** — the kernel-driven detection rules: score every process_start cmdline (lolbin) / dns_query (dga) into anomalies above threshold. `make detect-check` runs the full events→detect→analytics pipeline; all three scoring kernels fire. Per-pattern label lists + label-level DGA dedup pending (need kernels that return the match breakdown). | | `triage` classifiers | `typed/triage.ss` | ✅ pure predicates (transient-unit?, phantom-rootkit-race?); vectors pass | -| `triage` engine (rules + dispatch) | `jsecmon/triage.ss` | ✅ **untyped layer** — 6 false-positive rules + first-match engine, dispatch in ordinary Jerboa delegating byte/string classification to the typed kernels; 11 secmon triage vectors pass (`make triage-check`). Remaining rules are mechanical follow-on. | +| `triage` engine (rules + dispatch) | `jsecmon/triage.ss` | ✅ **untyped layer** — all 18 false-positive rules + first-match engine, in secmon's exact RULES order, dispatch in ordinary Jerboa delegating byte/string classification to the typed kernels; 40 triage vectors pass (`make triage-check`), incl. the security-relevant negatives (non-sshd reading host keys, systemd impersonated from /tmp, unknown daemon reading passwd). Remaining: `compute_triaged_ids` (triage↔storage exclude_event_ids integration). | | `sigma` | — | ⏳ YAML import — I/O, untyped layer | | `psk::constant_time_eq` | `typed/psk.ss` | ✅ ported, vectors pass | | `psk::from_hex` (hex codec) | `typed/psk.ss` | ✅ hex encode + decode + 32-byte precondition; vectors pass (decode∘encode identity over all 256 byte values) | --- a/examples/triage_check.ss +++ b/examples/triage_check.ss @@ -68,6 +68,62 @@ (none "container_event" "?" "{\"container_event_type\":\"NamespaceEscape\",\"details\":\"ROOTKIT: Hidden process detected! comm=evil pid=31337\"}") +;; ── the 12 follow-on rules (built from each rule's secmon match logic) ─────── +;; cron/logrotate/transient-unit privilege change (Expected). +(want "scheduled-job-privchange" 'expected + "privilege_change" "(logrotate)" "{\"new_id\":0}") +(want "scheduled-job-privchange" 'expected + "privilege_change" "cron" "{}") + +;; routine pseudo-fs mounts; a real-disk mount by an odd proc is NOT suppressed. +(want "pseudo-fs-mount" 'benign "mount_event" "systemd-nspawn" "{\"source\":\"proc\"}") +(want "pseudo-fs-mount" 'benign "mount_event" "podman" "{\"source\":\"overlay\"}") +(none "mount_event" "evil" "{\"source\":\"/dev/sda1\"}") + +;; NSS lookups by well-known daemons / transient units; unknown reader stays hot. +(want "nss-passwd-lookup" 'benign "sensitive_file_access" "sshd" "{\"path\":\"/etc/passwd\"}") +(want "nss-passwd-lookup" 'benign "sensitive_file_access" "nginx" "{\"path\":\"/etc/group\"}") +(want "nss-passwd-lookup" 'benign "sensitive_file_access" "(mandb)" "{\"path\":\"/etc/nsswitch.conf\"}") +(none "sensitive_file_access" "evilproc" "{\"path\":\"/etc/passwd\"}") + +;; cron/atd capability change (Expected) vs transient-unit capability (Benign). +(want "cron-capability-change" 'expected "capability_event" "crond" "{}") +(want "cron-capability-change" 'expected "capability_event" "atd" "{}") +(want "transient-unit-capability" 'benign "capability_event" "(mandb)" "{}") + +;; SELinux AVC denials by known daemons / transient units; granted/unknown skip. +(want "selinux-known-denial" 'expected "selinux_event" "systemd-udevd" "{\"event_type\":\"AvcDenied\"}") +(want "selinux-known-denial" 'expected "selinux_event" "(logrotate)" "{\"event_type\":\"AvcDenied\"}") +(none "selinux_event" "systemd-udevd" "{\"event_type\":\"AvcGranted\"}") +(none "selinux_event" "evilproc" "{\"event_type\":\"AvcDenied\"}") + +;; sshd reading its own keys/config; a NON-sshd reader of host keys stays hot. +(want "sshd-host-key-read" 'benign "sensitive_file_access" "sshd" "{\"path\":\"/etc/ssh/ssh_host_rsa_key\"}") +(want "sshd-host-key-read" 'benign "sensitive_file_access" "sshd-session" "{\"path\":\"/etc/ssh/sshd_config\"}") +(none "sensitive_file_access" "evilcat" "{\"path\":\"/etc/ssh/ssh_host_rsa_key\"}") + +;; /proc/self/* introspection, and explicit passwd/group lookup tools. +(want "self-proc-inspection" 'benign "sensitive_file_access" "myapp" "{\"path\":\"/proc/self/maps\"}") +(want "lookup-tool" 'benign "sensitive_file_access" "id" "{\"path\":\"/etc/passwd\"}") +(want "lookup-tool" 'benign "sensitive_file_access" "getent" "{\"path\":\"/etc/group\"}") + +;; transient-unit namespace setup; a real unshare by a non-transient proc stays hot. +(want "transient-unit-namespace" 'benign "namespace_event" "(mandb)" "{}") +(none "namespace_event" "unshare" "{}") + +;; PSI pressure counter reads. +(want "psi-pressure-read" 'benign "sensitive_file_access" "htop" "{\"path\":\"/proc/pressure/io\"}") + +;; systemd-executor memfd hand-off; impersonation from /tmp or w/o memfd still fires. +(want "systemd-executor-memfd" 'benign "process_start" "systemd-executor" + "{\"exe\":\"/usr/lib/systemd/systemd-executor\",\"cmdline\":[\"/proc/self/fd/4\"]}") +(want "systemd-executor-memfd" 'benign "process_start" "systemd" + "{\"exe\":\"/lib/systemd/systemd\",\"cmdline\":[\"/proc/self/fd/7\"]}") +(none "process_start" "systemd" + "{\"exe\":\"/tmp/evil\",\"cmdline\":[\"/proc/self/fd/4\"]}") +(none "process_start" "systemd-executor" + "{\"exe\":\"/usr/lib/systemd/systemd-executor\",\"cmdline\":[\"--unit=foo.service\"]}") + (newline) (if (= fails 0) (displayln "OK: untyped triage engine matches secmon's vectors.") --- a/jsecmon/triage.ss +++ b/jsecmon/triage.ss @@ -127,16 +127,204 @@ "/proc network monitor reporting a placeholder socket as a real connection" "The Linux /proc network monitor walks /proc/net/{tcp,udp} and maps socket inodes back to PIDs by scanning every /proc/<pid>/fd/. Short-lived sockets in transitional states (just after accept(), or after bind() before listen()) appear with placeholder local_addr=0.0.0.0 and local_port=0 and get attached to whichever long-lived process is scanned first. That triple is impossible for a real connect(2), so any event matching it is a /proc-walker artifact."))) + ;; ── lookup tables (verbatim from secmon's matches!() arms) ───────────────── + (def (in? x lst) (and (member x lst) #t)) + + (def cron-procs '("cron" "crond" "CRON" "anacron" "atd")) + + (def nss-files + '("/etc/passwd" "/etc/group" "/etc/shadow" "/etc/gshadow" + "/etc/nsswitch.conf" "/etc/hosts" "/etc/resolv.conf")) + + ;; Daemons that obviously do NSS lookups as part of normal operation. + (def nss-daemons + '("sshd" "sshd-session" "sshd-auth" "sshd-pam" + "login" "getty" "agetty" "su" "sudo" + "pickup" "qmgr" "master" "smtpd" "cleanup" "local" "trivial-rewrite" + "postfix" "postdrop" "postqueue" "sendmail" "exim" "exim4" + "cron" "crond" "CRON" "anacron" "atd" + "systemd" "systemd-tmpfile" "systemd-tmpfiles" "systemd-userdbd" + "systemd-logind" "systemd-networkd" "systemd-resolved" "systemd-journald" + "systemd-udevd" "systemd-machined" "systemd-hostnamed" + "dbus-daemon" "dbus" "polkitd" "polkit" "accounts-daemon" + "logrotate" "mandb" "rsyslogd" "syslog-ng" "auditd" "rsyncd" + "rpcbind" "rpc.statd" "named" "unbound" "chronyd" "ntpd" + "nginx" "httpd" "apache2" "lighttpd" "caddy" + "dockerd" "containerd" "runc" "podman" "snapd" + "gdm" "lightdm" "sddm" "Xorg" + "dpkg" "apt" "apt-get" "apt-key" "apt-config" "aptitude" + "yum" "dnf" "rpm" "zypper" "pacman" + "tar" "install" "cp" "mv" + "aide" "aide.wrapper" "dailyaidecheck" "tripwire" + "borg" "restic" "rsync" "duplicity" + "package-data-do" "unattended-upgr" "needrestart" "update-motd" + "run-parts" "apt-helper")) + + (def selinux-daemons + '("systemd" "systemd-udevd" "systemd-tmpfile" "systemd-tmpfiles" + "systemd-logind" "systemd-journald" "systemd-networkd" "systemd-resolved" + "systemd-machined" + "mount" "umount" "fsck" "modprobe" "insmod" "depmod" + "udevadm" "kmod" "lvm" "vgs" "lvs" "dmsetup" + "dbus-daemon" "polkitd" "accounts-daemon" + "NetworkManager" "wpa_supplicant" "dhclient" "dhcpcd" + "rsyslogd" "auditd" "chronyd" "ntpd" + "mandb" "logrotate" "updatedb" "mlocate" "plocate" + "apt" "dpkg" "unattended-upgr")) + + (def lookup-tools + '("id" "getent" "finger" "groups" "users" "who" "w" "whoami" + "lslogins" "passwd" "chage")) + (def lookup-paths '("/etc/passwd" "/etc/group" "/etc/shadow" "/etc/gshadow")) + + (def pseudo-fs-sources + '("proc" "sysfs" "tmpfs" "devtmpfs" "devpts" "mqueue" + "cgroup" "cgroup2" "overlay" "overlayfs" "fuse" "fusectl" + "configfs" "debugfs" "tracefs" "binfmt_misc" "securityfs" + "pstore" "bpf" "ramfs")) + + (def (sshd-key-path? p) + (or (string-prefix? "/etc/ssh/ssh_host_" p) + (string=? p "/etc/ssh/sshd_config") + (string-prefix? "/etc/ssh/sshd_config.d/" p) + (string-prefix? "/etc/ssh/moduli" p) + (string-prefix? "/etc/ssh/ssh_known_hosts" p))) + + ;; cmdline parses to a JSON list; secmon serializes it and substring-matches — + ;; equivalent to "some element contains the needle". + (def (cmdline-contains? data sub) + (let ((c (and data (hash-get data "cmdline")))) + (cond ((string? c) (and (string-contains c sub) #t)) + ((list? c) (and (any (lambda (s) (and (string? s) (string-contains s sub))) c) #t)) + (else #f)))) + + ;; ── rules (continued) ────────────────────────────────────────────────────── + + ;; cron/logrotate/transient-unit setuid/setgid while launching a scheduled job. + (def (rule-cron-logrotate-privchange ev) + (and (string=? (event-type ev) "privilege_change") + (or (transient-unit? (pname ev)) (in? (pname ev) cron-procs)) + (make-verdict 'expected "scheduled-job-privchange" + "Scheduled job (cron/logrotate/etc.) changing privileges" + "Cron jobs and systemd transient units routinely setuid/setgid as they switch from the daemon context to the job's target user (and sometimes back to root for cleanup). Worth a glance to confirm the job name is one you expect on this host, but no privsec implications on its own."))) + + ;; proc/sysfs/tmpfs/etc. mounts during boot, container startup, namespace setup. + (def (rule-pseudo-fs-mount ev) + (and (string=? (event-type ev) "mount_event") + (in? (d-str (event-data ev) "source") pseudo-fs-sources) + (make-verdict 'benign "pseudo-fs-mount" + "Routine pseudo-filesystem mount (proc/sysfs/tmpfs/etc.)" + "Mounts of kernel-provided virtual filesystems happen during boot, container startup, and namespace creation. Unless the mount target is somewhere unexpected, these are part of normal system bring-up."))) + + ;; A well-known daemon reading /etc/passwd|group|shadow for an NSS lookup. + (def (rule-nss-passwd-lookup ev) + (and (string=? (event-type ev) "sensitive_file_access") + (in? (d-str (event-data ev) "path") nss-files) + (or (in? (pname ev) nss-daemons) (transient-unit? (pname ev))) + (make-verdict 'benign "nss-passwd-lookup" + "Daemon reading /etc/passwd|group|shadow for name service lookup" + "Almost every system daemon reads /etc/passwd, /etc/group, and similar files as part of routine name service lookups (getpwnam, getgrnam, getpwuid). sshd alone does this on every connection. The 'sensitive file access' detector currently flags every read regardless of process, which produces thousands of alerts per hour on a normal host. Worth noting only if the reading process is unusual — and the unusual ones are exactly the events not matched by this rule."))) + + ;; cron/atd switching effective uid/gid (and caps) to launch a job. + (def (rule-cron-capability-change ev) + (and (string=? (event-type ev) "capability_event") + (in? (pname ev) cron-procs) + (make-verdict 'expected "cron-capability-change" + "cron / atd adjusting capabilities to launch a scheduled job" + "cron and atd switch their effective uid/gid (and the associated capability set) before exec'ing a scheduled job as the target user. This is normal scheduler behavior. If a specific job is suspicious, look at the process_start events that follow this capability change rather than the change itself."))) + + ;; SELinux AVC denial by a well-known daemon — a policy gap, not an attack. + (def (rule-known-selinux-denial ev) + (and (string=? (event-type ev) "selinux_event") + (string=? (d-str (event-data ev) "event_type") "AvcDenied") + (or (in? (pname ev) selinux-daemons) (transient-unit? (pname ev))) + (make-verdict 'expected "selinux-known-denial" + "SELinux AVC denial by a well-known system daemon" + "AVC denials from system daemons (systemd-udevd, mount, NetworkManager, dbus-daemon, etc.) are usually caused by reference-policy gaps rather than attacks. They're particularly common during boot, package upgrades, and udev cold-plug events. The right fix is usually a policy update or audit2allow rule, not an incident response. Investigate only if the audit message refers to an unusual target class or path."))) + + ;; systemd transient unit applying its capability set before exec'ing the job. + (def (rule-transient-unit-capability ev) + (and (string=? (event-type ev) "capability_event") + (transient-unit? (pname ev)) + (make-verdict 'benign "transient-unit-capability" + "systemd transient unit adjusting its capability set" + "Names in parentheses like (mandb), (logrotate), (cron) are systemd transient units. systemd applies CapabilityBoundingSet=, AmbientCapabilities=, and User= to these units before exec'ing the job, which the capability detector picks up as a change event. It's part of the unit's sandbox setup, not a privilege escalation."))) + + ;; sshd reading its own host keys / config — legitimate owner, fires per-connection. + (def (rule-sshd-host-key-read ev) + (and (string=? (event-type ev) "sensitive_file_access") + (sshd-proc? (pname ev)) + (sshd-key-path? (d-str (event-data ev) "path")) + (make-verdict 'benign "sshd-host-key-read" + "sshd reading its own host key / config" + "sshd reads /etc/ssh/ssh_host_*_key, sshd_config, and moduli on startup AND on each new connection (because forked workers re-read them). On a busy host this can produce thousands of reads per hour. The reader is the legitimate sshd daemon — the only thing to worry about would be a *non-sshd* process reading these paths, which this rule does not match."))) + + ;; A process reading its own /proc/self/* state — cannot escape its own memory. + (def (rule-self-proc-inspection ev) + (and (string=? (event-type ev) "sensitive_file_access") + (string-prefix? "/proc/self/" (d-str (event-data ev) "path")) + (make-verdict 'benign "self-proc-inspection" + "Process reading its own /proc/self/* state" + "/proc/self is a symlink to the calling process's own /proc/<pid> directory. Reads of /proc/self/maps, /proc/self/status, /proc/self/cmdline, and /proc/self/exe come from glibc, sanitizers, crash handlers, and self-introspecting tools — they cannot escape the calling process's own memory."))) + + ;; id/getent/finger/etc. reading passwd/group — literally what the tool does. + (def (rule-lookup-tool ev) + (and (string=? (event-type ev) "sensitive_file_access") + (in? (pname ev) lookup-tools) + (in? (d-str (event-data ev) "path") lookup-paths) + (make-verdict 'benign "lookup-tool" + "Explicit user/group lookup tool reading passwd/group" + "id, getent, finger, groups, who, etc. exist specifically to read and display /etc/passwd and /etc/group entries. Flagging them is always a false positive — that is literally what the program does."))) + + ;; systemd transient unit unshare/clone'ing into its private namespace. + (def (rule-transient-unit-namespace ev) + (and (string=? (event-type ev) "namespace_event") + (transient-unit? (pname ev)) + (make-verdict 'benign "transient-unit-namespace" + "systemd transient unit creating its private namespace" + "When systemd starts a transient unit configured with PrivateTmp=, PrivateNetwork=, ProtectSystem=, etc., it issues unshare(2) or clone(2) with the requested namespace flags BEFORE running the job. The namespace_event detector picks up the unshare/clone as 'namespace manipulation', but this is the sandbox setup, not an escape from one."))) + + ;; Reads of /proc/pressure/{cpu,memory,io} — read-only PSI kernel counters. + (def (rule-psi-pressure-read ev) + (and (string=? (event-type ev) "sensitive_file_access") + (string-prefix? "/proc/pressure/" (d-str (event-data ev) "path")) + (make-verdict 'benign "psi-pressure-read" + "Process reading /proc/pressure/{cpu,memory,io} (PSI counters)" + "/proc/pressure/cpu, /proc/pressure/memory, and /proc/pressure/io are Pressure Stall Information counters maintained by the kernel. systemd reads them from every transient unit it spawns to record resource accounting; htop, top, glances, and most monitoring agents read them too. They expose only kernel statistics — there's nothing sensitive to leak, and writing to them just resets the counters. The reads get flagged because /proc is on the sensitive-paths list."))) + + ;; systemd-executor exec'ing a memfd ELF via /proc/self/fd — canonical, not fileless attack. + (def (rule-systemd-executor-memfd ev) + (and (string=? (event-type ev) "process_start") + (let ((n (pname ev))) (or (string=? n "systemd") (string=? n "systemd-executor"))) + (let ((exe (d-str (event-data ev) "exe"))) + (or (string-prefix? "/usr/lib/systemd/" exe) (string-prefix? "/lib/systemd/" exe))) + (cmdline-contains? (event-data ev) "/proc/self/fd/") + (make-verdict 'benign "systemd-executor-memfd" + "systemd-executor handing off pre-loaded ELF via /proc/self/fd" + "Modern systemd PID 1 invokes its per-unit executor by passing the executable as a memfd file descriptor and exec'ing it via /proc/self/fd/<n>. This avoids touching disk and is the standard hand-off path for every systemd-managed service since v254. The lolbin command-line scorer flags /proc/self/fd as a memfd-loader pattern because in attacker tooling that primitive is used for fileless execution — but for systemd itself it's normal startup behavior. Restricted to processes named `systemd` or `systemd-executor` with an exe under /usr/lib/systemd or /lib/systemd, so a process impersonating systemd from elsewhere would still fire."))) + ;; ── engine ───────────────────────────────────────────────────────────────── - ;; Order doesn't matter for these vectors (no overlap), but first-match-wins - ;; mirrors secmon's RULES loop exactly. + ;; First-match-wins, in secmon's exact RULES order (compute_triaged_ids relies + ;; on the same precedence when collapsing benign/expected IDs). (def triage-rules (list rule-sshd-privsep rule-systemd-private-mount rule-loop-device-remount rule-snapd-https + rule-cron-logrotate-privchange + rule-pseudo-fs-mount + rule-nss-passwd-lookup + rule-cron-capability-change + rule-known-selinux-denial + rule-transient-unit-capability + rule-sshd-host-key-read + rule-self-proc-inspection + rule-lookup-tool + rule-transient-unit-namespace + rule-psi-pressure-read rule-phantom-rootkit - rule-proc-net-placeholder)) + rule-proc-net-placeholder + rule-systemd-executor-memfd)) ;; Run every rule against an event; return the first verdict, or #f. (def (triage-event ev)