crates/nornir-build-thing/src/kconfig/hardened.config
# nordisk HARDENED overlay — a KSPP-style fragment merged ON TOP of
# `base.config` (the vanilla boot contract), for the `hardened` profile of the
# `[[build.kernel]]` kind. Layering, not replacing, is the point: a hardened
# kernel keeps every measured boot-contract option.
#
# Each choice names its COST, because hardening that hides its price gets
# reverted the first time it is paid. `=y` lines are canary-checked to survive
# `olddefconfig`; `is not set` lines are checked not to have crept back in.
#
# MEASURED 2026-09-07 on linux-6.12.104 with the build script's own steps
# (`make ARCH=x86_64 <base>` -> `scripts/kconfig/merge_config.sh -m` ->
# `make olddefconfig` -> the canary loop), on all four presets:
#
# | preset | base | canary refusals |
# |--------|------------|-----------------|
# | normal | defconfig | 0 |
# | full | defconfig | 0 |
# | slim | tinyconfig | 0 |
# | nuked | tinyconfig | 0 |
#
# The tinyconfig columns are zero only WITH a consumer closure merged last
# (tunnr's `variants/kernel/tunnr-hardening.config`): nine of this file's
# directives depend on parents a tinyconfig base answers `n`. That is a
# property of the base, not of this overlay — see that file, which prices each
# of the nine.
# ── the prompts the vetoes below need ──────────────────────────────────────
# Several attack-surface vetoes in this file (IO_URING, USERFAULTFD, DEVMEM,
# PROC_KCORE, KEXEC) are `bool "..." if EXPERT` with `default y`: on a
# non-EXPERT `defconfig` they are not PROMPTED, so `olddefconfig` restores the
# default and the veto silently loses. Measured: without this line the four
# vetoes come back `=y` on `defconfig` and the canary reports four refusals.
# EXPERT only UNHIDES options — it turns nothing on by itself (the same reason
# `minimal.config:33` carries it) — so the cost is zero and the gain is that a
# stated refusal is honoured instead of quietly reverted.
CONFIG_EXPERT=y
# ── memory-corruption defences ─────────────────────────────────────────────
# Bounds-check copy_to/from_user against slab/stack object sizes.
# Cost: a few % on copy-heavy syscall paths; kills a whole exploit class.
CONFIG_HARDENED_USERCOPY=y
# Zero heap allocations by default (init_on_alloc=1 built in).
# Cost: measured low single-digit % alloc-path overhead; removes uninitialized
# heap infoleaks. INIT_ON_FREE is deliberately NOT enabled — its cost is roughly
# double (every kfree pays), and on this constellation's short-lived build/boot
# kernels alloc-zeroing already closes the leak that matters. It is a PRICED
# DECLINE, not an oversight: it survives `olddefconfig` on every preset
# (measured), so a deployment that wants it adds one line in its own closure
# file and pays the kfree cost knowingly.
CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y
# Randomized slab freelists + hardened freelist metadata.
# Cost: noise-level; hardens heap-spray / freelist-hijack primitives.
# Both `depends on !SLUB_TINY` — a tinyconfig appliance that keeps SLUB_TINY
# must veto these in its closure and say what the allocator swap costs.
CONFIG_SLAB_FREELIST_RANDOM=y
CONFIG_SLAB_FREELIST_HARDENED=y
# Randomize page-allocator freelists (defeats page-adjacency grooming).
# Cost: noise-level at the 4-order default.
CONFIG_SHUFFLE_PAGE_ALLOCATOR=y
# Compile-time + runtime bounds checks on the mem*/str* family.
# Cost: compile-time mostly; runtime checks are branch-predictable.
CONFIG_FORTIFY_SOURCE=y
# Strong stack canaries on every function with local arrays.
# Cost: ~1% text size; standard since every distro ships it.
CONFIG_STACKPROTECTOR=y
CONFIG_STACKPROTECTOR_STRONG=y
# Guard-paged, vmalloc'd kernel stacks (overflow faults instead of corrupting).
CONFIG_VMAP_STACK=y
# Per-syscall kernel-stack offset randomization (default-on at boot).
# Cost: <1%; breaks stack-address-reuse exploits. The PARENT is named too:
# `RANDOMIZE_KSTACK_OFFSET` is `bool "..." if EXPERT` and on a tinyconfig base
# allnoconfig answers it `n`, which silently dropped the `_DEFAULT` child.
CONFIG_RANDOMIZE_KSTACK_OFFSET=y
CONFIG_RANDOMIZE_KSTACK_OFFSET_DEFAULT=y
# Zero call-used registers on function return: removes the register-resident
# gadget material a ROP/JOP chain scavenges.
# Cost: measured single-digit % text growth; needs gcc >= 11 / clang >= 15
# (`CC_HAS_ZERO_CALL_USED_REGS`). The toolchain image ships gcc 14.2 and
# clang 19.1, and it survives `olddefconfig` on all four presets.
CONFIG_ZERO_CALL_USED_REGS=y
# BUG() instead of "fix up and continue" on list corruption — corruption is a
# refusal, never a limp. DEBUG_LIST is its detector.
# Cost: list-op branch; the constellation's fail-loud law applied to the kernel.
CONFIG_DEBUG_LIST=y
CONFIG_BUG_ON_DATA_CORRUPTION=y
# An oops is a REFUSAL, not a limp. Without this a kernel that has already lost
# an invariant keeps scheduling the surviving tasks, which is exactly the
# "carry on in an unknown state" the constellation's fail-loud law forbids.
# Cost: an oops now takes the machine down instead of one task — and with the
# appliances' `panic=-1` that means an immediate reboot. That is the intended
# behaviour for an appliance and it is a BEHAVIOUR CHANGE for a bench kernel:
# a debuggable boot passes `oops=0` on the cmdline to get the old behaviour.
CONFIG_PANIC_ON_OOPS=y
# Kernel text read-only + data non-executable (W^X for the kernel's own
# mappings), enforced at boot and not merely a default the arch happens to pick.
# Cost: none at runtime (page-table permissions set once); closes the
# "write a gadget into .text / execute from .data" class outright.
CONFIG_STRICT_KERNEL_RWX=y
# ── CPU-vulnerability mitigations, named rather than inherited ─────────────
# `CPU_MITIGATIONS` is the master switch (`mitigations=off` is its cmdline
# twin); retpoline and page-table isolation are the two that cost measurable
# throughput, which is exactly why a build that wants them must SAY so instead
# of relying on a defconfig default that a `tinyconfig` base does not have.
# Measured: all three are `n` on a tinyconfig base until named here.
# Cost: PTI is the expensive one — syscall-heavy workloads pay double-digit %
# on pre-Meltdown-fixed silicon and ~nothing on hardware with the fix, where
# the kernel disables it at boot anyway. Retpoline is a few % on
# indirect-branch-heavy paths.
# NOTE the 6.12 SPELLING: these were renamed `MITIGATION_*` in 6.9
# (RETPOLINE -> MITIGATION_RETPOLINE, PAGE_TABLE_ISOLATION ->
# MITIGATION_PAGE_TABLE_ISOLATION). The old names are DEAD SYMBOLS on this
# kernel — naming them would be a hardening claim that merges to nothing.
CONFIG_CPU_MITIGATIONS=y
CONFIG_MITIGATION_RETPOLINE=y
CONFIG_MITIGATION_PAGE_TABLE_ISOLATION=y
# ── address-space layout ───────────────────────────────────────────────────
# KASLR: randomize kernel text base and the direct map / vmalloc regions.
# Cost: none at runtime (boot-time shuffle); defconfig usually has these, named
# here so the hardened claim does not depend on a defconfig default.
CONFIG_RANDOMIZE_BASE=y
CONFIG_RANDOMIZE_MEMORY=y
# Refuse mmap below 64KiB (NULL-deref exploitation).
# Cost: none for the constellation (no dosemu/wine workloads).
CONFIG_DEFAULT_MMAP_MIN_ADDR=65536
# No legacy vsyscall page (a fixed-address ROP gadget farm).
# Cost: breaks only pre-2013 glibc static binaries; the constellation has none.
CONFIG_LEGACY_VSYSCALL_NONE=y
# ── syscall sandboxing ─────────────────────────────────────────────────────
# seccomp + the BPF filter mode: the primitive a payload needs to drop the
# syscalls it will never issue again after setup. Compiling it in does NOT
# apply a filter — that is the payload's job — but WITHOUT it the payload
# cannot, and `prctl(PR_SET_SECCOMP)` returns EINVAL.
# Cost: `SECCOMP_FILTER` is a branch on the syscall entry path when no filter
# is installed, i.e. noise. It `depends on NET` (the BPF machinery), which
# every appliance in this constellation has.
# LANDLOCK is deliberately NOT here: `SECURITY_LANDLOCK depends on SECURITY`,
# and `SECURITY depends on MULTIUSER` — a single-user appliance that vetoes
# MULTIUSER cannot have it at any price short of reversing that decision. Said
# in the negative so nobody reads its absence as an oversight.
CONFIG_SECCOMP=y
CONFIG_SECCOMP_FILTER=y
# ── usermode helpers: one door, and it is nailed shut ──────────────────────
# `STATIC_USERMODEHELPER` routes EVERY `call_usermodehelper()` through one
# compiled-in path instead of whatever a writable sysctl (core_pattern,
# modprobe, poweroff_cmd, uevent_helper) names — the classic
# "write a sysctl, get root code execution" primitive. The empty path is the
# documented way to turn helpers off entirely (`kernel/umh.c:398`, `:419`:
# "STATIC_USERMODEHELPER_PATH=\"\" turns all helpers off").
# Cost: NOTHING may spawn a userspace helper from the kernel. Feasible here
# because it is MEASURED: this overlay sets `MODULES=n` (so no `request_module`)
# and `UEVENT_HELPER` is `n` on every preset, which leaves no caller. A
# deployment that reverses `MODULES=n` MUST revisit this line in the same
# commit, or module autoload silently stops working.
CONFIG_STATIC_USERMODEHELPER=y
CONFIG_STATIC_USERMODEHELPER_PATH=""
# ── attack surface that is REMOVED rather than filtered ────────────────────
# Each of these is a syscall or a device an appliance PID 1 does not use, and
# each has been a local-privilege-escalation vehicle. Removing beats filtering:
# there is no policy to misconfigure and no filter to bypass.
#
# /dev/mem: userspace reading kernel RAM. This overlay used to say
# `STRICT_DEVMEM=y` + `IO_STRICT_DEVMEM=y` — a FILTER on the device. That was
# incoherent and the audit was right to file it: `STRICT_DEVMEM depends on MMU
# && DEVMEM` (lib/Kconfig.debug:1866), so on any base where DEVMEM is off the
# filter merges to nothing, and on a base where it is on we were ASKING FOR the
# device in order to filter it. No /dev/mem at all is strictly stronger.
# Cost: no `dd if=/dev/mem`, no `/dev/kmem` debugging. Nothing here does that.
# CONFIG_DEVMEM is not set
# kexec: a second kernel loaded from userspace, i.e. a documented way around
# Secure Boot and any measured-boot chain above it.
# Cost: no crash-kernel / kdump. The appliances reboot (`panic=-1`) instead.
# CONFIG_KEXEC is not set
# /proc/kcore: the whole of kernel memory, readable, defeating KASLR in one
# open(2). Cost: no live kernel debugging with gdb/crash.
# CONFIG_PROC_KCORE is not set
# debugfs: an unversioned, largely unaudited write surface many drivers hang
# privileged knobs off. Cost: no tracing/driver knobs at runtime; a MEASURING
# kernel enables it through `profile.config`, which is where it belongs.
# The veto needs its SELECTOR named as well, and finding that out is the whole
# reason this file is measured rather than reasoned about: on `defconfig`,
# `BLK_DEV_IO_TRACE` (block-layer tracing, on by default) carries
# `select DEBUG_FS` (kernel/trace/Kconfig:648), so `olddefconfig` put debugfs
# straight back and the canary reported it. Nothing selects BLK_DEV_IO_TRACE in
# turn, so vetoing it is enough; it is the same decision (no tracing surface on
# an appliance), only spelled where kconfig can honour it.
# CONFIG_BLK_DEV_IO_TRACE is not set
# CONFIG_DEBUG_FS is not set
# bpf(2): a JIT that compiles attacker-influenced bytecode in ring 0, and a
# recurring CVE source. Nothing in this constellation loads a BPF program.
# Cost: no bpftrace, no XDP, no seccomp-BPF *loading via bpf(2)* — note that
# classic seccomp filters above use `prctl`, not `bpf(2)`, and are unaffected.
# CONFIG_BPF_SYSCALL is not set
# io_uring is the veto this file DOES NOT take, and finding that out is the
# best thing the requirement closure has done. It was vetoed here on
# 2026-09-07 with the comment "nothing here issues io_uring_setup(2)" — and
# `tests/build_dag_gunnar.rs` went red on the next run:
#
# kernel 'tunnr-kernel': a consumer requires what the preset vetoes —
# CONFIG_IO_URING must be `y` for gunnar/gunnar-installer:data.io
# (data.io = uring: the store's writer arm is one linked io_uring_enter
# (blob write -> fsync -> journal write -> fsync)) but the `nuked` preset
# fragment says `n`
#
# So the claim was false: gunnar's installer declares io_uring as a FEATURE of
# its store writer, with a named `safe` fallback. Vetoing it here would have
# silently demoted a consumer's data path to the fallback on every hardened
# kernel in the constellation — a product decision about gunnar, taken in
# edda, by a hardening overlay. That is not this file's call to make.
# A deployment that wants io_uring gone drops `data.io = uring` from the
# consumer and vetoes the symbol in its OWN closure file, in one commit, where
# the cost lands on the thing that pays it.
# userfaultfd: userspace handling of kernel page faults, the standard tool for
# winning kernel use-after-free races by stalling a fault at will.
# Cost: no post-copy live migration of a guest that uses it. Not a workload
# here — the appliances ARE the guests.
# CONFIG_USERFAULTFD is not set
# ── LSM: lockdown ──────────────────────────────────────────────────────────
# The lockdown LSM, registered early. NOT forced to a mode at build time: the
# cmdline chooses (lockdown=integrity|confidentiality), so one artifact serves
# both a measured-boot appliance and a debuggable bench boot. Forcing
# LOCK_DOWN_KERNEL_FORCE_* here would cost kexec/bpf-writes/serial-side
# debugging on EVERY boot of the artifact — a per-boot decision, priced there.
# A consumer that vetoes `MULTIUSER` gets NONE of these four (`SECURITY depends
# on MULTIUSER`, security/Kconfig:57) and must say so in its closure — a
# `lockdown=` on the cmdline of such a kernel is a claim with no enforcer.
CONFIG_SECURITY=y
CONFIG_SECURITYFS=y
CONFIG_SECURITY_LOCKDOWN_LSM=y
CONFIG_SECURITY_LOCKDOWN_LSM_EARLY=y
# Yama: ptrace scoping (a process may trace only its descendants).
# Cost: breaks casual `gdb -p` of unrelated processes; that is the feature.
CONFIG_SECURITY_YAMA=y
# ── modules: OFF, and why that is feasible here ────────────────────────────
# MODULES=n is the strongest module-signing policy there is: no loader, no
# signature to forge. Feasible because it is MEASURED for this constellation —
# the appliances carry initramfs userspace with ZERO modules ("CONFIG_TUN=y is
# builtin and is all that is needed"; the wg data plane is userspace boringtun),
# and every option the boot contract needs is =y in base.config.
# Cost: everything not built in is GONE — a deployment that must load modules
# flips this to CONFIG_MODULES=y + CONFIG_MODULE_SIG_FORCE=y (+ a signing key),
# which is the documented fallback, not the default. See STATIC_USERMODEHELPER
# above: that flip has to move two lines, not one.
# CONFIG_MODULES is not set
# ── what this overlay does NOT buy, priced so the absence is a decision ────
# CFI_CLANG (forward-edge control-flow integrity) — MEASURED REFUSAL: it
# `depends on CLANG_VERSION >= ... && LTO_CLANG`, and a `toolchain = "gcc"`
# declaration cannot have it. Naming it here would break every gcc row's
# canary (it was one of four refusals in the 2026-09-07 probe). It belongs on
# a clang+LTO row's own closure file, not in a toolchain-agnostic overlay.
# RANDSTRUCT_FULL and GCC_PLUGIN_LATENT_ENTROPY — same shape, same probe, same
# refusal: both need a plugin/compiler feature the image does not build with.
# DEBUG_CREDENTIALS — the symbol was REMOVED from Linux in 6.11. Asking for it
# on 6.12 is a hardening claim that merges to nothing; it is named here only
# so the next reader does not re-add it.