Skip to content

Commit 857adca

Browse files
authored
Merge pull request #28 from gxc/ssh-git-servers
feat: allow SSH clones from custom git servers via GIT_CLONE_EXTRA_HOSTS
2 parents 7f25657 + 55631a8 commit 857adca

15 files changed

Lines changed: 825 additions & 105 deletions

.env.example

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,38 @@ CPG_QUEUE_BACKEND=durable
8282
# Empty = no allowlist.
8383
# ALLOWED_SOURCE_ROOTS=/abs/path/to/sources:/abs/path/to/other-sources
8484

85+
# Custom git clone servers (optional) — allow your own git host beyond
86+
# github.com/gitlab.com, e.g. a self-hosted Forgejo on the LAN.
87+
# GIT_CLONE_EXTRA_HOSTS: ','-separated `host[:port]` entries. A bare host means
88+
# port 22 only; `host:port` pins that port; `host:*` allows any port on it.
89+
# Listed hosts are cloned via ssh:// only (github.com/gitlab.com stay
90+
# https-only). A malformed value fails the server boot.
91+
# ssh:// auth depends on how the MCP runs (the clone runs where the MCP runs):
92+
# - MCP on the host (`python main.py`): set GIT_CLONE_SSH_KEY_PATH to the key
93+
# FILE on this host, or a full GIT_CLONE_SSH_COMMAND override.
94+
# - Full docker stack (`./scripts/deploy.sh`): set GIT_CLONE_SSH_KEYS_HOST_DIR
95+
# to a HOST directory containing the private key as id_ed25519; compose mounts
96+
# it read-only at /keys in the codebadger-mcp container and the server then
97+
# sees /keys/id_ed25519. GIT_CLONE_SSH_KEY_PATH has no effect in the container
98+
# — do not set it here. With GIT_CLONE_SSH_COMMAND, any path it references
99+
# must exist inside the container (e.g. /keys/...).
100+
# Example Forgejo reachable at ssh://git@192.168.152.14:3000/...:
101+
# GIT_CLONE_EXTRA_HOSTS=192.168.152.14:3000
102+
# # host-run MCP:
103+
# GIT_CLONE_SSH_KEY_PATH=/path/to/id_ed25519
104+
# # dockerized stack:
105+
# GIT_CLONE_SSH_KEYS_HOST_DIR=/path/to/keydir # containing id_ed25519
106+
# Host keys: without GIT_CLONE_SSH_KNOWN_HOSTS the clone uses
107+
# StrictHostKeyChecking=accept-new (trust-on-first-use — and in the dockerized
108+
# stack that record dies with the container, so it is TOFU on every recreate).
109+
# Point it at a known_hosts file to pin the server key instead; in the docker
110+
# stack that path must be IN-CONTAINER, e.g. /keys/known_hosts next to the key.
111+
# GIT_CLONE_EXTRA_HOSTS=
112+
# GIT_CLONE_SSH_KEYS_HOST_DIR=
113+
# GIT_CLONE_SSH_KEY_PATH=
114+
# GIT_CLONE_SSH_KNOWN_HOSTS=
115+
# GIT_CLONE_SSH_COMMAND=
116+
85117
DOCKER_HOST=unix:///var/run/docker.sock
86118

87119
# GitHub (optional) — token for cloning private repos.

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,9 @@ codebadger.db
145145
docker-compose.override.yml
146146
scripts/backfill_overlays.sh
147147
.claude/settings.json
148+
149+
# Operator ssh keys for GIT_CLONE_EXTRA_HOSTS clones (compose default host dir).
150+
# Never commit private keys. The dir itself is tracked (via .gitkeep) so compose
151+
# doesn't create it root-owned when GIT_CLONE_SSH_KEYS_HOST_DIR is unset.
152+
.ssh-keys/*
153+
!.ssh-keys/.gitkeep

.ssh-keys/.gitkeep

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Default mount source for GIT_CLONE_SSH_KEYS_HOST_DIR (docker-compose).
2+
# Place an operator deploy key here as id_ed25519 (and optionally known_hosts).
3+
# Everything in this directory except this file is gitignored.

Dockerfile.mcp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
1515
git \
1616
curl \
1717
ca-certificates \
18+
openssh-client \
1819
&& curl -fsSL "https://download.docker.com/linux/static/stable/${DOCKER_CLI_ARCH}/docker-${DOCKER_CLI_VERSION}.tgz" \
1920
| tar -xz -C /usr/local/bin --strip-components=1 docker/docker \
2021
&& docker --version \

docker-compose.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ services:
5454
# DOCKER_HOST) for a rootless / non-default socket; container side stays fixed.
5555
- ${DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock
5656
- ./logs:/app/logs
57+
# Operator ssh key dir for GIT_CLONE_EXTRA_HOSTS clones, mounted READ-ONLY
58+
# at the fixed in-container path /keys. Point GIT_CLONE_SSH_KEYS_HOST_DIR
59+
# (.env) at a HOST dir containing the private key as id_ed25519; the key
60+
# then resolves to /keys/id_ed25519 inside this container (see the env
61+
# below). Unset => an empty dir is mounted and no key path is set — no
62+
# behavior change.
63+
- ${GIT_CLONE_SSH_KEYS_HOST_DIR:-./.ssh-keys}:/keys:ro
5764
environment:
5865
# 0.0.0.0 = reachable on all interfaces. Set MCP_HOST=127.0.0.1 to bind
5966
# loopback only (e.g. when a reverse proxy / socat already fronts it).
@@ -96,6 +103,21 @@ services:
96103
# this container — local sources live under /app/playground here.
97104
CHAT_DEPLOY: ${CHAT_DEPLOY:-false}
98105
ALLOWED_SOURCE_ROOTS: ${ALLOWED_SOURCE_ROOTS:-}
106+
# Custom git clone servers (optional) — allowlist your own git host
107+
# (e.g. a LAN Forgejo) beyond github.com/gitlab.com. ssh:// clones auth
108+
# via the mounted key (below) or a full GIT_CLONE_SSH_COMMAND.
109+
GIT_CLONE_EXTRA_HOSTS: ${GIT_CLONE_EXTRA_HOSTS:-}
110+
# In the dockerized stack the key's in-container path is DERIVED from
111+
# GIT_CLONE_SSH_KEYS_HOST_DIR (the host dir mounted at /keys above).
112+
# GIT_CLONE_SSH_KEY_PATH is a host-run-MCP setting and is deliberately
113+
# NOT passed through here — a host path would never resolve in-container.
114+
GIT_CLONE_SSH_KEY_PATH: ${GIT_CLONE_SSH_KEYS_HOST_DIR:+/keys/id_ed25519}
115+
# Optional host-key pinning. Like GIT_CLONE_SSH_COMMAND this is an
116+
# IN-CONTAINER path: drop a known_hosts next to the key and set
117+
# GIT_CLONE_SSH_KNOWN_HOSTS=/keys/known_hosts. Unset => accept-new, whose
118+
# record lives in the container and is lost on every recreate.
119+
GIT_CLONE_SSH_KNOWN_HOSTS: ${GIT_CLONE_SSH_KNOWN_HOSTS:-}
120+
GIT_CLONE_SSH_COMMAND: ${GIT_CLONE_SSH_COMMAND:-}
99121
depends_on:
100122
codebadger-postgres:
101123
condition: service_healthy

docs/deployment.md

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,13 @@ CHAT_DEPLOY=true # in .env (passed through to the container by compose)
139139
With `CHAT_DEPLOY=true` the MCP **refuses `source_type="local"`** and returns a
140140
message steering the caller to the safe inputs. What remains:
141141

142-
- **Git repos** — only `https://github.com/…` and `https://gitlab.com/…` are
143-
accepted. The URL is checked twice (a literal `https://<host>/` prefix *and* a
144-
parsed-hostname allowlist) and rejects other hosts, non-`https` schemes
145-
(`git://`, `ssh://`, `file://`), embedded credentials, ports, and look-alike
146-
domains — so a repo URL can't be turned into an SSRF probe.
142+
- **Git repos**`https://github.com/…` / `https://gitlab.com/…` are accepted,
143+
plus `ssh://…` on hosts the operator listed in `GIT_CLONE_EXTRA_HOSTS`
144+
(see [Custom git servers](#custom-git-servers-git_clone_) below). The URL is
145+
checked twice (a literal `https://<host>/` prefix *and* a parsed-hostname
146+
allowlist) and rejects other hosts, non-`https` schemes (`git://`, `file://`),
147+
embedded credentials, ports, and look-alike domains — so a repo URL can't be
148+
turned into an SSRF probe.
147149
- **Pasted snippets**`source_type="snippet"` with the code in a
148150
`<code language="…">` tag; the language is validated/inferred and a mislabeled
149151
or ambiguous snippet is refused. Nothing touches the host filesystem.
@@ -205,6 +207,66 @@ defaults — note a few differ in the shipped `docker-compose.yml` (called out b
205207
| `ALLOWED_SOURCE_ROOTS` | `` (empty) | `:`-separated allowlist of dirs local sources must canonically resolve within (as the MCP sees them, e.g. `/app/playground`). Empty = no allowlist. |
206208
| `GITHUB_TOKEN` | `` (empty) | PAT for cloning private repos (never embed it in the URL). |
207209

210+
### Custom git servers (`GIT_CLONE_*`)
211+
212+
By default `generate_cpg` only clones from `https://github.com/…` /
213+
`https://gitlab.com/…`. To also analyze code on your own git server (e.g. a
214+
self-hosted **Forgejo**/**Gitea** on the LAN), allowlist it with
215+
`GIT_CLONE_EXTRA_HOSTS` — no other change is needed; callers then pass the
216+
repo URL (`ssh://git@192.168.152.14:3000/<owner>/<repo>.git`) as
217+
`source_path` with `source_type='github'`. Custom hosts are **ssh-only**
218+
http(s) clone URLs are rejected for them.
219+
220+
```bash
221+
# ','-separated host[:port] entries — the same in both modes below.
222+
# A bare host means port 22; `host:port` pins that port; `host:*` allows any.
223+
GIT_CLONE_EXTRA_HOSTS=192.168.152.14:3000
224+
225+
# MCP run on the host (`python main.py`): key FILE on this host
226+
GIT_CLONE_SSH_KEY_PATH=/abs/path/to/id_ed25519
227+
228+
# Full docker stack (`./scripts/deploy.sh`): HOST DIRECTORY containing the key
229+
# as id_ed25519; compose mounts it read-only at /keys in the codebadger-mcp
230+
# container, so the server sees /keys/id_ed25519
231+
GIT_CLONE_SSH_KEYS_HOST_DIR=/abs/path/to/keydir
232+
```
233+
234+
| Variable | Default | Description |
235+
|---|---|---|
236+
| `GIT_CLONE_EXTRA_HOSTS` | `` (empty) | ','-separated `host[:port]` entries accepted in addition to github.com/gitlab.com (IPv6 goes in brackets, e.g. `[::1]:2222`). A bare host means **port 22 only**; `host:port` pins that port; `host:*` allows any port on it. Allowlisted hosts are cloned over `ssh://` only; the built-in hosts keep their strict https-only, default-port-only posture unless an operator explicitly lists one here (e.g. `github.com:22` would enable ssh for github.com). Parsed once at startup — a malformed value fails the boot rather than surfacing on the first ssh clone. |
237+
| `GIT_CLONE_SSH_KEYS_HOST_DIR` | `` (empty) | **Dockerized stack only.** Host directory containing the private key (named `id_ed25519`); docker compose mounts it read-only at `/keys` in the `codebadger-mcp` container and the server resolves the key to the fixed in-container path `/keys/id_ed25519`. Unset, an empty dir is mounted and no key path is configured. |
238+
| `GIT_CLONE_SSH_KEY_PATH` | `` (empty) | **Host-run MCP only — ignored by the dockerized stack** (a host path never resolves inside the `codebadger-mcp` container; do not set it in `.env`). Private key FILE for `ssh://` clones of a custom host. The clone also sets `-o BatchMode=yes`, so a missing key fails fast instead of hanging on a prompt. |
239+
| `GIT_CLONE_SSH_KNOWN_HOSTS` | `` (empty) | `known_hosts` file pinning the custom servers' host keys (`-o StrictHostKeyChecking=yes`). Unset, the clone falls back to `accept-new`: the key is recorded on first contact, but **in the dockerized stack that record lives in the container and is lost on every recreate**, making it trust-on-first-use each deploy. Like `GIT_CLONE_SSH_COMMAND` this is an in-container path there — put a `known_hosts` in the mounted key dir and set `/keys/known_hosts`. |
240+
| `GIT_CLONE_SSH_COMMAND` | `` (empty) | Full ssh command override (passed to git as `GIT_SSH_COMMAND` for the clone); takes precedence over both key settings. In the dockerized stack any key path it references must exist **inside the `codebadger-mcp` container** (e.g. `/keys/…`). |
241+
242+
Notes:
243+
- **Where does the clone run?** In the MCP process, so every path must make
244+
sense *there*: host-run MCP → host filesystem; full docker stack → inside
245+
the `codebadger-mcp` container. The two key variables above exist because of
246+
this split: `GIT_CLONE_SSH_KEY_PATH` is a host path for host-run MCP,
247+
while `GIT_CLONE_SSH_KEYS_HOST_DIR` is the compose bridge that maps a host
248+
key dir onto the fixed container path `/keys` (hence the container always
249+
sees `/keys/id_ed25519`). For a host-run MCP any key file name works
250+
(`ssh -i` doesn't care); in the dockerized stack the key **must** be named
251+
`id_ed25519` because the in-container path is fixed — or bypass it with
252+
`GIT_CLONE_SSH_COMMAND`.
253+
- Embedded credentials in the `source_path` URL are always rejected. For
254+
github.com/gitlab.com private repos pass the PAT via the `github_token`
255+
argument; it is injected into the clone URL and stripped from `.git/config`
256+
after the clone.
257+
- `ssh://` URLs may carry a username (`git@…`) but not a password; keys/agent
258+
do the auth. scp-style `git@host:path` URLs are not accepted — use
259+
`ssh://git@host[:port]/path` (it carries ports unambiguously).
260+
- **Ports are part of the allowlist.** A bare `forge.lan` entry only permits
261+
`ssh://…@forge.lan[:22]/…`, so allowlisting a git server does not also expose
262+
every other port on that machine to a caller who can influence `source_path`.
263+
Use `forge.lan:3000` for a non-default ssh port, or `forge.lan:*` to accept
264+
any port on it.
265+
- The allowlist still blocks every other host (alternate git hosts, look-alike
266+
domains, cloud metadata endpoints, …), so the SSRF posture of
267+
[docs/security.md](security.md) is unchanged — you are explicitly trusting
268+
the hosts you list.
269+
208270
### Memory & the Joern pool
209271

210272
Three distinct memory knobs, easy to confuse — keep them straight:

docs/security.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ The numbered controls are the boundary checks; each is described below.
7373
| # | Boundary | Control | Where |
7474
|---|----------|---------|-------|
7575
|| Tool input → MCP | **Allowlist/format validation of every parameter**: `source_type`, `language` (whitelist), `codebase_hash` (`^[a-f0-9]{16}$`), `github_token` & `branch` (anti URL-/arg-injection, e.g. blocks `--upload-pack`), snippet `code`/`filename`/label, regex `pattern` (length + ReDoS shapes). | `src/utils/validators.py` |
76-
| ①a | Repo URL → clone (**SSRF/undefined-clone prevention**) | **Strict allowlist on remote repos**: only `https://github.com/` or `https://gitlab.com/` (incl. `www.`). Enforced by **two independent gates** — a literal, case-sensitive `https://<host>/` prefix match *and* a parsed-`hostname` allowlist — plus rejection of any non-`https` scheme (`git://`, `ssh://`, `file://`, …), embedded credentials (`user:tok@`), non-default ports, and whitespace/control chars. Blocks userinfo host-smuggling (`https://github.com@evil/…`), internal/metadata hosts, and look-alike domains. | `validators.py` (`validate_repo_url`) |
76+
| ①a | Repo URL → clone (**SSRF/undefined-clone prevention**) | **Strict allowlist on remote repos**: only `https://github.com/` or `https://gitlab.com/` (incl. `www.`) by default. For the built-in hosts, enforced by **two independent gates** — a literal, case-sensitive `https://<host>/` prefix match *and* a parsed-`hostname` allowlist — plus rejection of any non-`https` scheme (`git://`, `file://`, …), embedded credentials (`user:tok@`), non-default ports, and whitespace/control chars. Blocks userinfo host-smuggling (`https://github.com@evil/…`), internal/metadata hosts, and look-alike domains. **Operator extension**: `GIT_CLONE_EXTRA_HOSTS` (env) explicitly adds `host[:port]` entries which may also be cloned over `ssh://`, gated by the parsed-`hostname` allowlist (the literal-prefix gate is https-only) plus an **exact port match** — a bare entry means port 22, so one allowlisted host is not a licence to reach every port on that machine; `host:*` opts into any. A username but no password is allowed; auth rides in `GIT_SSH_COMMAND`, never the URL. Everything else about the posture (exact-hostname match, no embedded credentials, control chars, ≥`/owner/repo` path) is unchanged; the config is parsed at startup so a typo fails the boot, and the injected `github_token` for github.com/gitlab.com is stripped from `.git/config` after the clone. | `validators.py` (`validate_repo_url`), `services/git_manager.py` |
7777
| ①b | Snippet code → CPG | **Language validated *and* inferred.** Pasted code is supplied in `<code language="…">` tags (parsed by regex); the declared language must be supported, and a content-signal check **refuses an obviously mislabeled tag** or **ambiguous/undeclared** language — every refusal returns an actionable message rather than building a wrong-language CPG. | `validators.py` (`parse_snippet_blocks`, `validate_and_infer_snippet_language`) |
7878
|| Source staging | **Path confinement + symlink-safe copy.** Local paths must be absolute, are rejected if they contain null bytes/control chars, then `realpath`-canonicalized (collapsing `..` and resolving symlinks *before* any check) and screened against a system-dir denylist (`/etc`, `/proc`, `/sys`, `/root`, …). An optional `ALLOWED_SOURCE_ROOTS` allowlist hard-contains local sources to named roots. Snapshot reads confined with `realpath`+prefix / `commonpath`; the copy never dereferences symlinks whose target escapes the source tree. | `validators.py` (`resolve_host_path`), `core_tools.py` |
7979
| ②a | Deployment posture | **`CHAT_DEPLOY=true` disables `source_type='local'` entirely** so a chat-facing / multi-tenant MCP cannot read arbitrary host paths — callers must use an allowlisted repo URL or a pasted snippet. | `core_tools.py`, `config.py` |

main.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
QueryExecutor,
3434
CodeBrowsingService
3535
)
36-
from src.utils import setup_logging
36+
from src.utils import setup_logging, validate_extra_repo_hosts_config
3737
from src.utils import compute_recommendation, current_from_config, render_recommendation
3838
from src.startup_tuning import apply_startup_tuning, container_mem_limit_mb, parse_mem_to_mb
3939
from src.health import (
@@ -374,6 +374,11 @@ async def app_lifespan(server: FastMCP):
374374
)
375375
logger.info("Starting CodeBadger Server")
376376

377+
# Fail the boot on a typo'd GIT_CLONE_EXTRA_HOSTS rather than letting it sit
378+
# latent until the first ssh:// clone (github/gitlab clones would keep
379+
# working, hiding the misconfiguration from the operator).
380+
validate_extra_repo_hosts_config()
381+
377382
# Print the memory-aware configuration envelope before the heavy service
378383
# init, flag drift that risks an OOM cascade, and auto-derive an unset Joern
379384
# memory budget from host RAM (before the Joern manager is constructed).

src/defaults.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,29 @@ def resolve_redis_url() -> str:
6666
# generate_cpg: a chat-facing MCP must never expose arbitrary host filesystem
6767
# paths. Callers use a github.com/gitlab.com URL or a pasted snippet instead.
6868
CHAT_DEPLOY = False
69+
70+
# --- Custom git clone servers (self-hosted Forgejo / Gitea / GitLab, ...) ----
71+
# Beyond the built-in github.com/gitlab.com https allowlist, an operator can
72+
# allowlist their own git server(s) for cloning via generate_cpg. Addresses are
73+
# configured through the environment so a LAN deployment needs no code changes:
74+
# GIT_CLONE_EXTRA_HOSTS ','-separated `host[:port]` entries accepted in
75+
# addition to github.com/gitlab.com. A bare host means
76+
# port 22 only; `host:port` pins that port; `host:*`
77+
# allows any port on it. Custom hosts are cloned over
78+
# ssh:// only (github.com/gitlab.com stay https-only).
79+
# Parsed at startup — a malformed value fails the boot.
80+
# GIT_CLONE_SSH_KEY_PATH Private key for ssh:// clones of custom hosts.
81+
# GIT_CLONE_SSH_KNOWN_HOSTS
82+
# known_hosts file pinning the custom servers' host
83+
# keys (StrictHostKeyChecking=yes). Unset = accept-new,
84+
# i.e. trust-on-first-use.
85+
# GIT_CLONE_SSH_COMMAND Full ssh command override (takes precedence over the
86+
# key path; passed to git as GIT_SSH_COMMAND).
87+
GIT_CLONE_EXTRA_HOSTS = ""
88+
GIT_CLONE_SSH_KEY_PATH = ""
89+
GIT_CLONE_SSH_KNOWN_HOSTS = ""
90+
GIT_CLONE_SSH_COMMAND = ""
91+
6992
# Optional ':'-separated allowlist of host directory roots that source_type=
7093
# 'local' paths must canonically resolve within. Empty = no allowlist (the
7194
# denylist + symlink-resolving canonicalization in resolve_host_path still apply).

0 commit comments

Comments
 (0)