Repository: finos/git-proxy

Issue:
Allow for the configuration to be sourced externally and updated at runtime
**Is your feature request related to a problem? Please describe.**
Instead of a static `proxy.config.json`, we would like to have a way to provide the configuration from an external source (like a gitops repo, or an http service, etc.) and allow it to be reloaded/applied at runtime.

**Describe the solution you'd like**
The idea is to implement a robust remote configuration management solution for GitProxy that would support dynamic loading of configurations from multiple sources (local files, HTTP endpoints, and Git repositories). The system would maintain a default configuration in `proxy.config.json` which could be overridden by user-specific settings, and would feature a dynamic configuration loader that periodically checks for updates at configurable intervals. When changes are detected, the system would validate the new configuration, gracefully stop existing services, apply the updates, and restart services with the new configuration. The solution would include built-in error handling with automatic rollback to previous configurations if updates fail, and would provide an admin-only endpoint for manual configuration reloading. This architecture would ensure that configuration changes could be managed centrally while maintaining system stability and availability, with the ability to merge configurations from multiple sources when needed.

**Describe alternatives you've considered**
None

**Additional context**
The idea behind remote configuration management for GitProxy is to enable automated, remote configuration updates without requiring manual intervention or service restarts. This would allow administrators to centrally manage and update configurations across multiple GitProxy instances, ensuring consistent settings and reducing operational overhead. The system would automatically detect and apply configuration changes from remote sources while maintaining system stability through validation and rollback capabilities.


Hints from issue comments:

Solution patch:
diff --git a/.gitignore b/.gitignore
index 1849589c..747f84c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -263,4 +263,10 @@ yarn-error.log*
 
 # Docusaurus website
 website/build
-website/.docusaurus
\ No newline at end of file
+website/.docusaurus
+
+# git-config-cache
+.git-config-cache
+
+# Jetbrains IDE
+.idea
diff --git a/config.schema.json b/config.schema.json
index c0ac8966..78cc005c 100644
--- a/config.schema.json
+++ b/config.schema.json
@@ -28,7 +28,7 @@
       "description": "API Rate limiting configuration.",
       "type": "object",
       "properties": {
-        "windowMs": { 
+        "windowMs": {
           "type": "number",
           "description": "How long to remember requests for, in milliseconds (default 10 mins)."
         },
@@ -112,6 +112,18 @@
         "cert": { "type": "string" }
       },
       "required": ["enabled", "key", "cert"]
+    },
+    "configurationSources": {
+      "enabled": { "type": "boolean" },
+      "reloadIntervalSeconds": { "type": "number" },
+      "merge": { "type": "boolean" },
+      "sources": {
+        "type": "array",
+        "items": {
+          "type": "object",
+          "description": "Configuration source"
+        }
+      }
     }
   },
   "definitions": {
diff --git a/package.json b/package.json
index 3ce14e6a..4c62e9fa 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,7 @@
     "connect-mongo": "^5.1.0",
     "cors": "^2.8.5",
     "diff2html": "^3.4.33",
+    "env-paths": "^2.2.1",
     "express": "^4.18.2",
     "express-http-proxy": "^2.0.0",
     "express-rate-limit": "^7.1.5",
diff --git a/packages/git-proxy-cli/index.js b/packages/git-proxy-cli/index.js
index b0090a4b..142a58a3 100755
--- a/packages/git-proxy-cli/index.js
+++ b/packages/git-proxy-cli/index.js
@@ -7,7 +7,8 @@ const util = require('util');
 
 const GIT_PROXY_COOKIE_FILE = 'git-proxy-cookie';
 // GitProxy UI HOST and PORT (configurable via environment variable)
-const { GIT_PROXY_UI_HOST: uiHost = 'http://localhost', GIT_PROXY_UI_PORT: uiPort = 8080 } = process.env;
+const { GIT_PROXY_UI_HOST: uiHost = 'http://localhost', GIT_PROXY_UI_PORT: uiPort = 8080 } =
+  process.env;
 
 const baseUrl = `${uiHost}:${uiPort}`;
 
@@ -306,6 +307,29 @@ async function logout() {
   console.log('Logout: OK');
 }
 
+/**
+ * Reloads the GitProxy configuration without restarting the process
+ */
+async function reloadConfig() {
+  if (!fs.existsSync(GIT_PROXY_COOKIE_FILE)) {
+    console.error('Error: Reload config: Authentication required');
+    process.exitCode = 1;
+    return;
+  }
+
+  try {
+    const cookies = JSON.parse(fs.readFileSync(GIT_PROXY_COOKIE_FILE, 'utf8'));
+
+    await axios.post(`${baseUrl}/api/v1/admin/reload-config`, {}, { headers: { Cookie: cookies } });
+
+    console.log('Configuration reloaded successfully');
+  } catch (error) {
+    const errorMessage = `Error: Reload config: '${error.message}'`;
+    process.exitCode = 2;
+    console.error(errorMessage);
+  }
+}
+
 // Parsing command line arguments
 yargs(hideBin(process.argv)) // eslint-disable-line @typescript-eslint/no-unused-expressions
   .command({
@@ -436,6 +460,11 @@ yargs(hideBin(process.argv)) // eslint-disable-line @typescript-eslint/no-unused
       rejectGitPush(argv.id);
     },
   })
+  .command({
+    command: 'reload-config',
+    description: 'Reload GitProxy configuration without restarting',
+    action: reloadConfig,
+  })
   .demandCommand(1, 'You need at least one command before moving on')
   .strict()
   .help().argv;
diff --git a/proxy.config.json b/proxy.config.json
index 580982cd..2a45cefa 100644
--- a/proxy.config.json
+++ b/proxy.config.json
@@ -96,6 +96,39 @@
       }
     ]
   },
+  "configurationSources": {
+    "enabled": false,
+    "reloadIntervalSeconds": 60,
+    "merge": false,
+    "sources": [
+      {
+        "type": "file",
+        "enabled": false,
+        "path": "./external-config.json"
+      },
+      {
+        "type": "http",
+        "enabled": false,
+        "url": "http://config-service/git-proxy-config",
+        "headers": {},
+        "auth": {
+          "type": "bearer",
+          "token": ""
+        }
+      },
+      {
+        "type": "git",
+        "enabled": false,
+        "repository": "https://git-server.com/project/git-proxy-config",
+        "branch": "main",
+        "path": "git-proxy/config.json",
+        "auth": {
+          "type": "ssh",
+          "privateKeyPath": "/path/to/.ssh/id_rsa"
+        }
+      }
+    ]
+  },
   "domains": {},
   "privateOrganizations": [],
   "urlShortener": "",
@@ -103,7 +136,7 @@
   "csrfProtection": true,
   "plugins": [],
   "tls": {
-    "enabled": true,
+    "enabled": false,
     "key": "certs/key.pem",
     "cert": "certs/cert.pem"
   }
diff --git a/src/config/ConfigLoader.ts b/src/config/ConfigLoader.ts
new file mode 100644
index 00000000..80429e38
--- /dev/null
+++ b/src/config/ConfigLoader.ts
@@ -0,0 +1,419 @@
+import fs from 'fs';
+import path from 'path';
+import axios from 'axios';
+import { execFile } from 'child_process';
+import { promisify } from 'util';
+import EventEmitter from 'events';
+import envPaths from 'env-paths';
+
+const execFileAsync = promisify(execFile);
+
+interface GitAuth {
+  type: 'ssh';
+  privateKeyPath: string;
+}
+
+interface HttpAuth {
+  type: 'bearer';
+  token: string;
+}
+
+interface BaseSource {
+  type: 'file' | 'http' | 'git';
+  enabled: boolean;
+}
+
+interface FileSource extends BaseSource {
+  type: 'file';
+  path: string;
+}
+
+interface HttpSource extends BaseSource {
+  type: 'http';
+  url: string;
+  headers?: Record<string, string>;
+  auth?: HttpAuth;
+}
+
+interface GitSource extends BaseSource {
+  type: 'git';
+  repository: string;
+  branch?: string;
+  path: string;
+  auth?: GitAuth;
+}
+
+type ConfigurationSource = FileSource | HttpSource | GitSource;
+
+export interface ConfigurationSources {
+  enabled: boolean;
+  sources: ConfigurationSource[];
+  reloadIntervalSeconds: number;
+  merge?: boolean;
+}
+
+export interface Configuration {
+  configurationSources: ConfigurationSources;
+  [key: string]: any;
+}
+
+// Add path validation helper
+function isValidPath(filePath: string): boolean {
+  if (!filePath || typeof filePath !== 'string') return false;
+
+  // Check for null bytes and other control characters
+  if (/[\0]/.test(filePath)) return false;
+
+  try {
+    path.resolve(filePath);
+    return true;
+  } catch (error) {
+    return false;
+  }
+}
+
+// Add URL validation helper
+function isValidGitUrl(url: string): boolean {
+  // Allow git://, https://, or ssh:// URLs
+  // Also allow scp-style URLs (user@host:path)
+  const validUrlPattern =
+    /^(git:\/\/|https:\/\/|ssh:\/\/|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}:)/;
+  return typeof url === 'string' && validUrlPattern.test(url);
+}
+
+// Add branch name validation helper
+function isValidBranchName(branch: string): boolean {
+  if (typeof branch !== 'string') return false;
+
+  // Check for consecutive dots
+  if (branch.includes('..')) return false;
+
+  // Check other branch name rules
+  // Branch names can contain alphanumeric, -, _, /, and .
+  // Cannot start with - or .
+  // Cannot contain consecutive dots
+  // Cannot contain control characters or spaces
+  const validBranchPattern = /^[a-zA-Z0-9][a-zA-Z0-9_/.-]*$/;
+  return validBranchPattern.test(branch);
+}
+
+export class ConfigLoader extends EventEmitter {
+  private config: Configuration;
+  private reloadTimer: NodeJS.Timeout | null;
+  private isReloading: boolean;
+  private cacheDir: string | null;
+
+  constructor(initialConfig: Configuration) {
+    super();
+    this.config = initialConfig;
+    this.reloadTimer = null;
+    this.isReloading = false;
+    this.cacheDir = null;
+  }
+
+  async initialize(): Promise<boolean> {
+    // Get cache directory path
+    const paths = envPaths('git-proxy');
+    this.cacheDir = paths.cache;
+
+    // Create cache directory if it doesn't exist
+    if (!fs.existsSync(this.cacheDir)) {
+      try {
+        fs.mkdirSync(this.cacheDir, { recursive: true });
+        console.log(`Created cache directory at ${this.cacheDir}`);
+        return true;
+      } catch (err) {
+        console.error('Failed to create cache directory:', err);
+        return false;
+      }
+    }
+    console.log(`Using cache directory at ${this.cacheDir}`);
+    return true;
+  }
+
+  async start(): Promise<void> {
+    const { configurationSources } = this.config;
+    if (!configurationSources?.enabled) {
+      console.log('Configuration sources are disabled');
+      return;
+    }
+
+    console.log('Configuration sources are enabled');
+    console.log(
+      `Sources: ${JSON.stringify(configurationSources.sources.filter((s: ConfigurationSource) => s.enabled).map((s: ConfigurationSource) => s.type))}`,
+    );
+
+    // Clear any existing interval before starting a new one
+    if (this.reloadTimer) {
+      clearInterval(this.reloadTimer);
+      this.reloadTimer = null;
+    }
+
+    // Start periodic reload if interval is set
+    if (configurationSources.reloadIntervalSeconds > 0) {
+      console.log(
+        `Setting reload interval to ${configurationSources.reloadIntervalSeconds} seconds`,
+      );
+      this.reloadTimer = setInterval(
+        () => this.reloadConfiguration(),
+        configurationSources.reloadIntervalSeconds * 1000,
+      );
+    }
+
+    // Do initial load
+    await this.reloadConfiguration();
+  }
+
+  stop(): void {
+    if (this.reloadTimer) {
+      clearInterval(this.reloadTimer);
+      this.reloadTimer = null;
+    }
+  }
+
+  async reloadConfiguration(): Promise<void> {
+    if (this.isReloading) {
+      console.log('Configuration reload already in progress, skipping');
+      return;
+    }
+    this.isReloading = true;
+    console.log('Starting configuration reload');
+
+    try {
+      const { configurationSources } = this.config;
+      if (!configurationSources?.enabled) {
+        console.log('Configuration sources are disabled, skipping reload');
+        return;
+      }
+
+      const enabledSources = configurationSources.sources.filter(
+        (source: ConfigurationSource) => source.enabled,
+      );
+      console.log(`Found ${enabledSources.length} enabled configuration sources`);
+
+      const configs = await Promise.all(
+        enabledSources.map(async (source: ConfigurationSource) => {
+          try {
+            console.log(`Loading configuration from ${source.type} source`);
+            return await this.loadFromSource(source);
+          } catch (error: unknown) {
+            if (error instanceof Error) {
+              console.error(`Error loading from ${source.type} source:`, error.message);
+            }
+            return null;
+          }
+        }),
+      );
+
+      // Filter out null results from failed loads
+      const validConfigs = configs.filter((config): config is Configuration => config !== null);
+
+      if (validConfigs.length === 0) {
+        console.log('No valid configurations loaded from any source');
+        return;
+      }
+
+      // Use merge strategy based on configuration
+      const shouldMerge = configurationSources.merge ?? true; // Default to true for backward compatibility
+      console.log(`Using ${shouldMerge ? 'merge' : 'override'} strategy for configuration`);
+
+      const newConfig = shouldMerge
+        ? validConfigs.reduce(
+            (acc, curr) => {
+              return this.deepMerge(acc, curr) as Configuration;
+            },
+            { ...this.config },
+          )
+        : { ...this.config, ...validConfigs[validConfigs.length - 1] }; // Use last config for override
+
+      // Emit change event if config changed
+      if (JSON.stringify(newConfig) !== JSON.stringify(this.config)) {
+        console.log('Configuration has changed, updating and emitting change event');
+        this.config = newConfig;
+        this.emit('configurationChanged', this.config);
+      } else {
+        console.log('Configuration has not changed, no update needed');
+      }
+    } catch (error: unknown) {
+      console.error('Error reloading configuration:', error);
+      this.emit('configurationError', error);
+    } finally {
+      this.isReloading = false;
+    }
+  }
+
+  async loadFromSource(source: ConfigurationSource): Promise<Configuration> {
+    let exhaustiveCheck: never;
+    switch (source.type) {
+      case 'file':
+        return this.loadFromFile(source as FileSource);
+      case 'http':
+        return this.loadFromHttp(source as HttpSource);
+      case 'git':
+        return this.loadFromGit(source as GitSource);
+      default:
+        exhaustiveCheck = source;
+        throw new Error(`Unsupported configuration source type: ${exhaustiveCheck}`);
+    }
+  }
+
+  async loadFromFile(source: FileSource): Promise<Configuration> {
+    const configPath = path.resolve(process.cwd(), source.path);
+    if (!isValidPath(configPath)) {
+      throw new Error('Invalid configuration file path');
+    }
+    console.log(`Loading configuration from file: ${configPath}`);
+    const content = await fs.promises.readFile(configPath, 'utf8');
+    return JSON.parse(content);
+  }
+
+  async loadFromHttp(source: HttpSource): Promise<Configuration> {
+    console.log(`Loading configuration from HTTP: ${source.url}`);
+    const headers = {
+      ...source.headers,
+      ...(source.auth?.type === 'bearer' ? { Authorization: `Bearer ${source.auth.token}` } : {}),
+    };
+
+    const response = await axios.get(source.url, { headers });
+    return response.data;
+  }
+
+  async loadFromGit(source: GitSource): Promise<Configuration> {
+    console.log(`Loading configuration from Git: ${source.repository}`);
+
+    // Validate inputs
+    if (!source.repository || !isValidGitUrl(source.repository)) {
+      throw new Error('Invalid repository URL format');
+    }
+    if (source.branch && !isValidBranchName(source.branch)) {
+      throw new Error('Invalid branch name format');
+    }
+
+    // Use OS-specific cache directory
+    const paths = envPaths('git-proxy', { suffix: '' });
+    const tempDir = path.join(paths.cache, 'git-config-cache');
+
+    if (!isValidPath(tempDir)) {
+      throw new Error('Invalid temporary directory path');
+    }
+
+    console.log(`Creating git cache directory at ${tempDir}`);
+    await fs.promises.mkdir(tempDir, { recursive: true });
+
+    // Create a safe directory name from the repository URL
+    const repoDirName = Buffer.from(source.repository)
+      .toString('base64')
+      .replace(/[^a-zA-Z0-9]/g, '_');
+    const repoDir = path.join(tempDir, repoDirName);
+
+    if (!isValidPath(repoDir)) {
+      throw new Error('Invalid repository directory path');
+    }
+
+    console.log(`Using repository directory: ${repoDir}`);
+
+    // Clone or pull repository
+    if (!fs.existsSync(repoDir)) {
+      console.log(`Cloning repository ${source.repository} to ${repoDir}`);
+      const execOptions = {
+        cwd: process.cwd(),
+        env: {
+          ...process.env,
+          ...(source.auth?.type === 'ssh'
+            ? {
+                GIT_SSH_COMMAND: `ssh -i ${source.auth.privateKeyPath}`,
+              }
+            : {}),
+        },
+      };
+
+      try {
+        await execFileAsync('git', ['clone', source.repository, repoDir], execOptions);
+        console.log('Repository cloned successfully');
+      } catch (error: unknown) {
+        if (error instanceof Error) {
+          console.error('Failed to clone repository:', error.message);
+          throw new Error(`Failed to clone repository: ${error.message}`);
+        }
+        throw error;
+      }
+    } else {
+      console.log(`Pulling latest changes from ${source.repository}`);
+      try {
+        await execFileAsync('git', ['pull'], { cwd: repoDir });
+        console.log('Repository pulled successfully');
+      } catch (error: unknown) {
+        if (error instanceof Error) {
+          console.error('Failed to pull repository:', error.message);
+          throw new Error(`Failed to pull repository: ${error.message}`);
+        }
+        throw error;
+      }
+    }
+
+    // Checkout specific branch if specified
+    if (source.branch) {
+      console.log(`Checking out branch: ${source.branch}`);
+      try {
+        await execFileAsync('git', ['checkout', source.branch], { cwd: repoDir });
+        console.log(`Branch ${source.branch} checked out successfully`);
+      } catch (error: unknown) {
+        if (error instanceof Error) {
+          console.error(`Failed to checkout branch ${source.branch}:`, error.message);
+          throw new Error(`Failed to checkout branch ${source.branch}: ${error.message}`);
+        }
+        throw error;
+      }
+    }
+
+    // Read and parse config file
+    const configPath = path.join(repoDir, source.path);
+    if (!isValidPath(configPath)) {
+      throw new Error('Invalid configuration file path in repository');
+    }
+
+    console.log(`Reading configuration file: ${configPath}`);
+    if (!fs.existsSync(configPath)) {
+      throw new Error(`Configuration file not found at ${configPath}`);
+    }
+
+    try {
+      const content = await fs.promises.readFile(configPath, 'utf8');
+      const config = JSON.parse(content);
+      console.log('Configuration loaded successfully from Git');
+      return config;
+    } catch (error: unknown) {
+      if (error instanceof Error) {
+        console.error('Failed to read or parse configuration file:', error.message);
+        throw new Error(`Failed to read or parse configuration file: ${error.message}`);
+      }
+      throw error;
+    }
+  }
+
+  deepMerge(target: Record<string, any>, source: Record<string, any>): Record<string, any> {
+    const output = { ...target };
+    if (isObject(target) && isObject(source)) {
+      Object.keys(source).forEach((key) => {
+        if (isObject(source[key])) {
+          if (!(key in target)) {
+            Object.assign(output, { [key]: source[key] });
+          } else {
+            output[key] = this.deepMerge(target[key], source[key]);
+          }
+        } else {
+          Object.assign(output, { [key]: source[key] });
+        }
+      });
+    }
+    return output;
+  }
+}
+
+// Helper function to check if a value is an object
+function isObject(item: unknown): item is Record<string, any> {
+  return item !== null && typeof item === 'object' && !Array.isArray(item);
+}
+
+export default ConfigLoader;
+export { isValidGitUrl, isValidPath, isValidBranchName };
diff --git a/src/config/index.ts b/src/config/index.ts
index d041344a..b92134d7 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -1,7 +1,8 @@
 import { existsSync, readFileSync } from 'fs';
 
 import defaultSettings from '../../proxy.config.json';
-import { configFile } from './file';
+import { configFile, validate } from './file';
+import { ConfigLoader, Configuration } from './ConfigLoader';
 import {
   Authentication,
   AuthorisedRepo,
@@ -38,6 +39,12 @@ let _tlsEnabled = defaultSettings.tls.enabled;
 let _tlsKeyPemPath = defaultSettings.tls.key;
 let _tlsCertPemPath = defaultSettings.tls.cert;
 
+// Initialize configuration with defaults and user settings
+let _config = { ...defaultSettings, ...(_userSettings || {}) } as Configuration;
+
+// Create config loader instance
+const configLoader = new ConfigLoader(_config);
+
 // Get configured proxy URL
 export const getProxyUrl = () => {
   if (_userSettings !== null && _userSettings.proxyUrl) {
@@ -228,3 +235,55 @@ export const getRateLimit = () => {
   }
   return _rateLimit;
 };
+
+// Function to handle configuration updates
+const handleConfigUpdate = async (newConfig: typeof _config) => {
+  console.log('Configuration updated from external source');
+  try {
+    // 1. Get proxy module dynamically to avoid circular dependency
+    const proxy = require('../proxy');
+
+    // 2. Stop existing services
+    await proxy.stop();
+
+    // 3. Update config
+    _config = newConfig;
+
+    // 4. Validate new configuration
+    validate();
+
+    // 5. Restart services with new config
+    await proxy.start();
+
+    console.log('Services restarted with new configuration');
+  } catch (error) {
+    console.error('Failed to apply new configuration:', error);
+    // Attempt to restart with previous config
+    try {
+      const proxy = require('../proxy');
+      await proxy.start();
+    } catch (startError) {
+      console.error('Failed to restart services:', startError);
+    }
+  }
+};
+
+// Handle configuration updates
+configLoader.on('configurationChanged', handleConfigUpdate);
+
+configLoader.on('configurationError', (error: Error) => {
+  console.error('Error loading external configuration:', error);
+});
+
+// Start the config loader if external sources are enabled
+configLoader.start().catch((error: Error) => {
+  console.error('Failed to start configuration loader:', error);
+});
+
+// Force reload of configuration
+const reloadConfiguration = async () => {
+  await configLoader.reloadConfiguration();
+};
+
+// Export reloadConfiguration
+export { reloadConfiguration };
diff --git a/src/proxy/chain.ts b/src/proxy/chain.ts
index 55e271a3..8bc5e312 100644
--- a/src/proxy/chain.ts
+++ b/src/proxy/chain.ts
@@ -61,7 +61,7 @@ export const executeChain = async (req: any, res: any): Promise<Action> => {
  */
 let chainPluginLoader: PluginLoader;
 
-const getChain = async (
+export const getChain = async (
   action: Action,
 ): Promise<((req: any, action: Action) => Promise<Action>)[]> => {
   if (chainPluginLoader === undefined) {
diff --git a/src/proxy/index.ts b/src/proxy/index.ts
index 0a49d0a6..4cfcda98 100644
--- a/src/proxy/index.ts
+++ b/src/proxy/index.ts
@@ -1,4 +1,4 @@
-import express from 'express';
+import express, { Application } from 'express';
 import bodyParser from 'body-parser';
 import http from 'http';
 import https from 'https';
@@ -19,7 +19,15 @@ import { Repo } from '../db/types';
 const { GIT_PROXY_SERVER_PORT: proxyHttpPort, GIT_PROXY_HTTPS_SERVER_PORT: proxyHttpsPort } =
   require('../config/env').serverConfig;
 
-const options = {
+interface ServerOptions {
+  inflate: boolean;
+  limit: string;
+  type: string;
+  key: Buffer | undefined;
+  cert: Buffer | undefined;
+}
+
+const options: ServerOptions = {
   inflate: true,
   limit: '100000kb',
   type: '*/*',
@@ -47,7 +55,7 @@ export const proxyPreparations = async () => {
 };
 
 // just keep this async incase it needs async stuff in the future
-export const createApp = async () => {
+const createApp = async (): Promise<Application> => {
   const app = express();
   // Setup the proxy middleware
   app.use(bodyParser.raw(options));
@@ -55,23 +63,53 @@ export const createApp = async () => {
   return app;
 };
 
-export const start = async () => {
+let httpServer: http.Server | null = null;
+let httpsServer: https.Server | null = null;
+
+const start = async (): Promise<Application> => {
   const app = await createApp();
   await proxyPreparations();
-  http.createServer(options as any, app).listen(proxyHttpPort, () => {
+  httpServer = http.createServer(options as any, app).listen(proxyHttpPort, () => {
     console.log(`HTTP Proxy Listening on ${proxyHttpPort}`);
   });
   // Start HTTPS server only if TLS is enabled
   if (getTLSEnabled()) {
-    https.createServer(options, app).listen(proxyHttpsPort, () => {
+    httpsServer = https.createServer(options, app).listen(proxyHttpsPort, () => {
       console.log(`HTTPS Proxy Listening on ${proxyHttpsPort}`);
     });
   }
   return app;
 };
 
+const stop = (): Promise<void> => {
+  return new Promise((resolve, reject) => {
+    try {
+      // Close HTTP server if it exists
+      if (httpServer) {
+        httpServer.close(() => {
+          console.log('HTTP server closed');
+          httpServer = null;
+        });
+      }
+
+      // Close HTTPS server if it exists
+      if (httpsServer) {
+        httpsServer.close(() => {
+          console.log('HTTPS server closed');
+          httpsServer = null;
+        });
+      }
+
+      resolve();
+    } catch (error) {
+      reject(error);
+    }
+  });
+};
+
 export default {
   proxyPreparations,
   createApp,
   start,
+  stop,
 };
diff --git a/src/service/index.js b/src/service/index.js
index 180800d3..02e416aa 100644
--- a/src/service/index.js
+++ b/src/service/index.js
@@ -8,6 +8,8 @@ const config = require('../config');
 const db = require('../db');
 const rateLimit = require('express-rate-limit');
 const lusca = require('lusca');
+const configLoader = require('../config/ConfigLoader');
+const proxy = require('../proxy');
 
 const limiter = rateLimit(config.getRateLimit());
 
@@ -29,6 +31,42 @@ const createApp = async () => {
   app.use(cors(corsOptions));
   app.set('trust proxy', 1);
   app.use(limiter);
+
+  // Add new admin-only endpoint to reload config
+  app.post('/api/v1/admin/reload-config', async (req, res) => {
+    if (!req.isAuthenticated() || !req.user.admin) {
+      return res.status(403).json({ error: 'Unauthorized' });
+    }
+
+    try {
+      // 1. Reload configuration
+      await configLoader.loadConfiguration();
+
+      // 2. Stop existing services
+      await proxy.stop();
+
+      // 3. Apply new configuration
+      config.validate();
+
+      // 4. Restart services with new config
+      await proxy.start();
+
+      console.log('Configuration reloaded and services restarted successfully');
+      res.json({ status: 'success', message: 'Configuration reloaded and services restarted' });
+    } catch (error) {
+      console.error('Failed to reload configuration and restart services:', error);
+
+      // Attempt to restart with existing config if reload fails
+      try {
+        await proxy.start();
+      } catch (startError) {
+        console.error('Failed to restart services:', startError);
+      }
+
+      res.status(500).json({ error: 'Failed to reload configuration' });
+    }
+  });
+
   app.use(
     session({
       store: config.getDatabase().type === 'mongo' ? db.getSessionStore(session) : null,
diff --git a/website/docs/configuration/overview.mdx b/website/docs/configuration/overview.mdx
index 5493d54f..274de544 100644
--- a/website/docs/configuration/overview.mdx
+++ b/website/docs/configuration/overview.mdx
@@ -7,6 +7,7 @@ description: How to customise push protections and policies
 
 On installation, GitProxy ships with an [out-of-the-box configuration](https://github.com/finos/git-proxy/blob/main/proxy.config.json). This is fine for
 demonstration purposes but is likely not what you want to deploy into your environment.
+
 ### Customise configuration
 
 To customise your GitProxy configuration, create a `proxy.config.json` in your current
@@ -44,8 +45,9 @@ npx -- @finos/git-proxy --config ./config.json
 ```
 
 ### Set ports with ENV variables
+
 By default, GitProxy uses port 8000 to expose the Git Server and 8080 for the frontend application.
-The ports can be changed by setting the `GIT_PROXY_SERVER_PORT`, `GIT_PROXY_HTTPS_SERVER_PORT` (optional) and `GIT_PROXY_UI_PORT` 
+The ports can be changed by setting the `GIT_PROXY_SERVER_PORT`, `GIT_PROXY_HTTPS_SERVER_PORT` (optional) and `GIT_PROXY_UI_PORT`
 environment variables:
 
 ```
@@ -54,10 +56,10 @@ export GIT_PROXY_SERVER_PORT="9090"
 export GIT_PROXY_HTTPS_SERVER_PORT="9443"
 ```
 
-Note that `GIT_PROXY_UI_PORT` is needed for both server and UI Node processes, 
+Note that `GIT_PROXY_UI_PORT` is needed for both server and UI Node processes,
 whereas `GIT_PROXY_SERVER_PORT` (and `GIT_PROXY_HTTPS_SERVER_PORT`) is only needed by the server process.
 
-By default, GitProxy CLI connects to GitProxy running on localhost and default port. This can be 
+By default, GitProxy CLI connects to GitProxy running on localhost and default port. This can be
 changed by setting the `GIT_PROXY_UI_HOST` and `GIT_PROXY_UI_PORT` environment variables:
 
 ```
@@ -79,5 +81,66 @@ To validate your configuration at a custom file location, run:
 git-proxy --validate --config ./config.json
 ```
 
+### Configuration Sources
+
+GitProxy supports dynamic configuration loading from multiple sources. This feature allows you to manage your configuration from external sources and update it without restarting the service. Configuration sources can be files, HTTP endpoints, or Git repositories.
+
+To enable configuration sources, add the `configurationSources` section to your configuration:
+
+```json
+{
+  "configurationSources": {
+    "enabled": true,
+    "reloadIntervalSeconds": 60,
+    "merge": false,
+    "sources": [
+      {
+        "type": "file",
+        "enabled": true,
+        "path": "./external-config.json"
+      },
+      {
+        "type": "http",
+        "enabled": true,
+        "url": "http://config-service/git-proxy-config",
+        "headers": {},
+        "auth": {
+          "type": "bearer",
+          "token": "your-token"
+        }
+      },
+      {
+        "type": "git",
+        "enabled": true,
+        "repository": "https://git-server.com/project/git-proxy-config",
+        "branch": "main",
+        "path": "git-proxy/config.json",
+        "auth": {
+          "type": "ssh",
+          "privateKeyPath": "/path/to/.ssh/id_rsa"
+        }
+      }
+    ]
+  }
+}
+```
+
+The configuration options for `configurationSources` are:
+
+- `enabled`: Enable/disable dynamic configuration loading
+- `reloadIntervalSeconds`: How often to check for configuration updates (in seconds)
+- `merge`: When true, merges configurations from all enabled sources. When false, uses the last successful configuration load. This can be used to upload only partial configuration to external source
+- `sources`: Array of configuration sources to load from
+
+Each source can be one of three types:
+
+1. `file`: Load from a local JSON file
+2. `http`: Load from an HTTP endpoint
+3. `git`: Load from a Git repository
 
+When configuration changes are detected, GitProxy will:
 
+1. Validate the new configuration
+2. Stop existing services
+3. Apply the new configuration
+4. Restart services with the updated configuration
diff --git a/website/docs/configuration/reference.mdx b/website/docs/configuration/reference.mdx
index 14d617b7..3b840230 100644
--- a/website/docs/configuration/reference.mdx
+++ b/website/docs/configuration/reference.mdx
@@ -648,5 +648,105 @@ description: JSON schema reference documentation for GitProxy
 </blockquote>
 </details>
 
-----------------------------------------------------------------------------------------------------------------------------
+<details>
+<summary>
+<strong> <a name="configurationSources"></a>19. [Optional] Property GitProxy configuration file > configurationSources</strong>
+</summary>
+<blockquote>
+
+|                           |                                                         |
+| ------------------------- | ------------------------------------------------------- |
+| **Type**                  | `object`                                                |
+| **Required**              | No                                                      |
+| **Additional properties** | [[Not allowed]](# "Additional Properties not allowed.") |
+
+**Description:** Configuration for dynamic loading from external sources
+
+<details>
+<summary>
+<strong> <a name="configurationSources_enabled"></a>19.1. [Optional] Property configurationSources > enabled</strong>
+</summary>
+<blockquote>
+
+|              |           |
+| ------------ | --------- |
+| **Type**     | `boolean` |
+| **Required** | No        |
+
+**Description:** Enable/disable dynamic configuration loading
+
+</blockquote>
+</details>
+
+<details>
+<summary>
+<strong> <a name="configurationSources_reloadIntervalSeconds"></a>19.2. [Optional] Property configurationSources > reloadIntervalSeconds</strong>
+</summary>
+<blockquote>
+
+|              |          |
+| ------------ | -------- |
+| **Type**     | `number` |
+| **Required** | No       |
+
+**Description:** How often to check for configuration updates (in seconds)
+
+</blockquote>
+</details>
+
+<details>
+<summary>
+<strong> <a name="configurationSources_merge"></a>19.3. [Optional] Property configurationSources > merge</strong>
+</summary>
+<blockquote>
+
+|              |           |
+| ------------ | --------- |
+| **Type**     | `boolean` |
+| **Required** | No        |
+
+**Description:** When true, merges configurations from all enabled sources. When false, uses the last successful configuration load
+
+</blockquote>
+</details>
+
+<details>
+<summary>
+<strong> <a name="configurationSources_sources"></a>19.4. [Optional] Property configurationSources > sources</strong>
+</summary>
+<blockquote>
+
+|              |         |
+| ------------ | ------- |
+| **Type**     | `array` |
+| **Required** | No      |
+
+**Description:** Array of configuration sources to load from
+
+Each item in the array must be an object with the following properties:
+
+- `type`: (Required) Type of configuration source (`"file"`, `"http"`, or `"git"`)
+- `enabled`: (Required) Whether this source is enabled
+- `path`: (Required for `file` type) Path to the configuration file
+- `url`: (Required for `http` type) URL of the configuration endpoint
+- `repository`: (Required for `git` type) Git repository URL
+- `branch`: (Optional for `git` type) Branch to use
+- `path`: (Required for `git` type) Path to configuration file in repository
+- `headers`: (Optional for `http` type) HTTP headers to include
+- `auth`: (Optional) Authentication configuration
+  - For `http` type:
+    - `type`: `"bearer"`
+    - `token`: Bearer token value
+  - For `git` type:
+    - `type`: `"ssh"`
+    - `privateKeyPath`: Path to SSH private key
+
+</blockquote>
+</details>
+
+</blockquote>
+</details>
+
+---
+
 Generated using [json-schema-for-humans](https://github.com/coveooss/json-schema-for-humans) on 2025-05-01 at 18:17:32 +0100



Test patch:
diff --git a/test/ConfigLoader.test.js b/test/ConfigLoader.test.js
new file mode 100644
index 00000000..59d63458
--- /dev/null
+++ b/test/ConfigLoader.test.js
@@ -0,0 +1,426 @@
+import fs from 'fs';
+import path from 'path';
+import { expect } from 'chai';
+import { ConfigLoader } from '../src/config/ConfigLoader';
+import { isValidGitUrl, isValidPath, isValidBranchName } from '../src/config/ConfigLoader';
+import sinon from 'sinon';
+import axios from 'axios';
+
+describe('ConfigLoader', () => {
+  let configLoader;
+  let tempDir;
+  let tempConfigFile;
+
+  beforeEach(() => {
+    // Create temp directory for test files
+    tempDir = fs.mkdtempSync('gitproxy-configloader-test-');
+    tempConfigFile = path.join(tempDir, 'test-config.json');
+  });
+
+  afterEach(() => {
+    // Clean up temp files
+    if (fs.existsSync(tempDir)) {
+      fs.rmSync(tempDir, { recursive: true });
+    }
+    sinon.restore();
+  });
+
+  describe('loadFromFile', () => {
+    it('should load configuration from file', async () => {
+      const testConfig = {
+        proxyUrl: 'https://test.com',
+        cookieSecret: 'test-secret',
+      };
+      fs.writeFileSync(tempConfigFile, JSON.stringify(testConfig));
+
+      configLoader = new ConfigLoader({});
+      const result = await configLoader.loadFromFile({
+        type: 'file',
+        enabled: true,
+        path: tempConfigFile,
+      });
+
+      expect(result).to.deep.equal(testConfig);
+    });
+  });
+
+  describe('loadFromHttp', () => {
+    it('should load configuration from HTTP endpoint', async () => {
+      const testConfig = {
+        proxyUrl: 'https://test.com',
+        cookieSecret: 'test-secret',
+      };
+
+      sinon.stub(axios, 'get').resolves({ data: testConfig });
+
+      configLoader = new ConfigLoader({});
+      const result = await configLoader.loadFromHttp({
+        type: 'http',
+        enabled: true,
+        url: 'http://config-service/config',
+        headers: {},
+      });
+
+      expect(result).to.deep.equal(testConfig);
+    });
+
+    it('should include bearer token if provided', async () => {
+      const axiosStub = sinon.stub(axios, 'get').resolves({ data: {} });
+
+      configLoader = new ConfigLoader({});
+      await configLoader.loadFromHttp({
+        type: 'http',
+        enabled: true,
+        url: 'http://config-service/config',
+        auth: {
+          type: 'bearer',
+          token: 'test-token',
+        },
+      });
+
+      expect(
+        axiosStub.calledWith('http://config-service/config', {
+          headers: { Authorization: 'Bearer test-token' },
+        }),
+      ).to.be.true;
+    });
+  });
+
+  describe('reloadConfiguration', () => {
+    it('should emit configurationChanged event when config changes', async () => {
+      const initialConfig = {
+        configurationSources: {
+          enabled: true,
+          sources: [
+            {
+              type: 'file',
+              enabled: true,
+              path: tempConfigFile,
+            },
+          ],
+          reloadIntervalSeconds: 0,
+        },
+      };
+
+      const newConfig = {
+        proxyUrl: 'https://new-test.com',
+      };
+
+      fs.writeFileSync(tempConfigFile, JSON.stringify(newConfig));
+
+      configLoader = new ConfigLoader(initialConfig);
+      const spy = sinon.spy();
+      configLoader.on('configurationChanged', spy);
+
+      await configLoader.reloadConfiguration();
+
+      expect(spy.calledOnce).to.be.true;
+      expect(spy.firstCall.args[0]).to.deep.include(newConfig);
+    });
+
+    it('should not emit event if config has not changed', async () => {
+      const testConfig = {
+        proxyUrl: 'https://test.com',
+      };
+
+      const config = {
+        configurationSources: {
+          enabled: true,
+          sources: [
+            {
+              type: 'file',
+              enabled: true,
+              path: tempConfigFile,
+            },
+          ],
+          reloadIntervalSeconds: 0,
+        },
+      };
+
+      fs.writeFileSync(tempConfigFile, JSON.stringify(testConfig));
+
+      configLoader = new ConfigLoader(config);
+      const spy = sinon.spy();
+      configLoader.on('configurationChanged', spy);
+
+      await configLoader.reloadConfiguration(); // First reload should emit
+      await configLoader.reloadConfiguration(); // Second reload should not emit since config hasn't changed
+
+      expect(spy.calledOnce).to.be.true; // Should only emit once
+    });
+  });
+
+  describe('initialize', () => {
+    it('should initialize cache directory using env-paths', async () => {
+      const configLoader = new ConfigLoader({});
+      await configLoader.initialize();
+
+      // Check that cacheDir is set and is a string
+      expect(configLoader.cacheDir).to.be.a('string');
+
+      // Check that it contains 'git-proxy' in the path
+      expect(configLoader.cacheDir).to.include('git-proxy');
+
+      // On macOS, it should be in the Library/Caches directory
+      // On Linux, it should be in the ~/.cache directory
+      // On Windows, it should be in the AppData/Local directory
+      if (process.platform === 'darwin') {
+        expect(configLoader.cacheDir).to.include('Library/Caches');
+      } else if (process.platform === 'linux') {
+        expect(configLoader.cacheDir).to.include('.cache');
+      } else if (process.platform === 'win32') {
+        expect(configLoader.cacheDir).to.include('AppData/Local');
+      }
+    });
+
+    it('should create cache directory if it does not exist', async () => {
+      const configLoader = new ConfigLoader({});
+      await configLoader.initialize();
+
+      // Check if directory exists
+      expect(fs.existsSync(configLoader.cacheDir)).to.be.true;
+    });
+  });
+
+  describe('loadRemoteConfig', () => {
+    let configLoader;
+    beforeEach(async () => {
+      const configFilePath = path.join(__dirname, '..', 'proxy.config.json');
+      const config = JSON.parse(fs.readFileSync(configFilePath, 'utf-8'));
+
+      config.configurationSources.enabled = true;
+      configLoader = new ConfigLoader(config);
+      await configLoader.initialize();
+    });
+
+    it('should load configuration from git repository', async function () {
+      // eslint-disable-next-line no-invalid-this
+      this.timeout(10000);
+
+      const source = {
+        type: 'git',
+        repository: 'https://github.com/finos/git-proxy.git',
+        path: 'proxy.config.json',
+        branch: 'main',
+        enabled: true,
+      };
+
+      const config = await configLoader.loadFromGit(source);
+
+      // Verify the loaded config has expected structure
+      expect(config).to.be.an('object');
+      expect(config).to.have.property('proxyUrl');
+      expect(config).to.have.property('cookieSecret');
+    });
+
+    it('should throw error for invalid configuration file path', async function () {
+      const source = {
+        type: 'git',
+        repository: 'https://github.com/finos/git-proxy.git',
+        path: '\0', // Invalid path
+        branch: 'main',
+        enabled: true,
+      };
+
+      try {
+        await configLoader.loadFromGit(source);
+        throw new Error('Expected error was not thrown');
+      } catch (error) {
+        expect(error.message).to.equal('Invalid configuration file path in repository');
+      }
+    });
+
+    it('should load configuration from http', async function () {
+      // eslint-disable-next-line no-invalid-this
+      this.timeout(10000);
+
+      const source = {
+        type: 'http',
+        url: 'https://raw.githubusercontent.com/finos/git-proxy/refs/heads/main/proxy.config.json',
+        enabled: true,
+      };
+
+      const config = await configLoader.loadFromHttp(source);
+
+      // Verify the loaded config has expected structure
+      expect(config).to.be.an('object');
+      expect(config).to.have.property('proxyUrl');
+      expect(config).to.have.property('cookieSecret');
+    });
+  });
+
+  describe('deepMerge', () => {
+    let configLoader;
+
+    beforeEach(() => {
+      configLoader = new ConfigLoader({});
+    });
+
+    it('should merge simple objects', () => {
+      const target = { a: 1, b: 2 };
+      const source = { b: 3, c: 4 };
+
+      const result = configLoader.deepMerge(target, source);
+
+      expect(result).to.deep.equal({ a: 1, b: 3, c: 4 });
+    });
+
+    it('should merge nested objects', () => {
+      const target = {
+        a: 1,
+        b: { x: 1, y: 2 },
+        c: { z: 3 },
+      };
+      const source = {
+        b: { y: 4, w: 5 },
+        c: { z: 6 },
+      };
+
+      const result = configLoader.deepMerge(target, source);
+
+      expect(result).to.deep.equal({
+        a: 1,
+        b: { x: 1, y: 4, w: 5 },
+        c: { z: 6 },
+      });
+    });
+
+    it('should handle arrays by replacing them', () => {
+      const target = {
+        a: [1, 2, 3],
+        b: { items: [4, 5] },
+      };
+      const source = {
+        a: [7, 8],
+        b: { items: [9] },
+      };
+
+      const result = configLoader.deepMerge(target, source);
+
+      expect(result).to.deep.equal({
+        a: [7, 8],
+        b: { items: [9] },
+      });
+    });
+
+    it('should handle null and undefined values', () => {
+      const target = {
+        a: 1,
+        b: null,
+        c: undefined,
+      };
+      const source = {
+        a: null,
+        b: 2,
+        c: 3,
+      };
+
+      const result = configLoader.deepMerge(target, source);
+
+      expect(result).to.deep.equal({
+        a: null,
+        b: 2,
+        c: 3,
+      });
+    });
+
+    it('should handle empty objects', () => {
+      const target = {};
+      const source = { a: 1, b: { c: 2 } };
+
+      const result = configLoader.deepMerge(target, source);
+
+      expect(result).to.deep.equal({ a: 1, b: { c: 2 } });
+    });
+
+    it('should not modify the original objects', () => {
+      const target = { a: 1, b: { c: 2 } };
+      const source = { b: { c: 3 } };
+      const originalTarget = { ...target };
+      const originalSource = { ...source };
+
+      configLoader.deepMerge(target, source);
+
+      expect(target).to.deep.equal(originalTarget);
+      expect(source).to.deep.equal(originalSource);
+    });
+  });
+});
+
+describe('Validation Helpers', () => {
+  describe('isValidGitUrl', () => {
+    it('should validate git URLs correctly', () => {
+      // Valid URLs
+      expect(isValidGitUrl('git://github.com/user/repo.git')).to.be.true;
+      expect(isValidGitUrl('https://github.com/user/repo.git')).to.be.true;
+      expect(isValidGitUrl('ssh://git@github.com/user/repo.git')).to.be.true;
+      expect(isValidGitUrl('user@github.com:user/repo.git')).to.be.true;
+
+      // Invalid URLs
+      expect(isValidGitUrl('not-a-git-url')).to.be.false;
+      expect(isValidGitUrl('http://github.com/user/repo')).to.be.false;
+      expect(isValidGitUrl('')).to.be.false;
+      expect(isValidGitUrl(null)).to.be.false;
+      expect(isValidGitUrl(undefined)).to.be.false;
+      expect(isValidGitUrl(123)).to.be.false;
+    });
+  });
+
+  describe('isValidPath', () => {
+    it('should validate file paths correctly', () => {
+      const cwd = process.cwd();
+
+      // Valid paths
+      expect(isValidPath(path.join(cwd, 'config.json'))).to.be.true;
+      expect(isValidPath(path.join(cwd, 'subfolder/config.json'))).to.be.true;
+      expect(isValidPath('/etc/passwd')).to.be.true;
+      expect(isValidPath('../config.json')).to.be.true;
+
+      // Invalid paths
+      expect(isValidPath('')).to.be.false;
+      expect(isValidPath(null)).to.be.false;
+      expect(isValidPath(undefined)).to.be.false;
+
+      // Additional edge cases
+      expect(isValidPath({})).to.be.false;
+      expect(isValidPath([])).to.be.false;
+      expect(isValidPath(123)).to.be.false;
+      expect(isValidPath(true)).to.be.false;
+      expect(isValidPath('\0invalid')).to.be.false;
+      expect(isValidPath('\u0000')).to.be.false;
+    });
+
+    it('should handle path resolution errors', () => {
+      // Mock path.resolve to throw an error
+      const originalResolve = path.resolve;
+      path.resolve = () => {
+        throw new Error('Mock path resolution error');
+      };
+
+      expect(isValidPath('some/path')).to.be.false;
+
+      // Restore original path.resolve
+      path.resolve = originalResolve;
+    });
+  });
+
+  describe('isValidBranchName', () => {
+    it('should validate git branch names correctly', () => {
+      // Valid branch names
+      expect(isValidBranchName('main')).to.be.true;
+      expect(isValidBranchName('feature/new-feature')).to.be.true;
+      expect(isValidBranchName('release-1.0')).to.be.true;
+      expect(isValidBranchName('fix_123')).to.be.true;
+      expect(isValidBranchName('user/feature/branch')).to.be.true;
+
+      // Invalid branch names
+      expect(isValidBranchName('.invalid')).to.be.false;
+      expect(isValidBranchName('-invalid')).to.be.false;
+      expect(isValidBranchName('branch with spaces')).to.be.false;
+      expect(isValidBranchName('')).to.be.false;
+      expect(isValidBranchName(null)).to.be.false;
+      expect(isValidBranchName(undefined)).to.be.false;
+      expect(isValidBranchName('branch..name')).to.be.false;
+    });
+  });
+});
diff --git a/test/chain.test.js b/test/chain.test.js
index d2c14620..1fc74924 100644
--- a/test/chain.test.js
+++ b/test/chain.test.js
@@ -15,67 +15,81 @@ const mockLoader = {
   ],
 };
 
-const mockPushProcessors = {
-  parsePush: sinon.stub(),
-  audit: sinon.stub(),
-  checkRepoInAuthorisedList: sinon.stub(),
-  checkCommitMessages: sinon.stub(),
-  checkAuthorEmails: sinon.stub(),
-  checkUserPushPermission: sinon.stub(),
-  checkIfWaitingAuth: sinon.stub(),
-  pullRemote: sinon.stub(),
-  writePack: sinon.stub(),
-  preReceive: sinon.stub(),
-  getDiff: sinon.stub(),
-  gitleaks: sinon.stub(),
-  clearBareClone: sinon.stub(),
-  scanDiff: sinon.stub(),
-  blockForAuth: sinon.stub(),
+const initMockPushProcessors = (sinon) => {
+  const mockPushProcessors = {
+    parsePush: sinon.stub(),
+    audit: sinon.stub(),
+    checkRepoInAuthorisedList: sinon.stub(),
+    checkCommitMessages: sinon.stub(),
+    checkAuthorEmails: sinon.stub(),
+    checkUserPushPermission: sinon.stub(),
+    checkIfWaitingAuth: sinon.stub(),
+    pullRemote: sinon.stub(),
+    writePack: sinon.stub(),
+    preReceive: sinon.stub(),
+    getDiff: sinon.stub(),
+    gitleaks: sinon.stub(),
+    clearBareClone: sinon.stub(),
+    scanDiff: sinon.stub(),
+    blockForAuth: sinon.stub(),
+  };
+  mockPushProcessors.parsePush.displayName = 'parsePush';
+  mockPushProcessors.audit.displayName = 'audit';
+  mockPushProcessors.checkRepoInAuthorisedList.displayName = 'checkRepoInAuthorisedList';
+  mockPushProcessors.checkCommitMessages.displayName = 'checkCommitMessages';
+  mockPushProcessors.checkAuthorEmails.displayName = 'checkAuthorEmails';
+  mockPushProcessors.checkUserPushPermission.displayName = 'checkUserPushPermission';
+  mockPushProcessors.checkIfWaitingAuth.displayName = 'checkIfWaitingAuth';
+  mockPushProcessors.pullRemote.displayName = 'pullRemote';
+  mockPushProcessors.writePack.displayName = 'writePack';
+  mockPushProcessors.preReceive.displayName = 'preReceive';
+  mockPushProcessors.getDiff.displayName = 'getDiff';
+  mockPushProcessors.gitleaks.displayName = 'gitleaks';
+  mockPushProcessors.clearBareClone.displayName = 'clearBareClone';
+  mockPushProcessors.scanDiff.displayName = 'scanDiff';
+  mockPushProcessors.blockForAuth.displayName = 'blockForAuth';
+  return mockPushProcessors;
 };
-mockPushProcessors.parsePush.displayName = 'parsePush';
-mockPushProcessors.audit.displayName = 'audit';
-mockPushProcessors.checkRepoInAuthorisedList.displayName = 'checkRepoInAuthorisedList';
-mockPushProcessors.checkCommitMessages.displayName = 'checkCommitMessages';
-mockPushProcessors.checkAuthorEmails.displayName = 'checkAuthorEmails';
-mockPushProcessors.checkUserPushPermission.displayName = 'checkUserPushPermission';
-mockPushProcessors.checkIfWaitingAuth.displayName = 'checkIfWaitingAuth';
-mockPushProcessors.pullRemote.displayName = 'pullRemote';
-mockPushProcessors.writePack.displayName = 'writePack';
-mockPushProcessors.preReceive.displayName = 'preReceive';
-mockPushProcessors.getDiff.displayName = 'getDiff';
-mockPushProcessors.gitleaks.displayName = 'gitleaks';
-mockPushProcessors.clearBareClone.displayName = 'clearBareClone';
-mockPushProcessors.scanDiff.displayName = 'scanDiff';
-mockPushProcessors.blockForAuth.displayName = 'blockForAuth';
 
 const mockPreProcessors = {
   parseAction: sinon.stub(),
 };
 
+const clearCache = (sandbox) => {
+  delete require.cache[require.resolve('../src/proxy/processors')];
+  delete require.cache[require.resolve('../src/proxy/chain')];
+  sandbox.reset();
+};
+
 describe('proxy chain', function () {
   let processors;
   let chain;
+  let mockPushProcessors;
+  let sandboxSinon;
 
   beforeEach(async () => {
+    // Create a new sandbox for each test
+    sandboxSinon = sinon.createSandbox();
+    // Initialize the mock push processors
+    mockPushProcessors = initMockPushProcessors(sandboxSinon);
+
     // Re-import the processors module after clearing the cache
     processors = await import('../src/proxy/processors');
 
     // Mock the processors module
-    sinon.stub(processors, 'pre').value(mockPreProcessors);
+    sandboxSinon.stub(processors, 'pre').value(mockPreProcessors);
 
-    sinon.stub(processors, 'push').value(mockPushProcessors);
+    sandboxSinon.stub(processors, 'push').value(mockPushProcessors);
 
     // Re-import the chain module after stubbing processors
-    chain = (await import('../src/proxy/chain')).default;
+    chain = require('../src/proxy/chain').default;
 
     chain.chainPluginLoader = new PluginLoader([]);
   });
 
   afterEach(() => {
     // Clear the module from the cache after each test
-    delete require.cache[require.resolve('../src/proxy/processors')];
-    delete require.cache[require.resolve('../src/proxy/chain')];
-    sinon.reset();
+    clearCache(sandboxSinon);
   });
 
   it('getChain should set pluginLoaded if loader is undefined', async function () {