mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-02 14:56:16 -07:00
Compare commits
4 Commits
feature/pr
...
fix/secure
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f80e4f66d4 | ||
|
|
b2327008ed | ||
|
|
b3bd405399 | ||
|
|
8a41533779 |
15
action.yml
15
action.yml
@@ -105,6 +105,12 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
description: '[Orchestrator] Github private token to pull from github'
|
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:
|
githubOwner:
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
@@ -194,15 +200,6 @@ inputs:
|
|||||||
description:
|
description:
|
||||||
'[Orchestrator] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must
|
'[Orchestrator] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must
|
||||||
be configured.'
|
be configured.'
|
||||||
secretSource:
|
|
||||||
default: ''
|
|
||||||
required: false
|
|
||||||
description:
|
|
||||||
'[Orchestrator] Premade secret source for pulling build secrets. Supported values: aws-secrets-manager,
|
|
||||||
aws-parameter-store, gcp-secret-manager, azure-key-vault, hashicorp-vault, hashicorp-vault-kv1,
|
|
||||||
vault (alias for hashicorp-vault), env. Can also be a custom shell command with {0} placeholder
|
|
||||||
for the key, or a path to a YAML file defining custom sources. Takes precedence over
|
|
||||||
inputPullCommand when set.'
|
|
||||||
resourceTracking:
|
resourceTracking:
|
||||||
default: 'false'
|
default: 'false'
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
499
dist/index.js
generated
vendored
499
dist/index.js
generated
vendored
@@ -327,6 +327,7 @@ class BuildParameters {
|
|||||||
containerRegistryRepository: input_1.default.containerRegistryRepository,
|
containerRegistryRepository: input_1.default.containerRegistryRepository,
|
||||||
containerRegistryImageVersion: input_1.default.containerRegistryImageVersion,
|
containerRegistryImageVersion: input_1.default.containerRegistryImageVersion,
|
||||||
providerStrategy: orchestrator_options_1.default.providerStrategy,
|
providerStrategy: orchestrator_options_1.default.providerStrategy,
|
||||||
|
gitAuthMode: orchestrator_options_1.default.gitAuthMode,
|
||||||
buildPlatform: orchestrator_options_1.default.buildPlatform,
|
buildPlatform: orchestrator_options_1.default.buildPlatform,
|
||||||
kubeConfig: orchestrator_options_1.default.kubeConfig,
|
kubeConfig: orchestrator_options_1.default.kubeConfig,
|
||||||
containerMemory: orchestrator_options_1.default.containerMemory,
|
containerMemory: orchestrator_options_1.default.containerMemory,
|
||||||
@@ -1944,6 +1945,29 @@ exports["default"] = OrchestratorEnvironmentVariable;
|
|||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
};
|
};
|
||||||
@@ -1998,12 +2022,57 @@ class OrchestratorFolders {
|
|||||||
static get libraryCacheFolderFull() {
|
static get libraryCacheFolderFull() {
|
||||||
return node_path_1.default.join(OrchestratorFolders.cacheFolderForCacheKeyFull, `Library`);
|
return node_path_1.default.join(OrchestratorFolders.cacheFolderForCacheKeyFull, `Library`);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Whether to use http.extraHeader for git authentication (secure, default)
|
||||||
|
* instead of embedding the token in clone URLs (legacy).
|
||||||
|
*/
|
||||||
|
static get useHeaderAuth() {
|
||||||
|
return orchestrator_1.default.buildParameters.gitAuthMode !== 'url';
|
||||||
|
}
|
||||||
static get unityBuilderRepoUrl() {
|
static get unityBuilderRepoUrl() {
|
||||||
|
if (OrchestratorFolders.useHeaderAuth) {
|
||||||
|
return `https://github.com/${orchestrator_1.default.buildParameters.orchestratorRepoName}.git`;
|
||||||
|
}
|
||||||
return `https://${orchestrator_1.default.buildParameters.gitPrivateToken}@github.com/${orchestrator_1.default.buildParameters.orchestratorRepoName}.git`;
|
return `https://${orchestrator_1.default.buildParameters.gitPrivateToken}@github.com/${orchestrator_1.default.buildParameters.orchestratorRepoName}.git`;
|
||||||
}
|
}
|
||||||
static get targetBuildRepoUrl() {
|
static get targetBuildRepoUrl() {
|
||||||
|
if (OrchestratorFolders.useHeaderAuth) {
|
||||||
|
return `https://github.com/${orchestrator_1.default.buildParameters.githubRepo}.git`;
|
||||||
|
}
|
||||||
return `https://${orchestrator_1.default.buildParameters.gitPrivateToken}@github.com/${orchestrator_1.default.buildParameters.githubRepo}.git`;
|
return `https://${orchestrator_1.default.buildParameters.gitPrivateToken}@github.com/${orchestrator_1.default.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.
|
||||||
|
*/
|
||||||
|
static get gitAuthConfigScript() {
|
||||||
|
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).
|
||||||
|
*/
|
||||||
|
static async configureGitAuth() {
|
||||||
|
if (!OrchestratorFolders.useHeaderAuth)
|
||||||
|
return;
|
||||||
|
const token = orchestrator_1.default.buildParameters.gitPrivateToken || process.env.GIT_PRIVATE_TOKEN || '';
|
||||||
|
if (!token)
|
||||||
|
return;
|
||||||
|
const encoded = Buffer.from(`x-access-token:${token}`).toString('base64');
|
||||||
|
const { OrchestratorSystem } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(9744)));
|
||||||
|
await OrchestratorSystem.Run(`git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`);
|
||||||
|
}
|
||||||
static get buildVolumeFolder() {
|
static get buildVolumeFolder() {
|
||||||
return 'data';
|
return 'data';
|
||||||
}
|
}
|
||||||
@@ -2204,6 +2273,9 @@ class OrchestratorOptions {
|
|||||||
}
|
}
|
||||||
return provider || 'local';
|
return provider || 'local';
|
||||||
}
|
}
|
||||||
|
static get gitAuthMode() {
|
||||||
|
return OrchestratorOptions.getInput('gitAuthMode') || 'header';
|
||||||
|
}
|
||||||
static get containerCpu() {
|
static get containerCpu() {
|
||||||
return OrchestratorOptions.getInput('containerCpu') || `1024`;
|
return OrchestratorOptions.getInput('containerCpu') || `1024`;
|
||||||
}
|
}
|
||||||
@@ -2243,9 +2315,6 @@ class OrchestratorOptions {
|
|||||||
static get pullInputList() {
|
static get pullInputList() {
|
||||||
return OrchestratorOptions.getInput('pullInputList')?.split(`,`) || [];
|
return OrchestratorOptions.getInput('pullInputList')?.split(`,`) || [];
|
||||||
}
|
}
|
||||||
static get secretSource() {
|
|
||||||
return OrchestratorOptions.getInput('secretSource') || '';
|
|
||||||
}
|
|
||||||
static get inputPullCommand() {
|
static get inputPullCommand() {
|
||||||
const value = OrchestratorOptions.getInput('inputPullCommand');
|
const value = OrchestratorOptions.getInput('inputPullCommand');
|
||||||
if (value === 'gcp-secret-manager') {
|
if (value === 'gcp-secret-manager') {
|
||||||
@@ -2367,39 +2436,13 @@ exports["default"] = OrchestratorOptions;
|
|||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
||||||
if (k2 === undefined) k2 = k;
|
|
||||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
||||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
||||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
||||||
}
|
|
||||||
Object.defineProperty(o, k2, desc);
|
|
||||||
}) : (function(o, m, k, k2) {
|
|
||||||
if (k2 === undefined) k2 = k;
|
|
||||||
o[k2] = m[k];
|
|
||||||
}));
|
|
||||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
||||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
||||||
}) : function(o, v) {
|
|
||||||
o["default"] = v;
|
|
||||||
});
|
|
||||||
var __importStar = (this && this.__importStar) || function (mod) {
|
|
||||||
if (mod && mod.__esModule) return mod;
|
|
||||||
var result = {};
|
|
||||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
||||||
__setModuleDefault(result, mod);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
||||||
const core = __importStar(__nccwpck_require__(42186));
|
|
||||||
const input_1 = __importDefault(__nccwpck_require__(91933));
|
const input_1 = __importDefault(__nccwpck_require__(91933));
|
||||||
const generic_input_reader_1 = __nccwpck_require__(2263);
|
const generic_input_reader_1 = __nccwpck_require__(2263);
|
||||||
const orchestrator_options_1 = __importDefault(__nccwpck_require__(82473));
|
const orchestrator_options_1 = __importDefault(__nccwpck_require__(82473));
|
||||||
const secret_source_service_1 = __nccwpck_require__(79089);
|
|
||||||
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
|
|
||||||
const formatFunction = (value, arguments_) => {
|
const formatFunction = (value, arguments_) => {
|
||||||
for (const element of arguments_) {
|
for (const element of arguments_) {
|
||||||
value = value.replace(`{${element.key}}`, element.value);
|
value = value.replace(`{${element.key}}`, element.value);
|
||||||
@@ -2407,6 +2450,7 @@ const formatFunction = (value, arguments_) => {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
class OrchestratorQueryOverride {
|
class OrchestratorQueryOverride {
|
||||||
|
// TODO accept premade secret sources or custom secret source definition yamls
|
||||||
static query(key, alternativeKey) {
|
static query(key, alternativeKey) {
|
||||||
if (OrchestratorQueryOverride.queryOverrides && OrchestratorQueryOverride.queryOverrides[key] !== undefined) {
|
if (OrchestratorQueryOverride.queryOverrides && OrchestratorQueryOverride.queryOverrides[key] !== undefined) {
|
||||||
return OrchestratorQueryOverride.queryOverrides[key];
|
return OrchestratorQueryOverride.queryOverrides[key];
|
||||||
@@ -2434,49 +2478,11 @@ class OrchestratorQueryOverride {
|
|||||||
if (!this.shouldUseOverride(query)) {
|
if (!this.shouldUseOverride(query)) {
|
||||||
throw new Error(`Should not be trying to run override query on ${query}`);
|
throw new Error(`Should not be trying to run override query on ${query}`);
|
||||||
}
|
}
|
||||||
// Validate the query key before interpolating it into a shell command
|
return await generic_input_reader_1.GenericInputReader.Run(formatFunction(orchestrator_options_1.default.inputPullCommand, [{ key: 0, value: query }]));
|
||||||
(0, secret_source_service_1.validateSecretKey)(query);
|
|
||||||
const result = await generic_input_reader_1.GenericInputReader.Run(formatFunction(orchestrator_options_1.default.inputPullCommand, [{ key: 0, value: query }]));
|
|
||||||
// Mask the fetched secret value so it does not appear in GitHub Actions logs
|
|
||||||
if (result && result.trim().length > 0) {
|
|
||||||
core.setSecret(result);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Populate query overrides using either:
|
|
||||||
* 1. Premade/custom secret sources (via secretSource input), or
|
|
||||||
* 2. Shell command (via inputPullCommand, legacy approach)
|
|
||||||
*
|
|
||||||
* The secretSource input takes precedence if set. It supports:
|
|
||||||
* - Premade names: 'aws-secrets-manager', 'aws-parameter-store', 'gcp-secret-manager', 'azure-key-vault', 'env'
|
|
||||||
* - Custom commands: any string containing {0} placeholder
|
|
||||||
* - YAML file path: a path ending in .yml or .yaml containing custom source definitions
|
|
||||||
*/
|
|
||||||
static async PopulateQueryOverrideInput() {
|
static async PopulateQueryOverrideInput() {
|
||||||
const queries = orchestrator_options_1.default.pullInputList;
|
const queries = orchestrator_options_1.default.pullInputList;
|
||||||
OrchestratorQueryOverride.queryOverrides = {};
|
OrchestratorQueryOverride.queryOverrides = {};
|
||||||
const secretSource = orchestrator_options_1.default.secretSource;
|
|
||||||
// Use SecretSourceService if secretSource is configured
|
|
||||||
if (secretSource) {
|
|
||||||
orchestrator_logger_1.default.log(`Using secret source: ${secretSource}`);
|
|
||||||
// YAML file: load definitions and use the first source
|
|
||||||
if (secretSource.endsWith('.yml') || secretSource.endsWith('.yaml')) {
|
|
||||||
const definitions = secret_source_service_1.SecretSourceService.loadFromYaml(secretSource);
|
|
||||||
if (definitions.length > 0) {
|
|
||||||
orchestrator_logger_1.default.log(`Loaded ${definitions.length} secret source(s) from ${secretSource}`);
|
|
||||||
for (const key of queries) {
|
|
||||||
OrchestratorQueryOverride.queryOverrides[key] = await secret_source_service_1.SecretSourceService.fetchSecret(definitions[0], key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Premade or custom command source
|
|
||||||
const results = await secret_source_service_1.SecretSourceService.fetchAll(secretSource, queries);
|
|
||||||
Object.assign(OrchestratorQueryOverride.queryOverrides, results);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Legacy: use inputPullCommand if set
|
|
||||||
for (const element of queries) {
|
for (const element of queries) {
|
||||||
if (OrchestratorQueryOverride.shouldUseOverride(element)) {
|
if (OrchestratorQueryOverride.shouldUseOverride(element)) {
|
||||||
OrchestratorQueryOverride.queryOverrides[element] = await OrchestratorQueryOverride.queryOverride(element);
|
OrchestratorQueryOverride.queryOverrides[element] = await OrchestratorQueryOverride.queryOverride(element);
|
||||||
@@ -8051,6 +8057,7 @@ class RemoteClient {
|
|||||||
}
|
}
|
||||||
remote_client_logger_1.RemoteClientLogger.log(`Initializing source repository for cloning with caching of LFS files`);
|
remote_client_logger_1.RemoteClientLogger.log(`Initializing source repository for cloning with caching of LFS files`);
|
||||||
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global advice.detachedHead false`);
|
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global advice.detachedHead false`);
|
||||||
|
await orchestrator_folders_1.OrchestratorFolders.configureGitAuth();
|
||||||
remote_client_logger_1.RemoteClientLogger.log(`Cloning the repository being built:`);
|
remote_client_logger_1.RemoteClientLogger.log(`Cloning the repository being built:`);
|
||||||
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"`);
|
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"`);
|
||||||
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`);
|
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`);
|
||||||
@@ -8155,10 +8162,7 @@ class RemoteClient {
|
|||||||
const gitPrivateToken = process.env.GIT_PRIVATE_TOKEN;
|
const gitPrivateToken = process.env.GIT_PRIVATE_TOKEN;
|
||||||
if (gitPrivateToken) {
|
if (gitPrivateToken) {
|
||||||
remote_client_logger_1.RemoteClientLogger.log(`Attempting to pull LFS files with GIT_PRIVATE_TOKEN...`);
|
remote_client_logger_1.RemoteClientLogger.log(`Attempting to pull LFS files with GIT_PRIVATE_TOKEN...`);
|
||||||
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
|
await RemoteClient.configureTokenAuth(gitPrivateToken);
|
||||||
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
|
|
||||||
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
|
|
||||||
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global url."https://${gitPrivateToken}@github.com/".insteadOf "https://github.com/"`);
|
|
||||||
await orchestrator_system_1.OrchestratorSystem.Run(`git lfs pull`, true);
|
await orchestrator_system_1.OrchestratorSystem.Run(`git lfs pull`, true);
|
||||||
await orchestrator_system_1.OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
await orchestrator_system_1.OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
||||||
remote_client_logger_1.RemoteClientLogger.log(`Successfully pulled LFS files with GIT_PRIVATE_TOKEN`);
|
remote_client_logger_1.RemoteClientLogger.log(`Successfully pulled LFS files with GIT_PRIVATE_TOKEN`);
|
||||||
@@ -8173,10 +8177,7 @@ class RemoteClient {
|
|||||||
const githubToken = process.env.GITHUB_TOKEN;
|
const githubToken = process.env.GITHUB_TOKEN;
|
||||||
if (githubToken) {
|
if (githubToken) {
|
||||||
remote_client_logger_1.RemoteClientLogger.log(`Attempting to pull LFS files with GITHUB_TOKEN fallback...`);
|
remote_client_logger_1.RemoteClientLogger.log(`Attempting to pull LFS files with GITHUB_TOKEN fallback...`);
|
||||||
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
|
await RemoteClient.configureTokenAuth(githubToken);
|
||||||
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
|
|
||||||
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
|
|
||||||
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global url."https://${githubToken}@github.com/".insteadOf "https://github.com/"`);
|
|
||||||
await orchestrator_system_1.OrchestratorSystem.Run(`git lfs pull`, true);
|
await orchestrator_system_1.OrchestratorSystem.Run(`git lfs pull`, true);
|
||||||
await orchestrator_system_1.OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
await orchestrator_system_1.OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
||||||
remote_client_logger_1.RemoteClientLogger.log(`Successfully pulled LFS files with GITHUB_TOKEN`);
|
remote_client_logger_1.RemoteClientLogger.log(`Successfully pulled LFS files with GITHUB_TOKEN`);
|
||||||
@@ -8233,6 +8234,23 @@ class RemoteClient {
|
|||||||
}
|
}
|
||||||
return false;
|
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.
|
||||||
|
*/
|
||||||
|
static async configureTokenAuth(token) {
|
||||||
|
if (orchestrator_folders_1.OrchestratorFolders.useHeaderAuth) {
|
||||||
|
const encoded = Buffer.from(`x-access-token:${token}`).toString('base64');
|
||||||
|
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global http.https://github.com/.extraHeader "Authorization: Basic ${encoded}"`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."https://github.com/".insteadOf || true`);
|
||||||
|
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."ssh://git@github.com/".insteadOf || true`);
|
||||||
|
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global --unset-all url."git@github.com".insteadOf || true`);
|
||||||
|
await orchestrator_system_1.OrchestratorSystem.Run(`git config --global url."https://${token}@github.com/".insteadOf "https://github.com/"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
__decorate([
|
__decorate([
|
||||||
(0, cli_functions_repository_1.CliFunction)(`remote-cli-pre-build`, `sets up a repository, usually before a game-ci build`)
|
(0, cli_functions_repository_1.CliFunction)(`remote-cli-pre-build`, `sets up a repository, usually before a game-ci build`)
|
||||||
@@ -9698,333 +9716,6 @@ class ContainerHookService {
|
|||||||
exports.ContainerHookService = ContainerHookService;
|
exports.ContainerHookService = ContainerHookService;
|
||||||
|
|
||||||
|
|
||||||
/***/ }),
|
|
||||||
|
|
||||||
/***/ 79089:
|
|
||||||
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
||||||
if (k2 === undefined) k2 = k;
|
|
||||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
||||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
||||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
||||||
}
|
|
||||||
Object.defineProperty(o, k2, desc);
|
|
||||||
}) : (function(o, m, k, k2) {
|
|
||||||
if (k2 === undefined) k2 = k;
|
|
||||||
o[k2] = m[k];
|
|
||||||
}));
|
|
||||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
||||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
||||||
}) : function(o, v) {
|
|
||||||
o["default"] = v;
|
|
||||||
});
|
|
||||||
var __importStar = (this && this.__importStar) || function (mod) {
|
|
||||||
if (mod && mod.__esModule) return mod;
|
|
||||||
var result = {};
|
|
||||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
||||||
__setModuleDefault(result, mod);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
||||||
};
|
|
||||||
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|
||||||
exports.SecretSourceService = exports.validateSecretKey = void 0;
|
|
||||||
const node_fs_1 = __importDefault(__nccwpck_require__(87561));
|
|
||||||
const core = __importStar(__nccwpck_require__(42186));
|
|
||||||
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
|
|
||||||
const orchestrator_system_1 = __nccwpck_require__(9744);
|
|
||||||
/**
|
|
||||||
* Validate that a secret key name contains only safe characters.
|
|
||||||
* Prevents shell injection when keys are interpolated into commands.
|
|
||||||
*
|
|
||||||
* Allowed characters: alphanumeric, hyphens, underscores, dots, forward slashes.
|
|
||||||
*
|
|
||||||
* @param key - The secret key name to validate
|
|
||||||
* @returns The validated key (unchanged)
|
|
||||||
* @throws Error if the key contains disallowed characters
|
|
||||||
*/
|
|
||||||
function validateSecretKey(key) {
|
|
||||||
if (!/^[a-zA-Z0-9\-_./]+$/.test(key)) {
|
|
||||||
throw new Error(`Invalid secret key name: "${key}". Keys may only contain alphanumeric characters, hyphens, underscores, dots, and forward slashes.`);
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
exports.validateSecretKey = validateSecretKey;
|
|
||||||
/**
|
|
||||||
* Mask a secret value so it does not appear in GitHub Actions logs.
|
|
||||||
* Empty or whitespace-only values are skipped (core.setSecret would be a no-op).
|
|
||||||
*/
|
|
||||||
function maskSecretValue(value) {
|
|
||||||
if (value.trim().length > 0) {
|
|
||||||
core.setSecret(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Premade secret sources and custom YAML-based secret source definitions.
|
|
||||||
*
|
|
||||||
* Premade sources are string shortcuts that expand to shell commands:
|
|
||||||
* - `aws-secrets-manager` -- AWS Secrets Manager
|
|
||||||
* - `aws-parameter-store` -- AWS Systems Manager Parameter Store
|
|
||||||
* - `gcp-secret-manager` -- Google Cloud Secret Manager
|
|
||||||
* - `azure-key-vault` -- Azure Key Vault (requires AZURE_VAULT_NAME env var)
|
|
||||||
* - `hashicorp-vault` -- HashiCorp Vault KV v2 (requires VAULT_ADDR, optionally VAULT_MOUNT)
|
|
||||||
* - `hashicorp-vault-kv1` -- HashiCorp Vault KV v1 (requires VAULT_ADDR, optionally VAULT_MOUNT)
|
|
||||||
* - `env` -- Read from environment variables (no shell command needed)
|
|
||||||
*
|
|
||||||
* Custom YAML format:
|
|
||||||
* sources:
|
|
||||||
* - name: my-vault
|
|
||||||
* command: 'vault kv get -field=value secret/{0}'
|
|
||||||
* - name: my-api
|
|
||||||
* command: 'curl -s https://secrets.example.com/api/{0}'
|
|
||||||
* parseOutput: json-field
|
|
||||||
* jsonField: value
|
|
||||||
*/
|
|
||||||
class SecretSourceService {
|
|
||||||
/**
|
|
||||||
* Check if a source name is a known premade source.
|
|
||||||
*/
|
|
||||||
static isPremadeSource(sourceName) {
|
|
||||||
return sourceName in SecretSourceService.premadeSources;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Get the list of available premade source names.
|
|
||||||
*/
|
|
||||||
static getAvailableSources() {
|
|
||||||
return Object.keys(SecretSourceService.premadeSources);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Resolve a source name to a SecretSourceDefinition.
|
|
||||||
*
|
|
||||||
* - If the name matches a premade source, returns that definition.
|
|
||||||
* - If it looks like a shell command (contains spaces or {0}), wraps it as a custom command.
|
|
||||||
* - Otherwise, returns undefined.
|
|
||||||
*/
|
|
||||||
static resolveSource(sourceName) {
|
|
||||||
// Check premade sources
|
|
||||||
if (SecretSourceService.isPremadeSource(sourceName)) {
|
|
||||||
return SecretSourceService.premadeSources[sourceName];
|
|
||||||
}
|
|
||||||
// If it contains a placeholder or spaces, treat it as a raw command
|
|
||||||
if (sourceName.includes('{0}') || sourceName.includes(' ')) {
|
|
||||||
return {
|
|
||||||
name: 'custom-command',
|
|
||||||
command: sourceName,
|
|
||||||
parseOutput: 'raw',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Load custom secret source definitions from a YAML file.
|
|
||||||
*
|
|
||||||
* Expected format:
|
|
||||||
* sources:
|
|
||||||
* - name: my-source
|
|
||||||
* command: 'my-tool get-secret {0}'
|
|
||||||
* - name: my-api
|
|
||||||
* command: 'curl -s https://api.example.com/secrets/{0}'
|
|
||||||
* parseOutput: json-field
|
|
||||||
* jsonField: value
|
|
||||||
*/
|
|
||||||
static loadFromYaml(filePath) {
|
|
||||||
if (!node_fs_1.default.existsSync(filePath)) {
|
|
||||||
orchestrator_logger_1.default.logWarning(`Secret source YAML not found: ${filePath}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const content = node_fs_1.default.readFileSync(filePath, 'utf8');
|
|
||||||
const parsed = SecretSourceService.parseSimpleYaml(content);
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
orchestrator_logger_1.default.logWarning(`Failed to parse secret source YAML: ${error.message}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Fetch a secret value using the given source definition.
|
|
||||||
*
|
|
||||||
* Validates the key against an allowlist pattern before interpolating it
|
|
||||||
* into the command string to prevent shell injection. The fetched secret
|
|
||||||
* value is masked via core.setSecret() so it does not leak in logs.
|
|
||||||
*
|
|
||||||
* @param source - The secret source definition to use
|
|
||||||
* @param key - The secret key to fetch
|
|
||||||
* @returns The secret value, or empty string on failure
|
|
||||||
*/
|
|
||||||
static async fetchSecret(source, key) {
|
|
||||||
// Validate the key to prevent shell injection
|
|
||||||
validateSecretKey(key);
|
|
||||||
const command = source.command.replace(/\{0\}/g, key);
|
|
||||||
try {
|
|
||||||
const output = await orchestrator_system_1.OrchestratorSystem.Run(command, false, true);
|
|
||||||
let value;
|
|
||||||
if (source.parseOutput === 'json-field' && source.jsonField) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(output);
|
|
||||||
value = parsed[source.jsonField] || '';
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
orchestrator_logger_1.default.logWarning(`Failed to parse JSON output from ${source.name} for key ${key}`);
|
|
||||||
value = output.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
value = output.trim();
|
|
||||||
}
|
|
||||||
// Mask the secret value so it does not appear in GitHub Actions logs
|
|
||||||
maskSecretValue(value);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
orchestrator_logger_1.default.logWarning(`Failed to fetch secret '${key}' from ${source.name}: ${error.message}`);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Fetch a secret from an environment variable. No shell command needed.
|
|
||||||
* The value is masked via core.setSecret() so it does not leak in logs.
|
|
||||||
*/
|
|
||||||
static fetchFromEnv(key) {
|
|
||||||
const value = process.env[key] || '';
|
|
||||||
maskSecretValue(value);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Resolve a source name and fetch all secrets from it.
|
|
||||||
*
|
|
||||||
* @param sourceName - Premade source name, shell command, or 'env'
|
|
||||||
* @param keys - List of secret keys to fetch
|
|
||||||
* @returns Map of key -> value
|
|
||||||
*/
|
|
||||||
static async fetchAll(sourceName, keys) {
|
|
||||||
const results = {};
|
|
||||||
if (sourceName === 'env') {
|
|
||||||
for (const key of keys) {
|
|
||||||
results[key] = SecretSourceService.fetchFromEnv(key);
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
const source = SecretSourceService.resolveSource(sourceName);
|
|
||||||
if (!source) {
|
|
||||||
orchestrator_logger_1.default.logWarning(`Unknown secret source '${sourceName}'. Available sources: ${SecretSourceService.getAvailableSources().join(', ')}`);
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
orchestrator_logger_1.default.log(`Fetching ${keys.length} secret(s) from ${source.name}`);
|
|
||||||
for (const key of keys) {
|
|
||||||
results[key] = await SecretSourceService.fetchSecret(source, key);
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Simple YAML parser for secret source definitions.
|
|
||||||
* Handles the specific structure we expect without requiring a YAML library.
|
|
||||||
*/
|
|
||||||
static parseSimpleYaml(content) {
|
|
||||||
const definitions = [];
|
|
||||||
const lines = content.split('\n');
|
|
||||||
let current = null;
|
|
||||||
for (const rawLine of lines) {
|
|
||||||
const line = rawLine.replace(/\r$/, '');
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (trimmed === '' || trimmed.startsWith('#'))
|
|
||||||
continue;
|
|
||||||
if (trimmed === '- name:' || trimmed.startsWith('- name:')) {
|
|
||||||
if (current?.name && current?.command) {
|
|
||||||
definitions.push(current);
|
|
||||||
}
|
|
||||||
current = {
|
|
||||||
name: trimmed
|
|
||||||
.replace('- name:', '')
|
|
||||||
.trim()
|
|
||||||
.replace(/^['"]|['"]$/g, ''),
|
|
||||||
parseOutput: 'raw',
|
|
||||||
};
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (current && trimmed.startsWith('command:')) {
|
|
||||||
current.command = trimmed
|
|
||||||
.replace('command:', '')
|
|
||||||
.trim()
|
|
||||||
.replace(/^['"]|['"]$/g, '');
|
|
||||||
}
|
|
||||||
else if (current && trimmed.startsWith('parseOutput:')) {
|
|
||||||
const value = trimmed
|
|
||||||
.replace('parseOutput:', '')
|
|
||||||
.trim()
|
|
||||||
.replace(/^['"]|['"]$/g, '');
|
|
||||||
current.parseOutput = value;
|
|
||||||
}
|
|
||||||
else if (current && trimmed.startsWith('jsonField:')) {
|
|
||||||
current.jsonField = trimmed
|
|
||||||
.replace('jsonField:', '')
|
|
||||||
.trim()
|
|
||||||
.replace(/^['"]|['"]$/g, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (current?.name && current?.command) {
|
|
||||||
definitions.push(current);
|
|
||||||
}
|
|
||||||
return definitions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
exports.SecretSourceService = SecretSourceService;
|
|
||||||
SecretSourceService.premadeSources = {
|
|
||||||
'aws-secrets-manager': {
|
|
||||||
name: 'aws-secrets-manager',
|
|
||||||
command: 'aws secretsmanager get-secret-value --secret-id {0} --query SecretString --output text',
|
|
||||||
parseOutput: 'raw',
|
|
||||||
},
|
|
||||||
'aws-secret-manager': {
|
|
||||||
// Alias for backward compatibility (original name in inputPullCommand)
|
|
||||||
name: 'aws-secret-manager',
|
|
||||||
command: 'aws secretsmanager get-secret-value --secret-id {0} --query SecretString --output text',
|
|
||||||
parseOutput: 'raw',
|
|
||||||
},
|
|
||||||
'aws-parameter-store': {
|
|
||||||
name: 'aws-parameter-store',
|
|
||||||
command: 'aws ssm get-parameter --name {0} --with-decryption --query Parameter.Value --output text',
|
|
||||||
parseOutput: 'raw',
|
|
||||||
},
|
|
||||||
'gcp-secret-manager': {
|
|
||||||
name: 'gcp-secret-manager',
|
|
||||||
command: 'gcloud secrets versions access latest --secret="{0}"',
|
|
||||||
parseOutput: 'raw',
|
|
||||||
},
|
|
||||||
'azure-key-vault': {
|
|
||||||
name: 'azure-key-vault',
|
|
||||||
command: 'az keyvault secret show --vault-name "$AZURE_VAULT_NAME" --name {0} --query value --output tsv',
|
|
||||||
parseOutput: 'raw',
|
|
||||||
},
|
|
||||||
'hashicorp-vault': {
|
|
||||||
// HashiCorp Vault KV v2 (default). Requires VAULT_ADDR env var.
|
|
||||||
// Optionally set VAULT_MOUNT to override the mount path (default: 'secret').
|
|
||||||
// Authentication is handled by VAULT_TOKEN or other Vault auth env vars.
|
|
||||||
name: 'hashicorp-vault',
|
|
||||||
command: 'vault kv get -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
|
|
||||||
parseOutput: 'raw',
|
|
||||||
},
|
|
||||||
'hashicorp-vault-kv1': {
|
|
||||||
// HashiCorp Vault KV v1. Requires VAULT_ADDR env var.
|
|
||||||
// Optionally set VAULT_MOUNT to override the mount path (default: 'secret').
|
|
||||||
name: 'hashicorp-vault-kv1',
|
|
||||||
command: 'vault read -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
|
|
||||||
parseOutput: 'raw',
|
|
||||||
},
|
|
||||||
vault: {
|
|
||||||
// Short alias for hashicorp-vault (KV v2)
|
|
||||||
name: 'vault',
|
|
||||||
command: 'vault kv get -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
|
|
||||||
parseOutput: 'raw',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/***/ }),
|
/***/ }),
|
||||||
|
|
||||||
/***/ 23451:
|
/***/ 23451:
|
||||||
@@ -10118,6 +9809,7 @@ printenv
|
|||||||
git config --global advice.detachedHead false
|
git config --global advice.detachedHead false
|
||||||
git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"
|
git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"
|
||||||
git config --global filter.lfs.process "git-lfs filter-process --skip"
|
git config --global filter.lfs.process "git-lfs filter-process --skip"
|
||||||
|
${orchestrator_folders_1.OrchestratorFolders.gitAuthConfigScript}
|
||||||
BRANCH="${orchestrator_1.default.buildParameters.orchestratorBranch}"
|
BRANCH="${orchestrator_1.default.buildParameters.orchestratorBranch}"
|
||||||
REPO="${orchestrator_folders_1.OrchestratorFolders.unityBuilderRepoUrl}"
|
REPO="${orchestrator_folders_1.OrchestratorFolders.unityBuilderRepoUrl}"
|
||||||
if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
|
if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
|
||||||
@@ -10234,6 +9926,7 @@ class BuildAutomationWorkflow {
|
|||||||
static setupCommands(builderPath, isContainerized) {
|
static setupCommands(builderPath, isContainerized) {
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
const commands = `mkdir -p ${orchestrator_folders_1.OrchestratorFolders.ToLinuxFolder(orchestrator_folders_1.OrchestratorFolders.builderPathAbsolute)}
|
const commands = `mkdir -p ${orchestrator_folders_1.OrchestratorFolders.ToLinuxFolder(orchestrator_folders_1.OrchestratorFolders.builderPathAbsolute)}
|
||||||
|
${orchestrator_folders_1.OrchestratorFolders.gitAuthConfigScript}
|
||||||
BRANCH="${orchestrator_1.default.buildParameters.orchestratorBranch}"
|
BRANCH="${orchestrator_1.default.buildParameters.orchestratorBranch}"
|
||||||
REPO="${orchestrator_folders_1.OrchestratorFolders.unityBuilderRepoUrl}"
|
REPO="${orchestrator_folders_1.OrchestratorFolders.unityBuilderRepoUrl}"
|
||||||
DEST="${orchestrator_folders_1.OrchestratorFolders.ToLinuxFolder(orchestrator_folders_1.OrchestratorFolders.builderPathAbsolute)}"
|
DEST="${orchestrator_folders_1.OrchestratorFolders.ToLinuxFolder(orchestrator_folders_1.OrchestratorFolders.builderPathAbsolute)}"
|
||||||
|
|||||||
2
dist/index.js.map
generated
vendored
2
dist/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -54,6 +54,7 @@ class BuildParameters {
|
|||||||
public sshAgent!: string;
|
public sshAgent!: string;
|
||||||
public sshPublicKeysDirectoryPath!: string;
|
public sshPublicKeysDirectoryPath!: string;
|
||||||
public providerStrategy!: string;
|
public providerStrategy!: string;
|
||||||
|
public gitAuthMode!: string;
|
||||||
public gitPrivateToken!: string;
|
public gitPrivateToken!: string;
|
||||||
public awsStackName!: string;
|
public awsStackName!: string;
|
||||||
public awsEndpoint?: string;
|
public awsEndpoint?: string;
|
||||||
@@ -194,6 +195,7 @@ class BuildParameters {
|
|||||||
containerRegistryRepository: Input.containerRegistryRepository,
|
containerRegistryRepository: Input.containerRegistryRepository,
|
||||||
containerRegistryImageVersion: Input.containerRegistryImageVersion,
|
containerRegistryImageVersion: Input.containerRegistryImageVersion,
|
||||||
providerStrategy: OrchestratorOptions.providerStrategy,
|
providerStrategy: OrchestratorOptions.providerStrategy,
|
||||||
|
gitAuthMode: OrchestratorOptions.gitAuthMode,
|
||||||
buildPlatform: OrchestratorOptions.buildPlatform,
|
buildPlatform: OrchestratorOptions.buildPlatform,
|
||||||
kubeConfig: OrchestratorOptions.kubeConfig,
|
kubeConfig: OrchestratorOptions.kubeConfig,
|
||||||
containerMemory: OrchestratorOptions.containerMemory,
|
containerMemory: OrchestratorOptions.containerMemory,
|
||||||
|
|||||||
140
src/model/orchestrator/options/orchestrator-folders-auth.test.ts
Normal file
140
src/model/orchestrator/options/orchestrator-folders-auth.test.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
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`);
|
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 {
|
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`;
|
return `https://${Orchestrator.buildParameters.gitPrivateToken}@github.com/${Orchestrator.buildParameters.orchestratorRepoName}.git`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static get targetBuildRepoUrl(): string {
|
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`;
|
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() {
|
public static get buildVolumeFolder() {
|
||||||
return 'data';
|
return 'data';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ class OrchestratorOptions {
|
|||||||
return provider || 'local';
|
return provider || 'local';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get gitAuthMode(): string {
|
||||||
|
return OrchestratorOptions.getInput('gitAuthMode') || 'header';
|
||||||
|
}
|
||||||
|
|
||||||
static get containerCpu(): string {
|
static get containerCpu(): string {
|
||||||
return OrchestratorOptions.getInput('containerCpu') || `1024`;
|
return OrchestratorOptions.getInput('containerCpu') || `1024`;
|
||||||
}
|
}
|
||||||
@@ -190,10 +194,6 @@ class OrchestratorOptions {
|
|||||||
return OrchestratorOptions.getInput('pullInputList')?.split(`,`) || [];
|
return OrchestratorOptions.getInput('pullInputList')?.split(`,`) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get secretSource(): string {
|
|
||||||
return OrchestratorOptions.getInput('secretSource') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
static get inputPullCommand(): string {
|
static get inputPullCommand(): string {
|
||||||
const value = OrchestratorOptions.getInput('inputPullCommand');
|
const value = OrchestratorOptions.getInput('inputPullCommand');
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import * as core from '@actions/core';
|
|
||||||
import Input from '../../input';
|
import Input from '../../input';
|
||||||
import { GenericInputReader } from '../../input-readers/generic-input-reader';
|
import { GenericInputReader } from '../../input-readers/generic-input-reader';
|
||||||
import OrchestratorOptions from './orchestrator-options';
|
import OrchestratorOptions from './orchestrator-options';
|
||||||
import { SecretSourceService, validateSecretKey } from '../services/secrets/secret-source-service';
|
|
||||||
import OrchestratorLogger from '../services/core/orchestrator-logger';
|
|
||||||
|
|
||||||
const formatFunction = (value: string, arguments_: any[]) => {
|
const formatFunction = (value: string, arguments_: any[]) => {
|
||||||
for (const element of arguments_) {
|
for (const element of arguments_) {
|
||||||
@@ -16,6 +13,8 @@ const formatFunction = (value: string, arguments_: any[]) => {
|
|||||||
class OrchestratorQueryOverride {
|
class OrchestratorQueryOverride {
|
||||||
static queryOverrides: { [key: string]: string } | undefined;
|
static queryOverrides: { [key: string]: string } | undefined;
|
||||||
|
|
||||||
|
// TODO accept premade secret sources or custom secret source definition yamls
|
||||||
|
|
||||||
public static query(key: string, alternativeKey: string) {
|
public static query(key: string, alternativeKey: string) {
|
||||||
if (OrchestratorQueryOverride.queryOverrides && OrchestratorQueryOverride.queryOverrides[key] !== undefined) {
|
if (OrchestratorQueryOverride.queryOverrides && OrchestratorQueryOverride.queryOverrides[key] !== undefined) {
|
||||||
return OrchestratorQueryOverride.queryOverrides[key];
|
return OrchestratorQueryOverride.queryOverrides[key];
|
||||||
@@ -50,62 +49,14 @@ class OrchestratorQueryOverride {
|
|||||||
throw new Error(`Should not be trying to run override query on ${query}`);
|
throw new Error(`Should not be trying to run override query on ${query}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the query key before interpolating it into a shell command
|
return await GenericInputReader.Run(
|
||||||
validateSecretKey(query);
|
|
||||||
|
|
||||||
const result = await GenericInputReader.Run(
|
|
||||||
formatFunction(OrchestratorOptions.inputPullCommand, [{ key: 0, value: query }]),
|
formatFunction(OrchestratorOptions.inputPullCommand, [{ key: 0, value: query }]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mask the fetched secret value so it does not appear in GitHub Actions logs
|
|
||||||
if (result && result.trim().length > 0) {
|
|
||||||
core.setSecret(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Populate query overrides using either:
|
|
||||||
* 1. Premade/custom secret sources (via secretSource input), or
|
|
||||||
* 2. Shell command (via inputPullCommand, legacy approach)
|
|
||||||
*
|
|
||||||
* The secretSource input takes precedence if set. It supports:
|
|
||||||
* - Premade names: 'aws-secrets-manager', 'aws-parameter-store', 'gcp-secret-manager', 'azure-key-vault', 'env'
|
|
||||||
* - Custom commands: any string containing {0} placeholder
|
|
||||||
* - YAML file path: a path ending in .yml or .yaml containing custom source definitions
|
|
||||||
*/
|
|
||||||
public static async PopulateQueryOverrideInput() {
|
public static async PopulateQueryOverrideInput() {
|
||||||
const queries = OrchestratorOptions.pullInputList;
|
const queries = OrchestratorOptions.pullInputList;
|
||||||
OrchestratorQueryOverride.queryOverrides = {};
|
OrchestratorQueryOverride.queryOverrides = {};
|
||||||
|
|
||||||
const secretSource = OrchestratorOptions.secretSource;
|
|
||||||
|
|
||||||
// Use SecretSourceService if secretSource is configured
|
|
||||||
if (secretSource) {
|
|
||||||
OrchestratorLogger.log(`Using secret source: ${secretSource}`);
|
|
||||||
|
|
||||||
// YAML file: load definitions and use the first source
|
|
||||||
if (secretSource.endsWith('.yml') || secretSource.endsWith('.yaml')) {
|
|
||||||
const definitions = SecretSourceService.loadFromYaml(secretSource);
|
|
||||||
if (definitions.length > 0) {
|
|
||||||
OrchestratorLogger.log(`Loaded ${definitions.length} secret source(s) from ${secretSource}`);
|
|
||||||
for (const key of queries) {
|
|
||||||
OrchestratorQueryOverride.queryOverrides[key] = await SecretSourceService.fetchSecret(definitions[0], key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Premade or custom command source
|
|
||||||
const results = await SecretSourceService.fetchAll(secretSource, queries);
|
|
||||||
Object.assign(OrchestratorQueryOverride.queryOverrides, results);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy: use inputPullCommand if set
|
|
||||||
for (const element of queries) {
|
for (const element of queries) {
|
||||||
if (OrchestratorQueryOverride.shouldUseOverride(element)) {
|
if (OrchestratorQueryOverride.shouldUseOverride(element)) {
|
||||||
OrchestratorQueryOverride.queryOverrides[element] = await OrchestratorQueryOverride.queryOverride(element);
|
OrchestratorQueryOverride.queryOverrides[element] = await OrchestratorQueryOverride.queryOverride(element);
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ export class RemoteClient {
|
|||||||
|
|
||||||
RemoteClientLogger.log(`Initializing source repository for cloning with caching of LFS files`);
|
RemoteClientLogger.log(`Initializing source repository for cloning with caching of LFS files`);
|
||||||
await OrchestratorSystem.Run(`git config --global advice.detachedHead false`);
|
await OrchestratorSystem.Run(`git config --global advice.detachedHead false`);
|
||||||
|
await OrchestratorFolders.configureGitAuth();
|
||||||
RemoteClientLogger.log(`Cloning the repository being built:`);
|
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.smudge "git-lfs smudge --skip -- %f"`);
|
||||||
await OrchestratorSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`);
|
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;
|
const gitPrivateToken = process.env.GIT_PRIVATE_TOKEN;
|
||||||
if (gitPrivateToken) {
|
if (gitPrivateToken) {
|
||||||
RemoteClientLogger.log(`Attempting to pull LFS files with GIT_PRIVATE_TOKEN...`);
|
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 RemoteClient.configureTokenAuth(gitPrivateToken);
|
||||||
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 OrchestratorSystem.Run(`git lfs pull`, true);
|
await OrchestratorSystem.Run(`git lfs pull`, true);
|
||||||
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
||||||
RemoteClientLogger.log(`Successfully pulled LFS files with GIT_PRIVATE_TOKEN`);
|
RemoteClientLogger.log(`Successfully pulled LFS files with GIT_PRIVATE_TOKEN`);
|
||||||
@@ -432,12 +428,7 @@ export class RemoteClient {
|
|||||||
const githubToken = process.env.GITHUB_TOKEN;
|
const githubToken = process.env.GITHUB_TOKEN;
|
||||||
if (githubToken) {
|
if (githubToken) {
|
||||||
RemoteClientLogger.log(`Attempting to pull LFS files with GITHUB_TOKEN fallback...`);
|
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 RemoteClient.configureTokenAuth(githubToken);
|
||||||
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 OrchestratorSystem.Run(`git lfs pull`, true);
|
await OrchestratorSystem.Run(`git lfs pull`, true);
|
||||||
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
await OrchestratorSystem.Run(`git lfs checkout || true`, true);
|
||||||
RemoteClientLogger.log(`Successfully pulled LFS files with GITHUB_TOKEN`);
|
RemoteClientLogger.log(`Successfully pulled LFS files with GITHUB_TOKEN`);
|
||||||
@@ -501,4 +492,25 @@ export class RemoteClient {
|
|||||||
|
|
||||||
return false;
|
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/"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,446 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import * as core from '@actions/core';
|
|
||||||
import { SecretSourceService, validateSecretKey } from './secret-source-service';
|
|
||||||
|
|
||||||
jest.mock('node:fs');
|
|
||||||
jest.mock('@actions/core', () => ({
|
|
||||||
setSecret: jest.fn(),
|
|
||||||
info: jest.fn(),
|
|
||||||
warning: jest.fn(),
|
|
||||||
error: jest.fn(),
|
|
||||||
}));
|
|
||||||
jest.mock('../core/orchestrator-system', () => ({
|
|
||||||
OrchestratorSystem: {
|
|
||||||
Run: jest.fn().mockResolvedValue(''),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
jest.mock('../core/orchestrator-logger', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: {
|
|
||||||
log: jest.fn(),
|
|
||||||
logWarning: jest.fn(),
|
|
||||||
error: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockFs = fs as jest.Mocked<typeof fs>;
|
|
||||||
|
|
||||||
describe('SecretSourceService', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validateSecretKey', () => {
|
|
||||||
it('should accept alphanumeric keys', () => {
|
|
||||||
expect(validateSecretKey('MY_SECRET_KEY')).toBe('MY_SECRET_KEY');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept keys with hyphens', () => {
|
|
||||||
expect(validateSecretKey('my-secret-key')).toBe('my-secret-key');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept keys with dots', () => {
|
|
||||||
expect(validateSecretKey('my.secret.key')).toBe('my.secret.key');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept keys with forward slashes', () => {
|
|
||||||
expect(validateSecretKey('path/to/secret')).toBe('path/to/secret');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept keys with mixed valid characters', () => {
|
|
||||||
expect(validateSecretKey('my-app/prod_db.password')).toBe('my-app/prod_db.password');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject keys with semicolons (shell injection)', () => {
|
|
||||||
expect(() => validateSecretKey('; rm -rf /')).toThrow('Invalid secret key name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject keys with backticks (command substitution)', () => {
|
|
||||||
expect(() => validateSecretKey('`whoami`')).toThrow('Invalid secret key name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject keys with dollar signs (variable expansion)', () => {
|
|
||||||
expect(() => validateSecretKey('$HOME')).toThrow('Invalid secret key name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject keys with pipe characters', () => {
|
|
||||||
expect(() => validateSecretKey('key | cat /etc/passwd')).toThrow('Invalid secret key name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject keys with ampersands', () => {
|
|
||||||
expect(() => validateSecretKey('key && echo pwned')).toThrow('Invalid secret key name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject keys with newlines', () => {
|
|
||||||
expect(() => validateSecretKey('key\nmalicious')).toThrow('Invalid secret key name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject keys with quotes', () => {
|
|
||||||
expect(() => validateSecretKey('"key"')).toThrow('Invalid secret key name');
|
|
||||||
expect(() => validateSecretKey("'key'")).toThrow('Invalid secret key name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject keys with parentheses (subshell)', () => {
|
|
||||||
expect(() => validateSecretKey('$(whoami)')).toThrow('Invalid secret key name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject empty keys', () => {
|
|
||||||
expect(() => validateSecretKey('')).toThrow('Invalid secret key name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject keys with spaces', () => {
|
|
||||||
expect(() => validateSecretKey('key with spaces')).toThrow('Invalid secret key name');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isPremadeSource', () => {
|
|
||||||
it('should return true for aws-secrets-manager', () => {
|
|
||||||
expect(SecretSourceService.isPremadeSource('aws-secrets-manager')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for aws-secret-manager (legacy alias)', () => {
|
|
||||||
expect(SecretSourceService.isPremadeSource('aws-secret-manager')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for aws-parameter-store', () => {
|
|
||||||
expect(SecretSourceService.isPremadeSource('aws-parameter-store')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for gcp-secret-manager', () => {
|
|
||||||
expect(SecretSourceService.isPremadeSource('gcp-secret-manager')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for azure-key-vault', () => {
|
|
||||||
expect(SecretSourceService.isPremadeSource('azure-key-vault')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for hashicorp-vault', () => {
|
|
||||||
expect(SecretSourceService.isPremadeSource('hashicorp-vault')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for hashicorp-vault-kv1', () => {
|
|
||||||
expect(SecretSourceService.isPremadeSource('hashicorp-vault-kv1')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for vault (short alias)', () => {
|
|
||||||
expect(SecretSourceService.isPremadeSource('vault')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for unknown source', () => {
|
|
||||||
expect(SecretSourceService.isPremadeSource('unknown-source')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getAvailableSources', () => {
|
|
||||||
it('should return all premade source names', () => {
|
|
||||||
const sources = SecretSourceService.getAvailableSources();
|
|
||||||
expect(sources).toContain('aws-secrets-manager');
|
|
||||||
expect(sources).toContain('aws-parameter-store');
|
|
||||||
expect(sources).toContain('gcp-secret-manager');
|
|
||||||
expect(sources).toContain('azure-key-vault');
|
|
||||||
expect(sources).toContain('hashicorp-vault');
|
|
||||||
expect(sources).toContain('hashicorp-vault-kv1');
|
|
||||||
expect(sources).toContain('vault');
|
|
||||||
expect(sources.length).toBeGreaterThanOrEqual(8);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolveSource', () => {
|
|
||||||
it('should resolve premade source by name', () => {
|
|
||||||
const source = SecretSourceService.resolveSource('aws-secrets-manager');
|
|
||||||
expect(source).toBeDefined();
|
|
||||||
expect(source!.name).toBe('aws-secrets-manager');
|
|
||||||
expect(source!.command).toContain('secretsmanager');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve custom command with {0} placeholder', () => {
|
|
||||||
const source = SecretSourceService.resolveSource('vault kv get -field=value secret/{0}');
|
|
||||||
expect(source).toBeDefined();
|
|
||||||
expect(source!.name).toBe('custom-command');
|
|
||||||
expect(source!.command).toContain('{0}');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve command with spaces as custom command', () => {
|
|
||||||
const source = SecretSourceService.resolveSource('my-tool get-secret');
|
|
||||||
expect(source).toBeDefined();
|
|
||||||
expect(source!.name).toBe('custom-command');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return undefined for unknown single-word source', () => {
|
|
||||||
const source = SecretSourceService.resolveSource('unknown');
|
|
||||||
expect(source).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fetchSecret', () => {
|
|
||||||
it('should run the command with {0} replaced by key', async () => {
|
|
||||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
|
||||||
OrchestratorSystem.Run.mockResolvedValue('my-secret-value');
|
|
||||||
|
|
||||||
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
|
||||||
const result = await SecretSourceService.fetchSecret(source, 'MY_SECRET');
|
|
||||||
|
|
||||||
expect(result).toBe('my-secret-value');
|
|
||||||
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('MY_SECRET'), false, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse JSON output when parseOutput is json-field', async () => {
|
|
||||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
|
||||||
OrchestratorSystem.Run.mockResolvedValue(JSON.stringify({ value: 'extracted-secret' }));
|
|
||||||
|
|
||||||
const source = {
|
|
||||||
name: 'test-source',
|
|
||||||
command: 'fetch {0}',
|
|
||||||
parseOutput: 'json-field' as const,
|
|
||||||
jsonField: 'value',
|
|
||||||
};
|
|
||||||
const result = await SecretSourceService.fetchSecret(source, 'KEY');
|
|
||||||
|
|
||||||
expect(result).toBe('extracted-secret');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fall back to raw output on invalid JSON with json-field mode', async () => {
|
|
||||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
|
||||||
OrchestratorSystem.Run.mockResolvedValue('not-json');
|
|
||||||
|
|
||||||
const source = {
|
|
||||||
name: 'test-source',
|
|
||||||
command: 'fetch {0}',
|
|
||||||
parseOutput: 'json-field' as const,
|
|
||||||
jsonField: 'value',
|
|
||||||
};
|
|
||||||
const result = await SecretSourceService.fetchSecret(source, 'KEY');
|
|
||||||
|
|
||||||
expect(result).toBe('not-json');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty string on command failure', async () => {
|
|
||||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
|
||||||
OrchestratorSystem.Run.mockRejectedValue(new Error('command not found'));
|
|
||||||
|
|
||||||
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
|
||||||
const result = await SecretSourceService.fetchSecret(source, 'KEY');
|
|
||||||
|
|
||||||
expect(result).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject keys with shell injection characters', async () => {
|
|
||||||
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
|
||||||
|
|
||||||
await expect(SecretSourceService.fetchSecret(source, '; rm -rf /')).rejects.toThrow('Invalid secret key name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject keys with command substitution', async () => {
|
|
||||||
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
|
||||||
|
|
||||||
await expect(SecretSourceService.fetchSecret(source, '$(whoami)')).rejects.toThrow('Invalid secret key name');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject keys with backtick command substitution', async () => {
|
|
||||||
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
|
||||||
|
|
||||||
await expect(SecretSourceService.fetchSecret(source, '`cat /etc/passwd`')).rejects.toThrow(
|
|
||||||
'Invalid secret key name',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept keys with valid path-like patterns', async () => {
|
|
||||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
|
||||||
OrchestratorSystem.Run.mockResolvedValue('secret-value');
|
|
||||||
|
|
||||||
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
|
||||||
const result = await SecretSourceService.fetchSecret(source, 'prod/database/password');
|
|
||||||
|
|
||||||
expect(result).toBe('secret-value');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should mask fetched secret values with core.setSecret', async () => {
|
|
||||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
|
||||||
OrchestratorSystem.Run.mockResolvedValue('super-secret-value');
|
|
||||||
|
|
||||||
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
|
||||||
await SecretSourceService.fetchSecret(source, 'MY_SECRET');
|
|
||||||
|
|
||||||
expect(core.setSecret).toHaveBeenCalledWith('super-secret-value');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not mask empty secret values', async () => {
|
|
||||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
|
||||||
OrchestratorSystem.Run.mockResolvedValue('');
|
|
||||||
|
|
||||||
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
|
||||||
await SecretSourceService.fetchSecret(source, 'MY_SECRET');
|
|
||||||
|
|
||||||
expect(core.setSecret).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should mask JSON-extracted secret values', async () => {
|
|
||||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
|
||||||
OrchestratorSystem.Run.mockResolvedValue(JSON.stringify({ value: 'json-secret' }));
|
|
||||||
|
|
||||||
const source = {
|
|
||||||
name: 'test-source',
|
|
||||||
command: 'fetch {0}',
|
|
||||||
parseOutput: 'json-field' as const,
|
|
||||||
jsonField: 'value',
|
|
||||||
};
|
|
||||||
await SecretSourceService.fetchSecret(source, 'KEY');
|
|
||||||
|
|
||||||
expect(core.setSecret).toHaveBeenCalledWith('json-secret');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fetchFromEnv', () => {
|
|
||||||
it('should return env var value when set', () => {
|
|
||||||
process.env.TEST_SECRET_KEY = 'env-value';
|
|
||||||
const result = SecretSourceService.fetchFromEnv('TEST_SECRET_KEY');
|
|
||||||
expect(result).toBe('env-value');
|
|
||||||
delete process.env.TEST_SECRET_KEY;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty string when env var is not set', () => {
|
|
||||||
const result = SecretSourceService.fetchFromEnv('NONEXISTENT_KEY_12345');
|
|
||||||
expect(result).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should mask env var values with core.setSecret', () => {
|
|
||||||
process.env.TEST_MASK_KEY = 'masked-env-value';
|
|
||||||
SecretSourceService.fetchFromEnv('TEST_MASK_KEY');
|
|
||||||
expect(core.setSecret).toHaveBeenCalledWith('masked-env-value');
|
|
||||||
delete process.env.TEST_MASK_KEY;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not mask empty env var values', () => {
|
|
||||||
const result = SecretSourceService.fetchFromEnv('NONEXISTENT_KEY_99999');
|
|
||||||
expect(result).toBe('');
|
|
||||||
expect(core.setSecret).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fetchAll', () => {
|
|
||||||
it('should fetch all keys from env source', async () => {
|
|
||||||
process.env.KEY_A = 'val-a';
|
|
||||||
process.env.KEY_B = 'val-b';
|
|
||||||
|
|
||||||
const results = await SecretSourceService.fetchAll('env', ['KEY_A', 'KEY_B']);
|
|
||||||
|
|
||||||
expect(results.KEY_A).toBe('val-a');
|
|
||||||
expect(results.KEY_B).toBe('val-b');
|
|
||||||
|
|
||||||
delete process.env.KEY_A;
|
|
||||||
delete process.env.KEY_B;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fetch all keys from premade source', async () => {
|
|
||||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
|
||||||
OrchestratorSystem.Run.mockResolvedValueOnce('secret-1').mockResolvedValueOnce('secret-2');
|
|
||||||
|
|
||||||
const results = await SecretSourceService.fetchAll('aws-parameter-store', ['param1', 'param2']);
|
|
||||||
|
|
||||||
expect(results.param1).toBe('secret-1');
|
|
||||||
expect(results.param2).toBe('secret-2');
|
|
||||||
expect(OrchestratorSystem.Run).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty results for unknown source', async () => {
|
|
||||||
const results = await SecretSourceService.fetchAll('unknown', ['key1']);
|
|
||||||
expect(results).toEqual({});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('loadFromYaml', () => {
|
|
||||||
it('should return empty array when file does not exist', () => {
|
|
||||||
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
|
||||||
const result = SecretSourceService.loadFromYaml('/nonexistent.yml');
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse valid YAML source definitions', () => {
|
|
||||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
|
||||||
(mockFs.readFileSync as jest.Mock).mockReturnValue(`
|
|
||||||
sources:
|
|
||||||
- name: my-vault
|
|
||||||
command: 'vault kv get -field=value secret/{0}'
|
|
||||||
- name: my-api
|
|
||||||
command: 'curl -s https://api.example.com/{0}'
|
|
||||||
parseOutput: json-field
|
|
||||||
jsonField: secret_value
|
|
||||||
`);
|
|
||||||
|
|
||||||
const result = SecretSourceService.loadFromYaml('/sources.yml');
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result[0].name).toBe('my-vault');
|
|
||||||
expect(result[0].command).toBe('vault kv get -field=value secret/{0}');
|
|
||||||
expect(result[1].name).toBe('my-api');
|
|
||||||
expect(result[1].parseOutput).toBe('json-field');
|
|
||||||
expect(result[1].jsonField).toBe('secret_value');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle YAML with single source', () => {
|
|
||||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
|
||||||
(mockFs.readFileSync as jest.Mock).mockReturnValue(`
|
|
||||||
sources:
|
|
||||||
- name: simple
|
|
||||||
command: echo {0}
|
|
||||||
`);
|
|
||||||
|
|
||||||
const result = SecretSourceService.loadFromYaml('/simple.yml');
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].name).toBe('simple');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty array on parse error', () => {
|
|
||||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
|
||||||
(mockFs.readFileSync as jest.Mock).mockImplementation(() => {
|
|
||||||
throw new Error('Permission denied');
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = SecretSourceService.loadFromYaml('/error.yml');
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('premade source commands', () => {
|
|
||||||
it('aws-secrets-manager uses --query SecretString', () => {
|
|
||||||
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
|
|
||||||
expect(source.command).toContain('--query SecretString');
|
|
||||||
expect(source.command).toContain('--output text');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('aws-parameter-store uses --with-decryption', () => {
|
|
||||||
const source = SecretSourceService.resolveSource('aws-parameter-store')!;
|
|
||||||
expect(source.command).toContain('--with-decryption');
|
|
||||||
expect(source.command).toContain('--query Parameter.Value');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gcp-secret-manager uses latest version', () => {
|
|
||||||
const source = SecretSourceService.resolveSource('gcp-secret-manager')!;
|
|
||||||
expect(source.command).toContain('latest');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('azure-key-vault uses AZURE_VAULT_NAME env var', () => {
|
|
||||||
const source = SecretSourceService.resolveSource('azure-key-vault')!;
|
|
||||||
expect(source.command).toContain('$AZURE_VAULT_NAME');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hashicorp-vault uses vault kv get with VAULT_MOUNT', () => {
|
|
||||||
const source = SecretSourceService.resolveSource('hashicorp-vault')!;
|
|
||||||
expect(source.command).toContain('vault kv get');
|
|
||||||
expect(source.command).toContain('VAULT_MOUNT');
|
|
||||||
expect(source.command).toContain('-field=value');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hashicorp-vault-kv1 uses vault read for KV v1', () => {
|
|
||||||
const source = SecretSourceService.resolveSource('hashicorp-vault-kv1')!;
|
|
||||||
expect(source.command).toContain('vault read');
|
|
||||||
expect(source.command).toContain('-field=value');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('vault alias resolves to same command as hashicorp-vault', () => {
|
|
||||||
const vault = SecretSourceService.resolveSource('vault')!;
|
|
||||||
const hashicorpVault = SecretSourceService.resolveSource('hashicorp-vault')!;
|
|
||||||
expect(vault.command).toBe(hashicorpVault.command);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import * as core from '@actions/core';
|
|
||||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
|
||||||
import { OrchestratorSystem } from '../core/orchestrator-system';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A secret source definition: how to fetch a secret value by key.
|
|
||||||
*/
|
|
||||||
export interface SecretSourceDefinition {
|
|
||||||
name: string;
|
|
||||||
command: string;
|
|
||||||
parseOutput?: 'raw' | 'json-field';
|
|
||||||
jsonField?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that a secret key name contains only safe characters.
|
|
||||||
* Prevents shell injection when keys are interpolated into commands.
|
|
||||||
*
|
|
||||||
* Allowed characters: alphanumeric, hyphens, underscores, dots, forward slashes.
|
|
||||||
*
|
|
||||||
* @param key - The secret key name to validate
|
|
||||||
* @returns The validated key (unchanged)
|
|
||||||
* @throws Error if the key contains disallowed characters
|
|
||||||
*/
|
|
||||||
export function validateSecretKey(key: string): string {
|
|
||||||
if (!/^[a-zA-Z0-9\-_./]+$/.test(key)) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid secret key name: "${key}". Keys may only contain alphanumeric characters, hyphens, underscores, dots, and forward slashes.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mask a secret value so it does not appear in GitHub Actions logs.
|
|
||||||
* Empty or whitespace-only values are skipped (core.setSecret would be a no-op).
|
|
||||||
*/
|
|
||||||
function maskSecretValue(value: string): void {
|
|
||||||
if (value.trim().length > 0) {
|
|
||||||
core.setSecret(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Premade secret sources and custom YAML-based secret source definitions.
|
|
||||||
*
|
|
||||||
* Premade sources are string shortcuts that expand to shell commands:
|
|
||||||
* - `aws-secrets-manager` -- AWS Secrets Manager
|
|
||||||
* - `aws-parameter-store` -- AWS Systems Manager Parameter Store
|
|
||||||
* - `gcp-secret-manager` -- Google Cloud Secret Manager
|
|
||||||
* - `azure-key-vault` -- Azure Key Vault (requires AZURE_VAULT_NAME env var)
|
|
||||||
* - `hashicorp-vault` -- HashiCorp Vault KV v2 (requires VAULT_ADDR, optionally VAULT_MOUNT)
|
|
||||||
* - `hashicorp-vault-kv1` -- HashiCorp Vault KV v1 (requires VAULT_ADDR, optionally VAULT_MOUNT)
|
|
||||||
* - `env` -- Read from environment variables (no shell command needed)
|
|
||||||
*
|
|
||||||
* Custom YAML format:
|
|
||||||
* sources:
|
|
||||||
* - name: my-vault
|
|
||||||
* command: 'vault kv get -field=value secret/{0}'
|
|
||||||
* - name: my-api
|
|
||||||
* command: 'curl -s https://secrets.example.com/api/{0}'
|
|
||||||
* parseOutput: json-field
|
|
||||||
* jsonField: value
|
|
||||||
*/
|
|
||||||
export class SecretSourceService {
|
|
||||||
private static readonly premadeSources: Record<string, SecretSourceDefinition> = {
|
|
||||||
'aws-secrets-manager': {
|
|
||||||
name: 'aws-secrets-manager',
|
|
||||||
command: 'aws secretsmanager get-secret-value --secret-id {0} --query SecretString --output text',
|
|
||||||
parseOutput: 'raw',
|
|
||||||
},
|
|
||||||
'aws-secret-manager': {
|
|
||||||
// Alias for backward compatibility (original name in inputPullCommand)
|
|
||||||
name: 'aws-secret-manager',
|
|
||||||
command: 'aws secretsmanager get-secret-value --secret-id {0} --query SecretString --output text',
|
|
||||||
parseOutput: 'raw',
|
|
||||||
},
|
|
||||||
'aws-parameter-store': {
|
|
||||||
name: 'aws-parameter-store',
|
|
||||||
command: 'aws ssm get-parameter --name {0} --with-decryption --query Parameter.Value --output text',
|
|
||||||
parseOutput: 'raw',
|
|
||||||
},
|
|
||||||
'gcp-secret-manager': {
|
|
||||||
name: 'gcp-secret-manager',
|
|
||||||
command: 'gcloud secrets versions access latest --secret="{0}"',
|
|
||||||
parseOutput: 'raw',
|
|
||||||
},
|
|
||||||
'azure-key-vault': {
|
|
||||||
name: 'azure-key-vault',
|
|
||||||
command: 'az keyvault secret show --vault-name "$AZURE_VAULT_NAME" --name {0} --query value --output tsv',
|
|
||||||
parseOutput: 'raw',
|
|
||||||
},
|
|
||||||
'hashicorp-vault': {
|
|
||||||
// HashiCorp Vault KV v2 (default). Requires VAULT_ADDR env var.
|
|
||||||
// Optionally set VAULT_MOUNT to override the mount path (default: 'secret').
|
|
||||||
// Authentication is handled by VAULT_TOKEN or other Vault auth env vars.
|
|
||||||
name: 'hashicorp-vault',
|
|
||||||
command: 'vault kv get -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
|
|
||||||
parseOutput: 'raw',
|
|
||||||
},
|
|
||||||
'hashicorp-vault-kv1': {
|
|
||||||
// HashiCorp Vault KV v1. Requires VAULT_ADDR env var.
|
|
||||||
// Optionally set VAULT_MOUNT to override the mount path (default: 'secret').
|
|
||||||
name: 'hashicorp-vault-kv1',
|
|
||||||
command: 'vault read -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
|
|
||||||
parseOutput: 'raw',
|
|
||||||
},
|
|
||||||
vault: {
|
|
||||||
// Short alias for hashicorp-vault (KV v2)
|
|
||||||
name: 'vault',
|
|
||||||
command: 'vault kv get -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
|
|
||||||
parseOutput: 'raw',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a source name is a known premade source.
|
|
||||||
*/
|
|
||||||
static isPremadeSource(sourceName: string): boolean {
|
|
||||||
return sourceName in SecretSourceService.premadeSources;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the list of available premade source names.
|
|
||||||
*/
|
|
||||||
static getAvailableSources(): string[] {
|
|
||||||
return Object.keys(SecretSourceService.premadeSources);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a source name to a SecretSourceDefinition.
|
|
||||||
*
|
|
||||||
* - If the name matches a premade source, returns that definition.
|
|
||||||
* - If it looks like a shell command (contains spaces or {0}), wraps it as a custom command.
|
|
||||||
* - Otherwise, returns undefined.
|
|
||||||
*/
|
|
||||||
static resolveSource(sourceName: string): SecretSourceDefinition | undefined {
|
|
||||||
// Check premade sources
|
|
||||||
if (SecretSourceService.isPremadeSource(sourceName)) {
|
|
||||||
return SecretSourceService.premadeSources[sourceName];
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it contains a placeholder or spaces, treat it as a raw command
|
|
||||||
if (sourceName.includes('{0}') || sourceName.includes(' ')) {
|
|
||||||
return {
|
|
||||||
name: 'custom-command',
|
|
||||||
command: sourceName,
|
|
||||||
parseOutput: 'raw',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load custom secret source definitions from a YAML file.
|
|
||||||
*
|
|
||||||
* Expected format:
|
|
||||||
* sources:
|
|
||||||
* - name: my-source
|
|
||||||
* command: 'my-tool get-secret {0}'
|
|
||||||
* - name: my-api
|
|
||||||
* command: 'curl -s https://api.example.com/secrets/{0}'
|
|
||||||
* parseOutput: json-field
|
|
||||||
* jsonField: value
|
|
||||||
*/
|
|
||||||
static loadFromYaml(filePath: string): SecretSourceDefinition[] {
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
OrchestratorLogger.logWarning(`Secret source YAML not found: ${filePath}`);
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, 'utf8');
|
|
||||||
const parsed = SecretSourceService.parseSimpleYaml(content);
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.logWarning(`Failed to parse secret source YAML: ${error.message}`);
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a secret value using the given source definition.
|
|
||||||
*
|
|
||||||
* Validates the key against an allowlist pattern before interpolating it
|
|
||||||
* into the command string to prevent shell injection. The fetched secret
|
|
||||||
* value is masked via core.setSecret() so it does not leak in logs.
|
|
||||||
*
|
|
||||||
* @param source - The secret source definition to use
|
|
||||||
* @param key - The secret key to fetch
|
|
||||||
* @returns The secret value, or empty string on failure
|
|
||||||
*/
|
|
||||||
static async fetchSecret(source: SecretSourceDefinition, key: string): Promise<string> {
|
|
||||||
// Validate the key to prevent shell injection
|
|
||||||
validateSecretKey(key);
|
|
||||||
|
|
||||||
const command = source.command.replace(/\{0\}/g, key);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const output = await OrchestratorSystem.Run(command, false, true);
|
|
||||||
|
|
||||||
let value: string;
|
|
||||||
|
|
||||||
if (source.parseOutput === 'json-field' && source.jsonField) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(output);
|
|
||||||
value = parsed[source.jsonField] || '';
|
|
||||||
} catch {
|
|
||||||
OrchestratorLogger.logWarning(`Failed to parse JSON output from ${source.name} for key ${key}`);
|
|
||||||
value = output.trim();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
value = output.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mask the secret value so it does not appear in GitHub Actions logs
|
|
||||||
maskSecretValue(value);
|
|
||||||
|
|
||||||
return value;
|
|
||||||
} catch (error: any) {
|
|
||||||
OrchestratorLogger.logWarning(`Failed to fetch secret '${key}' from ${source.name}: ${error.message}`);
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a secret from an environment variable. No shell command needed.
|
|
||||||
* The value is masked via core.setSecret() so it does not leak in logs.
|
|
||||||
*/
|
|
||||||
static fetchFromEnv(key: string): string {
|
|
||||||
const value = process.env[key] || '';
|
|
||||||
maskSecretValue(value);
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a source name and fetch all secrets from it.
|
|
||||||
*
|
|
||||||
* @param sourceName - Premade source name, shell command, or 'env'
|
|
||||||
* @param keys - List of secret keys to fetch
|
|
||||||
* @returns Map of key -> value
|
|
||||||
*/
|
|
||||||
static async fetchAll(sourceName: string, keys: string[]): Promise<Record<string, string>> {
|
|
||||||
const results: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (sourceName === 'env') {
|
|
||||||
for (const key of keys) {
|
|
||||||
results[key] = SecretSourceService.fetchFromEnv(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = SecretSourceService.resolveSource(sourceName);
|
|
||||||
if (!source) {
|
|
||||||
OrchestratorLogger.logWarning(
|
|
||||||
`Unknown secret source '${sourceName}'. Available sources: ${SecretSourceService.getAvailableSources().join(
|
|
||||||
', ',
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
OrchestratorLogger.log(`Fetching ${keys.length} secret(s) from ${source.name}`);
|
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
results[key] = await SecretSourceService.fetchSecret(source, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple YAML parser for secret source definitions.
|
|
||||||
* Handles the specific structure we expect without requiring a YAML library.
|
|
||||||
*/
|
|
||||||
private static parseSimpleYaml(content: string): SecretSourceDefinition[] {
|
|
||||||
const definitions: SecretSourceDefinition[] = [];
|
|
||||||
const lines = content.split('\n');
|
|
||||||
let current: Partial<SecretSourceDefinition> | null = null;
|
|
||||||
|
|
||||||
for (const rawLine of lines) {
|
|
||||||
const line = rawLine.replace(/\r$/, '');
|
|
||||||
const trimmed = line.trim();
|
|
||||||
|
|
||||||
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
|
||||||
|
|
||||||
if (trimmed === '- name:' || trimmed.startsWith('- name:')) {
|
|
||||||
if (current?.name && current?.command) {
|
|
||||||
definitions.push(current as SecretSourceDefinition);
|
|
||||||
}
|
|
||||||
|
|
||||||
current = {
|
|
||||||
name: trimmed
|
|
||||||
.replace('- name:', '')
|
|
||||||
.trim()
|
|
||||||
.replace(/^['"]|['"]$/g, ''),
|
|
||||||
parseOutput: 'raw',
|
|
||||||
};
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current && trimmed.startsWith('command:')) {
|
|
||||||
current.command = trimmed
|
|
||||||
.replace('command:', '')
|
|
||||||
.trim()
|
|
||||||
.replace(/^['"]|['"]$/g, '');
|
|
||||||
} else if (current && trimmed.startsWith('parseOutput:')) {
|
|
||||||
const value = trimmed
|
|
||||||
.replace('parseOutput:', '')
|
|
||||||
.trim()
|
|
||||||
.replace(/^['"]|['"]$/g, '');
|
|
||||||
current.parseOutput = value as 'raw' | 'json-field';
|
|
||||||
} else if (current && trimmed.startsWith('jsonField:')) {
|
|
||||||
current.jsonField = trimmed
|
|
||||||
.replace('jsonField:', '')
|
|
||||||
.trim()
|
|
||||||
.replace(/^['"]|['"]$/g, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current?.name && current?.command) {
|
|
||||||
definitions.push(current as SecretSourceDefinition);
|
|
||||||
}
|
|
||||||
|
|
||||||
return definitions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,6 +27,7 @@ printenv
|
|||||||
git config --global advice.detachedHead false
|
git config --global advice.detachedHead false
|
||||||
git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"
|
git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"
|
||||||
git config --global filter.lfs.process "git-lfs filter-process --skip"
|
git config --global filter.lfs.process "git-lfs filter-process --skip"
|
||||||
|
${OrchestratorFolders.gitAuthConfigScript}
|
||||||
BRANCH="${Orchestrator.buildParameters.orchestratorBranch}"
|
BRANCH="${Orchestrator.buildParameters.orchestratorBranch}"
|
||||||
REPO="${OrchestratorFolders.unityBuilderRepoUrl}"
|
REPO="${OrchestratorFolders.unityBuilderRepoUrl}"
|
||||||
if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
|
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(
|
const commands = `mkdir -p ${OrchestratorFolders.ToLinuxFolder(
|
||||||
OrchestratorFolders.builderPathAbsolute,
|
OrchestratorFolders.builderPathAbsolute,
|
||||||
)}
|
)}
|
||||||
|
${OrchestratorFolders.gitAuthConfigScript}
|
||||||
BRANCH="${Orchestrator.buildParameters.orchestratorBranch}"
|
BRANCH="${Orchestrator.buildParameters.orchestratorBranch}"
|
||||||
REPO="${OrchestratorFolders.unityBuilderRepoUrl}"
|
REPO="${OrchestratorFolders.unityBuilderRepoUrl}"
|
||||||
DEST="${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.builderPathAbsolute)}"
|
DEST="${OrchestratorFolders.ToLinuxFolder(OrchestratorFolders.builderPathAbsolute)}"
|
||||||
|
|||||||
Reference in New Issue
Block a user