From e0923c06438f7c5704d8de4608cd467d28cfce35 Mon Sep 17 00:00:00 2001 From: keng Date: Mon, 8 Jun 2026 17:08:58 +0800 Subject: [PATCH] feat(analyze): support private GitHub repos via optional PAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional Personal Access Token field to the GitHub mode of the analyze form. The token travels form → /api/analyze → cloneOrPull and is injected into git via GIT_CONFIG_COUNT / GIT_CONFIG_KEY_0 / GIT_CONFIG_VALUE_0 (http.extraHeader = "Authorization: Basic …"), so it never appears in argv, the URL, or the process listing. Token is transient (form state only), never persisted, and cleared on mode change, cancel, and completion. Backend validates token shape (1–256 chars, [A-Za-z0-9._~+/=-]+) to block CRLF header smuggling before passing it to git. Existing SSRF / URL validation is unchanged — the token path is orthogonal to URL handling. Co-Authored-By: Claude Opus 4.7 --- gitnexus-web/src/components/RepoAnalyzer.tsx | 43 +++++++++++++- gitnexus-web/src/locales/en/onboarding.json | 3 + .../src/locales/zh-CN/onboarding.json | 3 + gitnexus-web/src/services/backend-client.ts | 1 + gitnexus/src/server/api.ts | 45 +++++++++++++-- gitnexus/src/server/git-clone.ts | 46 +++++++++++---- gitnexus/test/unit/git-clone.test.ts | 56 +++++++++++++++++++ 7 files changed, 180 insertions(+), 17 deletions(-) diff --git a/gitnexus-web/src/components/RepoAnalyzer.tsx b/gitnexus-web/src/components/RepoAnalyzer.tsx index 0b7f0abbd..039f3616c 100644 --- a/gitnexus-web/src/components/RepoAnalyzer.tsx +++ b/gitnexus-web/src/components/RepoAnalyzer.tsx @@ -16,6 +16,7 @@ import { ArrowRight, AlertCircle, Sparkles, + Key, } from '@/lib/lucide-icons'; import { startAnalyze, @@ -168,6 +169,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp const folderInputRef = useRef(null); const [mode, setMode] = useState('github'); const [githubUrl, setGithubUrl] = useState(''); + const [githubToken, setGithubToken] = useState(''); const [gitlabUrl, setGitlabUrl] = useState(''); const [localPath, setLocalPath] = useState(''); const [phase, setPhase] = useState('input'); @@ -193,6 +195,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp const handleModeChange = (m: InputMode) => { setMode(m); setGithubUrl(''); + setGithubToken(''); setGitlabUrl(''); setLocalPath(''); setValidationError(null); @@ -231,7 +234,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp try { const request = mode === 'github' - ? { url: githubUrl.trim() } + ? { + url: githubUrl.trim(), + ...(githubToken.trim() ? { token: githubToken.trim() } : {}), + } : mode === 'gitlab' ? { url: gitlabUrl.trim() } : { path: localPath.trim() }; @@ -254,6 +260,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp nameSource.split(/[/\\]/).filter(Boolean).at(-1) ?? t('onboarding:repoAnalyzer.defaultRepoName'); setCompletedRepoName(name); + setGithubToken(''); setPhase('done'); sseControllerRef.current = null; completeTimerRef.current = setTimeout(() => { @@ -282,6 +289,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp } catch {} jobIdRef.current = null; } + setGithubToken(''); setPhase('input'); setProgress({ phase: 'queued', percent: 0, message: t('common:analyzePhases.queued') }); }; @@ -344,6 +352,39 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp )} + + {/* Optional GitHub Personal Access Token for private repos */} +
+ +
+ + setGithubToken(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && canSubmit && !isLoading) { + e.preventDefault(); + handleAnalyze(); + } + }} + disabled={isLoading} + placeholder={t('onboarding:repoAnalyzer.githubTokenPlaceholder')} + autoComplete="off" + spellCheck={false} + className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50" + /> +
+

+ {t('onboarding:repoAnalyzer.githubTokenHelp')} +

+
)} diff --git a/gitnexus-web/src/locales/en/onboarding.json b/gitnexus-web/src/locales/en/onboarding.json index cd12c2095..f8aeb49e7 100644 --- a/gitnexus-web/src/locales/en/onboarding.json +++ b/gitnexus-web/src/locales/en/onboarding.json @@ -58,6 +58,9 @@ "loadingGraph": "Loading graph...", "defaultRepoName": "repository", "githubRepositoryUrl": "GitHub Repository URL", + "githubTokenLabel": "Personal Access Token (optional)", + "githubTokenPlaceholder": "ghp_… or github_pat_…", + "githubTokenHelp": "Required for private repos. Needs the 'repo' (or fine-grained Contents:read) scope. Sent once, not stored.", "gitlabRepositoryUrl": "GitLab Repository URL", "gitlabSupported": "Supports GitLab.com and self-hosted GitLab instances.", "localFolderPath": "Local Folder Path", diff --git a/gitnexus-web/src/locales/zh-CN/onboarding.json b/gitnexus-web/src/locales/zh-CN/onboarding.json index 6199511f3..27f1cde08 100644 --- a/gitnexus-web/src/locales/zh-CN/onboarding.json +++ b/gitnexus-web/src/locales/zh-CN/onboarding.json @@ -58,6 +58,9 @@ "loadingGraph": "正在加载图数据...", "defaultRepoName": "仓库", "githubRepositoryUrl": "GitHub 仓库 URL", + "githubTokenLabel": "个人访问令牌(可选)", + "githubTokenPlaceholder": "ghp_… 或 github_pat_…", + "githubTokenHelp": "私有仓库需要此项。需 'repo' 范围(或细粒度 Contents:read)。仅发送一次,不会保存。", "gitlabRepositoryUrl": "GitLab 仓库 URL", "gitlabSupported": "支持 GitLab.com 和自托管 GitLab 实例。", "localFolderPath": "本地文件夹路径", diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index e887e3901..49c096c08 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -763,6 +763,7 @@ export const startAnalyze = async (request: { path?: string; force?: boolean; embeddings?: boolean; + token?: string; }): Promise<{ jobId: string; status: string }> => { const response = await fetchWithTimeout( `${_backendUrl}/api/analyze`, diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index da99ed93b..634483716 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -1415,7 +1415,14 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // POST /api/analyze — start a new analysis job app.post('/api/analyze', createRouteLimiter({ limit: 10 }), async (req, res) => { try { - const { url: repoUrl, path: repoLocalPath, force, embeddings, dropEmbeddings } = req.body; + const { + url: repoUrl, + path: repoLocalPath, + force, + embeddings, + dropEmbeddings, + token: repoToken, + } = req.body; // Input type validation if (repoUrl !== undefined && typeof repoUrl !== 'string') { @@ -1432,6 +1439,27 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => return; } + // Token: optional, restricted charset to prevent header smuggling + // (CRLF) and bound length so a hostile body can't blow up env size. + if (repoToken !== undefined) { + if (typeof repoToken !== 'string') { + res.status(400).json({ error: '"token" must be a string' }); + return; + } + if (repoToken.length === 0 || repoToken.length > 256) { + res.status(400).json({ error: '"token" length must be between 1 and 256' }); + return; + } + if (!/^[A-Za-z0-9._~+/=-]+$/.test(repoToken)) { + res.status(400).json({ error: '"token" contains invalid characters' }); + return; + } + if (!repoUrl) { + res.status(400).json({ error: '"token" requires "url"' }); + return; + } + } + // Path validation: require absolute path, reject traversal (e.g. /tmp/../etc/passwd) if (repoLocalPath) { if (!path.isAbsolute(repoLocalPath)) { @@ -1470,11 +1498,16 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => progress: { phase: 'cloning', percent: 0, message: `Cloning ${repoUrl}...` }, }); - await cloneOrPull(repoUrl, targetPath, (progress) => { - jobManager.updateJob(job.id, { - progress: { phase: progress.phase, percent: 5, message: progress.message }, - }); - }); + await cloneOrPull( + repoUrl, + targetPath, + (progress) => { + jobManager.updateJob(job.id, { + progress: { phase: progress.phase, percent: 5, message: progress.message }, + }); + }, + repoToken ? { token: repoToken } : undefined, + ); } if (!targetPath) { diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts index d92e9c28f..3fce694a4 100644 --- a/gitnexus/src/server/git-clone.ts +++ b/gitnexus/src/server/git-clone.ts @@ -379,6 +379,7 @@ export async function cloneOrPull( url: string, targetDir: string, onProgress?: (progress: CloneProgress) => void, + options?: { token?: string }, ): Promise { // Containment barrier — inline with the canonical path.relative idiom so // CodeQL recognizes the sanitizer at every following filesystem and @@ -413,29 +414,54 @@ export async function cloneOrPull( // whatever remote the dir was originally cloned from. await assertRemoteMatchesRequestedUrl(safeTarget, url); onProgress?.({ phase: 'pulling', message: 'Pulling latest changes...' }); - await runGit(['pull', '--ff-only'], safeTarget); + await runGit(['pull', '--ff-only'], safeTarget, options); } else { await fs.mkdir(path.dirname(safeTarget), { recursive: true }); onProgress?.({ phase: 'cloning', message: `Cloning ${url}...` }); - await runGit(buildCloneArgs(url, safeTarget)); + await runGit(buildCloneArgs(url, safeTarget), undefined, options); } return safeTarget; } -function runGit(args: string[], cwd?: string): Promise { +/** + * Build the spawn env for `git`. Injects an Authorization header via the + * standard `GIT_CONFIG_*` env protocol (git ≥2.31) when a token is supplied, + * so credentials never appear in argv or the URL. Exported for unit tests. + */ +export function buildGitEnv( + baseEnv: NodeJS.ProcessEnv, + options?: { token?: string }, +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...baseEnv, + // Prevent git from prompting for credentials (hangs the process) + GIT_TERMINAL_PROMPT: '0', + // Ensure no credential helper tries to open a GUI prompt + GIT_ASKPASS: process.platform === 'win32' ? 'echo' : '/bin/true', + }; + + const token = options?.token; + if (token) { + // `x-access-token` is the documented username for GitHub PATs / app tokens + // when supplied via HTTP Basic. Token is base64-encoded here, never put + // into argv or the URL. + const credential = Buffer.from(`x-access-token:${token}`).toString('base64'); + env.GIT_CONFIG_COUNT = '1'; + env.GIT_CONFIG_KEY_0 = 'http.extraHeader'; + env.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${credential}`; + } + + return env; +} + +function runGit(args: string[], cwd?: string, options?: { token?: string }): Promise { return new Promise((resolve, reject) => { const proc = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, - env: { - ...process.env, - // Prevent git from prompting for credentials (hangs the process) - GIT_TERMINAL_PROMPT: '0', - // Ensure no credential helper tries to open a GUI prompt - GIT_ASKPASS: process.platform === 'win32' ? 'echo' : '/bin/true', - }, + env: buildGitEnv(process.env, options), }); let stderr = ''; diff --git a/gitnexus/test/unit/git-clone.test.ts b/gitnexus/test/unit/git-clone.test.ts index 88832aa83..9ecd5a7e0 100644 --- a/gitnexus/test/unit/git-clone.test.ts +++ b/gitnexus/test/unit/git-clone.test.ts @@ -5,6 +5,7 @@ import { validateGitUrl, cloneOrPull, buildCloneArgs, + buildGitEnv, normalizeGitUrlForCompare, assertRemoteMatchesRequestedUrl, } from '../../src/server/git-clone.js'; @@ -311,6 +312,61 @@ describe('git-clone', () => { // --depth must be before the `--` separator (it's an option, not a positional). expect(depthIdx).toBeLessThan(args.indexOf('--')); }); + + it('never embeds a token in argv: token is injected via env, not URL', () => { + // Buffer for buildCloneArgs is URL-only; token must travel through env + // (buildGitEnv) so it cannot appear in `ps auxww` or in command logs. + const args = buildCloneArgs('https://github.com/owner/repo.git', '/safe/target'); + expect(args.some((a) => a.includes('ghp_'))).toBe(false); + expect(args.some((a) => /[A-Za-z0-9]{40}/.test(a) && !a.includes('github.com'))).toBe(false); + }); + }); + + describe('buildGitEnv — token injection', () => { + // The token MUST travel via GIT_CONFIG_* env vars (git ≥2.31), not via + // argv or URL. This keeps it out of `ps`, shell history, and stderr. + + it('passes through base env and sets prompt-suppression env vars', () => { + const env = buildGitEnv({ FOO: 'bar' }); + expect(env.FOO).toBe('bar'); + expect(env.GIT_TERMINAL_PROMPT).toBe('0'); + expect(env.GIT_ASKPASS).toBeDefined(); + }); + + it('does not set GIT_CONFIG_* env vars when no token is provided', () => { + const env = buildGitEnv({}); + expect(env.GIT_CONFIG_COUNT).toBeUndefined(); + expect(env.GIT_CONFIG_KEY_0).toBeUndefined(); + expect(env.GIT_CONFIG_VALUE_0).toBeUndefined(); + }); + + it('also leaves GIT_CONFIG_* unset when token is empty string', () => { + const env = buildGitEnv({}, { token: '' }); + expect(env.GIT_CONFIG_COUNT).toBeUndefined(); + expect(env.GIT_CONFIG_KEY_0).toBeUndefined(); + expect(env.GIT_CONFIG_VALUE_0).toBeUndefined(); + }); + + it('sets http.extraHeader with Basic auth when token is provided', () => { + const env = buildGitEnv({}, { token: 'ghp_secret123' }); + expect(env.GIT_CONFIG_COUNT).toBe('1'); + expect(env.GIT_CONFIG_KEY_0).toBe('http.extraHeader'); + const expected = + 'Authorization: Basic ' + + Buffer.from('x-access-token:ghp_secret123').toString('base64'); + expect(env.GIT_CONFIG_VALUE_0).toBe(expected); + }); + + it('never includes the raw token value in any env entry', () => { + // Defence-in-depth: token must only appear inside the base64 of the + // Authorization header, never as a plain substring of any env var. + const token = 'ghp_uniqueRawSecret_98765'; + const env = buildGitEnv({ EXISTING: 'value' }, { token }); + for (const [key, value] of Object.entries(env)) { + if (key === 'GIT_CONFIG_VALUE_0') continue; + expect(String(value)).not.toContain(token); + } + }); }); describe('cloneOrPull — containment barrier', () => {