feat(analyze): private GitHub repos via PAT + Azure DevOps Server support (#2076, #2210) (#2223)

This commit is contained in:
Gergő Magyar 2026-06-16 05:49:02 +01:00 committed by GitHub
parent 3c82361b66
commit 7d12ea8fd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1062 additions and 32 deletions

View file

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

View file

@ -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''',
]

View file

@ -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 */}
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-accent/30 bg-gradient-to-br from-accent/20 to-accent-dim/10 shadow-glow-soft">
<Github className="h-7 w-7 text-accent" />
<GitBranch className="h-7 w-7 text-accent" />
</div>
<h2 className="text-lg leading-snug font-semibold text-text-primary">

View file

@ -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
<Gitlab className="h-3 w-3" />
{t('repoAnalyzer.gitlabUrl')}
</button>
<button
role="tab"
aria-selected={mode === 'azure'}
onClick={() => 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'
} `}
>
<AzureDevops className="h-3 w-3" />
{t('repoAnalyzer.azureDevOpsUrl')}
</button>
<button
role="tab"
aria-selected={mode === 'local'}
@ -173,7 +196,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
null,
);
const [githubUrl, setGithubUrl] = useState('');
const [githubToken, setGithubToken] = useState('');
const [gitlabUrl, setGitlabUrl] = useState('');
const [azureUrl, setAzureUrl] = useState('');
const [localPath, setLocalPath] = useState('');
const [phase, setPhase] = useState<InternalPhase>('input');
const [validationError, setValidationError] = useState<string | null>(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
</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>
)}
@ -516,6 +591,61 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
</div>
)}
{/* Azure DevOps URL input */}
{showInput && mode === 'azure' && (
<div className="space-y-2">
<label
htmlFor={inputId}
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
>
{t('onboarding:repoAnalyzer.azureDevOpsRepositoryUrl')}
</label>
<div
className={`flex items-center gap-3 rounded-xl border bg-void px-4 py-3.5 transition-all duration-200 ${
validationError && phase === 'error'
? 'border-red-500/50'
: isValidAzureUrl(azureUrl)
? 'border-accent/50 shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
: 'border-border-default focus-within:border-accent/40'
} `}
>
<AzureDevops className="h-4 w-4 shrink-0 text-text-muted" />
<input
id={inputId}
type="url"
value={azureUrl}
onChange={(e) => {
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 && (
<div className="shrink-0">
{isValidAzureUrl(azureUrl) ? (
<Check className="h-3.5 w-3.5 text-emerald-400" />
) : (
<AlertCircle className="h-3.5 w-3.5 text-text-muted" />
)}
</div>
)}
</div>
<p className="text-xs text-text-muted">
{t('onboarding:repoAnalyzer.azureDevOpsSupported')}
</p>
</div>
)}
{/* Local folder input */}
{showInput && mode === 'local' && (
<div className="space-y-2">

View file

@ -185,6 +185,41 @@ export const Gitlab = forwardRef<SVGSVGElement, LucideProps>(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<SVGSVGElement, LucideProps>(function AzureDevops(
{
size = 24,
color = 'currentColor',
className,
strokeWidth: _strokeWidth,
absoluteStrokeWidth: _absoluteStrokeWidth,
...rest
},
ref,
) {
return (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill={color}
className={className}
{...rest}
>
<path d="M0 8.877L2.247 5.91l8.405-3.416V.022l7.37 5.393L2.966 8.338v8.225L0 15.707zm24-4.45v14.651l-5.753 4.9-9.303-3.057v3.056l-5.978-7.416 15.057 1.798V5.415z" />
</svg>
);
});
export const Github = forwardRef<SVGSVGElement, LucideProps>(function Github(
{
size = 24,

View file

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

View file

@ -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": {

View file

@ -6,6 +6,7 @@
"analysisFailed": "分析失败,请检查服务器日志。",
"startAnalysisFailed": "启动分析失败",
"invalidGithubUrl": "请输入有效的 GitHub 仓库 URL。",
"invalidAzureDevOpsUrl": "请输入有效的 Azure DevOps 仓库 URL。",
"missingFolderPath": "请输入文件夹路径。",
"backend": {
"reconnecting": "服务器连接已断开,正在重连…",

View file

@ -31,7 +31,7 @@
},
"analyzeFirst": {
"title": "分析你的第一个仓库",
"description": "粘贴 GitHub URLGitNexus 会克隆仓库、解析代码,并直接在浏览器中构建实时知识图谱。",
"description": "粘贴仓库 URLGitNexus 会克隆仓库、解析代码,并直接在浏览器中构建实时知识图谱。",
"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": {

View file

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

View file

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

View file

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

View file

@ -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<string> {
// 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<void> {
/**
* 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<string> = 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.<origin+path>.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<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

@ -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<number> =>
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<void>((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');
});
});

View file

@ -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<script>']) {
expect(validateAnalyzeToken(bad, GH)?.error).toBe('"token" contains invalid characters');
}
});
it('rejects a token without a url', () => {
expect(validateAnalyzeToken('ghp_abc123', undefined)?.error).toBe('"token" requires "url"');
expect(validateAnalyzeToken('ghp_abc123', '')?.error).toBe('"token" requires "url"');
});
it('rejects a token for a non-github host (host-bind)', () => {
for (const url of [
'https://gitlab.com/o/r',
'https://dev.azure.com/o/p/_git/r',
'https://github.com.evil.com/o/r',
'https://api.github.com/o/r',
]) {
expect(validateAnalyzeToken('ghp_abc123', url)?.error).toBe(
'"token" is only supported for github.com URLs',
);
}
});
it('treats github.com@evil.com as the evil host (userinfo, not host)', () => {
// new URL(...).hostname is evil.com here — must be rejected.
expect(validateAnalyzeToken('ghp_abc123', 'https://github.com@evil.com/o/r')?.error).toBe(
'"token" is only supported for github.com URLs',
);
});
it('rejects a token when the url is unparseable', () => {
expect(validateAnalyzeToken('ghp_abc123', 'not-a-url')?.error).toBe(
'"url" must be a valid URL when "token" is provided',
);
});
});

View file

@ -1,12 +1,30 @@
import { afterAll, beforeAll, describe, it, expect } from 'vitest';
import { afterAll, beforeAll, describe, it, expect, vi } from 'vitest';
// The logger is a Proxy with no `set` trap, so vi.spyOn can't patch it.
// Mock the module and expose `warn` as a countable spy (other levels no-op).
const warnSpy = vi.fn();
vi.mock('../../src/core/logger.js', () => ({
logger: {
debug: () => {},
info: () => {},
warn: (...args: unknown[]) => warnSpy(...args),
error: () => {},
trace: () => {},
fatal: () => {},
},
}));
import {
extractRepoName,
getCloneDir,
validateGitUrl,
cloneOrPull,
buildCloneArgs,
buildGitEnv,
normalizeGitUrlForCompare,
assertRemoteMatchesRequestedUrl,
isAzureDevOpsUrl,
warnIfInsecureAzureConfig,
} from '../../src/server/git-clone.js';
import path from 'node:path';
import os from 'node:os';
@ -311,6 +329,142 @@ 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: credentials travel through env, not URL', () => {
// buildCloneArgs is URL-only; the credential 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');
// No credential material in argv — assert on the credential markers
// directly rather than substring-matching the host (which CodeQL flags
// as incomplete URL sanitization, js/incomplete-url-substring).
expect(args.some((a) => a.includes('ghp_'))).toBe(false);
expect(args.some((a) => a.toLowerCase().includes('authorization'))).toBe(false);
expect(args.some((a) => a.includes('extraHeader'))).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('scrubs inherited git trace vars that would log the Authorization header', () => {
const env = buildGitEnv({
GIT_TRACE: '1',
GIT_TRACE_CURL: '1',
GIT_TRACE_PACKET: '1',
GIT_CURL_VERBOSE: '1',
});
expect(env.GIT_TRACE).toBeUndefined();
expect(env.GIT_TRACE_CURL).toBeUndefined();
expect(env.GIT_TRACE_PACKET).toBeUndefined();
expect(env.GIT_CURL_VERBOSE).toBeUndefined();
});
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('injects a host-scoped Basic-auth header when a github.com token is provided', () => {
const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://github.com/owner/repo' });
expect(env.GIT_CONFIG_COUNT).toBe('1');
// Host-scoped key: the header attaches only to this origin's requests.
expect(env.GIT_CONFIG_KEY_0).toBe('http.https://github.com/owner/repo.extraHeader');
const expected =
'Authorization: Basic ' + Buffer.from('x-access-token:ghp_secret123').toString('base64');
expect(env.GIT_CONFIG_VALUE_0).toBe(expected);
});
it('does not inject a token for a non-github host (defense-in-depth host bind)', () => {
const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://gitlab.com/owner/repo' });
expect(env.GIT_CONFIG_COUNT).toBeUndefined();
});
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, url: 'https://github.com/o/r' });
for (const [key, value] of Object.entries(env)) {
if (key === 'GIT_CONFIG_VALUE_0') continue;
expect(String(value)).not.toContain(token);
}
});
it('injects the server Azure PAT (host-scoped) for an Azure URL', () => {
const prev = process.env.AZURE_DEVOPS_PAT;
process.env.AZURE_DEVOPS_PAT = 'azure-pat-xyz';
try {
const env = buildGitEnv({}, { url: 'https://dev.azure.com/org/proj/_git/repo' });
expect(env.GIT_CONFIG_COUNT).toBe('1');
expect(env.GIT_CONFIG_KEY_0).toBe(
'http.https://dev.azure.com/org/proj/_git/repo.extraHeader',
);
const expected = 'Authorization: Basic ' + Buffer.from(':azure-pat-xyz').toString('base64');
expect(env.GIT_CONFIG_VALUE_0).toBe(expected);
} finally {
if (prev === undefined) delete process.env.AZURE_DEVOPS_PAT;
else process.env.AZURE_DEVOPS_PAT = prev;
}
});
it('emits EXACTLY ONE header when a github token and an Azure PAT could both apply', () => {
// A token only injects for github.com (where isAzureDevOpsUrl is false),
// so the two never collide — guard the resolver directly anyway.
const prev = process.env.AZURE_DEVOPS_PAT;
process.env.AZURE_DEVOPS_PAT = 'azure-pat-xyz';
try {
const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://github.com/o/r' });
expect(env.GIT_CONFIG_COUNT).toBe('1');
expect(env.GIT_CONFIG_VALUE_1).toBeUndefined();
for (const value of Object.values(env)) {
expect(String(value)).not.toContain('azure-pat-xyz');
}
} finally {
if (prev === undefined) delete process.env.AZURE_DEVOPS_PAT;
else process.env.AZURE_DEVOPS_PAT = prev;
}
});
it('appends after an existing GIT_CONFIG_COUNT instead of overwriting it', () => {
const env = buildGitEnv(
{ GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.sslVerify', GIT_CONFIG_VALUE_0: 'true' },
{ token: 'ghp_secret123', url: 'https://github.com/o/r' },
);
expect(env.GIT_CONFIG_COUNT).toBe('2');
// Operator's pre-existing config is preserved at index 0.
expect(env.GIT_CONFIG_KEY_0).toBe('http.sslVerify');
expect(env.GIT_CONFIG_VALUE_0).toBe('true');
// Our credential is appended at index 1.
expect(env.GIT_CONFIG_KEY_1).toBe('http.https://github.com/o/r.extraHeader');
expect(env.GIT_CONFIG_VALUE_1).toContain('Authorization: Basic ');
});
it('strips control characters from the config key (no key injection)', () => {
const env = buildGitEnv(
{},
{ token: 'ghp_secret123', url: 'https://github.com/o/r%0Anewline' },
);
const key = env.GIT_CONFIG_KEY_0 ?? '';
expect(key).not.toContain('\n');
expect(key).not.toContain('\r');
});
});
describe('cloneOrPull — containment barrier', () => {
@ -374,6 +528,128 @@ describe('git-clone', () => {
});
});
describe('isAzureDevOpsUrl', () => {
it('recognizes dev.azure.com (cloud)', () => {
expect(isAzureDevOpsUrl('https://dev.azure.com/org/project/_git/repo')).toBe(true);
});
it('recognizes *.visualstudio.com (cloud legacy)', () => {
expect(isAzureDevOpsUrl('https://myorg.visualstudio.com/project/_git/repo')).toBe(true);
});
it('returns false for github.com', () => {
expect(isAzureDevOpsUrl('https://github.com/user/repo')).toBe(false);
});
it('returns false for gitlab.com', () => {
expect(isAzureDevOpsUrl('https://gitlab.com/user/repo')).toBe(false);
});
it('returns false for invalid URL', () => {
expect(isAzureDevOpsUrl('not-a-url')).toBe(false);
});
it('normalizes a trailing-dot FQDN (dev.azure.com.)', () => {
expect(isAzureDevOpsUrl('https://dev.azure.com./org/proj/_git/repo')).toBe(true);
expect(isAzureDevOpsUrl('https://myorg.visualstudio.com./project/_git/repo')).toBe(true);
});
it('does not over-match a lookalike host with a trailing label', () => {
expect(isAzureDevOpsUrl('https://dev.azure.com.evil.com/org/proj/_git/repo')).toBe(false);
expect(isAzureDevOpsUrl('https://evilvisualstudio.com/project/_git/repo')).toBe(false);
});
it('recognizes a self-hosted host configured via AZURE_DEVOPS_URL', () => {
const prev = process.env.AZURE_DEVOPS_URL;
try {
process.env.AZURE_DEVOPS_URL = 'http://tfs.corp.example';
expect(isAzureDevOpsUrl('http://tfs.corp.example/Coll/Proj/_git/Repo')).toBe(true);
expect(isAzureDevOpsUrl('https://other.host.example/Coll/Proj/_git/Repo')).toBe(false);
} finally {
if (prev === undefined) delete process.env.AZURE_DEVOPS_URL;
else process.env.AZURE_DEVOPS_URL = prev;
}
});
it('falls through to the cloud check when AZURE_DEVOPS_URL is invalid', () => {
const prev = process.env.AZURE_DEVOPS_URL;
try {
process.env.AZURE_DEVOPS_URL = 'not-a-url';
expect(isAzureDevOpsUrl('https://dev.azure.com/org/proj/_git/repo')).toBe(true);
} finally {
if (prev === undefined) delete process.env.AZURE_DEVOPS_URL;
else process.env.AZURE_DEVOPS_URL = prev;
}
});
});
describe('cleartext-credential warnings', () => {
it('warns when injecting a credential over cleartext http://', () => {
warnSpy.mockClear();
buildGitEnv({}, { token: 'ghp_x', url: 'http://github.com/o/r' });
expect(warnSpy).toHaveBeenCalledTimes(1);
});
it('does not warn when injecting over https://', () => {
warnSpy.mockClear();
buildGitEnv({}, { token: 'ghp_x', url: 'https://github.com/o/r' });
expect(warnSpy).not.toHaveBeenCalled();
});
it('does not warn over http:// when no credential is injected', () => {
warnSpy.mockClear();
// gitlab host is not in the token allowlist and no Azure PAT is set,
// so nothing is injected — and nothing is warned about.
buildGitEnv({}, { token: 'ghp_x', url: 'http://gitlab.com/o/r' });
expect(warnSpy).not.toHaveBeenCalled();
});
it('warnIfInsecureAzureConfig warns for http AZURE_DEVOPS_URL, not https', () => {
const prev = process.env.AZURE_DEVOPS_URL;
warnSpy.mockClear();
try {
process.env.AZURE_DEVOPS_URL = 'http://tfs.corp.example';
warnIfInsecureAzureConfig();
expect(warnSpy).toHaveBeenCalledTimes(1);
warnSpy.mockClear();
process.env.AZURE_DEVOPS_URL = 'https://tfs.corp.example';
warnIfInsecureAzureConfig();
expect(warnSpy).not.toHaveBeenCalled();
} finally {
if (prev === undefined) delete process.env.AZURE_DEVOPS_URL;
else process.env.AZURE_DEVOPS_URL = prev;
}
});
});
describe('extractRepoName — Azure DevOps URLs', () => {
it('extracts name from self-hosted Azure DevOps URL', () => {
expect(
extractRepoName('http://azuredevops.example.com/DefaultCollection/MyProject/_git/MyRepo'),
).toBe('MyRepo');
});
it('extracts name from dev.azure.com URL', () => {
expect(extractRepoName('https://dev.azure.com/org/project/_git/myrepo')).toBe('myrepo');
});
it('extracts name from visualstudio.com URL', () => {
expect(extractRepoName('https://myorg.visualstudio.com/project/_git/myrepo')).toBe('myrepo');
});
});
describe('validateGitUrl — Azure DevOps URLs', () => {
it('allows self-hosted Azure DevOps Server URLs', () => {
expect(() =>
validateGitUrl('http://azuredevops.example.com/DefaultCollection/Project/_git/Repo'),
).not.toThrow();
});
it('allows dev.azure.com URLs', () => {
expect(() => validateGitUrl('https://dev.azure.com/org/project/_git/repo')).not.toThrow();
});
});
describe('normalizeGitUrlForCompare', () => {
it('strips trailing .git', () => {
expect(normalizeGitUrlForCompare('https://github.com/owner/repo.git')).toBe(