diff --git a/action.yml b/action.yml index 64b6ad32..065ad54c 100644 --- a/action.yml +++ b/action.yml @@ -194,6 +194,14 @@ inputs: description: '[Orchestrator] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must 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, 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: default: 'false' required: false diff --git a/src/model/orchestrator/options/orchestrator-options.ts b/src/model/orchestrator/options/orchestrator-options.ts index e68160cc..101e6c5b 100644 --- a/src/model/orchestrator/options/orchestrator-options.ts +++ b/src/model/orchestrator/options/orchestrator-options.ts @@ -190,6 +190,10 @@ class OrchestratorOptions { return OrchestratorOptions.getInput('pullInputList')?.split(`,`) || []; } + static get secretSource(): string { + return OrchestratorOptions.getInput('secretSource') || ''; + } + static get inputPullCommand(): string { const value = OrchestratorOptions.getInput('inputPullCommand'); diff --git a/src/model/orchestrator/options/orchestrator-query-override.ts b/src/model/orchestrator/options/orchestrator-query-override.ts index 19d7c6f7..270f0bd5 100644 --- a/src/model/orchestrator/options/orchestrator-query-override.ts +++ b/src/model/orchestrator/options/orchestrator-query-override.ts @@ -1,6 +1,8 @@ import Input from '../../input'; import { GenericInputReader } from '../../input-readers/generic-input-reader'; import OrchestratorOptions from './orchestrator-options'; +import { SecretSourceService } from '../services/secrets/secret-source-service'; +import OrchestratorLogger from '../services/core/orchestrator-logger'; const formatFunction = (value: string, arguments_: any[]) => { for (const element of arguments_) { @@ -13,8 +15,6 @@ const formatFunction = (value: string, arguments_: any[]) => { class OrchestratorQueryOverride { static queryOverrides: { [key: string]: string } | undefined; - // TODO accept premade secret sources or custom secret source definition yamls - public static query(key: string, alternativeKey: string) { if (OrchestratorQueryOverride.queryOverrides && OrchestratorQueryOverride.queryOverrides[key] !== undefined) { return OrchestratorQueryOverride.queryOverrides[key]; @@ -54,9 +54,50 @@ class OrchestratorQueryOverride { ); } + /** + * 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() { const queries = OrchestratorOptions.pullInputList; 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) { if (OrchestratorQueryOverride.shouldUseOverride(element)) { OrchestratorQueryOverride.queryOverrides[element] = await OrchestratorQueryOverride.queryOverride(element); diff --git a/src/model/orchestrator/services/secrets/secret-source-service.test.ts b/src/model/orchestrator/services/secrets/secret-source-service.test.ts new file mode 100644 index 00000000..02b1e356 --- /dev/null +++ b/src/model/orchestrator/services/secrets/secret-source-service.test.ts @@ -0,0 +1,270 @@ +import fs from 'node:fs'; +import { SecretSourceService } from './secret-source-service'; + +jest.mock('node:fs'); +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; + +describe('SecretSourceService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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 false for unknown source', () => { + expect(SecretSourceService.isPremadeSource('hashicorp-vault')).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.length).toBeGreaterThanOrEqual(5); + }); + }); + + 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(''); + }); + }); + + 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(''); + }); + }); + + 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'); + }); + }); +}); diff --git a/src/model/orchestrator/services/secrets/secret-source-service.ts b/src/model/orchestrator/services/secrets/secret-source-service.ts new file mode 100644 index 00000000..e8afe064 --- /dev/null +++ b/src/model/orchestrator/services/secrets/secret-source-service.ts @@ -0,0 +1,256 @@ +import fs from 'node:fs'; +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; +} + +/** + * 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) + * - `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 = { + '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', + }, + }; + + /** + * 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. + * + * @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 { + const command = source.command.replace(/\{0\}/g, key); + + try { + const output = await OrchestratorSystem.Run(command, false, true); + + if (source.parseOutput === 'json-field' && source.jsonField) { + try { + const parsed = JSON.parse(output); + + return parsed[source.jsonField] || ''; + } catch { + OrchestratorLogger.logWarning(`Failed to parse JSON output from ${source.name} for key ${key}`); + + return output.trim(); + } + } + + return output.trim(); + } 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. + */ + static fetchFromEnv(key: string): string { + return process.env[key] || ''; + } + + /** + * 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> { + const results: Record = {}; + + 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 | 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; + } +}