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', () => {