diff --git a/charts/cozystack/templates/_helpers.tpl b/charts/cozystack/templates/_helpers.tpl index 5093556..38dc501 100644 --- a/charts/cozystack/templates/_helpers.tpl +++ b/charts/cozystack/templates/_helpers.tpl @@ -165,6 +165,29 @@ machine: {{- end }} {{- (include "talm.discovered.disks_info" .) | nindent 4 }} disk: {{ include "talm.discovered.system_disk_name" . | quote }} + {{- /* CVE-2026-53359: disable KVM nested virtualization (guest-to-host + escape mitigation). kvm_intel/kvm_amd are built into the Talos + kernel, so the nested= parameter takes effect only from the + kernel command line — a modprobe.d drop-in or a runtime /sys + write cannot set it (the sysfs knob is read-only post-boot). + Both modules are listed so one config works on Intel and AMD; + the non-matching module silently ignores its arg. + + On Talos >1.11 the generated base config defaults + machine.install.grubUseUKICmdline to true (UKI cmdline), and + Talos rejects extraKernelArgs alongside it ("install.extraKernelArgs + and install.grubUseUKICmdline can't be used together"). Pin it + false so the args land on the Talos-built cmdline. The guard + matches the base default exactly: emit the field only where the + base bundle would set it true, so we never surface a key on a + node whose schema predates grubUseUKICmdline. */ -}} + {{- if or (not .TalosVersion) (not (semverCompare "<1.12.0-0" .TalosVersion)) }} + grubUseUKICmdline: false + {{- end }} + extraKernelArgs: + # CVE-2026-53359: disable KVM nested virtualization (guest-to-host escape mitigation) + - kvm_intel.nested=0 + - kvm_amd.nested=0 {{- end }} {{- /* Shared cluster section */ -}} diff --git a/charts/generic/templates/_helpers.tpl b/charts/generic/templates/_helpers.tpl index 9bfb11f..748c437 100644 --- a/charts/generic/templates/_helpers.tpl +++ b/charts/generic/templates/_helpers.tpl @@ -58,6 +58,29 @@ machine: install: {{- (include "talm.discovered.disks_info" .) | nindent 4 }} disk: {{ include "talm.discovered.system_disk_name" . | quote }} + {{- /* CVE-2026-53359: disable KVM nested virtualization (guest-to-host + escape mitigation). kvm_intel/kvm_amd are built into the Talos + kernel, so the nested= parameter takes effect only from the + kernel command line — a modprobe.d drop-in or a runtime /sys + write cannot set it (the sysfs knob is read-only post-boot). + Both modules are listed so one config works on Intel and AMD; + the non-matching module silently ignores its arg. + + On Talos >1.11 the generated base config defaults + machine.install.grubUseUKICmdline to true (UKI cmdline), and + Talos rejects extraKernelArgs alongside it ("install.extraKernelArgs + and install.grubUseUKICmdline can't be used together"). Pin it + false so the args land on the Talos-built cmdline. The guard + matches the base default exactly: emit the field only where the + base bundle would set it true, so we never surface a key on a + node whose schema predates grubUseUKICmdline. */ -}} + {{- if or (not .TalosVersion) (not (semverCompare "<1.12.0-0" .TalosVersion)) }} + grubUseUKICmdline: false + {{- end }} + extraKernelArgs: + # CVE-2026-53359: disable KVM nested virtualization (guest-to-host escape mitigation) + - kvm_intel.nested=0 + - kvm_amd.nested=0 {{- end }} {{- /* Shared cluster section */ -}} diff --git a/pkg/engine/contract_machine_kvmnested_test.go b/pkg/engine/contract_machine_kvmnested_test.go new file mode 100644 index 0000000..d04d7d8 --- /dev/null +++ b/pkg/engine/contract_machine_kvmnested_test.go @@ -0,0 +1,160 @@ +// Copyright Cozystack Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Contract: CVE-2026-53359 KVM-nested-virtualization mitigation in the +// cozystack and generic presets. kvm_intel/kvm_amd are built into the +// Talos kernel, so nested= is settable only from the kernel command +// line; both presets pin it via machine.install.extraKernelArgs. +// +// On Talos >1.11 the generated base config defaults +// machine.install.grubUseUKICmdline to true (UKI cmdline), and Talos +// rejects extraKernelArgs alongside it. The presets therefore pin +// grubUseUKICmdline:false wherever the base would set it true. The +// full-pipeline test below renders through engine.Render (base bundle + +// preset patch) and runs Talos's own validation to prove the merged +// config the operator applies does not trip the UKI conflict. + +package engine + +import ( + "context" + "strings" + "testing" + + helmEngine "github.com/cozystack/talm/pkg/engine/helm" + "github.com/siderolabs/talos/pkg/machinery/config/configloader" +) + +// kvmNestedRuntimeMode is a minimal validation.RuntimeMode implementation. +// RequiresInstall() true selects the install-mode validation branch that +// enforces the extraKernelArgs/grubUseUKICmdline mutual exclusion. +type kvmNestedRuntimeMode struct{ requiresInstall bool } + +func (m kvmNestedRuntimeMode) String() string { return "kvm-nested-contract" } +func (m kvmNestedRuntimeMode) RequiresInstall() bool { return m.requiresInstall } +func (kvmNestedRuntimeMode) InContainer() bool { return false } + +// Contract: both presets always emit the KVM nested-virt kernel args +// under machine.install, for every chart × schema × machineType cell. +// A regression that drops them silently re-exposes the guest-to-host +// escape on every node the preset installs. +func TestContract_Machine_Install_KVMNestedArgs(t *testing.T) { + for _, cell := range allCells() { + t.Run(cell.name, func(t *testing.T) { + out := renderChartTemplate(t, cell.chartPath, cell.templateFile, cell.talosVersion) + assertContains(t, out, "extraKernelArgs:") + assertContains(t, out, "kvm_intel.nested=0") + assertContains(t, out, "kvm_amd.nested=0") + }) + } +} + +// Contract: grubUseUKICmdline:false is emitted exactly where the +// generated base config would default it to true — i.e. when no +// --talos-version is pinned (the bundle falls back to the current +// contract, >1.11) or when the pinned version is >=1.12. For an +// explicit pre-1.12 version the field is omitted, both because the base +// does not set it and because older Talos schemas reject the key. +func TestContract_Machine_Install_GrubUKICmdlineGate(t *testing.T) { + for _, tc := range []struct { + talosVersion string + wantField bool + }{ + {"", true}, // empty → base uses current contract (>1.11) → pin false + {"v1.12.0", true}, // >=1.12 → base defaults true → pin false + {"v1.13.0", true}, // >=1.12 → base defaults true → pin false + {"v1.11.0", false}, // <1.12 → base leaves it unset → do not emit + } { + name := tc.talosVersion + if name == "" { + name = "empty" + } + t.Run(name, func(t *testing.T) { + out := renderChartTemplate(t, cozystackChartPath, controlplaneTpl, tc.talosVersion) + if tc.wantField { + assertContains(t, out, "grubUseUKICmdline: false") + } else { + assertNotContains(t, out, "grubUseUKICmdline") + } + }) + } +} + +// Contract: the FULL config an operator applies — base bundle merged +// with the preset patch, exactly what `talm apply` serialises (Full: +// true) — passes Talos validation without the UKI/extraKernelArgs +// conflict, for both presets and both machine types on v1.12+. +// +// This is the regression guard for the whole mitigation: the raw Helm +// output above never carries grubUseUKICmdline (the base bundle injects +// it), so only a full-pipeline render surfaces the collision. Offline +// discovery leaves install.disk empty, which trips a separate +// validation error, so the assertion is scoped to the UKI conflict +// specifically rather than "no errors at all". +func TestContract_Machine_Install_FullConfigValidates_NoUKIConflict(t *testing.T) { + // Offline render still consults the Helm `lookup`; reset it to the + // empty stub so no live discovery is attempted. + helmEngine.LookupFunc = func(string, string, string) (map[string]any, error) { + return map[string]any{}, nil + } + + const ukiConflict = "grubUseUKICmdline" + + cells := []struct { + name string + chartPath string + templateFile string + }{ + {"cozystack/controlplane", cozystackChartPath, controlplaneTpl}, + {"cozystack/worker", cozystackChartPath, workerTpl}, + {"generic/controlplane", genericChartPath, controlplaneTpl}, + {"generic/worker", genericChartPath, workerTpl}, + } + + for _, cell := range cells { + t.Run(cell.name, func(t *testing.T) { + out, err := Render(context.Background(), nil, Options{ + Offline: true, + Full: true, + Root: cell.chartPath, + TalosVersion: "v1.12.0", + TemplateFiles: []string{cell.templateFile}, + Values: []string{ + "endpoint=" + testEndpoint, + "advertisedSubnets={" + testAdvertisedSubnet + "}", + }, + }) + if err != nil { + t.Fatalf("full render failed: %v", err) + } + + got := string(out) + assertContains(t, got, "grubUseUKICmdline: false") + assertContains(t, got, "kvm_intel.nested=0") + if strings.Contains(got, "grubUseUKICmdline: true") { + t.Fatalf("full config still carries grubUseUKICmdline: true — UKI cmdline not disabled:\n%s", got) + } + + cfg, lerr := configloader.NewFromBytes(out) + if lerr != nil { + t.Fatalf("config loader rejected the rendered config: %v", lerr) + } + + _, verr := cfg.Validate(kvmNestedRuntimeMode{requiresInstall: true}) + if verr != nil && strings.Contains(verr.Error(), ukiConflict) { + t.Fatalf("Talos validation reports the UKI/extraKernelArgs conflict — apply would fail: %v", verr) + } + }) + } +}