diff --git a/forgetest/tests/test_image.py b/forgetest/tests/test_image.py index 55ee8e1..1fce39e 100644 --- a/forgetest/tests/test_image.py +++ b/forgetest/tests/test_image.py @@ -233,5 +233,86 @@ class PackLicensesTests(unittest.TestCase): self.assertFalse(os.path.exists(self.tree)) +class RootfsLintTests(unittest.TestCase): + """rootfs-lint.sh, the image's last rootfs step, on a fixture tree: a + tree that holds every rule passes, and a drift fails the lint naming + its rule.""" + + SSHD = ("Include /etc/ssh/sshd_config.d/*.conf\n" + "PermitRootLogin no\n" + "PasswordAuthentication yes\n" + "PermitEmptyPasswords no\n" + "Subsystem\tsftp\t/usr/libexec/sftp-server\n") + + def setUp(self): + self.root = tempfile.mkdtemp(prefix="forgetest-lint.") + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) + self.put("usr/sbin/rsyslogd", "", mode=0o755) + self.put("etc/init.d/syslog", "#!/bin/sh\nRSYSLOGD=rsyslogd\nRSYSLOGD_BIN=/usr/sbin/rsyslogd\n", mode=0o755) + os.makedirs(self.path("etc/rc5.d")) + os.symlink("../init.d/syslog", self.path("etc/rc5.d/S20syslog")) + self.put("etc/ssh/sshd_config", self.SSHD) + self.put("etc/ssh/sshd_config_readonly", self.SSHD) + self.put("etc/network/interfaces", "auto lo\niface lo inet loopback\n" + NetworkBootTests.STANZA) + self.put("etc/nsswitch.conf", "hosts: files dns\n") + + def path(self, rel): + return os.path.join(self.root, rel) + + def put(self, rel, text, mode=0o644): + os.makedirs(os.path.dirname(self.path(rel)), exist_ok=True) + with open(self.path(rel), "w") as f: + f.write(text) + os.chmod(self.path(rel), mode) + + def lint(self, kind="release"): + return subprocess.run(["sh", os.path.join(LAYER, "images", "rootfs-lint.sh"), self.root, kind], + capture_output=True, text=True, timeout=60) + + def assertFails(self, rule, kind="release"): + p = self.lint(kind) + self.assertEqual(p.returncode, 1, p.stderr) + self.assertIn("rootfs-lint: %s:" % rule, p.stderr) + + def test_a_tree_that_holds_every_rule_passes(self): + for kind in ("release", "dev"): + p = self.lint(kind) + self.assertEqual(p.returncode, 0, "%s: %s" % (kind, p.stderr)) + + def test_the_layer_interfaces_file_sends_the_name(self): + with open(BSP_INTERFACES, encoding="utf-8") as f: + self.put("etc/network/interfaces", f.read()) + p = self.lint() + self.assertEqual(p.returncode, 0, p.stderr) + + def test_busybox_syslogd_beside_rsyslog_fails(self): + os.makedirs(self.path("sbin")) + os.symlink("/bin/busybox.nosuid", self.path("sbin/syslogd")) + self.assertFails("syslog") + + def test_a_pre_authentication_banner_fails_either_image(self): + self.put("etc/ssh/sshd_config_readonly", self.SSHD + "Banner /etc/issue.net\n") + for kind in ("release", "dev"): + self.assertFails("sshd", kind) + + def test_root_and_an_empty_password_over_ssh_fail_a_release_image_only(self): + dev = self.SSHD.replace("PermitRootLogin no", "PermitRootLogin yes").replace( + "PermitEmptyPasswords no", "PermitEmptyPasswords yes") + self.put("etc/ssh/sshd_config", dev) + self.put("etc/ssh/sshd_config_readonly", dev) + self.assertFails("sshd", "release") + p = self.lint("dev") + self.assertEqual(p.returncode, 0, p.stderr) + + def test_a_dhcp_stanza_that_does_not_send_the_name_fails(self): + self.put("etc/network/interfaces", NetworkBootTests.STANZA.replace( + "\tudhcpc_opts -x hostname:$(hostname)\n", "")) + self.assertFails("dhcp-name") + + def test_an_mdns_responder_fails(self): + os.makedirs(self.path("etc/avahi")) + self.assertFails("mdns") + + if __name__ == "__main__": unittest.main() diff --git a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image-dev.bb b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image-dev.bb index 5599289..eaad0c1 100644 --- a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image-dev.bb +++ b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image-dev.bb @@ -36,6 +36,9 @@ IMAGE_FEATURES += " \ tools-debug \ debug-tweaks \ " +# The rootfs lint holds a dev image to the dev policy: root and an empty +# password over SSH are what debug-tweaks turns on. +FORGEFIRM_IMAGE_KIND = "dev" # Dev images boot from SD, never from a 200 MiB eMMC slot: lift the slot # ceiling and give the filesystem generous working space instead. diff --git a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb index 8e5c8ec..03c2555 100644 --- a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb +++ b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb @@ -231,3 +231,19 @@ pack_licenses() { || bbfatal "pack_licenses: pack-licenses.sh refused the license tree (its message is above)" } ROOTFS_POSTPROCESS_COMMAND += "pack_licenses " + +# The rootfs lint: the image content fixed at build time, checked on the +# tree as it is imaged, so a drift fails the build of the release image +# too, which no campaign runs. rootfs-lint.sh, next to this recipe, holds +# the rules. It runs after every other rootfs step (an append is applied +# after every +=, the dev recipe's and debug-tweaks' included), and +# FORGEFIRM_IMAGE_KIND tells it which image's policy applies. +ROOTFS_LINT := "${THISDIR}/rootfs-lint.sh" +do_rootfs[file-checksums] += "${ROOTFS_LINT}:True" +FORGEFIRM_IMAGE_KIND ?= "release" + +rootfs_lint() { + sh "${ROOTFS_LINT}" "${IMAGE_ROOTFS}" "${FORGEFIRM_IMAGE_KIND}" \ + || bbfatal "rootfs_lint: rootfs-lint.sh refused the rootfs (its messages are above)" +} +ROOTFS_POSTPROCESS_COMMAND:append = " rootfs_lint" diff --git a/meta-forgefirm/recipes-forgefirm/images/rootfs-lint.sh b/meta-forgefirm/recipes-forgefirm/images/rootfs-lint.sh new file mode 100755 index 0000000..97e73f5 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/images/rootfs-lint.sh @@ -0,0 +1,144 @@ +#!/bin/sh +# Copyright 2026 514 LLC d/b/a OpenGlow +# Written by Scott Wiederhold +# https://community.openglow.org +# SPDX-License-Identifier: MIT +# +# rootfs-lint.sh +# +# The image content that is fixed at build time, checked on the tree as it +# is imaged: a failure here fails the build, on the release image too, +# which no campaign runs. forgefirm-image.bb's rootfs_lint runs it last of +# the rootfs steps; forgetest's test_image.py runs it on fixture trees. +# Every rule is checked and every failure printed, one line each, before +# it exits 1. The rules: +# +# syslog rsyslog is the only logger: /usr/sbin/rsyslogd is on the +# image, the syslog init script starts it and runlevel 5 +# starts that script; no syslogd or klogd is linked in a bin +# directory, and no init script runs one. +# sshd every sshd configuration the image carries (sshd_config, +# and sshd_config_readonly, which the read-only rootfs runs) +# takes passwords (PasswordAuthentication yes) and sends no +# pre-authentication banner; on a release image it refuses +# root (PermitRootLogin no) and an empty password +# (PermitEmptyPasswords no). Nothing in sshd_config.d sets +# these, since an included value comes first. +# dhcp-name the wlan0 DHCP stanza sends the machine's name: +# udhcpc_opts carries -x hostname:$(hostname). +# mdns no mDNS responder: no /etc/avahi, no avahi-daemon, no +# libnss_mdns, and the hosts line of nsswitch.conf names no +# mdns source. + +root=${1:?rootfs} +kind=${2:?release or dev} +case "$kind" in + release|dev) ;; + *) echo "rootfs-lint: the image kind is release or dev, not $kind" >&2; exit 2 ;; +esac +[ -d "$root" ] || { echo "rootfs-lint: $root is not a directory" >&2; exit 2; } + +failed=0 +fail() { echo "rootfs-lint: $1: $2" >&2; failed=1; } + +# The active lines of a file: no comment, no blank line. +active() { grep -v -E '^[[:space:]]*(#|$)' "$1" 2>/dev/null; } + +# ---------------------------------------------------------------- syslog +[ -x "$root/usr/sbin/rsyslogd" ] || fail syslog "/usr/sbin/rsyslogd is not on the image" +if [ -f "$root/etc/init.d/syslog" ]; then + active "$root/etc/init.d/syslog" | grep -q '/usr/sbin/rsyslogd' \ + || fail syslog "/etc/init.d/syslog does not start /usr/sbin/rsyslogd" +else + fail syslog "there is no /etc/init.d/syslog" +fi +started= +for l in "$root"/etc/rc5.d/S*; do + [ -L "$l" ] && [ "$(readlink "$l")" = "../init.d/syslog" ] && started=$l +done +[ -n "$started" ] || fail syslog "runlevel 5 does not start /etc/init.d/syslog" +for d in bin sbin usr/bin usr/sbin; do + for p in syslogd klogd; do + if [ -e "$root/$d/$p" ] || [ -L "$root/$d/$p" ]; then + fail syslog "/$d/$p is on the image beside rsyslog" + fi + done +done +for s in "$root"/etc/init.d/*; do + [ -f "$s" ] || continue + # syslogd or klogd as a word of its own: rsyslogd is not one + if active "$s" | grep -q -E '(^|[^[:alnum:]_.-])(syslogd|klogd)([^[:alnum:]_.-]|$)'; then + fail syslog "/etc/init.d/${s##*/} runs syslogd or klogd" + fi +done + +# ------------------------------------------------------------------ sshd +# The first value a configuration sets for a keyword before any Match +# block: sshd takes the first one it reads. Keywords are case-insensitive, +# and a value follows spaces or an equals sign. +sshd_value() { + awk -v k="$2" ' + { line = $0; sub(/^[ \t]+/, "", line) } + line == "" || line ~ /^#/ { next } + { n = split(line, f, /[ \t=]+/); key = tolower(f[1]) } + key == "match" { exit } + key == tolower(k) { print (n > 1 ? f[2] : ""); exit }' "$1" +} +configs= +for c in sshd_config sshd_config_readonly; do + [ -f "$root/etc/ssh/$c" ] && configs="$configs $c" +done +[ -n "$configs" ] || fail sshd "the image carries no /etc/ssh/sshd_config" +for c in $configs; do + f=$root/etc/ssh/$c + v=$(sshd_value "$f" PasswordAuthentication) + [ "$v" = yes ] || fail sshd "$c: PasswordAuthentication is '${v:-unset}', not yes" + v=$(sshd_value "$f" Banner) + [ -z "$v" ] || [ "$v" = none ] || fail sshd "$c: Banner $v sends a pre-authentication banner" + if [ "$kind" = release ]; then + v=$(sshd_value "$f" PermitRootLogin) + [ "$v" = no ] || fail sshd "$c: PermitRootLogin is '${v:-unset}', not no" + v=$(sshd_value "$f" PermitEmptyPasswords) + [ "$v" = no ] || fail sshd "$c: PermitEmptyPasswords is '${v:-unset}', not no" + fi +done +for f in "$root"/etc/ssh/sshd_config.d/*.conf; do + [ -f "$f" ] || continue + for k in PasswordAuthentication Banner PermitRootLogin PermitEmptyPasswords; do + v=$(sshd_value "$f" "$k") + [ -z "$v" ] || fail sshd "sshd_config.d/${f##*/} sets $k $v ahead of sshd_config" + done +done + +# ------------------------------------------------------------- dhcp-name +ifaces=$root/etc/network/interfaces +if [ -f "$ifaces" ]; then + # the udhcpc_opts lines of the wlan0 dhcp stanza, which runs to the + # next stanza + opts=$(awk ' + /^[ \t]*#/ { next } + /^(iface|auto|allow-|mapping|source)/ { in_w = ($1 == "iface" && $2 == "wlan0" && $4 == "dhcp"); next } + in_w && $1 == "udhcpc_opts" { sub(/^[ \t]*udhcpc_opts[ \t]+/, ""); print }' "$ifaces") + case " $opts " in + *' -x hostname:$(hostname) '*) ;; + *) fail dhcp-name "the wlan0 dhcp stanza does not send -x hostname:\$(hostname) (udhcpc_opts: '${opts}')" ;; + esac +else + fail dhcp-name "there is no /etc/network/interfaces" +fi + +# ------------------------------------------------------------------ mdns +[ -e "$root/etc/avahi" ] && fail mdns "/etc/avahi is on the image" +for d in sbin usr/sbin; do + [ -e "$root/$d/avahi-daemon" ] && fail mdns "/$d/avahi-daemon is on the image" +done +for d in lib usr/lib; do + for l in "$root/$d"/libnss_mdns*; do + [ -e "$l" ] && fail mdns "/$d/${l##*/} is on the image" + done +done +if active "$root/etc/nsswitch.conf" | grep -E '^[[:space:]]*hosts:' | grep -q 'mdns'; then + fail mdns "nsswitch.conf resolves hosts through mdns" +fi + +exit $failed