feat: allow SSH clones from custom git servers via GIT_CLONE_EXTRA_HOSTS - #28
Conversation
Allowlist self-hosted git servers (host[:port] entries, IPv6 bracketed) for generate_cpg cloning over ssh:// only; github.com/gitlab.com keep their https-only posture with per-call token injection (stripped from .git/config after the clone). - validate_repo_url: custom hosts accept ssh:// only; http(s) stays github/gitlab-only, port pinning and the SSRF posture unchanged - git_manager: ssh auth rides in GIT_SSH_COMMAND (key path or full command override) - compose: mount the operator key dir read-only at /keys and derive the in-container key path (/keys/id_ed25519) from GIT_CLONE_SSH_KEYS_HOST_DIR - docs: two-mode path semantics (host-run MCP vs dockerized stack) - tests: unit coverage for the ssh-only validator and clone behavior
Addresses the review on qcri#28. The default posture (no GIT_CLONE_EXTRA_HOSTS) remains byte-identical to main; these tighten the opt-in extension. - Ports are part of the allowlist. A bare `host` entry now means port 22 only instead of any port, so allowlisting a git server no longer lets a caller who can influence source_path reach every other port on that machine over ssh. `host:port` pins a port as before; the new `host:*` opts into any port explicitly. - Reject port 0 on ssh URLs (urlparse only range-checks the upper bound, so it slipped past the entry parser's own 1..65535 check). - A portless ssh URL now matches against 22 — what a clone would actually dial — so a natural `forge.lan:22` entry no longer rejects `ssh://git@forge.lan/owner/repo`. - Parse GIT_CLONE_EXTRA_HOSTS at startup so a typo fails the boot instead of sitting latent until the first ssh clone, and redact the raw config from the error an MCP caller sees (it is logged for the operator). - ssh usernames must start alphanumeric: a leading '-' reaches ssh's argv as an option. git blocks this downstream; match the file's posture. - Normalize IPv6 hosts through `ipaddress`, so `[::1]` and `[0:0:0:0:0:0:0:1]` are one entry and IPv4-mapped forms parse. - _ssh_clone_env returns only GIT_SSH_COMMAND. GitPython layers env over os.environ, so copying the whole environment (POSTGRES_PASSWORD, GITHUB_TOKEN, JOERN_SERVER_AUTH_PASSWORD) into the Git object was needless secret spread. Shell-quote the key path while here. - New GIT_CLONE_SSH_KNOWN_HOSTS pins the server host key (StrictHostKeyChecking=yes). Unset keeps accept-new, now documented as trust-on-first-use per deploy in the dockerized stack, where the record dies with the container. - Track .ssh-keys/ via .gitkeep so compose doesn't create the default bind mount root-owned in the working tree. - docs/security.md: scope the "two independent gates" claim to the built-in https hosts; the ssh path is the hostname gate plus the port. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reviewed this closely and it's solid work — thanks. The default posture is provably unchanged: I differential-tested Also worth calling out: I've pushed a follow-up commit to this branch with the review items rather than sending you round-trips. Summary: Ports are now part of the allowlist. This was the one thing I'd call a genuine widening: a bare Falling out of that:
Config validation moved to startup.
New Smaller ones: ssh usernames must start alphanumeric (a leading Docs ( Tests: 634 passed, 35 skipped (up from 628 — six new cases covering the port surface, port 0, option-shaped usernames, IPv6 equivalence, startup validation, and error redaction). Two |
Why: SSH paths are needed
generate_cpgonly ever acceptedhttps://github.com/...andhttps://gitlab.com/…— a hardcoded two-host allowlist. That locks the server out of the deployments where it is arguably most useful:ssh://on a custom port with key-based auth. Forcing HTTPS would mean minting and rotating HTTP tokens on the git server and feeding them through the MCP; an operator deploy key needs none of that — and the credential never appears in a URL, an argument, or.git/config.ssh://git@host:3000/owner/repo.gitcarries custom ports unambiguously — the common shape for a self-hosted instance — unlike scp-stylegit@host:path, which stays rejected.This PR therefore extends the clone allowlist in an operator-configured, opt-in way via
GIT_CLONE_EXTRA_HOSTS. With the variable unset, behavior is unchanged frommain.What changed (vs.
main)Validation —
src/utils/validators.pyvalidate_repo_url()restructured into explicit scheme branches:github.com/gitlab.comkeepmain's exact posture: https-only, default port only, no embedded credentials, canonical lowercasehttps://<host>/literal prefix gate.ssh://is accepted only for hosts listed inGIT_CLONE_EXTRA_HOSTS: a username is allowed, an embedded password is rejected, the hostname must match an entry exactly (look-alike / smuggling hosts still rejected), and the path must still be at least/owner/repo. http(s) to a custom host is rejected — extra hosts are ssh-only.GIT_CLONE_EXTRA_HOSTSparser (_extra_repo_host_entries()/is_extra_repo_host()): comma-separatedhost/host:portentries, IPv6 in brackets. A bare host allows any port on it;host:portpins that port. Malformed entries raiseValidationErrorso a typo'd config fails loudly instead of silently widening the allowlist.Cloning —
src/services/git_manager.py_ssh_clone_env()builds theGIT_SSH_COMMANDused for the clone: the fullGIT_CLONE_SSH_COMMANDoverride wins, otherwise it is assembled fromGIT_CLONE_SSH_KEY_PATHplus-o BatchMode=yes(a missing key fails fast instead of hanging on a prompt) and-o StrictHostKeyChecking=accept-new(host key recorded on first contact). The env is passed togit.Repo.clone_from()..git/configcredential strip runs only when a credential was actually injected;ssh://URLs are passed through untouched (auth rides inGIT_SSH_COMMAND, never the URL).Config & deployment
src/defaults.py:GIT_CLONE_EXTRA_HOSTS,GIT_CLONE_SSH_KEY_PATH,GIT_CLONE_SSH_COMMAND— all default empty = no behavior change.docker-compose.yml: mounts${GIT_CLONE_SSH_KEYS_HOST_DIR:-./.ssh-keys}read-only at/keysin thecodebadger-mcpcontainer and derivesGIT_CLONE_SSH_KEY_PATH=/keys/id_ed25519in-container.GIT_CLONE_SSH_KEY_PATHis deliberately not passed through — a host path never resolves inside the container.Dockerfile.mcp: addsopenssh-client..gitignore: ignores.ssh-keys/..env.example: documented, commented examples for both host-run and dockerized MCP.src/tools/core_tools.py:generate_cpgdocstring / field descriptions updated so MCP callers learn thatssh://URLs on configured hosts are accepted.Docs
docs/deployment.md: new "Custom git servers (GIT_CLONE_*)" section with a variable table and the host-run-MCP vs. dockerized key handling explained.docs/security.md: control ①a updated — the SSRF posture is unchanged; the extension is explicit, operator-scoped, exact-host, and ssh-only.Tests
tests/test_git_manager.py:GIT_SSH_COMMANDconstruction and override precedence,ssh://URL passthrough with no credential stripping, historic github-token injection unchanged, custom-host http(s) rejected before any clone.tests/test_validators.py(TestExtraGitHosts): accept/reject matrix for ssh/http(s) across host/port/IPv6 entries, port pinning,ssh://still rejected for github.com/gitlab.com, malformed configs fail loudly, and the empty-config default posture is unchanged.Testing
pytest tests/ --ignore=tests/integration): 630 passed, 35 skipped, 0 failed — the skips are environment-gated (Postgres/Redis/live-Joern tests). Includes the 17 new tests covering the accept/reject matrix and the clone env behavior, plustest_compose_security.pyover the compose changes. The live-server integration suite is exercised in CI.Security posture
Default allowlist unchanged (
github.com/gitlab.com, https-only). The extension is opt-in, operator-scoped, exact-host, ssh-only, with no credentials ever placed in URLs; control characters, look-alike domains, userinfo smuggling, and short paths are still rejected.