0.3.0 — Sudo/su brute-force detection
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/pamsignalinstead 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'sExecStartis updated in lockstep, soapt upgrade/dnf upgradeis the only action a regular user needs. Anyone scripting against the absolute/usr/bin/pamsignalpath (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-alignedEVENT_*/USER_*/SOURCE_*/HOST_*/SERVICE_*/PROCESS_*fields and a parallelPAMSIGNAL_*dictionary; only the ECS set survives. Savedjournalctlqueries that filter byPAMSIGNAL_EVENT=LOGIN_FAILEDetc. need to switch toEVENT_ACTION=login_failure. The full mapping (PAMSIGNAL_USERNAME→USER_NAME,PAMSIGNAL_SOURCE_IP→SOURCE_IP,PAMSIGNAL_PORT→SOURCE_PORT,PAMSIGNAL_SERVICE→SERVICE_NAME,PAMSIGNAL_HOSTNAME→HOST_HOSTNAME,PAMSIGNAL_PID→PROCESS_PID,PAMSIGNAL_TARGET_USER→USER_TARGET_NAME) is indocs/architecture.mdand therefactor!:commit body. The JSONpamsignal.event_type/pamsignal.attempts/pamsignal.window_secwebhook fields are unchanged — those are vendor-specific by intent and not part of this retirement.
Features
- Sudo / su brute-force detection.
ps_parse_messagenow recognizespam_unix(<svc>:auth): authentication failure;and extractsruser=(actor),user=(elevation target), andrhost=(remote endpoint when present). The brute-force tracker keys by source IP whenrhost=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 newps_notify_local_brute_forceemits 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"}, ...}(nosource.*). Per-event chat alerts are suppressed for sudo/suLOGIN_FAILEDto avoid one-ping-per-mistyped-password noise; the journal entry is still written for every individual failure sojournalctlhistory is complete, and only the threshold-breach alert fires. The brute-force tracker entry struct gainskey_type(IP vs LOCAL_USER),service, andtarget_usernamefields; the existingipfield is renamed tokey(64-byte buffer fits bothINET6_ADDRSTRLENand 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.inis now a template;configure_filesubstitutes@sbindir@at configure time and installs to the dir reported bypkg-config --variable=systemdsystemunitdir systemd(with a<prefix>/lib/systemd/systemfallback). Bothdebian/rulesandpamsignal.specdrop the post-installmv(out of/etc/systemd/system) andsed(/usr/local/bin→/usr/bin) workarounds — the artifact is correct straight out ofmeson install. - RPM
%precreates the group explicitly before the user:groupadd -r pamsignalruns beforeuseradd -r -g pamsignal …soProvides: group(pamsignal)is honored even on hosts whereUSERGROUPS_ENABis unset anduseraddwould otherwise skip auto-creating the matching group. -
PS_DEFAULT_CONFIG_PATHis now derived fromsysconfdirat configure time: a newinclude/paths.h.intemplate is generated into the build dir viaconfigure_file, substituting@sysconfdir@fromget_option('prefix') / get_option('sysconfdir'). Packaged builds (--sysconfdir=/etc) still embed/etc/pamsignal/pamsignal.conf; a devmeson installwith the default--prefix=/usr/localnow correctly embeds/usr/local/etc/pamsignal/pamsignal.confso the daemon finds its conf without an explicit--configflag. - Daemon binary moved from
bindirtosbindir(FHS §4.10: system administration daemons belong insbin, notbin). 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'sExecStartis updated automatically by theconfigure_filesubstitution. Thepamsignalcommand name is unchanged —/usr/sbinis in root'sPATHon every supported distro, and ordinary users invoke the daemon only viasystemctl. RPM%filesswitches to%{_sbindir}/pamsignal. Upgrade behavior: on the deb/rpm transition, the package manager removes the old/usr/bin/pamsignalfile and installs the new one at/usr/sbin/pamsignal;systemctl daemon-reloadis auto-fired and the unit's new ExecStart points at the new path. Anyone scripting against the absolute/usr/bin/pamsignalpath needs to update to/usr/sbin/pamsignal.
Documentation
-
pamsignal(8)man page added. Newpamsignal.8.intemplate covers SYNOPSIS, OPTIONS (-f/--foreground,-c/--config PATH), SIGNALS (SIGHUP/SIGTERM/SIGINTsemantics), FILES (/etc/pamsignal/pamsignal.conf,/run/pamsignal/pamsignal.pid, vendor unit path), structured-journal output (ECS field reference + samplejournalctlqueries), EXIT STATUS, SECURITY (system user, memfd-backed curl--config,--proto =https,_EXEallowlist, per-IP cooldown), SEE ALSO, BUGS, AUTHOR. The.THversion is filled byconfigure_filefrommeson.project_version()so it tracks the release. Installed to<prefix>/share/man/man8/; debhelper auto-compresses on deb, brp-compress on rpm. The RPM%filesglob%{_mandir}/man8/pamsignal.8*accepts either compressed or uncompressed. -
docs/deployment.mduninstall recipe completed. Removes the man page (/usr/local/share/man/man8/pamsignal.8) soman pamsignaldoes not keep resolving after a devmeson installis unwound, and explicitly removes/etc/pamsignal/(auto-created by systemd'sConfigurationDirectory=pamsignalon first unit start regardless of--prefix, unused on a dev--prefix=/usr/localinstall but left behind by the prior recipe). Notes that/run/pamsignal/is auto-cleaned byRuntimeDirectory=. -
docs/deployment.mdUninstall 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 removevsapt purgefor Debian/Ubuntu (with the conffile-preservation rationale),dnf removefor Fedora/RHEL family (with the.rpmsavebehavior on modified configs), and the existing manual recipe explicitly framed as the source-build branch. Both packaged paths note that thepamsignalsystem user is preserved by intent (avoids orphaning files owned by a recyclable UID) and shows afind / -user pamsignalcheck before any manualuserdel. -
docs/deployment.mdInstall 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/%prealready handle user creation and conf permissions; only the source-build subsection still walks throughuseradd/usermodand thechown root:pamsignal /…/pamsignal.conf && chmod 0640step. The "Configure" section now disambiguates the conf path (/etc/pamsignal/pamsignal.conffor packaged or--prefix=/usrsource,/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.ctrim()no longer callsisspace()— a locale-independentis_ws()classifier replaces it, eliminating theclang-analyzer-security.ArrayBoundfinding (tainted index reaching__ctype_b_loc()'s table) and making config parsing deterministic acrossLC_CTYPE.src/main.chas_journal_access()no longer doesmalloc(getgroups(0, NULL) * sizeof(gid_t))— it uses a 256-entry stack buffer withgetgroups(256, buf), droppingclang-analyzer-optin.taint.TaintedAllocand removing a heap allocation. A user with >256 supplementary groups (NGROUPS_MAX is 65536 in theory but real users have <32) getsEINVALand the daemon fails closed with the existing "add user to systemd-journal" error path.src/journal_watch.cps_fail_table_init()adds an explicitif (copy_count < 0) copy_count = 0;floor — the runtime invariant already held, but the floor lets the analyzer provefail_table_count ∈ [0, capacity]across reinit cycles, closing a transitiveArrayBoundfinding surfaced bytests/test_journal_watch.c. Newtests/test_config.c::test_config_load_whitespace_all_kindscovers\t/\r/\v/\fto lock inis_ws()'s coverage of the C-localeisspaceset.