diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3df09f5..6e5d63c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,8 @@ jobs: run: | sudo apt-get update -qq sudo apt-get install -y --no-install-recommends \ - build-essential clang make linux-libc-dev + build-essential clang make linux-libc-dev \ + libglib2.0-dev pkg-config - name: show compiler run: ${{ matrix.cc }} --version @@ -54,6 +55,18 @@ jobs: - name: sanity — --detect-rules sigma run: ./skeletonkey --detect-rules --format=sigma | head -50 + - name: tests — detect() unit suite + env: + CC: ${{ matrix.cc }} + run: | + # Run as a non-root user so modules' "already root" gates do + # not short-circuit before the synthetic host-fingerprint + # checks fire. The test binary itself is platform-agnostic; + # the assertions are #ifdef __linux__ guarded. + sudo useradd -m -s /bin/bash skeletonkeyci 2>/dev/null || true + sudo chown -R skeletonkeyci . + sudo -u skeletonkeyci make test + # Static build job: ensures the project links cleanly when -static is # requested. Useful for deployment to minimal containers / fleet scans # where shared-libc availability isn't guaranteed. @@ -66,7 +79,8 @@ jobs: run: | sudo apt-get update -qq sudo apt-get install -y --no-install-recommends \ - build-essential make linux-libc-dev libc6-dev + build-essential make linux-libc-dev libc6-dev \ + libglib2.0-dev pkg-config - name: make static # Glibc static linking pulls in NSS at runtime which breaks # getpwnam; the legacy DIRTYFAIL Makefile noted this. For now, diff --git a/Makefile b/Makefile index aaa2451..bf39759 100644 --- a/Makefile +++ b/Makefile @@ -177,15 +177,40 @@ $(P2TR_OBJS): CFLAGS += $(P2TR_CFLAGS) # Top-level dispatcher TOP_OBJ := $(BUILD)/skeletonkey.o -ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) $(OVL_OBJS) $(CR4_OBJS) $(DCOW_OBJS) $(PTM_OBJS) $(NXC_OBJS) $(AFP_OBJS) $(FUL_OBJS) $(STR_OBJS) $(AFP2_OBJS) $(CRA_OBJS) $(OSU_OBJS) $(NSU_OBJS) $(AUG_OBJS) $(NFD_OBJS) $(NPL_OBJS) $(SAM_OBJS) $(SEQ_OBJS) $(SUE_OBJS) $(VMW_OBJS) $(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS) +# All module objects in one var so both the main binary and the test +# binary can re-use the list without duplicating the long enumeration. +MODULE_OBJS := $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) \ + $(OVL_OBJS) $(CR4_OBJS) $(DCOW_OBJS) $(PTM_OBJS) $(NXC_OBJS) \ + $(AFP_OBJS) $(FUL_OBJS) $(STR_OBJS) $(AFP2_OBJS) $(CRA_OBJS) \ + $(OSU_OBJS) $(NSU_OBJS) $(AUG_OBJS) $(NFD_OBJS) $(NPL_OBJS) \ + $(SAM_OBJS) $(SEQ_OBJS) $(SUE_OBJS) $(VMW_OBJS) \ + $(DDC_OBJS) $(FGN_OBJS) $(P2TR_OBJS) -.PHONY: all clean debug static help +ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(MODULE_OBJS) + +# Tests — `make test` builds and runs the detect() unit-test harness. +# Links against the same module objects as the main binary minus the +# top-level dispatcher (which provides main(); the test has its own). +TEST_DIR := tests +TEST_SRCS := $(TEST_DIR)/test_detect.c +TEST_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(TEST_SRCS)) +TEST_BIN := skeletonkey-test +TEST_ALL_OBJS := $(TEST_OBJS) $(CORE_OBJS) $(MODULE_OBJS) + +.PHONY: all clean debug static help test all: $(BIN) $(BIN): $(ALL_OBJS) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS) +$(TEST_BIN): $(TEST_ALL_OBJS) + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS) + +test: $(TEST_BIN) + @echo "[*] running test suite ($(TEST_BIN))" + ./$(TEST_BIN) + # Generic compile: any .c → corresponding .o under build/ $(BUILD)/%.o: %.c @mkdir -p $(dir $@) @@ -198,13 +223,14 @@ static: LDFLAGS += -static static: clean $(BIN) clean: - rm -rf $(BUILD) $(BIN) + rm -rf $(BUILD) $(BIN) $(TEST_BIN) help: @echo "Targets:" @echo " make build optimized skeletonkey binary" @echo " make debug build with -O0 -g3" @echo " make static build a fully static binary" + @echo " make test build + run the detect() unit test suite" @echo " make clean remove build artifacts" @echo "" @echo "Per-module (legacy) — not built by default:" diff --git a/modules/pack2theroot_cve_2026_41651/skeletonkey_modules.c b/modules/pack2theroot_cve_2026_41651/skeletonkey_modules.c index 07a11a8..2ee38b2 100644 --- a/modules/pack2theroot_cve_2026_41651/skeletonkey_modules.c +++ b/modules/pack2theroot_cve_2026_41651/skeletonkey_modules.c @@ -305,7 +305,12 @@ static skeletonkey_result_t p2tr_detect(const struct skeletonkey_ctx *ctx) { p2tr_verbose = !ctx->json; - if (geteuid() == 0) { + /* "Already root" check — consult ctx->host first so unit tests + * can construct a non-root fingerprint regardless of the test + * process's real euid. Production main() populates host->is_root + * from geteuid() at startup, so behaviour is unchanged. */ + bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0); + if (is_root) { if (!ctx->json) fprintf(stderr, "[i] pack2theroot: already root — nothing to do\n"); return SKELETONKEY_OK; diff --git a/skeletonkey-test b/skeletonkey-test new file mode 100755 index 0000000..ad8c83b Binary files /dev/null and b/skeletonkey-test differ diff --git a/tests/test_detect.c b/tests/test_detect.c new file mode 100644 index 0000000..fd1c1e1 --- /dev/null +++ b/tests/test_detect.c @@ -0,0 +1,191 @@ +/* + * tests/test_detect.c — detect() unit tests + * + * Each test builds a synthetic struct skeletonkey_host fingerprint + * (vulnerable / patched / specific-gate-closed) and asserts each + * module's detect() returns the expected verdict. Catches regressions + * in the host-fingerprint-consuming logic across the corpus. + * + * Coverage today is the four modules that already consume ctx->host: + * - dirtydecrypt (CVE-2026-31635) + * - fragnesia (CVE-2026-46300) + * - pack2theroot (CVE-2026-41651) + * - overlayfs (CVE-2021-3493) + * Coverage grows automatically as more modules migrate to ctx->host + * (see ROADMAP "core/host" follow-up). + * + * Why only Linux: every module's real detect() lives inside + * `#ifdef __linux__`; on non-Linux the stubs unconditionally return + * PRECOND_FAIL so the tests are tautologies. The harness compiles + * cross-platform but skips the assertions on non-Linux to keep the + * macOS dev build green while still preventing bit-rot of the test + * infrastructure. + */ + +#include "../core/module.h" +#include "../core/host.h" + +#include +#include +#include + +extern const struct skeletonkey_module dirtydecrypt_module; +extern const struct skeletonkey_module fragnesia_module; +extern const struct skeletonkey_module pack2theroot_module; +extern const struct skeletonkey_module overlayfs_module; + +static int g_pass = 0; +static int g_fail = 0; + +static const char *result_str(skeletonkey_result_t r) +{ + switch (r) { + case SKELETONKEY_OK: return "OK"; + case SKELETONKEY_TEST_ERROR: return "TEST_ERROR"; + case SKELETONKEY_VULNERABLE: return "VULNERABLE"; + case SKELETONKEY_EXPLOIT_FAIL: return "EXPLOIT_FAIL"; + case SKELETONKEY_PRECOND_FAIL: return "PRECOND_FAIL"; + case SKELETONKEY_EXPLOIT_OK: return "EXPLOIT_OK"; + } + return "???"; +} + +#ifdef __linux__ +/* Suppress per-module banner chatter so the test output stays tidy. + * Modules respect ctx->json to mean "structured output mode; no banners" + * — see each module's `if (!ctx->json) fprintf(...)` pattern. */ +static void run_one(const char *test_name, + const struct skeletonkey_module *m, + const struct skeletonkey_host *h, + skeletonkey_result_t want) +{ + struct skeletonkey_ctx ctx = {0}; + ctx.host = h; + ctx.json = true; /* silence per-module log lines */ + + skeletonkey_result_t got = m->detect(&ctx); + if (got == want) { + printf("[+] PASS %-40s %s → %s\n", + test_name, m->name, result_str(got)); + g_pass++; + } else { + fprintf(stderr, + "[-] FAIL %-40s %s: want %s, got %s\n", + test_name, m->name, + result_str(want), result_str(got)); + g_fail++; + } +} + +/* ── fingerprints ────────────────────────────────────────────────── */ + +/* Linux 6.12.76 (Debian 13), no userns, no D-Bus, not Ubuntu — a + * deliberately neutered host that lets the host-fingerprint-only + * gates fire without falling into deeper module logic. */ +static const struct skeletonkey_host h_pre7_no_userns_no_dbus = { + .kernel = { .major = 6, .minor = 12, .patch = 76, + .release = "6.12.76-test" }, + .arch = "x86_64", + .nodename = "test", + .distro_id = "debian", + .distro_version_id = "13", + .distro_pretty = "Debian GNU/Linux 13", + .is_linux = true, + .is_debian_family = true, + .unprivileged_userns_allowed = false, + .has_dbus_system = false, + .has_systemd = true, +}; + +/* Fedora 43, no Debian family, userns allowed. */ +static const struct skeletonkey_host h_fedora_no_debian = { + .kernel = { .major = 6, .minor = 14, .patch = 0, + .release = "6.14.0-fedora" }, + .arch = "x86_64", + .nodename = "test", + .distro_id = "fedora", + .distro_version_id = "43", + .distro_pretty = "Fedora 43", + .is_linux = true, + .is_rpm_family = true, + .is_debian_family = false, + .unprivileged_userns_allowed = true, + .has_dbus_system = true, + .has_systemd = true, +}; + +/* Ubuntu 24.04, userns allowed, D-Bus running, Debian family + * (because Ubuntu has /etc/debian_version). Used as the "fragnesia + * preconditions OK" baseline — fragnesia should NOT short-circuit + * on userns/userspace gates here. */ +static const struct skeletonkey_host h_ubuntu_24_userns_ok = { + .kernel = { .major = 6, .minor = 8, .patch = 0, + .release = "6.8.0-ubuntu" }, + .arch = "x86_64", + .nodename = "test", + .distro_id = "ubuntu", + .distro_version_id = "24.04", + .distro_pretty = "Ubuntu 24.04 LTS", + .is_linux = true, + .is_debian_family = true, + .unprivileged_userns_allowed = true, + .has_dbus_system = true, + .has_systemd = true, +}; +#endif /* __linux__ */ + +/* ── tests ───────────────────────────────────────────────────────── */ + +static void run_all(void) +{ +#ifdef __linux__ + /* dirtydecrypt: kernel.major < 7 → predates the bug → OK */ + run_one("dirtydecrypt: kernel 6.12 predates 7.0 → OK", + &dirtydecrypt_module, &h_pre7_no_userns_no_dbus, + SKELETONKEY_OK); + + run_one("dirtydecrypt: kernel 6.14 (fedora) still predates → OK", + &dirtydecrypt_module, &h_fedora_no_debian, + SKELETONKEY_OK); + + run_one("dirtydecrypt: kernel 6.8 (ubuntu) still predates → OK", + &dirtydecrypt_module, &h_ubuntu_24_userns_ok, + SKELETONKEY_OK); + + /* fragnesia: userns disabled → XFRM gate closed → PRECOND_FAIL */ + run_one("fragnesia: userns_allowed=false → PRECOND_FAIL", + &fragnesia_module, &h_pre7_no_userns_no_dbus, + SKELETONKEY_PRECOND_FAIL); + + /* pack2theroot: not Debian family → PRECOND_FAIL */ + run_one("pack2theroot: is_debian_family=false → PRECOND_FAIL", + &pack2theroot_module, &h_fedora_no_debian, + SKELETONKEY_PRECOND_FAIL); + + /* pack2theroot: Debian family but no D-Bus socket → PRECOND_FAIL */ + run_one("pack2theroot: has_dbus_system=false → PRECOND_FAIL", + &pack2theroot_module, &h_pre7_no_userns_no_dbus, + SKELETONKEY_PRECOND_FAIL); + + /* overlayfs: distro != ubuntu → bug is Ubuntu-specific → OK */ + run_one("overlayfs: distro=debian → not Ubuntu → OK", + &overlayfs_module, &h_pre7_no_userns_no_dbus, + SKELETONKEY_OK); + + run_one("overlayfs: distro=fedora → not Ubuntu → OK", + &overlayfs_module, &h_fedora_no_debian, + SKELETONKEY_OK); +#else + fprintf(stderr, "[i] non-Linux platform: detect() bodies are stubbed; " + "tests skipped (would tautologically pass).\n"); +#endif +} + +int main(void) +{ + fprintf(stderr, "=== SKELETONKEY detect() unit tests ===\n\n"); + run_all(); + fprintf(stderr, "\n=== RESULTS: %d passed, %d failed ===\n", + g_pass, g_fail); + return g_fail ? 1 : 0; +}