From 6f268d0f91b10a96a7a10bedefae9a779d8da7ef Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 6 Apr 2026 19:44:16 +0200 Subject: [PATCH] Respect nested .gitignore files during indexing, security audit all variants Nested .gitignore support (fixes #178): - Load per-subdirectory .gitignore during walk, match paths relative to the gitignore's directory via local_rel_path() - Root and nested gitignores stack independently - Owned gitignores collected and freed at walk end (avoids use-after-free from borrowed pointers on the iterative stack) Security: - Run security-strings/install/network + ClamAV + Windows Defender on ALL binary variants (standard + UI), not just standard - Whitelist UI bundle URLs (React, Three.js, Google Fonts, Tailwind, W3C) Co-Authored-By: dLo999 --- .github/workflows/_smoke.yml | 16 +++--- scripts/security-strings.sh | 16 ++++++ src/discover/discover.c | 97 ++++++++++++++++++++++++++++++++---- tests/test_discover.c | 80 +++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 21 deletions(-) diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index 5bc880084..7a1b89371 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -62,8 +62,7 @@ jobs: env: SMOKE_DOWNLOAD_URL: http://localhost:18080 - - name: Security audits (standard only) - if: matrix.variant == 'standard' + - name: Security audits run: | scripts/security-strings.sh ./codebase-memory-mcp scripts/security-install.sh ./codebase-memory-mcp @@ -76,7 +75,7 @@ jobs: scripts/security-fuzz-random.sh ./codebase-memory-mcp 60 - name: ClamAV scan (Linux) - if: matrix.variant == 'standard' && startsWith(matrix.os, 'ubuntu') + if: startsWith(matrix.os, 'ubuntu') run: | sudo apt-get update -qq && sudo apt-get install -y -qq clamav > /dev/null 2>&1 sudo sed -i 's/^Example/#Example/' /etc/clamav/freshclam.conf 2>/dev/null || true @@ -86,7 +85,7 @@ jobs: clamscan --no-summary ./codebase-memory-mcp - name: ClamAV scan (macOS) - if: matrix.variant == 'standard' && startsWith(matrix.os, 'macos') + if: startsWith(matrix.os, 'macos') run: | brew install clamav > /dev/null 2>&1 CLAMAV_ETC=$(brew --prefix)/etc/clamav @@ -149,15 +148,13 @@ jobs: env: SMOKE_DOWNLOAD_URL: http://localhost:18080 - - name: Security audits (standard only) - if: matrix.variant == 'standard' + - name: Security audits shell: msys2 {0} run: | scripts/security-strings.sh ./codebase-memory-mcp.exe scripts/security-install.sh ./codebase-memory-mcp.exe - - name: Windows Defender scan (standard only) - if: matrix.variant == 'standard' + - name: Windows Defender scan shell: pwsh run: | & "C:\Program Files\Windows Defender\MpCmdRun.exe" -SignatureUpdate 2>$null @@ -192,8 +189,7 @@ jobs: chmod +x codebase-memory-mcp scripts/smoke-test.sh ./codebase-memory-mcp - - name: Security audits (standard only) - if: matrix.variant == 'standard' + - name: Security audits run: | scripts/security-strings.sh ./codebase-memory-mcp scripts/security-install.sh ./codebase-memory-mcp diff --git a/scripts/security-strings.sh b/scripts/security-strings.sh index f08941513..a66eec003 100755 --- a/scripts/security-strings.sh +++ b/scripts/security-strings.sh @@ -54,6 +54,22 @@ ALLOWED_URLS=( "https://bugs.launchpad.net" "https://gcc.gnu.org" "https://sourceware.org" + # W3C XML namespace URIs (SVG, MathML, XLink — used in UI bundle) + "http://www.w3.org/" + # UI bundle: React, Three.js, Tailwind, Google Fonts, bundled libraries + "https://react.dev" + "https://fonts.googleapis.com" + "https://fonts.gstatic.com" + "https://tailwindcss.com" + "https://cdn.jsdelivr.net" + "https://docs.pmnd.rs" + "https://jcgt.org" + "https://github.com/pmndrs" + "https://github.com/react-spring" + "https://github.com/101arrowz" + "https://github.com/arty-name" + "https://github.com/fredli74" + "https://github.com/lojjic" ) while IFS= read -r url; do diff --git a/src/discover/discover.c b/src/discover/discover.c index 02085617f..b1e49ae89 100644 --- a/src/discover/discover.c +++ b/src/discover/discover.c @@ -228,16 +228,36 @@ static void fl_add(file_list_t *fl, const char *abs_path, const char *rel_path, /* ── Recursive walk ──────────────────────────────────────────────── */ +/* Compute path relative to a nested .gitignore's directory. + * "webapp/src/foo.js" with prefix "webapp" → "src/foo.js". */ +static const char *local_rel_path(const char *rel_path, const char *local_prefix) { + if (!local_prefix || local_prefix[0] == '\0') { + return rel_path; + } + size_t prefix_len = strlen(local_prefix); + if (strncmp(rel_path, local_prefix, prefix_len) == 0 && rel_path[prefix_len] == '/') { + return rel_path + prefix_len + SKIP_ONE; + } + return rel_path; +} + /* Check if a directory entry should be skipped (hardcoded dirs + gitignore). */ static bool should_skip_directory(const char *entry_name, const char *rel_path, const cbm_discover_opts_t *opts, const cbm_gitignore_t *gitignore, - const cbm_gitignore_t *cbmignore) { + const cbm_gitignore_t *cbmignore, const cbm_gitignore_t *local_gi, + const char *local_gi_prefix) { if (cbm_should_skip_dir(entry_name, opts ? opts->mode : CBM_MODE_FULL)) { return true; } if (gitignore && cbm_gitignore_matches(gitignore, rel_path, true)) { return true; } + if (local_gi) { + const char *lrel = local_rel_path(rel_path, local_gi_prefix); + if (cbm_gitignore_matches(local_gi, lrel, true)) { + return true; + } + } if (cbmignore && cbm_gitignore_matches(cbmignore, rel_path, true)) { return true; } @@ -247,7 +267,8 @@ static bool should_skip_directory(const char *entry_name, const char *rel_path, /* Check if a regular file should be skipped (filters + gitignore + size). */ static bool should_skip_file(const char *entry_name, const char *rel_path, const cbm_discover_opts_t *opts, const cbm_gitignore_t *gitignore, - const cbm_gitignore_t *cbmignore, off_t file_size) { + const cbm_gitignore_t *cbmignore, const cbm_gitignore_t *local_gi, + const char *local_gi_prefix, off_t file_size) { cbm_index_mode_t mode = opts ? opts->mode : CBM_MODE_FULL; if (cbm_has_ignored_suffix(entry_name, mode)) { return true; @@ -261,6 +282,12 @@ static bool should_skip_file(const char *entry_name, const char *rel_path, if (gitignore && cbm_gitignore_matches(gitignore, rel_path, false)) { return true; } + if (local_gi) { + const char *lrel = local_rel_path(rel_path, local_gi_prefix); + if (cbm_gitignore_matches(local_gi, lrel, false)) { + return true; + } + } if (cbmignore && cbm_gitignore_matches(cbmignore, rel_path, false)) { return true; } @@ -308,8 +335,10 @@ static int safe_stat(const char *abs_path, struct stat *st) { /* Process a single regular file entry during directory walk. */ static void walk_dir_process_file(const char *abs_path, const char *rel_path, const char *name, const cbm_discover_opts_t *opts, const cbm_gitignore_t *gitignore, - const cbm_gitignore_t *cbmignore, off_t size, file_list_t *out) { - if (should_skip_file(name, rel_path, opts, gitignore, cbmignore, size)) { + const cbm_gitignore_t *cbmignore, const cbm_gitignore_t *local_gi, + const char *local_gi_prefix, off_t size, file_list_t *out) { + if (should_skip_file(name, rel_path, opts, gitignore, cbmignore, local_gi, local_gi_prefix, + size)) { return; } CBMLanguage lang = detect_file_language(name, abs_path); @@ -322,9 +351,38 @@ static void walk_dir_process_file(const char *abs_path, const char *rel_path, co typedef struct { char dir[CBM_SZ_4K]; char prefix[CBM_SZ_4K]; + cbm_gitignore_t *local_gi; /* nested .gitignore for this subtree */ + char local_gi_prefix[CBM_SZ_4K]; /* rel_prefix when local_gi was loaded */ } walk_frame_t; #define WALK_STACK_CAP 512 /* Build abs/rel paths and process one directory entry. */ +/* Try to load a nested .gitignore from this directory. Returns owned pointer or NULL. */ +static cbm_gitignore_t *try_load_nested_gitignore(const walk_frame_t *frame) { + if (frame->local_gi || frame->prefix[0] == '\0') { + return NULL; + } + char gi_path[CBM_SZ_4K]; + snprintf(gi_path, sizeof(gi_path), "%s/.gitignore", frame->dir); + struct stat gi_st; + if (stat(gi_path, &gi_st) == 0 && S_ISREG(gi_st.st_mode)) { + return cbm_gitignore_load(gi_path); + } + return NULL; +} + +/* Push a subdirectory onto the walk stack, inheriting local gitignore context. */ +static void walk_push_subdir(walk_frame_t *stack, int *top, const char *abs_path, + const char *rel_path, const walk_frame_t *parent) { + if (*top >= WALK_STACK_CAP) { + return; + } + snprintf(stack[*top].dir, CBM_SZ_4K, "%s", abs_path); + snprintf(stack[*top].prefix, CBM_SZ_4K, "%s", rel_path); + stack[*top].local_gi = parent->local_gi; + snprintf(stack[*top].local_gi_prefix, CBM_SZ_4K, "%s", parent->local_gi_prefix); + (*top)++; +} + static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *frame, const cbm_discover_opts_t *opts, const cbm_gitignore_t *gitignore, @@ -345,19 +403,18 @@ static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *fram } if (S_ISDIR(st.st_mode)) { - if (!should_skip_directory(entry->name, rel_path, opts, gitignore, cbmignore)) { - if (*top < WALK_STACK_CAP) { - snprintf(stack[*top].dir, CBM_SZ_4K, "%s", abs_path); - snprintf(stack[*top].prefix, CBM_SZ_4K, "%s", rel_path); - (*top)++; - } + if (!should_skip_directory(entry->name, rel_path, opts, gitignore, cbmignore, + frame->local_gi, frame->local_gi_prefix)) { + walk_push_subdir(stack, top, abs_path, rel_path, frame); } } else if (S_ISREG(st.st_mode)) { walk_dir_process_file(abs_path, rel_path, entry->name, opts, gitignore, cbmignore, - st.st_size, out); + frame->local_gi, frame->local_gi_prefix, st.st_size, out); } } +enum { GI_OWNED_CAP = 64 }; + static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_discover_opts_t *opts, const cbm_gitignore_t *gitignore, const cbm_gitignore_t *cbmignore, file_list_t *out) { @@ -365,6 +422,11 @@ static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_dis if (!stack) { return; } + /* Collect all owned gitignores — freed at the end because child frames + * on the stack hold borrowed pointers to them. */ + cbm_gitignore_t *owned_gis[GI_OWNED_CAP]; + int owned_count = 0; + int top = 0; snprintf(stack[top].dir, CBM_SZ_4K, "%s", dir_path); snprintf(stack[top].prefix, CBM_SZ_4K, "%s", rel_prefix); @@ -372,6 +434,16 @@ static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_dis while (top > 0) { walk_frame_t frame = stack[--top]; + + cbm_gitignore_t *loaded = try_load_nested_gitignore(&frame); + if (loaded) { + frame.local_gi = loaded; + snprintf(frame.local_gi_prefix, sizeof(frame.local_gi_prefix), "%s", frame.prefix); + if (owned_count < GI_OWNED_CAP) { + owned_gis[owned_count++] = loaded; + } + } + cbm_dir_t *d = cbm_opendir(frame.dir); if (!d) { continue; @@ -383,6 +455,9 @@ static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_dis } cbm_closedir(d); } + for (int i = 0; i < owned_count; i++) { + cbm_gitignore_free(owned_gis[i]); + } free(stack); } diff --git a/tests/test_discover.c b/tests/test_discover.c index ec0dda0b1..94f6b8a8f 100644 --- a/tests/test_discover.c +++ b/tests/test_discover.c @@ -593,6 +593,82 @@ TEST(discover_cbmignore_no_git) { PASS(); } +/* ── Nested .gitignore tests (issue #178) ──────────────────────── */ + +TEST(discover_nested_gitignore) { + char *base = th_mktempdir("cbm_disc_ngi"); + ASSERT(base != NULL); + + th_mkdir_p(TH_PATH(base, ".git")); + th_write_file(TH_PATH(base, "main.go"), "package main\n"); + th_write_file(TH_PATH(base, "webapp/.gitignore"), "generated/\n"); + th_write_file(TH_PATH(base, "webapp/src/routes.js"), "export default []\n"); + th_write_file(TH_PATH(base, "webapp/generated/types.js"), "export {}\n"); + + cbm_discover_opts_t opts = {0}; + cbm_file_info_t *files = NULL; + int count = 0; + int rc = cbm_discover(base, &opts, &files, &count); + ASSERT_EQ(rc, 0); + + bool found_generated = false; + bool found_routes = false; + for (int i = 0; i < count; i++) { + if (strstr(files[i].rel_path, "generated")) + found_generated = true; + if (strstr(files[i].rel_path, "routes.js")) + found_routes = true; + } + ASSERT_FALSE(found_generated); + ASSERT_TRUE(found_routes); + + cbm_discover_free(files, count); + th_cleanup(base); + PASS(); +} + +TEST(discover_nested_gitignore_stacks_with_root) { + char *base = th_mktempdir("cbm_disc_ngi_stack"); + ASSERT(base != NULL); + + th_mkdir_p(TH_PATH(base, ".git")); + th_write_file(TH_PATH(base, ".gitignore"), "*.log\n"); + th_write_file(TH_PATH(base, "webapp/.gitignore"), ".output/\n"); + th_write_file(TH_PATH(base, "main.go"), "package main\n"); + th_write_file(TH_PATH(base, "error.log"), "error log\n"); + th_write_file(TH_PATH(base, "webapp/src/app.js"), "const x = 1\n"); + th_write_file(TH_PATH(base, "webapp/.output/data.js"), "output data\n"); + + cbm_discover_opts_t opts = {0}; + cbm_file_info_t *files = NULL; + int count = 0; + int rc = cbm_discover(base, &opts, &files, &count); + ASSERT_EQ(rc, 0); + + bool found_log = false; + bool found_output = false; + bool found_main = false; + bool found_app = false; + for (int i = 0; i < count; i++) { + if (strstr(files[i].rel_path, ".log")) + found_log = true; + if (strstr(files[i].rel_path, ".output")) + found_output = true; + if (strstr(files[i].rel_path, "main.go")) + found_main = true; + if (strstr(files[i].rel_path, "app.js")) + found_app = true; + } + ASSERT_FALSE(found_log); + ASSERT_FALSE(found_output); + ASSERT_TRUE(found_main); + ASSERT_TRUE(found_app); + + cbm_discover_free(files, count); + th_cleanup(base); + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────── */ SUITE(discover) { @@ -681,4 +757,8 @@ SUITE(discover) { RUN_TEST(discover_generic_dirs_full_mode); RUN_TEST(discover_generic_dirs_fast_mode); RUN_TEST(discover_cbmignore_no_git); + + /* Nested .gitignore tests (issue #178) */ + RUN_TEST(discover_nested_gitignore); + RUN_TEST(discover_nested_gitignore_stacks_with_root); }