From 7232e8fdcdf55630c43db4ec83bea676f0fa00c7 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Wed, 19 Aug 2026 17:05:57 +0900 Subject: [PATCH 01/12] feat: add isCollectible, displayName, phase and chain options for plugins --- src/plugin.ts | 100 ++++++++++++++++++++++++++++++---- src/proxy/processors/types.ts | 20 +++++++ 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index 2550283fa..df46a2624 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -20,6 +20,7 @@ import Module from 'node:module'; import { Action } from './proxy/actions'; import { handleErrorAndLog } from './utils/errors'; +import { PullPhase, PushChainName, PushPhase } from './proxy/processors/types'; /* eslint-disable @typescript-eslint/no-unused-expressions */ ('use strict'); @@ -192,11 +193,68 @@ class ProxyPlugin { } /** - * A plugin which executes a function when receiving a git push request. + * Options for all ActionPlugin instances. + * @property {boolean} isCollectible - If true, the plugin will not stop the chain if it fails. Errors will be collected + * and reported at the end of the chain. Useful for plugins that are not critical to the success of the operation. + * @property {string} displayName - The name of the plugin which is used for user-facing progress reporting. Optional. + * @property {PushPhase | PullPhase} phase - The phase of the action chain where the plugin will be executed. Optional. + */ +interface ActionPluginOptions { + readonly isCollectible?: boolean; + readonly displayName?: string; + readonly phase?: PushPhase | PullPhase; +} + +/** + * Options for PushActionPlugin instances, extended from {ActionPluginOptions}. + * @property {PushPhase} phase - The phase of the *push* action chain where the plugin will be executed. Defaults to {PushPhase.AFTER_PERMISSIONS}. + * @property {PushChainName[]} chains - The push operations where the plugin will be executed. Optional, defaults to all push operations. + */ +interface PushPluginOptions extends ActionPluginOptions { + readonly phase?: PushPhase; + readonly chains?: PushChainName[]; +} + +/** + * Options for PullActionPlugin instances, extended from {ActionPluginOptions}. + * @property {PullPhase} phase - The phase of the *pull* action chain where the plugin will be executed. Defaults to {PullPhase.AFTER_AUTHORISATION}. + */ +interface PullPluginOptions extends ActionPluginOptions { + readonly phase?: PullPhase; +} + +/** + * Base class for all action plugins (executed as part of the action chain for + * `git push` or `git pull` operations). */ -class PushActionPlugin extends ProxyPlugin { - isGitProxyPushActionPlugin: boolean; +export abstract class ActionPlugin extends ProxyPlugin { exec: (req: Request, action: Action) => Promise; + readonly isCollectible: boolean; + readonly displayName?: string; + readonly phase: PushPhase | PullPhase; + + /** + * Parent constructor for all ActionPlugin instances. Do not use this constructor directly. + */ + constructor( + exec: (req: Request, action: Action) => Promise, + options: ActionPluginOptions & { phase: PushPhase | PullPhase }, + ) { + super(); + this.exec = exec; + this.isCollectible = options.isCollectible ?? false; + this.displayName = options.displayName; + this.phase = options.phase; + } +} + +/** + * A plugin which executes a function when receiving a git push request. + */ +class PushActionPlugin extends ActionPlugin { + isGitProxyPushActionPlugin = true; + declare readonly phase: PushPhase; + declare readonly chains?: PushChainName[]; /** * Wrapper class which contains at least one function executed as part of the action chain for git push operations. @@ -211,20 +269,30 @@ class PushActionPlugin extends ProxyPlugin { * - Takes in an Express Request object as the first parameter (`req`). * - Takes in an Action object as the second parameter (`action`). * - Returns a Promise that resolves to an Action. + * + * @param {PushPluginOptions} options - An object containing the following properties: + * - {boolean} isCollectible - If true, the plugin will not stop the chain if it fails. Errors will be collected + * and reported at the end of the chain. Useful for plugins that are not critical to the success of the operation. + * - {string} displayName - The name of the plugin which is used for user-facing progress reporting. Optional. + * - {PushPhase} phase - The phase of the *push* action chain where the plugin will be executed. Optional, defaults to {PushPhase.AFTER_PERMISSIONS}. + * - {PushChainName[]} chains - The push operations where the plugin will be executed. Optional, defaults to all push operations. */ - constructor(exec: (req: Request, action: Action) => Promise) { - super(); + constructor( + exec: (req: Request, action: Action) => Promise, + options: PushPluginOptions = {}, + ) { + super(exec, { ...options, phase: options.phase ?? PushPhase.AFTER_PERMISSIONS }); this.isGitProxyPushActionPlugin = true; - this.exec = exec; + this.chains = options.chains; } } /** * A plugin which executes a function when receiving a git fetch request. */ -class PullActionPlugin extends ProxyPlugin { - isGitProxyPullActionPlugin: boolean; - exec: (req: Request, action: Action) => Promise; +class PullActionPlugin extends ActionPlugin { + isGitProxyPullActionPlugin = true; + declare readonly phase: PullPhase; /** * Wrapper class which contains at least one function executed as part of the action chain for git pull operations. @@ -239,11 +307,19 @@ class PullActionPlugin extends ProxyPlugin { * - Takes in an Express Request object as the first parameter (`req`). * - Takes in an Action object as the second parameter (`action`). * - Returns a Promise that resolves to an Action. + * + * @param {PushPluginOptions} options - An object containing the following properties: + * - {boolean} isCollectible - If true, the plugin will not stop the chain if it fails. Errors will be collected + * and reported at the end of the chain. Useful for plugins that are not critical to the success of the operation. + * - {string} displayName - The name of the plugin which is used for user-facing progress reporting. Optional. + * - {PullPhase} phase - The phase of the *pull* action chain where the plugin will be executed. Optional, defaults to {PullPhase.AFTER_AUTHORISATION}. */ - constructor(exec: (req: Request, action: Action) => Promise) { - super(); + constructor( + exec: (req: Request, action: Action) => Promise, + options: PullPluginOptions = {}, + ) { + super(exec, { ...options, phase: options.phase ?? PullPhase.AFTER_AUTHORISATION }); this.isGitProxyPullActionPlugin = true; - this.exec = exec; } } diff --git a/src/proxy/processors/types.ts b/src/proxy/processors/types.ts index 550255761..2bd16aa12 100644 --- a/src/proxy/processors/types.ts +++ b/src/proxy/processors/types.ts @@ -31,6 +31,26 @@ export interface ProcessorExec { readonly isCollectible?: boolean; } +/** + * A single element of a chain. Can be a processor function, a push phase, or a pull phase. + */ +export type ChainElement = ProcessorExec | PushPhase | PullPhase; + +export const PushPhase = { + AFTER_PERMISSIONS: 'AFTER_PERMISSIONS', + AFTER_CHECKOUT: 'AFTER_CHECKOUT', + AFTER_DIFF: 'AFTER_DIFF', + BEFORE_APPROVAL: 'BEFORE_APPROVAL', +}; +export type PushPhase = (typeof PushPhase)[keyof typeof PushPhase]; + +export const PullPhase = { + AFTER_AUTHORISATION: 'AFTER_AUTHORISATION', +}; +export type PullPhase = (typeof PullPhase)[keyof typeof PullPhase]; + +export type PushChainName = 'tag' | 'branch'; + export interface Processor { exec: ProcessorExec; metadata: ProcessorMetadata; From ac4aae8686029500c9353592871b32c96f0fe1fe Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Wed, 19 Aug 2026 17:09:32 +0900 Subject: [PATCH 02/12] feat: build chain from elements, make chain immutable --- src/proxy/chain.ts | 93 ++++++++++++++++++++++++++++------------------ 1 file changed, 56 insertions(+), 37 deletions(-) diff --git a/src/proxy/chain.ts b/src/proxy/chain.ts index cab32f5c4..dd15150dd 100644 --- a/src/proxy/chain.ts +++ b/src/proxy/chain.ts @@ -16,15 +16,21 @@ import { Request, Response } from 'express'; -import { PluginLoader } from '../plugin'; +import { PluginLoader, ActionPlugin, PushActionPlugin } from '../plugin'; import { Action, RequestType, PushType } from './actions'; import * as proc from './processors'; -import { ProcessorExec } from './processors/types'; +import { + ProcessorExec, + PullPhase, + PushPhase, + ChainElement, + PushChainName, +} from './processors/types'; import { attemptAutoApproval, attemptAutoRejection } from './actions/autoActions'; import { handleErrorAndLog } from '../utils/errors'; import { createProgressWriter } from './sideband'; -const branchPushChain: ProcessorExec[] = [ +const branchPushChainElements: ChainElement[] = [ proc.push.resolveUserFromToken, proc.push.checkEmptyBranch, proc.push.checkRepoInAuthorisedList, @@ -42,7 +48,7 @@ const branchPushChain: ProcessorExec[] = [ proc.push.blockForAuth, ]; -const tagPushChain: ProcessorExec[] = [ +const tagPushChainElements: ChainElement[] = [ proc.push.checkRepoInAuthorisedList, proc.push.checkUserPushPermission, proc.push.checkIfWaitingAuth, @@ -53,11 +59,14 @@ const tagPushChain: ProcessorExec[] = [ proc.push.blockForAuth, ]; -const pullActionChain: ProcessorExec[] = [proc.push.checkRepoInAuthorisedList]; +const pullActionChainElements: ChainElement[] = [ + proc.push.checkRepoInAuthorisedList, + PullPhase.AFTER_AUTHORISATION, +]; -const defaultActionChain: ProcessorExec[] = [proc.push.checkRepoInAuthorisedList]; +const defaultActionChainElements: ChainElement[] = [proc.push.checkRepoInAuthorisedList]; -let pluginsInserted = false; +let builtChains: Record = {}; /** * Compose a single error message from all failed steps, so that the git @@ -208,39 +217,52 @@ export const executeChain = async (req: Request, res: Response): Promise */ let chainPluginLoader: PluginLoader; +const buildChain = ( + elements: ChainElement[], + chainName: string, + plugins: ActionPlugin[], +): ProcessorExec[] => + elements.flatMap((element) => + typeof element === 'function' + ? [element] + : plugins.filter((plugin) => plugin.phase === element).map((plugin) => plugin.exec), + ); + +const filterPushPluginsByChain = (plugins: readonly PushActionPlugin[], chainName: PushChainName) => + plugins.filter((p) => (p.chains ?? ['branch', 'tag']).includes(chainName)); + +const buildAllChains = (): Record => { + const pushPlugins = chainPluginLoader.pushPlugins; + const pullPlugins = chainPluginLoader.pullPlugins; + + return { + branch: buildChain( + branchPushChainElements, + 'branch', + filterPushPluginsByChain(pushPlugins, 'branch'), + ), + tag: buildChain(tagPushChainElements, 'tag', filterPushPluginsByChain(pushPlugins, 'tag')), + pull: buildChain(pullActionChainElements, 'pull', pullPlugins), + default: [...defaultActionChainElements] as ProcessorExec[], + }; +}; + export const getChain = async (action: Action): Promise => { if (chainPluginLoader === undefined) { - console.error( - 'Plugin loader was not initialized! This is an application error. Please report it to the GitProxy maintainers. Skipping plugins...', + throw new Error( + 'Plugin loader was not initialized! This is an application error. Please report it to the GitProxy maintainers.', ); - pluginsInserted = true; } - if (!pluginsInserted) { - console.log( - `Inserting loaded plugins (${chainPluginLoader.pushPlugins.length} push, ${chainPluginLoader.pullPlugins.length} pull) into proxy chains`, - ); - for (const pluginObj of chainPluginLoader.pushPlugins) { - console.log(`Inserting push plugin ${pluginObj.constructor.name} into chain`); - branchPushChain.splice(0, 0, pluginObj.exec); - tagPushChain.splice(0, 0, pluginObj.exec); - } - for (const pluginObj of chainPluginLoader.pullPlugins) { - console.log(`Inserting pull plugin ${pluginObj.constructor.name} into chain`); - // insert custom functions before other pull actions - pullActionChain.splice(0, 0, pluginObj.exec); - } - // This is set to true so that we don't re-insert the plugins into the chain - pluginsInserted = true; - } + builtChains ??= buildAllChains(); switch (action.type) { case RequestType.PULL: - return pullActionChain; + return builtChains.pull; case RequestType.PUSH: - return action.actionType === PushType.TAG ? tagPushChain : branchPushChain; + return action.actionType === PushType.TAG ? builtChains.tag : builtChains.branch; default: - return defaultActionChain; + return builtChains.default; } }; @@ -251,20 +273,17 @@ export default { get chainPluginLoader() { return chainPluginLoader; }, - get pluginsInserted() { - return pluginsInserted; - }, get branchPushChain() { - return branchPushChain; + return builtChains.branch; }, get tagPushChain() { - return tagPushChain; + return builtChains.tag; }, get pullActionChain() { - return pullActionChain; + return builtChains.pull; }, get defaultActionChain() { - return defaultActionChain; + return builtChains.default; }, executeChain, getChain, From 45bbc69f9ad9a804586b521532c357a57c1dbd56 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 21 Aug 2026 15:31:23 +0900 Subject: [PATCH 03/12] test: fix failing chain tests --- plugins/git-proxy-plugin-samples/package.json | 2 +- src/proxy/chain.ts | 13 +++++++------ src/proxy/processors/types.ts | 7 +++++++ test/chain.test.ts | 18 ++---------------- test/fixtures/test-package/package-lock.json | 4 +++- 5 files changed, 20 insertions(+), 24 deletions(-) diff --git a/plugins/git-proxy-plugin-samples/package.json b/plugins/git-proxy-plugin-samples/package.json index e571da7d9..33b4fa492 100644 --- a/plugins/git-proxy-plugin-samples/package.json +++ b/plugins/git-proxy-plugin-samples/package.json @@ -16,6 +16,6 @@ "express": "^5.2.1" }, "peerDependencies": { - "@finos/git-proxy": "^2.0.0" + "@finos/git-proxy": "^2.1.0" } } diff --git a/src/proxy/chain.ts b/src/proxy/chain.ts index dd15150dd..9d915b64f 100644 --- a/src/proxy/chain.ts +++ b/src/proxy/chain.ts @@ -25,6 +25,7 @@ import { PushPhase, ChainElement, PushChainName, + BuiltChains, } from './processors/types'; import { attemptAutoApproval, attemptAutoRejection } from './actions/autoActions'; import { handleErrorAndLog } from '../utils/errors'; @@ -66,7 +67,7 @@ const pullActionChainElements: ChainElement[] = [ const defaultActionChainElements: ChainElement[] = [proc.push.checkRepoInAuthorisedList]; -let builtChains: Record = {}; +let builtChains: BuiltChains | undefined; /** * Compose a single error message from all failed steps, so that the git @@ -231,7 +232,7 @@ const buildChain = ( const filterPushPluginsByChain = (plugins: readonly PushActionPlugin[], chainName: PushChainName) => plugins.filter((p) => (p.chains ?? ['branch', 'tag']).includes(chainName)); -const buildAllChains = (): Record => { +const buildAllChains = (): BuiltChains => { const pushPlugins = chainPluginLoader.pushPlugins; const pullPlugins = chainPluginLoader.pullPlugins; @@ -274,16 +275,16 @@ export default { return chainPluginLoader; }, get branchPushChain() { - return builtChains.branch; + return builtChains?.branch ?? []; }, get tagPushChain() { - return builtChains.tag; + return builtChains?.tag ?? []; }, get pullActionChain() { - return builtChains.pull; + return builtChains?.pull ?? []; }, get defaultActionChain() { - return builtChains.default; + return builtChains?.default ?? []; }, executeChain, getChain, diff --git a/src/proxy/processors/types.ts b/src/proxy/processors/types.ts index 2bd16aa12..56ee6ce52 100644 --- a/src/proxy/processors/types.ts +++ b/src/proxy/processors/types.ts @@ -36,6 +36,13 @@ export interface ProcessorExec { */ export type ChainElement = ProcessorExec | PushPhase | PullPhase; +export interface BuiltChains { + branch: ProcessorExec[]; + tag: ProcessorExec[]; + pull: ProcessorExec[]; + default: ProcessorExec[]; +} + export const PushPhase = { AFTER_PERMISSIONS: 'AFTER_PERMISSIONS', AFTER_CHECKOUT: 'AFTER_CHECKOUT', diff --git a/test/chain.test.ts b/test/chain.test.ts index 24612ad8d..0a678b1eb 100644 --- a/test/chain.test.ts +++ b/test/chain.test.ts @@ -114,12 +114,9 @@ describe('proxy chain', function () { vi.resetModules(); }); - it('getChain should set pluginLoaded if loader is undefined', async () => { + it('getChain should throw an error if loader is undefined', async () => { chain.chainPluginLoader = undefined; - const actual = await chain.getChain({ type: 'push' }); - expect(actual).toEqual(chain.branchPushChain); - expect(chain.chainPluginLoader).toBeUndefined(); - expect(chain.pluginsInserted).toBe(true); + await expect(chain.getChain({ type: 'push' })).rejects.toThrow(/Plugin loader was not initialized/); }); it('getChain should load plugins from an initialized PluginLoader', async () => { @@ -127,7 +124,6 @@ describe('proxy chain', function () { const initialChain = [...chain.branchPushChain]; const actual = await chain.getChain({ type: 'push' }); expect(actual.length).toBeGreaterThan(initialChain.length); - expect(chain.pluginsInserted).toBe(true); }); it('getChain should load pull plugins from an initialized PluginLoader', async () => { @@ -135,7 +131,6 @@ describe('proxy chain', function () { const initialChain = [...chain.pullActionChain]; const actual = await chain.getChain({ type: 'pull' }); expect(actual.length).toBeGreaterThan(initialChain.length); - expect(chain.pluginsInserted).toBe(true); }); it('executeChain should stop executing if action has continue returns false', async () => { @@ -586,19 +581,10 @@ describe('proxy chain', function () { expect(branchChain).toEqual(chain.branchPushChain); }); - it('getChain should return tagPushChain if loader is undefined for tag pushes', async () => { - chain.chainPluginLoader = undefined; - const actual = await chain.getChain({ type: RequestType.PUSH, actionType: PushType.TAG }); - expect(actual).toEqual(chain.tagPushChain); - expect(chain.chainPluginLoader).toBeUndefined(); - expect(chain.pluginsInserted).toBe(true); - }); - it('getChain should load tag plugins from an initialized PluginLoader', async () => { chain.chainPluginLoader = mockLoader; const initialChain = [...chain.tagPushChain]; const actual = await chain.getChain({ type: RequestType.PUSH, actionType: PushType.TAG }); expect(actual.length).toBeGreaterThan(initialChain.length); - expect(chain.pluginsInserted).toBe(true); }); }); diff --git a/test/fixtures/test-package/package-lock.json b/test/fixtures/test-package/package-lock.json index 35831992a..36d1ceb25 100644 --- a/test/fixtures/test-package/package-lock.json +++ b/test/fixtures/test-package/package-lock.json @@ -13,7 +13,7 @@ }, "../../..": { "name": "@finos/git-proxy", - "version": "2.0.0", + "version": "2.1.0", "license": "Apache-2.0", "workspaces": [ "./packages/git-proxy-cli" @@ -25,6 +25,7 @@ "@material-ui/icons": "4.11.3", "@primer/octicons-react": "^19.21.2", "@seald-io/nedb": "^4.1.2", + "agent-base": "^7.1.4", "axios": "^1.18.1", "bcryptjs": "^3.0.3", "clsx": "^2.1.1", @@ -40,6 +41,7 @@ "express-session": "^1.19.0", "font-awesome": "^4.7.0", "history": "5.3.0", + "httpntlm": "^1.8.13", "https-proxy-agent": "^7.0.6", "isomorphic-git": "^1.36.3", "jsonwebtoken": "^9.0.3", From 65f729b0631ff848af04237c339b56f6445da324 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 21 Aug 2026 16:55:48 +0900 Subject: [PATCH 04/12] feat: add missing phases to push chains, export plugin types --- src/plugin.ts | 12 +++++++++++- src/proxy/chain.ts | 7 +++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/plugin.ts b/src/plugin.ts index df46a2624..6d8b16144 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -323,4 +323,14 @@ class PullActionPlugin extends ActionPlugin { } } -export { PluginLoader, PushActionPlugin, PullActionPlugin, isCompatiblePlugin }; +export { + PluginLoader, + PushActionPlugin, + PullActionPlugin, + isCompatiblePlugin, + PushPhase, + PullPhase, + PushChainName, + PushPluginOptions, + PullPluginOptions, +}; diff --git a/src/proxy/chain.ts b/src/proxy/chain.ts index 9d915b64f..a910db82b 100644 --- a/src/proxy/chain.ts +++ b/src/proxy/chain.ts @@ -35,28 +35,35 @@ const branchPushChainElements: ChainElement[] = [ proc.push.resolveUserFromToken, proc.push.checkEmptyBranch, proc.push.checkRepoInAuthorisedList, + PushPhase.AFTER_PERMISSIONS, proc.push.checkMessages, proc.push.checkAuthorEmails, proc.push.checkUserPushPermission, proc.push.pullRemote, // cleanup is handled after chain execution if successful proc.push.writePack, + PushPhase.AFTER_CHECKOUT, proc.push.checkHiddenCommits, proc.push.checkIfWaitingAuth, proc.push.preReceive, proc.push.getDiff, + PushPhase.AFTER_DIFF, proc.push.gitleaks, proc.push.scanDiff, + PushPhase.BEFORE_APPROVAL, proc.push.blockForAuth, ]; const tagPushChainElements: ChainElement[] = [ proc.push.checkRepoInAuthorisedList, + PushPhase.AFTER_PERMISSIONS, proc.push.checkUserPushPermission, proc.push.checkIfWaitingAuth, proc.push.checkMessages, proc.push.pullRemote, proc.push.writePack, + PushPhase.AFTER_CHECKOUT, proc.push.preReceive, + PushPhase.BEFORE_APPROVAL, proc.push.blockForAuth, ]; From 89bccb89e255f7cc4d4150b938c04349cb625b3e Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 21 Aug 2026 16:56:24 +0900 Subject: [PATCH 05/12] feat: add sample secret scanner push plugin --- .../customPushSecretScanner.ts | 87 +++++++++++++++++++ plugins/git-proxy-plugin-samples/index.js | 2 +- 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 plugins/git-proxy-plugin-samples/customPushSecretScanner.ts diff --git a/plugins/git-proxy-plugin-samples/customPushSecretScanner.ts b/plugins/git-proxy-plugin-samples/customPushSecretScanner.ts new file mode 100644 index 000000000..b72e079f5 --- /dev/null +++ b/plugins/git-proxy-plugin-samples/customPushSecretScanner.ts @@ -0,0 +1,87 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * This sample plugin scans for secrets in the diff of a git push. + */ + +// Peer dependencies; it's expected that these deps exist on Node module path if you've installed @finos/git-proxy +import { PushActionPlugin, PushPhase, PushPluginOptions } from '@finos/git-proxy/plugin'; +import { Action, Step } from '@finos/git-proxy/proxy/actions'; +import { Request } from 'express'; +import parseDiff from 'parse-diff'; + +const RULES = [ + { name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/g }, + { name: 'Private key block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g }, + { name: 'Assigned secret', re: /(api[_-]?key|token|password)\s*[:=]\s*['"][^'"]{8,}/gi }, +]; + +class CustomPushSecretScanner extends PushActionPlugin { + constructor() { + super(exec, pluginOptions); + } +} + +const pluginOptions: PushPluginOptions = { + phase: PushPhase.AFTER_DIFF, // When to execute the plugin within default chain steps + displayName: 'customSecretScanner.exec', // Display name for the plugin + isCollectible: true, // If true, the chain will keep running even if plugin returns an error + chains: ['branch', 'tag'], // Which chains to execute the plugin on +}; + +async function exec(req: Request, action: Action) { + const step = new Step('CustomPushSecretScanner'); + const diff = action.steps.find((s) => s.stepName === 'diff')?.content; + + if (!diff) { + step.log('no diff available; skipping scan'); + action.addStep(step); + return action; + } + + const findings = findSecrets(diff); + if (findings.length > 0) { + const report = findings + .map((f, i) => `${i + 1}. ${f.rule} in ${f.file}:${f.line}`) + .join('\n'); + step.error = true; + step.setError(`\n\nPush blocked: possible secrets detected.\n\n${report}\n`); + } + + action.addStep(step); + return action; +} + +const findSecrets = (diff: string): { rule: string; file?: string; line: number }[] => + parseDiff(diff).flatMap((file) => + file.chunks.flatMap((chunk) => + chunk.changes + .filter((c) => c.type === 'add') // filter for newly added lines + .flatMap((c) => + RULES.flatMap((rule) => + [...c.content.matchAll(rule.re)].map(() => ({ + rule: rule.name, + file: file.to || file.from, + line: c.ln, + })), + ), + ), + ), + ); + +// Default exports are supported and will be loaded by the plugin loader +export default new CustomPushSecretScanner(); diff --git a/plugins/git-proxy-plugin-samples/index.js b/plugins/git-proxy-plugin-samples/index.js index 1c0c34296..5e64ad35e 100644 --- a/plugins/git-proxy-plugin-samples/index.js +++ b/plugins/git-proxy-plugin-samples/index.js @@ -19,7 +19,7 @@ * ES modules to demonstrate the use of ESM in plugins. */ -// Peer dependencies; its expected that these deps exist on Node module path if you've installed @finos/git-proxy +// Peer dependencies; it's expected that these deps exist on Node module path if you've installed @finos/git-proxy import { PullActionPlugin } from '@finos/git-proxy/plugin'; import { Step } from '@finos/git-proxy/proxy/actions'; From d525a5928ee256b0f5e7db6e20538a67e6c50b93 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 21 Aug 2026 21:36:31 +0900 Subject: [PATCH 06/12] docs: update plugin guide --- website/docs/development/plugins.mdx | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/website/docs/development/plugins.mdx b/website/docs/development/plugins.mdx index 02a148d22..745ff60af 100644 --- a/website/docs/development/plugins.mdx +++ b/website/docs/development/plugins.mdx @@ -3,19 +3,21 @@ title: Plugins --- ## How plugins work -GitProxy supports extensibility in the form of plugins. These plugins are specified via [configuration](/docs/category/configuration) as NPM packages or JavaScript code on disk. For each plugin configured, GitProxy will attempt to load each package or file as a standard [Node module](https://nodejs.org/api/modules.html). Plugin authors will create instances of the extension classes exposed by GitProxy and use these objects to implement custom functionality. +GitProxy supports extensibility in the form of plugins. These plugins are specified via [configuration](/docs/category/configuration) as NPM packages or JavaScript/TypeScript code on disk. For each plugin configured, GitProxy will attempt to load each package or file as a standard [Node module](https://nodejs.org/api/modules.html). Plugin authors will create instances of the extension classes exposed by GitProxy and use these objects to implement custom functionality. -For each loaded "plugin object", it is inserted into GitProxy's chain of actions which are triggered on a given Git action received by GitProxy such as `git push` or `git fetch`. +For each loaded "plugin object", it is inserted into GitProxy's chain of actions which are triggered on a given Git action received by GitProxy such as `git push` or `git fetch`. Action chains are divided into different [phases](#chain-phases), according to the available data and completed checks. :::caution -The order that plugins are configured matters! Plugins execute _before_ GitProxy's builtin steps and in the order that they are configured in `proxy.config.json`. If you wish to use a combination of features, ensure that your custom plugins do not conflict or interfere with later steps in the processing chain. +Before v2.3, plugins executed _before_ GitProxy's built-in steps by default. Now, you can configure when to run each plugin by specifying a `PullPhase` or `PushPhase` as explained [below](#chain-phases). Plugins will execute _immediately after_ the phase begins, and in the order they are configured in `proxy.config.json`. If you wish to use a combination of features, ensure that your custom plugins do not conflict or interfere with later steps in the processing chain. ::: GitProxy uses the [load-plugin package](https://www.npmjs.com/package/load-plugin) to provide the Node module resolution. +## TypeScript Support +Plugins can be written in TypeScript, as shown in our [push secret scanner sample](https://github.com/finos/git-proxy/tree/main/plugins/git-proxy-plugin-samples/customPushSecretScanner.ts). + ## Limitations - Plugins are only supported on the Git HTTP proxy server. There is no similar extensibility today for the dashboard UI or its backing API. -- Extensions are defined as JavaScript classes which are [quite limited](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Inheritance_and_the_prototype_chain#inheritance_with_the_prototype_chain). GitProxy has a rather naive system for determining if a provided module has any objects which it recognizes as "GitProxy plugin types". A conversion of the project to TypeScript will provide more flexible options for enforcing API contracts via strict interfaces & types in a future release. [Use of TypeScript is a roadmap item](https://github.com/finos/git-proxy/issues/276). ## Using plugins The primary goals of the plugin system is to: @@ -42,8 +44,8 @@ $ cd git-proxy $ npm pack $ (cd plugins/git-proxy-plugin-samples && npm pack) $ npm install -g \ - ./finos-git-proxy-1.7.0.tgz \ - ./plugins/git-proxy-plugin-samples/finos-git-proxy-plugin-samples-0.1.1.tgz + ./finos-git-proxy-2.1.0.tgz \ + ./plugins/git-proxy-plugin-samples/finos-git-proxy-plugin-samples-0.1.2.tgz ``` 2. Create or edit an existing `proxy.config.json` file to configure the plugin(s) to load. You must include the full import path that would typically be used in a `import {}` (ESM) or `require()` (CJS) statement: @@ -52,7 +54,8 @@ $ npm install -g \ { "plugins": [ "@finos/git-proxy-plugin-samples", - "@finos/git-proxy-plugin-samples/example.cjs" + "@finos/git-proxy-plugin-samples/example.cjs", + "@finos/git-proxy-plugin-samples/customSecretScanner.ts ] } ``` @@ -67,15 +70,15 @@ HTTPS Proxy Listening on 8443 Found 2 plugin modules Loaded plugin: RunOnPullPlugin Loaded plugin: HelloPlugin -Loaded plugin: LogRequestPlugin +Loaded plugin: CustomSecretScanner ``` -### via JavaScript file(s) +### via JavaScript/TypeScript file(s) :::caution This section is considered highly experimental and not recommended for general use. Even when authoring local plugins that are not distributed, it is best to use `node_modules/` and not rely on bespoke system setup or layout of files. Use `npm pack` and `npm install path/to/plugin.tgz` if you must use local plugin code to ensure GitProxy's plugin manager can properly load your plugins. ::: -Plugins written as standalone JavaScript files are used similarly to npm packages. The main difference is that file-based module loading requires additional steps to ensure that the given JavaScript files (written as Node CommonJS or ES modules) have all the necessary dependencies to be imported. Since GitProxy plugin system relies on class-based inheritence, the module will require at least a dependency to `@finos/git-proxy` to be able to import the required classes. Plugins do not have to be distributed by NPM to be used in this fashion which may be advantageous in certain environments. +Plugins written as standalone JavaScript or TypeScript files are used similarly to npm packages. The main difference is that file-based module loading requires additional steps to ensure that the given files (written as Node CommonJS or ES modules) have all the necessary dependencies to be imported. Since GitProxy plugin system relies on class-based inheritence, the module will require at least a dependency to `@finos/git-proxy` to be able to import the required classes. Plugins do not have to be distributed by NPM to be used in this fashion which may be advantageous in certain environments. 1. To use a plugin that is written as a standalone JavaScript file, ensure that the JS code has all its necessary dependencies: @@ -86,7 +89,7 @@ $ cat package.json "name": "foo-plugin", ... "dependencies": { - "@finos/git-proxy": "^1.7.0" + "@finos/git-proxy": "^2.1.0" } } # Alternatively, add git-proxy that is cloned locally as a file-based dependency From 2ce40817569dedb6fc4025fa74b55b8847ba6f91 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 21 Aug 2026 21:37:34 +0900 Subject: [PATCH 07/12] docs: add push chain phase documentation to plugin guide I'm skipping this from the architecture guide for now, since it's only relevant to plugin users --- website/docs/development/plugins.mdx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/website/docs/development/plugins.mdx b/website/docs/development/plugins.mdx index 745ff60af..c9447c9f1 100644 --- a/website/docs/development/plugins.mdx +++ b/website/docs/development/plugins.mdx @@ -221,3 +221,28 @@ HTTPS Proxy Listening on 8443 Found 1 plugin modules Loaded plugin: PushActionPlugin ``` + +## Plugin configuration + +Plugins accept options (`PushPluginOptions` and `PullPluginOptions` to fine-grain their behaviour: + +### Chain Phases + +Action chains are divided into phases. In each phase of the chain, certain conditions will be guaranteed and certain attributes will be available. + +For example, branch pushes include an `AFTER_PERMISSIONS` phase, which guarantees that the repository is in the authorised list before running the plugin. By default, plugins are set to `phase: AFTER_PERMISSIONS`, which tells GitProxy to execute the plugin *immediately after* we've finished checking that the repository can actually be pushed to. + +#### Push chain phases + +Currently, there are 4 different `PushPhase`s to run your plugins: + +- `AFTER_PERMISSIONS`: The push is guaranteed to be in the authorised list, and identity of the user pushing is also present. The following `Action` attributes will be populated: `action.user`, `action.userEmail`, `action.commitData`, etc. +- `AFTER_CHECKOUT`: Commit messages, author emails and the user's push permission have finished validating. The actual remote is already pulled but the diff hasn't been processed. The following `Action` attributes will be populated: `action.newIdxFiles`. +- `AFTER_DIFF`: The push will now contain the diff data in `action.diff`. +- `BEFORE_APPROVAL`: The push has gone through the remaining checks (GitLeaks, built-in diff scanning) in the chain, and is about to request approval. + +Depending on what your plugin does, you'll want to pick a phase where the data you need is available. A custom diff scanning plugin must be set to `AFTER_DIFF` or `BEFORE_APPROVAL`, otherwise it will not have the `action.diff` readily available. + +#### Pull chain phases + +The pull chain currently has a single `PullPhase`: `AFTER_AUTHORISATION`. Plugins are set to run in the `AFTER_AUTHORISATION` phase by default. The reasoning is that it's unsafe to run plugins before validating that the repository is actually in the authorised list (an unauthorised repository could contain malicious code to exploit subsequent plugins). From 7eb425807d590fde991d46d841a578f609f45521 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Fri, 21 Aug 2026 21:40:06 +0900 Subject: [PATCH 08/12] chore: rename secret scanner plugin sample --- .../{customPushSecretScanner.ts => customSecretScanner.ts} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename plugins/git-proxy-plugin-samples/{customPushSecretScanner.ts => customSecretScanner.ts} (94%) diff --git a/plugins/git-proxy-plugin-samples/customPushSecretScanner.ts b/plugins/git-proxy-plugin-samples/customSecretScanner.ts similarity index 94% rename from plugins/git-proxy-plugin-samples/customPushSecretScanner.ts rename to plugins/git-proxy-plugin-samples/customSecretScanner.ts index b72e079f5..41043a6f1 100644 --- a/plugins/git-proxy-plugin-samples/customPushSecretScanner.ts +++ b/plugins/git-proxy-plugin-samples/customSecretScanner.ts @@ -30,7 +30,7 @@ const RULES = [ { name: 'Assigned secret', re: /(api[_-]?key|token|password)\s*[:=]\s*['"][^'"]{8,}/gi }, ]; -class CustomPushSecretScanner extends PushActionPlugin { +class CustomSecretScanner extends PushActionPlugin { constructor() { super(exec, pluginOptions); } @@ -44,7 +44,7 @@ const pluginOptions: PushPluginOptions = { }; async function exec(req: Request, action: Action) { - const step = new Step('CustomPushSecretScanner'); + const step = new Step('CustomSecretScanner'); const diff = action.steps.find((s) => s.stepName === 'diff')?.content; if (!diff) { @@ -84,4 +84,4 @@ const findSecrets = (diff: string): { rule: string; file?: string; line: number ); // Default exports are supported and will be loaded by the plugin loader -export default new CustomPushSecretScanner(); +export default new CustomSecretScanner(); From b42c0d5f87fc072efb1b2868f9dcde4acde2c6d2 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 22 Aug 2026 11:18:14 +0900 Subject: [PATCH 09/12] docs: isCollectible, chains and displayName plugin options --- website/docs/development/plugins.mdx | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/website/docs/development/plugins.mdx b/website/docs/development/plugins.mdx index c9447c9f1..1d0a9721c 100644 --- a/website/docs/development/plugins.mdx +++ b/website/docs/development/plugins.mdx @@ -246,3 +246,48 @@ Depending on what your plugin does, you'll want to pick a phase where the data y #### Pull chain phases The pull chain currently has a single `PullPhase`: `AFTER_AUTHORISATION`. Plugins are set to run in the `AFTER_AUTHORISATION` phase by default. The reasoning is that it's unsafe to run plugins before validating that the repository is actually in the authorised list (an unauthorised repository could contain malicious code to exploit subsequent plugins). + +### `isCollectible` + +Errors in a given action or plugin can be collected at the end of the chain. When `isCollectible: true`, the chain will continue running even if the plugin errors out. If set to `false`, plugin failure will immediately stop the chain and return the error to the user. + +Setting `isCollectible` to `true` allows for a better user experience, as users can see all the errors and fix them in one go. However, actions that perform critical tasks, such as parsing the push (`parsePush`) or obtaining the diff (`getDiff`) must fail fast as the missing attributes would affect subsequent actions and plugins. + +### `chains` + +Currently, `chains` are only available for `PushActionPlugin`. By default, plugins run for _both_ branch pushes and tag pushes. However, you can fine-tune which chains to execute on, by passing in a `PushChainName[]` as follows: `chains: ['tag', 'branch']`. + +### `displayName` + +This is the user-facing plugin name. It will be shown on the push database, as well as during the push itself as a sideband message: + +```bash +$ git push + +Enumerating objects: 5, done. +Counting objects: 100% (5/5), done. +Delta compression using up to 14 threads +Compressing objects: 100% (3/3), done. +Writing objects: 100% (3/3), 367 bytes | 367.00 KiB/s, done. +Total 3 (delta 1), reused 0 (delta 0), pack-reused 0 +remote: Resolving user from token... +remote: Checking for empty branch... +remote: Checking repository is authorised... +remote: Checking commit messages... +remote: Checking author emails... +remote: Checking push permissions... +remote: Fetching remote repository... +remote: Writing pack data... +remote: Checking for hidden commits... +remote: Checking approval status... +remote: Running pre-receive hook... +remote: Computing diff... +remote: Running CustomSecretScanner... <- displayName shown +remote: Scanning for secrets... +remote: Scanning diff contents... +remote: Requesting approval... +remote: +remote: +remote: +remote: GitProxy has received your push ✅ +``` From 4eb6b08f3e1671fbae4e314ad500277f68b8b63d Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 22 Aug 2026 11:19:39 +0900 Subject: [PATCH 10/12] feat: display plugin name feedback on push, fix capitalization --- src/proxy/chain.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/proxy/chain.ts b/src/proxy/chain.ts index a910db82b..4d82299ed 100644 --- a/src/proxy/chain.ts +++ b/src/proxy/chain.ts @@ -99,13 +99,14 @@ const composeErrorMessage = (action: Action): string | undefined => { }; const stepProgressLabels: Record = { + 'resolveUserFromToken.exec': 'Resolving user from token', 'checkEmptyBranch.exec': 'Checking for empty branch', 'checkRepoInAuthorisedList.exec': 'Checking repository is authorised', 'checkMessages.exec': 'Checking commit messages', 'checkAuthorEmails.exec': 'Checking author emails', 'checkUserPushPermission.exec': 'Checking push permissions', 'pullRemote.exec': 'Fetching remote repository', - 'writePack.exec': 'writing pack data', + 'writePack.exec': 'Writing pack data', 'checkHiddenCommits.exec': 'Checking for hidden commits', 'checkIfWaitingAuth.exec': 'Checking approval status', 'executeExternalPreReceiveHook.exec': 'Running pre-receive hook', @@ -126,9 +127,9 @@ const getProgressMessage = (fn: ProcessorExec): string => { return stepProgressLabels[displayName]; } if (displayName) { - return `running ${displayName.replace(/\.exec$/, '')}`; + return `Running ${displayName.replace(/\.exec$/, '')}`; } - return 'running plugin'; + return 'Running plugin'; }; export const executeChain = async (req: Request, res: Response): Promise => { @@ -233,7 +234,16 @@ const buildChain = ( elements.flatMap((element) => typeof element === 'function' ? [element] - : plugins.filter((plugin) => plugin.phase === element).map((plugin) => plugin.exec), + : plugins.filter((plugin) => plugin.phase === element).map(toPluginExec), + ); + +const toPluginExec = (plugin: ActionPlugin): ProcessorExec => + Object.assign( + (req: Request, action: Action) => plugin.exec(req, action), + { + displayName: plugin.displayName ?? `${plugin.constructor.name}.exec`, + isCollectible: plugin.isCollectible ?? false, + }, ); const filterPushPluginsByChain = (plugins: readonly PushActionPlugin[], chainName: PushChainName) => From 2e09d525c2884994d3b0f3a49df23c7fb270b811 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 22 Aug 2026 12:01:54 +0900 Subject: [PATCH 11/12] chore: npm run format --- test/chain.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/chain.test.ts b/test/chain.test.ts index 0a678b1eb..a40d1d309 100644 --- a/test/chain.test.ts +++ b/test/chain.test.ts @@ -116,7 +116,9 @@ describe('proxy chain', function () { it('getChain should throw an error if loader is undefined', async () => { chain.chainPluginLoader = undefined; - await expect(chain.getChain({ type: 'push' })).rejects.toThrow(/Plugin loader was not initialized/); + await expect(chain.getChain({ type: 'push' })).rejects.toThrow( + /Plugin loader was not initialized/, + ); }); it('getChain should load plugins from an initialized PluginLoader', async () => { From 178f795e7c42c198f60dfbd88c8367b55a582956 Mon Sep 17 00:00:00 2001 From: Juan Escalada Date: Sat, 22 Aug 2026 12:13:59 +0900 Subject: [PATCH 12/12] chore: npm run format --- .../git-proxy-plugin-samples/customSecretScanner.ts | 6 ++---- src/proxy/chain.ts | 11 ++++------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/plugins/git-proxy-plugin-samples/customSecretScanner.ts b/plugins/git-proxy-plugin-samples/customSecretScanner.ts index 41043a6f1..1c9766d16 100644 --- a/plugins/git-proxy-plugin-samples/customSecretScanner.ts +++ b/plugins/git-proxy-plugin-samples/customSecretScanner.ts @@ -38,7 +38,7 @@ class CustomSecretScanner extends PushActionPlugin { const pluginOptions: PushPluginOptions = { phase: PushPhase.AFTER_DIFF, // When to execute the plugin within default chain steps - displayName: 'customSecretScanner.exec', // Display name for the plugin + displayName: 'CustomSecretScanner', // Display name for the plugin isCollectible: true, // If true, the chain will keep running even if plugin returns an error chains: ['branch', 'tag'], // Which chains to execute the plugin on }; @@ -55,9 +55,7 @@ async function exec(req: Request, action: Action) { const findings = findSecrets(diff); if (findings.length > 0) { - const report = findings - .map((f, i) => `${i + 1}. ${f.rule} in ${f.file}:${f.line}`) - .join('\n'); + const report = findings.map((f, i) => `${i + 1}. ${f.rule} in ${f.file}:${f.line}`).join('\n'); step.error = true; step.setError(`\n\nPush blocked: possible secrets detected.\n\n${report}\n`); } diff --git a/src/proxy/chain.ts b/src/proxy/chain.ts index 4d82299ed..e69b76b25 100644 --- a/src/proxy/chain.ts +++ b/src/proxy/chain.ts @@ -238,13 +238,10 @@ const buildChain = ( ); const toPluginExec = (plugin: ActionPlugin): ProcessorExec => - Object.assign( - (req: Request, action: Action) => plugin.exec(req, action), - { - displayName: plugin.displayName ?? `${plugin.constructor.name}.exec`, - isCollectible: plugin.isCollectible ?? false, - }, - ); + Object.assign((req: Request, action: Action) => plugin.exec(req, action), { + displayName: plugin.displayName ?? `${plugin.constructor.name}.exec`, + isCollectible: plugin.isCollectible ?? false, + }); const filterPushPluginsByChain = (plugins: readonly PushActionPlugin[], chainName: PushChainName) => plugins.filter((p) => (p.chains ?? ['branch', 'tag']).includes(chainName));