Skip to content

feat: supply-chain scanning plugin with chainPhase plugin hooks (RFC / discussion)#1639

Open
dcoric wants to merge 6 commits into
finos:mainfrom
dcoric:feat/supply-chain-plugin
Open

feat: supply-chain scanning plugin with chainPhase plugin hooks (RFC / discussion)#1639
dcoric wants to merge 6 commits into
finos:mainfrom
dcoric:feat/supply-chain-plugin

Conversation

@dcoric

@dcoric dcoric commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

What this is (and what it is not)

This is an RFC / discussion PR: a working proof of concept that demonstrates the idea end to end. We are not asking for this to be merged as-is. What we want is a conversation about two questions:

  1. Should GitProxy offer first-class supply-chain scanning at the proxy choke point, for both pushes and pulls?
  2. Is the small core extension proposed here (plugin chainPhase) the right mechanism for plugins that need to run at specific points in the proxy chain?

Everything in this branch works and is tested, but names, rule sets, config shape and the core hook design are all up for debate.

One more thing we want to be upfront about: this implementation was produced mostly with LLM assistance (human-directed architecture, design decisions and review; largely AI-generated code). Treat it accordingly. If any part of this proceeds beyond discussion, it should get thorough human review and be reshaped by maintainers first - for a control that can block pushes and clones, humans need full control of and confidence in every line, and we do not consider the AI-assisted authoring and verification below a substitute for that.

Demo

Both recordings are real: a live GitProxy instance with the plugin enabled (failOn: "high"), against a public, deliberately poisoned testbed repository (dcoric/git-proxy-supplychain-testbed) that you can reproduce with yourself.

Pull / clone protection - a developer clones a repository with a poisoned go.mod through the proxy and is stopped cold, with the full ranked findings report in their terminal:

supply-chain-pull-block.mp4

Push protection (diff-aware) - a commit adding a typosquatted module and a replace to a raw-IP host is pushed through the proxy and rejected. Note that only the newly added lines are flagged; the pre-existing content of the repository is not re-reported:

supply-chain-push-block.mp4

Why we built it

Supply-chain attacks through package manifests are now routine: install lifecycle scripts that download and execute payloads, typosquatted package names, dependencies redirected to attacker-controlled forks or raw IPs, lockfiles quietly resolving from off-registry hosts. The recent npm worm attacks and classics like event-stream all ride on exactly these signatures.

GitProxy already sits at the perfect enforcement point: every push and every clone in a controlled environment flows through it. Today the chain inspects pushes for policy, but nothing looks at what the code being pushed or pulled will do to the developer's machine at install time. A developer who clones a poisoned repository and runs npm install / pip install / go build has already lost by the time any downstream scanner sees it.

So we built protection for both directions:

  • Push protection: a compromised dependency manifest is flagged (or blocked) before it lands in your canonical repo.
  • Pull / clone protection: a developer cloning a poisoned repository through the proxy is warned, or the clone is refused outright, before the code reaches their machine.

What it detects

Three ecosystems so far, entirely offline (no network calls, no new runtime dependencies):

Ecosystem Files Signatures
npm package.json, package-lock.json, yarn.lock, pnpm-lock.yaml install lifecycle scripts (preinstall/postinstall/prepare) and dangerous script content (curl-pipe-sh, base64 decode, eval, raw IPs), non-registry dependency sources (git/url/file/scp-style), resolutions/overrides/pnpm.overrides forcing non-registry sources, npm aliases, unpinned versions, typosquats vs an offline popular-package list, lockfile sources that are git/plain-http/off-registry
Python requirements*.txt, pyproject.toml, setup.py, setup.cfg, Pipfile, Pipfile.lock, poetry.lock setup.py install-time code execution (os.system, subprocess, eval/exec, network access), --index-url / --extra-index-url dependency confusion, VCS and URL requirements, unpinned versions, typosquats vs a PyPI popular list, lockfile git/http sources and custom indexes
Go go.mod, go.sum replace directives redirecting to local paths, forks or raw-IP hosts (the classic Go attack vector), suspicious require hosts (raw IPv4, localhost, plain http), pseudo-versions and +incompatible on new requires, toolchain changes, exclude entries, typosquats with path-aware matching (host impersonation and tail typos, /vN and gopkg.in normalization), go.sum host checks

Severity is ranked info < low < medium < high < critical. Findings are attached to the action as a step (visible in the review dashboard for pushes) and rendered as a ranked, human-readable report.

Everything is non-blocking by default (failOn: "off"): findings annotate the push review. Set failOn/pull.failOn to a severity to hard-block. Config comes from a JSON file via GIT_PROXY_SUPPLY_CHAIN_CONFIG, with per-ecosystem toggles and allow-lists.

How it is built

The core change is deliberately tiny (3 files in src/), everything else lives in a plugin:

  1. chainPhase plugin option (src/plugin.ts, src/proxy/chain.ts): PushActionPlugin and PullActionPlugin accept an optional { chainPhase }. 'afterDiff' inserts the plugin right after the built-in getDiff step, so it can read the computed diff and the already-cloned repo. 'afterAuth' (pull) runs only for authorized repos. The default 'start' preserves the historical index-0 insertion, so existing plugins are completely unaffected. This is the piece we would most like design feedback on: it is generic infrastructure, not supply-chain-specific, and other plugin authors likely have the same "I need the diff" problem.

  2. The scanner plugin (plugins/git-proxy-plugin-supply-chain/, mirrors git-proxy-plugin-samples): pure lib/ analyzer modules with zero @finos/git-proxy imports (unit-testable without a build), a thin index.js that wires them into push and pull plugin classes. Adding an ecosystem is mechanical: one lib/ecosystems/<eco>.js, a filename rule, a registry entry, a config flag. Go was added in exactly one commit on top of the npm/Python base, which we take as evidence the extension seams are right.

  3. Protocol-correct pull blocking (src/proxy/routes/index.ts): blocked pulls previously reused the receive-pack sideband error path, which git clients cannot render for upload-pack responses. We added an ERR PKT-LINE response with the correct content type, so a blocked clone shows the full findings report in the developer's terminal via remote: lines and the clone fails cleanly.

Where sideband streaming fits (#1637)

Blocking works today, and blocked clients see the full report. The gap is non-blocking warnings on pull: when findings are below the block threshold, the clone proceeds and the warnings only exist in server logs. There is currently no channel to whisper "this repo you just cloned has a postinstall script that curls a raw IP" into the developer's terminal while still letting the clone succeed.

@jescalada's sideband streaming work in #1637 is exactly the missing delivery mechanism. With progress/sideband streaming wired into the chain, warn-level supply-chain findings could be streamed live to the client during clone/fetch (and richer live feedback during push) without blocking anything. We built this expecting that synergy: the findings report is already rendered as terminal-friendly text and attached to the action step, waiting for a transport. If #1637 lands, this plugin becomes dramatically more useful in warn-only mode, which is how we expect most organizations would run it initially.

What we verified

  • Unit tests: 103 dedicated tests for the rule engine across 8 files (test/plugins/supply-chain/), full suite at 1244 passing. Every rule has positive and negative cases, including malformed-input tests (analyzers never throw; the scanner never breaks a push or clone on its own error).
  • End to end: we drove the real pullExec/exec plugin code against a live, deliberately poisoned GitHub testbed repo. Clone: 11 findings, blocked at critical, correct client-facing message. Push (clean manifest to poisoned manifest across two commits): 9 findings, blocked, and diff-awareness confirmed (only newly added modules flagged, pre-existing ones suppressed).
  • Live wire verification (the recordings above): a real GitProxy instance (npm run server, plugin loaded through the actual PluginLoader), a real git clone through http://localhost:8000 aborted with fatal: remote error: and the full report, and a real git push rejected over the receive-pack sideband with remote: lines. This exercised the entire stack including the Express routes, not just the chain.
  • Wire format: the upload-pack ERR packet is additionally byte-verified in test/testProxyRoute.test.ts.
  • Running through the real PluginLoader also caught a bug the unit tests could not: the loader takes only the default export of each configured module (load-plugin's key defaults to 'default'), so the pull plugin's named export was silently dropped. Fixed by shipping the pull scanner as its own entry file (pull.js) registered as a second plugins entry. Worth noting for the plugin-system discussion: a module cannot currently export both a push and a pull plugin.
  • Runnable demos: SUPPLY-CHAIN.md in this branch contains a 60-second no-proxy demo and a full proxy walkthrough for both push and pull protection.

Honest limitations (we would rather over-disclose)

  • This is a warning layer, not a guarantee. Manifest heuristics catch common attack signatures in declared dependencies; absence of findings does not mean a repository is safe. Malicious code can live outside manifests entirely.
  • Pull scanning is HTTPS-only and scans the default branch, not the specific requested ref, so scanned content can differ from delivered content. It also costs an extra fetch on allowed pulls.
  • Pull actions are not audited today (audit.ts skips PULL), so a blocked clone has no dashboard record.
  • There is no findings-aware approval UI yet. Push findings do surface in the push detail view (the steps timeline renders step logs and raw step content), but a reviewer sees an unstructured text report and a JSON blob rather than a purpose-built findings panel, and the approve/reject flow does not understand severities. Proper UI approval integration needs to be added before this can be operated as a real control rather than a terminal-level one.
  • The popular-package lists for typosquat detection are offline snapshots; a networked tier (OSV.dev, registry metadata via global fetch, opt-in) is sketched but deliberately out of scope here.
  • Warn-level pull findings are server-log-only until something like feat: add live feedback during pushes (sideband streaming) #1637 exists (see above).

Where this could go next

  • Cargo and RubyGems analyzers (each is one lib/ecosystems module plus registry entries).
  • Opt-in networked verification tier: OSV.dev advisories, registry publish-date and ownership checks.
  • Findings-aware approval UI: a proper findings panel in the push detail view and an approval flow that understands severities (today reviewers see raw step logs). We consider this a prerequisite for real-world use, not a nice-to-have.
  • Warn-level sideband delivery on top of feat: add live feedback during pushes (sideband streaming) #1637.
  • SSH pull scanning.

If the direction resonates we are happy to split this into reviewable pieces (core chainPhase hook first, plugin second), rewrite whatever the maintainers want reshaped, or fold parts into existing efforts. Tear it apart.

@netlify

netlify Bot commented Jul 6, 2026

Copy link
Copy Markdown

Deploy Preview for endearing-brigadeiros-63f9d0 canceled.

Name Link
🔨 Latest commit 2c44057
🔍 Latest deploy log https://app.netlify.com/projects/endearing-brigadeiros-63f9d0/deploys/6a4b90d0d96fb600080e1892

Comment thread src/proxy/routes/index.ts
res.set('expires', 'Fri, 01 Jan 1980 00:00:00 GMT');
res.set('pragma', 'no-cache');
res.set('cache-control', 'no-cache, max-age=0, must-revalidate');
res.status(200).send(handleErrPacket(message));
@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.29630% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.86%. Comparing base (3186da2) to head (2c44057).

Files with missing lines Patch % Lines
src/proxy/chain.ts 93.54% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1639      +/-   ##
==========================================
- Coverage   85.87%   85.86%   -0.02%     
==========================================
  Files          84       84              
  Lines        8109     8159      +50     
  Branches     1376     1391      +15     
==========================================
+ Hits         6964     7006      +42     
- Misses       1117     1124       +7     
- Partials       28       29       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

dcoric added 6 commits July 6, 2026 13:22
Add an optional chainPhase option to PushActionPlugin so push plugins can
run after the built-in getDiff step, with access to the computed diff and
the cloned repo. Default 'start' preserves existing behaviour.

Add @finos/git-proxy-plugin-supply-chain, an out-of-tree push plugin that
flags common npm supply-chain-attack signatures on changed manifests and
lockfiles: install lifecycle scripts, non-registry dependency sources,
overrides/resolutions, typosquats, unpinned versions, and off-registry
lockfile sources. Non-blocking by default; configurable to hard-block via
failOn.
Extend the supply-chain scanner to Python dependency files. Add pattern
based manifest classification (requirements*.txt) and a Python analyzer
covering setup.py install-time code execution, custom package indexes
(index-url and poetry/pipenv source blocks), non-registry sources
(vcs/url/editable, inline git/url/path, PEP 508 direct references),
unpinned requirements, typosquats against a PyPI reference list, and
git/http lockfile sources. Generalise the typosquat helper so it serves
both npm and PyPI, and enable the python ecosystem by default.
Add pull/clone protection. Introduce a new afterAuth pull chainPhase and a
PullActionPlugin that, once a repo is confirmed authorised, shallow-clones
the repository being fetched, enumerates its manifests and scans them with
the existing npm/Python analyzers, treating the whole tree as newly
introduced. Warn by default (findings logged, clone proceeds); block above
pull.failOn, in which case the clone fails with the findings shown in the
developer's terminal. HTTPS only; default branch scanned; SSH pulls and
inline (non-blocking) terminal warnings are deferred.
A blocked pull/fetch previously reused the receive-pack sideband error
response, which is the wrong content type and framing for git-upload-pack
and a fetching client cannot render it. Send a protocol-correct
PKT-LINE("ERR" SP message) with the application/x-git-upload-pack-result
content type so git clone/fetch aborts and prints the reason. Add
handleErrPacket, route git-upload-pack POSTs to it in sendErrorResponse,
and cover it with unit tests. Also add SUPPLY-CHAIN.md, a manual test and
demo guide.
@dcoric dcoric force-pushed the feat/supply-chain-plugin branch from 04105a1 to 2c44057 Compare July 6, 2026 11:26
@dcoric dcoric marked this pull request as ready for review July 6, 2026 11:28
@dcoric dcoric requested a review from a team as a code owner July 6, 2026 11:28
@dcoric dcoric self-assigned this Jul 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants