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.
-
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.
-
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.
-
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.
-
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
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 theversions recorded in the reviewed
package-lock.jsonand verifies their integrityhashes:
.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:30Every contributor-facing entry point tells a human to run
npm installinstead:
README.md:37— "Install dependencies"CONTRIBUTING.md:11— "Development setup"AGENTS.md:43— "Install:npm install".devcontainer/devcontainer.json—"postCreateCommand": "npm install"npm installdoes not read the lockfile as an instruction. It re-resolves thesemver ranges in
package.jsonagainst the registry, takes whatever is newestand matching, and rewrites
package-lock.json.package.jsonuses caret rangesfor 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.0andprettier ^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-313callsnpm ci"the first documentedsetup 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.
Unreviewed code execution on contributor and devcontainer machines.
npm ci --dry-runon this tree reports install scripts present in thedependency graph (for example
core-js). Resolution happens at install time,so a contributor following
README.mdgets whatever patch/minor versions werepublished 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.
Integrity hashes are skipped.
npm cifails closed when a resolvedtarball does not match the
integrityhash in the lockfile.npm installre-resolves instead, so a substituted upstream artifact for a version outside
the lockfile is never compared against anything the repo reviewed.
Silent lockfile drift into unrelated PRs. Because
npm installrewritespackage-lock.jsonin the working tree, a contributor whose change hasnothing 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 ciwill faithfully install the drifted versions on every later run.The devcontainer makes it automatic.
postCreateCommandruns withoutanyone typing it, so the floated install is the default state of the
project's own prescribed environment.
npm cicurrently succeeds onmain(npm ci --dry-runexits 0 atb54cf81),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.mjschecks that hold the Node major consistentacross docs, devcontainer and workflows.
1.
README.md:35-39— replace:npm ciinstalls exactly the versions inpackage-lock.jsonand verifies theirintegrity hashes, which is what CI runs. Use
npm install <package>only whenyou intend to add or upgrade a dependency; that rewrites the lockfile and the
change belongs in its own reviewed commit.
npm ciinstalls the exact, integrity-checked versions inpackage-lock.json,matching CI. Reach for
npm install <package>only to add or upgrade adependency, and commit the resulting lockfile change deliberately.
with:
4.
.devcontainer/devcontainer.json— replace:with:
5.
tests/dev-environment.test.mjs— add a regression test that fails if anydeveloper doc or devcontainer lifecycle command reintroduces a bare
npm install:parseDevcontainer()andlifecycleCommands()already exist in that file,backing the
devcontainer.json parses and pins an image and a container userand
every npm script invoked by devcontainer lifecycle commands existstests.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