Files
forgefirm/meta-forgefirm/recipes-forgefirm/images/rootfs-lint.sh
T
ScottW514 08c7df9cbb rootfs-lint.sh: the image content fixed at build time, checked as the last rootfs step
A new script beside the image recipe, run by both images as the last
rootfs step (ROOTFS_POSTPROCESS_COMMAND:append, after every +=, the dev
recipe's and debug-tweaks' included); a refusal is bbfatal. It checks
every rule and prints every failure before it exits. FORGEFIRM_IMAGE_KIND
(release by default, dev in forgefirm-image-dev.bb) picks the sshd policy.
The rules:

- syslog: /usr/sbin/rsyslogd is on the image, /etc/init.d/syslog starts
  it and runlevel 5 starts that script; no syslogd or klogd is linked in
  a bin directory and no init script runs one. This is the image half of
  what logs.routing checked on the board; that test left in 01f5991 with
  nothing else holding it.
- sshd: sshd_config and sshd_config_readonly take passwords
  (PasswordAuthentication yes) and send no banner; on a release image
  PermitRootLogin no and PermitEmptyPasswords no; sshd_config.d sets none
  of the four, since an included value comes first. setup.ssh-until-
  reboot checked the first two on the board and left in 01f5991;
  release.sh gates the release pair at release time only.
- dhcp-name: the wlan0 dhcp stanza's udhcpc_opts carries
  -x hostname:$(hostname).
- mdns: no /etc/avahi, avahi-daemon or libnss_mdns, and no mdns source
  on nsswitch.conf's hosts line.
dhcp-name and mdns are the image half of setup.machine-name, whose
scripts test_image.py's MachineNameTests already run.

test_image.py's RootfsLintTests run it on a fixture tree: the tree that
holds every rule passes as either image, the BSP layer's own interfaces
file passes, and one drift per rule fails naming the rule (a busybox
syslogd link; a Banner, on either image; root and an empty password,
refused on a release image and passed on a dev one; a dhcp stanza with
no name; /etc/avahi).

Proof: test_image.py under Linux, 21 OK; pyflakes clean. On image
20261015134521's two rootfs trees from the build, the release tree
passes as release, the dev tree passes as dev, and the dev tree linted
as release is refused on PermitRootLogin and PermitEmptyPasswords in
both sshd files. bitbake -e places rootfs_lint last in both images'
ROOTFS_POSTPROCESS_COMMAND, with FORGEFIRM_IMAGE_KIND release and dev.
The layer content hash moves: the next image cannot inherit a campaign.
2026-10-15 10:19:54 -04:00

145 lines
6.0 KiB
Bash
Executable File

#!/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 <rootfs> <release|dev>
#
# 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