Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
94 commits
Select commit Hold shift + click to select a range
301077c
Stage 2 foundation: service sandbox, web layer, docker-mode requirement
VectoDE Oct 13, 2026
146252b
Add the nginx, apache and caddy modules and certificate helpers
VectoDE Oct 13, 2026
7eef6a1
Close three gaps in the Caddy configuration
VectoDE Oct 13, 2026
fc7dfb1
Add the tls and vhost modules
VectoDE Oct 13, 2026
093b761
Add the haproxy module for balanced TCP services
VectoDE Oct 13, 2026
138511a
Test the web layer and hook the web servers into fail2ban and CrowdSec
VectoDE Oct 13, 2026
88e5908
Test the web modules with Pebble on every system
VectoDE Oct 13, 2026
5037374
Document the web modules
VectoDE Oct 13, 2026
f7df34c
Add the php and composer runtime modules
VectoDE Oct 13, 2026
89e88b0
Add the node runtime module
VectoDE Oct 13, 2026
7fd1218
Add the python and ruby runtime modules
VectoDE Oct 13, 2026
fc9e9b6
php: read php-fpm -i before matching it
VectoDE Oct 13, 2026
7c88bed
Add the java runtime module
VectoDE Oct 13, 2026
7305cf0
Add the go runtime module
VectoDE Oct 13, 2026
b2265ac
Add the dotnet runtime module
VectoDE Oct 13, 2026
4e8cce1
Runtime modules: name the system by ID and version in messages
VectoDE Oct 13, 2026
c678852
Document the runtime modules and test them on every system
VectoDE Oct 13, 2026
9cc123e
Merge branch 'feat/lss-1b' into feat/lss-2
VectoDE Oct 13, 2026
0bcc8da
Add the phpmyadmin, adminer and pgadmin modules
VectoDE Oct 13, 2026
fb85019
Document the database tools; WebSockets through Apache's admin proxy
VectoDE Oct 13, 2026
40e7193
Add the mariadb module
VectoDE Oct 13, 2026
7bc4046
Add the mysql module
VectoDE Oct 13, 2026
a5cabd3
mysql: never let root exist without a password
VectoDE Oct 13, 2026
e6a2264
mysql, mariadb: delete only fixed paths, and nothing on a failed start
VectoDE Oct 13, 2026
b9a8e86
Add the postgresql module
VectoDE Oct 13, 2026
e3a2302
postgresql: isolate application roles, keep root out of postgres-owne…
VectoDE Oct 13, 2026
1c7f07f
mysql: keep the bootstrap file out of a directory mysql owns
VectoDE Oct 13, 2026
038f0a0
Add the redis module
VectoDE Oct 13, 2026
ba501e9
Add the valkey module
VectoDE Oct 13, 2026
7896e7d
Add the mongodb module
VectoDE Oct 13, 2026
d6c851b
redis, valkey: label a non-default port for SELinux
VectoDE Oct 13, 2026
03047b2
Integration settings for the database modules
VectoDE Oct 13, 2026
11e07fe
mongodb: authorization on from the first start
VectoDE Oct 13, 2026
f4c6d09
Run the last command of a pipeline in the current shell
VectoDE Oct 13, 2026
8f52b2d
Add the modsecurity module: a web application firewall for nginx and …
VectoDE Oct 13, 2026
949ef7d
Profiles webhosting and all; test databases and modsecurity on every …
VectoDE Oct 13, 2026
6ccfc7a
Fix the first CI findings of the web modules
VectoDE Oct 13, 2026
73bb165
fix(security): stable unit paths on RHEL, quiet augenrules and firewa…
VectoDE Oct 13, 2026
45247ce
ci(dco): skip merge commits like the DCO GitHub App
VectoDE Oct 13, 2026
d185701
fix(service): let scanners enter their SELinux domain
VectoDE Oct 13, 2026
6fc0a06
style(postgresql): explain the single-quoted inner script for ShellCheck
VectoDE Oct 13, 2026
d61b924
fix(repo): separate RPM key for NodeSource
VectoDE Oct 13, 2026
6fdb59d
fix(vhost): delete the certificate of a removed site
VectoDE Oct 13, 2026
6ac87f2
fix(database): data directory before the sandbox, wait for reloads
VectoDE Oct 13, 2026
ed41adf
test(integration): Pebble for profiles with tls, robust diagnostics
VectoDE Oct 13, 2026
9882fa0
test(web): cover sites, admin paths, certificates and reload for all …
VectoDE Oct 13, 2026
972c0f6
fix(caddy): access log owned by caddy before the configuration test
VectoDE Oct 13, 2026
8605388
style(mongodb): shfmt layout of the here-document substitutions
VectoDE Oct 13, 2026
e543dc8
fix(test): match tls in the profile's module list
VectoDE Oct 13, 2026
1c6700d
ci(integration): Lynis minimum 80 for the RHEL family
VectoDE Oct 13, 2026
d52e583
fix(mysql): cap the open file limit for the package scripts
VectoDE Oct 14, 2026
f429076
fix(web): build libmodsecurity and the nginx connector on every distr…
VectoDE Oct 14, 2026
7dacaf3
fix(base): install locales so a second run leaves /etc/default/locale…
VectoDE Oct 14, 2026
126b576
refactor: resolve SonarQube findings in valkey and the test stubs
VectoDE Oct 14, 2026
d6f2185
fix(database): sandbox the data directory MariaDB is configured for
VectoDE Oct 14, 2026
6cafbbd
fix(pkg): retry dnf transactions when a mirror drops out
VectoDE Oct 14, 2026
8e5c5d8
fix(pkg): keep apt notices that report nothing to do out of the log
VectoDE Oct 14, 2026
6e38985
Merge feat/lss-1b into feat/lss-2
VectoDE Oct 14, 2026
590c9bc
fix(web): pick the libxml2 runtime package apt offers
VectoDE Oct 14, 2026
b266323
fix(database): ask the MariaDB server for its data directory
VectoDE Oct 14, 2026
462482b
fix(web): build the nginx connector with PCRE1 for nginx before 1.21.5
VectoDE Oct 14, 2026
479d427
fix(web): write the fail2ban jails while the web server is being inst…
VectoDE Oct 14, 2026
b78a6e0
test: end hanging lss runs after 40 minutes and keep kcov's trace out…
VectoDE Oct 14, 2026
61d0f48
fix(base): flush the swap file before swapon
VectoDE Oct 14, 2026
2a49aab
fix(service): stop waiting for a unit after five minutes
VectoDE Oct 14, 2026
3a60905
fix(database): let MySQL write its error log and socket directory
VectoDE Oct 14, 2026
dbd9b46
feat(web): build mod_security2 for Apache on Ubuntu 22.04 and use CRS…
VectoDE Oct 14, 2026
614d622
fix(web): use the pinned CRS version; show MySQL and AppArmor diagnos…
VectoDE Oct 14, 2026
ae9e99d
fix(mysql): keep mysqld's start notification working under AppArmor
VectoDE Oct 14, 2026
d6fcf29
fix(service): decide AppArmor confinement fail-closed
VectoDE Oct 14, 2026
d57e012
test(integration): list the Lynis tests that cost hardening points
VectoDE Oct 14, 2026
72d1f18
fix(swap, modsecurity): flush the swap file last; pkg-config for mod_…
VectoDE Oct 14, 2026
824d699
fix(modsecurity): let the nginx workers append to the audit log
VectoDE Oct 14, 2026
e1ea116
test(integration): read Lynis' partial hardening points correctly
VectoDE Oct 14, 2026
54a3bef
fix(modsecurity): debug the missing nginx audit entry instead of gues…
VectoDE Oct 15, 2026
e33ad07
test(integration): install pending updates in the profile runs
VectoDE Oct 15, 2026
56bbf26
fix(modsecurity): build the nginx module against the distribution's p…
VectoDE Oct 15, 2026
01c573e
chore(modsecurity): drop the nginx debug probe
VectoDE Oct 15, 2026
941dcce
docs(readme): resource figures of the web, runtime and database modules
VectoDE Oct 15, 2026
3fbefaf
ci(integration): profile all on every VM distribution at night
VectoDE Oct 15, 2026
e454618
ci(integration): nightly profile all on Ubuntu VMs too
VectoDE Oct 15, 2026
3bdc1f1
fix(web): label the admin port for web servers under SELinux
VectoDE Oct 15, 2026
c2c8642
test(integration): show AppArmor and SELinux denials from the audit log
VectoDE Oct 15, 2026
d5f3402
fix(database): show the web server's error log when an admin page fails
VectoDE Oct 15, 2026
620979b
fix(php): extend PHP-FPM's AppArmor profile on Ubuntu 24.04 and later
VectoDE Oct 15, 2026
d17a1e5
fix(haproxy): let haproxy enter its SELinux domain
VectoDE Oct 15, 2026
4dce9f8
fix(admin-user): keep the run lock away from passwd and chpasswd
VectoDE Oct 15, 2026
5bf98cc
fix(service): let sandboxed services enter their SELinux domain
VectoDE Oct 15, 2026
3246f69
fix(modsecurity): read YAJL support from the engine file itself
VectoDE Oct 15, 2026
0e529bd
fix(php): allow the lss pool socket in PHP-FPM's AppArmor profile
VectoDE Oct 15, 2026
30f8947
fix(service): keep NoNewPrivileges for programs without an SELinux do…
VectoDE Oct 15, 2026
b6276da
fix(service): allow SELinux domain transitions under NoNewPrivileges …
VectoDE Oct 15, 2026
b7a601a
fix: SELinux policy module once the program exists; yescrypt cost onl…
VectoDE Oct 15, 2026
c18a1a1
style: quote test values for the newer shellcheck, name the key in _p…
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
11 changes: 8 additions & 3 deletions .github/workflows/dco.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# SPDX-License-Identifier: MIT

# Every commit in a pull request must be signed off (Developer Certificate of Origin): the author certifies the right
# to submit it under the project's license. Commits by bots such as Dependabot are exempt.
# to submit it under the project's license. Commits by bots such as Dependabot and merge commits are exempt.
name: DCO

on: pull_request
Expand Down Expand Up @@ -33,8 +33,13 @@ jobs:
PR: ${{ github.event.pull_request.number }}
run: |
missing=0
while IFS=$'\t' read -r sha login email; do
while IFS=$'\t' read -r sha login email parents; do
case "$login" in *'[bot]') continue ;; esac
# Merge commits bring in work that was signed off on its own branch, as with the DCO GitHub App.
if ((parents > 1)); then
echo "${sha:0:7} is a merge commit"
continue
fi
message="$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha" --jq .commit.message)"
if printf '%s\n' "$message" | grep -i '^Signed-off-by:' | grep -qiF "<$email>"; then
echo "${sha:0:7} signed off by $email"
Expand All @@ -43,5 +48,5 @@ jobs:
missing=1
fi
done < <(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR/commits" \
--jq '.[] | [.sha, (.author.login // ""), .commit.author.email] | @tsv')
--jq '.[] | [.sha, (.author.login // ""), .commit.author.email, (.parents | length)] | @tsv')
exit "$missing"
26 changes: 22 additions & 4 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ jobs:
container:
name: ${{ matrix.system }}${{ matrix.arch == 'arm64' && ' arm64' || '' }} / ${{ matrix.group }}
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
timeout-minutes: 45
timeout-minutes: 90
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]
group: [base, security, scanners, web, runtime, database, database-alt]
arch: [x64, arm64]
include:
- group: base
Expand All @@ -40,6 +40,16 @@ jobs:
modules: ssh-hardening ssh-2fa fail2ban crowdsec password-policy auto-updates guard
- group: scanners
modules: aide rkhunter clamav lynis
# 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
- 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.
- group: database
modules: mariadb postgresql redis nginx,php,mariadb,phpmyadmin caddy,php,adminer apache,postgresql,pgadmin
- group: database-alt
modules: mysql valkey mongodb
steps:
- uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
Expand Down Expand Up @@ -98,12 +108,18 @@ jobs:
vm:
name: ${{ matrix.system }} VM / ${{ matrix.run }}
runs-on: ubuntu-24.04
timeout-minutes: 60
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
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.
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"}]') }}
steps:
- uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
Expand Down Expand Up @@ -132,7 +148,9 @@ jobs:
else
case $SYSTEM in
vm-debian-*) minimum=82 ;;
# The same minimum as the host job on the Ubuntu runners.
vm-ubuntu-*) minimum=75 ;;
*) minimum=80 ;;
esac
tests/integration/run.sh "$SYSTEM" --profile minimal-secure "$minimum"
tests/integration/run.sh "$SYSTEM" --profile "${RUN#profile }" "$minimum"
fi
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ now or later.
`rkhunter`, `clamav`, `lynis`, `usbguard` and `guard`, which checks the server on a timer, repairs drift and
sends notifications.
- Profile `minimal-secure`.
- Web modules: `nginx`, `apache` and `caddy` (one of them owns ports 80 and 443), `tls` (certbot with a deploy hook
that installs every certificate for the web server and reloads it), `vhost` (static, PHP and reverse-proxy sites,
each with its own certificate) and `haproxy` (load balancing of TCP services with health checks).
- Runtime modules: `php` (PHP-FPM with a hardened, sandboxed pool on the web servers' socket), `composer`, `node`
(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.
- `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
authentication, optional databases and least-privilege users; root or admin accounts never exist without a password.
- 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.
- 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
66 changes: 64 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,38 @@ up). Peak RAM covers install and `lss verify`; "while running" is what stays res
| `usbguard` | 10 s | 90 MiB | 10 MiB | usbguard-daemon |
| `guard` | 5 s | 90 MiB | – | daily check |

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.

| Modules tested together | Install and verify | Peak RAM | Disk | While running |
| ---------------------------------- | ------------------ | --------------------------- | ------- | ------------------------------------- |
| `nginx` `tls` `vhost` | 50 s | 640 MiB (dnf), else 270 MiB | 235 MiB | nginx, renewal timer |
| `apache` `tls` `vhost` | 60 s | 370 MiB | 170 MiB | Apache, renewal timer |
| `caddy` `tls` `vhost` | 30 s | 210 MiB | 110 MiB | Caddy, renewal timer |
| `haproxy` | 10 s | 215 MiB | 10 MiB | haproxy |
| `nginx` `modsecurity` | 155 s (build) | 1.5 GiB | 55 MiB | nginx with the Core Rule Set |
| `apache` `modsecurity` | 45 s | 310 MiB | 110 MiB | Apache with the Core Rule Set |
| `nginx` `php` | 30 s | 275 MiB | 125 MiB | nginx, PHP-FPM |
| `php` `composer` | 40 s | 305 MiB | 285 MiB | PHP-FPM |
| `node` | 15 s | 435 MiB | 285 MiB | – |
| `python` | 30 s | 755 MiB (dnf), else 145 MiB | 90 MiB | – |
| `java` | 30 s | 485 MiB | 735 MiB | – |
| `go` | 10 s | 255 MiB | 285 MiB | – |
| `dotnet` | 25 s | 480 MiB | 690 MiB | – |
| `ruby` | 15 s | 265 MiB | 265 MiB | – |
| `mariadb` | 25 s | 470 MiB | 455 MiB | mariadbd |
| `mysql` | 40 s | 900 MiB | 1.2 GiB | mysqld |
| `postgresql` | 25 s | 300 MiB | 305 MiB | postgres |
| `redis` | 15 s | 165 MiB | 95 MiB | redis-server |
| `valkey` | 10 s | 165 MiB | 85 MiB | valkey-server |
| `mongodb` | 25 s | 820 MiB | 780 MiB | mongod |
| `nginx` `php` `mariadb` `phpmyadmin` | 45 s | 385 MiB | 170 MiB | nginx, PHP-FPM, mariadbd |
| `caddy` `php` `adminer` | 35 s | 240 MiB | 135 MiB | Caddy, PHP-FPM |
| `apache` `postgresql` `pgadmin` | 70 s | 500 MiB | 705 MiB | Apache, pgAdmin (gunicorn), postgres |

`nginx` `modsecurity` builds libmodsecurity and the nginx module from source on the first install; that is where the
time and the peak memory go. Later runs reuse the build.

The integration jobs print these figures as `RESOURCES` lines, so every new module and every new system adds its own.

## Quick start
Expand Down Expand Up @@ -118,6 +150,8 @@ LSS_SSH_HARDENING_PORT=2222
LSS_TIME_TIMEZONE=Europe/Berlin
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
```

`--set` wins over the environment, which wins over the file, which wins over the profile, which wins over the
Expand All @@ -133,9 +167,37 @@ change, run `lss configure MODULE`.
(AppArmor or SELinux), `auditd`, `auto-updates`, `password-policy`, `aide`, `rkhunter`, `clamav`, `lynis`,
`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).

**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
none), `go` (official archive), `dotnet` (.NET SDK 10, telemetry off). `LSS_PHP_VERSION`, `LSS_NODE_VERSION`,
`LSS_JAVA_VERSION` and `LSS_DOTNET_VERSION` pick another version.

**Databases:** `mariadb` or `mysql`, `postgresql`, `redis` or `valkey`, `mongodb`. Each listens on localhost only,
with hardened authentication and optional databases and users (`LSS_<ENGINE>_USERS=user:database`, the password with
`lss secrets show <engine>-user-<user>`). `access=vpn` or `public` opens the port.

**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.

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

### 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
`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
internet for Let's Encrypt to issue one.

Admin interfaces such as phpMyAdmin listen on `127.0.0.1:8090`. Reach them through an SSH tunnel
(`ssh -L 8090:127.0.0.1:8090 server`, then `http://localhost:8090/phpmyadmin/`) or set
`LSS_<SERVER>_ADMIN_PUBLIC=true` to publish port 8090 with TLS and the password from
`lss secrets show web-admin-password`.

## Guard

Expand Down
Loading