Skip to content

Commit 6f268d0

Browse files
DeusDatadLo999
andcommitted
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 <dLo999@users.noreply.github.com>
1 parent 64179b8 commit 6f268d0

4 files changed

Lines changed: 188 additions & 21 deletions

File tree

.github/workflows/_smoke.yml

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,7 @@ jobs:
6262
env:
6363
SMOKE_DOWNLOAD_URL: http://localhost:18080
6464

65-
- name: Security audits (standard only)
66-
if: matrix.variant == 'standard'
65+
- name: Security audits
6766
run: |
6867
scripts/security-strings.sh ./codebase-memory-mcp
6968
scripts/security-install.sh ./codebase-memory-mcp
@@ -76,7 +75,7 @@ jobs:
7675
scripts/security-fuzz-random.sh ./codebase-memory-mcp 60
7776
7877
- name: ClamAV scan (Linux)
79-
if: matrix.variant == 'standard' && startsWith(matrix.os, 'ubuntu')
78+
if: startsWith(matrix.os, 'ubuntu')
8079
run: |
8180
sudo apt-get update -qq && sudo apt-get install -y -qq clamav > /dev/null 2>&1
8281
sudo sed -i 's/^Example/#Example/' /etc/clamav/freshclam.conf 2>/dev/null || true
@@ -86,7 +85,7 @@ jobs:
8685
clamscan --no-summary ./codebase-memory-mcp
8786
8887
- name: ClamAV scan (macOS)
89-
if: matrix.variant == 'standard' && startsWith(matrix.os, 'macos')
88+
if: startsWith(matrix.os, 'macos')
9089
run: |
9190
brew install clamav > /dev/null 2>&1
9291
CLAMAV_ETC=$(brew --prefix)/etc/clamav
@@ -149,15 +148,13 @@ jobs:
149148
env:
150149
SMOKE_DOWNLOAD_URL: http://localhost:18080
151150

152-
- name: Security audits (standard only)
153-
if: matrix.variant == 'standard'
151+
- name: Security audits
154152
shell: msys2 {0}
155153
run: |
156154
scripts/security-strings.sh ./codebase-memory-mcp.exe
157155
scripts/security-install.sh ./codebase-memory-mcp.exe
158156
159-
- name: Windows Defender scan (standard only)
160-
if: matrix.variant == 'standard'
157+
- name: Windows Defender scan
161158
shell: pwsh
162159
run: |
163160
& "C:\Program Files\Windows Defender\MpCmdRun.exe" -SignatureUpdate 2>$null
@@ -192,8 +189,7 @@ jobs:
192189
chmod +x codebase-memory-mcp
193190
scripts/smoke-test.sh ./codebase-memory-mcp
194191
195-
- name: Security audits (standard only)
196-
if: matrix.variant == 'standard'
192+
- name: Security audits
197193
run: |
198194
scripts/security-strings.sh ./codebase-memory-mcp
199195
scripts/security-install.sh ./codebase-memory-mcp

scripts/security-strings.sh

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,22 @@ ALLOWED_URLS=(
5454
"https://bugs.launchpad.net"
5555
"https://gcc.gnu.org"
5656
"https://sourceware.org"
57+
# W3C XML namespace URIs (SVG, MathML, XLink — used in UI bundle)
58+
"http://www.w3.org/"
59+
# UI bundle: React, Three.js, Tailwind, Google Fonts, bundled libraries
60+
"https://react.dev"
61+
"https://fonts.googleapis.com"
62+
"https://fonts.gstatic.com"
63+
"https://tailwindcss.com"
64+
"https://cdn.jsdelivr.net"
65+
"https://docs.pmnd.rs"
66+
"https://jcgt.org"
67+
"https://github.com/pmndrs"
68+
"https://github.com/react-spring"
69+
"https://github.com/101arrowz"
70+
"https://github.com/arty-name"
71+
"https://github.com/fredli74"
72+
"https://github.com/lojjic"
5773
)
5874

5975
while IFS= read -r url; do

src/discover/discover.c

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -228,16 +228,36 @@ static void fl_add(file_list_t *fl, const char *abs_path, const char *rel_path,
228228

229229
/* ── Recursive walk ──────────────────────────────────────────────── */
230230

231+
/* Compute path relative to a nested .gitignore's directory.
232+
* "webapp/src/foo.js" with prefix "webapp" → "src/foo.js". */
233+
static const char *local_rel_path(const char *rel_path, const char *local_prefix) {
234+
if (!local_prefix || local_prefix[0] == '\0') {
235+
return rel_path;
236+
}
237+
size_t prefix_len = strlen(local_prefix);
238+
if (strncmp(rel_path, local_prefix, prefix_len) == 0 && rel_path[prefix_len] == '/') {
239+
return rel_path + prefix_len + SKIP_ONE;
240+
}
241+
return rel_path;
242+
}
243+
231244
/* Check if a directory entry should be skipped (hardcoded dirs + gitignore). */
232245
static bool should_skip_directory(const char *entry_name, const char *rel_path,
233246
const cbm_discover_opts_t *opts, const cbm_gitignore_t *gitignore,
234-
const cbm_gitignore_t *cbmignore) {
247+
const cbm_gitignore_t *cbmignore, const cbm_gitignore_t *local_gi,
248+
const char *local_gi_prefix) {
235249
if (cbm_should_skip_dir(entry_name, opts ? opts->mode : CBM_MODE_FULL)) {
236250
return true;
237251
}
238252
if (gitignore && cbm_gitignore_matches(gitignore, rel_path, true)) {
239253
return true;
240254
}
255+
if (local_gi) {
256+
const char *lrel = local_rel_path(rel_path, local_gi_prefix);
257+
if (cbm_gitignore_matches(local_gi, lrel, true)) {
258+
return true;
259+
}
260+
}
241261
if (cbmignore && cbm_gitignore_matches(cbmignore, rel_path, true)) {
242262
return true;
243263
}
@@ -247,7 +267,8 @@ static bool should_skip_directory(const char *entry_name, const char *rel_path,
247267
/* Check if a regular file should be skipped (filters + gitignore + size). */
248268
static bool should_skip_file(const char *entry_name, const char *rel_path,
249269
const cbm_discover_opts_t *opts, const cbm_gitignore_t *gitignore,
250-
const cbm_gitignore_t *cbmignore, off_t file_size) {
270+
const cbm_gitignore_t *cbmignore, const cbm_gitignore_t *local_gi,
271+
const char *local_gi_prefix, off_t file_size) {
251272
cbm_index_mode_t mode = opts ? opts->mode : CBM_MODE_FULL;
252273
if (cbm_has_ignored_suffix(entry_name, mode)) {
253274
return true;
@@ -261,6 +282,12 @@ static bool should_skip_file(const char *entry_name, const char *rel_path,
261282
if (gitignore && cbm_gitignore_matches(gitignore, rel_path, false)) {
262283
return true;
263284
}
285+
if (local_gi) {
286+
const char *lrel = local_rel_path(rel_path, local_gi_prefix);
287+
if (cbm_gitignore_matches(local_gi, lrel, false)) {
288+
return true;
289+
}
290+
}
264291
if (cbmignore && cbm_gitignore_matches(cbmignore, rel_path, false)) {
265292
return true;
266293
}
@@ -308,8 +335,10 @@ static int safe_stat(const char *abs_path, struct stat *st) {
308335
/* Process a single regular file entry during directory walk. */
309336
static void walk_dir_process_file(const char *abs_path, const char *rel_path, const char *name,
310337
const cbm_discover_opts_t *opts, const cbm_gitignore_t *gitignore,
311-
const cbm_gitignore_t *cbmignore, off_t size, file_list_t *out) {
312-
if (should_skip_file(name, rel_path, opts, gitignore, cbmignore, size)) {
338+
const cbm_gitignore_t *cbmignore, const cbm_gitignore_t *local_gi,
339+
const char *local_gi_prefix, off_t size, file_list_t *out) {
340+
if (should_skip_file(name, rel_path, opts, gitignore, cbmignore, local_gi, local_gi_prefix,
341+
size)) {
313342
return;
314343
}
315344
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
322351
typedef struct {
323352
char dir[CBM_SZ_4K];
324353
char prefix[CBM_SZ_4K];
354+
cbm_gitignore_t *local_gi; /* nested .gitignore for this subtree */
355+
char local_gi_prefix[CBM_SZ_4K]; /* rel_prefix when local_gi was loaded */
325356
} walk_frame_t;
326357
#define WALK_STACK_CAP 512
327358
/* Build abs/rel paths and process one directory entry. */
359+
/* Try to load a nested .gitignore from this directory. Returns owned pointer or NULL. */
360+
static cbm_gitignore_t *try_load_nested_gitignore(const walk_frame_t *frame) {
361+
if (frame->local_gi || frame->prefix[0] == '\0') {
362+
return NULL;
363+
}
364+
char gi_path[CBM_SZ_4K];
365+
snprintf(gi_path, sizeof(gi_path), "%s/.gitignore", frame->dir);
366+
struct stat gi_st;
367+
if (stat(gi_path, &gi_st) == 0 && S_ISREG(gi_st.st_mode)) {
368+
return cbm_gitignore_load(gi_path);
369+
}
370+
return NULL;
371+
}
372+
373+
/* Push a subdirectory onto the walk stack, inheriting local gitignore context. */
374+
static void walk_push_subdir(walk_frame_t *stack, int *top, const char *abs_path,
375+
const char *rel_path, const walk_frame_t *parent) {
376+
if (*top >= WALK_STACK_CAP) {
377+
return;
378+
}
379+
snprintf(stack[*top].dir, CBM_SZ_4K, "%s", abs_path);
380+
snprintf(stack[*top].prefix, CBM_SZ_4K, "%s", rel_path);
381+
stack[*top].local_gi = parent->local_gi;
382+
snprintf(stack[*top].local_gi_prefix, CBM_SZ_4K, "%s", parent->local_gi_prefix);
383+
(*top)++;
384+
}
385+
328386
static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *frame,
329387
const cbm_discover_opts_t *opts,
330388
const cbm_gitignore_t *gitignore,
@@ -345,33 +403,47 @@ static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *fram
345403
}
346404

347405
if (S_ISDIR(st.st_mode)) {
348-
if (!should_skip_directory(entry->name, rel_path, opts, gitignore, cbmignore)) {
349-
if (*top < WALK_STACK_CAP) {
350-
snprintf(stack[*top].dir, CBM_SZ_4K, "%s", abs_path);
351-
snprintf(stack[*top].prefix, CBM_SZ_4K, "%s", rel_path);
352-
(*top)++;
353-
}
406+
if (!should_skip_directory(entry->name, rel_path, opts, gitignore, cbmignore,
407+
frame->local_gi, frame->local_gi_prefix)) {
408+
walk_push_subdir(stack, top, abs_path, rel_path, frame);
354409
}
355410
} else if (S_ISREG(st.st_mode)) {
356411
walk_dir_process_file(abs_path, rel_path, entry->name, opts, gitignore, cbmignore,
357-
st.st_size, out);
412+
frame->local_gi, frame->local_gi_prefix, st.st_size, out);
358413
}
359414
}
360415

416+
enum { GI_OWNED_CAP = 64 };
417+
361418
static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_discover_opts_t *opts,
362419
const cbm_gitignore_t *gitignore, const cbm_gitignore_t *cbmignore,
363420
file_list_t *out) {
364421
walk_frame_t *stack = calloc(WALK_STACK_CAP, sizeof(walk_frame_t));
365422
if (!stack) {
366423
return;
367424
}
425+
/* Collect all owned gitignores — freed at the end because child frames
426+
* on the stack hold borrowed pointers to them. */
427+
cbm_gitignore_t *owned_gis[GI_OWNED_CAP];
428+
int owned_count = 0;
429+
368430
int top = 0;
369431
snprintf(stack[top].dir, CBM_SZ_4K, "%s", dir_path);
370432
snprintf(stack[top].prefix, CBM_SZ_4K, "%s", rel_prefix);
371433
top++;
372434

373435
while (top > 0) {
374436
walk_frame_t frame = stack[--top];
437+
438+
cbm_gitignore_t *loaded = try_load_nested_gitignore(&frame);
439+
if (loaded) {
440+
frame.local_gi = loaded;
441+
snprintf(frame.local_gi_prefix, sizeof(frame.local_gi_prefix), "%s", frame.prefix);
442+
if (owned_count < GI_OWNED_CAP) {
443+
owned_gis[owned_count++] = loaded;
444+
}
445+
}
446+
375447
cbm_dir_t *d = cbm_opendir(frame.dir);
376448
if (!d) {
377449
continue;
@@ -383,6 +455,9 @@ static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_dis
383455
}
384456
cbm_closedir(d);
385457
}
458+
for (int i = 0; i < owned_count; i++) {
459+
cbm_gitignore_free(owned_gis[i]);
460+
}
386461
free(stack);
387462
}
388463

tests/test_discover.c

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,82 @@ TEST(discover_cbmignore_no_git) {
593593
PASS();
594594
}
595595

596+
/* ── Nested .gitignore tests (issue #178) ──────────────────────── */
597+
598+
TEST(discover_nested_gitignore) {
599+
char *base = th_mktempdir("cbm_disc_ngi");
600+
ASSERT(base != NULL);
601+
602+
th_mkdir_p(TH_PATH(base, ".git"));
603+
th_write_file(TH_PATH(base, "main.go"), "package main\n");
604+
th_write_file(TH_PATH(base, "webapp/.gitignore"), "generated/\n");
605+
th_write_file(TH_PATH(base, "webapp/src/routes.js"), "export default []\n");
606+
th_write_file(TH_PATH(base, "webapp/generated/types.js"), "export {}\n");
607+
608+
cbm_discover_opts_t opts = {0};
609+
cbm_file_info_t *files = NULL;
610+
int count = 0;
611+
int rc = cbm_discover(base, &opts, &files, &count);
612+
ASSERT_EQ(rc, 0);
613+
614+
bool found_generated = false;
615+
bool found_routes = false;
616+
for (int i = 0; i < count; i++) {
617+
if (strstr(files[i].rel_path, "generated"))
618+
found_generated = true;
619+
if (strstr(files[i].rel_path, "routes.js"))
620+
found_routes = true;
621+
}
622+
ASSERT_FALSE(found_generated);
623+
ASSERT_TRUE(found_routes);
624+
625+
cbm_discover_free(files, count);
626+
th_cleanup(base);
627+
PASS();
628+
}
629+
630+
TEST(discover_nested_gitignore_stacks_with_root) {
631+
char *base = th_mktempdir("cbm_disc_ngi_stack");
632+
ASSERT(base != NULL);
633+
634+
th_mkdir_p(TH_PATH(base, ".git"));
635+
th_write_file(TH_PATH(base, ".gitignore"), "*.log\n");
636+
th_write_file(TH_PATH(base, "webapp/.gitignore"), ".output/\n");
637+
th_write_file(TH_PATH(base, "main.go"), "package main\n");
638+
th_write_file(TH_PATH(base, "error.log"), "error log\n");
639+
th_write_file(TH_PATH(base, "webapp/src/app.js"), "const x = 1\n");
640+
th_write_file(TH_PATH(base, "webapp/.output/data.js"), "output data\n");
641+
642+
cbm_discover_opts_t opts = {0};
643+
cbm_file_info_t *files = NULL;
644+
int count = 0;
645+
int rc = cbm_discover(base, &opts, &files, &count);
646+
ASSERT_EQ(rc, 0);
647+
648+
bool found_log = false;
649+
bool found_output = false;
650+
bool found_main = false;
651+
bool found_app = false;
652+
for (int i = 0; i < count; i++) {
653+
if (strstr(files[i].rel_path, ".log"))
654+
found_log = true;
655+
if (strstr(files[i].rel_path, ".output"))
656+
found_output = true;
657+
if (strstr(files[i].rel_path, "main.go"))
658+
found_main = true;
659+
if (strstr(files[i].rel_path, "app.js"))
660+
found_app = true;
661+
}
662+
ASSERT_FALSE(found_log);
663+
ASSERT_FALSE(found_output);
664+
ASSERT_TRUE(found_main);
665+
ASSERT_TRUE(found_app);
666+
667+
cbm_discover_free(files, count);
668+
th_cleanup(base);
669+
PASS();
670+
}
671+
596672
/* ── Suite ─────────────────────────────────────────────────────── */
597673

598674
SUITE(discover) {
@@ -681,4 +757,8 @@ SUITE(discover) {
681757
RUN_TEST(discover_generic_dirs_full_mode);
682758
RUN_TEST(discover_generic_dirs_fast_mode);
683759
RUN_TEST(discover_cbmignore_no_git);
760+
761+
/* Nested .gitignore tests (issue #178) */
762+
RUN_TEST(discover_nested_gitignore);
763+
RUN_TEST(discover_nested_gitignore_stacks_with_root);
684764
}

0 commit comments

Comments
 (0)