From b3137c11c153089b1a95edac7485469a025569b7 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 7 Aug 2026 12:30:37 +0800 Subject: [PATCH 1/5] fix(security): harden agent provisioning execution Signed-off-by: Mark --- .../src/agent-provisioning.service.spec.ts | 88 +++++++++++++ .../src/agent-provisioning.service.ts | 117 ++++++++++++------ 2 files changed, 166 insertions(+), 39 deletions(-) create mode 100644 apps/agent-provisioning/src/agent-provisioning.service.spec.ts diff --git a/apps/agent-provisioning/src/agent-provisioning.service.spec.ts b/apps/agent-provisioning/src/agent-provisioning.service.spec.ts new file mode 100644 index 000000000..dfbde0601 --- /dev/null +++ b/apps/agent-provisioning/src/agent-provisioning.service.spec.ts @@ -0,0 +1,88 @@ +const mockExecFile = jest.fn(); +const mockReadFile = jest.fn(); +const mockAccess = jest.fn(); + +jest.mock('child_process', () => ({ execFile: mockExecFile })); +jest.mock('util', () => ({ promisify: jest.fn(() => mockExecFile) })); +jest.mock('fs', () => ({ promises: { access: mockAccess, readFile: mockReadFile } })); + +import { AgentType } from '@credebl/enum/enum'; +import { AgentProvisioningService } from './agent-provisioning.service'; + +const payload = { + orgId: 'org-123', + externalIp: '127.0.0.1', + walletName: 'wallet', + walletPassword: 'wallet-secret', + seed: 'seed', + webhookEndpoint: 'https://example.test/webhook', + walletStorageHost: 'postgres', + walletStoragePort: '5432', + walletStorageUser: 'user', + walletStoragePassword: 'storage-secret', + internalIp: '127.0.0.1', + containerName: 'issuer-agent', + agentType: AgentType.AFJ, + orgName: 'Organization', + indyLedger: '[]', + protocol: 'http', + credoImage: 'credo:latest', + tenant: false, + inboundEndpoint: '127.0.0.1' +}; + +describe('AgentProvisioningService', () => { + const logger = { log: jest.fn(), error: jest.fn() }; + const service = new AgentProvisioningService(logger as never); + const savedEnv = { ...process.env }; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...savedEnv, + AFJ_AGENT_SPIN_UP: '/apps/agent-provisioning/AFJ/scripts/start_agent.sh', + AFJ_AGENT_ENDPOINT_PATH: '/apps/agent-provisioning/AFJ/endpoints/', + SCHEMA_FILE_SERVER_URL: 'https://schema.example', + AGENT_API_KEY: 'agent-key', + AWS_ACCOUNT_ID: 'account', + S3_BUCKET_ARN: 'bucket', + CLUSTER_NAME: 'cluster', + TASKDEFINITION_FAMILY: 'family', + ADMIN_TG_ARN: 'admin-tg', + INBOUND_TG_ARN: 'inbound-tg', + FILESYSTEMID: 'filesystem', + ECS_SUBNET_ID: 'subnet', + ECS_SECURITY_GROUP_ID: 'security-group' + }; + }); + + afterAll(() => { + process.env = savedEnv; + }); + + it('executes the provisioning script without a shell and returns the generated endpoint', async () => { + mockExecFile.mockResolvedValue({ stdout: '', stderr: '' }); + mockReadFile.mockResolvedValue('{"CONTROLLER_ENDPOINT":"https://agent.example"}'); + + await expect(service.walletProvision(payload)).resolves.toEqual({ agentEndPoint: 'https://agent.example' }); + expect(mockExecFile).toHaveBeenCalledWith( + expect.stringContaining('/apps/agent-provisioning/AFJ/scripts/start_agent.sh'), + expect.arrayContaining([payload.orgId, payload.containerName, payload.walletPassword]), + expect.objectContaining({ timeout: 120000 }) + ); + }); + + it('rejects identifiers that could escape the generated endpoint path before executing a script', async () => { + await expect(service.walletProvision({ ...payload, containerName: '../../tmp/owned' })).rejects.toThrow( + 'containerName contains unsafe characters' + ); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it('propagates a provisioning script failure instead of attempting to read an endpoint file', async () => { + mockExecFile.mockRejectedValue(new Error('script failed')); + + await expect(service.walletProvision(payload)).rejects.toBeDefined(); + expect(mockReadFile).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/agent-provisioning/src/agent-provisioning.service.ts b/apps/agent-provisioning/src/agent-provisioning.service.ts index 3fe825e47..0756be335 100644 --- a/apps/agent-provisioning/src/agent-provisioning.service.ts +++ b/apps/agent-provisioning/src/agent-provisioning.service.ts @@ -1,12 +1,17 @@ import * as dotenv from 'dotenv'; -import * as fs from 'fs'; +import { promises as fs } from 'fs'; import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { AgentType } from '@credebl/enum/enum'; import { IWalletProvision } from './interface/agent-provisioning.interfaces'; import { RpcException } from '@nestjs/microservices'; -import { exec } from 'child_process'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); +const SAFE_FILE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const AGENT_PROVISION_TIMEOUT_MS = 120_000; dotenv.config(); @@ -41,49 +46,77 @@ export class AgentProvisioningService { inboundEndpoint } = payload; if (agentType === AgentType.AFJ) { - // The wallet provision command is used to invoke a shell script - const walletProvision = `${process.cwd() + process.env.AFJ_AGENT_SPIN_UP} ${orgId} "${externalIp}" "${walletName}" "${walletPassword}" ${seed} ${webhookEndpoint} ${walletStorageHost} ${walletStoragePort} ${walletStorageUser} ${walletStoragePassword} ${containerName} ${protocol} ${tenant} ${credoImage} "${indyLedger}" ${inboundEndpoint} ${process.env.SCHEMA_FILE_SERVER_URL} ${process.env.AGENT_API_KEY} ${process.env.AWS_ACCOUNT_ID} ${process.env.S3_BUCKET_ARN} ${process.env.CLUSTER_NAME} ${process.env.TASKDEFINITION_FAMILY} ${process.env.ADMIN_TG_ARN} ${process.env.INBOUND_TG_ARN} ${process.env.FILESYSTEMID} ${process.env.ECS_SUBNET_ID} ${process.env.ECS_SECURITY_GROUP_ID}`; - const spinUpResponse: object = new Promise(async (resolve) => { - await exec(walletProvision, async (err, stdout, stderr) => { - this.logger.log(`shell script output: ${stdout}`); - if (stderr) { - this.logger.log(`shell script error: ${stderr}`); - } + this.assertSafeFileIdentifier(orgId, 'orgId'); + this.assertSafeFileIdentifier(containerName, 'containerName'); - const agentEndpointPath = `${process.cwd()}${process.env.AFJ_AGENT_ENDPOINT_PATH}${orgId}_${containerName}.json`; + const spinUpScript = process.env.AFJ_AGENT_SPIN_UP; + const endpointDirectory = process.env.AFJ_AGENT_ENDPOINT_PATH; + if (!spinUpScript || !endpointDirectory) { + throw new Error('AFJ_AGENT_SPIN_UP and AFJ_AGENT_ENDPOINT_PATH must be configured'); + } - const agentEndPointExists = await this.checkFileExistence(agentEndpointPath); + const requiredEnvironment = [ + 'SCHEMA_FILE_SERVER_URL', + 'AGENT_API_KEY', + 'AWS_ACCOUNT_ID', + 'S3_BUCKET_ARN', + 'CLUSTER_NAME', + 'TASKDEFINITION_FAMILY', + 'ADMIN_TG_ARN', + 'INBOUND_TG_ARN', + 'FILESYSTEMID', + 'ECS_SUBNET_ID', + 'ECS_SECURITY_GROUP_ID' + ]; + const missingEnvironment = requiredEnvironment.filter((name) => !process.env[name]); + if (missingEnvironment.length) { + throw new Error(`Missing provisioning configuration: ${missingEnvironment.join(', ')}`); + } - let agentEndPoint; + await execFileAsync( + `${process.cwd()}${spinUpScript}`, + [ + orgId, + externalIp, + walletName, + walletPassword, + seed, + webhookEndpoint, + walletStorageHost, + walletStoragePort, + walletStorageUser, + walletStoragePassword, + containerName, + protocol, + String(tenant), + credoImage, + indyLedger, + inboundEndpoint, + ...requiredEnvironment.map((name) => process.env[name] as string) + ], + { timeout: AGENT_PROVISION_TIMEOUT_MS, maxBuffer: 1024 * 1024 } + ); - if (agentEndPointExists) { - this.logger.log('Agent endpoint file exists'); - agentEndPoint = await fs.readFileSync(agentEndpointPath, 'utf8'); - // Proceed with accessing the files if needed - } else { - this.logger.log('Agent endpoint file does not exist'); - throw new NotFoundException(`Agent endpoint file does not exist: ${agentEndpointPath}`); - } + const agentEndpointPath = `${process.cwd()}${endpointDirectory}${orgId}_${containerName}.json`; + const agentEndPointExists = await this.checkFileExistence(agentEndpointPath); + if (!agentEndPointExists) { + throw new NotFoundException(`Agent endpoint file does not exist: ${agentEndpointPath}`); + } - let parsedEndpoint; - try { - parsedEndpoint = JSON.parse(agentEndPoint); - } catch (parseError) { - this.logger.error(`Failed to parse agent endpoint file: ${parseError.message}`); - throw new Error(`Invalid JSON in agent endpoint file: ${agentEndpointPath}`); - } + const agentEndPoint = await fs.readFile(agentEndpointPath, 'utf8'); + let parsedEndpoint: { CONTROLLER_ENDPOINT?: string }; + try { + parsedEndpoint = JSON.parse(agentEndPoint); + } catch (parseError) { + this.logger.error(`Failed to parse agent endpoint file: ${parseError.message}`); + throw new Error(`Invalid JSON in agent endpoint file: ${agentEndpointPath}`); + } - if (!parsedEndpoint.CONTROLLER_ENDPOINT) { - this.logger.error('CONTROLLER_ENDPOINT key missing in agent endpoint file'); - throw new Error(`Missing CONTROLLER_ENDPOINT in: ${agentEndpointPath}`); - } + if (!parsedEndpoint.CONTROLLER_ENDPOINT) { + throw new Error(`Missing CONTROLLER_ENDPOINT in: ${agentEndpointPath}`); + } - resolve({ - agentEndPoint: parsedEndpoint.CONTROLLER_ENDPOINT - }); - }); - }); - return spinUpResponse; + return { agentEndPoint: parsedEndpoint.CONTROLLER_ENDPOINT }; } else if (agentType === AgentType.ACAPY) { // TODO: ACA-PY Agent Spin-Up } @@ -95,10 +128,16 @@ export class AgentProvisioningService { async checkFileExistence(filePath: string): Promise { try { - await fs.accessSync(filePath); + await fs.access(filePath); return true; // File exists } catch (error) { return false; // File does not exist } } + + private assertSafeFileIdentifier(value: string, field: string): void { + if (!SAFE_FILE_IDENTIFIER.test(value)) { + throw new Error(`${field} contains unsafe characters`); + } + } } From e7ca8442b6b44e771619d65667cafaf05d1eea75 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 12 Aug 2026 10:24:49 +0800 Subject: [PATCH 2/5] fix(security): sanitize agent provisioning failures Signed-off-by: Mark --- .../src/agent-provisioning.service.spec.ts | 25 +++++++++++++++++-- .../src/agent-provisioning.service.ts | 12 +++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/apps/agent-provisioning/src/agent-provisioning.service.spec.ts b/apps/agent-provisioning/src/agent-provisioning.service.spec.ts index dfbde0601..8c410e2d0 100644 --- a/apps/agent-provisioning/src/agent-provisioning.service.spec.ts +++ b/apps/agent-provisioning/src/agent-provisioning.service.spec.ts @@ -79,10 +79,31 @@ describe('AgentProvisioningService', () => { expect(mockExecFile).not.toHaveBeenCalled(); }); + it('rejects non-string identifiers before executing a script', async () => { + await expect(service.walletProvision({ ...payload, orgId: 123 as unknown as string })).rejects.toThrow( + 'orgId contains unsafe characters' + ); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it.each([{}, 1, [], '', ' '])('rejects invalid CONTROLLER_ENDPOINT values', async (endpoint) => { + mockExecFile.mockResolvedValue({ stdout: '', stderr: '' }); + mockReadFile.mockResolvedValue(JSON.stringify({ CONTROLLER_ENDPOINT: endpoint })); + + await expect(service.walletProvision(payload)).rejects.toThrow('Missing CONTROLLER_ENDPOINT'); + }); + it('propagates a provisioning script failure instead of attempting to read an endpoint file', async () => { - mockExecFile.mockRejectedValue(new Error('script failed')); + const failure = Object.assign(new Error('script failed'), { + stdout: 'stdout-secret', + stderr: 'stderr-secret' + }); + mockExecFile.mockRejectedValue(failure); - await expect(service.walletProvision(payload)).rejects.toBeDefined(); + await expect(service.walletProvision(payload)).rejects.toThrow('Agent provisioning script failed'); expect(mockReadFile).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + expect(JSON.stringify(logger.error.mock.calls)).not.toContain('stdout-secret'); + expect(JSON.stringify(logger.error.mock.calls)).not.toContain('stderr-secret'); }); }); diff --git a/apps/agent-provisioning/src/agent-provisioning.service.ts b/apps/agent-provisioning/src/agent-provisioning.service.ts index 0756be335..13421cbcf 100644 --- a/apps/agent-provisioning/src/agent-provisioning.service.ts +++ b/apps/agent-provisioning/src/agent-provisioning.service.ts @@ -95,7 +95,9 @@ export class AgentProvisioningService { ...requiredEnvironment.map((name) => process.env[name] as string) ], { timeout: AGENT_PROVISION_TIMEOUT_MS, maxBuffer: 1024 * 1024 } - ); + ).catch(() => { + throw new Error('Agent provisioning script failed'); + }); const agentEndpointPath = `${process.cwd()}${endpointDirectory}${orgId}_${containerName}.json`; const agentEndPointExists = await this.checkFileExistence(agentEndpointPath); @@ -104,7 +106,7 @@ export class AgentProvisioningService { } const agentEndPoint = await fs.readFile(agentEndpointPath, 'utf8'); - let parsedEndpoint: { CONTROLLER_ENDPOINT?: string }; + let parsedEndpoint: { CONTROLLER_ENDPOINT?: unknown }; try { parsedEndpoint = JSON.parse(agentEndPoint); } catch (parseError) { @@ -112,7 +114,7 @@ export class AgentProvisioningService { throw new Error(`Invalid JSON in agent endpoint file: ${agentEndpointPath}`); } - if (!parsedEndpoint.CONTROLLER_ENDPOINT) { + if ('string' !== typeof parsedEndpoint.CONTROLLER_ENDPOINT || !parsedEndpoint.CONTROLLER_ENDPOINT.trim()) { throw new Error(`Missing CONTROLLER_ENDPOINT in: ${agentEndpointPath}`); } @@ -135,8 +137,8 @@ export class AgentProvisioningService { } } - private assertSafeFileIdentifier(value: string, field: string): void { - if (!SAFE_FILE_IDENTIFIER.test(value)) { + private assertSafeFileIdentifier(value: unknown, field: string): void { + if ('string' !== typeof value || !SAFE_FILE_IDENTIFIER.test(value)) { throw new Error(`${field} contains unsafe characters`); } } From 06018edac7b2d13df044c657a1eed167d5a4da1d Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 21 Aug 2026 22:46:29 +0800 Subject: [PATCH 3/5] fix(security): preserve provisioning compatibility Signed-off-by: Mark --- .../src/agent-provisioning.service.spec.ts | 33 ++++++++++-- .../src/agent-provisioning.service.ts | 54 ++++++++++++++++--- 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/apps/agent-provisioning/src/agent-provisioning.service.spec.ts b/apps/agent-provisioning/src/agent-provisioning.service.spec.ts index 8c410e2d0..7694a83cb 100644 --- a/apps/agent-provisioning/src/agent-provisioning.service.spec.ts +++ b/apps/agent-provisioning/src/agent-provisioning.service.spec.ts @@ -68,15 +68,23 @@ describe('AgentProvisioningService', () => { expect(mockExecFile).toHaveBeenCalledWith( expect.stringContaining('/apps/agent-provisioning/AFJ/scripts/start_agent.sh'), expect.arrayContaining([payload.orgId, payload.containerName, payload.walletPassword]), - expect.objectContaining({ timeout: 120000 }) + expect.objectContaining({ timeout: 300000 }) ); }); - it('rejects identifiers that could escape the generated endpoint path before executing a script', async () => { - await expect(service.walletProvision({ ...payload, containerName: '../../tmp/owned' })).rejects.toThrow( - 'containerName contains unsafe characters' + it('normalizes organization-derived container names before executing the script and reading its endpoint', async () => { + mockExecFile.mockResolvedValue({ stdout: '', stderr: '' }); + mockReadFile.mockResolvedValue('{"CONTROLLER_ENDPOINT":"https://agent.example"}'); + + await expect(service.walletProvision({ ...payload, containerName: 'Crédit Agricole, Inc.' })).resolves.toEqual({ + agentEndPoint: 'https://agent.example' + }); + expect(mockExecFile).toHaveBeenCalledWith( + expect.any(String), + expect.arrayContaining(['Credit_Agricole_Inc']), + expect.any(Object) ); - expect(mockExecFile).not.toHaveBeenCalled(); + expect(mockReadFile).toHaveBeenCalledWith(expect.stringContaining('org-123_Credit_Agricole_Inc.json'), 'utf8'); }); it('rejects non-string identifiers before executing a script', async () => { @@ -86,6 +94,19 @@ describe('AgentProvisioningService', () => { expect(mockExecFile).not.toHaveBeenCalled(); }); + it('uses a configured provisioning timeout', async () => { + process.env.AFJ_AGENT_PROVISION_TIMEOUT_MS = '600000'; + mockExecFile.mockResolvedValue({ stdout: '', stderr: '' }); + mockReadFile.mockResolvedValue('{"CONTROLLER_ENDPOINT":"https://agent.example"}'); + + await service.walletProvision(payload); + expect(mockExecFile).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ timeout: 600000 }) + ); + }); + it.each([{}, 1, [], '', ' '])('rejects invalid CONTROLLER_ENDPOINT values', async (endpoint) => { mockExecFile.mockResolvedValue({ stdout: '', stderr: '' }); mockReadFile.mockResolvedValue(JSON.stringify({ CONTROLLER_ENDPOINT: endpoint })); @@ -95,6 +116,7 @@ describe('AgentProvisioningService', () => { it('propagates a provisioning script failure instead of attempting to read an endpoint file', async () => { const failure = Object.assign(new Error('script failed'), { + code: 17, stdout: 'stdout-secret', stderr: 'stderr-secret' }); @@ -105,5 +127,6 @@ describe('AgentProvisioningService', () => { expect(logger.error).toHaveBeenCalled(); expect(JSON.stringify(logger.error.mock.calls)).not.toContain('stdout-secret'); expect(JSON.stringify(logger.error.mock.calls)).not.toContain('stderr-secret'); + expect(JSON.stringify(logger.error.mock.calls)).toContain('exit code 17'); }); }); diff --git a/apps/agent-provisioning/src/agent-provisioning.service.ts b/apps/agent-provisioning/src/agent-provisioning.service.ts index 13421cbcf..67640acda 100644 --- a/apps/agent-provisioning/src/agent-provisioning.service.ts +++ b/apps/agent-provisioning/src/agent-provisioning.service.ts @@ -11,7 +11,7 @@ import { promisify } from 'util'; const execFileAsync = promisify(execFile); const SAFE_FILE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; -const AGENT_PROVISION_TIMEOUT_MS = 120_000; +const DEFAULT_AGENT_PROVISION_TIMEOUT_MS = 300_000; dotenv.config(); @@ -47,7 +47,7 @@ export class AgentProvisioningService { } = payload; if (agentType === AgentType.AFJ) { this.assertSafeFileIdentifier(orgId, 'orgId'); - this.assertSafeFileIdentifier(containerName, 'containerName'); + const safeContainerName = this.normalizeContainerName(containerName); const spinUpScript = process.env.AFJ_AGENT_SPIN_UP; const endpointDirectory = process.env.AFJ_AGENT_ENDPOINT_PATH; @@ -73,6 +73,7 @@ export class AgentProvisioningService { throw new Error(`Missing provisioning configuration: ${missingEnvironment.join(', ')}`); } + const provisionTimeoutMs = this.getProvisionTimeoutMs(); await execFileAsync( `${process.cwd()}${spinUpScript}`, [ @@ -86,7 +87,7 @@ export class AgentProvisioningService { walletStoragePort, walletStorageUser, walletStoragePassword, - containerName, + safeContainerName, protocol, String(tenant), credoImage, @@ -94,12 +95,18 @@ export class AgentProvisioningService { inboundEndpoint, ...requiredEnvironment.map((name) => process.env[name] as string) ], - { timeout: AGENT_PROVISION_TIMEOUT_MS, maxBuffer: 1024 * 1024 } - ).catch(() => { - throw new Error('Agent provisioning script failed'); + { timeout: provisionTimeoutMs, maxBuffer: 1024 * 1024 } + ).catch((error) => { + const failureDetail = + 'number' === typeof error?.code + ? ` (exit code ${error.code})` + : 'string' === typeof error?.signal + ? ` (signal ${error.signal})` + : ''; + throw new Error(`Agent provisioning script failed${failureDetail}`); }); - const agentEndpointPath = `${process.cwd()}${endpointDirectory}${orgId}_${containerName}.json`; + const agentEndpointPath = `${process.cwd()}${endpointDirectory}${orgId}_${safeContainerName}.json`; const agentEndPointExists = await this.checkFileExistence(agentEndpointPath); if (!agentEndPointExists) { throw new NotFoundException(`Agent endpoint file does not exist: ${agentEndpointPath}`); @@ -123,11 +130,42 @@ export class AgentProvisioningService { // TODO: ACA-PY Agent Spin-Up } } catch (error) { - this.logger.error(`[walletProvision] - error in wallet provision: ${JSON.stringify(error)}`); + this.logger.error( + `[walletProvision] - error in wallet provision: ${error instanceof Error ? error.message : 'Unknown error'}` + ); throw new RpcException(error); } } + private getProvisionTimeoutMs(): number { + const configuredTimeout = process.env.AFJ_AGENT_PROVISION_TIMEOUT_MS; + if (!configuredTimeout) { + return DEFAULT_AGENT_PROVISION_TIMEOUT_MS; + } + + const timeout = Number(configuredTimeout); + if (!Number.isInteger(timeout) || 0 >= timeout) { + throw new Error('AFJ_AGENT_PROVISION_TIMEOUT_MS must be a positive integer'); + } + + return timeout; + } + + private normalizeContainerName(value: unknown): string { + if ('string' !== typeof value) { + throw new Error('containerName contains unsafe characters'); + } + + const normalized = value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^A-Za-z0-9_-]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 128); + + return normalized || 'agent'; + } + async checkFileExistence(filePath: string): Promise { try { await fs.access(filePath); From 1def944ea6c87d13f32ddcaf3f939644cae67de6 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 21 Aug 2026 22:58:12 +0800 Subject: [PATCH 4/5] fix(security): validate agent endpoint documents Signed-off-by: Mark --- .../src/agent-provisioning.service.spec.ts | 7 +++++++ .../src/agent-provisioning.service.ts | 10 +++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/agent-provisioning/src/agent-provisioning.service.spec.ts b/apps/agent-provisioning/src/agent-provisioning.service.spec.ts index 7694a83cb..0073fc618 100644 --- a/apps/agent-provisioning/src/agent-provisioning.service.spec.ts +++ b/apps/agent-provisioning/src/agent-provisioning.service.spec.ts @@ -114,6 +114,13 @@ describe('AgentProvisioningService', () => { await expect(service.walletProvision(payload)).rejects.toThrow('Missing CONTROLLER_ENDPOINT'); }); + it('rejects a non-object endpoint document', async () => { + mockExecFile.mockResolvedValue({ stdout: '', stderr: '' }); + mockReadFile.mockResolvedValue('null'); + + await expect(service.walletProvision(payload)).rejects.toThrow('Missing CONTROLLER_ENDPOINT'); + }); + it('propagates a provisioning script failure instead of attempting to read an endpoint file', async () => { const failure = Object.assign(new Error('script failed'), { code: 17, diff --git a/apps/agent-provisioning/src/agent-provisioning.service.ts b/apps/agent-provisioning/src/agent-provisioning.service.ts index 67640acda..2e674aa1a 100644 --- a/apps/agent-provisioning/src/agent-provisioning.service.ts +++ b/apps/agent-provisioning/src/agent-provisioning.service.ts @@ -113,7 +113,7 @@ export class AgentProvisioningService { } const agentEndPoint = await fs.readFile(agentEndpointPath, 'utf8'); - let parsedEndpoint: { CONTROLLER_ENDPOINT?: unknown }; + let parsedEndpoint: unknown; try { parsedEndpoint = JSON.parse(agentEndPoint); } catch (parseError) { @@ -121,11 +121,15 @@ export class AgentProvisioningService { throw new Error(`Invalid JSON in agent endpoint file: ${agentEndpointPath}`); } - if ('string' !== typeof parsedEndpoint.CONTROLLER_ENDPOINT || !parsedEndpoint.CONTROLLER_ENDPOINT.trim()) { + const controllerEndpoint = + null !== parsedEndpoint && 'object' === typeof parsedEndpoint && !Array.isArray(parsedEndpoint) + ? (parsedEndpoint as Record).CONTROLLER_ENDPOINT + : undefined; + if ('string' !== typeof controllerEndpoint || !controllerEndpoint.trim()) { throw new Error(`Missing CONTROLLER_ENDPOINT in: ${agentEndpointPath}`); } - return { agentEndPoint: parsedEndpoint.CONTROLLER_ENDPOINT }; + return { agentEndPoint: controllerEndpoint }; } else if (agentType === AgentType.ACAPY) { // TODO: ACA-PY Agent Spin-Up } From dd465c3b1dfc5f5d88de229eedc7e3bd6fd8b867 Mon Sep 17 00:00:00 2001 From: Ajay Jadhav Date: Wed, 26 Aug 2026 12:41:46 +0530 Subject: [PATCH 5/5] fix(security): address SonarQube issues in agent provisioning - Prefer node:util and node:child_process imports over bare specifiers - Reduce walletProvision cognitive complexity by extracting helper methods - Replace nested ternary with if/else in formatScriptFailure - Use TypeError for type-check failures in normalizeContainerName - Simplify regex using Unicode property escape (\p{M}) instead of backtracking-prone range [\u0300-\u036f] Signed-off-by: Ajay Jadhav --- .../src/agent-provisioning.service.spec.ts | 4 +- .../src/agent-provisioning.service.ts | 248 ++++++++++-------- 2 files changed, 140 insertions(+), 112 deletions(-) diff --git a/apps/agent-provisioning/src/agent-provisioning.service.spec.ts b/apps/agent-provisioning/src/agent-provisioning.service.spec.ts index 0073fc618..31b3b542a 100644 --- a/apps/agent-provisioning/src/agent-provisioning.service.spec.ts +++ b/apps/agent-provisioning/src/agent-provisioning.service.spec.ts @@ -2,8 +2,8 @@ const mockExecFile = jest.fn(); const mockReadFile = jest.fn(); const mockAccess = jest.fn(); -jest.mock('child_process', () => ({ execFile: mockExecFile })); -jest.mock('util', () => ({ promisify: jest.fn(() => mockExecFile) })); +jest.mock('node:child_process', () => ({ execFile: mockExecFile })); +jest.mock('node:util', () => ({ promisify: jest.fn(() => mockExecFile) })); jest.mock('fs', () => ({ promises: { access: mockAccess, readFile: mockReadFile } })); import { AgentType } from '@credebl/enum/enum'; diff --git a/apps/agent-provisioning/src/agent-provisioning.service.ts b/apps/agent-provisioning/src/agent-provisioning.service.ts index 2e674aa1a..4f944dd50 100644 --- a/apps/agent-provisioning/src/agent-provisioning.service.ts +++ b/apps/agent-provisioning/src/agent-provisioning.service.ts @@ -6,8 +6,8 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { AgentType } from '@credebl/enum/enum'; import { IWalletProvision } from './interface/agent-provisioning.interfaces'; import { RpcException } from '@nestjs/microservices'; -import { execFile } from 'child_process'; -import { promisify } from 'util'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); const SAFE_FILE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; @@ -26,113 +26,15 @@ export class AgentProvisioningService { */ async walletProvision(payload: IWalletProvision): Promise { try { - const { - containerName, - externalIp, - orgId, - seed, - walletName, - walletPassword, - walletStorageHost, - walletStoragePassword, - walletStoragePort, - walletStorageUser, - webhookEndpoint, - agentType, - protocol, - credoImage, - tenant, - indyLedger, - inboundEndpoint - } = payload; - if (agentType === AgentType.AFJ) { - this.assertSafeFileIdentifier(orgId, 'orgId'); - const safeContainerName = this.normalizeContainerName(containerName); - - const spinUpScript = process.env.AFJ_AGENT_SPIN_UP; - const endpointDirectory = process.env.AFJ_AGENT_ENDPOINT_PATH; - if (!spinUpScript || !endpointDirectory) { - throw new Error('AFJ_AGENT_SPIN_UP and AFJ_AGENT_ENDPOINT_PATH must be configured'); - } - - const requiredEnvironment = [ - 'SCHEMA_FILE_SERVER_URL', - 'AGENT_API_KEY', - 'AWS_ACCOUNT_ID', - 'S3_BUCKET_ARN', - 'CLUSTER_NAME', - 'TASKDEFINITION_FAMILY', - 'ADMIN_TG_ARN', - 'INBOUND_TG_ARN', - 'FILESYSTEMID', - 'ECS_SUBNET_ID', - 'ECS_SECURITY_GROUP_ID' - ]; - const missingEnvironment = requiredEnvironment.filter((name) => !process.env[name]); - if (missingEnvironment.length) { - throw new Error(`Missing provisioning configuration: ${missingEnvironment.join(', ')}`); - } - - const provisionTimeoutMs = this.getProvisionTimeoutMs(); - await execFileAsync( - `${process.cwd()}${spinUpScript}`, - [ - orgId, - externalIp, - walletName, - walletPassword, - seed, - webhookEndpoint, - walletStorageHost, - walletStoragePort, - walletStorageUser, - walletStoragePassword, - safeContainerName, - protocol, - String(tenant), - credoImage, - indyLedger, - inboundEndpoint, - ...requiredEnvironment.map((name) => process.env[name] as string) - ], - { timeout: provisionTimeoutMs, maxBuffer: 1024 * 1024 } - ).catch((error) => { - const failureDetail = - 'number' === typeof error?.code - ? ` (exit code ${error.code})` - : 'string' === typeof error?.signal - ? ` (signal ${error.signal})` - : ''; - throw new Error(`Agent provisioning script failed${failureDetail}`); - }); - - const agentEndpointPath = `${process.cwd()}${endpointDirectory}${orgId}_${safeContainerName}.json`; - const agentEndPointExists = await this.checkFileExistence(agentEndpointPath); - if (!agentEndPointExists) { - throw new NotFoundException(`Agent endpoint file does not exist: ${agentEndpointPath}`); - } - - const agentEndPoint = await fs.readFile(agentEndpointPath, 'utf8'); - let parsedEndpoint: unknown; - try { - parsedEndpoint = JSON.parse(agentEndPoint); - } catch (parseError) { - this.logger.error(`Failed to parse agent endpoint file: ${parseError.message}`); - throw new Error(`Invalid JSON in agent endpoint file: ${agentEndpointPath}`); - } - - const controllerEndpoint = - null !== parsedEndpoint && 'object' === typeof parsedEndpoint && !Array.isArray(parsedEndpoint) - ? (parsedEndpoint as Record).CONTROLLER_ENDPOINT - : undefined; - if ('string' !== typeof controllerEndpoint || !controllerEndpoint.trim()) { - throw new Error(`Missing CONTROLLER_ENDPOINT in: ${agentEndpointPath}`); - } - - return { agentEndPoint: controllerEndpoint }; - } else if (agentType === AgentType.ACAPY) { + if (payload.agentType === AgentType.AFJ) { + return await this.provisionAfjAgent(payload); + } + + if (payload.agentType === AgentType.ACAPY) { // TODO: ACA-PY Agent Spin-Up } + + throw new RpcException(`Unsupported agent type: ${payload.agentType}`); } catch (error) { this.logger.error( `[walletProvision] - error in wallet provision: ${error instanceof Error ? error.message : 'Unknown error'}` @@ -141,6 +43,131 @@ export class AgentProvisioningService { } } + private async provisionAfjAgent(payload: IWalletProvision): Promise { + this.assertSafeFileIdentifier(payload.orgId, 'orgId'); + const safeContainerName = this.normalizeContainerName(payload.containerName); + + const { spinUpScript, endpointDirectory, requiredEnvironment } = this.validateAfjConfig(); + const provisionTimeoutMs = this.getProvisionTimeoutMs(); + + await this.executeProvisioningScript( + payload, + safeContainerName, + spinUpScript, + requiredEnvironment, + provisionTimeoutMs + ); + + return this.readAgentEndpoint(payload.orgId, safeContainerName, endpointDirectory); + } + + private validateAfjConfig(): { + spinUpScript: string; + endpointDirectory: string; + requiredEnvironment: string[]; + } { + const spinUpScript = process.env.AFJ_AGENT_SPIN_UP; + const endpointDirectory = process.env.AFJ_AGENT_ENDPOINT_PATH; + if (!spinUpScript || !endpointDirectory) { + throw new Error('AFJ_AGENT_SPIN_UP and AFJ_AGENT_ENDPOINT_PATH must be configured'); + } + + const requiredEnvironment = [ + 'SCHEMA_FILE_SERVER_URL', + 'AGENT_API_KEY', + 'AWS_ACCOUNT_ID', + 'S3_BUCKET_ARN', + 'CLUSTER_NAME', + 'TASKDEFINITION_FAMILY', + 'ADMIN_TG_ARN', + 'INBOUND_TG_ARN', + 'FILESYSTEMID', + 'ECS_SUBNET_ID', + 'ECS_SECURITY_GROUP_ID' + ]; + const missingEnvironment = requiredEnvironment.filter((name) => !process.env[name]); + if (missingEnvironment.length) { + throw new Error(`Missing provisioning configuration: ${missingEnvironment.join(', ')}`); + } + + return { spinUpScript, endpointDirectory, requiredEnvironment }; + } + + private async executeProvisioningScript( + payload: IWalletProvision, + safeContainerName: string, + spinUpScript: string, + requiredEnvironment: string[], + provisionTimeoutMs: number + ): Promise { + await execFileAsync( + `${process.cwd()}${spinUpScript}`, + [ + payload.orgId, + payload.externalIp, + payload.walletName, + payload.walletPassword, + payload.seed, + payload.webhookEndpoint, + payload.walletStorageHost, + payload.walletStoragePort, + payload.walletStorageUser, + payload.walletStoragePassword, + safeContainerName, + payload.protocol, + String(payload.tenant), + payload.credoImage, + payload.indyLedger, + payload.inboundEndpoint, + ...requiredEnvironment.map((name) => process.env[name] as string) + ], + { timeout: provisionTimeoutMs, maxBuffer: 1024 * 1024 } + ).catch((error) => { + throw new Error(`Agent provisioning script failed${this.formatScriptFailure(error)}`); + }); + } + + private formatScriptFailure(error: { code?: unknown; signal?: unknown }): string { + if ('number' === typeof error?.code) { + return ` (exit code ${error.code})`; + } + if ('string' === typeof error?.signal) { + return ` (signal ${error.signal})`; + } + return ''; + } + + private async readAgentEndpoint( + orgId: string, + safeContainerName: string, + endpointDirectory: string + ): Promise { + const agentEndpointPath = `${process.cwd()}${endpointDirectory}${orgId}_${safeContainerName}.json`; + const agentEndPointExists = await this.checkFileExistence(agentEndpointPath); + if (!agentEndPointExists) { + throw new NotFoundException(`Agent endpoint file does not exist: ${agentEndpointPath}`); + } + + const agentEndPoint = await fs.readFile(agentEndpointPath, 'utf8'); + let parsedEndpoint: unknown; + try { + parsedEndpoint = JSON.parse(agentEndPoint); + } catch (parseError) { + this.logger.error(`Failed to parse agent endpoint file: ${parseError.message}`); + throw new Error(`Invalid JSON in agent endpoint file: ${agentEndpointPath}`); + } + + const controllerEndpoint = + null !== parsedEndpoint && 'object' === typeof parsedEndpoint && !Array.isArray(parsedEndpoint) + ? (parsedEndpoint as Record).CONTROLLER_ENDPOINT + : undefined; + if ('string' !== typeof controllerEndpoint || !controllerEndpoint.trim()) { + throw new Error(`Missing CONTROLLER_ENDPOINT in: ${agentEndpointPath}`); + } + + return { agentEndPoint: controllerEndpoint }; + } + private getProvisionTimeoutMs(): number { const configuredTimeout = process.env.AFJ_AGENT_PROVISION_TIMEOUT_MS; if (!configuredTimeout) { @@ -157,14 +184,15 @@ export class AgentProvisioningService { private normalizeContainerName(value: unknown): string { if ('string' !== typeof value) { - throw new Error('containerName contains unsafe characters'); + throw new TypeError('containerName contains unsafe characters'); } const normalized = value .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') + .replace(/\p{M}/gu, '') .replace(/[^A-Za-z0-9_-]+/g, '_') - .replace(/^_+|_+$/g, '') + .replace(/^_+/, '') + .replace(/_+$/, '') .slice(0, 128); return normalized || 'agent';