diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..768ab9b --- /dev/null +++ b/.github/actionlint.yaml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c2e2ef..2eef4eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index fa8f5b5..5fb1b63 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -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: @@ -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 @@ -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: @@ -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: @@ -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 diff --git a/.github/workflows/meta.yml b/.github/workflows/meta.yml index 487f57c..6a4494d 100644 --- a/.github/workflows/meta.yml +++ b/.github/workflows/meta.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5513e82..919f403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 123af18..83d4e74 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/docs/assurance.md b/docs/assurance.md index 3e6b4b3..ff84740 100644 --- a/docs/assurance.md +++ b/docs/assurance.md @@ -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. diff --git a/docs/hardening.md b/docs/hardening.md index ca55efa..0469456 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -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 diff --git a/lib/service.sh b/lib/service.sh index 3cc18ba..8108282 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -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() { diff --git a/libexec/lss-guard b/libexec/lss-guard index 2ef0c55..67b4cc2 100755 --- a/libexec/lss-guard +++ b/libexec/lss-guard @@ -7,15 +7,20 @@ # run daily by lss-guard.timer. Settings come from /etc/lss/guard.conf (KEY=value lines, parsed, never sourced). # # Checks: lss verify of every installed module (failed modules and files changed outside lss are configured again -# when GUARD_AUTOFIX=true), failed systemd units, disk space, pending security updates, the Lynis hardening index. +# when GUARD_AUTOFIX=true), failed systemd units, disk space, pending security updates, vulnerable packages (debsecan +# on Debian, dnf updateinfo on the RHEL family), failed SSH logins and active bans, the results of the daily AIDE, +# rkhunter and ClamAV runs, the Lynis hardening index. # Every finding goes to the journal (identifier lss-guard); a summary goes to the webhook and the e-mail address. +# Notes (logins, bans) are part of the summary but do not make the run fail. set -Eeuo pipefail shopt -s inherit_errexit CONF=${LSS_GUARD_CONF:-/etc/lss/guard.conf} LSS=${LSS_GUARD_LSS:-/usr/local/sbin/lss} STATE_DIR=${LSS_STATE_DIR:-/var/lib/lss} -declare -a FINDINGS=() FIXED=() +OS_RELEASE=${LSS_OS_RELEASE:-/etc/os-release} +# Global also when the script is sourced inside a function (the unit tests). +declare -ga FINDINGS=() FIXED=() NOTES=() setting() { local key=$1 default=${2-} line @@ -30,9 +35,27 @@ setting() { printf '%s' "${default}" } +# Findings can quote text that local users control (file names, log lines). Control characters are replaced, so such +# text cannot add lines to the summary, end the mail early or forge journal entries. +clean() { + local text=$1 + printf '%s' "${text//[[:cntrl:]]/?}" + return +} + finding() { - FINDINGS+=("$1") - logger -t lss-guard -p auth.warning -- "$1" + local text + text=$(clean "$1") + FINDINGS+=("${text}") + logger -t lss-guard -p auth.warning -- "${text}" + return +} + +note() { + local text + text=$(clean "$1") + NOTES+=("${text}") + logger -t lss-guard -p auth.info -- "${text}" return } @@ -92,6 +115,127 @@ check_updates() { return } +# Failed SSH logins of the last 24 hours (a finding above GUARD_LOGIN_THRESHOLD, with the busiest sources) and the +# successful logins (a note, so an unexpected one stands out). +check_logins() { + local threshold log failed count sources accepted + threshold=$(setting GUARD_LOGIN_THRESHOLD 50) + # _COMM and _UID are set by journald itself; a syslog identifier (-t sshd) could be forged with logger. + log=$(journalctl --since -24h --no-pager -o cat _COMM=sshd _COMM=sshd-session _UID=0 2>/dev/null) || return 0 + failed=$(grep -E 'Failed (password|publickey|keyboard-interactive)|Invalid user|maximum authentication attempts' <<<"${log}" || true) + count=0 + [[ -z ${failed} ]] || count=$(wc -l <<<"${failed}") + if ((count > threshold)); then + sources=$(grep -oE 'from [0-9a-fA-F.:]+' <<<"${failed}" | awk '{ print $2 }' | sort | uniq -c | sort -rn | + head -n 3 | awk '{ printf "%s%s (%s)", sep, $2, $1; sep = ", " }') + finding "${count} failed SSH logins in the last 24 hours, most from ${sources:-unknown sources}." + fi + accepted=$(grep -oE 'Accepted [a-z-]+ for [^ ]+ from [0-9a-fA-F.:]+' <<<"${log}" | awk '{ print $4 " from " $6 }' | + sort | uniq -c | sort -rn | awk '{ printf "%s%s %s %s (%s)", sep, $2, $3, $4, $1; sep = ", " }' || true) + if [[ -n ${accepted} ]]; then + note "SSH logins in the last 24 hours: ${accepted}." + fi + return +} + +# Addresses that fail2ban or CrowdSec block right now. +check_bans() { + local count + if command -v fail2ban-client >/dev/null 2>&1; then + count=$(fail2ban-client banned 2>/dev/null | grep -oE "'([0-9]{1,3}\.){3}[0-9]{1,3}'|'[0-9a-fA-F]*:[0-9a-fA-F:]+'" | + wc -l || true) + if ((count > 0)); then + note "fail2ban currently bans ${count} address(es)." + fi + fi + if command -v cscli >/dev/null 2>&1; then + count=$(cscli decisions list -o raw 2>/dev/null | tail -n +2 | grep -c . || true) + if ((count > 0)); then + note "CrowdSec currently blocks ${count} address(es)." + fi + fi + return +} + +# Vulnerabilities in installed packages: debsecan on Debian (Ubuntu is covered by the security updates check), the +# advisories of severity Critical and Important on the RHEL family. +check_vulnerabilities() { + local codename all fixed count + if command -v debsecan >/dev/null 2>&1; then + codename=$(sed -n 's/^VERSION_CODENAME=//p' "${OS_RELEASE}" 2>/dev/null | tr -d '"') + all=$(debsecan --suite "${codename}" --format summary 2>/dev/null | grep -c 'high urgency' || true) + fixed=$(debsecan --suite "${codename}" --only-fixed --format summary 2>/dev/null | grep -c 'high urgency' || true) + if ((fixed > 0)); then + finding "${fixed} high-urgency vulnerabilities in installed packages have a fix: install the security updates." + fi + if ((all > fixed)); then + note "$((all - fixed)) high-urgency vulnerabilities in installed packages have no fix yet (debsecan)." + fi + elif command -v dnf >/dev/null 2>&1; then + count=$(dnf -q updateinfo list --security --sec-severity Critical --sec-severity Important 2>/dev/null | + grep -c . || true) + if ((count > 0)); then + finding "${count} critical or important security advisories apply (dnf updateinfo list --security)." + fi + fi + return +} + +# scan_run UNIT: prints "STATUS EPOCH INVOCATION" of the last run of UNIT.service; nothing when it never ran. +scan_run() { + local unit=$1 status exited invocation epoch + systemctl cat "${unit}.service" >/dev/null 2>&1 || return 0 + exited=$(systemctl show -p ExecMainExitTimestamp --value "${unit}.service" 2>/dev/null || true) + [[ -n ${exited} && ${exited} != n/a ]] || return 0 + status=$(systemctl show -p ExecMainStatus --value "${unit}.service" 2>/dev/null || true) + invocation=$(systemctl show -p InvocationID --value "${unit}.service" 2>/dev/null || true) + epoch=$(date -d "${exited}" +%s 2>/dev/null || echo 0) + printf '%s %s %s\n' "${status:-0}" "${epoch}" "${invocation:--}" + return +} + +# The daily scanners report what they find through their exit status, which their units accept as success: AIDE 1-7 +# for added, removed or changed files, rkhunter 1 for warnings, clamscan 1 for infected files. +check_scanners() { + local entry unit label run status epoch invocation output when count detail + for entry in lss-aide-check:AIDE lss-rkhunter:rkhunter lss-clamscan:ClamAV; do + unit=${entry%%:*} + label=${entry#*:} + run=$(scan_run "${unit}") + [[ -n ${run} ]] || continue + read -r status epoch invocation <<<"${run}" + when=$(date -d "@${epoch}" '+%Y-%m-%d %H:%M' 2>/dev/null || echo unknown) + if ((epoch < $(date +%s) - 2 * 86400)); then + finding "The daily ${label} check has not run since ${when} (systemctl status ${unit}.timer)." + fi + if [[ ! ${status} =~ ^[0-9]+$ ]] || ((status == 0)); then + continue + fi + output='' + if [[ ${invocation} != - ]]; then + output=$(journalctl --no-pager -o cat "_SYSTEMD_INVOCATION_ID=${invocation}" 2>/dev/null || true) + fi + case ${label} in + AIDE) + detail=$(awk -F: '/^ *(Added|Removed|Changed) entries/ { n = $2; gsub(/[^0-9]/, "", n); split($1, w, " "); printf "%s%s %s", sep, n, tolower(w[1]); sep = ", " }' <<<"${output}") + finding "AIDE found changed files on ${when}${detail:+ (${detail})}: review them with journalctl -u ${unit}, then accept them by rebuilding the database (aideinit --yes --force on Debian and Ubuntu, aide --init and moving aide.db.new.gz to aide.db.gz in /var/lib/aide on the RHEL family)." + ;; + rkhunter) + count=$(grep -c '^Warning:' <<<"${output}" || true) + detail=$(grep -m 1 '^Warning:' <<<"${output}" | cut -c 10-120 || true) + finding "rkhunter reported ${count} warning(s) on ${when}${detail:+, the first: ${detail}} (journalctl -u ${unit})." + ;; + ClamAV) + count=$(grep -c ' FOUND$' <<<"${output}" || true) + detail=$(grep ' FOUND$' <<<"${output}" | head -n 3 | sed 's/: .* FOUND$//' | paste -sd ',' - | sed 's/,/, /g' || true) + finding "ClamAV found ${count} infected file(s) on ${when}${detail:+: ${detail}} (journalctl -u ${unit})." + ;; + *) ;; + esac + done + return +} + check_lynis() { local minimum last minimum=$(setting GUARD_LYNIS_MIN 0) @@ -130,7 +274,7 @@ notify() { fi if [[ -n ${email} ]] && command -v sendmail >/dev/null 2>&1; then printf 'To: %s\nSubject: [lss guard] %s\n\n%s\n' "${email}" "$(hostname -f 2>/dev/null || hostname)" "${summary}" | - sendmail -t || logger -t lss-guard -p auth.err -- "Mail delivery failed" + sendmail -t -i || logger -t lss-guard -p auth.err -- "Mail delivery failed" fi return } @@ -144,13 +288,20 @@ main() { check_units check_disks check_updates + check_vulnerabilities + check_logins + check_bans + check_scanners check_lynis local summary host host=$(hostname -f 2>/dev/null || hostname) if [[ ${#FINDINGS[@]} -eq 0 && ${#FIXED[@]} -eq 0 ]]; then logger -t lss-guard -p auth.info -- "All checks passed" if [[ $(setting GUARD_DAILY_SUMMARY false) == true ]]; then - notify "lss guard on ${host}: all checks passed." + summary="lss guard on ${host}: all checks passed." + local line + for line in "${NOTES[@]}"; do summary+=$'\n'"- ${line}"; done + notify "${summary}" fi return 0 fi @@ -158,8 +309,12 @@ main() { local line for line in "${FINDINGS[@]}"; do summary+=$'\n'"- ${line}"; done for line in "${FIXED[@]}"; do summary+=$'\n'"- Restored module ${line} (it had been changed outside lss)."; done + for line in "${NOTES[@]}"; do summary+=$'\n'"- ${line}"; done notify "${summary}" [[ ${#FINDINGS[@]} -eq 0 ]] } -main "$@" +# The unit tests source this file without running it. +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + main "$@" +fi diff --git a/modules/base/admin-user.sh b/modules/base/admin-user.sh index fe58dae..7479e8d 100644 --- a/modules/base/admin-user.sh +++ b/modules/base/admin-user.sh @@ -82,6 +82,12 @@ mod_install() { return } +# sudo-rs (the default sudo from Ubuntu 25.10 on) knows only part of the sudoers options and logs to the journal. +_admin_sudo_rs() { + sudo --version 2>/dev/null | grep -q '^sudo-rs' + return +} + # Writes a sudoers file only after visudo accepted it; a broken sudoers file would lock every administrator out. _admin_sudoers() { local dest=$1 content=$2 tmp @@ -134,10 +140,16 @@ mod_configure() { sudoers="# Managed by lss (module admin-user). Defaults use_pty -Defaults logfile=\"/var/log/sudo.log\" Defaults timestamp_timeout=5 Defaults passwd_tries=3 Defaults env_reset" + if ! _admin_sudo_rs; then + sudoers+=$'\n''Defaults logfile="/var/log/sudo.log"' + fi + # The RHEL family leaves /usr/local/sbin out of the sudo path, so "sudo lss" would not find lss. + if os_is_rhel; then + sudoers+=$'\n''Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"' + fi if opt_bool sudo_nopasswd; then sudoers+=$'\n'"${name} ALL=(ALL:ALL) NOPASSWD: ALL" fi diff --git a/modules/base/swap.sh b/modules/base/swap.sh index 7f360b2..7879bb2 100644 --- a/modules/base/swap.sh +++ b/modules/base/swap.sh @@ -68,7 +68,8 @@ mod_configure() { dd if=/dev/zero of="${path}" bs=1M count="${size}" status=none ) chmod 600 "${path}" - mkswap --quiet "${path}" >/dev/null + # mkswap reports on stdout; --quiet exists only from util-linux 2.38 on (Rocky 9 has 2.37). + mkswap "${path}" >/dev/null files_note_change "swap file ${path} (${size} MiB)" fi chmod 600 "${path}" diff --git a/modules/base/system.sh b/modules/base/system.sh index 96c9a02..6636aab 100644 --- a/modules/base/system.sh +++ b/modules/base/system.sh @@ -85,10 +85,29 @@ _system_cron() { return } +# Only root reads the system crontab and the cron directories (CIS 5.1.2 to 5.1.7, Lynis SCHD-7704). +SYSTEM_CRON_PATHS="/etc/crontab:600 /etc/cron.hourly:700 /etc/cron.daily:700 /etc/cron.weekly:700 /etc/cron.monthly:700 /etc/cron.d:700" + +_system_cron_permissions() { + local entry path mode + for entry in ${SYSTEM_CRON_PATHS}; do + path=${entry%:*} + mode=${entry#*:} + [[ -e ${path} ]] || continue + if [[ $(stat -c '%a %U %G' "${path}") != "${mode} root root" ]]; then + chown root:root "${path}" + chmod "${mode}" "${path}" + files_note_change "permissions of ${path}" + fi + done + return +} + mod_configure() { _system_hostname _system_locale svc_enable "$(_system_cron)" + _system_cron_permissions return } @@ -104,11 +123,16 @@ mod_verify() { fi getent hosts "$(hostname)" >/dev/null || die "system: the host name $(hostname) does not resolve." svc_is_active "$(_system_cron)" || die "system: cron is not running." + local entry + for entry in ${SYSTEM_CRON_PATHS}; do + [[ ! -e ${entry%:*} || $(stat -c '%a' "${entry%:*}") == "${entry#*:}" ]] || + die "system: ${entry%:*} must be mode ${entry#*:}." + done return } # Base packages, host name and locale stay: other software depends on them, and they were set on purpose. mod_remove() { - log_info "system: base packages, host name and locale stay as they are." + log_info "system: base packages, host name, locale and the cron permissions stay as they are." return } diff --git a/modules/base/tools.sh b/modules/base/tools.sh index 05cf697..04ec717 100644 --- a/modules/base/tools.sh +++ b/modules/base/tools.sh @@ -8,7 +8,7 @@ MOD_DESCRIPTION="Administration tools: editor, shell multiplexer, network and di MOD_REQUIRES="system" MOD_OPTIONS="extra||More packages to install, separated by spaces (names as in the distribution)" -TOOLS_DEB="vim-tiny nano less htop tmux jq git rsync unzip zip lsof bind9-dnsutils tcpdump traceroute ncdu tree bash-completion psmisc" +TOOLS_DEB="vim-tiny nano less htop tmux jq git rsync unzip zip lsof bind9-dnsutils tcpdump traceroute ncdu tree bash-completion psmisc apt-show-versions" TOOLS_RPM="vim-minimal nano less htop tmux jq git rsync unzip zip lsof bind-utils tcpdump traceroute ncdu tree bash-completion psmisc" _tools_wanted() { diff --git a/modules/security/aide.sh b/modules/security/aide.sh index bca9ab1..8acf958 100644 --- a/modules/security/aide.sh +++ b/modules/security/aide.sh @@ -32,7 +32,7 @@ mod_check() { } mod_install() { - pkg_install_family "aide aide-common" "aide" + pkg_install_family "aide aide-common debsums" "aide" return } @@ -97,7 +97,9 @@ Description=AIDE file integrity check (lss) Type=oneshot Nice=19 IOSchedulingClass=idle -ExecStart=$(command -v aide) --check --config $(_aide_conf) +ExecStart=$(readlink -f "$(command -v aide)") --check --config $(_aide_conf) +PrivateNetwork=yes +$(svc_scanner_sandbox strict /var/log /var/lib/aide) SuccessExitStatus=1 2 3 4 5 6 7 EOF svc_unit "${AIDE_UNIT}.timer" <= 10)); then + rpm+=" audit-rules" + fi + pkg_install_family "auditd audispd-plugins acct" "${rpm}" + return +} + +# Process accounting service: acct on Debian and Ubuntu, psacct on the RHEL family. +_auditd_acct_unit() { + if os_is_debian; then printf 'acct'; else printf 'psacct'; fi return } @@ -117,6 +128,7 @@ mod_configure() { _auditd_boot_args svc_enable auditd + svc_enable "$(_auditd_acct_unit)" if [[ ${conf_changed} == 1 ]]; then # auditd refuses "systemctl restart" on the RHEL family; SIGHUP rereads auditd.conf. pkill -HUP -x auditd || true @@ -125,8 +137,8 @@ mod_configure() { if _auditd_immutable_now; then log_warn "auditd: the loaded rules are locked (-e 2); the new rules apply after the next reboot." else - augenrules --check >/dev/null 2>&1 || true - augenrules --load >/dev/null || die "auditd: the kernel rejected the audit rules." + # augenrules reports slow rule forms on RHEL; that detail belongs in the log, not on the screen. + log_command augenrules --load || die "auditd: the kernel rejected the audit rules." fi fi return @@ -134,12 +146,14 @@ mod_configure() { mod_verify() { svc_is_active auditd || die "auditd: the service is not running." + svc_is_active "$(_auditd_acct_unit)" || die "auditd: process accounting ($(_auditd_acct_unit)) is not running." auditctl -s | grep -qE '^enabled [12]' || die "auditd: kernel auditing is off." auditctl -l | grep -qE -- '(-k |key=)identity' || die "auditd: the lss rules are not loaded." return } mod_remove() { + pkg_remove_family acct psacct file_remove "${AUDIT_RULES}" file_remove "${AUDIT_FINAL}" if [[ -f /etc/default/grub.d/60-lss-audit.cfg ]]; then diff --git a/modules/security/clamav.sh b/modules/security/clamav.sh index 2d76297..da72efb 100644 --- a/modules/security/clamav.sh +++ b/modules/security/clamav.sh @@ -66,8 +66,10 @@ Description=ClamAV scan of writable directories (lss) Type=oneshot Nice=19 IOSchedulingClass=idle -ExecStart=/bin/sh -c 'for d in $(opt scan_paths); do [ -d "\$\$d" ] && set -- "\$\$@" "\$\$d"; done; [ \$\$# -eq 0 ] || exec $(command -v clamscan) --recursive --infected --cross-fs=no --max-filesize=100M --max-scansize=400M "\$\$@"' +ExecStart=/bin/sh -c 'for d in $(opt scan_paths); do [ -d "\$\$d" ] && set -- "\$\$@" "\$\$d"; done; [ \$\$# -eq 0 ] || exec $(readlink -f "$(command -v clamscan)") --recursive --infected --cross-fs=no --max-filesize=100M --max-scansize=400M "\$\$@"' SuccessExitStatus=1 +PrivateNetwork=yes +$(svc_scanner_sandbox strict /tmp /var/tmp) EOF svc_unit "${CLAM_UNIT}.timer" </dev/null 2>&1; then - _fw_firewalld --zone=public --remove-service="${service}" >/dev/null + # firewall-offline-cmd reads --remove-service as a lokkit option, which cannot be combined with --zone. + if svc_is_active firewalld; then + firewall-cmd --permanent --zone=public --remove-service="${service}" >/dev/null + else + firewall-offline-cmd --zone=public --remove-service-from-zone="${service}" >/dev/null + fi files_note_change "firewalld: closed service ${service}" fi done @@ -69,7 +74,7 @@ _fw_defaults_firewalld() { } mod_configure() { - local ssh_port spec + local ssh_port spec i ssh_port=$(fw_ssh_port) # SSH first: recorded and applied before the firewall is switched on. fw_allow "${ssh_port}/tcp" @@ -91,6 +96,11 @@ mod_configure() { svc_enable ufw else svc_enable firewalld + # firewall-cmd prints a waiting notice while firewalld is still starting. + for ((i = 0; i < 30; i++)); do + firewall-cmd --state >/dev/null 2>&1 && break + sleep 1 + done fi files_note_change "firewall enabled" _fw_is_open "${ssh_port}" || die "firewall: SSH port ${ssh_port} is not open; the firewall stays off." @@ -121,7 +131,7 @@ mod_verify() { ufw status verbose | grep -q 'Default: deny (incoming)' || die "firewall: inbound traffic is not denied by default." else [[ $(firewall-cmd --get-default-zone) == public ]] || die "firewall: default zone is not public." - [[ $(firewall-cmd --zone=public --get-target) =~ ^(default|DROP|REJECT|%%REJECT%%)$ ]] || + [[ $(firewall-cmd --permanent --zone=public --get-target) =~ ^(default|DROP|REJECT|%%REJECT%%)$ ]] || die "firewall: the public zone accepts everything." fi for spec in $(opt allow); do diff --git a/modules/security/guard.sh b/modules/security/guard.sh index af60de5..84e1444 100644 --- a/modules/security/guard.sh +++ b/modules/security/guard.sh @@ -4,7 +4,7 @@ MOD_NAME="guard" MOD_GROUP="security" -MOD_DESCRIPTION="Keeps the server at the level lss set up: daily verify and repair, failed services, disk, updates, alerts" +MOD_DESCRIPTION="Keeps the server at the level lss set up: daily verify and repair, failed services, disk, updates, vulnerable packages, logins, scanner findings, alerts" MOD_REQUIRES="system" MOD_OPTIONS="autofix|true|Configure modules again that fail verify or were changed outside lss time|07:00|Time of the daily check (HH:MM) @@ -12,6 +12,7 @@ email||Send findings to this address (needs the mail-relay module or another sen webhook||Send findings to this HTTPS webhook (Slack, Teams, Mattermost, Discord or ntfy) disk_threshold|90|Report file systems that are fuller than this (percent) lynis_min|0|Report a Lynis hardening index below this (needs the lynis module) +login_threshold|50|Report more failed SSH logins than this within 24 hours daily_summary|false|Also send a message when everything is fine" GUARD_SCRIPT=/usr/local/lib/lss/guard @@ -26,6 +27,7 @@ mod_check() { [[ $(opt time) =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]] || die "LSS_GUARD_TIME must look like 07:00." (($(opt_int disk_threshold) <= 100)) || die "LSS_GUARD_DISK_THRESHOLD must be at most 100." opt_int lynis_min >/dev/null + opt_int login_threshold >/dev/null email=$(opt email) webhook=$(opt webhook) [[ -z ${email} || ${email} =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]] || die "LSS_GUARD_EMAIL '${email}' is not an e-mail address." @@ -34,6 +36,15 @@ mod_check() { return } +# debsecan matches the installed packages with the Debian security tracker; Ubuntu and the RHEL family have no use for +# it (the security updates check and dnf updateinfo cover them). +mod_install() { + if [[ ${OS_ID} == debian ]]; then + pkg_install debsecan + fi + return +} + mod_configure() { local bool file_write 0755 root:root "${GUARD_SCRIPT}" <"${LSS_ROOT}/libexec/lss-guard" @@ -48,6 +59,7 @@ mod_configure() { printf 'GUARD_DAILY_SUMMARY=%s\n' "${bool}" printf 'GUARD_EMAIL=%s\nGUARD_WEBHOOK=%s\n' "$(opt email)" "$(opt webhook)" printf 'GUARD_DISK_THRESHOLD=%s\nGUARD_LYNIS_MIN=%s\n' "$(opt_int disk_threshold)" "$(opt_int lynis_min)" + printf 'GUARD_LOGIN_THRESHOLD=%s\n' "$(opt_int login_threshold)" ) svc_unit "${GUARD_UNIT}.service" </dev/null || true + [[ -d ${USBGUARD_BUS} ]] + return +} mod_check() { if os_in_container; then @@ -22,6 +30,8 @@ mod_install() { } mod_configure() { + local bus=0 + ! _usbguard_bus || bus=1 # The policy is generated once from the devices connected now; later devices need an explicit rule # (usbguard allow-device ... -p), so a keyboard plugged in later cannot type commands. if [[ ! -s ${USBGUARD_RULES} ]] || ! grep -q 'lss' "${USBGUARD_RULES}"; then @@ -31,21 +41,35 @@ mod_configure() { ) fi local changed=${LSS_FILE_CHANGED:-0} - svc_enable usbguard - if [[ ${changed} == 1 ]]; then - svc_restart usbguard + # usbguard-daemon stops at once without a USB bus; systemd then starts it on the first boot that has one, and the + # rules above (empty on such a machine) block every device until it is allowed explicitly. + svc_dropin usbguard.service lss <<<$'# Managed by lss (module usbguard).\n[Unit]\nConditionPathIsDirectory='"${USBGUARD_BUS}" + if [[ ${bus} == 1 ]]; then + svc_enable usbguard + if [[ ${changed} == 1 ]]; then + svc_restart usbguard + fi + elif ! svc_is_enabled usbguard; then + systemctl enable usbguard >/dev/null 2>&1 || die "usbguard: could not enable usbguard.service." + files_note_change "enabled usbguard" fi return } mod_verify() { - svc_is_active usbguard || die "usbguard: the service is not running." - usbguard list-rules >/dev/null || die "usbguard: the daemon does not answer." + svc_is_enabled usbguard || die "usbguard: the service is disabled." + if _usbguard_bus; then + svc_is_active usbguard || die "usbguard: the service is not running." + usbguard list-rules >/dev/null || die "usbguard: the daemon does not answer." + else + [[ -f /etc/systemd/system/usbguard.service.d/lss.conf ]] || die "usbguard: the start condition of lss is missing." + fi return } mod_remove() { svc_disable usbguard + svc_dropin_remove usbguard.service lss file_remove "${USBGUARD_RULES}" if [[ ${LSS_PURGE:-0} == 1 ]]; then pkg_remove usbguard diff --git a/tests/fixtures/bin/selinuxenabled b/tests/fixtures/bin/selinuxenabled new file mode 100755 index 0000000..e6990ff --- /dev/null +++ b/tests/fixtures/bin/selinuxenabled @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# shellcheck source=tests/fixtures/bin/_stub.bash +source "$(dirname "$0")/_stub.bash" +stub_main selinuxenabled "$@" diff --git a/tests/integration/run.sh b/tests/integration/run.sh index e1cc04c..b493c69 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -8,8 +8,9 @@ # # For every module: install (with its requirements), verify, install again (every module in the run must report # "unchanged"), and remove. With --profile: install the whole profile, verify, install again, then run the Lynis -# audit and fail below LYNIS_MIN_INDEX. SYSTEM is a directory in tests/integration/images (a systemd container); -# "host" runs on the machine itself (for modules that need a real kernel, used on throwaway CI virtual machines only). +# audit and fail below LYNIS_MIN_INDEX. SYSTEM is a directory in tests/integration/images (a systemd container), +# vm-NAME for a KVM virtual machine from tests/integration/vm-images.conf, or "host" for the machine itself (modules +# that need a real kernel; used on throwaway CI virtual machines only). set -Eeuo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/../.." @@ -21,6 +22,8 @@ shift } settings=tests/integration/settings.conf container="lss-test-${system}" +# shellcheck source=tests/integration/vm.sh +source tests/integration/vm.sh # Collapsible sections in the GitHub Actions log. group_start() { @@ -37,6 +40,8 @@ in_target() { local script=$1 if [[ ${system} == host ]]; then sudo --preserve-env=SSH_CONNECTION bash -c "${script}" + elif [[ ${system} == vm-* ]]; then + vm_run "${script}" else docker exec -i "${container}" bash -c "${script}" fi @@ -47,6 +52,8 @@ start_target() { if [[ ${system} == host ]]; then sudo rm -rf /opt/lss-src sudo cp -a "${PWD}" /opt/lss-src + elif [[ ${system} == vm-* ]]; then + vm_start "${system#vm-}" else [[ -d tests/integration/images/${system} ]] || { echo "Unknown system ${system}" >&2 @@ -78,6 +85,15 @@ install_again() { } printf '%s\n' "${second}" if grep -E 'OK +[a-z0-9-]+: done' <<<"${second}"; then + # What changed: the notes of the run and, for rewritten files, the difference to the version before it. + in_target 'run=$(ls -td /var/lib/lss/runs/*/ | head -n 1) + cat "${run}changes" + for backup in $(find "/var/lib/lss/backup/$(basename "${run}")" -path "*/files/*" -type f 2>/dev/null); do + file=/${backup#*/files/} + echo "--- ${file}" + diff "${backup}" "${file}" | head -n 20 + stat -c "%a %U:%G" "${backup}" "${file}" + done' || true echo "Second install of $* changed something; modules must be idempotent." >&2 exit 1 fi @@ -90,7 +106,9 @@ stop_target() { in_target 'tail -n 200 /var/log/lss/*.log 2>/dev/null; systemctl --failed --no-pager; journalctl -p warning -n 80 --no-pager' || true group_end fi - if [[ ${system} != host ]]; then + if [[ ${system} == vm-* ]]; then + vm_stop + elif [[ ${system} != host ]]; then docker rm -f "${container}" >/dev/null 2>&1 || true fi exit "${status}" @@ -98,7 +116,9 @@ stop_target() { # Available memory and used disk space in MiB, for the resource figures in the README. resources_snapshot() { - in_target 'echo $(($(grep MemAvailable /proc/meminfo | tr -s " " | cut -d " " -f 2) / 1024)) $(df -m --output=used / | tail -n 1)' + # Dropping the page cache first keeps cached files out of the figure (not possible in containers). + in_target 'sync; echo 3 >/proc/sys/vm/drop_caches 2>/dev/null || true + echo $(($(grep MemAvailable /proc/meminfo | tr -s " " | cut -d " " -f 2) / 1024)) $(df -m --output=used / | tail -n 1)' return } @@ -119,7 +139,11 @@ resources_report() { in_target 'touch /run/lss-memwatch.stop; sleep 1' mem_low=$(in_target "awk 'NR == 1 || \$2 < low { low = \$2 } END { print int(low / 1024) }' /run/lss-memwatch rm -f /run/lss-memwatch /run/lss-memwatch.stop") - echo "RESOURCES ${label}: $((SECONDS - started)) s, RAM $((mem_before - mem_after)) MiB after, $((mem_before - mem_low)) MiB peak, disk $((disk_after - disk_before)) MiB" + # Other processes on a shared runner can free memory meanwhile; a negative difference means "nothing measurable". + local mem_used=$((mem_before - mem_after)) mem_peak=$((mem_before - mem_low)) + ((mem_used > 0)) || mem_used=0 + ((mem_peak > mem_used)) || mem_peak=${mem_used} + echo "RESOURCES ${label}: $((SECONDS - started)) s, RAM ${mem_used} MiB after, ${mem_peak} MiB peak, disk $((disk_after - disk_before)) MiB" in_target 'ps -eo rss=,comm= --sort=-rss | head -n 8' | awk '{ printf " %5d MiB %s\n", $1 / 1024, $2 }' return } @@ -141,6 +165,22 @@ if [[ $1 == --profile ]]; then group_start "${system}: Lynis audit" in_target "lss --yes --no-color --set LSS_LYNIS_MIN_INDEX=${3:-0} audit" group_end + # The daily and weekly scans run once inside their systemd sandbox; a path the sandbox blocks fails the unit. + group_start "${system}: scheduled scans" + in_target 'for unit in lss-aide-check lss-rkhunter lss-clamscan lss-lynis; do + systemctl cat "${unit}.service" >/dev/null 2>&1 || continue + started=${SECONDS} + if ! systemctl start --wait "${unit}.service"; then + journalctl -u "${unit}.service" -n 60 --no-pager + exit 1 + fi + echo "${unit}: $(systemctl show -p ExecMainStatus --value "${unit}.service") after $((SECONDS - started)) s, $(systemd-analyze security "${unit}.service" 2>/dev/null | tail -n 1)" + done' + group_end + # What Lynis would still change, as the starting point for raising the hardening index. + group_start "${system}: Lynis details" + in_target "grep -iE 'different value than expected|weak configuration state|incorrect file permissions|permissions .* (not|too)|Result: .*(unsafe|not hardened|exposed)' /var/log/lynis.log | sed -E 's/^[0-9: -]+//' | sort -u | head -n 150" || true + group_end echo "${system}: profile ${profile} passed" exit 0 fi diff --git a/tests/integration/vm-images.conf b/tests/integration/vm-images.conf new file mode 100644 index 0000000..3af8eaa --- /dev/null +++ b/tests/integration/vm-images.conf @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Cloud images for the VM integration tests (tests/integration/run.sh vm-NAME ...). +# NAME|IMAGE URL|CHECKSUM FILE URL|ALGORITHM|ADMIN GROUP +# The images are the vendors' current builds; each download is checked against the checksum file the vendor publishes +# next to it. The VMs only live for one CI job. +debian-12|https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2|https://cloud.debian.org/images/cloud/bookworm/latest/SHA512SUMS|sha512|sudo +debian-13|https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2|https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS|sha512|sudo +rocky-9|https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2|https://dl.rockylinux.org/pub/rocky/9/images/x86_64/CHECKSUM|sha256|wheel +almalinux-10|https://repo.almalinux.org/almalinux/10/cloud/x86_64/images/AlmaLinux-10-GenericCloud-latest.x86_64.qcow2|https://repo.almalinux.org/almalinux/10/cloud/x86_64/images/CHECKSUM|sha256|wheel diff --git a/tests/integration/vm.sh b/tests/integration/vm.sh new file mode 100644 index 0000000..638a3c5 --- /dev/null +++ b/tests/integration/vm.sh @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# shellcheck shell=bash +# KVM virtual machines for tests/integration/run.sh (systems named vm-NAME, see vm-images.conf). Needs qemu-system-x86, +# qemu-utils, cloud-image-utils and access to /dev/kvm. The VM gets a test user with a fresh key in the administrator +# group, so the SSH hardening keeps letting it in. A USB controller with a keyboard gives usbguard a device to allow. + +VM_DIR='' +VM_PORT=${LSS_VM_SSH_PORT:-2222} + +vm_ssh() { + ssh -i "${VM_DIR}/key" -p "${VM_PORT}" -o BatchMode=yes -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10 tester@127.0.0.1 "$@" + return +} + +# vm_image NAME: downloads the image unless a copy with the published checksum is there; prints its path. +vm_image() { + local name=$1 line url sums algo group cache file expected + line=$(grep "^${name}|" tests/integration/vm-images.conf) || { + echo "Unknown VM image ${name}" >&2 + return 2 + } + IFS='|' read -r _ url sums algo group <<<"${line}" + cache=${LSS_VM_CACHE:-${HOME}/.cache/lss-vm} + mkdir -p "${cache}" + file=${cache}/${url##*/} + expected=$(curl --proto '=https' --tlsv1.2 -fsSL --retry 3 "${sums}" | grep -F "${url##*/}" | + grep -oE '[0-9a-f]{64,128}' | head -n 1) + [[ -n ${expected} ]] || { + echo "No checksum for ${url##*/} in ${sums}" >&2 + return 1 + } + if [[ ! -f ${file} ]] || ! "${algo}sum" --check --quiet <<<"${expected} ${file}" >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -fsSL --retry 3 -o "${file}.part" "${url}" >&2 + "${algo}sum" --check --quiet <<<"${expected} ${file}.part" >&2 || { + rm -f -- "${file}.part" + echo "Checksum mismatch for ${url}" >&2 + return 1 + } + mv -f -- "${file}.part" "${file}" + fi + printf '%s %s\n' "${file}" "${group}" + return +} + +vm_start() { + local name=$1 image group i + read -r image group <<<"$(vm_image "${name}")" + [[ -n ${image} ]] || exit 1 + VM_DIR=$(mktemp -d) + qemu-img create -q -f qcow2 -b "${image}" -F qcow2 "${VM_DIR}/disk.qcow2" 20G + ssh-keygen -q -t ed25519 -N '' -C lss-vm-test -f "${VM_DIR}/key" + cat >"${VM_DIR}/user-data" <"${VM_DIR}/meta-data" + cloud-localds "${VM_DIR}/seed.iso" "${VM_DIR}/user-data" "${VM_DIR}/meta-data" + qemu-system-x86_64 -enable-kvm -cpu host -machine q35 -m 4096 -smp 2 \ + -drive "file=${VM_DIR}/disk.qcow2,if=virtio" -drive "file=${VM_DIR}/seed.iso,if=virtio,format=raw" \ + -nic "user,model=virtio-net-pci,hostfwd=tcp:127.0.0.1:${VM_PORT}-:22" \ + -device qemu-xhci -device usb-kbd \ + -display none -serial "file:${VM_DIR}/console.log" -daemonize -pidfile "${VM_DIR}/qemu.pid" + for ((i = 0; i < 60; i++)); do + vm_ssh true 2>/dev/null && break + sleep 5 + done + vm_ssh true || { + tail -n 60 "${VM_DIR}/console.log" >&2 + exit 1 + } + vm_ssh 'sudo cloud-init status --wait >/dev/null 2>&1 || true' + tar --exclude=.git -cf - . | vm_ssh 'sudo rm -rf /opt/lss-src && sudo mkdir -p /opt/lss-src && sudo tar -xf - -C /opt/lss-src' + return +} + +# vm_run SCRIPT: runs SCRIPT as root in the VM; standard input is passed on. +vm_run() { + local script=$1 + # The RHEL family's sudo path has no /usr/local/sbin, where the harness links lss. + vm_ssh "sudo --preserve-env=SSH_CONNECTION bash -c $(printf '%q' "PATH=/usr/local/sbin:\${PATH}; ${script}")" + return +} + +vm_stop() { + [[ -n ${VM_DIR} && -f ${VM_DIR}/qemu.pid ]] || return 0 + kill "$(<"${VM_DIR}/qemu.pid")" 2>/dev/null || true + return +} diff --git a/tests/unit/guard.bats b/tests/unit/guard.bats new file mode 100644 index 0000000..77e4208 --- /dev/null +++ b/tests/unit/guard.bats @@ -0,0 +1,124 @@ +#!/usr/bin/env bats +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# The daily guard script (libexec/lss-guard): its checks against stub functions for systemctl, journalctl and the +# security tools. + +setup() { + load helpers + lss_test_setup + export LSS_GUARD_CONF=${BATS_TEST_TMPDIR}/guard.conf + : >"${LSS_GUARD_CONF}" + # shellcheck source=/dev/null + source "${REPO_ROOT}/libexec/lss-guard" + FINDINGS=() + NOTES=() + SCAN_UNIT='' + SCAN_STATUS=0 + SCAN_EXITED=$(date -d '-1 hour' '+%a %Y-%m-%d %H:%M:%S %Z') + SCAN_OUTPUT='' +} + +# One scanner unit (SCAN_UNIT) that ran at SCAN_EXITED with SCAN_STATUS; every other unit does not exist. +systemctl() { + local unit=${*: -1} + [[ ${unit} == "${SCAN_UNIT}.service" ]] || return 1 + case $* in + cat\ *) return 0 ;; + *ExecMainExitTimestamp*) printf '%s\n' "${SCAN_EXITED}" ;; + *ExecMainStatus*) printf '%s\n' "${SCAN_STATUS}" ;; + *InvocationID*) printf 'abc123\n' ;; + *) return 1 ;; + esac +} + +# shellcheck disable=SC2329 # called by the guard functions under test +journalctl() { + printf '%s\n' "${SCAN_OUTPUT}" +} + +@test "AIDE changes reach the summary with the numbers from the report" { + SCAN_UNIT=lss-aide-check + SCAN_STATUS=5 + SCAN_OUTPUT=$' Total number of entries:\t4211\n Added entries:\t\t1\n Removed entries:\t\t0\n Changed entries:\t\t2' + check_scanners + [ "${#FINDINGS[@]}" -eq 1 ] + [[ ${FINDINGS[0]} == "AIDE found changed files on "*"(1 added, 0 removed, 2 changed): review them with journalctl -u lss-aide-check, then accept them by rebuilding the database (aideinit --yes --force on Debian and Ubuntu, aide --init and moving aide.db.new.gz to aide.db.gz in /var/lib/aide on the RHEL family)." ]] +} + +@test "ClamAV names the infected files" { + SCAN_UNIT=lss-clamscan + SCAN_STATUS=1 + SCAN_OUTPUT=$'/home/alice/eicar.com: Win.Test.EICAR_HDB-1 FOUND\n/tmp/x.pdf: Pdf.Exploit FOUND' + check_scanners + [[ ${FINDINGS[0]} == "ClamAV found 2 infected file(s) on "*": /home/alice/eicar.com, /tmp/x.pdf (journalctl -u lss-clamscan)." ]] +} + +@test "rkhunter warnings are counted and the first one is quoted" { + SCAN_UNIT=lss-rkhunter + SCAN_STATUS=1 + SCAN_OUTPUT=$'Warning: The command \'/usr/bin/lwp-request\' has been replaced by a script\nWarning: Hidden file found' + check_scanners + [[ ${FINDINGS[0]} == "rkhunter reported 2 warning(s) on "*", the first: The command '/usr/bin/lwp-request' has been replaced by a script (journalctl -u lss-rkhunter)." ]] +} + +@test "a clean scan reports nothing and a scan that stopped running is a finding" { + SCAN_UNIT=lss-clamscan + check_scanners + [ "${#FINDINGS[@]}" -eq 0 ] + SCAN_EXITED=$(date -d '-3 days' '+%a %Y-%m-%d %H:%M:%S %Z') + check_scanners + [[ ${FINDINGS[0]} == "The daily ClamAV check has not run since "*"(systemctl status lss-clamscan.timer)." ]] +} + +@test "failed SSH logins above the threshold name the busiest sources; logins are a note" { + printf 'GUARD_LOGIN_THRESHOLD=2\n' >"${LSS_GUARD_CONF}" + SCAN_OUTPUT=$'Failed password for root from 203.0.113.9 port 4711 ssh2\nInvalid user admin from 203.0.113.9 port 4712\nFailed publickey for bob from 198.51.100.4 port 22 ssh2\nAccepted publickey for alice from 192.0.2.10 port 50000 ssh2: ED25519 SHA256:x' + check_logins + [ "${FINDINGS[0]}" = "3 failed SSH logins in the last 24 hours, most from 203.0.113.9 (2), 198.51.100.4 (1)." ] + [ "${NOTES[0]}" = "SSH logins in the last 24 hours: alice from 192.0.2.10 (1)." ] +} + +@test "failed logins at or below the threshold are no finding" { + SCAN_OUTPUT='Failed password for root from 203.0.113.9 port 4711 ssh2' + check_logins + [ "${#FINDINGS[@]}" -eq 0 ] +} + +@test "active fail2ban bans are a note, not a finding" { + fail2ban-client() { printf "[{'sshd': ['192.0.2.1', '2001:db8::7']}, {'recidive': []}]\n"; } + check_bans + [ "${#FINDINGS[@]}" -eq 0 ] + [ "${NOTES[0]}" = "fail2ban currently bans 2 address(es)." ] +} + +@test "debsecan: fixable high-urgency vulnerabilities are a finding, unfixed ones a note" { + export LSS_OS_RELEASE=${REPO_ROOT}/tests/fixtures/os-release/debian-13 + OS_RELEASE=${LSS_OS_RELEASE} + debsecan() { + printf '%s\n' "$*" >>"${STUB_LOG}" + if [[ $* == *--only-fixed* ]]; then + printf 'CVE-2026-1 openssl (fixed, remotely exploitable, high urgency)\n' + else + printf 'CVE-2026-1 openssl (fixed, remotely exploitable, high urgency)\nCVE-2026-2 curl (remotely exploitable, high urgency)\nCVE-2026-3 vim (low urgency)\n' + fi + } + check_vulnerabilities + grep -q -- '--suite trixie' "${STUB_LOG}" + [ "${FINDINGS[0]}" = "1 high-urgency vulnerabilities in installed packages have a fix: install the security updates." ] + [ "${NOTES[0]}" = "1 high-urgency vulnerabilities in installed packages have no fix yet (debsecan)." ] +} + +@test "text that users control cannot add lines to the summary" { + finding $'/tmp/evil\n.\n- All checks passed' + [ "${FINDINGS[0]}" = '/tmp/evil?.?- All checks passed' ] +} + +@test "logins are read from the sshd processes recorded by journald, not from a forgeable syslog identifier" { + journalctl() { printf '%s\n' "$*" >>"${STUB_LOG}"; } + check_logins + grep -q -- '_COMM=sshd _COMM=sshd-session _UID=0' "${STUB_LOG}" + run ! grep -q -- '-t sshd' "${STUB_LOG}" +} diff --git a/tests/unit/helpers.bash b/tests/unit/helpers.bash index fc63d35..607c143 100644 --- a/tests/unit/helpers.bash +++ b/tests/unit/helpers.bash @@ -17,6 +17,8 @@ lss_test_setup() { export LSS_COLOR=never export LSS_VERSION=0.0.0-test export STUB_LOG=${BATS_TEST_TMPDIR}/stub.log + # Systems without SELinux unless a test switches it on. + export STUB_EXIT_selinuxenabled=1 export PATH="${REPO_ROOT}/tests/fixtures/bin:${PATH}" OWNER="$(id -un):$(id -gn)" export OWNER diff --git a/tests/unit/system.bats b/tests/unit/system.bats index 940ea2f..7bd7d52 100644 --- a/tests/unit/system.bats +++ b/tests/unit/system.bats @@ -551,6 +551,28 @@ EOF run ! grep -q 'daemon-reload' "${STUB_LOG}" } +@test "svc_scanner_sandbox confines writes but keeps /tmp and the kernel views of the scanners" { + run svc_scanner_sandbox strict /var/lib/aide /var/log + [ "${status}" -eq 0 ] + [ "${lines[0]}" = ProtectSystem=strict ] + grep -qxF 'ProtectHome=read-only' <<<"${output}" + grep -qxF 'NoNewPrivileges=yes' <<<"${output}" + grep -qxF 'ReadWritePaths=-/var/lib/aide' <<<"${output}" + grep -qxF 'ReadWritePaths=-/var/log' <<<"${output}" + run ! grep -qE '^(PrivateTmp|ProtectKernelTunables|ProtectKernelModules|ProtectKernelLogs)=' <<<"${output}" + run svc_scanner_sandbox full + [ "${lines[0]}" = ProtectSystem=full ] + run ! grep -q ReadWritePaths <<<"${output}" +} + +@test "svc_scanner_sandbox lets scanners enter their SELinux domain" { + export STUB_EXIT_selinuxenabled=0 + run svc_scanner_sandbox strict /var/lib/aide + [ "${status}" -eq 0 ] + grep -qxF 'NoNewPrivileges=no' <<<"${output}" + run ! grep -qxF 'NoNewPrivileges=yes' <<<"${output}" +} + @test "fw_ssh_port falls back to 22 when sshd fails" { STUB_EXIT_sshd=255 run fw_ssh_port [ "${status}" -eq 0 ]