# Git and URL Dependencies

> Git and URL dependencies in npm point a package at a Git repo or tarball URL instead of a registry version, so its code is fetched live at install.

Also known as: git dependencies, GitHub dependencies, HTTP dependencies, tarball URL dependencies, remote dynamic dependencies, RDD, exotic dependencies

Source: https://socket.dev/glossary/git-dependencies

Last updated: 2026-10-29

Git and URL dependencies (npm calls them git URLs and tarball URLs; pnpm calls them exotic) are package.json entries that point at a Git repository or tarball URL instead of a registry version. npm fetches whatever that location serves at install time, so the code bypasses registry immutability, scanning, and takedown.

The registry never holds the bytes of a Git or URL dependency. A signed, scanned parent package can therefore pull in code that no registry check ever saw, and the parent's signature and provenance say nothing about it. As of npm v12, released July 8, 2026, the npm CLI refuses both kinds by default. We think that fixes the client and leaves the registry open. The npm registry still accepts published packages that declare Git and URL dependencies, while crates.io, PyPI, and RubyGems refuse them.

## How do you install an npm dependency from GitHub?

You install an npm dependency from GitHub by writing a Git reference where a version range would go, then opting in with `--allow-git`. Since npm v12, a plain `npm install` refuses it. The [package.json grammar](https://docs.npmjs.com/cli/v11/configuring-npm/package-json) accepts these forms:

```json
{
  "dependencies": {
    "a": "github:owner/repo",
    "b": "owner/repo#v2.1.0",
    "c": "github:owner/repo#98e8ff1da1a89f93d1397a24d7413ed15421c139",
    "d": "git+https://gitlab.com/owner/repo.git#semver:^2.0.0",
    "e": "git+ssh://git@github.com/owner/repo.git#main",
    "f": "https://example.com/pkgs/f-1.0.0.tgz"
  }
}
```

`a` follows the default branch. `b` and `e` follow a tag and a branch, and `d` asks npm to pick whichever tag matches a semver range. `c` names one commit. `f` is a URL dependency: a tarball fetched from any HTTP server. Only `c` names fixed content, because branches and tags can both be moved. A `github:owner/repo` reference is also a name lookup at install time, so it inherits [repojacking](https://socket.dev/glossary/repojacking) risk if the owner renames or deletes the account.

We installed a two-entry manifest with npm 12.2.1 on October 29, 2026:

```console
$ npm install
npm error code EALLOWGIT
npm error Fetching packages of type "git" have been disabled
npm error Refusing to fetch "is-number@github:jonschlinkert/is-number#98e8ff1"

$ npm install --allow-git=root
npm error code EALLOWREMOTE
npm error Fetching packages of type "remote" have been disabled
npm error Refusing to fetch "left-pad@https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"

$ npm install --allow-git=root --allow-remote=root
added 2 packages in 5s
```

Both settings take `none`, `root`, or `all`. `root` allows only the entries in your own package.json, and `all` also allows entries declared by your dependencies. Writing a registry.npmjs.org URL by hand did not help: npm 12 still refused it as a remote spec. `--allow-git` first shipped in npm 11.10.0 with a default of `all`, and [npm 12 changed both defaults to none](https://socket.dev/blog/npm-12).

## What npm records for Git dependencies and tarball URLs

npm records a Git dependency in [package-lock.json](https://socket.dev/glossary/package-lock-json) as a repository URL plus a commit SHA. A URL dependency gets the URL plus a hash of whatever the server sent the first time. This is the lockfile from the install above:

```json
"node_modules/is-number": {
  "version": "7.0.0",
  "resolved": "git+ssh://git@github.com/jonschlinkert/is-number.git#98e8ff1da1a89f93d1397a24d7413ed15421c139"
},
"node_modules/left-pad": {
  "version": "1.3.0",
  "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
  "integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="
}
```

The Git entry has no `integrity` field. npm expanded the short ref `98e8ff1` to the full SHA, and that SHA is the only thing tying the lockfile to the bytes. When we wrote the full 40-character SHA in package.json instead, npm 12 recorded an `integrity` hash for the same commit. Write the full SHA.

A registry version works differently. A published `name@version` on the npm registry can never be reused for different bytes. The registry publishes a `sha512` integrity and an ECDSA signature for each tarball, and it can replace a malicious version with a security placeholder. None of those guarantees cross the boundary to a Git host or an arbitrary URL. The registry still serves this 2018 release, signature included:

```console
$ curl -s https://registry.npmjs.org/web3-providers-ws/1.0.0-beta.33 | jq '.dependencies, .dist.signatures[0].keyid'
{
  "websocket": "git://github.com/frozeman/WebSocket-Node.git#browserifyCompatible",
  "underscore": "1.8.3",
  "web3-core-helpers": "1.0.0-beta.33"
}
"SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA"
```

The signature covers the tarball, and the tarball contains a pointer to a branch over unencrypted `git://`. As of October 29, 2026, `browserifyCompatible` resolves to commit `6c72925`. Whoever controls that repository decides what it resolves to next.

## Why a lockfile does not make a URL dependency safe

A [lockfile](https://socket.dev/glossary/lockfile) pins a URL dependency to the hash of the first response, which is trust on first use. To show what that buys, we served a tarball from a local HTTP server, installed it with npm 12.2.1, then replaced the file at the same URL:

```console
$ npm install --allow-remote=root && node -p 'require("helper")'
benign

# the server now returns different bytes at the same URL
$ rm -rf node_modules && npm ci --allow-remote=root
npm error code EINTEGRITY
npm error sha512-dxYl...nIBg== integrity checksum failed when using sha512:
  wanted sha512-dxYl...nIBg== but got sha512-cih3...vMDw==.

$ rm -rf node_modules package-lock.json && npm install --allow-remote=root && node -p 'require("helper")'
MALICIOUS
```

`npm ci` caught the swap because a hash already existed. The fresh install did not fail. It wrote the new hash into a new lockfile. If the first fetch is already malicious, the lockfile pins the malware faithfully.

The second limit matters more. npm's documentation says package-lock.json "will be ignored if found in any place other than the root project." A package you depend on cannot ship its lockfile to you. A Git or URL dependency declared inside a published package reaches you as a [transitive dependency](https://socket.dev/glossary/transitive-dependency), and it is resolved fresh on every machine that installs it for the first time. That matches what we found [after the axios compromise](https://socket.dev/blog/hidden-blast-radius-of-the-axios-compromise): lockfiles make existing installs deterministic, and they do not make new installs safe.

## Are Git and URL dependencies safe to use?

Git and URL dependencies are reasonable as direct dependencies you control and pin to a full commit SHA. Declared by someone else's package, they are a delivery channel that registry defenses cannot see. Two attacks show how.

### PhantomRaven and remote dynamic dependencies

Remote dynamic dependencies are URL dependencies used to keep a malicious payload off the registry. The term comes from the October 2025 disclosure of the PhantomRaven campaign, which [put 126 packages on npm](https://thehackernews.com/2025/10/phantomraven-malware-found-in-126-npm.html) with more than 86,000 downloads. Each published tarball held placeholder code. Its manifest declared a dependency on an attacker-controlled HTTP URL, and the tarball fetched from that URL carried a [`preinstall` script](https://socket.dev/glossary/postinstall-scripts) with the stealer. CrowdStrike's [analysis of a later wave](https://www.crowdstrike.com/en-us/blog/phantomraven-llm-generated-information-stealer-for-bug-bounty-hunting/) describes the shape: the packages "specify a dependency via an HTTP URL rather than a standard npm package reference."

The registry's own metadata shows what this bought the attacker. npm keeps publish times in each package's `time` field, including the moment it swapped in a `0.0.1-security` placeholder. We pulled those fields on October 29, 2026, for seven PhantomRaven packages named in public reporting:

| Package | Last version before placeholder | Published (UTC) | Placeholder (UTC) | Days on registry |
| --- | --- | --- | --- | --- |
| badgekit-api-client | 9.9.0 | 2025-09-13 19:55 | 2025-10-27 06:10 | 43.4 |
| op-cli-installer | 1.0.0 | 2025-09-14 14:54 | 2025-10-27 06:10 | 42.6 |
| polyfill-corejs3 | 9.9.0 | 2025-09-15 14:03 | 2025-10-27 06:10 | 41.7 |
| unused-imports | 9.9.0 | 2025-09-15 15:04 | 2025-10-27 06:10 | 41.6 |
| eslint-comments | 9.9.1 | 2025-09-16 02:54 | 2025-10-27 06:10 | 41.1 |
| sort-imports-es6-autofix | 9.9.0 | 2025-11-12 11:48 | 2026-01-07 05:37 | 55.7 |
| transform-jsbi-to-bigint | 9.9.0 | 2025-11-17 22:58 | 2026-03-18 02:18 | 120.1 |

Each PhantomRaven package stayed on the registry for 41 to 120 days. The five September packages were replaced within 21 seconds of each other on October 27, 2025, in one bulk takedown. The two November packages were published after the campaign was public and lasted longer. These numbers measure time to takedown, not confirmed exposure, but they are what you would expect when the registry artifact is clean. The attacker's server also decides what each install receives, so it can serve harmless code first and the stealer later.

### The TanStack compromise

The TanStack compromise used a Git dependency to run its payload during install. On May 11, 2026, an attacker published [84 malicious versions of 42 `@tanstack/*` packages](https://github.com/advisories/GHSA-g7cv-rxg3-hmpx) through the project's own [trusted publishing](https://socket.dev/glossary/npm-trusted-publishing) binding, as part of the [Mini Shai-Hulud](https://socket.dev/supply-chain-attacks/mini-shai-hulud) campaign. Each version added one entry to package.json:

```json
"optionalDependencies": {
  "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}
```

That commit was never part of TanStack's history. [TanStack's postmortem](https://tanstack.com/blog/npm-supply-chain-compromise-postmortem) lists it as an "orphan payload commit (in fork network)." GitHub serves commits pushed to a fork under the parent repository's name, so `tanstack/router#<sha>` named code TanStack never wrote. [Our analysis of the TanStack compromise](https://socket.dev/blog/tanstack-npm-packages-compromised-mini-shai-hulud-supply-chain-attack) found that the commit held only a package.json and a bundled `tanstack_runner.js`, plus a `prepare` script that ran it under Bun. The first malicious version went out at 19:20:39 UTC, and npm removed the last tarball at 23:55:26 UTC, about 4 hours and 35 minutes later.

> Diagram: two packages inside the npm registry boundary point to code outside it. A compromised TanStack version points to an orphan commit in GitHub's fork network that runs a prepare script. A PhantomRaven package points to an attacker HTTP host whose tarball runs a preinstall script. Both reach the developer or CI machine without any registry check.

The figure above shows two packages inside the npm registry boundary, where every version is immutable, hashed, signed, scanned, and removable. The first is the compromised `@tanstack/react-router` 1.169.5, published May 11, 2026, signed by the registry and published through trusted publishing. Its `optionalDependencies` entry for `@tanstack/setup` points outside the boundary to `tanstack/router#79ac49e`, an orphan commit in GitHub's fork network that was never in TanStack's history. The second is the PhantomRaven package `unused-imports` 9.9.0, also signed, whose `index.js` is placeholder code and whose `dependencies` entry points to an attacker-controlled HTTP host. That host can return a different tarball on every request, and the registry holds no copy. Both external sources feed the developer laptop or CI runner during `npm install`: the TanStack commit through `prepare: bun tanstack_runner.js`, and the PhantomRaven tarball through a `preinstall` stealer. No registry check runs on either external source.

Nothing about the TanStack tarballs looked wrong at publish time. They came from the real repository's CI through its trusted publishing binding, and the payload sat one hop outside the registry. [Provenance](https://socket.dev/glossary/package-provenance) tells you which pipeline published a tarball. It cannot tell you what a Git reference inside that tarball resolves to.

## Building a Git dependency runs its code

A Git dependency is source code, not a built package, so npm has to build it before installing it. When the cloned repository declares workspaces or any of `build`, `prepare`, `prepack`, `preinstall`, `install`, or `postinstall`, npm runs `npm install` inside the clone and packs the result. That build step is [documented behavior](https://docs.npmjs.com/cli/v11/configuring-npm/package-json) for Git URLs, and it is a separate code path from ordinary lifecycle scripts. Script-blocking controls have missed it repeatedly:

- In 2023, Google's security research team published [GHSA-wr8v-3jqh-9x36](https://github.com/google/security-research/security/advisories/GHSA-wr8v-3jqh-9x36), in which npm 9.2.0 ran a directory dependency's `prepare` script under `--ignore-scripts`.
- In January 2026, pnpm published [GHSA-379q-355j-w6rj](https://github.com/advisories/GHSA-379q-355j-w6rj), fixed in 10.26.0. Git-hosted dependencies had been running `prepare` during fetch despite pnpm 10's default script block.
- The same month, the [PackageGate disclosures](https://www.bleepingcomputer.com/news/security/hackers-can-bypass-npms-shai-hulud-defenses-via-git-dependencies/) showed that a Git dependency's own `.npmrc` could point npm at a different `git` binary, which ran code even under `--ignore-scripts`. npm's answer was [the `--allow-git` flag in npm 11.10.0](https://socket.dev/blog/npm-introduces-minimumreleaseage-and-bulk-oidc-configuration).

We tested the first class on October 29, 2026, with a local Git repository whose `prepare` script wrote a marker file. npm 10.9.9, the newest npm 10 release, ran the script with `--ignore-scripts` set. npm 11.21.1 and npm 12.2.1 did not. If your CI still runs npm 10, `--ignore-scripts` does not stop a Git dependency's `prepare` script.

Treat `--allow-git` as consent to execute. Allowing a Git dependency means allowing its build, so allow it only for repositories whose code you would run directly.

## Which registries let published packages depend on Git or URLs?

Most major registries refuse to publish a package that declares a Git or URL dependency, and npm accepts one. We checked each registry against its source, its docs, or a live run:

| Ecosystem | Can a published package declare a Git or URL dependency? | How it is enforced |
| --- | --- | --- |
| npm | Yes | Nothing at publish. npm 12 clients refuse both by default, and pnpm 11 and later refuse them as subdependencies. |
| PyPI | No | Warehouse rejects an upload whose Requires-Dist holds a direct reference, with "Can't have direct dependency." |
| crates.io | No | cargo publish fails with "all dependencies must have a version requirement specified when publishing." A git plus version entry publishes as the registry version only. |
| RubyGems | No | A gemspec cannot express one. add\_dependency with a URL raises Gem::Requirement::BadRequirementError. Git sources exist only in a Bundler Gemfile. |
| Packagist | Not transitively | Composer does not load the repositories of your requirements. Only the root composer.json can add a VCS source. |
| Go | Every module is a VCS path | The module proxy freezes the first fetch, and the go command checks new modules against the checksum database. |

We read PyPI's rule in [Warehouse's upload validator](https://github.com/pypi/warehouse/blob/main/warehouse/forklift/metadata.py), ran `cargo publish --dry-run` and `Gem::Specification#add_dependency` ourselves, and took the Composer rule from [its FAQ](https://getcomposer.org/doc/faqs/why-cant-composer-load-repositories-recursively.md). The Cargo fallback is in [the Cargo reference](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html).

Verdict: the rule that works is that only the root project may name a non-registry source. PyPI, crates.io, RubyGems, and Composer enforce it at publish or at resolution, so no package can pass a Git or URL dependency on to its consumers. [Go modules](https://socket.dev/glossary/go-modules) take the other route and turn every VCS reference into an immutable, hash-checked artifact on first fetch. That fixes mutability, not malice: a [cached backdoored boltdb-go/bolt version](https://socket.dev/blog/malicious-package-exploits-go-module-proxy-caching-for-persistence) kept being served for more than three years after its Git tag was rewritten to clean code. npm 12's `root` setting is the client-side version of the root-only rule, and it protects only the people who upgraded.

## How npm 12 and pnpm handle Git and URL dependencies

npm 12 and pnpm both block transitive Git dependencies by default, but they fail differently. We ran each case on October 29, 2026:

| Case | npm 12.2.1 default | npm 12.2.1 with --allow-git=root | pnpm default |
| --- | --- | --- | --- |
| Direct Git dependency | Fails with EALLOWGIT | Installs | Installs (pnpm 12.16.1) |
| Transitive Git dependency | Fails with EALLOWGIT | Fails: non-root git packages disabled | Fails with ERR\_PNPM\_EXOTIC\_SUBDEP (pnpm 11.28.9 and 12.16.1) |
| Transitive Git dependency in optionalDependencies | Skipped with no warning, install succeeds | Not tested | Fails with ERR\_PNPM\_EXOTIC\_SUBDEP (pnpm 11.28.9 and 12.16.1) |

The optional row is the TanStack shape. Had those versions met npm 12's defaults, `@tanstack/setup` would have been dropped and the install would have reported success. That protects you, and it also hides that a dependency tried. pnpm makes the attempt visible by failing the install. [pnpm 11 made `blockExoticSubdeps: true` a default](https://socket.dev/blog/pnpm-11-adds-new-supply-chain-protection-defaults) in May 2026, days before the TanStack versions were published.

What we recommend, and what each step costs:

- **Keep `allow-git` and `allow-remote` at `none` in CI.** A project that needs its own fork sets `root` in its .npmrc. The cost is that legitimate transitive Git dependencies break until their authors publish to a registry.
- **Pin direct Git dependencies to a full 40-character SHA.** npm then records an integrity hash. The SHA still says nothing about who wrote the commit, as TanStack showed.
- **Publish forks instead of referencing them.** A scoped package on npm or a private registry gets an immutable version and the same scanning as everything else. The cost is a publish step for every change.
- **Watch the declaration, not just the payload.** A Git or URL entry is visible in the published package.json even when the code it points to is not. Socket's [Git dependency](https://socket.dev/alerts/gitDependency), [GitHub dependency](https://socket.dev/alerts/gitHubDependency), and [HTTP dependency](https://socket.dev/alerts/httpDependency) alerts fire on that manifest entry, and the [default security policy](https://docs.socket.dev/docs/security-policy-default-enabled-alerts) sets all three to Warn. We would set them to Block and triage the direct dependencies you chose on purpose. The alert tells you a package reaches outside the registry. It cannot vouch for what that URL serves tomorrow.

npm 12's defaults protect new installs on upgraded clients. Every npm 10 and npm 11 client still resolves, by default, the Git and URL dependencies it is handed, and the registry still accepts packages that hand them out. Until npm rejects those manifests at publish time, as PyPI and crates.io do, a clean registry tarball remains a way to deliver code the registry never saw.
