Skip to content

Releases: anhtuank7c/pamsignal

v0.6.2

03 Jun 06:56

Choose a tag to compare

Patch release — no daemon code changes (the binary is identical to 0.6.1). This release is about everything around the daemon.

Highlights

  • 📊 Grafana fleet dashboard — new examples/grafana/ integration: a Grafana Alloy config, a 12-panel dashboard, 4 alert rules, a one-command docker-compose try-it stack, and verify.sh. See every host's auth activity on one screen.
  • 📚 Documentation overhaul (EN + VI) — restructured README plus three new guides: Secure SSH & Manage a Fleet, Grafana from Zero, and Use Cases & Integrations — each mirrored in Vietnamese under docs/vi/.
  • 📦 Debian packaging hygienedebian/watch, debian/upstream/metadata (DEP-12), Standards-Version 4.7.0.

Full notes: CHANGELOG.md

Install

Signed .deb (Ubuntu 24.04 + 20.04 Focal) and .rpm (Fedora + EL9) packages are attached below once CI finishes, and the signed apt/dnf repos update automatically. Repo setup: see the README.

pamsignal 0.6.1

27 May 15:24

Choose a tag to compare

Patch release. One user-facing addition (--help) plus the CI hardening accumulated since 0.6.0.

What's new

--help / -h flag

```
$ pamsignal --help
Usage: pamsignal [OPTION]...
Real-time PAM authentication monitor with multi-channel alerts.


```

Prints a complete usage summary covering every option, the compiled-in default config path, the canonical files, and pointers to `pamsignal(8)` and `pamsignal.conf(5)`. Goes to stdout, exits 0, runs before any privilege/journal-access check — works under root, dpkg/rpm post-install scripts, or a plain operator.

GNU coding-standards baseline: pamsignal had `--version` since 0.4.1, but `--help` was the missing half. Now closed. The `pamsignal(8)` man page also documents both `-V`/`--version` and `-h`/`--help` explicitly in the OPTIONS section.

CI hardening

Both `test-deb` (Noble) and `test-deb-focal` now assert:

  • `pamsignal --version` produces non-empty stdout matching `^pamsignal [0-9]+.[0-9]+.[0-9]+$`
  • `pamsignal --help` produces non-empty stdout starting with `Usage: pamsignal` and containing `--foreground`
  • Short forms `-V` / `-h` produce output byte-identical to the long forms

Closes the "installed cleanly but the flag prints nothing" failure mode that a `systemctl is-active` check would miss — a real operator-reported issue on 0.6.0 that turned out to be PATH-shadowing by a stale `/usr/local/bin/pamsignal` from an earlier `meson install`. A future regression in arg parsing can't reach the release page anymore.

Internal `test-deb-focal` instrumentation (`[N/9]` markers, `FAIL: ` on assertion failure, `printf %q` for perm comparison, SIGPIPE-safe man check, removal of docker minimal-image's `path-exclude=/usr/share/man/*` rule before install) — invisible to operators, benefits every future release.

Operator notes

No daemon-side changes. `pamsignal.service` behaviour is unchanged from 0.6.0. Operators on 0.6.0 don't need to upgrade urgently — this is a convenience + CI release, not a bug or security fix.

Install

Distribution Method
Ubuntu 24.04 / Debian 12 `apt update && apt upgrade pamsignal` from the gh-pages repo
Ubuntu 20.04 LTS (Focal, ESM) Download `pamsignal_0.6.1-1_focal_amd64.deb` below + `apt install ./.deb`
Fedora / CentOS / RHEL 9 / AlmaLinux 9 / Rocky 9 `dnf upgrade pamsignal` from the gh-pages repo

Full changelog

See CHANGELOG.md.

pamsignal 0.6.0

27 May 12:23

Choose a tag to compare

Highlights

Ubuntu 20.04 (Focal) is now a supported install target

A Focal-targeted .deb is built and smoke-tested in CI on every release and attached to this release page. Operators on 20.04 install with a one-line download + apt install:

```bash
VERSION=0.6.0
curl -fL -o pamsignal_focal.deb
"https://github.com/anhtuank7c/pamsignal/releases/download/v\${VERSION}/pamsignal_\${VERSION}-1_focal_amd64.deb"
sudo apt install ./pamsignal_focal.deb
```

No gh-pages apt pocket for Focal (rationale: ESM-only since 2025-04-30; per-distroseries repo infrastructure does not pay off for the audience size). Two systemd directives (`ProtectProc`, `ProcSubset`, both added in v247) are logged-and-ignored on Focal's systemd 245; everything else in the hardening unit is honored. See `docs/distros.md` for the full lifecycle picture (Focal exits ESM April 2030).

`pamsignal.conf(5)` man page

Operators now get `man pamsignal.conf` instead of grepping source comments or `docs/configuration.md`. Documents every config key, value range, mTLS and `webhook_auth_header` validation rules, reload semantics, and security notes. Installed to `/man5/` via meson.

Documentation refresh

  • README quickstart "Configure Alerts" surfaces `alert_cooldown_sec` and `enable_notification_type` with operator-facing semantics on day one.
  • Fail2ban integration guide (`examples/fail2ban/README.md`) rewritten from a 39-line stub into a complete walkthrough — install on Ubuntu/Debian and CentOS Stream 9 / AlmaLinux 9 / Rocky Linux 9 / Fedora, per-distro `banaction` selection, the critical whitelist-yourself-first step, verification flow, `bantime.increment` tuning, troubleshooting, and an architecture diagram.

Internal hygiene

RAII-style cleanup helpers (`include/ps_cleanup.h`) applied to the three fd-ladder-heavy security functions: `build_secrets_memfd` + `fire_curl` (memfd carrying webhook secrets to curl), `open_config_secure` + `ps_config_load` (O_NOFOLLOW config opener), and `validate_tls_path`. Defense in depth against future-edit fd leaks; zero runtime behaviour change.

Packaging compatibility

This release also fixes three pre-existing packaging-side bugs that surfaced when the new Focal job and new conf(5) man page first exercised the workflow: `debian/control` switched to the legacy `debhelper (>= 12~)` Build-Depends + `debian/compat` file (compatible with Focal's debhelper 12.10 AND Noble's 13.x); `debian/rules` uses `ninja` directly rather than the meson 0.54+ `compile`/`install` subcommands; `meson.build` uses `not fuzz_opt.disabled()` instead of meson 0.59's `.allowed()`; `pamsignal.spec` lists the new `%{_mandir}/man5/pamsignal.conf.5*` file. All four are backwards-compatible.

Install

Distribution Method
Ubuntu 24.04 / Debian 12 `apt install pamsignal` from the gh-pages repo
Ubuntu 20.04 LTS (Focal, ESM) Download `pamsignal_0.6.0-1_focal_amd64.deb` below + `apt install ./.deb`
Fedora / CentOS / RHEL 9 / AlmaLinux 9 / Rocky 9 `dnf install pamsignal` from the gh-pages repo

See the README install section for the full per-distro commands.

Full changelog

See CHANGELOG.md.

v0.5.0 — Selectable chat-alert categories

26 May 11:38

Choose a tag to compare

Minor release. Adds the first user-configurable filter over chat alerts — enable_notification_type lets operators opt in to specific event categories (login_success, login_failed, session_open, session_close, brute_force, all) rather than receiving every PAM event. Requested by @huy-lv (#22) for the common deployment shape where the host's notable signal is "someone got in" rather than "someone tried." The filter gates chat dispatch only — journalctl -t pamsignal still records every event, so the forensic trail and threat-model assumptions are unchanged. Default is all, so existing deployments behave identically without touching their config. Beneath the new filter, the existing per-event suppression for sudo/su LOGIN_FAILED (only the brute-force aggregate fires for those services) is untouched. Also ships two transitive-dependency security bumps in the bundled example receivers (idna CVE-2026-45409 in the Python receiver, qs CVE-2026-8723 in the Node.js receiver) — neither affects the C daemon, but the examples need to install cleanly into their own ecosystems. Internal: 13 new CMocka cases (9 config + 4 notify gating), zero new build warnings, ASVS L1 pre-merge gate clean.

Features

  • enable_notification_type config key — select which event categories fire a chat alert (#22). Reported by @huy-lv. Comma-separated list of tokens: login_success, login_failed, session_open, session_close, brute_force, or all. Default — when the key is omitted or set to all — is every category, so existing deployments behave unchanged. Filters only the chat dispatch (Telegram / Slack / Teams / WhatsApp / Discord / custom webhook); the local journalctl -t pamsignal trail records every event regardless, preserving the forensic record assumed by docs/threat-model.md. Unknown tokens, empty values, and empty list elements are hard errors at config load (matches the strictness of the existing validators). Implemented as a 5-bit mask on ps_config_t.enable_notification_type (PS_NOTIFY_LOGIN_SUCCESS, PS_NOTIFY_LOGIN_FAILED, PS_NOTIFY_SESSION_OPEN, PS_NOTIFY_SESSION_CLOSE, PS_NOTIFY_BRUTE_FORCE); ps_notify_event gates on the per-event-type bit and ps_notify_brute_force / ps_notify_local_brute_force gate on the brute-force bit. The existing per-event suppression for sudo/su LOGIN_FAILED in src/journal_watch.c is unchanged and layered beneath this filter. Parser handles arbitrary whitespace around list elements and is case-insensitive on token names. Tests: 9 new CMocka cases in tests/test_config.c (default = all, single category, multi-category CSV, all sentinel, whitespace + case tolerance, full 5-category list, unknown token rejected, empty value rejected, empty list element rejected, omitted-key keeps default) and 4 new cases in tests/test_notify.c exercising the dispatch gate via the file-static cooldown clock (event_notify_bit mapping, gated-off doesn't dispatch, gated-on does dispatch, PS_EVENT_UNKNOWN never dispatches). Documented in docs/configuration.md (new "Notification-type filter" section with token table, scope note, examples) and pamsignal.conf.example.

Security

  • examples/nodejs-webhook/: bump qs 6.14.2 / 6.15.1 → 6.15.2 in pnpm-lock.yaml (Dependabot alert #2, CVE-2026-8723, CVSS v4 6.3 medium). qs >= 6.11.1, <= 6.15.1 throws TypeError synchronously when qs.stringify is called with both arrayFormat: 'comma' and encodeValuesOnly: true on an array containing null/undefined — the raw encoder runs before skipNulls/strictNullHandling get a chance to handle the bad element. Under Express the throw is caught by the framework error boundary and returns 500; outside a handler (background jobs, startup paths) it crashes the worker. PAMSignal itself is C and unaffected; the alert fires only on the Express example receiver's transitive qs (via express, body-parser, formidable, supertest). Pinned via pnpm overrides in pnpm-workspace.yaml so both 6.14.2 (express 4.x) and 6.15.1 (body-parser / formidable) collapse to the patched 6.15.2; package.json direct deps unchanged. All 64 jest tests still pass.
  • examples/python-webhook/: bump idna 3.14 → 3.15 in uv.lock (Dependabot alert #1, CVE-2026-45409, CVSS v4 6.9 medium). idna < 3.15 is vulnerable to a DoS when idna.encode() is called with crafted long inputs (e.g. "٠" * N): valid_contexto runs before the length-rejection check, re-opening the gap that CVE-2024-3651 was supposed to close. PAMSignal itself is C and unaffected; the alert fires only on the Flask example receiver's transitive dep via requests. Lockfile-only refresh via uv lock --upgrade-package idna; pyproject.toml unchanged.

v0.4.1 — --version flag, CentOS 7 docs, Node.js mTLS example

12 May 04:47

Choose a tag to compare

Patch release. Closes one operator-facing UX gap (pamsignal --version now works — the man page synopsis and the package post-install smoke checks have always claimed this flag existed, but it didn't), documents the rationale for one cutoff that came up in operator conversations (CentOS / RHEL 7 unsupportability + migration paths), aligns the APT install snippet with the FHS keyring-path convention (#14), and ships an HTTPS + mTLS variant of the bundled Node.js webhook receiver to match what the threat-model docs claimed it could do (#12). No daemon behaviour changes; existing configs work unchanged. Internal: integration test suite rewritten to exercise the parse → track → format pipeline on real journal log lines, formatter coverage in test_notify.c expanded 4 → 27 cases, install-test default matrix narrowed to the build-matching Tier 1 pair. ASAN + UBSAN clean across all five test suites.

Documentation

  • docs/distros.md: CentOS / RHEL 7 unsupportability fully documented. The Tier 3 table row previously said only "Won't compile; many hardening directives ignored" — operators asking whether the cutoff could be relaxed for their CentOS 7 fleet had no specific answer to point at. Rewrote the row to cite the three independent blockers (stock kernel 3.10 lacks the memfd_create syscall added in Linux 3.17; glibc 2.17 lacks the wrapper added in 2.27; CentOS 7 itself reached EOL on 2024-06-30 — running a security daemon on an OS that no longer receives upstream security updates is counter-productive) and added a "Why CentOS / RHEL 7 isn't supportable" subsection that spells out exactly which part of the threat model breaks (attack #3, alert-credential exposure via /proc/<pid>/cmdline when memfd-backed curl config isn't available) and offers three concrete migration paths: in-place migration to AlmaLinux 9 / Rocky 9 / RHEL 9 via the official convert2rhel / migrate2rocky.sh / almalinux-deploy.sh scripts (recommended), container deployment on the existing host with a newer-glibc image (works only if uname -r shows a backport ≥ 3.17, not stock 3.10), or sidestepping pamsignal entirely with auditd + SIEM correlation. Linked from the Tier 3 table so the migration path is one click away from the "is my host supported" lookup.

Features

  • pamsignal --version / -V flag. The man page (pamsignal.8.in) and the packaging smoke check in test_install_docker.sh both assumed this flag existed, but src/main.c::parse_args only ever handled --foreground and --config — passing --version fell through into the daemon's normal startup, hit the non-root invariant at main.c line 77, and exited 1 with the "should not run as root" message under any package-postinstall context (dpkg, rpm, container RUN). Added a --version/-V branch that prints the meson-injected PAMSIGNAL_VERSION and exits 0 before any privilege checks, so pamsignal --version works from any context including root. The version string flows from meson.build (-DPAMSIGNAL_VERSION="<meson.project_version()>") so it stays in sync with the man page's .TH line and the .deb / .rpm package metadata. Synopsis line added to pamsignal.8.in.

  • test_install_docker.sh default matrix narrowed to Tier 1. Without arguments the script previously tested all seven distros (Ubuntu 24.04 / 22.04, Debian 12, Fedora 40, CentOS Stream 9, AlmaLinux 9, Rocky Linux 9), which produced false-negative [FAIL] results: the bundled .deb pins libc6 (>= 2.38) to ubuntu:24.04's glibc, and the .rpm pins to fedora:40's glibc 2.39+, so older targets apt/dnf-refuse the install correctly. That's the documented Tier 1 vs Tier 2 distinction in docs/distros.md — Tier 2 needs a per-distroseries build pipeline that doesn't ship yet. Default invocation now runs only ubuntu:24.04 (the .deb build env) and fedora:40 (the .rpm build env). The full matrix remains opt-in via the existing distro-name positional args (./test_install_docker.sh <deb> <rpm> ubuntu debian fedora centos almalinux rockylinux), so anyone exercising a same-distroseries-build pipeline keeps the wider check available. Usage text updated to explain the rationale.

Fixed

  • APT signing key path moved to /etc/apt/keyrings (#14). Reported by @hongquan. Our install snippets directed users to drop the repository signing key at /usr/share/keyrings/pamsignal.gpg, which is reserved for keys shipped by official distribution packages (debian-archive-keyring, ubuntu-keyring, etc.) — administrator-installed keys belong in /etc/apt/keyrings. The sources.list(5) man page (Debian 12+ / Ubuntu 22.04+) makes this explicit: "The recommended locations for keyrings are /usr/share/keyrings for keyrings managed by packages, and /etc/apt/keyrings for keyrings managed by the system operator." Apt accepts either path in the signed-by= option — the distinction is FHS-compliance and convention, not function — so this is a documentation fix only and existing installs keep working without intervention. Updated: the install snippet in README.md, the apt block in docs/deployment.md, the auto-rendered install snippet on the gh-pages landing page (in .github/workflows/release-packages.yml, takes effect on the next release-packages run), and a workflow comment in .github/workflows/bootstrap-signing-key.yml for consistency. Each snippet now prepends sudo install -d -m 0755 /etc/apt/keyrings since the directory isn't pre-created on every base image (older containers, some minimal Ubuntu/Debian installs); the command is idempotent and safe to re-run.

    Migrating an existing install (optional). If you installed pamsignal before this fix and want to align with the FHS convention, three idempotent commands handle the move:

    sudo install -d -m 0755 /etc/apt/keyrings
    sudo mv /usr/share/keyrings/pamsignal.gpg /etc/apt/keyrings/pamsignal.gpg
    sudo sed -i 's|/usr/share/keyrings/pamsignal.gpg|/etc/apt/keyrings/pamsignal.gpg|' \
      /etc/apt/sources.list.d/pamsignal.list
    sudo apt update

    The final apt update is for confirmation only — no NO_PUBKEY error in its output means the move worked and apt still trusts the repo. You do not need to do this. Apt resolves the absolute path in signed-by= directly, so the old /usr/share/keyrings/pamsignal.gpg layout will continue to work through future package upgrades and key rotations without issue. Migrate only if you prefer your system to follow the documented convention.

Examples

  • Node.js webhook example: HTTPS + mTLS server variant (#12). The bundled examples/nodejs-webhook/ reference receiver previously listened only on plain HTTP via app.listen(); the README's "Mutual TLS (advanced)" section (added with #8) showed an https.createServer({ requestCert: true, rejectUnauthorized: true }, app) snippet but the actual code that would do that didn't ship. This change closes the docs-vs-reality gap. src/server.ts now branches on env vars: when TLS_KEY_PATH and TLS_CERT_PATH are set, it constructs an HTTPS server (with optional TLS_CLIENT_CA_PATH for trust-store override and TLS_REQUIRE_CLIENT_CERT=true for full mTLS); otherwise the existing plain-HTTP path is unchanged. .env.example documents the four new env vars under a comment block that names the corresponding webhook_client_cert / webhook_client_key keys on the pamsignal side. scripts/gen-test-certs.sh produces a CA + server cert + client cert under ./certs/ for local end-to-end testing — it builds an internal CA, signs server.crt with subjectAltName=DNS:localhost,IP:127.0.0.1 (so Node's HTTPS client accepts it), signs a client.crt with CN=pamsignal-test-client, and tightens private-key files to mode 0600 to match what pamsignal will accept. The README's "Mutual TLS (advanced)" subsection is rewritten as a runnable end-to-end demo (cert script + env vars + matching pamsignal.conf block) rather than a conceptual snippet, and explicitly distinguishes the demo cert pipeline from production cert managers (cert-manager, certbot, systemd-creds). tests/mtls.test.ts adds three Jest cases that spin up the full HTTPS server with the same construction pattern, generate certs at setup-time via openssl spawn, and verify: a request with a valid client cert + Bearer succeeds (200), a request without a client cert is rejected at the TLS layer before reaching Express, and the Bearer middleware still 401s on a successful handshake when the token doesn't match. Total Jest cases 7 → 10. .gitignore adds certs/, package-lock.json, yarn.lock so locally-generated test certs and accidentally-installed npm/yarn lockfiles don't pollute the pnpm-managed repo. No change to pamsignal itself — pamsignal v0.4.0 already speaks mTLS correctly via #8.

v0.4.0 — Custom webhook authentication (Bearer + mTLS)

08 May 04:52

Choose a tag to compare

Minor release. Closes the long-standing gap in the custom-webhook authentication story: the channel now ships with two additive auth mechanisms — a single arbitrary HTTP header (Bearer / API key / HMAC-style; #7) and full mutual-TLS client authentication (cert + key + optional CA bundle; #8). Both flow through the same memfd-backed curl -K config the existing alert-credential isolation already used, so the curl child's argv is byte-identical regardless of which auth modes are configured — /proc/*/cmdline reveals nothing about which channel or which credentials are in play. No breaking changes.

What's new

webhook_auth_header — single HTTP header for the custom webhook (#7)

webhook_url = https://siem.example.com/ingest/pamsignal
webhook_auth_header = Authorization: Bearer <token>

Works with any single-header auth scheme: Bearer / OAuth, X-API-Key, Authorization: Splunk <token> (HEC), DD-API-KEY (Datadog), Wazuh API JWT, custom HMAC headers, etc. The header value is rendered into a memfd-backed curl -K config file — the secret never appears in argv, /proc/<pid>/cmdline, or any process listing. Validator at config load rejects CRLF / quote / backslash injection and refuses the key set without webhook_url.

Previously the only authenticated path was a reverse proxy in front of the receiver injecting the header — the bundled examples/nodejs-webhook/ reference receiver explicitly required this setup. The example README now documents direct configuration as the recommended path.

webhook_client_cert / webhook_client_key / webhook_ca_bundle — mTLS client auth (#8)

webhook_url = https://siem.internal.example.com/ingest
webhook_client_cert = /etc/pamsignal/webhook-client.crt
webhook_client_key  = /etc/pamsignal/webhook-client.key
# Optional, only if the receiver's CA isn't in the system trust store
webhook_ca_bundle   = /etc/pamsignal/internal-ca.pem

Targets the operator profile that already runs PKI (internal CA + cert-manager / SPIFFE / service-mesh issuance). Stronger than Bearer alone — the private key never travels over the wire, replay windows don't exist, credential rotation is delegated to the cert-management pipeline. Combines additively with webhook_auth_header — the corporate-SIEM / Wazuh pattern (mTLS for transport-layer service identity, Bearer for per-request authorization).

Validator at config load: O_NOFOLLOW open (symlinks rejected), regular-file required, ownership outside {root, daemon-uid} rejected, webhook_client_key additionally rejected if group- or world-readable. Path strings constrained to printable ASCII minus " and \ for unambiguous handling in the curl -K config. Cross-field consistency: cert without key (or key without cert) is a config-load error; any TLS key without webhook_url is rejected.

Documentation refresh

  • docs/configuration.md — full "Custom webhook authentication" reference covering both modes, combined-pattern examples, and the validation rules.
  • docs/architecture.md — new "Curl invocation (memfd-backed config)" subsection.
  • docs/alerts.md — "Authentication (optional)" + "Mutual TLS (optional, advanced)" subsections under "Custom webhook (ECS JSON)".
  • docs/deployment.md — cert/key file placement guidance.
  • docs/threat-model.md — attack #3 extended (covers the new auth surface), NS8 rewritten (Bearer + mTLS now ship; HMAC-signed payloads remain out of scope).
  • pamsignal.8.in — SECURITY + FILES sections updated.
  • examples/nodejs-webhook/ — adds an mTLS-enabled https.createServer variant.

Internal changes (no operator impact)

  • post_alert() and build_secrets_memfd() in src/notify.c swap from positional arguments to a curl_config_t struct that carries the optional auth header and TLS paths by name. The four legacy callers (Telegram, Slack/Teams/Discord, WhatsApp) pass struct-literal arguments and produce identical wire output to v0.3.x.
  • build_secrets_memfd buffer grows 2048 → 4096 to fit up to four config lines without truncation. Each snprintf append is bounds-checked.

Tests

CMocka test_config suite grew 33 → 53 (20 new cases): 9 for webhook_auth_header parse + validation, 11 for mTLS parse + perm validation including symlink rejection, world/group-readable key rejection, missing-cert-or-key, no-webhook_url, path-with-quote, and combined Bearer+mTLS load. All 4 suites pass under ASAN + UBSAN. clang-tidy clean. OWASP ASVS L1 audit zero FAIL findings.

Upgrading

sudo apt upgrade pamsignal   # Debian / Ubuntu
sudo dnf upgrade pamsignal   # Fedora / EL9

Existing v0.3.x configs continue to work unchanged. To enable the new auth options, see Configuration → Custom webhook authentication.

Closed issues

  • #7webhook_auth_header for the custom webhook channel
  • #8 — Optional mTLS client auth for the custom webhook channel

Full changelog: v0.3.4...v0.4.0

0.3.4 — Security: accept sshd-session (OpenSSH 9.8+ on Ubuntu 26.04 / Fedora 41+)

03 May 16:26

Choose a tag to compare

Security release. Fixes a real production defect: pamsignal silently dropped every sshd auth event on Ubuntu 26.04, Fedora 41+, and Debian Trixie (any host running OpenSSH 9.8+). Surfaced by an end-to-end scenario test on Ubuntu 26.04 / OpenSSH 9.10p2 / OpenSSL 3.5.5 / systemd 259 — a stack CI doesn't currently exercise. Bundles repository-hygiene additions (CONTRIBUTING.md, issue templates, PR template) that accumulated under ## Unreleased since v0.3.3, plus the tests/scenario.sh script that caught the sshd-session bug, plus forward-compat hardening of the CI mock webhook for OpenSSL 3.5+.

Security

  • _EXE allowlist now accepts sshd-session. OpenSSH 9.8 (released 2024-07) split the server into a privilege-separated listener (sshd) and a per-connection auth process (sshd-session). Failed-password / accepted-password journal entries on Ubuntu 26.04, Fedora 41+, and Debian Trixie come from sshd-session, not sshd. Pamsignal's anti-spoofing _EXE allowlist accepted only the latter, so every sshd auth event on those distributions was dropped at src/journal_watch.c's pre-parse filter — the daemon ran fine, journald accumulated events fine, the daemon just didn't see them. Fix: extend the basename allowlist with sshd-session (refactored into a testable ps_is_trusted_exe helper). Eight new CMocka test cases cover the canonical paths (/usr/sbin/sshd, /usr/bin/sudo, etc.), the new sshd-session paths (/usr/sbin/sshd-session, /usr/lib/openssh/sshd-session), the alternate system-prefix paths (/sbin, /lib, /lib64, /opt), and the spoofing-rejection paths (logger, /tmp/, /home/, basename-imposter binaries, NULL/empty input, paths without a slash). test_journal_watch grows from 19 → 27 tests.

Tooling

  • tests/scenario.sh — full-stack local end-to-end scenario test that mirrors release-packages.yml's test-deb job and adds two pieces of coverage that don't exist in CI: explicit Type=notify activation verification (systemctl reports active only after sd_notify(READY=1) actually fires), and the v0.3.0 sudo brute-force scenario with testpamuser temporarily added to /etc/sudoers.d/ so PAM auth is actually invoked. Twelve phases with pass/fail output, an EXIT trap that always cleans up (sshd_config restored from backup, testpamuser deleted, /etc/pamsignal removed, mock webhook stopped, test CA removed from system trust store, sudoers.d entry removed). The script's confirm() reads from /dev/tty directly so it works under wrappers that capture stdout. Caught the sshd-session bug above on its first non-CI run.
  • CI mock webhook hardened for OpenSSL 3.5+ peers. The Python BaseHTTPRequestHandler defaults to HTTP/1.0 + no Content-Length + no explicit TLS close_notify, which OpenSSL 3.5+ (Ubuntu 26.04, Fedora 41+) reports as error:0A000126:SSL routines::unexpected eof while reading, breaking curl with exit 56 even though the HTTP response was successfully received. Forward-port the connection.unwrap() + Content-Length + Connection: close + protocol_version='HTTP/1.1' fixes from tests/scenario.sh into release-packages.yml's test-deb mock so the workflow keeps passing when GitHub Actions promotes the runner image from ubuntu-24.04 (OpenSSL 3.0) to ubuntu-26.04 (OpenSSL 3.5+). Also adds a READY-poll startup check with bounded retries so a Python-side bind error fails loudly with the captured stderr instead of silently hanging.

Repository hygiene

  • CONTRIBUTING.md at the repo root documents the contributor workflow: clone+build quickstart, branch-per-change with Conventional Commits (with the feat:/fix:/security:/refactor:/refactor!:/docs:/chore:/test:/perf: type table the project actually uses), coding standards (cross-referenced to .clang-format / .clang-tidy), the test requirement (every new function or changed behavior gets a CMocka test), the full pre-commit checklist (clang-format, clang-tidy, meson compile, meson test, optional sanitizer build), CHANGELOG conventions, code-review focus areas (threat-model alignment, test adequacy, API stability, style), and a packaging-changes section explicitly calling out the systemd-analyze CI gate at threshold 20 plus the el9-rpm %changelog-parsing trap.
  • Issue templates rewritten as Linux-daemon reports, not generic open-source-project forms. .github/ISSUE_TEMPLATE/bug_report.yml is a structured capture of the system state we'd ask the operator to gather anyway — a component dropdown (parser / brute-force tracker / alert dispatch / unit / packaging / journald integration / config parsing), distribution + version + kernel + systemd version + MAC-policy enforcement state, the systemctl status pamsignal output, the journalctl -u pamsignal excerpt, the source-of-truth auth-event excerpt with the right per-service-unit filters, the systemd-delta + drop-in-overrides listing (frequent root cause for "directive X isn't behaving" reports), the systemd-analyze security score (CI-gated at 20; a higher score on the operator's host suggests a drop-in or repackaging loosened a directive), the redacted config, and concrete operational reproduction steps (what auth events were generated, with what config values, in what order). The template includes the exact commands to run for each capture so an operator who doesn't know the daemon internals can still file a report we can act on. feature_request.yml rewritten around the project's operator-facing surface area (config keys, journal fields, chat-text alert shape, ECS JSON webhook fields, unit directives, CLI flags, alert-channel additions) with a mandatory compatibility-commitment dropdown (additive / default-on-overridable / default-off opt-in / deprecation-cycle / breaking) referencing the PAMSIGNAL_* retirement precedent, and a threat-model-alignment field that distinguishes "strengthens an in-scope attack" from "argues for moving NS1–NS10 into scope" (the latter being the highest bar). config.yml unchanged — disables blank issues, points security reports at GitHub Security Advisories, routes question traffic to question-labeled issues.
  • Pull-request template (.github/pull_request_template.md) auto-populates new PRs with a summary / linked-issue / test-plan / threat-model-alignment structure plus the eight-item pre-commit checklist that mirrors CONTRIBUTING.md. Reviewers can see at a glance whether the contributor ran the local checks.

0.3.3 — Sandbox tightening, threat model, sanitizer CI

03 May 15:05

Choose a tag to compare

Hardening release. Three independent improvements: the systemd unit's exposure score drops from 22 to 13 ("OK" band, near the achievable floor for a daemon with the daemon's network and journal-access constraints) via nine new directives; a canonical docs/threat-model.md documents what pamsignal defends against and what it deliberately doesn't (cross-referenced to source-line mitigations); ASAN + UBSAN run on every push and PR via a new .github/workflows/ci.yml workflow. No source-code behavior change visible to operators — the daemon's API surface (config keys, journal fields, webhook payload) is unchanged. Upgrade is apt upgrade pamsignal / dnf upgrade pamsignal; the systemd unit is replaced atomically and daemon-reload is auto-fired by the maintainer scripts.

Security

  • systemd unit hardening: exposure score 22 → 13 (displayed 2.2 → 1.3, "OK" band, near the achievable floor for a daemon that needs network egress + journal access). Nine new directives added to pamsignal.service.in, each justified against the daemon's actual behavior (no defensive copy-paste): UMask=0077, ProtectClock=yes, ProtectHostname=yes, ProtectProc=invisible, ProcSubset=pid, SystemCallArchitectures=native, RemoveIPC=yes, RestrictAddressFamilies=AF_UNIX AF_NETLINK AF_INET AF_INET6 (allowlists exactly the four families the daemon and its curl child need — drops AF_PACKET, AF_BLUETOOTH, AF_VSOCK, etc.), and DevicePolicy=closed (overrides the implicit RTC-read grant from PrivateDevices=yes). The CI regression-gate threshold drops from 30 to 20 to lock in the new baseline. Directives deliberately not added because they would break the daemon: PrivateNetwork=yes (curl needs outgoing HTTPS), PrivateUsers=yes (would interact badly with SupplementaryGroups=systemd-journal for journal-read access), IPAddressDeny=any (Telegram/Slack/Discord/etc. backend IPs change too frequently to allowlist), RootDirectory=/RootImage= (would require portable-service repackaging, out of scope). Documented in the directive comments so future readers know what the trade-offs were.

CI

  • AddressSanitizer + UndefinedBehaviorSanitizer on every push. New .github/workflows/ci.yml runs meson setup -Db_sanitize=address,undefined -Db_lundef=false --buildtype=debugoptimized, builds, and runs the full CMocka suite under both sanitizers on every push to main and every PR targeting it. ASAN catches use-after-free, heap/stack OOB, double-free, and leaks; UBSAN catches signed-integer overflow, misaligned pointer access, null deref, narrowing conversions, etc. — the dynamic-runtime evidence behind the threat-model claim that memory-safety bugs in src/ are an in-scope defense (attack #8). Initial run found zero issues on the existing test surface. Stack traces in failure reports include symbolized backtraces (print_stacktrace=1); halt_on_error=1 + abort_on_error=1 ensure the first finding stops the run rather than continuing in an undefined state. Concurrency group set to cancel older runs when newer commits land on the same ref.

Documentation

  • docs/threat-model.md documents what pamsignal defends against, what it deliberately does not, and the design rationale behind the split. Sections cover: assets in priority order (alert integrity, alert credentials, the pamsignal user's privilege envelope, journal entries pamsignal writes), adversary classes with explicit capabilities (external remote, local unprivileged, in-pamsignal-group, compromised daemon, network attacker on alert path), nine in-scope attacks each cross-referenced to the source-line of its mitigation (_EXE allowlist, brute-force tracker semantics, memfd credential isolation, clearenv() + absolute-path execv, TLS-only --proto =https, sanitize_string + json_escape, the systemd hardening directives, compiler hardening + libFuzzer, per-IP cooldown), ten explicit out-of-scope non-goals (root-on-host, in-pamsignal-group, compromised journald/libsystemd/curl, compromised alert provider, durable delivery, multi-host correlation, authenticated alert delivery, admin misconfiguration, input-flood DoS), the trust-boundary table, and the deliberate design limitations. SECURITY.md's scope section now references the threat model rather than duplicating the breakdown; the pamsignal(8) man page's SEE ALSO points readers there before reporting suspected vulnerabilities; the README's documentation index links it. Designed as the canonical reference for "should this contribution land?" — a feature that strengthens an in-scope mitigation is welcome; a feature that pulls work into the daemon from an out-of-scope area gets pointed at this document.

0.3.2 — Type=notify, watchdog, and SECURITY.md

03 May 13:46

Choose a tag to compare

Best-practices polish release. Three small, independent changes that bring the project closer to "modern systemd-native daemon" baseline: a documented vulnerability-disclosure channel, Type=notify + WatchdogSec= integration, and a CI gate that prevents the unit's hardening score from regressing in future PRs. No source-code behavior change beyond the systemd integration; the existing CMocka suite passes unchanged and systemd-analyze security still scores 2.2.

CI

  • systemd-analyze security regression gate. The test-deb job now runs systemd-analyze security --threshold=30 pamsignal.service after install. Current internal score is 22 (displayed as 2.2 — "OK", second-best band). systemd-analyze's --threshold operates on a 0–100 internal scale; the displayed "Overall exposure level" line shows the same value divided by 10. Threshold 30 leaves 8 points of headroom (equivalent to 0.8 in displayed-decimal units) for routine adjustments but fails the workflow if a future PR strips a major hardening directive (MemoryDenyWriteExecute=, SystemCallFilter=, CapabilityBoundingSet=, RestrictNamespaces=, etc., each worth 5+ score points internally). Catches the class of regression where an unrelated change accidentally weakens the daemon's sandbox.

Operations

  • Type=notify + WatchdogSec=30s in pamsignal.service. The daemon now signals readiness via sd_notify(READY=1) after ps_journal_watch_init() succeeds (and not before), so systemd holds the unit in activating until pamsignal can actually process events — Wants=/After= chains resolve correctly and systemctl status doesn't briefly lie about state during the startup window. The main loop also pings sd_notify(WATCHDOG=1) every iteration; if sd_journal_wait ever wedges (kernel bug, journal corruption) systemd auto-restarts pamsignal at the 30 s threshold instead of letting the daemon silently stop processing auth events. NotifyAccess=main confines notification socket access to the parent process so the fork+exec curl children can't spoof readiness/watchdog messages. No-op when NOTIFY_SOCKET is unset (manual launch, tests).

Documentation

  • SECURITY.md at the repo root documents the responsible-disclosure channel (GitHub Security Advisories preferred, email fallback with the same PGP key fingerprint that signs the release packages), 90-day coordinated-disclosure timeline, supported version policy (latest minor only), and a scope table separating in-scope findings (parser bypasses, brute-force tracker bugs, alert-payload injection, hardening regressions) from out-of-scope dependencies (curl, libsystemd, alert delivery channels, root-already-on-host scenarios).

0.3.1 — Revert /usr/sbin path move

03 May 12:10

Choose a tag to compare

Patch release that reverts the /usr/bin/usr/sbin move from v0.3.0. The reasoning that landed v0.3.0 cited FHS §4.10's letter ("system administration daemons live in sbin"), but the systemd-era convention has moved past that distinction: journalctl, systemctl, loginctl, udevadm, podman, and containerd all ship in /usr/bin despite being administrator commands. Fedora 42+ has formally retired the bin/sbin split — %{_sbindir} == %{_bindir} == /usr/bin — and Debian Trixie has the merge on its roadmap. The v0.3.0 layout was on the wrong side of that trajectory and produced two CI-test bugs we had to fix during the v0.3.0 release run; reverting now is cheaper than keeping the migration as the ecosystem unwinds in the same direction.

Breaking

  • Daemon binary back to bindir. Packaged installs land at /usr/bin/pamsignal again (was /usr/sbin/pamsignal for the ~30 minutes v0.3.0 was on the apt/dnf gh-pages repos). Dev installs land at /usr/local/bin/pamsignal. The systemd unit's ExecStart is updated by the same configure_file substitution that landed in v0.3.0, so an apt upgrade / dnf upgrade from v0.3.0 → v0.3.1 atomically removes /usr/sbin/pamsignal, installs /usr/bin/pamsignal, and rewrites ExecStart in lockstep — systemctl daemon-reload is auto-fired by the maintainer scripts. Anyone who scripted against the absolute /usr/sbin/pamsignal path (download count for v0.3.0 was 0 at the time of v0.3.1) needs to revert their scripts to /usr/bin/pamsignal.