nixos-hardening-tiers¶
Modules
Opt-in, stackable NixOS kernel/network hardening tiers in one module.
A host picks how much it wants (basic / medium / advanced, plus
independent antivirus and malloc toggles); everything defaults off, so
importing the module changes nothing until a host asks for it.
The value here is the module shape, not the specific sysctls. Copy the structure and swap in your own knobs.
The problem¶
You want a menu of hardening levels that hosts can dial up individually — a laptop takes everything, a build box takes only the cheap stuff — from a single module. Two things bite you when you build this naively, and both fail silently (the module still evaluates, still passes review):
Trap 1 — the config MUST be mkMerge, never //¶
The module body is an always-on block plus one mkIf per tier. It is tempting
to combine them with //:
In Nix // is right-biased attrset update: a // b // c evaluates to c with
only top-level keys merged. So that line throws away every block but the
last — the whole module quietly becomes a near-no-op. It still type-checks, it
still builds, and nobody notices until an audit finds none of the sysctls
actually applied. Use mkMerge, which is the NixOS module system's real merge:
config = mkMerge [
{ /* always-on */ }
(mkIf cfg.basic { ... })
(mkIf cfg.medium { ... })
(mkIf cfg.advanced { ... })
];
Trap 2 — shared keys across tiers must be deduped¶
Tiers stack: a host can run basic and advanced at once. If two tiers
both define the same sysctl, the NixOS module system sees two definitions of
one option and you get a collision (or a fragile priority tie-break). Two
patterns in this module fix that:
-
Guard the duplicate. Five net/vm keys are wanted by both
basicandadvanced. They live inbasic; theadvancedblock only sets themlib.mkIf (!cfg.basic), i.e. "only whenbasicisn't already providing them." One owner, no collision. -
Hoist to the always-block as a tri-state.
kernel.kptr_restrictwould otherwise be set once per tier with different values. Instead it lives once in the always-on block,mkIf (cfg.basic || cfg.medium || cfg.advanced), and computes its value (if cfg.advanced then 2 else if cfg.medium then 1 else 2) from whichever tiers are on. Defined exactly once, regardless of the combo.
The always-on block owns non-optional mitigations¶
Anything that no host should ever be able to turn off goes in the un-guarded
first block, not inside a tier. In this module that is a blacklisted kernel
module (an install ... /bin/true modprobe stub also defeats explicit
modprobe). Because it is not wrapped in any mkIf cfg.<tier>, every importing
host gets it unconditionally — the right home for a CVE mitigation you can't let
a host opt out of.
Usage¶
Import the module and enable tiers per host:
{
imports = [ ./nixos-hardening-tiers ];
# A workstation: take everything.
hardening.basic = true;
hardening.medium = true;
hardening.advanced = true;
hardening.malloc = true;
}
{
imports = [ ./nixos-hardening-tiers ];
# A GPU box: strong tiers, but keep runtime module loading so the
# out-of-tree driver still loads.
hardening.medium = true;
hardening.allowKernelModuleLoading = true;
}
Options¶
| Option | Default | Effect |
|---|---|---|
hardening.basic |
false |
Basic kernel/network sysctls; blacklists legacy fs/net modules; disables unprivileged user namespaces. |
hardening.medium |
false |
Kernel image protection, module locking, ptrace/bpf/dmesg restrictions, TCP SYN-flood hardening, KASLR/kstack randomization. |
hardening.advanced |
false |
Disables SMT, forces PTI + L1D flush, enables AppArmor, init_on_alloc/free. Higher performance cost. |
hardening.malloc |
false |
Hardened memory allocator (scudo) with zeroed allocations. |
hardening.antivirus |
false |
ClamAV daemon + updater. |
hardening.allowKernelModuleLoading |
false |
Keep runtime module loading unlocked under medium (for GPU drivers / DKMS). |
Caveats¶
advancedhas a real performance cost. Disabling SMT and forcing page table isolation / L1D flushing is a meaningful hit on some workloads. Measure before enabling it fleet-wide.mediumlocks kernel modules (security.lockKernelModules). Any host that loads modules after boot — proprietary GPU drivers, DKMS, some virtualization stacks — must sethardening.allowKernelModuleLoading = trueor it will break.- The sysctl values are examples. They are a reasonable starting point, not
gospel. Treat the tier contents as a template and adjust to your threat model;
the reusable part is the
mkMerge+ dedup structure. - Adding a key to two tiers reintroduces trap 2. If you extend a tier, check whether another tier already sets the same option, and dedup it (guard or hoist) the same way the existing shared keys are handled.
Source¶
modules/nixos-hardening-tiers/default.nix
# nixos-hardening-tiers
#
# Opt-in, stackable NixOS kernel/network hardening tiers.
#
# The whole point of this module is the *shape*, not the exact sysctls:
#
# 1. `config` MUST be `mkMerge [ ... ]` — an always-on block plus one
# `mkIf` per tier. Do NOT chain the blocks with `//`. In Nix `//` is
# right-biased attrset update, so `a // b // c` keeps only `c`; using it
# here would silently discard every earlier block and turn the module
# into a near-no-op that still evaluates cleanly and passes review.
#
# 2. Tiers stack. Any sysctl (or other key) set by more than one tier must
# be deduped, or the two definitions collide when both tiers are enabled
# on the same host. See the five net/vm keys shared by `basic` and
# `advanced`: `advanced` only applies them `mkIf (!cfg.basic)`, and
# `kernel.kptr_restrict` lives once in the always-block as a tri-state
# value rather than being redefined per tier.
#
# 3. The always-on block owns non-optional mitigations (here, blacklisting
# a vulnerable kernel module). No host can turn those off — they are not
# guarded by any tier toggle.
#
# All tiers default OFF. Nothing here applies unless a host opts in.
{
lib,
pkgs,
config,
...
}:
let
inherit (lib)
mkEnableOption
mkIf
mkMerge
types
mkOption
;
cfg = config.hardening;
in
{
options.hardening = {
antivirus = mkEnableOption "ClamAV antivirus daemon + updater";
malloc = mkOption {
description = "Enable a hardened memory allocator (scudo). Opt-in, off by default.";
type = types.bool;
default = false;
};
basic = mkOption {
description = "Basic kernel/network measures. Opt-in, off by default.";
type = types.bool;
default = false;
};
medium = mkEnableOption "somewhat invasive kernel protection measures";
advanced = mkEnableOption "pretty invasive kernel protection measures";
# The `medium` tier locks kernel modules by default, which breaks hosts
# that load out-of-tree modules at runtime (proprietary GPU drivers, DKMS,
# etc.). Flip this on such hosts.
allowKernelModuleLoading = mkOption {
description = ''
Keep runtime kernel module loading unlocked even under the `medium`
tier. Enable on hosts that need to load modules after boot (e.g.
out-of-tree GPU drivers or DKMS modules).
'';
type = types.bool;
default = false;
};
};
config = mkMerge [
# ── Always-on block ─────────────────────────────────────────────────────
# Applies to every host that imports this module, tiers or not. Put
# non-optional mitigations here so no host can opt out.
{
services.clamav = mkIf cfg.antivirus {
daemon.enable = true;
updater.enable = true;
};
environment = mkIf cfg.malloc {
memoryAllocator.provider = lib.mkDefault "scudo";
variables.SCUDO_OPTIONS = lib.mkDefault "zero_contents=1";
};
boot = {
# Example of a non-optional mitigation living outside the tiers.
# Replace/extend with whatever module(s) you must never load. The
# `install ... /bin/true` stub also defeats explicit `modprobe`.
blacklistedKernelModules = [ "act_pedit" ];
extraModprobeConfig = "install act_pedit ${pkgs.coreutils}/bin/true\n";
# kptr_restrict is set by more than one tier, so it lives here ONCE as
# a tri-state value instead of colliding across tier blocks.
kernel.sysctl."kernel.kptr_restrict" = mkIf (cfg.basic || cfg.medium || cfg.advanced) (
lib.mkForce (
if cfg.advanced then
2
else if cfg.medium then
1
else
2
)
);
};
}
# ── basic tier ──────────────────────────────────────────────────────────
(mkIf cfg.basic {
security.unprivilegedUsernsClone = lib.mkDefault false;
boot = {
kernel.sysctl = {
"net.core.bpf_jit_enable" = lib.mkDefault false;
"kernel.sysrq" = lib.mkForce 0;
# These five net/vm keys are ALSO wanted by `advanced`. They live
# here in `basic`; `advanced` defers to `basic` (see below) so the
# two tiers never define them twice on a host running both.
"net.core.rmem_max" = lib.mkDefault 16777216;
"net.core.wmem_max" = lib.mkDefault 16777216;
"vm.min_free_kbytes" = lib.mkDefault 65536;
"vm.swappiness" = lib.mkDefault 2;
"vm.vfs_cache_pressure" = 30;
"kernel.core_pattern" = "/var/crash/core.%u.%e.%p";
};
# Blacklist rarely-used, historically-buggy filesystem and legacy
# network-protocol modules to shrink the kernel attack surface.
blacklistedKernelModules = [
"ax25"
"netrom"
"rose"
"adfs"
"affs"
"bfs"
"befs"
"cramfs"
"efs"
"erofs"
"exofs"
"freevxfs"
"f2fs"
"hfs"
"hpfs"
"jfs"
"minix"
"nilfs2"
"ntfs"
"omfs"
"qnx4"
"qnx6"
"sysv"
"ufs"
];
};
})
# ── medium tier ─────────────────────────────────────────────────────────
(mkIf cfg.medium {
security = {
protectKernelImage = lib.mkDefault true;
# Locking kernel modules breaks runtime module loading. Parameterized
# so hosts that need it (GPU drivers, DKMS) can keep it unlocked.
lockKernelModules = lib.mkDefault (!cfg.allowKernelModuleLoading);
};
boot = {
consoleLogLevel = lib.mkOverride 500 3;
kernel.sysctl = {
"kernel.unprivileged_bpf_disabled" = lib.mkOverride 500 1;
"net.core.bpf_jit_harden" = lib.mkForce 2;
"kernel.yama.ptrace_scope" = lib.mkForce 2;
"kernel.ftrace_enabled" = lib.mkDefault false;
"kernel.randomize_va_space" = lib.mkForce 2;
"fs.suid_dumpable" = lib.mkOverride 500 0;
"kernel.dmesg_restrict" = lib.mkForce 1;
"vm.unprivileged_userfaultfd" = lib.mkForce 0;
"net.ipv4.tcp_syncookies" = lib.mkForce 1;
"net.ipv4.tcp_syn_retries" = lib.mkForce 2;
"net.ipv4.tcp_synack_retries" = lib.mkForce 2;
"net.ipv4.tcp_max_syn_backlog" = lib.mkForce 4096;
"net.ipv4.tcp_rfc1337" = lib.mkForce 1;
};
kernelParams = [
"page_alloc.shuffle=1"
"randomize_kstack_offset=on"
];
};
})
# ── advanced tier ───────────────────────────────────────────────────────
(mkIf cfg.advanced {
security = {
allowSimultaneousMultithreading = lib.mkDefault false;
forcePageTableIsolation = lib.mkDefault true;
virtualisation.flushL1DataCache = lib.mkDefault "always";
apparmor.enable = lib.mkDefault true;
apparmor.killUnconfinedConfinables = lib.mkDefault true;
unprivilegedUsernsClone = config.virtualisation.containers.enable;
};
boot = {
# These five keys overlap the `basic` tier verbatim. When both tiers
# stack on one host, two definitions of the same sysctl collide — so
# let `basic` own them and only apply here when `advanced` is the sole
# tier. This `mkIf (!cfg.basic)` guard is the dedup.
kernel.sysctl = lib.mkIf (!cfg.basic) {
"net.core.bpf_jit_enable" = lib.mkDefault false;
"kernel.sysrq" = lib.mkForce 0;
"net.core.rmem_max" = lib.mkDefault 16777216;
"net.core.wmem_max" = lib.mkDefault 16777216;
"vm.min_free_kbytes" = lib.mkDefault 65536;
};
kernelParams = [
"init_on_alloc=1"
"init_on_free=1"
];
};
})
];
}