diff --git a/boot/Taskfile.yml b/boot/Taskfile.yml index f7280a8..df58bed 100644 --- a/boot/Taskfile.yml +++ b/boot/Taskfile.yml @@ -69,6 +69,15 @@ tasks: cmds: - SPIN_LOGIND_TEST=1 go test ./boot/ -run '^TestLogindSessions$' -count=1 -v -timeout 5m + lockdown: + desc: >- + Check the guest kernel runs lockdown at confidentiality and the BPF LSM, and has no + /dev/mem or /proc/kcore, in a disposable guest. Needs a built release; uses KVM when + available, otherwise TCG. + deps: [':tools'] + cmds: + - SPIN_LOCKDOWN_TEST=1 go test ./boot/ -run '^TestTheGuestKernelIsLockedDown$' -count=1 -v -timeout 5m + initcalls: desc: >- Where the kernel's own boot goes, initcall by initcall, as a p50 over REPS boots. diff --git a/boot/console_test.go b/boot/console_test.go new file mode 100644 index 0000000..d1ecc48 --- /dev/null +++ b/boot/console_test.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 + +package boot_test + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/spin-stack/spin-machine/machine" +) + +// checkOnConsole boots a disposable 1 GiB guest of rel on root, whose unit prints ok or failed on +// the serial console, and fails the test unless ok comes first. It returns what the console said. +// Under TCG where there is no /dev/kvm: what it checks is behaviour, not time. +func checkOnConsole(t *testing.T, out string, rel *machine.Release, root *rawRoot, ok, failed string) string { + t.Helper() + spec := rel.Spec() + spec.BootCPUs = 2 + spec.Memory.SizeMB = 1024 + spec.Disks = []machine.Disk{{Path: root.path, Format: "raw"}} + spec.Serial = "stdio" + c := machine.DefaultCmdline() + c.Root = "/dev/vda" + c.Init = "/sbin/init" + spec.Cmdline = c + args, err := spec.Args() + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat("/dev/kvm"); err != nil { + spec.QEMU = filepath.Join(out, "bin/qemu-system-x86_64-tcg") + for i := range args { + if args[i] == "-accel" { + args[i+1] = "tcg" + } + if strings.HasPrefix(args[i], "host,migratable=on") { + args[i] = "max" + strings.TrimPrefix(args[i], "host") + } + } + t.Log("TCG: checking functionality only") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, spec.QEMU, args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { + cancel() + _ = cmd.Wait() // killed by the cancel above; the console is the result + }() + var console bytes.Buffer + buf := make([]byte, 4096) + for { + n, err := stdout.Read(buf) + console.Write(buf[:n]) + if strings.Contains(console.String(), ok) { + return console.String() + } + if err != nil || strings.Contains(console.String(), failed) { + t.Fatalf("guest check failed: %v\n%s\n%s", err, &console, &stderr) + } + } +} + +// oneshotAtBoot has root run script as a oneshot unit three seconds after boot, its output on +// the console. +func oneshotAtBoot(t *testing.T, root *rawRoot, name, script, env string) { + t.Helper() + content, err := os.ReadFile(filepath.Join("testdata", script)) + if err != nil { + t.Fatal(err) + } + root.write("/"+script, string(content)) + root.write("/etc/systemd/system/"+name+".service", `[Unit] +Description=A check in a disposable guest +After=multi-user.target +[Service] +Type=oneshot +`+env+` +ExecStart=/bin/sh /`+script+` +StandardOutput=journal+console +StandardError=journal+console +`) + root.write("/etc/systemd/system/"+name+".timer", `[Timer] +OnBootSec=3s +AccuracySec=100ms +`) + root.link("/etc/systemd/system/timers.target.wants/"+name+".timer", "/etc/systemd/system/"+name+".timer") +} diff --git a/boot/lockdown_test.go b/boot/lockdown_test.go new file mode 100644 index 0000000..d01dc5f --- /dev/null +++ b/boot/lockdown_test.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 + +package boot_test + +import ( + "os" + "testing" + + "github.com/spin-stack/spin-machine/machine" +) + +// The guest's root is not the kernel's: the kernel runs lockdown at confidentiality and the BPF +// LSM, and has no /dev/mem and no /proc/kcore. A consumer that enforces policy in the guest +// with BPF LSM programs relies on root being unable to read or rewrite the kernel they live in; +// kernel/Dockerfile fails a build whose config lost these, and this asks the kernel that boots. +func TestTheGuestKernelIsLockedDown(t *testing.T) { + if os.Getenv("SPIN_LOCKDOWN_TEST") != "1" { + t.Skip("set SPIN_LOCKDOWN_TEST=1 to check the guest kernel's lockdown in a built image") + } + out := releaseTree(t) + rel, err := machine.OpenRelease(out) + if err != nil { + t.Fatal(err) + } + base, err := rel.Rootfs() + if err != nil { + t.Fatal(err) + } + root := newRawRoot(t, out, base) + oneshotAtBoot(t, root, "lockdown-check", "lockdown-check.sh", "") + t.Log(checkOnConsole(t, out, rel, root, "LOCKDOWN_CHECK_OK", "LOCKDOWN_CHECK_FAILED")) +} diff --git a/boot/logind_test.go b/boot/logind_test.go index e9f1ba6..0d0b310 100644 --- a/boot/logind_test.go +++ b/boot/logind_test.go @@ -3,14 +3,8 @@ package boot_test import ( - "bytes" - "context" "os" - "os/exec" - "path/filepath" - "strings" "testing" - "time" "github.com/spin-stack/spin-machine/machine" ) @@ -45,86 +39,11 @@ func TestLogindSessions(t *testing.T) { if os.Getenv("SPIN_LOGIND_NO_SEATS") == "1" { root.remove("/etc/systemd/system/systemd-logind-varlink.socket.d/10-seats.conf") } - for _, name := range []string{"logind-check.sh", "logind-session.sh"} { - content, err := os.ReadFile(filepath.Join("testdata", name)) - if err != nil { - t.Fatal(err) - } - root.write("/"+name, string(content)) - } - root.write("/etc/systemd/system/logind-check.service", `[Unit] -Description=Check on-demand sessions in a disposable guest -After=multi-user.target -[Service] -Type=oneshot -Environment=LOGIND_EXPECT=`+expectedState+` -ExecStart=/bin/sh /logind-check.sh -StandardOutput=journal+console -StandardError=journal+console -`) - root.write("/etc/systemd/system/logind-check.timer", `[Timer] -OnBootSec=3s -AccuracySec=100ms -`) - root.link("/etc/systemd/system/timers.target.wants/logind-check.timer", "/etc/systemd/system/logind-check.timer") - spec := rel.Spec() - spec.BootCPUs = 2 - spec.Memory.SizeMB = 1024 - spec.Disks = []machine.Disk{{Path: root.path, Format: "raw"}} - spec.Serial = "stdio" - c := machine.DefaultCmdline() - c.Root = "/dev/vda" - c.Init = "/sbin/init" - spec.Cmdline = c - args, err := spec.Args() - if err != nil { - t.Fatal(err) - } - if _, err := os.Stat("/dev/kvm"); err != nil { - spec.QEMU = filepath.Join(out, "bin/qemu-system-x86_64-tcg") - for i := range args { - if args[i] == "-accel" { - args[i+1] = "tcg" - } - if strings.HasPrefix(args[i], "host,migratable=on") { - args[i] = "max" + strings.TrimPrefix(args[i], "host") - } - } - t.Log("TCG: checking functionality only") - } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - cmd := exec.CommandContext(ctx, spec.QEMU, args...) - var stderr bytes.Buffer - cmd.Stderr = &stderr - stdout, err := cmd.StdoutPipe() + content, err := os.ReadFile("testdata/logind-session.sh") if err != nil { t.Fatal(err) } - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - waited := false - stop := func() { - cancel() - if !waited { - _ = cmd.Wait() - waited = true - } - } - defer stop() - var console bytes.Buffer - buf := make([]byte, 4096) - for { - n, err := stdout.Read(buf) - console.Write(buf[:n]) - if strings.Contains(console.String(), "LOGIND_CHECK_OK") { - t.Log(console.String()) - return - } - if err != nil || strings.Contains(console.String(), "LOGIND_CHECK_FAILED") { - stop() - t.Fatalf("guest session check failed: %v\n%s\n%s", err, &console, &stderr) - } - } + root.write("/logind-session.sh", string(content)) + oneshotAtBoot(t, root, "logind-check", "logind-check.sh", "Environment=LOGIND_EXPECT="+expectedState) + t.Log(checkOnConsole(t, out, rel, root, "LOGIND_CHECK_OK", "LOGIND_CHECK_FAILED")) } diff --git a/boot/testdata/lockdown-check.sh b/boot/testdata/lockdown-check.sh new file mode 100644 index 0000000..29c9b03 --- /dev/null +++ b/boot/testdata/lockdown-check.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 +set -eu +trap 'echo LOCKDOWN_CHECK_FAILED' EXIT + +# The LSMs the kernel runs, in its own words, and the lockdown level it enforces: the brackets +# mark the one in force. +echo "lsm: $(cat /sys/kernel/security/lsm)" +echo "lockdown: $(cat /sys/kernel/security/lockdown)" +# capability is always there, whatever CONFIG_LSM says: the kernel puts it first among the rest. +test "$(cat /sys/kernel/security/lsm)" = "lockdown,capability,bpf" +grep -q '\[confidentiality\]' /sys/kernel/security/lockdown + +# Root cannot read the kernel's memory, nor lower the level. +test ! -e /dev/mem +test ! -e /proc/kcore +if echo none > /sys/kernel/security/lockdown 2>/dev/null; then + echo "root lowered lockdown" + exit 1 +fi +grep -q '\[confidentiality\]' /sys/kernel/security/lockdown + +trap - EXIT +echo LOCKDOWN_CHECK_OK diff --git a/kernel/Dockerfile b/kernel/Dockerfile index 78f6ed8..18cfa05 100644 --- a/kernel/Dockerfile +++ b/kernel/Dockerfile @@ -230,6 +230,21 @@ RUN <