diff --git a/.env.example b/.env.example
index 8af9dee79..445ae37a7 100644
--- a/.env.example
+++ b/.env.example
@@ -17,3 +17,9 @@ WEB_HOST_PORT=4173
# Optional read-only mount, exposed to the server as /workspace.
# Override with the directory that contains the repos you want to index.
WORKSPACE_DIR=./
+
+# Azure DevOps Server Integration (passed to the server container)
+# Prefer https:// — the PAT rides in an Authorization header, so cleartext
+# http:// exposes it on the wire (still supported for internal-only instances).
+# AZURE_DEVOPS_URL=https://azuredevops.example.com
+# AZURE_DEVOPS_PAT=your-pat-here
diff --git a/.gitleaks.toml b/.gitleaks.toml
index 769b7eee9..cecf77d5a 100644
--- a/.gitleaks.toml
+++ b/.gitleaks.toml
@@ -3,10 +3,17 @@ title = "GitNexus"
[extend]
useDefault = true
-# Fake embedding API keys in unit tests (current probe + historical placeholder).
+# Fake credentials in unit tests — none are real secrets:
+# - embedding API keys in the http-embedder tests (regexes below)
+# - synthetic GitHub PAT fixtures in the git-clone PAT-injection tests
+# (e.g. ghp_secret123, ghp_uniqueRawSecret_98765) — allowlisted by path
+# so the exception is bounded to that one test file.
[allowlist]
-description = "fake embedding API keys in http-embedder unit tests"
+description = "fake credentials in unit tests (no real secrets)"
regexes = [
'''secret-key-12345''',
'''test-api-key-redaction-check''',
]
+paths = [
+ '''gitnexus/test/unit/git-clone\.test\.ts''',
+]
diff --git a/gitnexus-web/src/components/AnalyzeOnboarding.tsx b/gitnexus-web/src/components/AnalyzeOnboarding.tsx
index 53260e388..b970ee842 100644
--- a/gitnexus-web/src/components/AnalyzeOnboarding.tsx
+++ b/gitnexus-web/src/components/AnalyzeOnboarding.tsx
@@ -3,7 +3,7 @@
*
* The "empty state" card rendered inside DropZone's Crossfade when the server
* is connected but zero repos are indexed. Replaces the generic error message
- * with a first-class GitHub URL input flow.
+ * with a first-class repository URL input flow.
*
* Rendering context:
* DropZone (Crossfade, phase="analyze")
@@ -15,7 +15,7 @@
* the app to the graph explorer.
*/
-import { Sparkles, Github } from '@/lib/lucide-icons';
+import { Sparkles, GitBranch } from '@/lib/lucide-icons';
import { RepoAnalyzer } from './RepoAnalyzer';
import { useTranslation } from 'react-i18next';
@@ -46,7 +46,7 @@ export const AnalyzeOnboarding = ({ onComplete }: AnalyzeOnboardingProps) => {
{/* Icon */}
-
+
diff --git a/gitnexus-web/src/components/RepoAnalyzer.tsx b/gitnexus-web/src/components/RepoAnalyzer.tsx
index 613284830..fd08d197d 100644
--- a/gitnexus-web/src/components/RepoAnalyzer.tsx
+++ b/gitnexus-web/src/components/RepoAnalyzer.tsx
@@ -10,12 +10,14 @@ import { useState, useRef, useEffect, useId } from 'react';
import {
Github,
Gitlab,
+ AzureDevops,
FolderOpen,
Loader2,
Check,
ArrowRight,
AlertCircle,
Sparkles,
+ Key,
} from '@/lib/lucide-icons';
import {
startAnalyze,
@@ -30,10 +32,14 @@ import { useTranslation } from 'react-i18next';
// ── Helpers ──────────────────────────────────────────────────────────────────
-type InputMode = 'github' | 'gitlab' | 'local';
+type InputMode = 'github' | 'gitlab' | 'azure' | 'local';
const GITHUB_RE = /^https?:\/\/(www\.)?github\.com\/[^/\s]+\/[^/\s]+/i;
const GITLAB_RE = /^https?:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+(\/.*)?$/i;
+// One-or-more path segments before `/_git/`, so the legacy single-project
+// cloud form (myorg.visualstudio.com/project/_git/repo) is accepted too —
+// the backend already supports it (isAzureDevOpsUrl / extractRepoName).
+const AZURE_RE = /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+_git\/[^/\s]+/i;
const IS_WINDOWS = navigator.userAgent.toLowerCase().includes('win');
function isValidGithubUrl(value: string): boolean {
@@ -44,6 +50,10 @@ function isValidGitlabUrl(value: string): boolean {
return GITLAB_RE.test(value.trim());
}
+function isValidAzureUrl(value: string): boolean {
+ return AZURE_RE.test(value.trim());
+}
+
// ── Mode tabs ────────────────────────────────────────────────────────────────
function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode) => void }) {
@@ -81,6 +91,19 @@ function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode
{t('repoAnalyzer.gitlabUrl')}
+ onChange('azure')}
+ className={`flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
+ mode === 'azure'
+ ? 'bg-accent text-white shadow-sm'
+ : 'text-text-muted hover:text-text-secondary'
+ } `}
+ >
+
+ {t('repoAnalyzer.azureDevOpsUrl')}
+
('input');
const [validationError, setValidationError] = useState(null);
@@ -239,7 +264,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
invalidateRequest();
setMode(m);
setGithubUrl('');
+ setGithubToken('');
setGitlabUrl('');
+ setAzureUrl('');
setLocalPath('');
setValidationError(null);
setUploadSummary(null);
@@ -259,7 +286,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
? isValidGithubUrl(githubUrl) && (phase === 'input' || phase === 'error')
: mode === 'gitlab'
? isValidGitlabUrl(gitlabUrl) && (phase === 'input' || phase === 'error')
- : localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
+ : mode === 'azure'
+ ? isValidAzureUrl(azureUrl) && (phase === 'input' || phase === 'error')
+ : localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
const handleAnalyze = async () => {
if (mode === 'github' && !isValidGithubUrl(githubUrl)) {
@@ -270,6 +299,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
setValidationError('Please enter a valid GitLab repository URL.');
return;
}
+ if (mode === 'azure' && !isValidAzureUrl(azureUrl)) {
+ setValidationError(t('errors:invalidAzureDevOpsUrl'));
+ return;
+ }
if (mode === 'local' && localPath.trim().length < 2) {
setValidationError(t('errors:missingFolderPath'));
return;
@@ -285,10 +318,15 @@ 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() };
+ : mode === 'azure'
+ ? { url: azureUrl.trim() }
+ : { path: localPath.trim() };
const { jobId } = await startAnalyze(request);
// Stale resolution: return without cancelling — URL jobIds may be
// dedup-aliased to a job another session owns (see cancelStaleUploadJob).
@@ -299,7 +337,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
? githubUrl.trim()
: mode === 'gitlab'
? gitlabUrl.trim()
- : localPath.trim();
+ : mode === 'azure'
+ ? azureUrl.trim()
+ : localPath.trim();
trackJob(jobId, nameSource);
} catch (err) {
// Unmount aborts the controller, so this also covers the unmounted case.
@@ -327,6 +367,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
: undefined) ??
t('onboarding:repoAnalyzer.defaultRepoName');
setCompletedRepoName(name);
+ setGithubToken('');
setPhase('done');
sseControllerRef.current = null;
completeTimerRef.current = setTimeout(() => {
@@ -396,6 +437,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') });
setUploading(false);
@@ -460,6 +502,39 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
)}
+
+ {/* Optional GitHub Personal Access Token for private repos */}
+
+
+ {t('onboarding:repoAnalyzer.githubTokenLabel')}
+
+
+
+ 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')}
+
+
)}
@@ -516,6 +591,61 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
)}
+ {/* Azure DevOps URL input */}
+ {showInput && mode === 'azure' && (
+
+
+ {t('onboarding:repoAnalyzer.azureDevOpsRepositoryUrl')}
+
+
+
+
{
+ setAzureUrl(e.target.value);
+ if (validationError) setValidationError(null);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && canSubmit && !isLoading) {
+ e.preventDefault();
+ handleAnalyze();
+ }
+ }}
+ disabled={isLoading}
+ placeholder="http://azuredevops.example.com/Collection/Project/_git/Repo"
+ autoComplete="url"
+ 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"
+ />
+ {azureUrl.length > 10 && (
+
+ {isValidAzureUrl(azureUrl) ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ {t('onboarding:repoAnalyzer.azureDevOpsSupported')}
+
+
+ )}
+
{/* Local folder input */}
{showInput && mode === 'local' && (
diff --git a/gitnexus-web/src/lib/lucide-icons.tsx b/gitnexus-web/src/lib/lucide-icons.tsx
index 7d9b5fc7d..ab2d87524 100644
--- a/gitnexus-web/src/lib/lucide-icons.tsx
+++ b/gitnexus-web/src/lib/lucide-icons.tsx
@@ -185,6 +185,41 @@ export const Gitlab = forwardRef
(function Gitlab(
);
});
+/**
+ * Azure DevOps mark — SVG path data from simple-icons (CC0-1.0).
+ *
+ * The Azure DevOps logo is a registered trademark of Microsoft Corporation.
+ * We use it here only to indicate Azure DevOps source-repo integration.
+ *
+ * API-compatible with `lucide-react` icons (`LucideProps`).
+ */
+export const AzureDevops = forwardRef(function AzureDevops(
+ {
+ size = 24,
+ color = 'currentColor',
+ className,
+ strokeWidth: _strokeWidth,
+ absoluteStrokeWidth: _absoluteStrokeWidth,
+ ...rest
+ },
+ ref,
+) {
+ return (
+
+
+
+ );
+});
+
export const Github = forwardRef(function Github(
{
size = 24,
diff --git a/gitnexus-web/src/locales/en/errors.json b/gitnexus-web/src/locales/en/errors.json
index c3f727078..0c7566ebe 100644
--- a/gitnexus-web/src/locales/en/errors.json
+++ b/gitnexus-web/src/locales/en/errors.json
@@ -6,6 +6,7 @@
"analysisFailed": "Analysis failed. Check server logs.",
"startAnalysisFailed": "Failed to start analysis",
"invalidGithubUrl": "Please enter a valid GitHub repository URL.",
+ "invalidAzureDevOpsUrl": "Please enter a valid Azure DevOps repository URL.",
"missingFolderPath": "Please enter a folder path.",
"backend": {
"reconnecting": "Server connection lost. Reconnecting…",
diff --git a/gitnexus-web/src/locales/en/onboarding.json b/gitnexus-web/src/locales/en/onboarding.json
index 8be58efce..c9a45d9b2 100644
--- a/gitnexus-web/src/locales/en/onboarding.json
+++ b/gitnexus-web/src/locales/en/onboarding.json
@@ -31,7 +31,7 @@
},
"analyzeFirst": {
"title": "Analyze your first repository",
- "description": "Paste a GitHub URL and GitNexus will clone it, parse the code, and build a live knowledge graph — right in your browser.",
+ "description": "Paste a repository URL and GitNexus will clone it, parse the code, and build a live knowledge graph — right in your browser.",
"footer": "Public repos only · Cloned locally by the server · No data leaves your machine"
},
"landing": {
@@ -51,6 +51,7 @@
"inputType": "Input type",
"githubUrl": "GitHub URL",
"gitlabUrl": "GitLab URL",
+ "azureDevOpsUrl": "Azure DevOps",
"localFolder": "Local Folder",
"starting": "Starting analysis...",
"analyzeRepository": "Analyze Repository",
@@ -58,8 +59,13 @@
"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.",
+ "azureDevOpsRepositoryUrl": "Azure DevOps Repository URL",
+ "azureDevOpsSupported": "Format: https://dev.azure.com/organization/project/_git/repository",
"localFolderPath": "Local Folder Path",
"hideBackground": "Hide (analysis continues in background)",
"upload": {
diff --git a/gitnexus-web/src/locales/zh-CN/errors.json b/gitnexus-web/src/locales/zh-CN/errors.json
index d567d4ff9..811f349fc 100644
--- a/gitnexus-web/src/locales/zh-CN/errors.json
+++ b/gitnexus-web/src/locales/zh-CN/errors.json
@@ -6,6 +6,7 @@
"analysisFailed": "分析失败,请检查服务器日志。",
"startAnalysisFailed": "启动分析失败",
"invalidGithubUrl": "请输入有效的 GitHub 仓库 URL。",
+ "invalidAzureDevOpsUrl": "请输入有效的 Azure DevOps 仓库 URL。",
"missingFolderPath": "请输入文件夹路径。",
"backend": {
"reconnecting": "服务器连接已断开,正在重连…",
diff --git a/gitnexus-web/src/locales/zh-CN/onboarding.json b/gitnexus-web/src/locales/zh-CN/onboarding.json
index f20e60b6d..930b5c3d8 100644
--- a/gitnexus-web/src/locales/zh-CN/onboarding.json
+++ b/gitnexus-web/src/locales/zh-CN/onboarding.json
@@ -31,7 +31,7 @@
},
"analyzeFirst": {
"title": "分析你的第一个仓库",
- "description": "粘贴 GitHub URL,GitNexus 会克隆仓库、解析代码,并直接在浏览器中构建实时知识图谱。",
+ "description": "粘贴仓库 URL,GitNexus 会克隆仓库、解析代码,并直接在浏览器中构建实时知识图谱。",
"footer": "仅支持公开仓库 · 服务器本地克隆 · 数据不会离开你的机器"
},
"landing": {
@@ -51,6 +51,7 @@
"inputType": "输入类型",
"githubUrl": "GitHub URL",
"gitlabUrl": "GitLab URL",
+ "azureDevOpsUrl": "Azure DevOps",
"localFolder": "本地文件夹",
"starting": "正在启动分析...",
"analyzeRepository": "分析仓库",
@@ -58,8 +59,13 @@
"loadingGraph": "正在加载图数据...",
"defaultRepoName": "仓库",
"githubRepositoryUrl": "GitHub 仓库 URL",
+ "githubTokenLabel": "个人访问令牌(可选)",
+ "githubTokenPlaceholder": "ghp_… 或 github_pat_…",
+ "githubTokenHelp": "私有仓库需要此项。需 'repo' 范围(或细粒度 Contents:read)。仅发送一次,不会保存。",
"gitlabRepositoryUrl": "GitLab 仓库 URL",
"gitlabSupported": "支持 GitLab.com 和自托管 GitLab 实例。",
+ "azureDevOpsRepositoryUrl": "Azure DevOps 仓库 URL",
+ "azureDevOpsSupported": "格式: http://server/Collection/Project/_git/Repository",
"localFolderPath": "本地文件夹路径",
"hideBackground": "隐藏(分析继续在后台进行)",
"upload": {
diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts
index b6f2e34a5..09b7dfffe 100644
--- a/gitnexus-web/src/services/backend-client.ts
+++ b/gitnexus-web/src/services/backend-client.ts
@@ -854,6 +854,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/.env.example b/gitnexus/.env.example
index 0c4cd297d..8f2f83dc4 100644
--- a/gitnexus/.env.example
+++ b/gitnexus/.env.example
@@ -10,3 +10,13 @@
# Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI.
# See README for details.
+
+# Azure DevOps Server (Self-Hosted) Integration
+# Base URL of your Azure DevOps Server instance. Prefer https:// — the PAT is
+# sent in an Authorization header, so cleartext http:// exposes it on the wire
+# (http:// is still supported for internal-only instances; the server warns).
+# AZURE_DEVOPS_URL=https://azuredevops.example.com
+
+# Personal Access Token with Code (Read) scope for cloning private repos.
+# Used for both self-hosted and cloud (dev.azure.com) Azure DevOps.
+# AZURE_DEVOPS_PAT=your-pat-here
diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts
index 05836d350..bb4cfcef4 100644
--- a/gitnexus/src/server/api.ts
+++ b/gitnexus/src/server/api.ts
@@ -33,7 +33,13 @@ import { mountMCPEndpoints } from './mcp-http.js';
import { fileURLToPath } from 'url';
import { JobManager } from './analyze-job.js';
import { assertString, escapeRegExp, BadRequestError, createRouteLimiter } from './validation.js';
-import { extractRepoName, getCloneDir, cloneOrPull } from './git-clone.js';
+import {
+ extractRepoName,
+ getCloneDir,
+ cloneOrPull,
+ warnIfInsecureAzureConfig,
+ GITHUB_TOKEN_HOSTS,
+} from './git-clone.js';
import { createAnalyzeUploadHandler } from './analyze-upload.js';
import { createLocalhostOriginGuard, normalizeBoundHost } from './middleware.js';
import { createLaunchAnalysisWorker } from './analyze-launch.js';
@@ -673,7 +679,47 @@ export const handleQueryRequest = async (
}
};
+/**
+ * Validate the optional `token` field of POST /api/analyze. Returns an
+ * { status, error } to send, or null when the token is absent or valid.
+ *
+ * The token is a GitHub PAT: charset-restricted (blocks CRLF header
+ * smuggling), length-bounded (1–256), and bound to github.com using the SAME
+ * GITHUB_TOKEN_HOSTS allowlist + hostname parse as resolveGitCredential, so a
+ * token the API accepts is exactly the one buildGitEnv will inject — and one
+ * it rejects is never sent off github.com.
+ *
+ * Exported for unit tests (the route validation is otherwise only reachable
+ * by booting the server).
+ */
+export function validateAnalyzeToken(
+ repoToken: unknown,
+ repoUrl: unknown,
+): { status: number; error: string } | null {
+ if (repoToken === undefined) return null;
+ if (typeof repoToken !== 'string') return { status: 400, error: '"token" must be a string' };
+ if (repoToken.length === 0 || repoToken.length > 256)
+ return { status: 400, error: '"token" length must be between 1 and 256' };
+ if (!/^[A-Za-z0-9._~+/=-]+$/.test(repoToken))
+ return { status: 400, error: '"token" contains invalid characters' };
+ if (!repoUrl || typeof repoUrl !== 'string')
+ return { status: 400, error: '"token" requires "url"' };
+ let tokenHost: string;
+ try {
+ tokenHost = new URL(repoUrl).hostname.toLowerCase();
+ } catch {
+ return { status: 400, error: '"url" must be a valid URL when "token" is provided' };
+ }
+ if (!GITHUB_TOKEN_HOSTS.has(tokenHost))
+ return { status: 400, error: '"token" is only supported for github.com URLs' };
+ return null;
+}
+
export const createServer = async (port: number, host: string = '127.0.0.1') => {
+ // Surface a cleartext Azure DevOps PAT config at boot (operators rarely
+ // read per-request logs). Warn-only — http:// self-hosted stays supported.
+ warnIfInsecureAzureConfig();
+
const app = express();
app.disable('x-powered-by');
@@ -1461,7 +1507,14 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
requireLocalhostOrigin,
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') {
@@ -1478,6 +1531,14 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
return;
}
+ // Token: optional, restricted charset to prevent header smuggling
+ // (CRLF), bound length, and bound to github.com (see validateAnalyzeToken).
+ const tokenError = validateAnalyzeToken(repoToken, repoUrl);
+ if (tokenError) {
+ res.status(tokenError.status).json({ error: tokenError.error });
+ return;
+ }
+
// Path validation. The previous `normalize !== resolve` guard was inert
// (both collapse `..` identically) and only false-rejected trailing
// slashes, so it is dropped. Analyzing a local path the operator names
@@ -1496,9 +1557,19 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
const job = jobManager.createJob({ repoUrl, repoPath: repoLocalPath });
- // If job was already running (dedup), just return its id
+ // If job was already running (dedup), just return its id. The token is
+ // not part of the dedup identity and is never stored on the job, so a
+ // token on THIS request had no effect — the existing job already
+ // cloned (or is cloning) with whatever credentials its originating
+ // request supplied. Surface `tokenIgnored` so an authenticated caller
+ // isn't misled into thinking their PAT took effect on a reused job.
if (job.status !== 'queued') {
- res.status(202).json({ jobId: job.id, status: job.status });
+ const body: { jobId: string; status: string; tokenIgnored?: boolean } = {
+ jobId: job.id,
+ status: job.status,
+ };
+ if (repoToken !== undefined) body.tokenIgnored = true;
+ res.status(202).json(body);
return;
}
@@ -1520,11 +1591,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 eae13b94e..16a7c48c5 100644
--- a/gitnexus/src/server/git-clone.ts
+++ b/gitnexus/src/server/git-clone.ts
@@ -236,6 +236,61 @@ export interface CloneProgress {
*
* Exported so the separator placement is testable without mocking spawn.
*/
+/**
+ * Detect Azure DevOps URLs — both self-hosted (via AZURE_DEVOPS_URL env)
+ * and cloud (dev.azure.com / *.visualstudio.com).
+ *
+ * Self-hosted Azure DevOps Server instances use arbitrary hostnames
+ * (e.g. `http://tfs.corp.example/Collection/Project/_git/Repo`), so the
+ * function checks `AZURE_DEVOPS_URL` first. Cloud addresses are a
+ * hardcoded fallback so PAT injection works out-of-the-box for
+ * dev.azure.com without extra configuration.
+ */
+export function isAzureDevOpsUrl(url: string): boolean {
+ try {
+ // Strip a single trailing dot: `dev.azure.com.` is a valid absolute FQDN
+ // that resolves to the same host, so it must match too.
+ const host = new URL(url).hostname.toLowerCase().replace(/\.$/, '');
+
+ // Self-hosted: match against the configured base URL.
+ const configuredBase = process.env.AZURE_DEVOPS_URL;
+ if (configuredBase) {
+ try {
+ const baseHost = new URL(configuredBase).hostname.toLowerCase().replace(/\.$/, '');
+ if (host === baseHost) return true;
+ } catch {
+ /* invalid AZURE_DEVOPS_URL — fall through to cloud check */
+ }
+ }
+
+ // Cloud fallback.
+ return host === 'dev.azure.com' || host.endsWith('.visualstudio.com');
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * One-time startup warning when AZURE_DEVOPS_URL is configured over cleartext
+ * http:// — the Azure DevOps PAT would then be sent unencrypted on every
+ * clone. Self-hosted instances that only serve http are still supported (we
+ * do not refuse), but operators rarely read request-time logs, so surface it
+ * at boot too. Call once from server startup.
+ */
+export function warnIfInsecureAzureConfig(): void {
+ const base = process.env.AZURE_DEVOPS_URL;
+ if (!base) return;
+ try {
+ if (new URL(base).protocol === 'http:') {
+ logger.warn(
+ 'AZURE_DEVOPS_URL is configured over cleartext http:// — the Azure DevOps PAT will be sent unencrypted. Prefer https:// where your instance supports it.',
+ );
+ }
+ } catch {
+ /* invalid AZURE_DEVOPS_URL — isAzureDevOpsUrl already tolerates this */
+ }
+}
+
export function buildCloneArgs(url: string, targetDir: string): string[] {
return ['clone', '--depth', '1', '--', url, targetDir];
}
@@ -379,6 +434,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 +469,176 @@ 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, { token: options?.token, url });
} 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, { token: options?.token, url });
}
return safeTarget;
}
-function runGit(args: string[], cwd?: string): Promise {
+/**
+ * Hosts the per-request GitHub PAT may be sent to. Exported so the
+ * /api/analyze boundary check and this injection-site check share one
+ * allowlist (they must agree, or a token accepted by the API could be
+ * silently dropped — or worse — at injection).
+ */
+export const GITHUB_TOKEN_HOSTS: ReadonlySet = new Set(['github.com', 'www.github.com']);
+
+/**
+ * Resolve at most ONE git credential for a clone/pull, by server-side policy
+ * keyed on the clone host against a fixed allowlist (never a free-form user
+ * toggle):
+ * 1. a per-request GitHub PAT — only for hosts in GITHUB_TOKEN_HOSTS;
+ * 2. else the server's AZURE_DEVOPS_PAT — only for Azure DevOps hosts;
+ * 3. else none.
+ * The two host sets are disjoint, so at most one credential ever applies; the
+ * GitHub token taking precedence is deterministic for the pathological case
+ * where AZURE_DEVOPS_URL is itself configured to a github.com host. Returns
+ * the base64 of the Basic-auth `user:secret` pair, or undefined.
+ *
+ * Security note (re CodeQL js/user-controlled-bypass): the clone URL is
+ * user-controlled and selects WHICH credential applies, but it cannot
+ * redirect a credential to an arbitrary host — the host is matched against
+ * fixed server-side allowlists (GITHUB_TOKEN_HOSTS, isAzureDevOpsUrl's
+ * dev.azure.com/*.visualstudio.com/configured AZURE_DEVOPS_URL), and the
+ * emitted header is host-scoped (buildExtraHeaderKey). A URL outside the
+ * allowlists yields no credential. The selection is therefore server-policy,
+ * not a bypass the user can steer.
+ */
+function resolveGitCredential(options?: { token?: string; url?: string }): string | undefined {
+ const url = options?.url;
+ if (!url) return undefined;
+
+ let host: string;
+ try {
+ host = new URL(url).hostname.toLowerCase();
+ } catch {
+ return undefined;
+ }
+
+ // 1. Per-request GitHub PAT — github.com only (mirrors the /api/analyze
+ // host-bind so the user's token is never sent off github.com).
+ if (options.token && GITHUB_TOKEN_HOSTS.has(host)) {
+ return Buffer.from(`x-access-token:${options.token}`).toString('base64');
+ }
+
+ // 2. Server-configured Azure DevOps PAT — Azure hosts only.
+ const azurePat = process.env.AZURE_DEVOPS_PAT;
+ if (azurePat && isAzureDevOpsUrl(url)) {
+ return Buffer.from(`:${azurePat}`).toString('base64');
+ }
+
+ return undefined;
+}
+
+/**
+ * Build the host-scoped git config key `http..extraHeader` from
+ * the raw clone URL, so the Authorization header is attached only to the
+ * intended origin (and its clone sub-requests like /info/refs), never a
+ * redirect target. Derived from the SAME raw URL git clones from — not the
+ * normalize-for-compare form, which strips `.git` and would desync the key
+ * from the wire URL and silently disable the header. Userinfo/query/fragment
+ * are dropped (not part of git's URL match) and control characters stripped
+ * (git rejects a newline in a config key outright).
+ */
+function buildExtraHeaderKey(url: string): string | undefined {
+ let scoped: string;
+ try {
+ const u = new URL(url);
+ u.username = '';
+ u.password = '';
+ u.search = '';
+ u.hash = '';
+ scoped = `${u.protocol}//${u.host}${u.pathname}`;
+ } catch {
+ return undefined;
+ }
+ scoped = scoped.replace(/[\r\n\0]/g, '');
+ return `http.${scoped}.extraHeader`;
+}
+
+/**
+ * Warn (do not block) when a credential is about to be sent over cleartext
+ * http://. Base64 is encoding, not encryption, so an on-path observer can
+ * read the PAT. We keep http:// working for self-hosted Azure DevOps Server.
+ */
+function warnIfCleartextCredential(url?: string): void {
+ if (!url) return;
+ try {
+ const u = new URL(url);
+ if (u.protocol === 'http:') {
+ logger.warn(
+ `Sending a git credential over cleartext http:// (${u.host}) — base64 is not encryption. Prefer https:// where the host supports it.`,
+ );
+ }
+ } catch {
+ /* resolver already validated the URL */
+ }
+}
+
+/**
+ * Build the spawn env for `git`. Suppresses credential prompts and, when a
+ * credential resolves (see resolveGitCredential), injects a single
+ * host-scoped Authorization header via the `GIT_CONFIG_*` env protocol
+ * (git ≥2.31) so credentials never appear in argv or the URL. Appends after
+ * any existing `GIT_CONFIG_COUNT` rather than overwriting it. Exported for
+ * unit tests.
+ */
+export function buildGitEnv(
+ baseEnv: NodeJS.ProcessEnv,
+ options?: { token?: string; url?: 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',
+ // Scrub git's HTTP/transport trace vars: if inherited from the parent
+ // process they dump every request header — including the injected
+ // Authorization header — to stderr, which runGit captures and logs.
+ // `undefined` makes child_process omit the key from the child env.
+ GIT_TRACE: undefined,
+ GIT_TRACE_CURL: undefined,
+ GIT_TRACE_PACKET: undefined,
+ GIT_CURL_VERBOSE: undefined,
+ };
+
+ const credential = resolveGitCredential(options);
+ const key = options?.url ? buildExtraHeaderKey(options.url) : undefined;
+ if (credential && key) {
+ // Append after any GIT_CONFIG_* the operator already set, so we never
+ // clobber their git config (e.g. an enforced http.sslVerify).
+ const existing = Number.parseInt(env.GIT_CONFIG_COUNT ?? '', 10);
+ const base = Number.isInteger(existing) && existing > 0 ? existing : 0;
+ env.GIT_CONFIG_COUNT = String(base + 1);
+ env[`GIT_CONFIG_KEY_${base}`] = key;
+ env[`GIT_CONFIG_VALUE_${base}`] = `Authorization: Basic ${credential}`;
+ warnIfCleartextCredential(options?.url);
+ }
+
+ return env;
+}
+
+// `options` carries the inputs the credential resolver needs: a per-request
+// GitHub `token` and the clone `url`. buildGitEnv injects at most ONE
+// host-scoped Authorization header (GitHub PAT for github.com, else the
+// server's AZURE_DEVOPS_PAT for Azure hosts) via the GIT_CONFIG_* protocol —
+// never in argv. See resolveGitCredential / buildExtraHeaderKey.
+function runGit(
+ args: string[],
+ cwd?: string,
+ options?: { token?: string; url?: 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/integration/server-analyze-token-validation.test.ts b/gitnexus/test/integration/server-analyze-token-validation.test.ts
new file mode 100644
index 000000000..c126517b1
--- /dev/null
+++ b/gitnexus/test/integration/server-analyze-token-validation.test.ts
@@ -0,0 +1,197 @@
+/**
+ * End-to-end HTTP test of POST /api/analyze token validation.
+ *
+ * The unit tests (api-analyze-token.test.ts) cover validateAnalyzeToken in
+ * isolation; this proves the REAL production route actually wires it in —
+ * express.json body parsing, the requireLocalhostOrigin guard, the route
+ * handler invoking the validator, and the 400 status/error shape on the wire.
+ * Closes the gap the PR #2223 tri-review noted: "the route validation is
+ * otherwise only reachable by booting the server."
+ *
+ * Only rejection paths are asserted: each returns 400 BEFORE any clone, so the
+ * test is hermetic (no network, no background git, no real repo). The accepted
+ * path would spawn a background clone and is left to the unit coverage.
+ *
+ * Mirrors the spawn+health-poll harness in server-http-startup.test.ts; the
+ * integration suite always builds dist first (pretest:integration).
+ */
+import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
+import fs from 'node:fs';
+import http from 'node:http';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = path.resolve(__dirname, '..', '..');
+const DIST_CLI = path.join(REPO_ROOT, 'dist', 'cli', 'index.js');
+const STARTUP_BUDGET_MS = process.env.CI ? 30_000 : 15_000;
+
+const allocateFreePort = (): Promise =>
+ new Promise((resolve, reject) => {
+ const probe = http.createServer();
+ probe.once('error', reject);
+ probe.listen(0, '127.0.0.1', () => {
+ const addr = probe.address();
+ if (typeof addr !== 'object' || !addr) {
+ probe.close();
+ reject(new Error('could not allocate ephemeral port'));
+ return;
+ }
+ const port = addr.port;
+ probe.close((err) => (err ? reject(err) : resolve(port)));
+ });
+ });
+
+const httpJson = (
+ port: number,
+ method: string,
+ reqPath: string,
+ body?: unknown,
+): Promise<{ status: number; body: string }> =>
+ new Promise((resolve, reject) => {
+ const payload = body === undefined ? undefined : JSON.stringify(body);
+ const req = http.request(
+ {
+ host: '127.0.0.1',
+ port,
+ path: reqPath,
+ method,
+ headers: payload
+ ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) }
+ : {},
+ },
+ (res) => {
+ const chunks: Buffer[] = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () =>
+ resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
+ );
+ },
+ );
+ req.on('error', reject);
+ req.setTimeout(5_000, () => {
+ req.destroy();
+ reject(new Error(`${method} ${reqPath} timed out`));
+ });
+ if (payload) req.write(payload);
+ req.end();
+ });
+
+const postAnalyze = (port: number, body: unknown) => httpJson(port, 'POST', '/api/analyze', body);
+
+// Spawned `serve` on Windows can report ready before the socket is reachable
+// from the parent (see server-http-startup.test.ts); the validateAnalyzeToken
+// unit tests cover the validation logic on every platform.
+const describeBlock = process.platform === 'win32' ? describe.skip : describe;
+
+describeBlock('POST /api/analyze token validation (real server)', () => {
+ let proc: ChildProcessWithoutNullStreams | undefined;
+ let homeDir: string | undefined;
+ let port = 0;
+
+ beforeAll(async () => {
+ if (!fs.existsSync(DIST_CLI)) {
+ throw new Error(`Missing ${DIST_CLI} — run npm run build before integration tests`);
+ }
+
+ port = await allocateFreePort();
+ homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-analyze-token-'));
+
+ proc = spawn(
+ process.execPath,
+ [DIST_CLI, 'serve', '--port', String(port), '--host', '127.0.0.1'],
+ {
+ cwd: REPO_ROOT,
+ env: { ...process.env, GITNEXUS_HOME: homeDir, NODE_OPTIONS: '' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ },
+ );
+
+ let stderr = '';
+ proc.stderr.on('data', (buf) => {
+ stderr += buf.toString();
+ });
+
+ const startedAt = Date.now();
+ while (Date.now() - startedAt < STARTUP_BUDGET_MS) {
+ if (proc.exitCode !== null) {
+ throw new Error(`serve exited ${proc.exitCode} before ready.\nstderr:\n${stderr}`);
+ }
+ try {
+ const { status } = await httpJson(port, 'GET', '/api/health');
+ if (status === 200) return;
+ } catch {
+ // Server still starting — retry until budget expires.
+ }
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ throw new Error(
+ `serve did not become ready within ${STARTUP_BUDGET_MS}ms.\nstderr:\n${stderr}`,
+ );
+ }, 60_000);
+
+ afterAll(async () => {
+ if (proc && !proc.killed) {
+ proc.kill('SIGTERM');
+ await new Promise((resolve) => {
+ const timer = setTimeout(() => {
+ proc?.kill('SIGKILL');
+ resolve();
+ }, 3_000);
+ proc?.on('exit', () => {
+ clearTimeout(timer);
+ resolve();
+ });
+ });
+ }
+ proc = undefined;
+ if (homeDir) {
+ fs.rmSync(homeDir, { recursive: true, force: true });
+ homeDir = undefined;
+ }
+ });
+
+ it('rejects a GitHub token paired with a non-github host (finding 6)', async () => {
+ const { status, body } = await postAnalyze(port, {
+ url: 'https://gitlab.com/owner/repo',
+ token: 'ghp_validformat123',
+ });
+ expect(status).toBe(400);
+ expect(JSON.parse(body).error).toContain('only supported for github.com');
+ });
+
+ it('rejects a GitHub token paired with an Azure DevOps URL (cross-credential trigger)', async () => {
+ const { status, body } = await postAnalyze(port, {
+ url: 'https://dev.azure.com/org/proj/_git/repo',
+ token: 'ghp_validformat123',
+ });
+ expect(status).toBe(400);
+ expect(JSON.parse(body).error).toContain('only supported for github.com');
+ });
+
+ it('rejects a token whose characters could smuggle a header (CRLF/space)', async () => {
+ const { status, body } = await postAnalyze(port, {
+ url: 'https://github.com/owner/repo',
+ token: 'bad token',
+ });
+ expect(status).toBe(400);
+ expect(JSON.parse(body).error).toContain('invalid characters');
+ });
+
+ it('rejects a token with no url (routed via a path so it reaches token validation)', async () => {
+ const { status, body } = await postAnalyze(port, {
+ path: '/tmp/gitnexus-nonexistent-abs-path',
+ token: 'ghp_validformat123',
+ });
+ expect(status).toBe(400);
+ expect(JSON.parse(body).error).toContain('requires "url"');
+ });
+
+ it('still rejects a request with neither url nor path (route reachable, json parsed)', async () => {
+ const { status, body } = await postAnalyze(port, {});
+ expect(status).toBe(400);
+ expect(JSON.parse(body).error).toContain('Provide');
+ });
+});
diff --git a/gitnexus/test/unit/api-analyze-token.test.ts b/gitnexus/test/unit/api-analyze-token.test.ts
new file mode 100644
index 000000000..396906109
--- /dev/null
+++ b/gitnexus/test/unit/api-analyze-token.test.ts
@@ -0,0 +1,75 @@
+/**
+ * Unit tests for validateAnalyzeToken — the optional `token` validation on
+ * POST /api/analyze. Tested directly (not via a booted server) since the
+ * route validation is otherwise only reachable through createServer.
+ *
+ * Closes the test gap (finding 12) and locks the github.com host-bind
+ * (finding 6) and CRLF/charset guards from the PR #2223 tri-review.
+ */
+import { describe, it, expect } from 'vitest';
+import { validateAnalyzeToken } from '../../src/server/api.js';
+
+const GH = 'https://github.com/owner/repo';
+
+describe('validateAnalyzeToken', () => {
+ it('returns null when no token is provided', () => {
+ expect(validateAnalyzeToken(undefined, GH)).toBeNull();
+ expect(validateAnalyzeToken(undefined, undefined)).toBeNull();
+ });
+
+ it('accepts a well-formed token for a github.com URL', () => {
+ expect(validateAnalyzeToken('ghp_abc123', GH)).toBeNull();
+ expect(validateAnalyzeToken('ghp_abc123', 'https://www.github.com/o/r')).toBeNull();
+ });
+
+ it('rejects a non-string token', () => {
+ expect(validateAnalyzeToken(123 as unknown, GH)).toEqual({
+ status: 400,
+ error: '"token" must be a string',
+ });
+ });
+
+ it('rejects an empty or over-long token', () => {
+ expect(validateAnalyzeToken('', GH)?.error).toBe('"token" length must be between 1 and 256');
+ expect(validateAnalyzeToken('a'.repeat(257), GH)?.error).toBe(
+ '"token" length must be between 1 and 256',
+ );
+ });
+
+ it('rejects a token with characters that could smuggle a header (CRLF/space/colon)', () => {
+ for (const bad of ['abc def', 'abc\r\nHost: x', 'x-access-token:abc', 'abc