tests: add negative regression suite for P0 security findings
ober
11bd84964c3dae8631c2ac04b61a286e02dabd80
--- 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 new file mode 100644 --- /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"