Skip to content

[sec-check] Documented setup installs with npm install, bypassing the reviewed package-lock.json that CI's npm ci enforces #703

Description

@hivecommons-hive

Security Finding

Severity: medium
Type: unsafe-pattern (supply chain — unpinned dependency install on the documented contributor path)

Every CI workflow installs dependencies with npm ci, which installs exactly the
versions recorded in the reviewed package-lock.json and verifies their integrity
hashes:

  • .github/workflows/ci.yml:26, :82
  • .github/workflows/deploy-gh-pages.yml:37
  • .github/workflows/import-architectures.yml:27
  • .github/workflows/refresh-community-people.yml:26
  • .github/workflows/refresh-radar-reports.yml:26
  • .github/workflows/pr-queue-hygiene.yml:30

Every contributor-facing entry point tells a human to run npm install
instead:

  • README.md:37 — "Install dependencies"
  • CONTRIBUTING.md:11 — "Development setup"
  • AGENTS.md:43 — "Install: npm install"
  • .devcontainer/devcontainer.json — "postCreateCommand": "npm install"

npm install does not read the lockfile as an instruction. It re-resolves the
semver ranges in package.json against the registry, takes whatever is newest
and matching, and rewrites package-lock.json. package.json uses caret ranges
for ten of its direct dependencies, including react ^19.3.0,
react-dom ^19.3.0, @docusaurus/faster ^3.10.2, cspell ^10.3.3,
markdownlint-cli ^0.49.1, npm-check-updates ^23.1.0 and prettier ^3.9.8,
so the ranges are open in practice, not pinned by accident.

The repo's own test suite already assumes the correct command: the comment at
tests/dev-environment.test.mjs:312-313 calls npm ci "the first documented
setup step". No documented path actually says that, and nothing fails when a doc
says otherwise.

Impact

The documented setup path installs, and executes, dependency code that no one in
this repository reviewed or pinned.

  1. Unreviewed code execution on contributor and devcontainer machines.
    npm ci --dry-run on this tree reports install scripts present in the
    dependency graph (for example core-js). Resolution happens at install time,
    so a contributor following README.md gets whatever patch/minor versions were
    published between the last lockfile update and their clone — across a graph of
    1569 packages — and their lifecycle scripts run locally. This is the exact
    delivery vector used by npm maintainer-account-takeover and malicious-release
    attacks, and the lockfile that exists to block it is bypassed by the command
    the docs give.

  2. Integrity hashes are skipped. npm ci fails closed when a resolved
    tarball does not match the integrity hash in the lockfile. npm install
    re-resolves instead, so a substituted upstream artifact for a version outside
    the lockfile is never compared against anything the repo reviewed.

  3. Silent lockfile drift into unrelated PRs. Because npm install rewrites
    package-lock.json in the working tree, a contributor whose change has
    nothing to do with dependencies can carry a dependency upgrade into their PR.
    It then lands under a review that was never about dependencies, and CI's
    npm ci will faithfully install the drifted versions on every later run.

  4. The devcontainer makes it automatic. postCreateCommand runs without
    anyone typing it, so the floated install is the default state of the
    project's own prescribed environment.

npm ci currently succeeds on main (npm ci --dry-run exits 0 at b54cf81),
so the lockfile is in sync and this fix is safe to apply as-is.

Recommendation

Make the documented path the lockfile-respecting one, and add a regression test
so the two cannot drift apart again — the same shape as the existing
tests/dev-environment.test.mjs checks that hold the Node major consistent
across docs, devcontainer and workflows.

1. README.md:35-39 — replace:

### Install dependencies

```bash
npm install

with:

```markdown
### Install dependencies

```bash
npm ci

npm ci installs exactly the versions in package-lock.json and verifies their
integrity hashes, which is what CI runs. Use npm install <package> only when
you intend to add or upgrade a dependency; that rewrites the lockfile and the
change belongs in its own reviewed commit.


**2. `CONTRIBUTING.md:9-12`** — replace:

```markdown
```bash
npm install
npm run docus:start

with:

```markdown
```bash
npm ci
npm run docus:start

npm ci installs the exact, integrity-checked versions in package-lock.json,
matching CI. Reach for npm install <package> only to add or upgrade a
dependency, and commit the resulting lockfile change deliberately.


**3. `AGENTS.md:43`** — replace:

```markdown
- **Install**: `npm install`

with:

- **Install**: `npm ci` — installs the exact versions in `package-lock.json`, as
  CI does. `npm install` re-resolves the semver ranges and rewrites the lockfile;
  use it only to add or upgrade a dependency on purpose.

4. .devcontainer/devcontainer.json — replace:

  "postCreateCommand": "npm install",

with:

  "postCreateCommand": "npm ci",

5. tests/dev-environment.test.mjs — add a regression test that fails if any
developer doc or devcontainer lifecycle command reintroduces a bare npm install:

// CI installs with `npm ci`, which is the only npm command that treats
// package-lock.json as an instruction: exact versions, integrity hashes
// verified, lockfile left alone. A bare `npm install` re-resolves the caret
// ranges in package.json against the registry, runs the lifecycle scripts of
// whatever it picked, and rewrites the lockfile in the contributor's tree. A
// doc that prescribes it hands every contributor an unreviewed dependency
// graph and lets an upgrade ride into an unrelated PR. `npm install <pkg>` is
// still the right way to add a dependency, so only the bare form is rejected.
test('no developer doc or devcontainer command prescribes a bare npm install', () => {
  const BARE_INSTALL = /\bnpm install\s*(?=$|[\n`'"&|;])/;
  const offenders = [];

  for (const doc of ['README.md', 'CONTRIBUTING.md', 'AGENTS.md']) {
    const lines = readFileSync(join(root, doc), 'utf8').split('\n');
    for (const [index, line] of lines.entries()) {
      if (BARE_INSTALL.test(line)) offenders.push(`${doc}:${index + 1}`);
    }
  }

  const devcontainer = parseDevcontainer();
  for (const hook of [
    'onCreateCommand',
    'updateContentCommand',
    'postCreateCommand',
    'postStartCommand',
    'postAttachCommand',
  ]) {
    const value = devcontainer[hook];
    for (const command of lifecycleCommands(value)) {
      if (BARE_INSTALL.test(command)) offenders.push(`devcontainer.${hook}`);
    }
  }

  assert.deepEqual(
    offenders,
    [],
    `these prescribe a bare \`npm install\`, which ignores package-lock.json; use \`npm ci\`: ${offenders.join(', ')}`,
  );
});

parseDevcontainer() and lifecycleCommands() already exist in that file,
backing the devcontainer.json parses and pins an image and a container user
and every npm script invoked by devcontainer lifecycle commands exists tests.

None of this touches .github/workflows/, so it is landable as an ordinary PR.

🐝 Hive Agent: security | Instance: hosted-available-lke648397-260827-5n31 | SHA: b54cf81

— hive: agent=sec-check backend=copilot model=claude-opus-5 copilot=1.0.88

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent/securityApproved by a Hive merger/owner for auto-merge on green CIhive/hosted-available-lke648397-260827-5n31Approved by a Hive merger/owner for auto-merge on green CIsecurityApproved by a Hive merger/owner for auto-merge on green CI

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions