Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 11 additions & 2 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, container, backup, backup-alt, monitoring, network, mail]
group: [base, security, scanners, web, edge, runtime, database, database-alt, container, backup, backup-alt, monitoring, network, mail]
arch: [x64, arm64]
include:
- group: base
Expand All @@ -43,6 +43,15 @@ jobs:
# Each web server alone, then with certificates (Pebble) and sites; then HAProxy.
- group: web
modules: nginx,tls,vhost apache,tls,vhost caddy,tls,vhost haproxy nginx,modsecurity apache,modsecurity
# Traefik in front: alone with its own ACME client (Pebble), then before each web server in backend mode with
# sites, an admin tool, fail2ban or CrowdSec, and every WAF mode. LSS_KEY=VALUE applies to its set only.
- group: edge
modules: >-
traefik,LSS_TRAEFIK_ACME=true
nginx,tls,vhost,php,adminer,fail2ban,traefik,LSS_TRAEFIK_ADMIN_HOST=admin.example.com
apache,tls,vhost,modsecurity,traefik,LSS_TRAEFIK_WAF=both
traefik,caddy,php,adminer,crowdsec,LSS_TRAEFIK_WAF=off
apache,modsecurity,traefik,LSS_TRAEFIK_WAF=modsecurity
- group: runtime
modules: php,composer nginx,php node python ruby java go dotnet
# Conflicting engines (mariadb/mysql, redis/valkey) run in separate jobs: a removal keeps their data.
Expand Down Expand Up @@ -173,7 +182,7 @@ jobs:
if [[ $RUN == kernel ]]; then
tests/integration/run.sh "$SYSTEM" time swap firewall kernel-hardening mac auditd usbguard wireguard
elif [[ $RUN == containers ]]; then
tests/integration/run.sh "$SYSTEM" firewall,docker,portainer firewall,k3s
tests/integration/run.sh "$SYSTEM" firewall,docker,portainer firewall,docker,traefik,LSS_TRAEFIK_DOCKER=true firewall,k3s
else
case $SYSTEM:$RUN in
vm-debian-*:"profile minimal-secure") minimum=82 ;;
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ now or later.
(NodeSource), `python` (venv, pip, pipx), `ruby` (Bundler), `java` (OpenJDK, Temurin where missing), `go` (official
archive) and `dotnet` (.NET SDK, telemetry off), all from repositories with pinned keys or checksum-verified
downloads.
- `traefik`: Traefik as an edge proxy on ports 80, 443 and 443/udp (HTTP/3) with security headers, HSTS, a JSON
access log, fail2ban or CrowdSec, and the OWASP Coraza WAF (or ModSecurity behind it). nginx, Apache or Caddy move
to 127.0.0.1 behind it and keep their sites, PHP and admin tools. Applications get routes through
`web_proxy_route`, which falls back to a reverse-proxy site without Traefik; the Docker provider is opt-in and goes
through a read-only socket proxy. On amd64 Coraza waits for a Traefik release with wazero 1.9 (wazero 1.8.0 crashes
Traefik there); until then lss installs `modsecurity` for nginx or Apache and routes the applications through the
web server, so every request still passes a WAF.
- `modsecurity`: the ModSecurity web application firewall with the OWASP Core Rule Set for nginx and Apache on every
supported system, with a module built from verified sources where the distribution has none.
- Database modules: `mariadb`, `mysql`, `postgresql`, `redis`, `valkey` and `mongodb`, on localhost with hardened
Expand Down
50 changes: 47 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ 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 |
| `traefik` | 35 s | 300 MiB; 1.8 GiB with Coraza (arm64) | 330 MiB | Traefik, 100 MiB; 1.75 GiB with Coraza |
| `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 |
Expand Down Expand Up @@ -198,7 +199,8 @@ change, run `lss configure MODULE`.
`usbguard`, `guard`.

**Web:** `nginx`, `apache` or `caddy`, `tls` (certificates from Let's Encrypt), `vhost` (sites), `haproxy`,
`modsecurity` (web application firewall with the OWASP Core Rule Set for nginx or Apache).
`modsecurity` (web application firewall with the OWASP Core Rule Set for nginx or Apache), `traefik` (edge proxy in
front of the web server and the applications). See [Traefik](#traefik).

**Runtimes:** `php` (PHP-FPM with a hardened pool for the web server), `composer`, `node` (Node.js 22, 24 or 26 from
NodeSource), `python` (venv, pip, pipx), `ruby` (Bundler), `java` (OpenJDK 21, Temurin where the distribution has
Expand Down Expand Up @@ -232,8 +234,9 @@ Dovecot and Rspamd for your domains), `webmail` (Roundcube on the web server). S

### Web sites

One web server owns ports 80 and 443. It answers only for the sites you set up; requests for any other host name get
no answer, and port 80 serves nothing but ACME challenges and redirects to HTTPS. A site is one entry in
One web server owns ports 80 and 443, unless `traefik` sits in front of it (see [Traefik](#traefik)). It answers
only for the sites you set up; requests for any other host name get no answer, and port 80 serves nothing but ACME
challenges and redirects to HTTPS. A site is one entry in
`LSS_VHOST_SITES`: a name, `static`, `php` or `proxy`, its host names and, if needed, a document root or backend URL.
With the `tls` module each site gets its own certificate from Let's Encrypt; until it arrives, and without `tls`, the
site uses a self-signed certificate. The names must point to the server and port 80 must be reachable from the
Expand All @@ -244,6 +247,47 @@ 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`.

### Traefik

`traefik` puts Traefik in front of everything that speaks HTTP. It takes ports 80/tcp, 443/tcp and 443/udp (HTTP/3),
redirects port 80 to HTTPS, adds the same security headers and HSTS as the web servers, writes a JSON access log
and filters every request through the OWASP Coraza WAF with the Core Rule Set (on arm64; on amd64 see below). The
web server stays: nginx, Apache or Caddy moves to `127.0.0.1:8008` and `127.0.0.1:8443` and keeps serving the sites
of `vhost`, PHP and the admin tools. It takes the client address from Traefik's `X-Forwarded-For`, so its logs, fail2ban and ModSecurity still see
the client. Removing `traefik` gives ports 80 and 443 back to the web server.

On amd64 Coraza stays switched off for now: every current Traefik release ships wazero 1.8.0, which crashes Traefik at
random on amd64 with Go 1.24 or later (fixed in wazero 1.9.0). arm64 is unaffected. With `LSS_TRAEFIK_WAF=coraza`
(the default) or `both`, lss puts ModSecurity in Coraza's place on amd64:

- with nginx or Apache, lss installs the `modsecurity` module itself (once; removing `traefik` removes it again, unless
you installed it yourself) and sends the routes of applications through the web server too, so every request passes
the WAF: Traefik, then nginx or Apache with ModSecurity, then the application on 127.0.0.1. Run `lss configure traefik`
after installing nginx or Apache later, so it adds ModSecurity then.
- Caddy has no ModSecurity connector. Requests reach Caddy and the applications unfiltered, and lss says so on every
run; put nginx or Apache in Caddy's place for a WAF.
- Containers of the Docker provider are routed to directly, without a WAF in front of them.

Once a Traefik release carries wazero 1.9, lss switches back to Coraza in Traefik and direct routes on amd64.
`LSS_TRAEFIK_WAF=modsecurity`, `both` on arm64 and `off` keep their meaning.

Applications get a host name of their own through a route: `LSS_TRAEFIK_ROUTES=app:app.example.com:127.0.0.1:3000`,
or from a module with `web_proxy_route NAME DOMAIN 127.0.0.1:PORT`. Without Traefik the same route becomes a
reverse-proxy site of the web server. Certificates come from the `tls` module, or from Traefik itself with
`LSS_TRAEFIK_ACME=true` (TLS-ALPN challenge on port 443).

| Setting | Default | Meaning |
| ---------------------------- | -------- | --------------------------------------------------------------------------------------- |
| `LSS_TRAEFIK_WAF` | `coraza` | `coraza` in Traefik (arm64), `modsecurity` in the web server behind it, `both`, or `off` |
| `LSS_TRAEFIK_PARANOIA` | `1` | Paranoia level of the Core Rule Set in Coraza (1 to 4) |
| `LSS_TRAEFIK_ADMIN_HOST` | empty | Publishes the admin port (phpMyAdmin, Adminer, pgAdmin) under this name with a password |
| `LSS_TRAEFIK_DOCKER` | `false` | Routes containers labelled `traefik.enable=true`, through a read-only socket proxy |
| `LSS_TRAEFIK_DASHBOARD_PORT` | `8099` | Dashboard and read-only API on 127.0.0.1 only (`ssh -L 8099:127.0.0.1:8099 server`) |

The Docker provider never sees the engine's socket. A small proxy container
([wollomatic/socket-proxy](https://github.com/wollomatic/socket-proxy), pinned by digest) holds it, runs without a
network and without capabilities, and answers only the calls Traefik needs to list and inspect containers.

### 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
Expand Down
8 changes: 8 additions & 0 deletions data/checksums.txt
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,11 @@ alertmanager-0.34.1 x86_64 265b9d1e55ef0d5306a436018af6d2b686c2ce051f03d968f7464
alertmanager-0.34.1 aarch64 d98d6cbaf52151c7e76e24355fec88b11cebcb9875d4cdd8b76ddce7a7e5535c https://github.com/prometheus/alertmanager/releases/download/v0.34.1/alertmanager-0.34.1.linux-arm64.tar.gz
node_exporter-1.12.1 x86_64 b51d8a76aa2a9156a55d501aca6276fae09e262259a5e4e831d2c2222f084e63 https://github.com/prometheus/node_exporter/releases/download/v1.12.1/node_exporter-1.12.1.linux-amd64.tar.gz
node_exporter-1.12.1 aarch64 ad35b605f9954b9f1ffddf5ba054bdc5a98d790b9eae5291e1eeb83f1ecbd0e7 https://github.com/prometheus/node_exporter/releases/download/v1.12.1/node_exporter-1.12.1.linux-arm64.tar.gz

# Traefik 3.7.15: SHA-256 from traefik_v3.7.15_checksums.txt of the release, which matches the digests GitHub
# publishes for the assets (gh api repos/traefik/traefik/releases/tags/v3.7.15, checked 2026-10-15).
traefik-3.7.15 x86_64 5b9f51199fcb7a3b5f7ae0f9dae8dcf01efadafd92c5fca96ea2822eca81f8e6 https://github.com/traefik/traefik/releases/download/v3.7.15/traefik_v3.7.15_linux_amd64.tar.gz
traefik-3.7.15 aarch64 1866d8e831b95d14b599bb59eb20d2033b979c0cb1e347b48a0866a9b810454e https://github.com/traefik/traefik/releases/download/v3.7.15/traefik_v3.7.15_linux_arm64.tar.gz
# Coraza http-wasm plugin for Traefik 0.3.0 (Coraza with the OWASP Core Rule Set 4.0.0 compiled in). The release of
# 2024 has no published digest; the SHA-256 is that of the asset downloaded on 2026-10-15, which pins it from then on.
coraza-http-wasm-0.3.0 any b96680b46e61287faae243e360db5de34e4f315e2a8ee105fedddc176e79b1e7 https://github.com/jcchavezs/coraza-http-wasm-traefik/releases/download/v0.3.0/coraza-http-wasm-v0.3.0.zip
4 changes: 4 additions & 0 deletions docs/hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Docker, databases and its own sudo rules) 78. CI checks that it stays at 82 on D
| `phpmyadmin` | Checksum-verified release without the setup wizard; configuration readable by root and PHP-FPM only, with a generated cookie secret; only the local database server, no root and no empty-password logins; no version check and no error reports; only on the admin port | (operations) |
| `adminer` | Checksum-verified release; a login plugin allows only the configured local servers and drivers; only on the admin port | (operations) |
| `pgadmin` | Pinned, checksum-verified wheels in an isolated environment; own system user; systemd sandbox without capabilities; bound to 127.0.0.1; no upgrade check, no psql tool, no browsing of binary paths; secure cookies when the admin port is public | (operations) |
| `traefik` | Release binary pinned and checked against `data/checksums.txt` (amd64 and arm64); own system user, systemd sandbox with `CAP_NET_BIND_SERVICE` as the only capability; ports 80, 443 and 443/udp with a redirect to HTTPS, TLS 1.2 and 1.3 with the cipher suites of the web servers, the same security headers and HSTS, no `Server` header; the OWASP Coraza WAF with the Core Rule Set from a pinned, checksum-verified local plugin (never the plugin catalog); dashboard and API on 127.0.0.1 only; JSON access log read by fail2ban (failed logins, WAF blocks) or CrowdSec; the web server behind it on 127.0.0.1 only; the Docker provider (opt-in) through a read-only socket proxy, never the engine socket; configuration readable by root and Traefik only, no secrets in it (the admin password only as a bcrypt hash in a file of its own) | OWASP CRS, Mozilla TLS guidelines, OWASP Secure Headers, CIS Docker 5 (socket) |
| `modsecurity` | Requests filtered by ModSecurity with the OWASP Core Rule Set (anomaly scoring, paranoia level 1 by default) and blocked with 403; matches go to an audit log without request bodies | OWASP CRS |
| `mariadb` | Bound to 127.0.0.1, `local-infile=0`, root only through the Unix socket, no anonymous accounts and no test database, one least-privilege user per application, systemd sandbox | CIS MariaDB |
| `mysql` | Bound to 127.0.0.1 (the X protocol too), `local-infile=0`, root through `auth_socket` (RHEL: a generated password in `/root/.my.cnf` 0600); root never exists without a password; systemd sandbox | CIS MySQL |
Expand Down Expand Up @@ -76,6 +77,9 @@ These settings differ from a strict reading of the benchmarks. Each one has a se
| Docker user namespace remapping | on | Images that expect to own host paths as root need it off. | `LSS_DOCKER_USERNS_REMAP=false` |
| Docker and k3s together | not possible | Each brings its own containerd and claims the forwarding rules; `profiles/all.conf` has Docker, `profiles/k8s-node.conf` has k3s. | – |
| Kubernetes API | 127.0.0.1 | kubectl through an SSH tunnel; a cluster with more nodes or remote tooling needs `vpn` or `public`. | `LSS_K3S_API_ACCESS` |
| Certificate check from Traefik to the web server | off, on 127.0.0.1 only | Traefik reaches the web server on `127.0.0.1:8443`, which presents the site's certificate, not one for 127.0.0.1. The connection never leaves the loopback interface. | – |
| Apache SNI policy behind Traefik | `SSLVHostSNIPolicy authonly` | Traefik reaches Apache on 127.0.0.1 without SNI, so the TLS handshake runs in the default site and the Host header picks the site. Apache 2.4.65 and later refuse that switch with 421 when the TLS settings of the two sites differ. `authonly` still refuses it when client authentication differs, and lss sets up none. It applies only in backend mode. | – |
| Coraza plugin | default on arm64; ModSecurity in its place on amd64 | The WebAssembly build of Coraza is young (its authors call it early stage), so `lss verify` sends an SQL injection and a path traversal through it and expects 403 for both. On amd64 Traefik 3.7.15 crashes with it at random: its wazero 1.8.0 does not save a register that `runtime.memmove` of Go 1.24 and later overwrites (fixed in wazero 1.9.0, wazero/wazero#2378). There lss installs `modsecurity` for nginx or Apache, routes the applications through the web server and verifies a 403 for every vhost site and every route through Traefik. Caddy has no ModSecurity connector, so behind Caddy and for Docker-provider containers nothing filters on amd64; lss warns on every run. | `LSS_TRAEFIK_WAF=modsecurity` |
| Traefik in k3s | on | The bundled ingress controller serves ports 80 and 443 for the cluster; without it you bring your own. | `LSS_K3S_TRAEFIK=false` |

## What lss does not do
Expand Down
5 changes: 3 additions & 2 deletions lib/container.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
# - the socket of a container engine is never mounted, except into the management tool (CTR_SOCKET_CONTAINERS)

# Containers that may mount the socket of the engine. Whoever controls that socket controls the host; Portainer is a
# management tool and needs it, nothing else does.
CTR_SOCKET_CONTAINERS="lss-portainer"
# management tool and needs it, and the socket proxy of the traefik module passes only read-only container calls
# on to Traefik (its Docker provider). Nothing else gets it.
CTR_SOCKET_CONTAINERS="lss-portainer lss-traefik-docker"
# Capabilities no container of lss gets, whatever a module asks for: each one hands over the kernel or other processes.
CTR_DENIED_CAPS="SYS_ADMIN SYS_MODULE SYS_RAWIO SYS_PTRACE SYS_BOOT SYS_TIME DAC_READ_SEARCH MAC_ADMIN MAC_OVERRIDE BPF PERFMON NET_ADMIN"
CTR_DEFAULT_MEMORY=512m
Expand Down
2 changes: 1 addition & 1 deletion lib/step.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ set -Eeuo pipefail
# change notes it sets reach the caller.
shopt -s inherit_errexit lastpipe

for library in log os config files template secrets state pkg repo fetch service firewall container web mail monitoring module; do
for library in log os config files template secrets state pkg repo fetch service firewall container web traefik mail monitoring module; do
# shellcheck source=/dev/null
source "${LSS_ROOT}/lib/${library}.sh"
done
Expand Down
Loading