Dependency Source Policy

Dependency Source Policy

AIWG runs a CI lint that scans `package.json` and `package-lock.json` for dependency sources that match a known supply-chain injection pattern. If your CI build fails with a `Dependency source policy violation` message, this doc explains what happened and how to fix it.

The policy lives in `.aiwg/architecture/adr-dep-source-policy.md`. This doc is the contributor-facing companion.

What the policy forbids

The lint rejects eight dep-source patterns:

PatternExampleWhy it's flagged
`git+*` scheme`git+https://github.com/foo/bar.git`npm clones the repo and runs its `prepare` script — arbitrary code execution at install time
`git://` scheme`git://github.com/foo/bar.git`Same as above
Hosted git shorthand`github:foo/bar`, `gitlab:foo/bar`, `gist:1234abcd`npm clones the repo and runs its `prepare` script
Direct git URL`https://github.com/foo/bar.git`Same as above
SSH git spec`[email protected]:foo/bar.git`Same as above
GitHub `owner/repo` shorthand`foo/bar#main`Same as above
Non-registry tarball`https://example.com/foo-1.0.0.tgz`The tarball can contain any payload and lifecycle scripts
`file:` path`file:./vendor/foo`Local-path deps bypass dep-resolution review
`link:` symlink`link:./packages/foo`Same — and follows the symlink target wherever it points

Registry-hosted tarballs (`registry.npmjs.org/.../foo.tgz`, `registry.yarnpkg.com/...`, `npm.pkg.github.com/...`) are fine — those are the normal `resolved` URL format that `npm install` emits for every registry dep. The lint only flags tarballs from non-registry hosts.

Why this policy exists

Mini Shai-Hulud's May 2026 npm wave and several other recent supply-chain attacks have used dep-source injection as a propagation vector. The pattern:

1. Attacker gains write access to a project (compromised maintainer account, malicious PR, social engineering). 2. Attacker adds a single dep — usually as `optionalDependencies` to reduce visibility — whose source is `git+https://...attacker-repo`. 3. Every `npm install` clones the attacker repo and runs its `prepare` script, which contains the payload. 4. Payload steals secrets from the install environment (CI tokens, dev workstation env vars, `.npmrc` credentials).

A normal-looking `package.json` review easily misses this because the attention goes to the names of new deps, not the source URLs. The lint forces every exotic source to be either swapped out or explicitly allowlisted — every exception becomes a discrete, reviewed decision.

The release-age gate (#1290) and audit-signature gate (#1288) handle related but distinct attacks: a brand-new malicious registry version entering the lockfile too quickly, and unsigned or invalid registry metadata in the installed dependency tree. Together with this dep-source lint, those controls cover the most likely manifest-to- lockfile injection paths.

Reading the failure output

A failing run looks like:

✗ Dependency source policy violation: 1 issue

  /path/to/package.json > optionalDependencies > "some-pkg"
    source: git+https://github.com/example/some-pkg.git
    pattern: git+* (git+ scheme prefix)
    fix: swap to a registry version, or allowlist it in
         ci/dep-source-allowlist.yaml with name, source-pattern,
         rationale, last-reviewed-date

See docs/contributing/dependency-sources.md for the policy rationale.
Allowlist file: ci/dep-source-allowlist.yaml

Three things to look at:

1. Where — the bucket (`dependencies`, `devDependencies`, `optionalDependencies`, `peerDependencies`) and the dep name. Or for transitive violations, the lockfile path (`package-lock.json > node_modules/foo/node_modules/bar`). 2. Source — the exact string that triggered the rule. 3. Pattern — which of the eight rules matched.

How to fix a violation

First choice: swap to a registry version

99% of legitimate use cases have a registry equivalent. If you added a git dep because you needed an unreleased bugfix, check whether the fix is in a newer registry version. If you forked an upstream package, ask whether the fork can be published to npmjs.org under a scoped name.

Second choice: allowlist the dep

If the registry doesn't have what you need (rare, but legitimate cases exist — e.g., an internal package hosted on a private git server that isn't an npm registry), add an entry to `ci/dep-source-allowlist.yaml`:

allowlist:
  - name: my-internal-pkg
    source-pattern: git+ssh://git@internal-host/team/my-internal-pkg.git
    rationale: |
      Internal-only package. The team's git server is not an npm
      registry. Sister project also uses this dep with the same
      source pattern.
    last-reviewed-date: 2026-05-12
    reviewer: jmagly

Schema requirements

Every allowlist entry needs:

  • `name` — exact package name match (no globs).
  • `source-pattern` — either an exact string match or a regex in

the form `/regex/flags`. Exact-string is preferred; regex is for cases where the URL contains a version number that rotates.

  • `rationale` — why this is acceptable. "Required for build" is

not enough — say why a registry version isn't available, and what the source you're trusting actually is.

  • `last-reviewed-date` — when the rationale was last sanity-checked.
  • `reviewer` — optional but recommended; helps the next person who

reviews the file know whom to ask.

When not to allowlist

  • You added the dep because the registry version was outdated. Get a

newer registry version published instead; allowlist entries become permanent technical debt.

  • You're not sure what the dep does. Find out first. An exotic-source

dep is exactly the place where "we'll figure it out later" turns into a supply-chain incident.

  • The source URL points to a personal GitHub fork. Almost always a

red flag. Fork ownership rotates; the trust relationship doesn't survive an account transfer.

Running the check locally

npm run lint:dep-sources
npm run lint:affected-packages

The check is fast (parses two JSON files and walks the entries). Run it before pushing to avoid CI round-trips. The script:

  • Exits 0 if no violations.
  • Exits 1 on any violation, with a detailed report.
  • Exits 2 on a fatal error (allowlist parse failure, missing file, etc.).

Useful flags:

node tools/lint/dep-source.mjs --help              # show options
node tools/lint/dep-source.mjs --quiet             # silent on success
node tools/lint/dep-source.mjs --allowlist <path>  # use a different allowlist
node tools/lint/affected-packages.mjs --affected-packages-csv /mnt/ops/users/roctinam/Downloads/22-packages.csv
node tools/lint/affected-packages.mjs --affected-packages-csv https://gist.githubusercontent.com/<user>/<gist-id>/raw/22-packages.csv

Known affected package feed

`npm run lint:affected-packages` scans `package.json` and `package-lock.json` for exact npm name+version matches from an operator-maintained CSV feed with this header:

Ecosystem,Namespace,Name,Version,Published,Detected

Source resolution order:

1. `--affected-packages-csv <path|url>` 2. `AIWG_AFFECTED_PACKAGES_CSV` 3. The canonical mounted path `/mnt/ops/users/roctinam/Downloads/22-packages.csv`

The scanner dedupes repeated rows by `(ecosystem, normalizedName, version)`, tracks the earliest and latest detection timestamps, and reports skipped non-npm rows so a future PyPI pass can reuse the same feed.

For automation, publish the CSV as a raw gist and point CI at the raw URL, not the HTML gist page. The scanner prints the exact source URL or local path it used so findings stay traceable in logs and issue comments.

Review cadence

Allowlist entries should be re-reviewed quarterly, or whenever:

  • The dep's upstream ownership changes (a maintainer leaves, the project

is transferred, the repo is renamed).

  • The dep's source URL changes (host migration, branch rename).
  • The dep accumulates known vulnerabilities — at which point the

rationale needs to be updated to acknowledge the new risk surface.

If an entry's `last-reviewed-date` is older than 90 days, that's technical debt; consider whether the dep can be swapped to a registry version or removed entirely.

Lockfile regeneration and the release-age gate (#1290)

When you regenerate the lockfile (running `npm install <new-dep>` or `npm update`), the repo-root `.npmrc` enforces a release-age gate: newly published versions of any dep must have been on the public registry for at least 7 days before npm will let them into the lockfile.

This is a Wave 7 control against the brand-new-malicious-publish-window attack pattern (Mini Shai-Hulud). The full policy lives in `docs/contributing/versioning.md` § Release-age policy.

If your `npm install <pkg>` errors with `No matching version found`, check whether the requested version was published less than 7 days ago. If so, either wait or — with deliberate sign-off — override with `npm install --min-release-age=0 <pkg>` and document the override in the lockfile commit message.

Requirements: npm 11.5+. Earlier versions silently ignore the gate. Run `npm install -g npm@^11.5` once on every contributor machine. Release publishers should use the project release workflow on Node 24, which ships a current npm 11.x and satisfies npm trusted-publishing requirements.

  • ADR: `.aiwg/architecture/adr-dep-source-policy.md` — full policy

rationale and alternatives considered.

  • Allowlist file: `ci/dep-source-allowlist.yaml`.
  • Lint script: `tools/lint/dep-source.mjs`.
  • Companion controls: audit signatures and SBOM (#1288), pinned

containers and actions (#1281, #1282), postinstall hook removal (#1279), release-age gate (#1290 / Wave 7), npm trusted publishing (#1283).

  • User-facing hardening guide: `docs/security/supply-chain-hardening.md`.
  • Planning threat model: `.aiwg/security/working/threat-model-supply-chain.md`.