Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0eca10b
feat(container): container library, container firewall and trusted ne…
VectoDE Oct 13, 2026
e7d50eb
feat(container): docker and podman modules
VectoDE Oct 13, 2026
3e21b71
feat(container): k3s and portainer modules
VectoDE Oct 13, 2026
10fc3b4
fix(container): fail closed on host paths and symlinks
VectoDE Oct 13, 2026
65ba9e0
fix(firewall): bind trusted networks to their interface and reject br…
VectoDE Oct 13, 2026
461c9d4
feat(guard): daily Trivy scan of the container images
VectoDE Oct 13, 2026
77e0bf8
test(container): container firewall, engines, Compose, Trivy and modu…
VectoDE Oct 13, 2026
46bfa8a
feat(profiles): k8s-node profile; docker and portainer in all
VectoDE Oct 13, 2026
a575151
ci(integration): podman in containers, docker, portainer and k3s in VMs
VectoDE Oct 13, 2026
2e3ebe8
docs: container modules, container firewall and k8s-node profile
VectoDE Oct 13, 2026
f723c3d
style: ShellCheck and shfmt findings in the guard scan and trust tests
VectoDE Oct 13, 2026
6d66c98
Merge feat/lss-2 into feat/lss-3
VectoDE Oct 14, 2026
07101de
test(integration): show containers, their logs and listening ports wh…
VectoDE Oct 14, 2026
c2d0ff1
fix(container): give the volumes of the socket container to root
VectoDE Oct 14, 2026
bb2979b
Merge feat/lss-2 into feat/lss-3
VectoDE Oct 14, 2026
4a4c4a4
fix(container): check dockerd's configuration for TCP instead of its …
VectoDE Oct 14, 2026
057dba6
Merge feat/lss-2 into feat/lss-3
VectoDE Oct 14, 2026
bbb88c8
fix(docker): use the nftables backend where the kernel lacks xt_addrtype
VectoDE Oct 14, 2026
0e96548
Merge feat/lss-2 into feat/lss-3
VectoDE Oct 14, 2026
20ff382
fix(docker): drop other forwarding where lss turns it on for Docker
VectoDE Oct 14, 2026
bf1fb2c
fix(docker): load br_netfilter where dockerd uses nftables
VectoDE Oct 14, 2026
f7b8bc0
Merge feat/lss-2 into feat/lss-3
VectoDE Oct 14, 2026
6e189a3
fix(docker): install the netfilter modules instead of switching to nf…
VectoDE Oct 14, 2026
849c248
Merge remote-tracking branch 'origin/feat/lss-2' into feat/lss-3
VectoDE Oct 14, 2026
2f46779
fix(docker): install kernel-modules-extra for the running kernel on EL10
VectoDE Oct 15, 2026
0121191
Merge remote-tracking branch 'origin/feat/lss-2' into feat/lss-3
VectoDE Oct 15, 2026
1e8fbe9
Merge remote-tracking branch 'origin/feat/lss-2' into feat/lss-3
VectoDE Oct 15, 2026
1f3af33
fix(k3s): wait for the secrets encryption status
VectoDE Oct 15, 2026
3004f59
Merge remote-tracking branch 'origin/feat/lss-2' into feat/lss-3
VectoDE Oct 15, 2026
180b53a
Merge remote-tracking branch 'origin/feat/lss-2' into feat/lss-3
VectoDE Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
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, web, runtime, database, database-alt]
group: [base, security, scanners, web, runtime, database, database-alt, container]
arch: [x64, arm64]
include:
- group: base
Expand All @@ -50,6 +50,10 @@ jobs:
modules: mariadb postgresql redis nginx,php,mariadb,phpmyadmin caddy,php,adminer apache,postgresql,pgadmin
- group: database-alt
modules: mysql valkey mongodb
# Podman runs inside a test container (its storage on a volume, not on overlayfs). Docker, Portainer and
# k3s need a kernel of their own (bridges, iptables, cgroups) and run in the VM jobs below.
- group: container
modules: podman
steps:
- uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
Expand Down Expand Up @@ -115,11 +119,14 @@ jobs:
system: [debian-12, debian-13, rocky-9, almalinux-10]
run: [kernel, profile minimal-secure]
# Every module at once (profiles/all.conf), on a clean system without the runner's preinstalled databases: on
# Debian 13 for every change, on every distribution in the nightly and the manual runs.
# Debian 13 for every change, on every distribution in the nightly and the manual runs. Docker with Portainer,
# then k3s (after Docker is removed; they conflict) on every supported system, in a clean VM because the runner
# already has Docker; minimal-secure on the newest Ubuntu release; the Kubernetes node profile with a Lynis audit.
include: >-
${{ fromJSON((github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
&& '[{"system":"debian-12","run":"profile all"},{"system":"debian-13","run":"profile all"},{"system":"rocky-9","run":"profile all"},{"system":"almalinux-10","run":"profile all"},{"system":"ubuntu-22.04","run":"profile all"},{"system":"ubuntu-24.04","run":"profile all"},{"system":"ubuntu-26.04","run":"profile all"}]'
|| '[{"system":"debian-13","run":"profile all"}]') }}
${{ fromJSON(format('[{0},{1}]', '{"system":"debian-12","run":"containers"},{"system":"debian-13","run":"containers"},{"system":"ubuntu-22.04","run":"containers"},{"system":"ubuntu-24.04","run":"containers"},{"system":"ubuntu-26.04","run":"containers"},{"system":"rocky-9","run":"containers"},{"system":"almalinux-10","run":"containers"},{"system":"ubuntu-26.04","run":"profile minimal-secure"},{"system":"debian-13","run":"profile k8s-node"},{"system":"almalinux-10","run":"profile k8s-node"}',
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
&& '{"system":"debian-12","run":"profile all"},{"system":"debian-13","run":"profile all"},{"system":"rocky-9","run":"profile all"},{"system":"almalinux-10","run":"profile all"},{"system":"ubuntu-22.04","run":"profile all"},{"system":"ubuntu-24.04","run":"profile all"},{"system":"ubuntu-26.04","run":"profile all"}'
|| '{"system":"debian-13","run":"profile all"}')) }}
steps:
- uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
Expand All @@ -137,20 +144,27 @@ jobs:
sudo udevadm trigger --name-match=kvm
# Kernel modules as on the runner VM, then the whole profile with a Lynis audit. minimal-secure reached a
# hardening index of 87 on Debian 12 and 85 on Debian 13, 85 on Rocky Linux 9 and 84 on AlmaLinux 10 (October
# 2026). The minimums leave room for small changes between Lynis versions.
# 2026). The minimums leave room for small changes between Lynis versions. Docker and k3s cost points (IP
# forwarding, Lynis' container checks); the minimums of Ubuntu and of the profiles all and k8s-node are estimates
# until the first runs.
- 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
elif [[ $RUN == containers ]]; then
tests/integration/run.sh "$SYSTEM" firewall,docker,portainer firewall,k3s
else
case $SYSTEM in
vm-debian-*) minimum=82 ;;
case $SYSTEM:$RUN in
vm-debian-*:"profile minimal-secure") minimum=82 ;;
vm-rocky-*:"profile minimal-secure" | vm-almalinux-*:"profile minimal-secure") minimum=80 ;;
*:"profile minimal-secure") minimum=75 ;;
vm-rocky-*:"profile all" | vm-almalinux-*:"profile all") minimum=80 ;;
# The same minimum as the host job on the Ubuntu runners.
vm-ubuntu-*) minimum=75 ;;
*) minimum=80 ;;
vm-debian-* | vm-ubuntu-*) minimum=75 ;;
*) minimum=60 ;;
esac
tests/integration/run.sh "$SYSTEM" --profile "${RUN#profile }" "$minimum"
fi
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ now or later.
- Profiles `webhosting` and `all`.
- Database tools: `phpmyadmin`, `adminer` and `pgadmin` on the admin port of the web server, from checksum-verified
downloads, limited to the local database servers; pgAdmin runs as its own sandboxed service.
- Container modules: `docker` (Docker CE from Docker's repository with a hardened daemon after CIS Docker), `podman`
(from the distribution), `portainer` (pinned image, on 127.0.0.1 or the WireGuard network, the only container with
the engine socket) and `k3s` (pinned, checksum-verified single-server Kubernetes after the k3s CIS hardening guide,
with k3s-selinux on the RHEL family).
- Container firewall: published container ports pass only where a module opened the port in the lss firewall, on ufw
and firewalld alike; `fw_trust` binds trusted networks to their interface.
- `lib/container.sh`: containers of lss run with a digest-pinned image, no new privileges, no capabilities, a
read-only root, memory and process limits and never the engine socket; host paths are checked after following
symbolic links. Compose files are checked against the same rules.
- guard scans the images of Docker and Podman daily with Trivy and reports HIGH and CRITICAL vulnerabilities.
- Profile `k8s-node` (minimal-secure plus k3s); `all` includes docker and portainer.
- 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.
Expand Down
52 changes: 50 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ up). Peak RAM covers install and `lss verify`; "while running" is what stays res
| `lynis` | 25 s | 80 MiB | 5 MiB | weekly audit |
| `usbguard` | 10 s | 90 MiB | 10 MiB | usbguard-daemon |
| `guard` | 5 s | 90 MiB | – | daily check |
| `docker` | not measured yet | not measured yet | not measured yet | dockerd and containerd |
| `podman` | not measured yet | not measured yet | not measured yet | – (no daemon) |
| `portainer` | not measured yet | not measured yet | not measured yet | Portainer container, up to 512 MiB |
| `k3s` | not measured yet | not measured yet | not measured yet | k3s server, about 1 GiB with Traefik |

Web, runtime and database modules, measured the same way. Modules that only work together are tested together; their
row is the cost of the whole set, including the web server or PHP it needs.
Expand Down Expand Up @@ -152,6 +156,10 @@ LSS_FIREWALL_ALLOW=8443/tcp 51820/udp
LSS_GUARD_WEBHOOK=https://ntfy.sh/your-topic
LSS_TLS_DOMAINS=server.example.com
LSS_VHOST_SITES=shop:php:shop.example.com,www.shop.example.com api:proxy:api.example.com:http://127.0.0.1:3000
LSS_DOCKER_USERNS_REMAP=true
LSS_PORTAINER_ACCESS=vpn
LSS_K3S_API_ACCESS=vpn
LSS_K3S_TRAEFIK=false
```

`--set` wins over the environment, which wins over the file, which wins over the profile, which wins over the
Expand Down Expand Up @@ -182,8 +190,11 @@ with hardened authentication and optional databases and users (`LSS_<ENGINE>_USE
**Database tools:** `phpmyadmin`, `adminer` (only the local database servers) and `pgadmin` (its own service; the
first password with `lss secrets show pgadmin-admin-password`), all on the admin port.

**Containers:** `docker` or `podman`, `portainer` (needs one of them) and `k3s` (single-server Kubernetes; not
together with `docker`). See [Containers](#containers).

The [hardening guide](docs/hardening.md) lists what each security module changes, the CIS Benchmark areas it covers
and where lss deliberately deviates. Containers, backup, monitoring and mail follow as further module groups.
and where lss deliberately deviates. Backup, monitoring and mail follow as further module groups.

### Web sites

Expand All @@ -199,11 +210,48 @@ Admin interfaces such as phpMyAdmin listen on `127.0.0.1:8090`. Reach them throu
`LSS_<SERVER>_ADMIN_PUBLIC=true` to publish port 8090 with TLS and the password from
`lss secrets show web-admin-password`.

### Containers

`docker` installs Docker CE from Docker's repository (Debian 12 and 13, Ubuntu 22.04, 24.04 and 26.04, the RHEL 9 and
10 family; `docker.io` from the distribution where Docker has no packages), `podman` the distribution's Podman. Both
run with limits for processes, logs and open files and the default seccomp profile; Docker also with `icc` off,
`live-restore`, `no-new-privileges` and a user namespace for containers (`LSS_DOCKER_USERNS_REMAP`).

A published port (`docker run -p 8080:80`) bypasses ufw and firewalld: the kernel redirects it to the container before
their rules apply. lss therefore adds a container firewall (`lss-container-firewall.service`, an nftables table of its
own) that lets a published port through only when a module opened it in the lss firewall, with the same scope. To
publish a port of your own container, bind it to 127.0.0.1 and put a site of the web server in front of it, or open
it with `LSS_FIREWALL_ALLOW=8080/tcp`. Without a host address Docker binds published ports to `LSS_DOCKER_BIND`
(127.0.0.1).

Containers that lss starts itself follow fixed rules: the image pinned by digest, no new privileges, no capabilities,
a read-only root file system, memory and process limits, never the engine's socket. Modules use `ctr_run` and
`ctr_compose_up` from `lib/container.sh` for that; `ctr_compose_check` checks a Compose file of your own against the
same rules.

`portainer` is the one container with the engine's socket, so it is as powerful as root and listens only on
`127.0.0.1:9443` (`LSS_PORTAINER_ACCESS=local`, through an SSH tunnel) or on the WireGuard network (`vpn`). The admin
password is `lss secrets show portainer-admin-password`. `LSS_PORTAINER_WEB_ADMIN=true` also publishes it below
`/portainer/` on the admin port of the web server.

`k3s` runs a single-server Kubernetes from a pinned, checksum-verified binary, hardened along the k3s CIS guide:
encrypted secrets, Pod Security `restricted` by default, an audit log, protected kernel defaults and SELinux on the
RHEL family. The API stays on 127.0.0.1 until `LSS_K3S_API_ACCESS` opens it; `kubectl` is installed next to it. The
profile `k8s-node` installs `minimal-secure` plus `k3s`:

```bash
sudo lss install --profile k8s-node --set LSS_ADMIN_USER_NAME=alice --set LSS_ADMIN_USER_GITHUB_USER=alice-on-github
```

With `docker` or `podman` installed, the guard scans every image daily with Trivy and reports HIGH and CRITICAL
vulnerabilities per image.

## 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, a
falling Lynis index and vulnerable packages (debsecan on Debian, `dnf updateinfo` on the RHEL family). It passes on
falling Lynis index, vulnerable packages (debsecan on Debian, `dnf updateinfo` on the RHEL family) and container
images with HIGH or CRITICAL vulnerabilities (Trivy, with `docker` or `podman`). 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.
Expand Down
16 changes: 16 additions & 0 deletions data/checksums.txt
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,19 @@ crs-plugin-wordpress-config any cdffad1a5410057a06cc2ce0489c470f77c69af876d79536
crs-plugin-wordpress-before any d6bb0e6a2848e6d39e73818537b21058c61a2b80a7b9e0de79fe2b3238eb9076 https://raw.githubusercontent.com/coreruleset/wordpress-rule-exclusions-plugin/161bb90bb0d543f805fc6cd966a7c53a2e5b08de/plugins/wordpress-rule-exclusions-before.conf
crs-plugin-xenforo-config any dcd4091a4b611931715f9b6a4c10a02210d617a810f044ef8e7d16fc9a6b6253 https://raw.githubusercontent.com/coreruleset/xenforo-rule-exclusions-plugin/193288ac2faa8ccbc4d86cdf829047f477812f28/plugins/xenforo-rule-exclusions-config.conf
crs-plugin-xenforo-before any f65f5600d9cb2004291013461fd206dd7cf2743398e8e40e5285f0588c0d6df3 https://raw.githubusercontent.com/coreruleset/xenforo-rule-exclusions-plugin/193288ac2faa8ccbc4d86cdf829047f477812f28/plugins/xenforo-rule-exclusions-before.conf

# k3s v1.36.5+k3s1 (the stable channel of https://update.k3s.io on 2026-10-13): SHA-256 from sha256sum-amd64.txt and
# sha256sum-arm64.txt of the release, the same as the asset digests GitHub reports.
k3s-v1.36.5+k3s1 x86_64 d73847bcd3c5fccef0115b372e2f9a91f3032dc84bbf71518a4617565294d313 https://github.com/k3s-io/k3s/releases/download/v1.36.5%2Bk3s1/k3s
k3s-v1.36.5+k3s1 aarch64 135e34cb9e8a1cfae3cb55577789e93501efe7edc080a8b2630458a85b07721e https://github.com/k3s-io/k3s/releases/download/v1.36.5%2Bk3s1/k3s-arm64
# k3s-selinux, the SELinux policy for k3s on the RHEL family (noarch, signed by Rancher). EL 9: the stable release
# 1.6-1 (SHA-256 of the downloaded file; GitHub reports no digest for this older release). EL 10: 1.7-4, the first
# release with an EL 10 build, published on GitHub as a testing release (rpm.rancher.io has no EL 10 repository yet);
# SHA-256 as GitHub reports it (gh api repos/k3s-io/k3s-selinux/releases/tags/v1.7.testing.4). Checked 2026-10-13.
k3s-selinux-el9 any 23d0095a766b89317d2bdbe8773825987db7221e48743417930a994ca1096793 https://github.com/k3s-io/k3s-selinux/releases/download/v1.6.stable.1/k3s-selinux-1.6-1.el9.noarch.rpm
k3s-selinux-el10 any 7ab503adacf07c3235ba543b94b6a109ec7b192596b887c200c90ab7a8bc22c5 https://github.com/k3s-io/k3s-selinux/releases/download/v1.7.testing.4/k3s-selinux-1.7-4.el10.noarch.rpm

# Trivy 0.74.0 for the daily image scan of the guard: SHA-256 from trivy_0.74.0_checksums.txt of the release, the same
# as the asset digests GitHub reports (2026-10-13). 0.75.0 was out for less than two weeks then.
trivy-0.74.0 x86_64 2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_Linux-64bit.tar.gz
trivy-0.74.0 aarch64 b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5 https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_Linux-ARM64.tar.gz
Loading