feat(analyze): support private GitHub repos via optional PAT

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 <noreply@anthropic.com>
This commit is contained in:
keng 2026-06-08 17:08:58 +08:00
parent 4fc2ffa5d0
commit e0923c0643
7 changed files with 180 additions and 17 deletions

View file

@ -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<HTMLInputElement>(null);
const [mode, setMode] = useState<InputMode>('github');
const [githubUrl, setGithubUrl] = useState('');
const [githubToken, setGithubToken] = useState('');
const [gitlabUrl, setGitlabUrl] = useState('');
const [localPath, setLocalPath] = useState('');
const [phase, setPhase] = useState<InternalPhase>('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
</div>
)}
</div>
{/* Optional GitHub Personal Access Token for private repos */}
<div className="space-y-1.5 pt-1">
<label
htmlFor={`${inputId}-token`}
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
>
{t('onboarding:repoAnalyzer.githubTokenLabel')}
</label>
<div className="flex items-center gap-3 rounded-xl border border-border-default bg-void px-4 py-3 transition-all duration-200 focus-within:border-accent/40">
<Key className="h-4 w-4 shrink-0 text-text-muted" />
<input
id={`${inputId}-token`}
type="password"
value={githubToken}
onChange={(e) => 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"
/>
</div>
<p className="text-xs text-text-muted">
{t('onboarding:repoAnalyzer.githubTokenHelp')}
</p>
</div>
</div>
)}

View file

@ -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",

View file

@ -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": "本地文件夹路径",

View file

@ -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`,

View file

@ -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) {

View file

@ -379,6 +379,7 @@ export async function cloneOrPull(
url: string,
targetDir: string,
onProgress?: (progress: CloneProgress) => void,
options?: { token?: string },
): Promise<string> {
// 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<void> {
/**
* 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<void> {
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 = '';

View file

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