Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
nodeLinker: node-modules

enableScripts: false

enableHardenedMode: true

checksumBehavior: throw

npmMinimalAgeGate: 7d
285 changes: 285 additions & 0 deletions RESOLUTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
# Dependency resolutions

Why each entry in the `resolutions` block of `package.json` exists, and when it can be removed.
`package.json` can record _what_ is constrained but not _why_, so without this file the block grows
into a set of constraints nobody knows how to maintain.

Each entry states:

- **Why** β€” which dependency path requires it, and why a normal upgrade is not currently possible.
- **Fixes** β€” the advisory or compatibility problem addressed.
- **Drop when** β€” an observable condition under which the resolution can be removed.

Add, update or remove a resolution and its entry here in the same change.

> **The install policy is part of this, even though it lives in another file.** `.yarnrc.yml` sets
> `npmMinimalAgeGate: 7d`, `enableScripts: false`, `enableHardenedMode: true` and
> `checksumBehavior: throw`, matching the other repositories in this family rather than falling back
> to yarn's defaults, which are looser on the first three.
>
> **The age gate is the one that will confuse you.** It refuses releases published within the last
> week, and `yarn up -R` reports success while silently selecting one patch below the patched release
> rather than failing. That reads as _"there is no fix on this line"_ and sends you up the remediation
> ladder for nothing. Compare the version you got against the version the advisory names, not against
> the version you had β€” and if the gate is the blocker, wait rather than lowering it.
>
> **`enableScripts: false` makes yarn report `YN0004`** for each package whose build script it skips β€”
> here `esbuild` and three `core-js` variants. None of them breaks: `esbuild` ships its binary as a
> platform-specific optional package rather than fetching it in a postinstall, and `core-js`'s script
> only prints a funding message. A package that genuinely needs its postinstall would fail, so treat a
> new `YN0004` as something to check rather than as noise.

> **Ranges, not exact versions.** Almost every entry below is a _floor_ β€” "never below this" β€” so it
> takes a compatible range. An exact version is a _fixture_, used only when something binds to that
> specific release, and it should say what binds it.
>
> A floor written as an exact version stops working over time: it cannot select a patch, so it holds
> the tree on the version it was written against. `axios`, `lodash` and `qs` were all previously
> constrained to exact versions here and had to be reopened into ranges. `axios` shows the mechanism
> working once corrected: the range resolves to 1.19.0, above the version its advisories name as
> patched, because releases published later were picked up without anyone touching the entry.
>
> The same reasoning retires entries as well as writing them. If every parent's declared range already
> reaches a patched release, the floor is not what is holding the tree up and can go β€” see
> [Removed](#removed), where four entries were retired on that basis.

---

## Security floors

### `axios: ^1.18.0`

- **Why:** `@eyeseetea/d2-api@1.21.0` requests `axios@1.6.4` as an exact version, so removing this
resolution resolves `axios` _downwards_ to 1.6.4 rather than upwards. The floor is required for as
long as a parent requests an exact version below it.
- **Fixes:** GHSA-35jp-ww65-95wh, GHSA-p92q-9vqr-4j8v, GHSA-62hf-57xw-28j9, GHSA-j5f8-grm9-p9fc,
GHSA-777c-7fjr-54vf, GHSA-6chq-wfr3-2hj9, GHSA-pf86-5x62-jrwf, GHSA-q8qp-cvcw-x6jj,
GHSA-pmwg-cvhr-8vh7, GHSA-3g43-6gmg-66jw.
- **Drop when:** every parent that requests `axios` requests a range admitting 1.18.0 or later β€”
currently blocked by `@eyeseetea/d2-api`'s exact `1.6.4`.

### `lodash: ^4.18.0`, `lodash-es: ^4.18.0`

- **Why:** `@eyeseetea/d2-api@1.21.0` and `@eyeseetea/d2-ui-components@2.12.0` both request
`lodash@4.17.21` as an exact version. Removing the resolution resolves _downwards_ to 4.17.21,
which is inside the affected range.
- **Fixes:** GHSA-r5fr-rjxr-66jc, which affects `>= 4.0.0, <= 4.17.23` and is patched in 4.18.0.
- **Drop when:** `@eyeseetea/d2-api` and `@eyeseetea/d2-ui-components` request a `lodash` range that
admits 4.18.0 or later.

### `qs: ^6.15.3`

- **Why:** `@eyeseetea/d2-api@1.21.0` requests `qs@6.9.7` as an exact version, so the constraint
cannot be removed without resolving downwards.
- **Fixes:** GHSA-q8mj-m7cp-5q26, which affects `>= 6.11.1, <= 6.15.1`.
- **Drop when:** `@eyeseetea/d2-api` requests a `qs` range admitting 6.15.2 or later.

### `i18next-conv/node-gettext: ^3.0.1`

- **Why:** `i18next-conv@6.1.1` requests `node-gettext@^2.0.0`, which cannot reach the fix. Scoped to
that parent because it is the only consumer. `i18next-conv` is reached through
`@dhis2/d2-i18n-extract` and `@dhis2/d2-i18n-generate`.
- **Fixes:** GHSA-g974-hxvm-x689 (prototype pollution). The advisory records no patched version, but
its affected range is `<= 3.0.0` and 3.0.1 is published and outside it β€” "no patched version
recorded" is not the same as "no fix exists".
- **Drop when:** `i18next-conv` requests `node-gettext@^3` or later, or the `@dhis2/d2-i18n-*` pair is
replaced (see _Dependency decisions with no trace in the manifest_).

### `react-linkify/linkify-it: ^5.0.2`

- **Why:** `react-linkify@1.0.0-alpha` requests `linkify-it@^2.0.3`, and the fix is on the 5.x line,
so the range cannot reach it. `react-linkify` is requested at exactly `1.0.0-alpha` by
`@eyeseetea/d2-ui-components@2.12.0`, so the parent cannot be moved either. Scoped to that parent
rather than applied globally.
- **Fixes:** GHSA-22p9-wv53-3rq4 (patched 5.0.1) and GHSA-v245-v573-v5vm (patched 5.0.2).
- **Compatibility was checked, not assumed.** `react-linkify` is unmaintained and written against the
linkify-it 2 API, so forcing a major is the whole risk here. `linkify-it@5.0.2` still exports a
callable CJS function and keeps the same `.tlds()`, `.match()` and `.test()` surface, and rendering
`<Linkify>` produces the expected `<a href>` for both URLs and `mailto:` addresses. Re-run those
four checks rather than trusting this note if the pinned major ever moves again.
- **Drop when:** `@eyeseetea/d2-ui-components` drops `react-linkify` or moves to a release requesting
a patched `linkify-it`.

---

## Inherited constraints with no recorded rationale

These entries predate this file and were added in one batch without an explanation. Each still binds
something in the tree, so they were kept rather than removed speculatively β€” as the `lodash` entry
above shows, removing a resolution can resolve a package _downwards_.

**They were converted from exact versions to `^` ranges on 2026-08-05.** Several were, by their own
descriptions elsewhere, security floors written in fixture shape β€” the form that cannot receive a
patch and eventually becomes the finding it was added to prevent. None of them was inside a live
advisory range at the time, so this is not a remediation: it removes a decay path before it opens.
Converting is also strictly narrower than pruning, which is still open and has to be judged one entry
at a time.

A `^` range keeps every consumer on the same major the exact version already forced it onto β€” it only
allows newer releases within that line.

| Resolution | Now resolves to | Note |
| -------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@babel/runtime: ^7.26.10` | 7.29.7 | Was exact `7.26.10`; no recorded reason |
| `i18next: 19.8.5` | 19.8.5 | ⚠️ **Deliberately still exact.** Far below the current release line, and a resolution on this package can break application startup, so it is changed only deliberately and verified by starting the app |
| `glob-parent: ^5.1.2` | 5.1.2 | Was exact; already the newest 5.x. Note this is a global entry and the tree has consumers declaring `^3.1.0` and `^6.0.1`, both of which it pulls onto the 5.x line |
| `moment: ^2.29.4` | 2.30.1 | Was exact; the direct dependency was reopened to the same range, which previously contradicted it |
| `nanoid: ^3.3.8` | 3.3.17 | Was exact `3.3.8`, which held `postcss` below the `^3.3.16` it declares |
| `node-fetch: ^2.6.7` | 2.7.0 | Was exact `2.6.7`, below the `^2.7.0` one consumer declares. Load-bearing: the tree also has a consumer on `^1.0.1`, and this floor is what lifts it onto a patched line |
| `diff: ^5.2.2` | 5.2.2 | Was exact; already the newest 5.x |
| `debug: ^4.3.4` | 4.4.3 | Was exact; no recorded reason |
| `ua-parser-js: ^0.7.24` | 0.7.41 | Was exact; `^0.7.x` stays inside the 0.7 line |

**Drop when:** for each, confirm no consumer requires the constrained line, then remove it and
re-install, comparing **resolved versions** rather than lockfile bytes. Treat each individually β€” they
were added as one batch but have nothing else in common. `glob-parent` is the most likely to be
retirable: in another repository that carried the same inherited entry, removing it let three major
lines coexist, all of them outside every advisory affecting them.

---

## Removed

> **The test is whether a resolved version moves, not whether the lockfile changes.** A byte-identical
> lockfile proves a constraint did nothing, but the reverse does not hold: a constraint can rewrite a
> descriptor, change the lockfile, and still leave every installed version exactly where it was. It can
> also add a line to the tree that is already patched, which changes the lockfile and changes nothing
> about exposure. Compare versions.

### `path-to-regexp: 1.9.0` β€” removed 2026-08-04

No `path-to-regexp` entry existed in `yarn.lock`, so the constraint matched no descriptor and had no
effect. Removing it left the lockfile byte-identical, which is the evidence it was a no-op.

### `form-data: ^4.0.6` and `jszip: ^3.8.0` β€” removed 2026-08-05

Both were inert. Removing them and re-installing left the resolved versions unchanged β€” `form-data` at
4.0.6 and `jszip` at 3.10.1 β€” because every parent's declared range already reaches those releases:

| Package | Ranges the parents declare | Resolves to, with or without the constraint |
| ----------- | -------------------------- | ------------------------------------------- |
| `form-data` | `^4.0.0` (Γ—2), `^4.0.6` | 4.0.6 |
| `jszip` | `^3.2.2`, `3.10.1` | 3.10.1 |

The original reasoning for `form-data` was that `@eyeseetea/d2-api` requests `^4.0.0`, "which admits
affected 4.0.x releases". True, but incomplete: `^4.0.0` admits the patched 4.0.6 as well, so
re-resolution reaches it without a constraint. Same for `jszip`, where `^3.2.2` admits 3.10.1.

**Restore either only if** a parent appears whose range cannot reach the patched line.

### `minimatch: ^3.1.4` and `brace-expansion: ^1.1.17` β€” removed 2026-08-05

The `minimatch` entry's own drop-when condition β€” _"every consumer resolves to a patched `minimatch`
on its own line without it"_ β€” was tested and found to be already met.

| Package | With the constraints | Without |
| ----------------- | -------------------- | -------------------- |
| `minimatch` | 3.1.5 | 10.2.6 **and** 3.1.5 |
| `brace-expansion` | 1.1.18 | 1.1.18 **and** 5.0.9 |

All four releases are outside every advisory affecting them, so the security outcome is identical.
Two things made the constraints unnecessary:

- **The 3.x line reaches the patch unaided.** The seven parents that request `minimatch` declare
`^3.0.4`, which already admits 3.1.5. The constraint was not what lifted them above 3.1.4.
- **`brace-expansion` only existed on the 1.x line because `minimatch` was held at 3.x.** Its sole
requester was `minimatch@3.x` at `^1.1.7`, which admits 1.1.18. The second constraint was holding up
the first, not a finding.

What the `minimatch` constraint _did_ do, which was not recorded: `glob@13.0.6` (under `cacache`)
requests `minimatch@^10.2.2`, and the unscoped entry pulled it down seven majors to 3.1.5. Removing it
gives that consumer the major it declared.

**Worth knowing before re-investigating this:** forcing `glob@13` onto `minimatch@3.1.5` looks like it
should break it, because `glob@13` references `minimatch.escape` and `minimatch.unescape` and neither
exists in 3.1.5. It does not break, because `glob@13`'s default entry point is
`dist/commonjs/index.min.js`, a bundle with `minimatch` inlined that never requires it at runtime β€”
the external copy is reachable only through the `glob/raw` subpath, which nothing here imports. So the
constraint was neither helping nor breaking anything.

Verified after removal with `yarn lint` β€” the tool the original entry named as the consumer β€” plus
type-check, the unit suite, `yarn localize` and a production build.

---

## Findings with no remediation available

Recorded so they are not investigated again from scratch. None of these is a resolution; they are
states of the upstream package, or of the line this repository is on.

### `react-router` and `react-router-dom` β€” GHSA-337j-9hxr-rhxg, GHSA-wrjc-x8rr-h8h6, GHSA-jjmj-jmhj-qwj2

**There is no fix on the 6.x line.** Two of the three advisories are patched in 7.18.0, and their
affected range (`< 7.18.0`) covers every 6.x release. The third affects `react-router-dom`
`>= 6.30.2, <= 6.30.4` and records no patched version at all β€” 6.30.4 is the final release of that
line and is still inside the range.

Upgrading to 6.30.4 was worth doing anyway: it cleared a fourth advisory, GHSA-2j2x-hqr9-3h42, which
_is_ patched at 6.30.4. That is as far as the 6.x line goes.

**The only remediation is migrating to `react-router` v7**, which is an API change on a direct
dependency used throughout the routing layer β€” a change with its own testing, not a dependency bump.
It is deliberately out of scope here so that a dependency pass does not become a routing refactor.

**Revisit when:** the v7 migration is scheduled. Anyone picking it up should go **straight to v7** β€”
stopping anywhere on 6.x lands back on these three.

### `elliptic` β€” GHSA-848j-6mx2-7j84

The advisory affects every published version (`<= 6.6.1`), and 6.6.1 is the latest release. There is
no version to upgrade to and no range that avoids it. Reached transitively through the crypto
polyfills used by the browser build.

**Revisit when:** a release above 6.6.1 is published, or the consumer that pulls it in stops needing
it.

### `eslint` β€” GHSA-p5wg-g6qr-c7cg

**This advisory was withdrawn on 2026-02-03.** It may still appear in scanner output, because
different databases pick up withdrawals at different times. It does not describe a real defect and
should be dismissed rather than remediated β€” do not upgrade `eslint` on account of it.

---

## Dependency decisions with no trace in the manifest

### Why `@dhis2/d2-i18n-extract` and `@dhis2/d2-i18n-generate` have not been replaced

Both are direct devDependencies, used by `yarn extract-pot` and `yarn localize`. Both are archived
upstream and no longer receive releases, so no fix can ever ship for them.
`@dhis2/cli-app-scripts` is the maintained equivalent.

The replacement was implemented and measured on this repository on 2026-08-04, then reverted. It is
recorded here because a remediation that was tried and rejected leaves no trace in the tree, so
without this entry the next person would repeat the work to reach the same result.

**Why it was not adopted.** `@dhis2/cli-app-scripts` cannot provide translation extraction without
the DHIS2 CLI framework underneath it, so adopting it for i18n alone pulls in a large amount of
additional build tooling. Part of that tooling is `request`, which has been deprecated since 2020 and
receives no releases. `request` depends on `uuid@^3.3.2`, and no published `uuid` version satisfies
both the advisory affecting that line and the `uuid/v4` subpath `request` imports, which was removed
in v7 β€” so that path cannot be resolved, scoped or upgraded.

The trade is therefore not _abandoned β†’ maintained_. It is _two frozen packages that currently
require no constraint_ in exchange for _a maintained package that carries an abandoned chain with no
possible fix_, plus several scoped resolutions to offset the rest of what it brings in. Both
toolchains are build-time only β€” they run during `yarn localize` and never reach the browser bundle β€”
so the change in actual exposure is negligible either way, and the decision rests on maintainability
rather than risk.

Everything the archived packages currently contribute is already satisfied inside the ranges they
request: `handlebars` resolves to 4.7.9 within the `^4.0.11` that `@dhis2/d2-i18n-generate` asks for,
and `node-gettext` is scoped above. Being archived is not the same as being vulnerable β€” frozen code
introduces nothing new either. The exposure here is conditional, not current.

**Replace them when either becomes true:**

1. `@dhis2/cli-helpers-engine` stops depending on `request`, removing the unresolvable `uuid` path. At
that point the replacement costs only scoped resolutions and the maintainability argument wins
outright.
2. Either archived package requires a constraint that cannot be satisfied within the ranges it
already requests β€” that is, both re-resolution and a scoped resolution fail. At that point staying
costs more than moving.

Whoever revisits this should re-measure rather than trust the description above: it is a snapshot of
2026-08-04, and the replacement tooling moves.
Loading
Loading