From ee50904429f2dea0ff4fefec7e43d945aa08fe9c Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 18:40:07 +0200 Subject: [PATCH 01/17] Run the integration tests on arm64 and the profile on Ubuntu 26.04 Every container job also runs on the free arm64 runners, and the kernel modules on an arm64 VM. The minimal-secure profile runs on an Ubuntu 26.04 runner VM besides 24.04. The x86_64 job names stay, so the required checks keep matching. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- .github/workflows/integration.yml | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index fa8f5b5..7be9218 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: From cd988ada03edc3c124f7dfbe5c8bfe7156687b49 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 18:47:35 +0200 Subject: [PATCH 02/17] guard: report vulnerable packages, failed logins and what the scanners found AIDE, rkhunter and clamscan report findings through their exit status, which their units accept as success, so a changed file or an infected one only reached the journal. guard now reads the last run of each unit and reports its findings, and a scanner that stopped running. It also reports high-urgency vulnerabilities with a fix (debsecan on Debian, installed by the module) or critical and important advisories (dnf updateinfo), and failed SSH logins above LSS_GUARD_LOGIN_THRESHOLD with their main sources. Successful logins and active fail2ban or CrowdSec bans are notes in the summary. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- CHANGELOG.md | 3 + README.md | 7 +- docs/hardening.md | 2 +- libexec/lss-guard | 147 ++++++++++++++++++++++++++++++++++++-- modules/security/guard.sh | 15 +++- tests/unit/guard.bats | 111 ++++++++++++++++++++++++++++ 6 files changed, 277 insertions(+), 8 deletions(-) create mode 100644 tests/unit/guard.bats diff --git a/CHANGELOG.md b/CHANGELOG.md index 5513e82..2b99c44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ 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. - 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..74c520b 100644 --- a/README.md +++ b/README.md @@ -139,8 +139,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/hardening.md b/docs/hardening.md index ca55efa..d794dbb 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -29,7 +29,7 @@ virtual machine it reaches a Lynis hardening index of 75; CI checks that it stay | `usbguard` (opt-in)| Only the USB devices present at installation are allowed | Removable media | | `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/libexec/lss-guard b/libexec/lss-guard index 2ef0c55..d04acb5 100755 --- a/libexec/lss-guard +++ b/libexec/lss-guard @@ -7,15 +7,19 @@ # 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} +declare -a FINDINGS=() FIXED=() NOTES=() setting() { local key=$1 default=${2-} line @@ -36,6 +40,12 @@ finding() { return } +note() { + NOTES+=("$1") + logger -t lss-guard -p auth.info -- "$1" + return +} + check_modules() { local output name failed=() drifted=() output=$("${LSS}" --no-color verify 2>&1) || true @@ -92,6 +102,124 @@ 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) + log=$(journalctl --since -24h --no-pager -o cat -t sshd -t sshd-session 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 + [[ ${status} =~ ^[0-9]+$ ]] && ((status > 0)) || continue + 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) @@ -144,13 +272,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 +293,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/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" <"${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 +} + +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)." ] +} From 9ca6c475c02a74cf04bb7df1aac49a17bb87e72d Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 18:50:47 +0200 Subject: [PATCH 03/17] guard: trust only journald fields for logins and clean quoted text A syslog identifier can be set by any local user (logger -t sshd), so the login check reads sshd lines by _COMM and _UID, which journald sets itself. Findings quote file names and log text that users control: control characters are replaced, so such text cannot add lines to the summary or forge journal entries, and sendmail runs with -i, so a lone dot cannot end the mail early. Found by a security review of the previous commit. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- libexec/lss-guard | 25 +++++++++++++++++++------ tests/unit/guard.bats | 12 ++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/libexec/lss-guard b/libexec/lss-guard index d04acb5..704ded1 100755 --- a/libexec/lss-guard +++ b/libexec/lss-guard @@ -34,15 +34,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() { - NOTES+=("$1") - logger -t lss-guard -p auth.info -- "$1" + local text + text=$(clean "$1") + NOTES+=("${text}") + logger -t lss-guard -p auth.info -- "${text}" return } @@ -107,7 +119,8 @@ check_updates() { check_logins() { local threshold log failed count sources accepted threshold=$(setting GUARD_LOGIN_THRESHOLD 50) - log=$(journalctl --since -24h --no-pager -o cat -t sshd -t sshd-session 2>/dev/null) || return 0 + # _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}") @@ -258,7 +271,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 } diff --git a/tests/unit/guard.bats b/tests/unit/guard.bats index 3691604..0d14554 100644 --- a/tests/unit/guard.bats +++ b/tests/unit/guard.bats @@ -109,3 +109,15 @@ journalctl() { [ "${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}" +} From 9a5b4c4dc9766408312a5d40cca4004d5a3e981b Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 18:51:52 +0200 Subject: [PATCH 04/17] Run the kernel modules and the profile in KVM VMs with Debian and the RHEL family The runner VM only covers Ubuntu. tests/integration/vm.sh boots the vendors' cloud images (Debian 12 and 13, Rocky Linux 9, AlmaLinux 10) with KVM, checked against the checksum file each vendor publishes, and reaches them over SSH with a fresh key for a test user in the administrator group, so the SSH hardening keeps it in. run.sh accepts vm-NAME as a system; the workflow runs the kernel modules and the minimal-secure profile with a Lynis audit on each. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- .github/workflows/integration.yml | 36 ++++++++++++ tests/integration/run.sh | 15 ++++- tests/integration/vm-images.conf | 12 ++++ tests/integration/vm.sh | 97 +++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 tests/integration/vm-images.conf create mode 100644 tests/integration/vm.sh diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 7be9218..3b35419 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -93,3 +93,39 @@ jobs: # of 75 here (October 2026); 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 + + 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: | + sudo apt-get -q update + sudo apt-get -q install -y --no-install-recommends qemu-system-x86 qemu-utils cloud-image-utils + 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. The minimum index is set low + # until the first measurements on these systems are in (stage 1b, issue #12). + - 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 + tests/integration/run.sh "$SYSTEM" --profile minimal-secure 60 + fi diff --git a/tests/integration/run.sh b/tests/integration/run.sh index e1cc04c..25a788e 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 @@ -90,7 +97,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}" 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..46a1867 --- /dev/null +++ b/tests/integration/vm.sh @@ -0,0 +1,97 @@ +# 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. + +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" \ + -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 + vm_ssh "sudo --preserve-env=SSH_CONNECTION bash -c $(printf '%q' "${script}")" + return +} + +vm_stop() { + [[ -n ${VM_DIR} && -f ${VM_DIR}/qemu.pid ]] || return 0 + kill "$(<"${VM_DIR}/qemu.pid")" 2>/dev/null || true + return +} From b7cfff38b849a7968ca6de0d21b54ed0e3d502bf Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 18:56:00 +0200 Subject: [PATCH 05/17] Harden further towards a Lynis index of 85 From the Lynis suggestions after minimal-secure: - ssh-hardening also writes the banner to /etc/issue and /etc/issue.net (BANN-7126/7130); the default text names enough legal terms for Lynis. - password-policy sets the hash cost (AUTH-9230) and installs libpam-tmpdir (DEB-0280). - auditd turns on process accounting (ACCT-9622). - aide installs debsums (PKGS-7370), tools apt-show-versions (PKGS-7394). The profile job prints the remaining Lynis details (sysctl, SSH options, file permissions), the input for the next steps. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- CHANGELOG.md | 2 ++ docs/hardening.md | 8 ++++---- modules/base/tools.sh | 2 +- modules/security/aide.sh | 2 +- modules/security/auditd.sh | 11 ++++++++++- modules/security/password-policy.sh | 5 +++-- modules/security/ssh-hardening.sh | 7 ++++++- tests/integration/run.sh | 4 ++++ 8 files changed, 31 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b99c44..314f3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ now or later. - 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 and apt-show-versions. - 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/docs/hardening.md b/docs/hardening.md index d794dbb..1ba7351 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -12,17 +12,17 @@ virtual machine it reaches a Lynis hardening index of 75; CI checks that it stay | 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 | +| `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 (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 | +| `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 | (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) | 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..ea4c6c8 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 } diff --git a/modules/security/auditd.sh b/modules/security/auditd.sh index 3174c8f..374577f 100644 --- a/modules/security/auditd.sh +++ b/modules/security/auditd.sh @@ -25,7 +25,13 @@ mod_check() { } mod_install() { - pkg_install_family "auditd audispd-plugins" "audit audit-libs" + pkg_install_family "auditd audispd-plugins acct" "audit audit-libs psacct" + 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 +123,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 @@ -134,12 +141,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/password-policy.sh b/modules/security/password-policy.sh index dbe0126..0269dac 100644 --- a/modules/security/password-policy.sh +++ b/modules/security/password-policy.sh @@ -27,7 +27,7 @@ mod_check() { } mod_install() { - pkg_install_family "libpam-pwquality libpam-modules" "libpwquality authselect" + pkg_install_family "libpam-pwquality libpam-modules libpam-tmpdir" "libpwquality authselect" return } @@ -119,7 +119,7 @@ EOF _pp_faillock_rhel fi _pp_login_defs PASS_MAX_DAYS "$(opt_int max_days)" PASS_MIN_DAYS 1 PASS_WARN_AGE 14 UMASK 027 \ - ENCRYPT_METHOD "$(_pp_hash)" + ENCRYPT_METHOD "$(_pp_hash)" SHA_CRYPT_MIN_ROUNDS 100000 SHA_CRYPT_MAX_ROUNDS 100000 YESCRYPT_COST_FACTOR 7 local timeout timeout=$(opt_int shell_timeout) file_write 0644 root:root "${PP_PROFILE}" < <( @@ -161,6 +161,7 @@ mod_verify() { } mod_remove() { + pkg_remove_family libpam-tmpdir '' file_remove "${PP_PWQUALITY}" file_remove "${PP_PROFILE}" if os_is_debian; then diff --git a/modules/security/ssh-hardening.sh b/modules/security/ssh-hardening.sh index d3198ff..8ed9128 100644 --- a/modules/security/ssh-hardening.sh +++ b/modules/security/ssh-hardening.sh @@ -11,7 +11,7 @@ allow_groups|ssh-users|Groups allowed to log in besides the administrators (sudo tcp_forwarding|local|Port forwarding: no, local (ssh -L, for admin interfaces bound to localhost) or yes max_sessions|4|Sessions per connection idle_timeout|900|Seconds without traffic before an idle session is closed -banner|Authorised access only. All activity is logged.|Text shown before login" +banner|Authorised access only. This system is private and monitored; all activity is logged, and unauthorised use is prohibited and subject to legal action.|Text shown before login (SSH, console and /etc/issue.net)" SSH_CONFIG=/etc/ssh/sshd_config.d/00-lss.conf SSH_BANNER=/etc/ssh/lss-banner @@ -174,6 +174,8 @@ mod_configure() { fi file_write 0644 root:root "${SSH_BANNER}" <<<"$(opt banner)" + file_write 0644 root:root /etc/issue <<<"$(opt banner)" + file_write 0644 root:root /etc/issue.net <<<"$(opt banner)" [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 file_write 0600 root:root "${SSH_CONFIG}" < <(_ssh_render) [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 @@ -216,6 +218,9 @@ mod_verify() { mod_remove() { file_remove "${SSH_CONFIG}" file_remove "${SSH_BANNER}" + # Back to a neutral console greeting (\S is the system name, \l the terminal). + file_write 0644 root:root /etc/issue <<<'\S \l' + file_write 0644 root:root /etc/issue.net <<<'\S' sshd -t || die "ssh-hardening: sshd configuration is invalid without the lss settings." svc_reload "$(_ssh_unit)" return diff --git a/tests/integration/run.sh b/tests/integration/run.sh index 25a788e..8931f58 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -150,6 +150,10 @@ 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 + # 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 From e93b22cfd0e4e0bb20985b8859e2e2157327a9aa Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 18:59:51 +0200 Subject: [PATCH 06/17] Fix the guard unit tests, two ShellCheck notes and the 26.04 runner label - lss-guard declares its arrays global: sourced inside the bats setup function, declare -a made them local, so they were unset in the test and set -u stopped the empty-array checks. - An explicit if instead of A && B || continue (SC2015). - The journalctl stub is called indirectly (SC2329). - actionlint does not know the ubuntu-26.04 runner label yet. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- .github/actionlint.yaml | 8 ++++++++ libexec/lss-guard | 7 +++++-- tests/unit/guard.bats | 1 + 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 .github/actionlint.yaml 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/libexec/lss-guard b/libexec/lss-guard index 704ded1..67b4cc2 100755 --- a/libexec/lss-guard +++ b/libexec/lss-guard @@ -19,7 +19,8 @@ CONF=${LSS_GUARD_CONF:-/etc/lss/guard.conf} LSS=${LSS_GUARD_LSS:-/usr/local/sbin/lss} STATE_DIR=${LSS_STATE_DIR:-/var/lib/lss} OS_RELEASE=${LSS_OS_RELEASE:-/etc/os-release} -declare -a FINDINGS=() FIXED=() NOTES=() +# 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 @@ -207,7 +208,9 @@ check_scanners() { if ((epoch < $(date +%s) - 2 * 86400)); then finding "The daily ${label} check has not run since ${when} (systemctl status ${unit}.timer)." fi - [[ ${status} =~ ^[0-9]+$ ]] && ((status > 0)) || continue + 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) diff --git a/tests/unit/guard.bats b/tests/unit/guard.bats index 0d14554..77e4208 100644 --- a/tests/unit/guard.bats +++ b/tests/unit/guard.bats @@ -34,6 +34,7 @@ systemctl() { esac } +# shellcheck disable=SC2329 # called by the guard functions under test journalctl() { printf '%s\n' "${SCAN_OUTPUT}" } From 71fc15540ce2df308e919722e88be7c72bae0e22 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 19:02:15 +0200 Subject: [PATCH 07/17] Support sudo-rs and the RHEL sudo path in admin-user Ubuntu 26.04 ships sudo-rs, whose visudo rejects Defaults logfile; the module now leaves it out there (sudo-rs logs to the journal). On the RHEL family sudo's secure_path lacks /usr/local/sbin, so sudo lss failed; the sudoers file adds it, the README says how to call lss before that, and the VM harness sets PATH itself. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- README.md | 3 ++- docs/hardening.md | 2 +- modules/base/admin-user.sh | 14 +++++++++++++- tests/integration/vm.sh | 3 ++- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 74c520b..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 diff --git a/docs/hardening.md b/docs/hardening.md index 1ba7351..cf85fc2 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -11,7 +11,7 @@ virtual machine it reaches a Lynis hardening index of 75; CI checks that it stay | 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 | +| `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, 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 | 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/tests/integration/vm.sh b/tests/integration/vm.sh index 46a1867..d0c412e 100644 --- a/tests/integration/vm.sh +++ b/tests/integration/vm.sh @@ -86,7 +86,8 @@ EOF # vm_run SCRIPT: runs SCRIPT as root in the VM; standard input is passed on. vm_run() { local script=$1 - vm_ssh "sudo --preserve-env=SSH_CONNECTION bash -c $(printf '%q' "${script}")" + # 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 } From adfe6794072b38f748aca62226c291b1fa14de21 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 19:16:46 +0200 Subject: [PATCH 08/17] Restrict cron and sshd_config permissions, raise the Lynis minimums Lynis details after minimal-secure named the cron files (SCHD-7704, CIS 5.1.2-5.1.7) and sshd_config (FILE-7524). The system module makes the crontab 600 and the cron directories 700, ssh-hardening makes sshd_config 600. Measured hardening index: Debian 12 87, Debian 13 85, Ubuntu runner 78 (the runner image brings Docker, PostgreSQL and its own sudoers file). CI now requires 82 on the Debian VMs and 75 on the runner. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- .github/workflows/integration.yml | 16 +++++++++++----- CHANGELOG.md | 3 ++- docs/assurance.md | 4 ++-- docs/hardening.md | 8 +++++--- modules/base/system.sh | 26 +++++++++++++++++++++++++- modules/security/ssh-hardening.sh | 6 ++++++ 6 files changed, 51 insertions(+), 12 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 3b35419..05a441f 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -90,9 +90,10 @@ 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 }} @@ -117,8 +118,9 @@ jobs: 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. The minimum index is set low - # until the first measurements on these systems are in (stage 1b, issue #12). + # 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 (October 2026); the RHEL family keeps a low minimum + # until its first measurements are in (stage 1b, issue #12). - name: Install, verify, reinstall, remove env: SYSTEM: vm-${{ matrix.system }} @@ -127,5 +129,9 @@ jobs: if [[ $RUN == kernel ]]; then tests/integration/run.sh "$SYSTEM" time swap firewall kernel-hardening mac auditd usbguard else - tests/integration/run.sh "$SYSTEM" --profile minimal-secure 60 + case $SYSTEM in + vm-debian-*) minimum=82 ;; + *) minimum=60 ;; + esac + tests/integration/run.sh "$SYSTEM" --profile minimal-secure "$minimum" fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 314f3cd..e34367c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,8 @@ now or later. 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 and apt-show-versions. + 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. - 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/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 cf85fc2..047d8e0 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -4,15 +4,16 @@ 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 (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, 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-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) | @@ -27,6 +28,7 @@ virtual machine it reaches a Lynis hardening index of 75; CI checks that it stay | `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, vulnerable packages, a needed reboot, a low Lynis index, AIDE/rkhunter/ClamAV findings and failed SSH logins; notes logins and bans | (operations) | 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/security/ssh-hardening.sh b/modules/security/ssh-hardening.sh index 8ed9128..f952f88 100644 --- a/modules/security/ssh-hardening.sh +++ b/modules/security/ssh-hardening.sh @@ -178,6 +178,11 @@ mod_configure() { file_write 0644 root:root /etc/issue.net <<<"$(opt banner)" [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 file_write 0600 root:root "${SSH_CONFIG}" < <(_ssh_render) + # The main file can hold host-specific settings; only root needs to read it (Lynis FILE-7524). + if [[ -f /etc/ssh/sshd_config && $(stat -c '%a' /etc/ssh/sshd_config) != 600 ]]; then + chmod 600 /etc/ssh/sshd_config + files_note_change "permissions of /etc/ssh/sshd_config" + fi [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 _ssh_moduli [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 @@ -220,6 +225,7 @@ mod_remove() { file_remove "${SSH_BANNER}" # Back to a neutral console greeting (\S is the system name, \l the terminal). file_write 0644 root:root /etc/issue <<<'\S \l' + [[ ! -f /etc/ssh/sshd_config ]] || chmod 644 /etc/ssh/sshd_config file_write 0644 root:root /etc/issue.net <<<'\S' sshd -t || die "ssh-hardening: sshd configuration is invalid without the lss settings." svc_reload "$(_ssh_unit)" From 8cfcb4fc152404fdd7aba599f7d4f2bdbd8f217c Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 19:20:30 +0200 Subject: [PATCH 09/17] Confine the scanner units with systemd AIDE, rkhunter, ClamAV and Lynis ran as root without any sandbox (Lynis BOOT-5264 rated their units unsafe). svc_scanner_sandbox gives them a read-only system and read-only home directories, no new privileges and write access only to their state and logs; AIDE and ClamAV get no network. The real /tmp, /proc/kallsyms, the kernel modules and the kernel log stay visible, because the scanners check them. The profile test starts every scanner unit once inside its sandbox and prints its systemd exposure score. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- CHANGELOG.md | 2 ++ docs/hardening.md | 2 +- lib/service.sh | 16 ++++++++++++++++ modules/security/aide.sh | 2 ++ modules/security/clamav.sh | 2 ++ modules/security/lynis.sh | 1 + modules/security/rkhunter.sh | 1 + tests/integration/run.sh | 12 ++++++++++++ tests/unit/system.bats | 14 ++++++++++++++ 9 files changed, 51 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e34367c..919f403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ now or later. - 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/docs/hardening.md b/docs/hardening.md index 047d8e0..0469456 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -24,7 +24,7 @@ Docker, databases and its own sudo rules) 78. CI checks that it stays at 82 on D | `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, 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 | (not in CIS) | +| `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 | diff --git a/lib/service.sh b/lib/service.sh index 3cc18ba..bc38714 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -105,6 +105,22 @@ 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 + shift + printf '%s\n' "ProtectSystem=${level}" ProtectHome=read-only NoNewPrivileges=yes 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/modules/security/aide.sh b/modules/security/aide.sh index ea4c6c8..844328c 100644 --- a/modules/security/aide.sh +++ b/modules/security/aide.sh @@ -98,6 +98,8 @@ Type=oneshot Nice=19 IOSchedulingClass=idle ExecStart=$(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" </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 diff --git a/tests/unit/system.bats b/tests/unit/system.bats index 940ea2f..e285379 100644 --- a/tests/unit/system.bats +++ b/tests/unit/system.bats @@ -551,6 +551,20 @@ 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 "fw_ssh_port falls back to 22 when sshd fails" { STUB_EXIT_sshd=255 run fw_ssh_port [ "${status}" -eq 0 ] From c9fc6ccbb9e90f4cfc7f31b372865fad33d4c86c Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 19:24:25 +0200 Subject: [PATCH 10/17] usbguard: work on systems without a USB bus; give the test VMs one The Debian cloud VM has no USB controller, so usbguard-daemon exited at once (no /sys/bus/usb/devices) and systemd restarted it until it gave up. The module now loads usbcore when it is missing; where the kernel still shows no USB bus, a drop-in makes systemd start usbguard on the first boot that has one, and the empty policy blocks every device until it is allowed. verify checks the matching state. The test VMs get an xHCI controller with a keyboard, so usbguard is also tested with a real device. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/security/usbguard.sh | 34 +++++++++++++++++++++++++++++----- tests/integration/vm.sh | 3 ++- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/modules/security/usbguard.sh b/modules/security/usbguard.sh index 8262dab..a94c567 100644 --- a/modules/security/usbguard.sh +++ b/modules/security/usbguard.sh @@ -8,6 +8,14 @@ MOD_DESCRIPTION="USBGuard: only USB devices present at installation are allowed, MOD_REQUIRES="system" USBGUARD_RULES=/etc/usbguard/rules.conf +USBGUARD_BUS=/sys/bus/usb/devices + +# The USB bus exists once usbcore is loaded; kernels built for clouds load it only when a controller shows up. +_usbguard_bus() { + [[ -d ${USBGUARD_BUS} ]] || modprobe -q usbcore 2>/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/integration/vm.sh b/tests/integration/vm.sh index d0c412e..638a3c5 100644 --- a/tests/integration/vm.sh +++ b/tests/integration/vm.sh @@ -5,7 +5,7 @@ # 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. +# 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} @@ -69,6 +69,7 @@ EOF 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 From 11d4e5e93feae1f829acd7c883b8bb412ea4f3fe Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 19:32:04 +0200 Subject: [PATCH 11/17] Quiet the workflow steps that printed Not and No lines The apt-get installs of QEMU and shfmt printed the man-db trigger note (Not building database ...) and the needrestart summary (No services need to be restarted ...); zizmor printed No findings to report. apt now runs with -qq, NEEDRESTART_SUSPEND and stdout discarded (errors stay in the log), and zizmor reports findings as annotations only. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- .github/workflows/ci.yml | 5 +++-- .github/workflows/integration.yml | 5 +++-- .github/workflows/meta.yml | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) 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 05a441f..7a08473 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -113,8 +113,9 @@ jobs: persist-credentials: false - name: Install QEMU and allow access to KVM run: | - sudo apt-get -q update - sudo apt-get -q install -y --no-install-recommends qemu-system-x86 qemu-utils cloud-image-utils + # 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 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 From a7bac7df28559857007e300e191369b00cde76a3 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 20:20:19 +0200 Subject: [PATCH 12/17] Fix swap on Rocky 9 and the firewall on AlmaLinux 10 The first runs in Rocky Linux and AlmaLinux VMs found two RHEL issues: - mkswap --quiet needs util-linux 2.38; Rocky 9 has 2.37. mkswap reports on stdout, which is discarded anyway. - firewall-offline-cmd reads --remove-service as a lokkit option and refuses it together with --zone; offline it is --remove-service-from-zone. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/base/swap.sh | 3 ++- modules/security/firewall.sh | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) 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/security/firewall.sh b/modules/security/firewall.sh index 843781f..9e2551a 100644 --- a/modules/security/firewall.sh +++ b/modules/security/firewall.sh @@ -61,7 +61,12 @@ _fw_defaults_firewalld() { fi for service in cockpit dhcpv6-client ssh; do if _fw_firewalld --zone=public --query-service="${service}" >/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 From 4f5f11ff5fb535d5ac628f611151d63514ca8016 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:07:38 +0200 Subject: [PATCH 13/17] Read the firewalld zone target from the permanent configuration firewall-cmd --get-target only works with --permanent, so verify got an empty answer and reported an open zone on Rocky and AlmaLinux. The resource figures now drop the page cache before measuring and report zero instead of a negative difference on shared runners. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/security/firewall.sh | 2 +- tests/integration/run.sh | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/modules/security/firewall.sh b/modules/security/firewall.sh index 9e2551a..0b276bc 100644 --- a/modules/security/firewall.sh +++ b/modules/security/firewall.sh @@ -126,7 +126,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/tests/integration/run.sh b/tests/integration/run.sh index 13e1662..216dd2a 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -107,7 +107,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 } @@ -128,7 +130,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 } From 37f8a8f27a3ceee020b0a4aed32203e75528dc05 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:53:07 +0200 Subject: [PATCH 14/17] Install augenrules on RHEL 10 and show what a second install changed RHEL 10 ships augenrules in the separate audit-rules package, so the auditd module failed on AlmaLinux 10. When a second install is not idempotent, the integration test now prints the change notes and the difference of every rewritten file, to find why AIDE and rkhunter rebuilt their databases on Rocky 9. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/security/auditd.sh | 7 ++++++- tests/integration/run.sh | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/modules/security/auditd.sh b/modules/security/auditd.sh index 374577f..1abb918 100644 --- a/modules/security/auditd.sh +++ b/modules/security/auditd.sh @@ -25,7 +25,12 @@ mod_check() { } mod_install() { - pkg_install_family "auditd audispd-plugins acct" "audit audit-libs psacct" + local rpm="audit audit-libs psacct" + # From RHEL 10 on, augenrules and the sample rules are in their own package. + if os_is_rhel && ((OS_MAJOR >= 10)); then + rpm+=" audit-rules" + fi + pkg_install_family "auditd audispd-plugins acct" "${rpm}" return } diff --git a/tests/integration/run.sh b/tests/integration/run.sh index 216dd2a..b493c69 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -85,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 From ef4d971b44cb58e76afbcc529b96430b473c60a0 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 22:44:23 +0200 Subject: [PATCH 15/17] fix(security): stable unit paths on RHEL, quiet augenrules and firewalld start On RHEL /sbin and /bin are symlinks, so command -v returned different paths depending on PATH and a second run rewrote the aide and rkhunter units. augenrules output and firewalld's dbus notice now go to the log only. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE (cherry picked from commit 73bb165d8f14ff0740516918e7b9853297683fd8) --- modules/security/aide.sh | 2 +- modules/security/auditd.sh | 4 ++-- modules/security/clamav.sh | 2 +- modules/security/firewall.sh | 7 ++++++- modules/security/rkhunter.sh | 2 +- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/modules/security/aide.sh b/modules/security/aide.sh index 844328c..8acf958 100644 --- a/modules/security/aide.sh +++ b/modules/security/aide.sh @@ -97,7 +97,7 @@ 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 diff --git a/modules/security/auditd.sh b/modules/security/auditd.sh index 1abb918..ca41e9f 100644 --- a/modules/security/auditd.sh +++ b/modules/security/auditd.sh @@ -137,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 diff --git a/modules/security/clamav.sh b/modules/security/clamav.sh index 6c173a4..da72efb 100644 --- a/modules/security/clamav.sh +++ b/modules/security/clamav.sh @@ -66,7 +66,7 @@ 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) diff --git a/modules/security/firewall.sh b/modules/security/firewall.sh index 0b276bc..ecf29ce 100644 --- a/modules/security/firewall.sh +++ b/modules/security/firewall.sh @@ -74,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" @@ -96,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." diff --git a/modules/security/rkhunter.sh b/modules/security/rkhunter.sh index 711ebbd..b2a9b53 100644 --- a/modules/security/rkhunter.sh +++ b/modules/security/rkhunter.sh @@ -73,7 +73,7 @@ Description=Rootkit Hunter scan (lss) Type=oneshot Nice=19 IOSchedulingClass=idle -ExecStart=$(command -v rkhunter) --check --skip-keypress --report-warnings-only --nocolors +ExecStart=$(readlink -f "$(command -v rkhunter)") --check --skip-keypress --report-warnings-only --nocolors $(svc_scanner_sandbox strict /var/lib/rkhunter /var/log /tmp /var/tmp) SuccessExitStatus=1 EOF From d15f9a684a4bcf1936056743b15f9efa30eed267 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 00:33:57 +0200 Subject: [PATCH 16/17] fix(service): let scanners enter their SELinux domain NoNewPrivileges blocks the transition from init_t into aide_t, so the AIDE check on RHEL ran as init_t and could not write /var/log/aide/aide.log. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE (cherry picked from commit d18570163eb95fe0217028d7abb8187aea4ac0b9) --- lib/service.sh | 9 +++++++-- tests/fixtures/bin/selinuxenabled | 8 ++++++++ tests/unit/helpers.bash | 2 ++ tests/unit/system.bats | 8 ++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) create mode 100755 tests/fixtures/bin/selinuxenabled diff --git a/lib/service.sh b/lib/service.sh index bc38714..8108282 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -110,9 +110,14 @@ svc_wait_port() { # 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 + local level=$1 path nnp=yes shift - printf '%s\n' "ProtectSystem=${level}" ProtectHome=read-only NoNewPrivileges=yes ProtectControlGroups=yes \ + # 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 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/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 e285379..7bd7d52 100644 --- a/tests/unit/system.bats +++ b/tests/unit/system.bats @@ -565,6 +565,14 @@ EOF 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 ] From a514d25cf20208eb55fb8b31bdd1fa1274aeee9b Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 01:56:19 +0200 Subject: [PATCH 17/17] ci(integration): Lynis minimum 80 for the RHEL family Rocky Linux 9 reached 85 and AlmaLinux 10 84 after minimal-secure. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE (cherry picked from commit 1c6700d1bd6a164847dedc215146a2477ab09f7d) --- .github/workflows/integration.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 7a08473..5fb1b63 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -120,8 +120,8 @@ jobs: 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 (October 2026); the RHEL family keeps a low minimum - # until its first measurements are in (stage 1b, issue #12). + # 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 }} @@ -132,7 +132,7 @@ jobs: else case $SYSTEM in vm-debian-*) minimum=82 ;; - *) minimum=60 ;; + *) minimum=80 ;; esac tests/integration/run.sh "$SYSTEM" --profile minimal-secure "$minimum" fi