tests: add negative regression suite for P0 security findings

ober

11bd84964c3dae8631c2ac04b61a286e02dabd80

diff --git a/Makefile b/Makefile
index a4cfc62..55ead32 100644
--- a/Makefile
+++ b/Makefile
@@ -240,6 +240,8 @@ test: binary
 	@test "$$(printf 'b\na\n' | bin/sort | tr '\n' ' ')" = "a b " && echo "PASS: sort"
 	@test "$$(printf '' | bin/sha256sum | cut -c1-8)" = "e3b0c442" && echo "PASS: sha256sum"
 	@printf 'apple\nbanana\n' | bin/grep an | grep -q banana && echo "PASS: grep"
+	@echo "=== security regression ==="
+	@REPO_ROOT="$(CURDIR)" sh tests/security/run-tests.sh
 	@echo "=== ok ==="
 
 focused-security-tests: binary scripts/focused-security-tests.sh
diff --git a/tests/security/run-tests.sh b/tests/security/run-tests.sh
new file mode 100644
index 0000000..9789a2e
--- /dev/null
+++ b/tests/security/run-tests.sh
@@ -0,0 +1,144 @@
+#!/bin/sh
+# Negative regression tests for P0 security findings:
+#   (a) expr : group match must not run shell command injection
+#   (b) chown -R / chgrp -R / chmod -R must not follow symlinks
+#   (c) JCOREUTILS_REQUIRE_SECURITY must fail closed, not run unconfined
+#
+# Usage: REPO_ROOT=<repo> sh tests/security/run-tests.sh
+set -eu
+
+repo_root=${REPO_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}
+tmp_parent=${TMPDIR:-/tmp}
+tmp_dir=$tmp_parent/jcoreutils-sec-regression-$$
+
+cleanup() { rm -rf "$tmp_dir"; }
+trap cleanup EXIT INT TERM
+
+cd "$repo_root"
+mkdir -p "$tmp_dir"
+
+PASS=0
+fail() {
+  printf 'FAIL: %s\n' "$1"
+  exit 1
+}
+pass() {
+  PASS=$((PASS + 1))
+  printf 'PASS: %s\n' "$1"
+}
+
+require_bin() {
+  [ -x "bin/$1" ] || fail "missing bin/$1; run make binary first"
+}
+require_bin expr
+require_bin chmod
+require_bin chown
+require_bin chgrp
+require_bin true
+
+if stat -c '%a' / >/dev/null 2>&1; then
+  get_mode() { stat -c '%a' "$1"; }
+else
+  get_mode() { stat -f '%Lp' "$1"; }
+fi
+
+# ---------------------------------------------------------------------------
+# (a) expr shell command injection via the group-extraction sed path.
+# A group pattern (contains \( \)) carrying a single-quote breakout is fed to
+# expr. The vulnerable shell-based sed executed the injected touch; the fixed
+# direct-exec sed treats the pattern as data, so the probe must never appear.
+# ---------------------------------------------------------------------------
+expr_probe=$tmp_dir/expr-injection-probe
+rm -f "$expr_probe"
+sq="'"
+expr_pat="a\\(b\\)c${sq} ; touch ${expr_probe} ; echo ${sq}"
+bin/expr abc : "$expr_pat" >/dev/null 2>&1 || true
+[ ! -e "$expr_probe" ] || fail "expr : group pattern executed shell injection"
+pass "expr group pattern is not shell-interpreted"
+
+# Legitimate group extraction must still work (sed runs, no shell).
+grp=$(bin/expr abc : 'a\(b\)c')
+[ "$grp" = "b" ] || fail "expr group extraction returned '$grp', expected 'b'"
+pass "expr group extraction works without a shell"
+
+# ---------------------------------------------------------------------------
+# (b) recursive chown/chgrp/chmod must not follow symlinks. A planted
+# victim/evil -> outside dir must not cause the outside tree to be re-owned,
+# re-grouped, or re-moded.
+# ---------------------------------------------------------------------------
+
+# chmod -R: the outside dir's mode (and a file within) must be unchanged.
+chmod_outside=$tmp_dir/chmod-outside
+mkdir -p "$chmod_outside"
+chmod 755 "$chmod_outside"
+printf 'data\n' > "$chmod_outside/inner.txt"
+chmod 644 "$chmod_outside/inner.txt"
+chmod_victim=$tmp_dir/chmod-victim
+mkdir -p "$chmod_victim"
+ln -s "$chmod_outside" "$chmod_victim/evil"
+bin/chmod -R 700 "$chmod_victim" >/dev/null 2>&1 || true
+[ "$(get_mode "$chmod_outside")" = "755" ] ||
+  fail "chmod -R followed a symlink and re-moded the outside dir"
+[ "$(get_mode "$chmod_outside/inner.txt")" = "644" ] ||
+  fail "chmod -R descended through a symlink and re-moded an outside file"
+pass "chmod -R does not follow symlinks"
+
+# chown -R: without root, assert via -v that the symlink target's contents are
+# never visited (a followed symlink would list evil/inner.txt).
+chown_outside=$tmp_dir/chown-outside
+mkdir -p "$chown_outside"
+printf 'x\n' > "$chown_outside/inner.txt"
+chown_victim=$tmp_dir/chown-victim
+mkdir -p "$chown_victim"
+ln -s "$chown_outside" "$chown_victim/evil"
+chown_out=$(bin/chown -R -v "$(id -u):$(id -g)" "$chown_victim" 2>&1) || true
+if printf '%s\n' "$chown_out" | grep -q "evil/inner.txt"; then
+  fail "chown -R descended through a symlink into the outside dir"
+fi
+pass "chown -R does not follow symlinks"
+
+# chgrp -R: same descent check via -v output.
+chgrp_outside=$tmp_dir/chgrp-outside
+mkdir -p "$chgrp_outside"
+printf 'x\n' > "$chgrp_outside/inner.txt"
+chgrp_victim=$tmp_dir/chgrp-victim
+mkdir -p "$chgrp_victim"
+ln -s "$chgrp_outside" "$chgrp_victim/evil"
+chgrp_out=$(bin/chgrp -R -v "$(id -g)" "$chgrp_victim" 2>&1) || true
+if printf '%s\n' "$chgrp_out" | grep -q "evil/inner.txt"; then
+  fail "chgrp -R descended through a symlink into the outside dir"
+fi
+pass "chgrp -R does not follow symlinks"
+
+# ---------------------------------------------------------------------------
+# (c) when a kernel sandbox is explicitly required but unavailable, the applet
+# must refuse to run (non-zero exit + error), not run unconfined.
+# ---------------------------------------------------------------------------
+if JCOREUTILS_REQUIRE_SECURITY=seccomp bin/true >/dev/null 2>&1; then
+  fail "JCOREUTILS_REQUIRE_SECURITY=seccomp did not fail closed"
+fi
+pass "JCOREUTILS_REQUIRE_SECURITY=seccomp fails closed"
+
+if JCOREUTILS_REQUIRE_SECURITY=landlock bin/true >/dev/null 2>&1; then
+  fail "JCOREUTILS_REQUIRE_SECURITY=landlock did not fail closed"
+fi
+pass "JCOREUTILS_REQUIRE_SECURITY=landlock fails closed"
+
+if JCOREUTILS_REQUIRE_SECURITY=all bin/true >/dev/null 2>&1; then
+  fail "JCOREUTILS_REQUIRE_SECURITY=all did not fail closed"
+fi
+pass "JCOREUTILS_REQUIRE_SECURITY=all fails closed"
+
+# An io-seccomp applet must also fail closed, and emit a diagnostic.
+sec_err=$(JCOREUTILS_REQUIRE_SECURITY=seccomp bin/chmod 700 "$chmod_victim" 2>&1 >/dev/null) || true
+if JCOREUTILS_REQUIRE_SECURITY=seccomp bin/chmod 700 "$chmod_victim" >/dev/null 2>&1; then
+  fail "chmod with JCOREUTILS_REQUIRE_SECURITY=seccomp did not fail closed"
+fi
+[ -n "$sec_err" ] || fail "fail-closed applet emitted no error diagnostic"
+pass "io-seccomp applet fails closed with a diagnostic"
+
+# Without a requirement the applet still runs unconfined (portable build).
+bin/true >/dev/null 2>&1 || fail "bin/true failed without a security requirement"
+pass "applet runs when no security control is required"
+
+printf 'security_regression_status=pass (%d checks)\n' "$PASS"