mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
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:
parent
4fc2ffa5d0
commit
e0923c0643
7 changed files with 180 additions and 17 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "本地文件夹路径",
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue