Skip to content

Supply chain proxy

Every npm, pnpm, Composer and Docker install your team makes can go through MopokeHQ’s registry proxy at https://registry.mopokehq.com. The proxy checks each package version against your organisation’s policy before it reaches a laptop or a pipeline, logs every download with its verdict, and holds risky versions for a person to review.

The proxy does not host packages. Public packages come from npmjs, Packagist, Docker Hub and ghcr.io. Your own private packages stay in your own registry (see Private packages), and publishing stays in your own CI, never through MopokeHQ.

Everything below is also on the Supply chain page, Access tab, with your organisation’s address filled in.

One host, one path per ecosystem, with your organisation’s slug in every path. The slug is shown on the Supply chain page.

Ecosystem Address Sign in with
npm, pnpm https://registry.mopokehq.com/npm/<slug>/ _authToken (bearer)
Composer 2 https://registry.mopokehq.com/composer/<slug> HTTP basic: any username, the key as password
Docker images registry.mopokehq.com/<slug>/docker.io/<image>:<tag>
registry.mopokehq.com/<slug>/ghcr.io/<owner>/<image>:<tag>
docker login: any username, the key as password

A key only works on its own organisation’s paths.

Proxy keys start with mpr_. Each one belongs to one person or one CI project and is never shared, so every download names who fetched it.

  • Personal keys. Anyone in the organisation creates their own on Supply chain, Access.
  • CI project keys. Owners and admins create one per project (one live key per project at a time).
  • A key is shown once. MopokeHQ stores only a hash of it.
  • A person’s keys stop working when they leave the organisation.
  • Revoking a key takes effect within a minute.

Keep keys in environment variables or CI secrets, never in a committed file.

Add this .npmrc to the project and commit it. The key stays in the environment as NPM_TOKEN:

registry=https://registry.mopokehq.com/npm/<slug>/
//registry.mopokehq.com/npm/<slug>/:_authToken=${NPM_TOKEN}

Or set it once in your user .npmrc, for every project on the machine (a project’s own .npmrc still wins). The single quotes keep ${NPM_TOKEN} literal, in PowerShell too:

Terminal window
npm config set registry https://registry.mopokehq.com/npm/<slug>/
npm config set //registry.mopokehq.com/npm/<slug>/:_authToken '${NPM_TOKEN}'

Then npm whoami answers with the key’s name, and npm ping works. npm audit is forwarded to npmjs with your private packages left out. Search and publishing are not available through the proxy.

pnpm’s own release age setting. The proxy enforces the cooldown for every client, so pnpm’s minimum-release-age is no longer needed. If you keep it, it still works as a second layer, but it also holds back the security fixes the proxy lets through early (see Security fast-track). Keep the rest of your pnpm hardening: a frozen lockfile, exact pins, store integrity and approved build scripts.

Terminal window
composer config repositories.packagist.org false
composer config repositories.mopokehq composer https://registry.mopokehq.com/composer/<slug>
composer config --global http-basic.registry.mopokehq.com token "$MOPOKE_REGISTRY_KEY"

The first two lines go in the project’s composer.json; the third keeps the key in your user configuration, outside the repository.

Terminal window
echo "$MOPOKE_REGISTRY_KEY" | docker login registry.mopokehq.com -u <slug> --password-stdin
docker pull registry.mopokehq.com/<slug>/docker.io/library/node:24-alpine

Prefix the image’s usual name with registry.mopokehq.com/<slug>/ and its registry (docker.io or ghcr.io), in Dockerfiles and compose files alike.

Create a CI project key, store it as a masked secret named MOPOKE_REGISTRY_KEY, and pass it to the package managers.

GitLab CI (a masked, protected CI/CD variable):

variables:
NPM_TOKEN: $MOPOKE_REGISTRY_KEY
COMPOSER_AUTH: '{"http-basic":{"registry.mopokehq.com":{"username":"token","password":"$MOPOKE_REGISTRY_KEY"}}}'

GitHub Actions and Gitea or Forgejo Actions (a repository secret):

env:
NPM_TOKEN: ${{ secrets.MOPOKE_REGISTRY_KEY }}
COMPOSER_AUTH: '{"http-basic":{"registry.mopokehq.com":{"username":"token","password":"${{ secrets.MOPOKE_REGISTRY_KEY }}"}}}'

For each package version, in this order:

  1. Known malicious: always blocked. Any version named in an OSV malicious package report is hidden from metadata and its download is refused. Nobody can release it.

  2. A person’s decision stands. A version an owner or admin released is served; one they kept blocked is refused.

  3. Release cooldown. Versions younger than your cooldown (24 hours by default, settable from 0 to 720 hours) are hidden from metadata, so installs resolve to the version before, and latest points at the newest version that has cleared it. A direct download of one (from a lockfile) is refused with the time it becomes available.

    • Security fast-track (on by default): a version inside the cooldown is let through when it fixes an advisory your organisation has an open exposure to. It still gets the risk check below.
  4. Risk score. Each version gets a score from 0 to 100:

    Signal Adds
    Named like a popular package (one edit away, two for long names, or the same without separators, such as crossenv for cross-env) 45
    Published by a new maintainer 35
    Pulled from the registry after publishing 30
    Package first published under a week ago (under a month) 25 (15)
    Runs install scripts, or is a Composer plugin 20
    Deprecated or abandoned upstream 10

    Your thresholds turn the score into a verdict: warn at 30 (served and logged as a warning), quarantine at 60 (held until an owner or admin releases it), and block (off by default; set a number to block very high scores outright).

  5. Unscanned. If the checks themselves cannot run, a version past its cooldown is served and logged as unscanned, and MopokeHQ’s on-call is alerted. A version inside its cooldown stays held.

Quarantined and blocked versions also drop out of metadata, so later installs resolve around them.

Packages in your private scopes skip the cooldown and the risk score (they are your own), and are still logged.

Integrity. npm tarballs are checked against the registry’s published checksum before any byte is sent; a mismatch is never served. Composer archives are checked against Packagist’s checksum where there is one; where there is none, the first download is recorded and later installs get that same copy. Image manifests and layers must match their digests.

Every image pull is logged, and images on your deny list are refused (for example docker.io/library/ubuntu:14.04, or ghcr.io/someorg/* for everything under it). The proxy pulls public images only, and pushes are refused.

The cooldown does not apply to image tags. A tag such as node:24-alpine moves with every security rebuild, and holding it back would hold back exactly the patched base images. Pin images by digest instead.

When a version is quarantined:

  • The developer sees it at install time. npm prints the reason from the refusal, and when metadata hides a version waiting for review, npm says why. pnpm prints only 403 Forbidden, so pnpm users learn from the email below or the Downloads tab.
  • Owners and admins get one email per install, listing each held version, why it was held and who asked for it, with a link to review it.

On Supply chain, Review, each held version shows its risk score and reasons, Asked for by (the people or CI projects whose installs it stopped, and how often) and Used in (the projects that already use the package, and at which version; “No project yet” means it would be a new dependency). Owners and admins choose Release or Keep blocked.

When a version is released, the people it stopped get an email, apart from whoever released it. CI keys belong to a pipeline rather than a person, so nobody is emailed for them; the pipeline passes on its next run.

Blocked versions send no email, because there is nothing to decide. Every quarantine, block and release goes into the evidence log and out to webhooks as package.quarantined, package.blocked and package.released.

The page has four tabs: Review, Downloads (the full log with verdicts, filterable to refused or warned), Access (keys and setup) and Policy. Everyone in the organisation can read them; owners and admins change the policy. Policy changes reach the proxy within 30 seconds.

Private packages stay in your own registry. The proxy fetches them from there on each install and does not cache them.

Today a private scope resolves from a GitLab package registry, on gitlab.com or self-managed:

  1. In GitLab, on the top-level group that holds the packages, create a group deploy token with only the read_package_registry scope.
  2. On Supply chain, Policy, enter its username and token. The token is stored encrypted with a key unique to your organisation and never shown again.
  3. Add each private scope with the GitLab group’s registry address:
    • npm, for example @yourcompany: https://gitlab.example.com/api/v4/groups/<group id>/-/packages/npm/
    • Composer, for example yourcompany: https://gitlab.example.com/api/v4/group/<group id>/-/packages/composer/packages.json

A private scope resolves only from your registry. If the package is not there, the answer is not found: the proxy never falls back to npmjs or Packagist, so nobody can win with a look-alike public package under your name. The deploy token is sent only to the host the scope names.

If npmjs or Packagist is down, the proxy serves the metadata and files it has cached, so most installs carry on.

If MopokeHQ itself is down, point npm back at the public registry and your private scope straight at your own registry until it recovers, then revert:

registry=https://registry.npmjs.org/
@yourcompany:registry=https://gitlab.example.com/api/v4/groups/<group id>/-/packages/npm/
//gitlab.example.com/api/v4/groups/<group id>/-/packages/npm/:_authToken=${GITLAB_READ_TOKEN}

For Composer, run composer config --unset repositories.packagist.org and add your own registry as a repository. Installs made this way are not checked or logged.