[PATCH v3] semodule-utils: add bad-data tests for semodule_package

Akhil Kohli <[email protected]> Thu, 23 Jul 2026 15:09:42 +0200
Newsgroups org.kernel.vger.selinux
Message-ID <[email protected]>
Add tests/bad-data/run.sh and fixtures for semodule_package bad-data
protection: input path validation for -m and -f (missing paths, directories,
broken symlinks, unreadable files, empty arguments) and malformed .mod.fc
content at packaging time. Wire the driver into semodule-utils make test.
Semantic validation of bad file contexts is deferred to sefcontext_compile.

Run the harness as a non-root user in GitHub Actions so unreadable -m/-f
inputs are exercised in CI. Semantic validation of bad file contexts is
deferred to sefcontext_compile

Signed-off-by: Akhil Kohli <[email protected]>
---
 .github/workflows/run_tests.yml               |  13 +
 semodule-utils/Makefile                       |   1 +
 .../fixtures/file_contexts/bad_context.mod.fc |   1 +
 .../fixtures/file_contexts/bad_fields.mod.fc  |   1 +
 .../fixtures/file_contexts/good.mod.fc        |   1 +
 .../tests/bad-data/fixtures/modules/good.te   |   8 +
 semodule-utils/tests/bad-data/run.sh          | 355 ++++++++++++++++++
 7 files changed, 380 insertions(+)
 create mode 100644 semodule-utils/tests/bad-data/fixtures/file_contexts/bad_context.mod.fc
 create mode 100644 semodule-utils/tests/bad-data/fixtures/file_contexts/bad_fields.mod.fc
 create mode 100644 semodule-utils/tests/bad-data/fixtures/file_contexts/good.mod.fc
 create mode 100644 semodule-utils/tests/bad-data/fixtures/modules/good.te
 create mode 100755 semodule-utils/tests/bad-data/run.sh

diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml
index f4aa1a4c..4322ddff 100644
--- a/.github/workflows/run_tests.yml
+++ b/.github/workflows/run_tests.yml
@@ -82,6 +82,19 @@ jobs:
         eval make test $EXPLICIT_MAKE_VARS
         echo "::endgroup::"
 
+        # semodule_package bad-data: unreadable -m/-f inputs need non-root (root reads mode 000).
+        if [ "${{ matrix.python-ruby-version.other }}" != "sanitizers" ] ; then
+          echo "::group::semodule-utils bad-data (non-root)"
+          sudo useradd -m -s /usr/sbin/nologin bad-data-test 2>/dev/null || true
+          # World-readable checkout; run via relative path (inherited CWD). Do not
+          # cd to $PWD — bad-data-test cannot traverse /home/runner/work/... .
+          chmod -R a+rX .
+          sudo runuser -u bad-data-test -- env PATH="$PATH" LD_LIBRARY_PATH="$LD_LIBRARY_PATH" \
+            ./semodule-utils/tests/bad-data/run.sh
+          sudo userdel -r bad-data-test 2>/dev/null || true
+          echo "::endgroup::"
+        fi
+
         if [ "${{ matrix.python-ruby-version.other }}" != "sanitizers" ] ; then
             # Test Python and Ruby wrappers
             echo "::group::Test Python and Ruby wrappers"
diff --git a/semodule-utils/Makefile b/semodule-utils/Makefile
index 81c97af0..4a902146 100644
--- a/semodule-utils/Makefile
+++ b/semodule-utils/Makefile
@@ -6,3 +6,4 @@ all install relabel clean:
 	done
 
 test:
+	./tests/bad-data/run.sh
diff --git a/semodule-utils/tests/bad-data/fixtures/file_contexts/bad_context.mod.fc b/semodule-utils/tests/bad-data/fixtures/file_contexts/bad_context.mod.fc
new file mode 100644
index 00000000..06aec43e
--- /dev/null
+++ b/semodule-utils/tests/bad-data/fixtures/file_contexts/bad_context.mod.fc
@@ -0,0 +1 @@
+/usr/bin/bad	not_a_valid_selinux_context
diff --git a/semodule-utils/tests/bad-data/fixtures/file_contexts/bad_fields.mod.fc b/semodule-utils/tests/bad-data/fixtures/file_contexts/bad_fields.mod.fc
new file mode 100644
index 00000000..13821d61
--- /dev/null
+++ b/semodule-utils/tests/bad-data/fixtures/file_contexts/bad_fields.mod.fc
@@ -0,0 +1 @@
+only_one_field_on_this_line
diff --git a/semodule-utils/tests/bad-data/fixtures/file_contexts/good.mod.fc b/semodule-utils/tests/bad-data/fixtures/file_contexts/good.mod.fc
new file mode 100644
index 00000000..12b26cae
--- /dev/null
+++ b/semodule-utils/tests/bad-data/fixtures/file_contexts/good.mod.fc
@@ -0,0 +1 @@
+/usr/bin/test_good	--	system_u:object_r:test_good_exec_t:s0
diff --git a/semodule-utils/tests/bad-data/fixtures/modules/good.te b/semodule-utils/tests/bad-data/fixtures/modules/good.te
new file mode 100644
index 00000000..a079aed6
--- /dev/null
+++ b/semodule-utils/tests/bad-data/fixtures/modules/good.te
@@ -0,0 +1,8 @@
+module test_good 1.0;
+
+require {
+	type test_good_t;
+	class file { read };
+}
+
+allow test_good_t self:file read;
diff --git a/semodule-utils/tests/bad-data/run.sh b/semodule-utils/tests/bad-data/run.sh
new file mode 100755
index 00000000..7c9f7c7d
--- /dev/null
+++ b/semodule-utils/tests/bad-data/run.sh
@@ -0,0 +1,355 @@
+#!/bin/sh
+#
+# Bad-data tests for semodule_package in the modular policy packaging pipeline.
+# Covers packaging path edge cases (-m/-f) and documents deferred rejection of bad
+# .mod.fc content (enforced later by sefcontext_compile).
+# Unreadable -m/-f cases skip when run as root; CI runs this script as non-root.
+#
+
+set -u
+
+# Prefer an absolute script dir, but keep a relative dirname when cd fails.
+# Non-root CI can inherit the repo as CWD without being able to traverse
+# /home/runner/work/...; relative paths from that CWD still work.
+BASEDIR=$(dirname -- "$0")
+ABS_BASEDIR=$(CDPATH= cd -- "${BASEDIR}" 2>/dev/null && pwd) || ABS_BASEDIR=
+if [ -n "${ABS_BASEDIR}" ]; then
+	BASEDIR="${ABS_BASEDIR}"
+fi
+FIXTURES="${BASEDIR}/fixtures"
+if [ ! -d "${FIXTURES}" ]; then
+	echo "FAIL: cannot resolve fixtures directory (\$0=$0 BASEDIR=${BASEDIR})" >&2
+	exit 1
+fi
+OUTDIR=$(mktemp -d "${TMPDIR:-/tmp}/semodule-package-bad-data.XXXXXX")
+PASS=0
+FAIL=0
+
+CHECKMODULE=${CHECKMODULE:-checkmodule}
+# Modular TE for MCS/MLS builds (checkmodule -M -m).
+CHECKMODULE_MOD_FLAGS=${CHECKMODULE_MOD_FLAGS:--M -m}
+SEMODULE_PACKAGE=${SEMODULE_PACKAGE:-semodule_package}
+
+GOOD_TE="${FIXTURES}/modules/good.te"
+GOOD_MOD="${OUTDIR}/test_good.mod"
+
+cleanup() {
+	rm -rf "${OUTDIR}"
+}
+trap cleanup EXIT
+
+die() {
+	echo "FAIL: $*" >&2
+	FAIL=$((FAIL + 1))
+}
+
+pass() {
+	echo "==== $*"
+	PASS=$((PASS + 1))
+	echo ""
+}
+
+build_good_mod() {
+	echo "==== Setup: build control module ${GOOD_MOD} from good.te"
+	rm -f "${GOOD_MOD}"
+
+	set +e
+	# shellcheck disable=SC2086
+	"${CHECKMODULE}" ${CHECKMODULE_MOD_FLAGS} -o "${GOOD_MOD}" "${GOOD_TE}" \
+		2>"${OUTDIR}/build_good.err"
+	rc=$?
+	set -e
+
+	if [ "${rc}" -ne 0 ]; then
+		echo "FAIL: could not build control module from ${GOOD_TE}" >&2
+		cat "${OUTDIR}/build_good.err" >&2
+		exit 1
+	fi
+	if [ ! -s "${GOOD_MOD}" ]; then
+		echo "FAIL: ${GOOD_MOD} is empty" >&2
+		exit 1
+	fi
+	echo ""
+}
+
+expect_package_pass() {
+	desc="$1"
+	outname="$2"
+	shift 2
+
+	outpp="${OUTDIR}/${outname}.pp"
+	stderr="${OUTDIR}/${outname}.err"
+
+	echo "==== POSITIVE (expect semodule_package success): ${desc}"
+	rm -f "${outpp}"
+
+	set +e
+	"${SEMODULE_PACKAGE}" -o "${outpp}" "$@" 2>"${stderr}"
+	rc=$?
+	set -e
+
+	if [ "${rc}" -ne 0 ]; then
+		echo "stderr:" >&2
+		cat "${stderr}" >&2
+		die "${desc}: expected exit 0, got rc=${rc}"
+		return 0
+	fi
+	if [ ! -s "${outpp}" ]; then
+		die "${desc}: expected non-empty ${outpp}"
+		return 0
+	fi
+
+	pass "${desc} (exit 0, .pp created)"
+}
+
+expect_package_pass_deferred() {
+	desc="$1"
+	outname="$2"
+	shift 2
+
+	outpp="${OUTDIR}/${outname}.pp"
+	stderr="${OUTDIR}/${outname}.err"
+
+	echo "==== DOCUMENT (semodule_package accepts input; validate in sefcontext_compile): ${desc}"
+	rm -f "${outpp}"
+
+	set +e
+	"${SEMODULE_PACKAGE}" -o "${outpp}" "$@" 2>"${stderr}"
+	rc=$?
+	set -e
+
+	if [ "${rc}" -ne 0 ]; then
+		echo "stderr:" >&2
+		cat "${stderr}" >&2
+		die "${desc}: expected exit 0 at packaging stage, got rc=${rc}"
+		return 0
+	fi
+	if [ ! -s "${outpp}" ]; then
+		die "${desc}: expected non-empty ${outpp} at packaging stage"
+		return 0
+	fi
+
+	pass "${desc} (packaging exit 0; labeling validation deferred to sefcontext_compile)"
+}
+
+expect_package_fail() {
+	desc="$1"
+	pattern="$2"
+	outname="$3"
+	shift 3
+
+	outpp="${OUTDIR}/${outname}.pp"
+	stderr="${OUTDIR}/${outname}.err"
+
+	echo "==== NEGATIVE (expect semodule_package failure): ${desc}"
+	rm -f "${outpp}"
+
+	set +e
+	"${SEMODULE_PACKAGE}" -o "${outpp}" "$@" 2>"${stderr}"
+	rc=$?
+	set -e
+
+	if [ "${rc}" -eq 0 ]; then
+		die "${desc}: expected non-zero exit, got rc=0"
+		return 0
+	fi
+	if [ -f "${outpp}" ]; then
+		die "${desc}: did not expect output ${outpp}"
+		return 0
+	fi
+	if ! grep -Eq "${pattern}" "${stderr}"; then
+		echo "FAIL: stderr did not match /${pattern}/" >&2
+		cat "${stderr}" >&2
+		FAIL=$((FAIL + 1))
+		return 0
+	fi
+
+	pass "${desc} (rejected as expected, rc=${rc})"
+}
+
+expect_package_fail_unreadable() {
+	desc="unreadable -m file"
+	mod="${OUTDIR}/unreadable.mod"
+	outpp="${OUTDIR}/unreadable.pp"
+	stderr="${OUTDIR}/unreadable.err"
+
+	echo "==== NEGATIVE (expect semodule_package failure): ${desc}"
+	if [ "$(id -u)" -eq 0 ]; then
+		echo "SKIP: root can read mode 000 files; unreadable check is non-root only"
+		PASS=$((PASS + 1))
+		echo ""
+		return 0
+	fi
+
+	rm -f "${outpp}"
+	cp "${GOOD_MOD}" "${mod}"
+	chmod 000 "${mod}"
+
+	set +e
+	"${SEMODULE_PACKAGE}" -o "${outpp}" -m "${mod}" 2>"${stderr}"
+	rc=$?
+	set -e
+
+	if [ "${rc}" -eq 0 ]; then
+		die "${desc}: expected non-zero exit, got rc=0"
+		return 0
+	fi
+	if [ -f "${outpp}" ]; then
+		die "${desc}: did not expect output ${outpp}"
+		return 0
+	fi
+	if ! grep -Eq 'Permission denied|Could not open|Failed to open' "${stderr}"; then
+		echo "FAIL: stderr did not mention permission or open failure" >&2
+		cat "${stderr}" >&2
+		FAIL=$((FAIL + 1))
+		return 0
+	fi
+
+	pass "${desc} (rejected as expected, rc=${rc})"
+}
+
+expect_package_fail_unreadable_fc() {
+	desc="unreadable -f file"
+	fc="${OUTDIR}/unreadable.mod.fc"
+	outpp="${OUTDIR}/unreadable_fc.pp"
+	stderr="${OUTDIR}/unreadable_fc.err"
+
+	echo "==== NEGATIVE (expect semodule_package failure): ${desc}"
+	if [ "$(id -u)" -eq 0 ]; then
+		echo "SKIP: root can read mode 000 files; unreadable check is non-root only"
+		PASS=$((PASS + 1))
+		echo ""
+		return 0
+	fi
+
+	rm -f "${outpp}"
+	cp "${GOOD_FC}" "${fc}"
+	chmod 000 "${fc}"
+
+	set +e
+	"${SEMODULE_PACKAGE}" -o "${outpp}" -m "${GOOD_MOD}" -f "${fc}" \
+		2>"${stderr}"
+	rc=$?
+	set -e
+
+	if [ "${rc}" -eq 0 ]; then
+		die "${desc}: expected non-zero exit, got rc=0"
+		return 0
+	fi
+	if [ -f "${outpp}" ]; then
+		die "${desc}: did not expect output ${outpp}"
+		return 0
+	fi
+	if ! grep -Eq 'Permission denied|Could not open|Failed to open' "${stderr}"; then
+		echo "FAIL: stderr did not mention permission or open failure" >&2
+		cat "${stderr}" >&2
+		FAIL=$((FAIL + 1))
+		return 0
+	fi
+
+	pass "${desc} (rejected as expected, rc=${rc})"
+}
+
+build_good_mod
+
+# Ephemeral path fixtures for packaging path tests.
+ln -sf /nonexistent/test_good.mod "${OUTDIR}/broken_symlink.mod"
+ln -sf /nonexistent/test_good.mod.fc "${OUTDIR}/broken_symlink.mod.fc"
+printf '' > "${OUTDIR}/empty.mod.fc"
+
+# NUL byte inside path column (packaging accepts; sefcontext_compile rejects).
+printf '/bin/foo\x00bar\t--\tsystem_u:object_r:test_good_exec_t:s0\n' \
+	> "${OUTDIR}/nul_bytes.mod.fc"
+
+GOOD_FC="${FIXTURES}/file_contexts/good.mod.fc"
+
+# --- semodule_package input paths ---
+expect_package_pass \
+	"control good .mod without file contexts" \
+	good_mod \
+	-m "${GOOD_MOD}"
+
+expect_package_pass \
+	"control good .mod with good .mod.fc" \
+	good_mod_fc \
+	-m "${GOOD_MOD}" -f "${GOOD_FC}"
+
+expect_package_fail \
+	"missing -m path" \
+	"Could not open|Failed to open" \
+	missing_mod \
+	-m "${OUTDIR}/does_not_exist.mod"
+
+expect_package_fail \
+	"missing -f path" \
+	"Failed to open|Could not open" \
+	missing_fc \
+	-m "${GOOD_MOD}" -f "${OUTDIR}/does_not_exist.mod.fc"
+
+expect_package_fail \
+	"directory instead of -m file" \
+	"Error while reading policy module|Could not open" \
+	directory_mod \
+	-m "${BASEDIR}"
+
+expect_package_fail \
+	"broken symlink for -m" \
+	"Could not open|Failed to open|No such file" \
+	symlink_mod \
+	-m "${OUTDIR}/broken_symlink.mod"
+
+expect_package_fail_unreadable
+
+expect_package_fail \
+	"empty -m path argument" \
+	"Could not open|Failed to open" \
+	empty_mod \
+	-m ""
+
+expect_package_fail \
+	"directory instead of -f file" \
+	"Permission denied|Failed to mmap|Failed to open|Could not open" \
+	directory_fc \
+	-m "${GOOD_MOD}" -f "${BASEDIR}"
+
+expect_package_fail \
+	"broken symlink for -f" \
+	"Could not open|Failed to open|No such file" \
+	symlink_fc \
+	-m "${GOOD_MOD}" -f "${OUTDIR}/broken_symlink.mod.fc"
+
+expect_package_fail_unreadable_fc
+
+expect_package_fail \
+	"empty -f path argument" \
+	"Could not open|Failed to open" \
+	empty_fc_arg \
+	-m "${GOOD_MOD}" -f ""
+
+# --- bad .mod.fc at packaging time ---
+expect_package_pass_deferred \
+	"invalid SELinux context in .mod.fc" \
+	bad_context \
+	-m "${GOOD_MOD}" -f "${FIXTURES}/file_contexts/bad_context.mod.fc"
+
+expect_package_pass_deferred \
+	"wrong field count in .mod.fc" \
+	bad_fields \
+	-m "${GOOD_MOD}" -f "${FIXTURES}/file_contexts/bad_fields.mod.fc"
+
+expect_package_pass_deferred \
+	"NUL byte in .mod.fc path field" \
+	nul_bytes \
+	-m "${GOOD_MOD}" -f "${OUTDIR}/nul_bytes.mod.fc"
+
+expect_package_pass_deferred \
+	"empty .mod.fc file" \
+	empty_fc \
+	-m "${GOOD_MOD}" -f "${OUTDIR}/empty.mod.fc"
+
+echo "========================================"
+echo "Results: ${PASS} passed, ${FAIL} failed"
+if [ "${FAIL}" -ne 0 ]; then
+	exit 1
+fi
+exit 0
-- 
2.55.0