GitProxy sits between a developer’s git push and the real remote, and runs a chain of checks before it forwards anything: is this repository authorised, who is pushing, do the commit messages pass, does the diff contain secrets. If the built-in checks are not the ones your organisation needs, you write a plugin, which is a small module that receives the same two arguments as every built-in step and gets inserted into the same chain.
This walks through a complete working plugin that scans a push diff for secrets and blocks the push, based on the sample added in PR #1683, and then through the things that will cost you an hour if nobody tells you about them. Most of that hour, for me, was spent on a plugin that loaded successfully and then silently never ran.
What a plugin is
A plugin is a module that default-exports an instance of PushActionPlugin or PullActionPlugin, imported from @finos/git-proxy/plugin. The class wraps a single function with the signature (req, action) => Promise<Action>, which is the same signature GitProxy’s own processors use, so your code is not a second-class citizen in the chain. You get the Express request and the current Action, you record what you did on the action, and you return it.
Configuration comes from a second argument, and that argument is where all the interesting decisions are. Here is the sample scanner in full:
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 the default chain
displayName: 'CustomSecretScanner', // user-facing name
isCollectible: true, // let the chain continue and report at the end
chains: ['branch', 'tag'], // which push chains to run 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') // only 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,
})),
),
),
),
);
export default new CustomSecretScanner();
Roughly eighty lines, and four of the decisions in it are the whole lesson.
Pick the phase, or your data will not be there
This is the mistake I would bet on you making, because I made it first. A push chain is divided into phases, and your plugin declares which one it runs in. The phase is not about ordering for its own sake, it is about what is guaranteed to be populated on the action when your code executes.
| Phase | What you can rely on |
|---|---|
AFTER_PERMISSIONS | Repository is in the authorised list and the pusher is identified: action.user, action.userEmail, action.commitData |
AFTER_CHECKOUT | Commit messages, author emails and push permission have been validated, and the bare clone exists with the pushed objects written into it |
AFTER_DIFF | The diff is available |
BEFORE_APPROVAL | The remaining built-in checks have run, and the push is about to request approval |
The pull chain currently has one phase, AFTER_AUTHORISATION, which fires after the repository has been checked against the authorised list. That check comes first deliberately: running plugin code against a repository GitProxy has not authorised is a wider blast radius than it looks.
AFTER_PERMISSIONS is the default, and it sits well before the diff is computed. So a diff scanner that does not set phase gets undefined where it expected a diff, on every single push, and either throws or quietly does nothing depending on how you wrote it. Setting phase: PushPhase.AFTER_DIFF is what makes the sample work at all.
The other side of the same coin is chains. Push plugins run on both branch pushes and tag pushes by default, and the tag chain has no diff step, so it has no AFTER_DIFF phase for a plugin to occupy. The sample declares chains: ['branch', 'tag'], which means on tag pushes it simply never runs. That is the correct outcome, since there is no diff on a tag push to scan, but it is worth narrowing to chains: ['branch'] in your own plugin so the intent is explicit rather than implied by the absence of a marker somewhere else. A plugin that silently does not run is the failure mode to design against here.
Read only the added lines
The scanning logic is four lines of parse-diff and one filter, and the filter is the important part:
.filter((c) => c.type === 'add')
Run your patterns over the raw diff string instead, and you match removal lines and unchanged context lines too. The immediate consequence is that a developer who deletes a leaked key gets blocked for cleaning it up, which is both infuriating and exactly backwards. GitProxy’s built-in scanDiff filters on added lines for the same reason, and since parse-diff is already a dependency you can do it in a plugin without adding anything.
While you are choosing patterns, match secret values rather than secret-sounding words. A rule of /TOKEN/ blocks a code comment that mentions tokens. AKIA[0-9A-Z]{16} blocks an AWS access key.
The sample looks the diff up by step name, action.steps.find((s) => s.stepName === 'diff'), which works but couples your plugin to another processor’s step name. GitProxy also exposes the diff on the action at this phase, and reading action.diff avoids a dependency that would break quietly if that step were ever renamed.
Report through the Action, not by throwing
Notice that the plugin never throws. Rejecting a push means marking the step:
step.error = true;
step.setError(`\n\nPush blocked: possible secrets detected.\n\n${report}\n`);
Throwing lands in the chain executor’s catch block, where the developer gets a generic “unexpected error” and you get a support question. Setting the error on the step gives them your message, at the end of their git push output, which is the only place they are looking.
The same reasoning applies to missing data. The sample logs no diff available; skipping scan and returns the action unchanged rather than treating it as an exception. A plugin that cannot do its job should say so on the record and get out of the way.
The remaining options
isCollectible: true means a failure from your plugin is collected and reported with every other failed check at the end of the chain, instead of stopping the chain at your step. For a scanner that is what you want: a developer sees all the problems in one push attempt rather than fixing them one round trip at a time. Set it to false for anything later steps depend on, where continuing would produce nonsense.
displayName is the user-facing name, and it shows up in two places: the push record in the dashboard, and the live progress that GitProxy streams back over git’s sideband channel while the push is in flight.
$ git push
...
remote: Running pre-receive hook...
remote: Computing diff...
remote: Running CustomSecretScanner...
remote: Scanning for secrets...
remote: Requesting approval...
remote:
remote: GitProxy has received your push
Without a displayName you get a generic line instead, so set it even though it is optional. There is a fallback based on the class name, but minifiers mangle class names, so it is not something to rely on.
Wiring it into config
Plugins are listed in proxy.config.json by the same specifier you would pass to import or require:
{
"plugins": [
"@finos/git-proxy-plugin-samples",
"@finos/git-proxy-plugin-samples/example.cjs",
"@finos/git-proxy-plugin-samples/customSecretScanner.ts"
]
}
For anything you actually deploy, install the plugin as a package (npm pack then npm install path/to/plugin.tgz) rather than pointing at loose files. GitProxy’s own documentation calls the file-based path experimental, and the resolution problems below are most of the reason why.
If you do point at a file during development, use a relative specifier. This entry:
"plugins": ["plugins/git-proxy-plugin-samples/index.js"]
fails with an error that looks nothing like a path problem:
Failed to load plugin: Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'plugins' imported from /home/juan/Projects/git-proxy/
Node did not look for a file. In ES modules, any specifier that does not begin with ./, ../, / or a URL scheme is a bare specifier, which means a package name, so it went looking for a package called plugins with a git-proxy-plugin-samples/index.js subpath inside it. Adding ./ fixes it, resolved relative to the process working directory. I wrote the specifier rule up on its own, with the same error string, in Cannot find package: ESM bare specifiers in config.
When the plugin loads and then does nothing
This is the one that cost me an afternoon, and it will happen to anyone developing a plugin against a checkout of GitProxy rather than an installed copy.
My plugin loaded. The startup log said so:
Found 1 plugin modules
found pull plugin RunOnPullPlugin
Loaded plugin: RunOnPullPlugin
Then it never executed, and constructor logging I had added to the plugin base class never printed. The answer was in a debug dump of the loaded instance:
RunOnPullPlugin {
isGitProxyPlugin: true,
isGitProxyPullActionPlugin: true,
exec: [Function: logMessage]
}
Three properties, and no phase. That object had never run the constructor I was editing. Because the chain builder places plugins by matching plugin.phase against phase markers, and undefined matches nothing, my plugin was dropped from the chain without a word.
The cause is module resolution. The plugin imports @finos/git-proxy/plugin, and Node resolves that by walking up node_modules directories from the plugin’s own location, stopping at the first match. One line tells you exactly which file you got:
node --input-type=module -e "console.log(import.meta.resolve('@finos/git-proxy/plugin'))"
Run from the plugin’s directory, that printed:
file:///home/juan/Projects/git-proxy/plugins/git-proxy-plugin-samples/node_modules/@finos/git-proxy/dist/src/plugin.js
The samples package had its own nested node_modules/@finos/git-proxy, a real installed copy of the published release, so resolution never reached the repository root. I had been rebuilding one copy of the code and loading a completely different one. Deleting the nested copy and reinstalling from the root fixed it.
Three things worth taking from that. Checking the module’s export list is a fast confirmation: the abstract base class I had just added was missing from it, which proved the copy predated my changes. The peerDependencies range in the plugin’s package.json is not the cause and never was, because a peer range is an instruction to the installer, not to Node’s resolver. And even after you fix your own setup, remember that the entry point resolves through the package’s exports map to compiled output in dist, while npm start runs the server from TypeScript source via tsx. Those are two different builds of the same code, so rebuild before you start if you have touched anything in the plugin API.
You can only import the subpaths the package declares
The other error you are likely to hit, from both Node and TypeScript at once:
Package subpath './proxy/processors/types' is not defined by "exports" in /path/to/node_modules/@finos/git-proxy/package.json
Cannot find module '@finos/git-proxy/proxy/processors/types' or its corresponding type declarations. ts(2307)
Both are the same thing seen from two angles. A package with an exports map only exposes the subpaths it explicitly declares, and internal file paths are not importable just because the files exist on disk. TypeScript resolves through the same map, hence the matching ts(2307).
The fix is to import from a declared entry point. PushPhase, PullPhase and the option types are re-exported from @finos/git-proxy/plugin, which is where the sample gets them, so one import line covers the classes and the phase values together. If you are stuck on a version that does not re-export them, the phase values are plain strings at runtime, so { phase: 'AFTER_DIFF' } behaves identically. You lose autocomplete and typo protection, which on a value this load-bearing is a real loss. The Node error and the matching ts(2307) are unpacked in Package subpath is not defined by exports.
TypeScript plugins
The sample is TypeScript, and the plugin documentation now lists that as supported. The thing to keep in mind is that the module still has to be loadable by the process importing it, so a .ts plugin depends on GitProxy running under a TypeScript-capable loader. If you are distributing a plugin as a package, compile it to JavaScript and ship the output. The samples in the repository include plain ESM and CommonJS versions alongside the TypeScript one, which is a good template for whichever you need.
What a pull plugin cannot do yet
Since this comes up immediately: you cannot scan fetched content in a pull plugin today, and the reason is structural rather than a missing feature. The pull chain runs on the /info/refs and /git-upload-pack requests, before GitProxy forwards anything upstream, so at the moment your plugin executes the remote has not sent a single object. The content you would want to inspect arrives in the response, long after the chain has finished.
Cloning the repository inside the plugin looks like a workaround and mostly is not. You would be inspecting a different fetch than the one being served, you would be scanning the default branch rather than the specific ref the developer asked for, and a payload sitting on any other branch would walk straight past. PR #1639, which takes that approach, discloses those limitations plainly, and they are worth reading before you build on the pattern. Two more problems in that clone path, a credential on argv and a scanner that fails open, are in Secrets in argv, and scanners that fail open.
What pull plugins are good for today is everything metadata-shaped, all of which works fine at AFTER_AUTHORISATION: allow and deny rules beyond the authorised list, per-user or per-team policy, rate limiting, time-of-day restrictions, and audit enrichment.
The takeaway
Writing the plugin is the easy half: one function, two arguments, return the action. The two things that decide whether it works are choosing the phase by what data you need rather than accepting the default, and reporting through the step instead of throwing. And when a plugin loads cleanly but never executes, before you doubt your own logic, check which copy of @finos/git-proxy it actually resolved, because a plugin built against a different copy of the package arrives missing exactly the properties the chain builder uses to place it. If you are curious about why the API is shaped this way, I wrote up the design side in how to design a plugin system for your project.