nixpkgs/nixos/tests/systemd-confinement/default.nix
aszlig 51d3f3475c
nixos/tests/confinement: Run test probes in Python
So far the architecture for the tests was that we would use a systemd
socket unit using the Accept option to start a small shell process where
we can pipe commands into by connecting to the socket created by the
socket unit.

This is unnecessary since we can directly use the code snippets from the
individual subtests and systemd will take care of checking the return
code in case we get any assertions[^1].

Another advantage of this is that tests now run in parallel, so we can
do rather expensive things such as looking in /nix to see whether
anything is writable.

The new assert_permissions() function is the main driver behind this and
allows for a more fine-grained way to check whether we got the right
permissions whilst also ignoring irrelevant things such as read-only
empty directories.

Our previous approach also just did a read-only check, which might be
fine in full-apivfs mode where the attack surface already is large, but
in chroot-only mode we really want to make sure nothing is every
writable.

A downside of the new approach is that currently the unit names are
numbered via lib.imap1, which makes it annoying to track its definition.

[^1]: Speaking of assertions, I wrapped the code to be run with pytest's
      assertion rewriting, so that we get more useful AssertionErrors.

Signed-off-by: aszlig <aszlig@nix.build>
2024-05-13 00:40:36 +02:00

335 lines
11 KiB
Nix

import ../make-test-python.nix {
name = "systemd-confinement";
nodes.machine = { pkgs, lib, ... }: let
testLib = pkgs.python3Packages.buildPythonPackage {
name = "confinement-testlib";
unpackPhase = ''
cat > setup.py <<EOF
from setuptools import setup
setup(name='confinement-testlib', py_modules=["checkperms"])
EOF
cp ${./checkperms.py} checkperms.py
'';
};
mkTest = name: testScript: pkgs.writers.writePython3 "${name}.py" {
libraries = [ pkgs.python3Packages.pytest testLib ];
} ''
# This runs our test script by using pytest's assertion rewriting, so
# that whenever we use "assert <something>", the actual values are
# printed rather than getting a generic AssertionError or the need to
# pass an explicit assertion error message.
import ast
from pathlib import Path
from _pytest.assertion.rewrite import rewrite_asserts
script = Path('${pkgs.writeText "${name}-main.py" ''
import errno, os, pytest, signal
from subprocess import run
from checkperms import Accessibility, assert_permissions
${testScript}
''}') # noqa
filename = str(script)
source = script.read_bytes()
tree = ast.parse(source, filename=filename)
rewrite_asserts(tree, source, filename)
exec(compile(tree, filename, 'exec', dont_inherit=True))
'';
mkTestStep = num: {
description,
testScript,
config ? {},
serviceName ? "test${toString num}",
rawUnit ? null,
}: {
systemd.packages = lib.optional (rawUnit != null) (pkgs.writeTextFile {
name = serviceName;
destination = "/etc/systemd/system/${serviceName}.service";
text = rawUnit;
});
systemd.services.${serviceName} = {
inherit description;
requiredBy = [ "multi-user.target" ];
confinement = (config.confinement or {}) // { enable = true; };
serviceConfig = (config.serviceConfig or {}) // {
ExecStart = mkTest serviceName testScript;
Type = "oneshot";
};
} // removeAttrs config [ "confinement" "serviceConfig" ];
};
in {
imports = lib.imap1 mkTestStep [
{ description = "chroot-only confinement";
config.confinement.mode = "chroot-only";
testScript = ''
assert_permissions({
'bin': Accessibility.WRITABLE,
'nix': Accessibility.WRITABLE,
'run': Accessibility.WRITABLE,
})
assert os.getuid() == 0
os.chown('/bin', 65534, 0)
'';
}
{ description = "full confinement with APIVFS";
testScript = ''
Path('/etc').rmdir()
assert_permissions({
'bin': Accessibility.WRITABLE,
'nix': Accessibility.WRITABLE,
'tmp': Accessibility.WRITABLE,
'run': Accessibility.WRITABLE,
'proc': Accessibility.SPECIAL,
'sys': Accessibility.SPECIAL,
'dev': Accessibility.WRITABLE,
})
bin_gid = Path('/bin').stat().st_gid
with pytest.raises(OSError) as excinfo:
os.chown('/bin', 65534, bin_gid)
assert excinfo.value.errno == errno.EINVAL
assert os.getuid() == 0
os.chown('/bin', 0, 0)
'';
}
{ description = "check existence of bind-mounted /etc";
config.serviceConfig.BindReadOnlyPaths = [ "/etc" ];
testScript = ''
assert Path('/etc/passwd').read_text()
'';
}
{ description = "check if User/Group really runs as non-root";
config.serviceConfig.User = "chroot-testuser";
config.serviceConfig.Group = "chroot-testgroup";
testScript = ''
assert list(Path('/dev').iterdir())
assert os.getuid() != 0
assert os.getgid() != 0
with pytest.raises(PermissionError):
Path('/bin/test').touch()
'';
}
{ description = "check if DynamicUser is working in full-apivfs mode";
config.confinement.mode = "full-apivfs";
config.serviceConfig.DynamicUser = true;
testScript = ''
assert_permissions({
'bin': Accessibility.READABLE,
'nix': Accessibility.READABLE,
'tmp': Accessibility.WRITABLE,
'run': Accessibility.STICKY,
'proc': Accessibility.SPECIAL,
'sys': Accessibility.SPECIAL,
'dev': Accessibility.SPECIAL,
'dev/shm': Accessibility.STICKY,
'dev/mqueue': Accessibility.STICKY,
'var': Accessibility.READABLE,
'var/tmp': Accessibility.WRITABLE,
})
assert os.getuid() != 0
assert os.getgid() != 0
with pytest.raises(OSError) as excinfo:
Path('/bin/test').touch()
assert excinfo.value.errno == errno.EROFS
with pytest.raises(OSError) as excinfo:
Path('/etc/test').touch()
assert excinfo.value.errno == errno.EROFS
'';
}
{ description = "check if DynamicUser and PrivateTmp=false are working in full-apivfs mode";
config.confinement.mode = "full-apivfs";
config.serviceConfig.DynamicUser = true;
config.serviceConfig.PrivateTmp = false;
testScript = ''
assert_permissions({
'bin': Accessibility.READABLE,
'nix': Accessibility.READABLE,
'run': Accessibility.STICKY,
'proc': Accessibility.SPECIAL,
'sys': Accessibility.SPECIAL,
'dev': Accessibility.SPECIAL,
'dev/shm': Accessibility.STICKY,
'dev/mqueue': Accessibility.STICKY,
})
assert os.getuid() != 0
assert os.getgid() != 0
with pytest.raises(OSError) as excinfo:
Path('/bin/test').touch()
assert excinfo.value.errno == errno.EROFS
with pytest.raises(OSError) as excinfo:
Path('/etc/test').touch()
assert excinfo.value.errno == errno.EROFS
'';
}
{ description = "check if DynamicUser is working in chroot-only mode";
config.confinement.mode = "chroot-only";
config.serviceConfig.DynamicUser = true;
testScript = ''
assert_permissions({
'bin': Accessibility.READABLE,
'nix': Accessibility.READABLE,
'run': Accessibility.READABLE,
})
assert os.getuid() != 0
assert os.getgid() != 0
with pytest.raises(OSError) as excinfo:
Path('/bin/test').touch()
assert excinfo.value.errno == errno.EROFS
'';
}
{ description = "check if DynamicUser and PrivateTmp=true are working in chroot-only mode";
config.confinement.mode = "chroot-only";
config.serviceConfig.DynamicUser = true;
config.serviceConfig.PrivateTmp = true;
testScript = ''
assert_permissions({
'bin': Accessibility.READABLE,
'nix': Accessibility.READABLE,
'run': Accessibility.READABLE,
'tmp': Accessibility.WRITABLE,
'var': Accessibility.READABLE,
'var/tmp': Accessibility.WRITABLE,
})
assert os.getuid() != 0
assert os.getgid() != 0
with pytest.raises(OSError) as excinfo:
Path('/bin/test').touch()
assert excinfo.value.errno == errno.EROFS
'';
}
(let
symlink = pkgs.runCommand "symlink" {
target = pkgs.writeText "symlink-target" "got me";
} "ln -s \"$target\" \"$out\"";
in {
description = "check if symlinks are properly bind-mounted";
config.confinement.packages = lib.singleton symlink;
testScript = ''
Path('/etc').rmdir()
assert Path('${symlink}').read_text() == 'got me'
'';
})
{ description = "check if StateDirectory works";
config.serviceConfig.User = "chroot-testuser";
config.serviceConfig.Group = "chroot-testgroup";
config.serviceConfig.StateDirectory = "testme";
# We restart on purpose here since we want to check whether the state
# directory actually persists.
config.serviceConfig.Restart = "on-failure";
config.serviceConfig.RestartMode = "direct";
testScript = ''
assert not Path('/tmp/canary').exists()
Path('/tmp/canary').touch()
if (foo := Path('/var/lib/testme/foo')).exists():
assert Path('/var/lib/testme/foo').read_text() == 'works'
else:
Path('/var/lib/testme/foo').write_text('works')
print('<4>Exiting with failure to check persistence on restart.')
raise SystemExit(1)
'';
}
{ description = "check if /bin/sh works";
testScript = ''
assert Path('/bin/sh').exists()
result = run(
['/bin/sh', '-c', 'echo -n bar'],
capture_output=True,
check=True,
)
assert result.stdout == b'bar'
'';
}
{ description = "check if suppressing /bin/sh works";
config.confinement.binSh = null;
testScript = ''
assert not Path('/bin/sh').exists()
with pytest.raises(FileNotFoundError):
run(['/bin/sh', '-c', 'echo foo'])
'';
}
{ description = "check if we can set /bin/sh to something different";
config.confinement.binSh = "${pkgs.hello}/bin/hello";
testScript = ''
assert Path('/bin/sh').exists()
result = run(
['/bin/sh', '-g', 'foo'],
capture_output=True,
check=True,
)
assert result.stdout == b'foo\n'
'';
}
{ description = "check if only Exec* dependencies are included";
config.environment.FOOBAR = pkgs.writeText "foobar" "eek";
testScript = ''
with pytest.raises(FileNotFoundError):
Path(os.environ['FOOBAR']).read_text()
'';
}
{ description = "check if fullUnit includes all dependencies";
config.environment.FOOBAR = pkgs.writeText "foobar" "eek";
config.confinement.fullUnit = true;
testScript = ''
assert Path(os.environ['FOOBAR']).read_text() == 'eek'
'';
}
{ description = "check if shipped unit file still works";
config.confinement.mode = "chroot-only";
rawUnit = ''
[Service]
SystemCallFilter=~kill
SystemCallErrorNumber=ELOOP
'';
testScript = ''
with pytest.raises(OSError) as excinfo:
os.kill(os.getpid(), signal.SIGKILL)
assert excinfo.value.errno == errno.ELOOP
'';
}
];
config.users.groups.chroot-testgroup = {};
config.users.users.chroot-testuser = {
isSystemUser = true;
description = "Chroot Test User";
group = "chroot-testgroup";
};
};
testScript = ''
machine.wait_for_unit("multi-user.target")
'';
}