Skip to content

Commit aa2b60b

Browse files
committed
Fix 29 CodeQL alerts: command injection, snprintf overflow, TOCTOU races
- Command injection (CRITICAL): validate shell args before system() in update command's unzip and version-check calls - TOCTOU cli.c: use open(O_CREAT, 0755) + fdopen() to set permissions atomically instead of fopen() + chmod() after close - TOCTOU pass_envscan.c: open file first, then fstat() on fd to check size, eliminating stat-then-open race window - Overflowing snprintf (11 locations): clamp offset after each append to prevent unsigned underflow on truncation in cypher.c, store.c, http_server.c, test_c_lsp.c - Add CBM_SNPRINTF_APPEND macro in str_util.h for future safe appends - CodeQL: remove pull_request trigger (only scan push to main) - CodeQL gate: increase timeout from 30 to 45 minutes - Add fuzz testing script (random JSON-RPC + Cypher mutations) - 12 Scorecard governance alerts dismissed (not code vulnerabilities)
1 parent 4322116 commit aa2b60b

File tree

12 files changed

+291
-39
lines changed

12 files changed

+291
-39
lines changed

.github/workflows/codeql.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ name: CodeQL SAST
33
on:
44
push:
55
branches: [main]
6-
pull_request:
7-
branches: [main]
86

97
permissions:
108
security-events: write

.github/workflows/dry-run.yml

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,20 +80,20 @@ jobs:
8080
if: ${{ !inputs.skip_lint }}
8181
runs-on: ubuntu-latest
8282
steps:
83-
- name: Wait for CodeQL on current commit (max 30 min)
83+
- name: Wait for CodeQL on current commit (max 45 min)
8484
env:
8585
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
8686
run: |
8787
CURRENT_SHA="${{ github.sha }}"
8888
echo "Current commit: $CURRENT_SHA"
8989
echo "Waiting for CodeQL to complete on this commit..."
9090
91-
for attempt in $(seq 1 60); do
91+
for attempt in $(seq 1 90); do
9292
LATEST=$(gh api repos/${{ github.repository }}/actions/workflows/codeql.yml/runs?per_page=5 \
9393
--jq '.workflow_runs[] | select(.head_sha == "'"$CURRENT_SHA"'") | "\(.conclusion) \(.status)"' 2>/dev/null | head -1 || echo "")
9494
9595
if [ -z "$LATEST" ]; then
96-
echo " Attempt $attempt/60: No CodeQL run found for $CURRENT_SHA yet..."
96+
echo " Attempt $attempt/90: No CodeQL run found for $CURRENT_SHA yet..."
9797
sleep 30
9898
continue
9999
fi
@@ -109,11 +109,11 @@ jobs:
109109
exit 1
110110
fi
111111
112-
echo " Attempt $attempt/60: CodeQL status=$STATUS (waiting 30s)..."
112+
echo " Attempt $attempt/90: CodeQL status=$STATUS (waiting 30s)..."
113113
sleep 30
114114
done
115115
116-
echo "BLOCKED: CodeQL did not complete within 30 minutes"
116+
echo "BLOCKED: CodeQL did not complete within 45 minutes"
117117
exit 1
118118
119119
- name: Check for open code scanning alerts
@@ -358,6 +358,10 @@ jobs:
358358
if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64'
359359
run: scripts/security-fuzz.sh ./codebase-memory-mcp
360360

361+
- name: Fuzz testing (60s random input)
362+
if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64'
363+
run: scripts/security-fuzz-random.sh ./codebase-memory-mcp 60
364+
361365
- name: ClamAV scan (Linux)
362366
if: matrix.variant == 'standard' && startsWith(matrix.os, 'ubuntu')
363367
run: |

.github/workflows/release.yml

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,20 +82,20 @@ jobs:
8282
codeql-gate:
8383
runs-on: ubuntu-latest
8484
steps:
85-
- name: Wait for CodeQL on current commit (max 30 min)
85+
- name: Wait for CodeQL on current commit (max 45 min)
8686
env:
8787
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
8888
run: |
8989
CURRENT_SHA="${{ github.sha }}"
9090
echo "Current commit: $CURRENT_SHA"
9191
echo "Waiting for CodeQL to complete on this commit..."
9292
93-
for attempt in $(seq 1 60); do
93+
for attempt in $(seq 1 90); do
9494
LATEST=$(gh api repos/${{ github.repository }}/actions/workflows/codeql.yml/runs?per_page=5 \
9595
--jq '.workflow_runs[] | select(.head_sha == "'"$CURRENT_SHA"'") | "\(.conclusion) \(.status)"' 2>/dev/null | head -1 || echo "")
9696
9797
if [ -z "$LATEST" ]; then
98-
echo " Attempt $attempt/60: No CodeQL run found for $CURRENT_SHA yet..."
98+
echo " Attempt $attempt/90: No CodeQL run found for $CURRENT_SHA yet..."
9999
sleep 30
100100
continue
101101
fi
@@ -111,11 +111,11 @@ jobs:
111111
exit 1
112112
fi
113113
114-
echo " Attempt $attempt/60: CodeQL status=$STATUS (waiting 30s)..."
114+
echo " Attempt $attempt/90: CodeQL status=$STATUS (waiting 30s)..."
115115
sleep 30
116116
done
117117
118-
echo "BLOCKED: CodeQL did not complete within 30 minutes"
118+
echo "BLOCKED: CodeQL did not complete within 45 minutes"
119119
exit 1
120120
121121
- name: Check for open code scanning alerts
@@ -354,6 +354,10 @@ jobs:
354354
if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64'
355355
run: scripts/security-fuzz.sh ./codebase-memory-mcp
356356

357+
- name: Fuzz testing (60s random input)
358+
if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64'
359+
run: scripts/security-fuzz-random.sh ./codebase-memory-mcp 60
360+
357361
# Native platform antivirus scan
358362
- name: ClamAV scan (Linux)
359363
if: matrix.variant == 'standard' && startsWith(matrix.os, 'ubuntu')

SECURITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ This project implements multiple layers of security verification. Every release
2929
- **Time-bomb pattern detection** — scans for `time()`/`sleep()` near dangerous calls (could indicate delayed activation)
3030
- **MCP tool handler file read audit** — tracks file read count in `mcp.c` against an expected maximum (detects added file reads that could exfiltrate data through tool responses)
3131
- **CodeQL SAST** — static application security testing on every push (taint analysis, CWE detection, data flow tracking). Any open alert blocks the release.
32+
- **Fuzz testing** — random/mutated inputs to MCP server and Cypher parser (60 seconds per build). Catches crashes, segfaults, and memory errors that structured tests miss.
3233
- **Native antivirus scanning** on every platform (any detection fails the build):
3334
- **Windows**: Windows Defender with ML heuristics — the same engine end users run
3435
- **Linux**: ClamAV with daily signature updates

scripts/security-fuzz-random.sh

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Fuzz testing: feeds random/mutated inputs to the MCP server and CLI
5+
# to find crashes, hangs, and memory errors. Runs for a limited time.
6+
#
7+
# Usage: scripts/security-fuzz-random.sh <binary-path> [duration_seconds]
8+
9+
BINARY="${1:?usage: security-fuzz-random.sh <binary-path> [duration_seconds]}"
10+
DURATION="${2:-60}"
11+
12+
if [[ ! -f "$BINARY" ]]; then
13+
echo "FAIL: binary not found: $BINARY"
14+
exit 1
15+
fi
16+
17+
echo "=== Fuzz Testing ($DURATION seconds) ==="
18+
19+
FUZZ_TMPDIR=$(mktemp -d)
20+
trap 'rm -rf "$FUZZ_TMPDIR"' EXIT
21+
22+
CRASHES=0
23+
ITERATIONS=0
24+
END_TIME=$((SECONDS + DURATION))
25+
26+
# Portable timeout
27+
run_with_timeout() {
28+
local secs="$1"; shift
29+
if command -v timeout &>/dev/null; then
30+
timeout "$secs" "$@" || true
31+
else
32+
perl -e "alarm($secs); exec @ARGV" -- "$@" || true
33+
fi
34+
}
35+
36+
# ── Phase 1: Random JSON-RPC mutations ───────────────────────
37+
echo ""
38+
echo "--- Phase 1: Random JSON-RPC mutations ---"
39+
40+
while [ $SECONDS -lt $END_TIME ]; do
41+
ITERATIONS=$((ITERATIONS + 1))
42+
43+
# Generate a random mutated JSON-RPC payload
44+
PAYLOAD=$(python3 -c "
45+
import random, string, json
46+
47+
# Base valid request
48+
base = {
49+
'jsonrpc': '2.0',
50+
'id': random.randint(-999999, 999999),
51+
'method': random.choice([
52+
'initialize', 'tools/call', 'tools/list',
53+
random.choice(string.ascii_letters) * random.randint(1, 100),
54+
'',
55+
]),
56+
}
57+
58+
# Random mutations
59+
mutation = random.randint(0, 10)
60+
if mutation == 0:
61+
# Valid tool call with random args
62+
base['params'] = {'name': 'search_graph', 'arguments': {
63+
'name_pattern': ''.join(random.choices(string.printable, k=random.randint(0, 500)))
64+
}}
65+
elif mutation == 1:
66+
# Huge nested object
67+
base['params'] = {'a': {'b': {'c': {'d': {'e': 'deep'}}}}}
68+
elif mutation == 2:
69+
# Random bytes as method
70+
base['method'] = ''.join(random.choices(string.printable, k=random.randint(1, 1000)))
71+
elif mutation == 3:
72+
# Null fields
73+
base['method'] = None
74+
base['id'] = None
75+
elif mutation == 4:
76+
# Array instead of object
77+
base = [1, 2, 3]
78+
elif mutation == 5:
79+
# Empty
80+
base = {}
81+
elif mutation == 6:
82+
# Random Cypher query
83+
base['params'] = {'name': 'query_graph', 'arguments': {
84+
'query': ''.join(random.choices(string.printable, k=random.randint(1, 200)))
85+
}}
86+
elif mutation == 7:
87+
# Very long string values
88+
base['params'] = {'name': 'search_graph', 'arguments': {
89+
'name_pattern': 'A' * random.randint(10000, 100000)
90+
}}
91+
elif mutation == 8:
92+
# Unicode/binary-like content
93+
base['params'] = {'name': 'search_code', 'arguments': {
94+
'pattern': ''.join(chr(random.randint(0, 0xFFFF)) for _ in range(100))
95+
}}
96+
elif mutation == 9:
97+
# Random tool name
98+
base['params'] = {'name': ''.join(random.choices(string.ascii_letters, k=50)), 'arguments': {}}
99+
else:
100+
# Completely random JSON
101+
base = ''.join(random.choices(string.printable, k=random.randint(1, 500)))
102+
103+
try:
104+
print(json.dumps(base))
105+
except:
106+
print(json.dumps({'garbage': True}))
107+
" 2>/dev/null || echo '{}')
108+
109+
# Build session: init + mutated payload
110+
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"fuzz","version":"1.0"}}}'
111+
printf '%s\n%s\n' "$INIT" "$PAYLOAD" > "$FUZZ_TMPDIR/input.jsonl"
112+
113+
# Run and check for crashes (not exit code — we expect errors, just not crashes)
114+
run_with_timeout 5 "$BINARY" < "$FUZZ_TMPDIR/input.jsonl" > /dev/null 2>&1
115+
EC=$?
116+
117+
# 139 = SIGSEGV, 134 = SIGABRT, 136 = SIGFPE — real crashes
118+
if [ $EC -eq 139 ] || [ $EC -eq 134 ] || [ $EC -eq 136 ]; then
119+
echo "CRASH: exit code $EC on iteration $ITERATIONS"
120+
echo "Payload: $PAYLOAD"
121+
CRASHES=$((CRASHES + 1))
122+
fi
123+
done
124+
125+
echo "Phase 1: $ITERATIONS iterations, $CRASHES crashes"
126+
127+
# ── Phase 2: Random Cypher queries via CLI ───────────────────
128+
echo ""
129+
echo "--- Phase 2: Random Cypher queries via CLI ---"
130+
131+
CLI_ITERATIONS=0
132+
CLI_END=$((SECONDS + 10))
133+
134+
while [ $SECONDS -lt $CLI_END ]; do
135+
CLI_ITERATIONS=$((CLI_ITERATIONS + 1))
136+
137+
QUERY=$(python3 -c "
138+
import random, string
139+
parts = ['MATCH', 'WHERE', 'RETURN', '(', ')', '-[', ']->', '<-[', ']-',
140+
'n', 'r', '.name', '.label', '=', '\"', \"'\", ';', '--', 'DROP',
141+
'DELETE', 'ATTACH', 'DETACH', '*', 'COUNT', 'LIMIT', 'ORDER BY']
142+
q = ' '.join(random.choices(parts, k=random.randint(1, 20)))
143+
# Sometimes add random garbage
144+
if random.random() > 0.5:
145+
q += ''.join(random.choices(string.printable, k=random.randint(1, 100)))
146+
print(q)
147+
" 2>/dev/null || echo "MATCH (n) RETURN n")
148+
149+
run_with_timeout 3 "$BINARY" cli query_graph "{\"query\":\"$QUERY\"}" > /dev/null 2>&1
150+
EC=$?
151+
152+
if [ $EC -eq 139 ] || [ $EC -eq 134 ] || [ $EC -eq 136 ]; then
153+
echo "CRASH: exit code $EC on Cypher iteration $CLI_ITERATIONS"
154+
echo "Query: $QUERY"
155+
CRASHES=$((CRASHES + 1))
156+
fi
157+
done
158+
159+
echo "Phase 2: $CLI_ITERATIONS iterations, $CRASHES total crashes"
160+
161+
# ── Summary ──────────────────────────────────────────────────
162+
echo ""
163+
if [ $CRASHES -gt 0 ]; then
164+
echo "=== FUZZ TESTING FAILED: $CRASHES crash(es) found ==="
165+
exit 1
166+
fi
167+
168+
echo "=== Fuzz testing passed: $((ITERATIONS + CLI_ITERATIONS)) iterations, 0 crashes ==="

src/cli/cli.c

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77
#include "cli/cli.h"
88
#include "foundation/compat.h"
9+
#include "foundation/str_util.h"
910

1011
// the correct standard headers are included below but clang-tidy doesn't map them.
1112
#include <ctype.h>
@@ -15,6 +16,7 @@
1516
#define CBM_VERSION "dev"
1617
#endif
1718
#include <errno.h> // EEXIST
19+
#include <fcntl.h> // open, O_WRONLY, O_CREAT, O_TRUNC
1820
#include <stdint.h> // uintptr_t
1921
#include <stdio.h>
2022
#include <stdlib.h>
@@ -2784,22 +2786,36 @@ int cbm_cmd_update(int argc, char **argv) {
27842786
return 1;
27852787
}
27862788

2789+
/* Open with final permissions atomically (no TOCTOU between write and chmod) */
2790+
#ifndef _WIN32
2791+
int fd = open(bin_dest, O_WRONLY | O_CREAT | O_TRUNC, 0755);
2792+
if (fd < 0) {
2793+
fprintf(stderr, "error: cannot write to %s\n", bin_dest);
2794+
free(bin_data);
2795+
return 1;
2796+
}
2797+
FILE *out = fdopen(fd, "wb");
2798+
#else
27872799
FILE *out = fopen(bin_dest, "wb");
2800+
#endif
27882801
if (!out) {
27892802
fprintf(stderr, "error: cannot write to %s\n", bin_dest);
27902803
free(bin_data);
2804+
#ifndef _WIN32
2805+
close(fd);
2806+
#endif
27912807
return 1;
27922808
}
27932809
fwrite(bin_data, 1, (size_t)bin_len, out);
27942810
fclose(out);
27952811
free(bin_data);
2796-
2797-
/* Make executable */
2798-
#ifndef _WIN32
2799-
chmod(bin_dest, 0755);
2800-
#endif
28012812
} else {
2802-
/* Windows: unzip */
2813+
/* Windows: unzip — validate paths before shell interpolation */
2814+
if (!cbm_validate_shell_arg(bin_dir) || !cbm_validate_shell_arg(tmp_archive)) {
2815+
fprintf(stderr, "error: path contains unsafe characters\n");
2816+
cbm_unlink(tmp_archive);
2817+
return 1;
2818+
}
28032819
snprintf(cmd, sizeof(cmd), "unzip -o -d '%s' '%s' 2>/dev/null", bin_dir, tmp_archive);
28042820
// NOLINTNEXTLINE(cert-env33-c) — intentional CLI subprocess for extraction
28052821
rc = system(cmd);
@@ -2825,9 +2841,11 @@ int cbm_cmd_update(int argc, char **argv) {
28252841

28262842
/* Step 7: Verify new version */
28272843
printf("\nUpdate complete. Verifying:\n");
2828-
snprintf(cmd, sizeof(cmd), "'%s' --version", bin_dest);
2829-
// NOLINTNEXTLINE(cert-env33-c,clang-analyzer-optin.taint.GenericTaint)
2830-
(void)system(cmd);
2844+
if (cbm_validate_shell_arg(bin_dest)) {
2845+
snprintf(cmd, sizeof(cmd), "'%s' --version", bin_dest);
2846+
// NOLINTNEXTLINE(cert-env33-c,clang-analyzer-optin.taint.GenericTaint)
2847+
(void)system(cmd);
2848+
}
28312849

28322850
printf("\nAll project indexes were cleared. They will be rebuilt\n");
28332851
printf("automatically when you next use the MCP server.\n");

src/cypher/cypher.c

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2561,7 +2561,10 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec
25612561
}
25622562
const char *v = binding_get_virtual(&bindings[bi], wc->items[ci].variable,
25632563
wc->items[ci].property);
2564-
kl += snprintf(key + kl, sizeof(key) - kl, "%s|", v);
2564+
kl += snprintf(key + kl, sizeof(key) - (size_t)kl, "%s|", v);
2565+
if (kl >= (int)sizeof(key)) {
2566+
kl = (int)sizeof(key) - 1;
2567+
}
25652568
}
25662569
int found = -1;
25672570
for (int a = 0; a < agg_cnt; a++) {
@@ -2918,7 +2921,10 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec
29182921
}
29192922
char func_buf[512];
29202923
vals[ci] = project_item(&bindings[bi], item, func_buf, sizeof(func_buf));
2921-
klen += snprintf(key + klen, sizeof(key) - klen, "%s|", vals[ci]);
2924+
klen += snprintf(key + klen, sizeof(key) - (size_t)klen, "%s|", vals[ci]);
2925+
if (klen >= (int)sizeof(key)) {
2926+
klen = (int)sizeof(key) - 1;
2927+
}
29222928
}
29232929

29242930
int found = -1;
@@ -3005,10 +3011,15 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec
30053011
if (ci2 > 0) {
30063012
cbuf[bl++] = ',';
30073013
}
3008-
bl += snprintf(cbuf + bl, sizeof(cbuf) - bl, "\"%s\"",
3014+
bl += snprintf(cbuf + bl, sizeof(cbuf) - (size_t)bl, "\"%s\"",
30093015
aggs[a].collect_lists[ci][ci2]);
3016+
if (bl >= (int)sizeof(cbuf)) {
3017+
bl = (int)sizeof(cbuf) - 1;
3018+
}
3019+
}
3020+
if (bl < (int)sizeof(cbuf) - 1) {
3021+
cbuf[bl++] = ']';
30103022
}
3011-
cbuf[bl++] = ']';
30123023
cbuf[bl] = '\0';
30133024
snprintf(bufs[ci], sizeof(bufs[ci]), "%s", cbuf);
30143025
} else {

0 commit comments

Comments
 (0)