tests: detect() unit harness with mocked ctx->host
Adds tests/test_detect.c — a standalone harness that constructs synthetic struct skeletonkey_host fingerprints (vulnerable / patched / specific-gate-closed) and asserts each migrated module's detect() returns the expected verdict. First real test coverage for the corpus; catches regressions in the host-fingerprint-consuming logic. Initial coverage — 8 deterministic cases across the 4 modules that already consume ctx->host: - dirtydecrypt: 3 cases verifying 'kernel < 7.0 -> predates the bug' short-circuit on synthetic 6.12 / 6.14 / 6.8 hosts. - fragnesia: unprivileged_userns_allowed=false -> PRECOND_FAIL. - pack2theroot: is_debian_family=false -> PRECOND_FAIL. - pack2theroot: has_dbus_system=false -> PRECOND_FAIL. - overlayfs: distro=debian / distro=fedora -> 'not Ubuntu' -> OK. Coverage grows automatically as more modules migrate to ctx->host (task #12 below adds them). Each new module that consults the host fingerprint can have its precondition gates tested with a one-line EXPECT_DETECT call against a pre-built fingerprint. Wiring: - Makefile: new MODULE_OBJS var consolidates the module .o list so both the main binary and the test binary can share it without duplication. New TEST_BIN := skeletonkey-test target. 'make test' builds and runs the suite. - .github/workflows/build.yml: install libglib2.0-dev + pkg-config so pack2theroot builds with GLib in CI (was previously stub-compiling). New 'tests — detect() unit suite' step runs 'make test' as a non-root user so modules' 'already root' gates don't short-circuit before the synthetic host checks fire. - Test harness compiles cross-platform but assertions are #ifdef __linux__ guarded (on non-Linux all module detect() bodies stub-out to PRECOND_FAIL, making assertions tautological); macOS dev build reports 'skipped'. Module change: - pack2theroot p2tr_detect now consults ctx->host->is_root (with a geteuid() fallback when ctx->host is null) instead of calling geteuid() directly. Production behaviour is identical (host->is_root is populated from geteuid() at startup); tests can now construct non-root fingerprints regardless of the test process's actual euid. Exposed a real consistency issue worth fixing. Verified in docker as non-root: 8/8 pass on Linux. macOS reports 'skipped' as designed.
This commit is contained in:
@@ -22,7 +22,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -y --no-install-recommends \
|
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
|
- name: show compiler
|
||||||
run: ${{ matrix.cc }} --version
|
run: ${{ matrix.cc }} --version
|
||||||
@@ -54,6 +55,18 @@ jobs:
|
|||||||
- name: sanity — --detect-rules sigma
|
- name: sanity — --detect-rules sigma
|
||||||
run: ./skeletonkey --detect-rules --format=sigma | head -50
|
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
|
# Static build job: ensures the project links cleanly when -static is
|
||||||
# requested. Useful for deployment to minimal containers / fleet scans
|
# requested. Useful for deployment to minimal containers / fleet scans
|
||||||
# where shared-libc availability isn't guaranteed.
|
# where shared-libc availability isn't guaranteed.
|
||||||
@@ -66,7 +79,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -y --no-install-recommends \
|
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
|
- name: make static
|
||||||
# Glibc static linking pulls in NSS at runtime which breaks
|
# Glibc static linking pulls in NSS at runtime which breaks
|
||||||
# getpwnam; the legacy DIRTYFAIL Makefile noted this. For now,
|
# getpwnam; the legacy DIRTYFAIL Makefile noted this. For now,
|
||||||
|
|||||||
@@ -177,15 +177,40 @@ $(P2TR_OBJS): CFLAGS += $(P2TR_CFLAGS)
|
|||||||
# Top-level dispatcher
|
# Top-level dispatcher
|
||||||
TOP_OBJ := $(BUILD)/skeletonkey.o
|
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)
|
all: $(BIN)
|
||||||
|
|
||||||
$(BIN): $(ALL_OBJS)
|
$(BIN): $(ALL_OBJS)
|
||||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lpthread $(P2TR_LIBS)
|
$(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/
|
# Generic compile: any .c → corresponding .o under build/
|
||||||
$(BUILD)/%.o: %.c
|
$(BUILD)/%.o: %.c
|
||||||
@mkdir -p $(dir $@)
|
@mkdir -p $(dir $@)
|
||||||
@@ -198,13 +223,14 @@ static: LDFLAGS += -static
|
|||||||
static: clean $(BIN)
|
static: clean $(BIN)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(BUILD) $(BIN)
|
rm -rf $(BUILD) $(BIN) $(TEST_BIN)
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Targets:"
|
@echo "Targets:"
|
||||||
@echo " make build optimized skeletonkey binary"
|
@echo " make build optimized skeletonkey binary"
|
||||||
@echo " make debug build with -O0 -g3"
|
@echo " make debug build with -O0 -g3"
|
||||||
@echo " make static build a fully static binary"
|
@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 " make clean remove build artifacts"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Per-module (legacy) — not built by default:"
|
@echo "Per-module (legacy) — not built by default:"
|
||||||
|
|||||||
@@ -305,7 +305,12 @@ static skeletonkey_result_t p2tr_detect(const struct skeletonkey_ctx *ctx)
|
|||||||
{
|
{
|
||||||
p2tr_verbose = !ctx->json;
|
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)
|
if (!ctx->json)
|
||||||
fprintf(stderr, "[i] pack2theroot: already root — nothing to do\n");
|
fprintf(stderr, "[i] pack2theroot: already root — nothing to do\n");
|
||||||
return SKELETONKEY_OK;
|
return SKELETONKEY_OK;
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
@@ -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 <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user