Skip to content

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 //:

config = alwaysBlock // (mkIf cfg.basic { ... }) // (mkIf cfg.advanced { ... });

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 basic and advanced. They live in basic; the advanced block only sets them lib.mkIf (!cfg.basic), i.e. "only when basic isn't already providing them." One owner, no collision.

  • Hoist to the always-block as a tri-state. kernel.kptr_restrict would 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

  • advanced has 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.
  • medium locks kernel modules (security.lockKernelModules). Any host that loads modules after boot — proprietary GPU drivers, DKMS, some virtualization stacks — must set hardening.allowKernelModuleLoading = true or 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"
        ];
      };
    })
  ];
}