Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ee50904
Run the integration tests on arm64 and the profile on Ubuntu 26.04
VectoDE Oct 13, 2026
cd988ad
guard: report vulnerable packages, failed logins and what the scanner…
VectoDE Oct 13, 2026
9ca6c47
guard: trust only journald fields for logins and clean quoted text
VectoDE Oct 13, 2026
9a5b4c4
Run the kernel modules and the profile in KVM VMs with Debian and the…
VectoDE Oct 13, 2026
b7cfff3
Harden further towards a Lynis index of 85
VectoDE Oct 13, 2026
e93b22c
Fix the guard unit tests, two ShellCheck notes and the 26.04 runner l…
VectoDE Oct 13, 2026
71fc155
Support sudo-rs and the RHEL sudo path in admin-user
VectoDE Oct 13, 2026
adfe679
Restrict cron and sshd_config permissions, raise the Lynis minimums
VectoDE Oct 13, 2026
8cfcb4f
Confine the scanner units with systemd
VectoDE Oct 13, 2026
c9fc6cc
usbguard: work on systems without a USB bus; give the test VMs one
VectoDE Oct 13, 2026
11d4e5e
Quiet the workflow steps that printed Not and No lines
VectoDE Oct 13, 2026
a7bac7d
Fix swap on Rocky 9 and the firewall on AlmaLinux 10
VectoDE Oct 13, 2026
4f5f11f
Read the firewalld zone target from the permanent configuration
VectoDE Oct 13, 2026
37f8a8f
Install augenrules on RHEL 10 and show what a second install changed
VectoDE Oct 13, 2026
ef4d971
fix(security): stable unit paths on RHEL, quiet augenrules and firewa…
VectoDE Oct 13, 2026
d15f9a6
fix(service): let scanners enter their SELinux domain
VectoDE Oct 13, 2026
a514d25
ci(integration): Lynis minimum 80 for the RHEL family
VectoDE Oct 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# SPDX-FileCopyrightText: 2025-2026 VectoDE
#
# SPDX-License-Identifier: MIT

# GitHub-hosted runner labels newer than the list built into actionlint.
self-hosted-runner:
labels:
- ubuntu-26.04
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ jobs:
persist-credentials: false
- name: Install tools
run: |
sudo apt-get update -q
sudo apt-get install -y -q --no-install-recommends shfmt
# Quiet: errors still reach the log, the man-db trigger and needrestart summaries do not.
sudo apt-get -qq update
sudo NEEDRESTART_SUSPEND=1 apt-get -qq install -y --no-install-recommends shfmt >/dev/null
scripts/dev/install-shellcheck.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: ShellCheck
Expand Down
71 changes: 62 additions & 9 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

# Installs every module on every supported distribution: install, verify, install again without a change, remove.
# Containers run systemd as PID 1; modules that change the kernel (firewall, swap, sysctl, ...) run on the runner
# virtual machine itself, which is thrown away afterwards.
# virtual machine itself, which is thrown away afterwards. Everything runs on x86_64 and on arm64 (aarch64).
name: Integration

on:
Expand All @@ -24,14 +24,15 @@ concurrency:

jobs:
container:
name: ${{ matrix.system }} / ${{ matrix.group }}
runs-on: ubuntu-24.04
name: ${{ matrix.system }}${{ matrix.arch == 'arm64' && ' arm64' || '' }} / ${{ matrix.group }}
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
system: [debian-12, debian-13, ubuntu-22.04, ubuntu-24.04, ubuntu-26.04, rocky-9, almalinux-10]
group: [base, security, scanners]
arch: [x64, arm64]
include:
- group: base
modules: system time admin-user tools logging
Expand All @@ -55,9 +56,13 @@ jobs:
tests/integration/run.sh "$SYSTEM" "${modules[@]}"

host:
name: ubuntu-24.04 VM / kernel
runs-on: ubuntu-24.04
name: ${{ matrix.runner == 'ubuntu-24.04' && 'ubuntu-24.04 VM' || 'ubuntu-24.04 arm64 VM' }} / kernel
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
runner: [ubuntu-24.04, ubuntu-24.04-arm]
steps:
- uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
Expand All @@ -69,9 +74,14 @@ jobs:
run: tests/integration/run.sh host time swap firewall kernel-hardening mac auditd usbguard

profile:
name: ubuntu-24.04 VM / profile minimal-secure
runs-on: ubuntu-24.04
name: ${{ matrix.runner }} VM / profile minimal-secure
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
# ubuntu-26.04 runners are in public preview (October 2026).
runner: [ubuntu-24.04, ubuntu-26.04]
steps:
- uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
Expand All @@ -80,6 +90,49 @@ jobs:
with:
persist-credentials: false
# The whole profile at once, as on a real server, then the Lynis audit. minimal-secure reached a hardening index
# of 75 here (October 2026); the minimum leaves room for small changes between Lynis versions.
# of 78 here (October 2026); the runner image itself (Docker, PostgreSQL, its sudoers file) costs the rest. The
# minimum leaves room for small changes between Lynis versions.
- name: Install the profile, verify, reinstall, audit
run: tests/integration/run.sh host --profile minimal-secure 70
run: tests/integration/run.sh host --profile minimal-secure 75

vm:
name: ${{ matrix.system }} VM / ${{ matrix.run }}
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
system: [debian-12, debian-13, rocky-9, almalinux-10]
run: [kernel, profile minimal-secure]
steps:
- uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install QEMU and allow access to KVM
run: |
# Quiet: errors still reach the log, the man-db trigger and needrestart summaries do not.
sudo apt-get -qq update
sudo NEEDRESTART_SUSPEND=1 apt-get -qq install -y --no-install-recommends qemu-system-x86 qemu-utils cloud-image-utils >/dev/null
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
# Kernel modules as on the runner VM, then the whole profile with a Lynis audit. minimal-secure reached a
# hardening index of 87 on Debian 12 and 85 on Debian 13, 85 on Rocky Linux 9 and 84 on AlmaLinux 10 (October
# 2026). The minimums leave room for small changes between Lynis versions.
- name: Install, verify, reinstall, remove
env:
SYSTEM: vm-${{ matrix.system }}
RUN: ${{ matrix.run }}
run: |
if [[ $RUN == kernel ]]; then
tests/integration/run.sh "$SYSTEM" time swap firewall kernel-hardening mac auditd usbguard
else
case $SYSTEM in
vm-debian-*) minimum=82 ;;
*) minimum=80 ;;
esac
tests/integration/run.sh "$SYSTEM" --profile minimal-secure "$minimum"
fi
2 changes: 2 additions & 0 deletions .github/workflows/meta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ jobs:
with:
version: 1.30.1
advanced-security: false
# Findings as annotations on the pull request; a clean run prints nothing.
annotations: true

reuse:
name: REUSE
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ now or later.
- A warning for every setting that no module reads, so a misspelt key does not silently fall back to the default.
- Quiet output: the output of apt, dnf and long-running tools such as the AIDE database build goes to the run log,
and appears on the terminal only with `--verbose` or, with its last 40 lines, when the command fails.
- guard reports vulnerable packages (debsecan, `dnf updateinfo`), failed SSH logins above a threshold and what the
daily AIDE, rkhunter and ClamAV runs found, which their units had only written to the journal; successful logins
and active bans appear as notes.
- More hardening towards a Lynis index of 85: the login banner also in `/etc/issue` and `/etc/issue.net`, costlier
password hashes, libpam-tmpdir, process accounting, debsums, apt-show-versions, cron files and `sshd_config`
readable by root only. minimal-secure reaches 85 to 87 on Debian.
- The AIDE, rkhunter, ClamAV and Lynis runs are confined by systemd: read-only system and home directories, no new
privileges, writes only to their own state and logs; AIDE and ClamAV have no network.
- System requirements in the README: RAM, disk and install time for the profile and every module, measured by the
integration tests on every supported system.
- Package repositories of vendors are added only with a pinned key fingerprint.
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ That creates the administrator `alice` with the SSH keys of the GitHub account,
the firewall, fail2ban, kernel hardening, AppArmor or SELinux, auditing, automatic security updates, integrity checks
and the daily guard. Keep a second SSH session open the first time.

Without a command, `sudo lss` opens a menu.
Without a command, `sudo lss` opens a menu. On the RHEL family, sudo looks for commands only in `/usr/sbin` and
`/usr/bin` until the `admin-user` module has added `/usr/local/sbin`; call `sudo /usr/local/sbin/lss` until then.

## Commands

Expand Down Expand Up @@ -139,8 +140,11 @@ as further module groups.
## Guard

The `guard` module checks the server every day: it runs `lss verify`, repairs modules that fail or whose files were
changed (`LSS_GUARD_AUTOFIX`), and reports failed services, full disks, pending security updates, a needed reboot and
a falling Lynis index. Findings go to a webhook (Slack, Teams, Mattermost, Discord, ntfy) or by e-mail.
changed (`LSS_GUARD_AUTOFIX`), and reports failed services, full disks, pending security updates, a needed reboot, a
falling Lynis index and vulnerable packages (debsecan on Debian, `dnf updateinfo` on the RHEL family). It passes on
what the daily AIDE, rkhunter and ClamAV runs found, and reports more failed SSH logins than
`LSS_GUARD_LOGIN_THRESHOLD` with their main sources. Successful SSH logins and active fail2ban or CrowdSec bans are
listed as notes. Findings go to a webhook (Slack, Teams, Mattermost, Discord, ntfy) or by e-mail.

## Documentation

Expand Down
4 changes: 2 additions & 2 deletions docs/assurance.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,6 @@ signed build attestation that `lss update` verifies before it installs a version
- Unit tests (bats) cover the CLI, the libraries and the module runner, including link refusal, setting validation,
restore on failure and the repository signature checks; line coverage of `lib/` is above 90 % and enforced in CI.
- The integration tests install, verify, reinstall (which must change nothing) and remove every module on every
supported distribution, and run the `minimal-secure` profile on a virtual machine followed by a Lynis audit that must
reach a hardening index of 70.
supported distribution on x64 and arm64, and run the `minimal-secure` profile in virtual machines followed by a Lynis
audit that must reach a hardening index of 82 on Debian and 75 on the Ubuntu runner.
- ShellCheck, shfmt, SonarQube Cloud, actionlint, zizmor, gitleaks and the REUSE check run on every pull request.
20 changes: 11 additions & 9 deletions docs/hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,34 @@ This page lists what the security modules change, which part of the CIS Benchmar
Enterprise Linux each one covers, and where lss deliberately does something else. Every value below can be checked on
a server with `lss verify`; the `lynis` module gives a second opinion with `lss audit`.

The profile `minimal-secure` installs everything in this list except the modules marked opt-in. On an Ubuntu 24.04
virtual machine it reaches a Lynis hardening index of 75; CI checks that it stays at 70 or above.
The profile `minimal-secure` installs everything in this list except the modules marked opt-in. On fresh Debian 12
and 13 cloud images it reaches a Lynis hardening index of 87 and 85, on the Ubuntu 24.04 GitHub runner (which brings
Docker, databases and its own sudo rules) 78. CI checks that it stays at 82 on Debian and 75 on the runner.

## What the modules do

| Module | Changes | CIS area |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------- |
| `admin-user` | One administrator with SSH keys only (RSA at least 3072 bits), sudo through the sudo or wheel group, a generated password for the console | User accounts, sudo |
| `ssh-hardening` | No root login, no passwords, public keys only, `AllowGroups`, 3 tries, 30 s login grace, idle sessions closed after 15 minutes, no agent, X11 or tunnel forwarding, verbose logging, a login banner, modern key exchange (ML-KEM and sntrup761 hybrids first), AEAD ciphers first with AES-CTR as fallback, encrypt-then-MAC MACs only, DH moduli of 3072 bits and more | SSH server |
| `admin-user` | One administrator with SSH keys only (RSA at least 3072 bits), sudo through the sudo or wheel group (with a pseudo terminal, a 5-minute timestamp and its own log file; sudo-rs logs to the journal), a generated password for the console | User accounts, sudo |
| `ssh-hardening` | No root login, no passwords, public keys only, `AllowGroups`, 3 tries, 30 s login grace, idle sessions closed after 15 minutes, no agent, X11 or tunnel forwarding, verbose logging, `sshd_config` readable by root only, a login banner (also in `/etc/issue` and `/etc/issue.net`), modern key exchange (ML-KEM and sntrup761 hybrids first), AEAD ciphers first with AES-CTR as fallback, encrypt-then-MAC MACs only, DH moduli of 3072 bits and more | SSH server |
| `ssh-2fa` (opt-in) | A TOTP code in addition to the key, per user | SSH server, authentication |
| `firewall` | ufw or firewalld, everything inbound closed except SSH and the ports of installed modules; a rollback timer switches it off again if SSH cannot be confirmed | Host-based firewall |
| `fail2ban` | Bans after 5 failures in 10 minutes, ban time grows from 1 hour to 1 week, a recidive jail for repeat offenders, bans through nftables | (not in CIS; defence in depth) |
| `crowdsec` (opt-in, instead of fail2ban) | Behaviour detection with shared block lists, bouncer on nftables | (not in CIS; defence in depth) |
| `kernel-hardening` | ASLR, restricted kernel pointers and dmesg, ptrace scope 1, unprivileged BPF off, SysRq off, protected links, FIFOs and regular files, no core dumps of setuid programs, reverse path filter, no ICMP or IPv6 redirects, no source routing, SYN cookies, martians logged; unused file systems and protocols blocked (cramfs, freevxfs, hfs, jffs2, udf, dccp, sctp, rds, tipc, FireWire, Thunderbolt, Bluetooth), USB storage blocked; core dumps off | Kernel parameters, file system modules, network parameters |
| `mac` | AppArmor (Debian, Ubuntu) or SELinux (Rocky, AlmaLinux) enabled and enforcing | Mandatory access control |
| `auditd` | Audit rules for identity files, sudoers, logins, time changes, network configuration, permission changes, failed file access, mounts, deletions, kernel modules, sudo use, cron and the MAC policy; `audit=1` on the kernel command line | System auditing |
| `auditd` | Audit rules for identity files, sudoers, logins, time changes, network configuration, permission changes, failed file access, mounts, deletions, kernel modules, sudo use, cron and the MAC policy; `audit=1` on the kernel command line; process accounting (acct/psacct) | System auditing |
| `auto-updates` | Security updates every day (unattended-upgrades or dnf-automatic), services restarted after updates (needrestart on Debian and Ubuntu), an automatic reboot at 04:00 when an update needs one | Software updates |
| `password-policy` | At least 14 characters with pwquality, account locked for 15 minutes after 5 failures (faillock), umask 027, idle shells log out after 15 minutes | Password policy, PAM, shell timeout |
| `aide` | File integrity database, checked every day | File integrity |
| `rkhunter` | Rootkit scan every day | (not in CIS) |
| `password-policy` | At least 14 characters with pwquality, account locked for 15 minutes after 5 failures (faillock), umask 027, idle shells log out after 15 minutes, costlier password hashes (100,000 SHA-512 rounds, yescrypt cost 7), a private TMPDIR per session (libpam-tmpdir, Debian and Ubuntu) | Password policy, PAM, shell timeout |
| `aide` | File integrity database, checked every day; debsums for the package checksums on Debian and Ubuntu | File integrity |
| `rkhunter` | Rootkit scan every day; like the AIDE, ClamAV and Lynis runs it is confined by systemd (read-only system, no new privileges, writes only to its own state and logs) | (not in CIS) |
| `clamav` (opt-in) | Virus signatures updated several times a day, daily low-priority scan of writable directories | (not in CIS) |
| `lynis` | Weekly audit with the hardening index kept as history; `lss audit` fails below a minimum you set | (audit tool) |
| `usbguard` (opt-in)| Only the USB devices present at installation are allowed | Removable media |
| `system` | System crontab and cron directories readable by root only (600 and 700) | Job schedulers |
| `logging` | Persistent journal with size and age limits, optional forwarding to a syslog server over TLS | Logging |
| `time` | chrony with NTS-authenticated servers, no NTP port open | Time synchronisation |
| `guard` | Runs `lss verify` every day, repairs modules that failed or were changed outside lss, reports failed units, full disks, pending security updates, a needed reboot and a low Lynis index | (operations) |
| `guard` | Runs `lss verify` every day, repairs modules that failed or were changed outside lss, reports failed units, full disks, pending security updates, vulnerable packages, a needed reboot, a low Lynis index, AIDE/rkhunter/ClamAV findings and failed SSH logins; notes logins and bans | (operations) |

## Deliberate deviations

Expand Down
21 changes: 21 additions & 0 deletions lib/service.sh
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,27 @@ svc_wait_port() {
return 1
}

# svc_scanner_sandbox full|strict [PATH...]: [Service] lines that confine lss's own scanner units. They read the whole
# system but write only to the PATHs (with strict) or below /var (with full). The real /tmp, /proc/kallsyms, the
# kernel modules and the kernel log stay visible: a private /tmp would hide it from ClamAV and rkhunter, and rkhunter,
# AIDE and Lynis check the others.
svc_scanner_sandbox() {
local level=$1 path nnp=yes
shift
# Under SELinux a scanner enters its own domain (aide_t, antivirus_t) on exec. NoNewPrivileges blocks that change;
# the scanner then stays in init_t, which may not write its reports.
if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then
nnp=no
fi
printf '%s\n' "ProtectSystem=${level}" ProtectHome=read-only "NoNewPrivileges=${nnp}" ProtectControlGroups=yes \
ProtectClock=yes ProtectHostname=yes RestrictSUIDSGID=yes RestrictRealtime=yes RestrictNamespaces=yes \
LockPersonality=yes SystemCallArchitectures=native
for path in "$@"; do
printf 'ReadWritePaths=-%s\n' "${path}"
done
return
}

# svc_unit UNIT: writes /etc/systemd/system/UNIT (a unit of lss's own) from stdin and reloads systemd when it
# changed.
svc_unit() {
Expand Down
Loading