mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-05-31 13:56:13 -07:00
fix(orchestrator): use http.extraHeader for secure git authentication
Replace token-in-URL pattern with http.extraHeader for git clone and LFS operations. The token no longer appears in clone URLs, git remote config, or process command lines. Add gitAuthMode input (default: 'header', legacy: 'url') so users can fall back to the old behavior if needed. Closes #785 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -105,6 +105,12 @@ inputs:
|
||||
required: false
|
||||
default: ''
|
||||
description: '[Orchestrator] Github private token to pull from github'
|
||||
gitAuthMode:
|
||||
required: false
|
||||
default: 'header'
|
||||
description:
|
||||
'[Orchestrator] How git authentication is configured. "header" (default) uses http.extraHeader so the token
|
||||
never appears in clone URLs or git config. "url" embeds the token in clone URLs (legacy behavior).'
|
||||
githubOwner:
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
@@ -54,6 +54,7 @@ class BuildParameters {
|
||||
public sshAgent!: string;
|
||||
public sshPublicKeysDirectoryPath!: string;
|
||||
public providerStrategy!: string;
|
||||
public gitAuthMode!: string;
|
||||
public gitPrivateToken!: string;
|
||||
public awsStackName!: string;
|
||||
public awsEndpoint?: string;
|
||||
@@ -194,6 +195,7 @@ class BuildParameters {
|
||||
containerRegistryRepository: Input.containerRegistryRepository,
|
||||
containerRegistryImageVersion: Input.containerRegistryImageVersion,
|
||||
providerStrategy: OrchestratorOptions.providerStrategy,
|
||||
gitAuthMode: OrchestratorOptions.gitAuthMode,
|
||||
buildPlatform: OrchestratorOptions.buildPlatform,
|
||||
kubeConfig: OrchestratorOptions.kubeConfig,
|
||||
containerMemory: OrchestratorOptions.containerMemory,
|
||||
|
||||
144
src/model/orchestrator/options/orchestrator-folders-auth.test.ts
Normal file
144
src/model/orchestrator/options/orchestrator-folders-auth.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { OrchestratorFolders } from './orchestrator-folders';
|
||||
|
||||
jest.mock('../orchestrator', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
buildParameters: {
|
||||
orchestratorRepoName: 'game-ci/unity-builder',
|
||||
githubRepo: 'myorg/myrepo',
|
||||
gitPrivateToken: 'ghp_test123',
|
||||
gitAuthMode: 'header',
|
||||
buildGuid: 'test-guid',
|
||||
projectPath: '',
|
||||
buildPath: 'Builds',
|
||||
cacheKey: 'test-cache',
|
||||
},
|
||||
lockedWorkspace: '',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./orchestrator-options', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
useSharedBuilder: false,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../services/core/orchestrator-system', () => ({
|
||||
OrchestratorSystem: {
|
||||
Run: jest.fn().mockResolvedValue(''),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockOrchestrator = require('../orchestrator').default;
|
||||
|
||||
describe('OrchestratorFolders git auth', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useHeaderAuth', () => {
|
||||
it('should return true when gitAuthMode is header', () => {
|
||||
mockOrchestrator.buildParameters.gitAuthMode = 'header';
|
||||
expect(OrchestratorFolders.useHeaderAuth).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when gitAuthMode is undefined (default)', () => {
|
||||
mockOrchestrator.buildParameters.gitAuthMode = undefined;
|
||||
expect(OrchestratorFolders.useHeaderAuth).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when gitAuthMode is url', () => {
|
||||
mockOrchestrator.buildParameters.gitAuthMode = 'url';
|
||||
expect(OrchestratorFolders.useHeaderAuth).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unityBuilderRepoUrl', () => {
|
||||
it('should not include token in URL when using header auth', () => {
|
||||
mockOrchestrator.buildParameters.gitAuthMode = 'header';
|
||||
const url = OrchestratorFolders.unityBuilderRepoUrl;
|
||||
expect(url).toBe('https://github.com/game-ci/unity-builder.git');
|
||||
expect(url).not.toContain('ghp_test123');
|
||||
});
|
||||
|
||||
it('should include token in URL when using url auth (legacy)', () => {
|
||||
mockOrchestrator.buildParameters.gitAuthMode = 'url';
|
||||
const url = OrchestratorFolders.unityBuilderRepoUrl;
|
||||
expect(url).toBe('https://ghp_test123@github.com/game-ci/unity-builder.git');
|
||||
});
|
||||
});
|
||||
|
||||
describe('targetBuildRepoUrl', () => {
|
||||
it('should not include token in URL when using header auth', () => {
|
||||
mockOrchestrator.buildParameters.gitAuthMode = 'header';
|
||||
const url = OrchestratorFolders.targetBuildRepoUrl;
|
||||
expect(url).toBe('https://github.com/myorg/myrepo.git');
|
||||
expect(url).not.toContain('ghp_test123');
|
||||
});
|
||||
|
||||
it('should include token in URL when using url auth (legacy)', () => {
|
||||
mockOrchestrator.buildParameters.gitAuthMode = 'url';
|
||||
const url = OrchestratorFolders.targetBuildRepoUrl;
|
||||
expect(url).toBe('https://ghp_test123@github.com/myorg/myrepo.git');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gitAuthConfigScript', () => {
|
||||
it('should emit http.extraHeader commands in header mode', () => {
|
||||
mockOrchestrator.buildParameters.gitAuthMode = 'header';
|
||||
const script = OrchestratorFolders.gitAuthConfigScript;
|
||||
expect(script).toContain('http.extraHeader');
|
||||
expect(script).toContain('GIT_PRIVATE_TOKEN');
|
||||
expect(script).toContain('Authorization: Basic');
|
||||
});
|
||||
|
||||
it('should emit no-op comment in url mode', () => {
|
||||
mockOrchestrator.buildParameters.gitAuthMode = 'url';
|
||||
const script = OrchestratorFolders.gitAuthConfigScript;
|
||||
expect(script).toContain('legacy');
|
||||
expect(script).not.toContain('http.extraHeader');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureGitAuth', () => {
|
||||
it('should run git config with http.extraHeader in header mode', async () => {
|
||||
mockOrchestrator.buildParameters.gitAuthMode = 'header';
|
||||
mockOrchestrator.buildParameters.gitPrivateToken = 'ghp_test123';
|
||||
const { OrchestratorSystem } = require('../services/core/orchestrator-system');
|
||||
|
||||
await OrchestratorFolders.configureGitAuth();
|
||||
|
||||
// Verify the base64 encoding and extraHeader config are correct
|
||||
const expectedEncoded = Buffer.from('x-access-token:ghp_test123').toString('base64');
|
||||
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
|
||||
expect.stringContaining(expectedEncoded),
|
||||
);
|
||||
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.extraHeader'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not run git config in url mode', async () => {
|
||||
mockOrchestrator.buildParameters.gitAuthMode = 'url';
|
||||
const { OrchestratorSystem } = require('../services/core/orchestrator-system');
|
||||
|
||||
await OrchestratorFolders.configureGitAuth();
|
||||
|
||||
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not run git config when no token is available', async () => {
|
||||
mockOrchestrator.buildParameters.gitAuthMode = 'header';
|
||||
mockOrchestrator.buildParameters.gitPrivateToken = '';
|
||||
const originalEnv = process.env.GIT_PRIVATE_TOKEN;
|
||||
delete process.env.GIT_PRIVATE_TOKEN;
|
||||
const { OrchestratorSystem } = require('../services/core/orchestrator-system');
|
||||
|
||||
await OrchestratorFolders.configureGitAuth();
|
||||
|
||||
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
|
||||
if (originalEnv !== undefined) process.env.GIT_PRIVATE_TOKEN = originalEnv;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,14 +72,67 @@ export class OrchestratorFolders {
|
||||
return path.join(OrchestratorFolders.cacheFolderForCacheKeyFull, `Library`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to use http.extraHeader for git authentication (secure, default)
|
||||
* instead of embedding the token in clone URLs (legacy).
|
||||
*/
|
||||
public static get useHeaderAuth(): boolean {
|
||||
return Orchestrator.buildParameters.gitAuthMode !== 'url';
|
||||
}
|
||||
|
||||
public static get unityBuilderRepoUrl(): string {
|
||||
if (OrchestratorFolders.useHeaderAuth) {
|
||||
return `https://github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`;
|
||||
}
|
||||
|
||||
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`;
|
||||
}
|
||||
|
||||
public static get targetBuildRepoUrl(): string {
|
||||
if (OrchestratorFolders.useHeaderAuth) {
|
||||
return `https://github.com/${Orchestrator.buildParameters.githubRepo}.git`;
|
||||
}
|
||||
|
||||
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.githubRepo}.git`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell commands to configure git authentication via http.extraHeader.
|
||||
* Uses GIT_PRIVATE_TOKEN env var so the token never appears in clone URLs or git config output.
|
||||
* This is the same mechanism used by actions/checkout.
|
||||
*
|
||||
* Only emits commands when gitAuthMode is 'header' (default). In 'url' mode,
|
||||
* returns a no-op comment since the token is already in the URL.
|
||||
*/
|
||||
public static get gitAuthConfigScript(): string {
|
||||
if (!OrchestratorFolders.useHeaderAuth) {
|
||||
return `# git auth: using token-in-URL mode (legacy)`;
|
||||
}
|
||||
|
||||
return `# git auth: configuring http.extraHeader (secure mode)
|
||||
if [ -n "$GIT_PRIVATE_TOKEN" ]; then
|
||||
git config --global http.https://github.com/.extraHeader "Authorization: Basic $(printf '%s' "x-access-token:$GIT_PRIVATE_TOKEN" | base64 -w 0)"
|
||||
fi`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure git authentication via http.extraHeader in the current Node process.
|
||||
* For use in the remote-client where shell scripts aren't used.
|
||||
* Only configures when gitAuthMode is 'header' (default).
|
||||
*/
|
||||
public static async configureGitAuth(): Promise<void> {
|
||||
if (!OrchestratorFolders.useHeaderAuth) return;
|
||||
|
||||
const token = Orchestrator.buildParameters.gitPrivateToken || process.env.GIT_PRIVATE_TOKEN || '';
|
||||
if (!token) return;
|
||||
|
||||
const encoded = Buffer.from(`x-access-token:${token}`).toString('base64');
|
||||
const { OrchestratorSystem } = await import('../services/core/orchestrator-system');
|
||||
await OrchestratorSystem.Run(
|
||||
`git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`,
|
||||
);
|
||||
}
|
||||
|
||||
public static get buildVolumeFolder() {
|
||||
return 'data';
|
||||
}
|
||||
|
||||
@@ -138,6 +138,10 @@ class OrchestratorOptions {
|
||||
return provider || 'local';
|
||||
}
|
||||
|
||||
static get gitAuthMode(): string {
|
||||
return OrchestratorOptions.getInput('gitAuthMode') || 'header';
|
||||
}
|
||||
|
||||
static get containerCpu(): string {
|
||||
return OrchestratorOptions.getInput('containerCpu') || `1024`;
|
||||
}
|
||||
|
||||
@@ -302,6 +302,7 @@ export class RemoteClient {
|
||||
|
||||
RemoteClientLogger.log(`Initializing source repository for cloning with caching of LFS files`);
|
||||
await OrchestratorSystem.Run(`git config --global advice.detachedHead false`);
|
||||
await OrchestratorFolders.configureGitAuth();
|
||||
RemoteClientLogger.log(`Cloning the repository being built:`);
|
||||
await OrchestratorSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"`);
|
||||
await OrchestratorSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`);
|
||||
@@ -411,12 +412,7 @@ export class RemoteClient {
|
||||
const gitPrivateToken = process.env.GIT_PRIVATE_TOKEN;
|
||||
if (gitPrivateToken) {
|
||||
RemoteClientLogger.log(`Attempting to pull LFS files with GIT_PRIVATE_TOKEN...`);
|
||||
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
|
||||
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
|
||||
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
|
||||
await OrchestratorSystem.Run(
|
||||
`git config --global url."https://${gitPrivateToken}@github.com/".insteadOf "https://github.com/"`,
|
||||
);
|
||||
await RemoteClient.configureTokenAuth(gitPrivateToken);
|
||||
await OrchestratorSystem.Run(`git lfs pull`, true);
|
||||
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
||||
RemoteClientLogger.log(`Successfully pulled LFS files with GIT_PRIVATE_TOKEN`);
|
||||
@@ -432,12 +428,7 @@ export class RemoteClient {
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
if (githubToken) {
|
||||
RemoteClientLogger.log(`Attempting to pull LFS files with GITHUB_TOKEN fallback...`);
|
||||
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
|
||||
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
|
||||
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
|
||||
await OrchestratorSystem.Run(
|
||||
`git config --global url."https://${githubToken}@github.com/".insteadOf "https://github.com/"`,
|
||||
);
|
||||
await RemoteClient.configureTokenAuth(githubToken);
|
||||
await OrchestratorSystem.Run(`git lfs pull`, true);
|
||||
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
||||
RemoteClientLogger.log(`Successfully pulled LFS files with GITHUB_TOKEN`);
|
||||
@@ -501,4 +492,25 @@ export class RemoteClient {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure git authentication for a token. In header mode (default), uses
|
||||
* http.extraHeader so the token never appears in URLs or git config output.
|
||||
* In url mode (legacy), uses url.insteadOf to embed the token in URLs.
|
||||
*/
|
||||
private static async configureTokenAuth(token: string): Promise<void> {
|
||||
if (OrchestratorFolders.useHeaderAuth) {
|
||||
const encoded = Buffer.from(`x-access-token:${token}`).toString('base64');
|
||||
await OrchestratorSystem.Run(
|
||||
`git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`,
|
||||
);
|
||||
} else {
|
||||
await OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
|
||||
await OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
|
||||
await OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
|
||||
await OrchestratorSystem.Run(
|
||||
`git config --global url."https://${token}@github.com/".insteadOf "https://github.com/"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ printenv
|
||||
git config --global advice.detachedHead false
|
||||
git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"
|
||||
git config --global filter.lfs.process "git-lfs filter-process --skip"
|
||||
${OrchestratorFolders.gitAuthConfigScript}
|
||||
BRANCH="${Orchestrator.buildParameters.orchestratorBranch}"
|
||||
REPO="${OrchestratorFolders.unityBuilderRepoUrl}"
|
||||
if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
|
||||
|
||||
@@ -92,6 +92,7 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
|
||||
const commands = `mkdir -p ${OrchestratorFolders.ToLinuxFolder(
|
||||
OrchestratorFolders.builderPathAbsolute,
|
||||
)}
|
||||
${OrchestratorFolders.gitAuthConfigScript}
|
||||
BRANCH="${Orchestrator.buildParameters.orchestratorBranch}"
|
||||
REPO="${OrchestratorFolders.unityBuilderRepoUrl}"
|
||||
DEST="${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.builderPathAbsolute)}"
|
||||
|
||||
Reference in New Issue
Block a user