diff --git a/plugins/git-proxy-plugin-samples/customSecretScanner.ts b/plugins/git-proxy-plugin-samples/customSecretScanner.ts new file mode 100644 index 000000000..1c9766d16 --- /dev/null +++ b/plugins/git-proxy-plugin-samples/customSecretScanner.ts @@ -0,0 +1,85 @@ +/** + * 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 CustomSecretScanner extends PushActionPlugin { + constructor() { + super(exec, pluginOptions); + } +} + +const pluginOptions: PushPluginOptions = { + phase: PushPhase.AFTER_DIFF, // When to execute the plugin within default chain steps + 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 +}; + +async function exec(req: Request, action: Action) { + const step = new Step('CustomSecretScanner'); + 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 CustomSecretScanner(); 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'; 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/plugin.ts b/src/plugin.ts index 2550283fa..6d8b16144 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,12 +307,30 @@ 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; } } -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 cab32f5c4..e69b76b25 100644 --- a/src/proxy/chain.ts +++ b/src/proxy/chain.ts @@ -16,48 +16,65 @@ 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, + BuiltChains, +} 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, + 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 tagPushChain: ProcessorExec[] = [ +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, ]; -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: BuiltChains | undefined; /** * Compose a single error message from all failed steps, so that the git @@ -82,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', @@ -109,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 => { @@ -208,39 +226,58 @@ 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(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) => + plugins.filter((p) => (p.chains ?? ['branch', 'tag']).includes(chainName)); + +const buildAllChains = (): BuiltChains => { + 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 +288,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, diff --git a/src/proxy/processors/types.ts b/src/proxy/processors/types.ts index 8ebb7cb28..c617c76ec 100644 --- a/src/proxy/processors/types.ts +++ b/src/proxy/processors/types.ts @@ -31,6 +31,33 @@ 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 interface BuiltChains { + branch: ProcessorExec[]; + tag: ProcessorExec[]; + pull: ProcessorExec[]; + default: ProcessorExec[]; +} + +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; diff --git a/test/chain.test.ts b/test/chain.test.ts index 24612ad8d..a40d1d309 100644 --- a/test/chain.test.ts +++ b/test/chain.test.ts @@ -114,12 +114,11 @@ 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 +126,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 +133,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 +583,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", diff --git a/website/docs/development/plugins.mdx b/website/docs/development/plugins.mdx index 02a148d22..1d0a9721c 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 @@ -218,3 +221,73 @@ 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). + +### `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 ✅ +```