Skip to content

0.3.0 — Sudo/su brute-force detection

Choose a tag to compare

@anhtuank7c anhtuank7c released this 03 May 11:48

Minor release. Three user-visible threads land together: a new local privilege-escalation brute-force detector, a packaging refactor that aligns the install layout with FHS and removes ~25 lines of post-install shell munging in debian/rules + pamsignal.spec, and the long-promised retirement of the PAMSIGNAL_* journal field set. Two breaking changes: the daemon binary moves from /usr/bin/pamsignal to /usr/sbin/pamsignal, and journalctl queries that filter by PAMSIGNAL_* need to switch to the ECS-aligned EVENT_* / USER_* / SOURCE_* field names. Both have one-line replacements documented in the relevant entries below.

Breaking

  • Daemon path moved to sbindir (FHS §4.10). Packaged builds now install to /usr/sbin/pamsignal instead of /usr/bin/pamsignal; dev installs land at /usr/local/sbin/pamsignal. The package manager handles the file relocation atomically on upgrade and the systemd unit's ExecStart is updated in lockstep, so apt upgrade / dnf upgrade is the only action a regular user needs. Anyone scripting against the absolute /usr/bin/pamsignal path (e.g. an out-of-tree systemd override) needs to update.
  • Legacy PAMSIGNAL_* journal fields removed. Through 0.2.x the daemon emitted both ECS-aligned EVENT_*/USER_*/SOURCE_*/HOST_*/SERVICE_*/PROCESS_* fields and a parallel PAMSIGNAL_* dictionary; only the ECS set survives. Saved journalctl queries that filter by PAMSIGNAL_EVENT=LOGIN_FAILED etc. need to switch to EVENT_ACTION=login_failure. The full mapping (PAMSIGNAL_USERNAMEUSER_NAME, PAMSIGNAL_SOURCE_IPSOURCE_IP, PAMSIGNAL_PORTSOURCE_PORT, PAMSIGNAL_SERVICESERVICE_NAME, PAMSIGNAL_HOSTNAMEHOST_HOSTNAME, PAMSIGNAL_PIDPROCESS_PID, PAMSIGNAL_TARGET_USERUSER_TARGET_NAME) is in docs/architecture.md and the refactor!: commit body. The JSON pamsignal.event_type / pamsignal.attempts / pamsignal.window_sec webhook fields are unchanged — those are vendor-specific by intent and not part of this retirement.

Features

  • Sudo / su brute-force detection. ps_parse_message now recognizes pam_unix(<svc>:auth): authentication failure; and extracts ruser= (actor), user= (elevation target), and rhost= (remote endpoint when present). The brute-force tracker keys by source IP when rhost= is a valid IP literal (covers SSH→sudo chains, indistinguishable from sshd brute-force) and by actor username for pure-local sudo/su attempts. A new ps_notify_local_brute_force emits the alert with chat-text format [ALERT] auth.brute_force_detected actor=alice target=root attempts=5 window=300s service=sudo host=… and ECS JSON {"user":{"name":"alice","target":{"name":"root"}}, "service":{"name":"sudo"}, ...} (no source.*). Per-event chat alerts are suppressed for sudo/su LOGIN_FAILED to avoid one-ping-per-mistyped-password noise; the journal entry is still written for every individual failure so journalctl history is complete, and only the threshold-breach alert fires. The brute-force tracker entry struct gains key_type (IP vs LOCAL_USER), service, and target_username fields; the existing ip field is renamed to key (64-byte buffer fits both INET6_ADDRSTRLEN and a username). New tests: 6 parser cases, 6 tracker cases (including the IP-vs-local-actor non-collision invariant), 1 notify smoke. login(1) failures parse but are not yet keyed for tracking — out of scope for this round.

Packaging

  • meson installs the systemd unit to the vendor path natively: pamsignal.service.in is now a template; configure_file substitutes @sbindir@ at configure time and installs to the dir reported by pkg-config --variable=systemdsystemunitdir systemd (with a <prefix>/lib/systemd/system fallback). Both debian/rules and pamsignal.spec drop the post-install mv (out of /etc/systemd/system) and sed (/usr/local/bin/usr/bin) workarounds — the artifact is correct straight out of meson install.
  • RPM %pre creates the group explicitly before the user: groupadd -r pamsignal runs before useradd -r -g pamsignal … so Provides: group(pamsignal) is honored even on hosts where USERGROUPS_ENAB is unset and useradd would otherwise skip auto-creating the matching group.
  • PS_DEFAULT_CONFIG_PATH is now derived from sysconfdir at configure time: a new include/paths.h.in template is generated into the build dir via configure_file, substituting @sysconfdir@ from get_option('prefix') / get_option('sysconfdir'). Packaged builds (--sysconfdir=/etc) still embed /etc/pamsignal/pamsignal.conf; a dev meson install with the default --prefix=/usr/local now correctly embeds /usr/local/etc/pamsignal/pamsignal.conf so the daemon finds its conf without an explicit --config flag.
  • Daemon binary moved from bindir to sbindir (FHS §4.10: system administration daemons belong in sbin, not bin). Packaged builds now install to /usr/sbin/pamsignal (was /usr/bin/pamsignal); dev installs land at /usr/local/sbin/pamsignal (was /usr/local/bin/pamsignal). The systemd unit's ExecStart is updated automatically by the configure_file substitution. The pamsignal command name is unchanged — /usr/sbin is in root's PATH on every supported distro, and ordinary users invoke the daemon only via systemctl. RPM %files switches to %{_sbindir}/pamsignal. Upgrade behavior: on the deb/rpm transition, the package manager removes the old /usr/bin/pamsignal file and installs the new one at /usr/sbin/pamsignal; systemctl daemon-reload is auto-fired and the unit's new ExecStart points at the new path. Anyone scripting against the absolute /usr/bin/pamsignal path needs to update to /usr/sbin/pamsignal.

Documentation

  • pamsignal(8) man page added. New pamsignal.8.in template covers SYNOPSIS, OPTIONS (-f/--foreground, -c/--config PATH), SIGNALS (SIGHUP/SIGTERM/SIGINT semantics), FILES (/etc/pamsignal/pamsignal.conf, /run/pamsignal/pamsignal.pid, vendor unit path), structured-journal output (ECS field reference + sample journalctl queries), EXIT STATUS, SECURITY (system user, memfd-backed curl --config, --proto =https, _EXE allowlist, per-IP cooldown), SEE ALSO, BUGS, AUTHOR. The .TH version is filled by configure_file from meson.project_version() so it tracks the release. Installed to <prefix>/share/man/man8/; debhelper auto-compresses on deb, brp-compress on rpm. The RPM %files glob %{_mandir}/man8/pamsignal.8* accepts either compressed or uncompressed.
  • docs/deployment.md uninstall recipe completed. Removes the man page (/usr/local/share/man/man8/pamsignal.8) so man pamsignal does not keep resolving after a dev meson install is unwound, and explicitly removes /etc/pamsignal/ (auto-created by systemd's ConfigurationDirectory=pamsignal on first unit start regardless of --prefix, unused on a dev --prefix=/usr/local install but left behind by the prior recipe). Notes that /run/pamsignal/ is auto-cleaned by RuntimeDirectory=.
  • docs/deployment.md Uninstall section split by install path. The previous single recipe assumed source-build only, ignoring the published deb/rpm path that the README points users at. The section is now three subsections: apt remove vs apt purge for Debian/Ubuntu (with the conffile-preservation rationale), dnf remove for Fedora/RHEL family (with the .rpmsave behavior on modified configs), and the existing manual recipe explicitly framed as the source-build branch. Both packaged paths note that the pamsignal system user is preserved by intent (avoids orphaning files owned by a recyclable UID) and shows a find / -user pamsignal check before any manual userdel.
  • docs/deployment.md Install section split by install path (mirrors the Uninstall split). Three subsections — apt, dnf, source build — replace the prior source-only recipe. Each packaged subsection ends "continue at Configure" because postinst/%pre already handle user creation and conf permissions; only the source-build subsection still walks through useradd/usermod and the chown root:pamsignal /…/pamsignal.conf && chmod 0640 step. The "Configure" section now disambiguates the conf path (/etc/pamsignal/pamsignal.conf for packaged or --prefix=/usr source, /usr/local/etc/… for default-prefix source) so readers don't edit the wrong file.

Security

  • Closed the two clang-analyzer taint-source warnings the lint pass had been carrying. src/config.c trim() no longer calls isspace() — a locale-independent is_ws() classifier replaces it, eliminating the clang-analyzer-security.ArrayBound finding (tainted index reaching __ctype_b_loc()'s table) and making config parsing deterministic across LC_CTYPE. src/main.c has_journal_access() no longer does malloc(getgroups(0, NULL) * sizeof(gid_t)) — it uses a 256-entry stack buffer with getgroups(256, buf), dropping clang-analyzer-optin.taint.TaintedAlloc and removing a heap allocation. A user with >256 supplementary groups (NGROUPS_MAX is 65536 in theory but real users have <32) gets EINVAL and the daemon fails closed with the existing "add user to systemd-journal" error path. src/journal_watch.c ps_fail_table_init() adds an explicit if (copy_count < 0) copy_count = 0; floor — the runtime invariant already held, but the floor lets the analyzer prove fail_table_count ∈ [0, capacity] across reinit cycles, closing a transitive ArrayBound finding surfaced by tests/test_journal_watch.c. New tests/test_config.c::test_config_load_whitespace_all_kinds covers \t/\r/\v/\f to lock in is_ws()'s coverage of the C-locale isspace set.