/** * gbrain remote-source git helpers (v0.28). * * Single source of SSRF-defensive git invocations. parseRemoteUrl delegates * to isInternalUrl from src/core/url-safety.ts (covers scheme allowlist, * IPv6 loopback, IPv4-mapped IPv6, metadata hostnames, hex/octal bypass, * and CGNAT 100.64/10). * * cloneRepo and pullRepo both spread GIT_SSRF_FLAGS so a future flag added * to one path lands on both — single source of truth. * * Tailscale 100.64/10 trips the integrations.ts allowlist (CGNAT line in * url-safety.ts isPrivateIpv4). For self-hosted internal git servers * reachable only via Tailscale, set GBRAIN_ALLOW_PRIVATE_REMOTES=1; loud * stderr warning at use site is the operator's signal. */ import { execFileSync } from 'child_process'; import { lstatSync, existsSync, readdirSync } from 'fs'; import { join } from 'path'; import { isInternalUrl } from './url-safety.ts'; /** * SSRF-defensive flag set. Used by both cloneRepo and pullRepo. * - http.followRedirects=false: closes DNS rebinding via redirect chains * - protocol.file.allow=never: no local-file URLs (defense in depth) * - protocol.ext.allow=never: no external helpers (`git-remote-foo`) * - --no-recurse-submodules: .gitmodules cannot become a second fetch surface */ export const GIT_SSRF_FLAGS = [ '-c', 'http.followRedirects=false', '-c', 'protocol.file.allow=never', '-c', 'protocol.ext.allow=never', '--no-recurse-submodules', ] as const; export type RemoteUrlErrorCode = | 'invalid_url' | 'unsupported_scheme' | 'embedded_credentials' | 'path_traversal' | 'internal_target'; export class RemoteUrlError extends Error { constructor(public code: RemoteUrlErrorCode, message: string) { super(message); this.name = 'RemoteUrlError'; } } export interface ParsedRemoteUrl { url: string; hostname: string; } /** * Validate a remote git URL for clone safety. https:// only. * Rejects: non-https schemes, embedded credentials, path traversal, and * internal/private targets via isInternalUrl. * * GBRAIN_ALLOW_PRIVATE_REMOTES=1 lets the URL through with a stderr warning. * Needed for self-hosted git over Tailscale (CGNAT 100.64/10) and similar. */ export function parseRemoteUrl(s: string): ParsedRemoteUrl { if (!s || typeof s !== 'string') { throw new RemoteUrlError('invalid_url', 'URL is empty or not a string'); } let url: URL; try { url = new URL(s); } catch { throw new RemoteUrlError('invalid_url', `URL malformed: ${s}`); } if (url.protocol !== 'https:') { throw new RemoteUrlError( 'unsupported_scheme', `URL scheme not supported (https:// only): ${url.protocol}`, ); } if (url.username || url.password) { throw new RemoteUrlError( 'embedded_credentials', 'URL must not contain embedded credentials (https://user:pass@host)', ); } if (s.includes('..')) { throw new RemoteUrlError('path_traversal', 'URL must not contain path-traversal (..)'); } if (isInternalUrl(s)) { if (process.env.GBRAIN_ALLOW_PRIVATE_REMOTES === '1') { console.error( `[gbrain] WARN: GBRAIN_ALLOW_PRIVATE_REMOTES=1, accepting internal/private URL: ${url.hostname}`, ); } else { throw new RemoteUrlError( 'internal_target', `URL targets internal/private network: ${url.hostname} ` + `(set GBRAIN_ALLOW_PRIVATE_REMOTES=1 for self-hosted git over Tailscale or similar)`, ); } } return { url: s, hostname: url.hostname }; } export interface CloneOpts { depth?: number; // default 1; 0 means full clone branch?: string; timeoutMs?: number; // default 600_000 (10 min) } export class GitOperationError extends Error { constructor( public op: 'clone' | 'pull' | 'remote_get_url', message: string, public cause?: unknown, ) { super(message); this.name = 'GitOperationError'; } } const GIT_ENV = { // Confine to the gbrain SSRF model — no credential helpers, no SSH askpass, // no GUI prompts. Inherit PATH so git itself is findable. GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_ASKPASS: '/bin/false', SSH_ASKPASS: '/bin/false', } as const; /** * Clone a remote git repo with SSRF-defensive flags. * - destDir must NOT exist or must be empty. * - Default --depth=1 (no history); pass {depth: 0} for full clone. * - Throws GitOperationError on failure; caller is responsible for cleanup. */ export function cloneRepo(url: string, destDir: string, opts: CloneOpts = {}): void { if (existsSync(destDir)) { let entries: string[]; try { entries = readdirSync(destDir); } catch (e) { throw new GitOperationError( 'clone', `Cannot inspect destination ${destDir}: ${(e as Error).message}`, e, ); } if (entries.length > 0) { throw new GitOperationError( 'clone', `Destination ${destDir} exists and is not empty; refusing to clone`, ); } } const args: string[] = [...GIT_SSRF_FLAGS, 'clone']; if (opts.depth !== 0) { args.push(`--depth=${opts.depth ?? 1}`); } if (opts.branch) { args.push('--branch', opts.branch); } args.push(url, destDir); try { execFileSync('git', args, { stdio: ['ignore', 'pipe', 'pipe'], timeout: opts.timeoutMs ?? 600_000, env: { ...process.env, ...GIT_ENV }, }); } catch (e) { throw new GitOperationError( 'clone', `git clone failed for ${url}: ${(e as Error).message}`, e, ); } } /** Pull a repo with --ff-only and the same SSRF-defensive flags as cloneRepo. */ export function pullRepo(repoPath: string, opts: { timeoutMs?: number } = {}): void { const args: string[] = ['-C', repoPath, ...GIT_SSRF_FLAGS, 'pull', '--ff-only']; try { execFileSync('git', args, { stdio: ['ignore', 'pipe', 'pipe'], timeout: opts.timeoutMs ?? 300_000, env: { ...process.env, ...GIT_ENV }, }); } catch (e) { throw new GitOperationError( 'pull', `git pull failed in ${repoPath}: ${(e as Error).message}`, e, ); } } export type RepoState = | 'healthy' | 'missing' | 'not-a-dir' | 'no-git' | 'url-drift' | 'corrupted'; /** * Classify the on-disk state of a clone. Used by performSync to decide * whether to run pull (healthy), re-clone (missing/no-git/not-a-dir), * refuse with corruption error (corrupted), or refuse with rebase-clone * hint (url-drift). */ export function validateRepoState( repoPath: string, expectedRemoteUrl?: string, ): RepoState { let stat; try { stat = lstatSync(repoPath); } catch (e: any) { if (e?.code === 'ENOENT') return 'missing'; return 'not-a-dir'; } if (!stat.isDirectory()) return 'not-a-dir'; if (!existsSync(join(repoPath, '.git'))) return 'no-git'; let remoteUrl: string; try { const out = execFileSync('git', ['-C', repoPath, 'remote', 'get-url', 'origin'], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 10_000, env: { ...process.env, ...GIT_ENV }, }); remoteUrl = out.toString().trim(); } catch { return 'corrupted'; } if (expectedRemoteUrl !== undefined && remoteUrl !== expectedRemoteUrl) { return 'url-drift'; } return 'healthy'; }