mandrel-platform 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -12
- package/config/stryker.base.json +7 -2
- package/package.json +1 -1
- package/scripts/audit-check.mjs +331 -6
- package/scripts/audit-check.test.mjs +382 -1
- package/scripts/check-affected-mode.test.mjs +5 -51
- package/scripts/check-codeql-gating.test.mjs +649 -0
- package/scripts/check-destructive-migration.mjs +277 -11
- package/scripts/check-destructive-migration.test.mjs +334 -0
- package/scripts/check-environments-isolation-audit.test.mjs +212 -0
- package/scripts/check-fail-fast-attribution.test.mjs +90 -4
- package/scripts/check-first-party-pin-freshness.mjs +137 -21
- package/scripts/check-first-party-pin-freshness.test.mjs +135 -0
- package/scripts/check-gitleaks-allowlist.test.mjs +312 -0
- package/scripts/check-osv-scan-mode.test.mjs +5 -50
- package/scripts/check-release-type.mjs +591 -0
- package/scripts/check-release-type.test.mjs +678 -0
- package/scripts/check-setup-toolchain-store.test.mjs +139 -0
- package/scripts/check-toolchain-cache-default.test.mjs +308 -0
- package/scripts/lib/yaml-step.mjs +109 -0
- package/scripts/lib/yaml-step.test.mjs +156 -0
- package/scripts/osv-report-gate.test.mjs +289 -0
- package/scripts/stryker-base-config.test.mjs +256 -0
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ aggregator context, and the pin-by-tag/SHA versioning model.
|
|
|
51
51
|
| [`deploy-cloudflare.yml`](docs/reusable-workflows.md#deploy-cloudflareyml) | Defence-in-depth Cloudflare deploy with a frozen deploy-secret allowlist. |
|
|
52
52
|
| [`secret-scan-push.yml`](docs/reusable-workflows.md#secret-scan-pushyml) | Full-history gitleaks secret scan on push to the default branch. |
|
|
53
53
|
| [`release-automation.yml`](docs/reusable-workflows.md#release-automationyml) | Conventional-commit release lifecycle (version bump + `CHANGELOG.md` + tag) via release-please. |
|
|
54
|
-
| [`codeql.yml`](docs/reusable-workflows.md#codeqlyml) | CodeQL SAST analysis — dual-mode: runs on this repo's
|
|
54
|
+
| [`codeql.yml`](docs/reusable-workflows.md#codeqlyml) | CodeQL SAST analysis — dual-mode: runs on this repo's weekly schedule **and** is `workflow_call`-consumable (this repo's `ci.yml` calls it as a merge-blocking `code-scanning` job). |
|
|
55
55
|
|
|
56
56
|
> `smoke-dispatch.yml` is a **platform-internal** cross-repo smoke trigger
|
|
57
57
|
> (`push` / `workflow_dispatch`, not `workflow_call`). It appears in the
|
|
@@ -177,13 +177,19 @@ export default config;
|
|
|
177
177
|
|
|
178
178
|
#### `stryker.base.json`
|
|
179
179
|
|
|
180
|
-
Shared Stryker mutation-testing defaults
|
|
181
|
-
|
|
182
|
-
`
|
|
183
|
-
`
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
180
|
+
Shared Stryker mutation-testing defaults: pnpm package manager, `perTest`
|
|
181
|
+
coverage analysis, HTML + clear-text + progress reporters, `ignoreStatic`,
|
|
182
|
+
`disableBail: true`, the timeout budget a bail-free run needs
|
|
183
|
+
(`timeoutMS: 120000` — 120 s — plus `timeoutFactor: 3` and
|
|
184
|
+
`dryRunTimeoutMinutes: 15`), and high/low/break thresholds.
|
|
185
|
+
|
|
186
|
+
Stryker has **no `extends` option at all** — its config reader loads exactly
|
|
187
|
+
one config file and merges CLI arguments over it, and `extends` is not among
|
|
188
|
+
the top-level properties its schema accepts, so a config that declares one is
|
|
189
|
+
reported as an unknown option and otherwise ignored. The working mechanism is
|
|
190
|
+
therefore a `stryker.config.mjs` (or `.js` when `"type": "module"`) module
|
|
191
|
+
that imports the base JSON **by package specifier** and spreads it, then pins
|
|
192
|
+
the test runner and mutate set:
|
|
187
193
|
|
|
188
194
|
```js
|
|
189
195
|
// stryker.config.mjs — import the base and spread it
|
|
@@ -197,6 +203,12 @@ export default {
|
|
|
197
203
|
};
|
|
198
204
|
```
|
|
199
205
|
|
|
206
|
+
Adopting the base is not a drop-in bump: `disableBail: true` changes what the
|
|
207
|
+
mutation score *means*, so any baseline captured under bail has to be
|
|
208
|
+
re-derived, and the base's `break: 50` is a fleet floor of last resort rather
|
|
209
|
+
than a calibrated target. Both are covered in
|
|
210
|
+
[Adopting the bail-free Stryker base](docs/patterns.md#4a-adopting-the-bail-free-stryker-base).
|
|
211
|
+
|
|
200
212
|
#### `commitlint.base.mjs`
|
|
201
213
|
|
|
202
214
|
Single-sources the conventional-commit **type-enum** — the eleven types
|
|
@@ -480,10 +492,17 @@ app.use("*", createHonoRateLimit({ limit: 100, windowMs: 60_000 }));
|
|
|
480
492
|
|
|
481
493
|
### `scripts/audit-check.mjs`
|
|
482
494
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
495
|
+
Dependency gate script with **two independent blocking conditions**. It exits
|
|
496
|
+
non-zero when *either* fires — a clean CVE scan does not excuse an unbounded
|
|
497
|
+
override, and vice versa:
|
|
498
|
+
|
|
499
|
+
1. **CVE gate.** Runs `pnpm audit --prod` and blocks on any **unsuppressed**
|
|
500
|
+
High or Critical vulnerability in the production dependency graph. This is
|
|
501
|
+
the stricter athportal/swarm-os policy: all unsuppressed High/Critical are
|
|
502
|
+
blocking, not just fixable ones.
|
|
503
|
+
2. **Unbounded-override lint.** Blocks on any dependency override written
|
|
504
|
+
without an upper bound — **independently of the CVE scan, and with zero
|
|
505
|
+
CVEs present**. It runs *first*, before `pnpm audit` is invoked at all.
|
|
487
506
|
|
|
488
507
|
Known/accepted CVEs are suppressed via a **dated, self-expiring allowlist**
|
|
489
508
|
(`audit-allowlist.json` in the project root). Expired entries are treated as
|
|
@@ -504,6 +523,44 @@ Or copy the script into your repo's `scripts/` directory when you need
|
|
|
504
523
|
local customization (and pin a semver range on `mandrel-platform` so drift
|
|
505
524
|
is detected by Renovate).
|
|
506
525
|
|
|
526
|
+
**Flags:**
|
|
527
|
+
|
|
528
|
+
| Flag | Default | Purpose |
|
|
529
|
+
| ---- | ------- | ------- |
|
|
530
|
+
| `--allowlist <path>` | `audit-allowlist.json` in the invoking directory | Where to read the dated CVE allowlist from. An absent file means "no suppressions". |
|
|
531
|
+
| `--package-json <path>` | `package.json` in the invoking directory | Which manifest the unbounded-override lint reads. Point it at a workspace member's manifest to lint that package instead of the root. An absent file skips the lint (the audit still runs). |
|
|
532
|
+
|
|
533
|
+
**Unbounded-override lint:**
|
|
534
|
+
|
|
535
|
+
An override *rewrites* a transitive dependent's declared range, so whatever is
|
|
536
|
+
written there is the only thing standing between your tree and the next release
|
|
537
|
+
of that package. Written open-endedly, the committed lockfile becomes the sole
|
|
538
|
+
pin — and the moment anything re-resolves (a fresh install, a lockfile-less CI
|
|
539
|
+
leg, a dependent's own bump) the newest release wins and can cross a major.
|
|
540
|
+
Nothing else in the toolchain lints for this, so the gate names each offending
|
|
541
|
+
override and its bound, then exits 1.
|
|
542
|
+
|
|
543
|
+
Every override field is checked — `overrides`, `resolutions`, and
|
|
544
|
+
`pnpm.overrides` — including dependent-scoped nested objects, which are
|
|
545
|
+
reported by their full path (`overrides.some-dep.left-pad`).
|
|
546
|
+
|
|
547
|
+
| Bound | Verdict | Why |
|
|
548
|
+
| ----- | ------- | --- |
|
|
549
|
+
| `1.2.3`, `=1.2.3` | ✅ bounded | Exact pin. |
|
|
550
|
+
| `^1.2.3`, `~1.2.3` | ✅ bounded | Cannot cross a major on its own. |
|
|
551
|
+
| `1.2.x`, `1.*` | ✅ bounded | The major is fixed; only lower positions float. |
|
|
552
|
+
| `>=1.2.3 <2.0.0`, `1.2.3 - 2.0.0` | ✅ bounded | Carries an explicit upper bound. |
|
|
553
|
+
| `npm:other-pkg@^1.2.3` | ✅ bounded | An alias is judged on the range it carries. |
|
|
554
|
+
| `>=1.2.3`, `>1.2.3` | ❌ blocking | A bare lower bound; the next major satisfies it. |
|
|
555
|
+
| `*`, `x`, `x.x`, `x.x.x`, `*.*.*` | ❌ blocking | npm reads a wildcard major as "any version". |
|
|
556
|
+
| `latest`, `next` | ❌ blocking | A dist-tag pins nothing. |
|
|
557
|
+
| `github:owner/repo`, `git+https://…`, `workspace:*`, `file:../pkg` | ❌ blocking | Non-registry specifiers re-resolve to whatever the source holds at install time. A git URL carrying an explicit `#semver:<range>` fragment is judged on that range instead. |
|
|
558
|
+
| `^1.0.0 \|\| >=2.0.0` | ❌ blocking | A `\|\|` union is only as bounded as its loosest arm. |
|
|
559
|
+
|
|
560
|
+
To fix a blocking override, give the bound an upper limit — `"^1.2.3"`,
|
|
561
|
+
`"~1.2.3"`, or `">=1.2.3 <2.0.0"`. There is no allowlist for this check: the
|
|
562
|
+
allowlist suppresses CVEs only.
|
|
563
|
+
|
|
507
564
|
**Allowlist format (`audit-allowlist.json`):**
|
|
508
565
|
|
|
509
566
|
```jsonc
|
package/config/stryker.base.json
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker-js/master/packages/api/schema/stryker-core.json",
|
|
3
|
-
"_comment": "Shared Stryker base for mandrel-platform consumers.
|
|
3
|
+
"_comment": "Shared Stryker base for mandrel-platform consumers. Stryker has NO `extends` option — its config reader loads exactly one config file and merges CLI arguments over it, and `extends` is not among the top-level properties its schema accepts, so a config declaring one is reported as an unknown option and otherwise ignored. Adopt this base by importing it by package specifier and spreading it into a `stryker.config.mjs`; the single worked example lives in the mandrel-platform README under `stryker.base.json`. testRunner, mutate, and per-repo thresholds stay consumer-tunable. Asserted by scripts/stryker-base-config.test.mjs.",
|
|
4
4
|
"packageManager": "pnpm",
|
|
5
5
|
"reporters": ["html", "clear-text", "progress"],
|
|
6
6
|
"coverageAnalysis": "perTest",
|
|
7
7
|
"ignoreStatic": true,
|
|
8
8
|
"cleanTempDir": true,
|
|
9
|
-
"
|
|
9
|
+
"disableBail_comment": "Bail is OFF deliberately. Under Stryker's default bail the vitest runner can score a mutant Survived having completed zero of its covering tests, so a run reports a number nothing measured and a committed baseline becomes a floor under it. Every mutant now runs its full covering set, which lengthens the run — the three timeouts below are sized for that and must move together with this flag. Asserted by scripts/stryker-base-config.test.mjs.",
|
|
10
|
+
"disableBail": true,
|
|
11
|
+
"timeoutMS": 120000,
|
|
12
|
+
"timeoutFactor": 3,
|
|
13
|
+
"dryRunTimeoutMinutes": 15,
|
|
14
|
+
"thresholds_comment": "`break: 50` is unchanged, and deliberately so. It was chosen while bail deflated the measured score (a consumer recorded 53.5% against a real 72.29%), but that deflation runs one way only: a bail-free score is higher, so a break calibrated against a deflated number cannot fail a run it would previously have passed. Raising it fleet-wide would be the very sin the flag above exists to prevent — a fail-closed number nothing measured, imposed on consumers whose real scores are unknown from here. So 50 stays a floor of last resort rather than a calibrated target: after re-deriving a baseline on the bail-free base (docs/patterns.md, 'Adopting the bail-free Stryker base'), set a tighter break locally. `high`/`low` are report-colour bands and fail nothing.",
|
|
10
15
|
"thresholds": {
|
|
11
16
|
"high": 80,
|
|
12
17
|
"low": 60,
|
package/package.json
CHANGED
package/scripts/audit-check.mjs
CHANGED
|
@@ -18,16 +18,28 @@
|
|
|
18
18
|
* an uninterpretable report means the gate cannot prove the graph is
|
|
19
19
|
* clean, so it must fail closed rather than wave the build through.
|
|
20
20
|
*
|
|
21
|
+
* Unbounded-override lint (Story #365):
|
|
22
|
+
* A dependency override REWRITES a transitive dependent's declared range.
|
|
23
|
+
* Written as a bare lower bound (`">=1.2.3"`, `"*"`, `"x.x.x"`) or as a
|
|
24
|
+
* non-registry specifier (`"github:owner/repo"`, `"workspace:*"`) it is
|
|
25
|
+
* open-ended, so
|
|
26
|
+
* the committed lockfile becomes the only pin and any fresh resolution
|
|
27
|
+
* re-picks the newest release — which can cross a major. Nothing else in the
|
|
28
|
+
* toolchain lints for that, so this gate names each such override and its
|
|
29
|
+
* bound before it runs the audit.
|
|
30
|
+
*
|
|
21
31
|
* Usage:
|
|
22
32
|
* node scripts/audit-check.mjs
|
|
23
33
|
* node scripts/audit-check.mjs --allowlist path/to/allowlist.json
|
|
34
|
+
* node scripts/audit-check.mjs --package-json path/to/package.json
|
|
24
35
|
*
|
|
25
36
|
* Exit codes:
|
|
26
37
|
* 0 — no blocking vulnerabilities (all High/Critical suppressed with
|
|
27
|
-
* valid, non-expired allowlist entries, or none found)
|
|
38
|
+
* valid, non-expired allowlist entries, or none found) and every
|
|
39
|
+
* dependency override carries an upper bound
|
|
28
40
|
* 1 — one or more unsuppressed High/Critical CVEs, expired allowlist
|
|
29
|
-
* entries were encountered, or the audit
|
|
30
|
-
* while pnpm audit exited non-zero
|
|
41
|
+
* entries were encountered, an override was unbounded, or the audit
|
|
42
|
+
* report was uninterpretable while pnpm audit exited non-zero
|
|
31
43
|
*
|
|
32
44
|
* Allowlist format (JSON):
|
|
33
45
|
* [
|
|
@@ -183,6 +195,251 @@ export function partitionAllowlist(allowlist, today) {
|
|
|
183
195
|
return { suppressed, expired, invalid };
|
|
184
196
|
}
|
|
185
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Fields a package.json can express dependency overrides through. All three
|
|
200
|
+
* are checked, because a repo that has migrated package managers routinely
|
|
201
|
+
* carries more than one and an unbounded bound is equally open-ended in any
|
|
202
|
+
* of them.
|
|
203
|
+
*/
|
|
204
|
+
const OVERRIDE_FIELDS = ["overrides", "resolutions", "pnpm.overrides"];
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Specifier prefixes that are not registry semver ranges at all: a git ref, a
|
|
208
|
+
* workspace sibling, a local path, a tarball URL. Each re-resolves to whatever
|
|
209
|
+
* that source holds at install time — `github:owner/repo` tracks the default
|
|
210
|
+
* branch, `workspace:*` tracks the sibling's current version — so none of them
|
|
211
|
+
* expresses an upper bound and none can be judged bounded. Before Story #375
|
|
212
|
+
* they all fell through to the catch-all and were reported bounded.
|
|
213
|
+
*/
|
|
214
|
+
const NON_SEMVER_PROTOCOLS = [
|
|
215
|
+
"bitbucket:",
|
|
216
|
+
"file:",
|
|
217
|
+
"gist:",
|
|
218
|
+
"git+",
|
|
219
|
+
"git:",
|
|
220
|
+
"github:",
|
|
221
|
+
"gitlab:",
|
|
222
|
+
"http:",
|
|
223
|
+
"https:",
|
|
224
|
+
"link:",
|
|
225
|
+
"portal:",
|
|
226
|
+
"workspace:",
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Is `value` a wildcard that pins nothing — `*`, a dist-tag, or a dotted
|
|
231
|
+
* wildcard such as `x.x.x`, `x.x` or `*.*.*`?
|
|
232
|
+
*
|
|
233
|
+
* The MAJOR position is the whole test, because npm reads `x.x.x` and `x.x` as
|
|
234
|
+
* exactly `*` (any version). A wildcard below the major — `1.2.x`, `1.*` —
|
|
235
|
+
* stays inside major 1 and is genuinely bounded, so only a wildcard in the
|
|
236
|
+
* first position is open-ended. Story #375: the enumerated regex this replaces
|
|
237
|
+
* listed `*`, `x` and `*.*.*` but not the dotted `x` forms, which is how the
|
|
238
|
+
* exact shape the lint exists to catch read as bounded.
|
|
239
|
+
*
|
|
240
|
+
* Deliberately answers for a SINGLE bare token only. A compound range spells
|
|
241
|
+
* its own upper bound out (`x.x <2.0.0`, `x.x.x - 2.0.0`) and is judged on
|
|
242
|
+
* that by the caller; reading the first dot-segment of the whole string would
|
|
243
|
+
* call such a range unbounded purely on its lower end.
|
|
244
|
+
*
|
|
245
|
+
* @param {string} value trimmed specifier
|
|
246
|
+
* @returns {boolean}
|
|
247
|
+
*/
|
|
248
|
+
function isWildcardSpec(value) {
|
|
249
|
+
if (/\s/.test(value)) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (/^(latest|next)$/i.test(value)) {
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return /^[*xX]$/.test(value.split(".")[0]);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Strip a leading range operator from a single bare token.
|
|
262
|
+
*
|
|
263
|
+
* `isWildcardSpec` reads the major position, which a leading operator shifts
|
|
264
|
+
* out of view: `^x.x.x` splits to `^x`, matches nothing, and reads as a real
|
|
265
|
+
* version. An operator applied to "any version" is still any version, so the
|
|
266
|
+
* operator has to come off before the major position can be judged.
|
|
267
|
+
*
|
|
268
|
+
* @param {string} term single bare token
|
|
269
|
+
* @returns {string}
|
|
270
|
+
*/
|
|
271
|
+
function stripRangeOperator(term) {
|
|
272
|
+
return term.replace(/^(?:[<>]=?|[\^~=])+/, "");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Does this single bare token carry a real upper bound?
|
|
277
|
+
*
|
|
278
|
+
* A wildcard pins nothing, and a bare lower bound (`>=1.0.0`) is open above by
|
|
279
|
+
* construction. Anything else — an exact pin, a caret/tilde range, an `x`-style
|
|
280
|
+
* partial with a fixed major, an explicit `<` cap — closes the range.
|
|
281
|
+
*
|
|
282
|
+
* @param {string} term single bare token
|
|
283
|
+
* @returns {boolean}
|
|
284
|
+
*/
|
|
285
|
+
function termCarriesUpperBound(term) {
|
|
286
|
+
if (isWildcardSpec(stripRangeOperator(term))) {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return !/^>=?/.test(term);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Is this override specifier bounded above?
|
|
295
|
+
*
|
|
296
|
+
* An override REWRITES a transitive dependent's declared range, so whatever is
|
|
297
|
+
* written here is the only thing standing between the tree and the next
|
|
298
|
+
* release of that package. A bare lower bound (`>=1.2.3`, `>1.2.3`, `*`,
|
|
299
|
+
* `x.x.x`, `latest`) leaves the committed lockfile as the sole pin: the moment
|
|
300
|
+
* anything re-resolves — a fresh install, a lockfile-less CI leg, a
|
|
301
|
+
* dependent's own bump — the newest release wins and can cross a major. That
|
|
302
|
+
* is how a major jump silently emptied a consumer's test suite.
|
|
303
|
+
*
|
|
304
|
+
* Bounded means the specifier can never cross a major on its own: an exact
|
|
305
|
+
* pin, a caret/tilde range, an `x`-style partial whose major is fixed
|
|
306
|
+
* (`1.2.x`), or a compound range carrying an explicit upper bound
|
|
307
|
+
* (`>=1.2.3 <2`).
|
|
308
|
+
*
|
|
309
|
+
* @param {string} spec
|
|
310
|
+
* @returns {boolean}
|
|
311
|
+
*/
|
|
312
|
+
export function isBoundedOverride(spec) {
|
|
313
|
+
if (typeof spec !== "string") {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const value = spec.trim();
|
|
318
|
+
if (value === "") {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// `npm:other-pkg@<range>` and `pkg@<range>` alias forms are judged on the
|
|
323
|
+
// range they carry, not on the alias target.
|
|
324
|
+
if (value.startsWith("npm:")) {
|
|
325
|
+
const aliasMatch = /^npm:(?:@[^/]+\/)?[^@\s]+@(.+)$/.exec(value);
|
|
326
|
+
return aliasMatch ? isBoundedOverride(aliasMatch[1]) : false;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// A non-registry specifier resolves outside semver entirely. Only the
|
|
330
|
+
// explicit `#semver:<range>` fragment a git URL may carry is a real range,
|
|
331
|
+
// and it is judged on its own merits exactly as an `npm:` alias is.
|
|
332
|
+
const lower = value.toLowerCase();
|
|
333
|
+
if (NON_SEMVER_PROTOCOLS.some((protocol) => lower.startsWith(protocol))) {
|
|
334
|
+
const semverFragment = /#semver:(.+)$/.exec(value);
|
|
335
|
+
return semverFragment ? isBoundedOverride(semverFragment[1]) : false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// A `||` union is only as bounded as its loosest arm.
|
|
339
|
+
if (value.includes("||")) {
|
|
340
|
+
return value.split("||").every((arm) => isBoundedOverride(arm));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Wildcards and dist-tags pin nothing at all — including behind a leading
|
|
344
|
+
// range operator, which shifts the major position out of view.
|
|
345
|
+
if (isWildcardSpec(stripRangeOperator(value))) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// A hyphen range is bounded by its right-hand side alone: `x.x.x - 2.0.0`
|
|
350
|
+
// caps at 2.0.0 however loose its lower end is, and `x.x - x.x` caps at
|
|
351
|
+
// nothing however much it is spelled like a range.
|
|
352
|
+
//
|
|
353
|
+
// A real hyphen range takes plain versions on both sides. A comparator on
|
|
354
|
+
// either end (`>=1.0.0 - 2.0.0`) is malformed, so reading its right-hand
|
|
355
|
+
// side as the cap would answer a range npm never agreed to parse — that one
|
|
356
|
+
// is left to the compound logic below, which keeps failing closed on it.
|
|
357
|
+
const hyphenRange = /^(.+?)\s+-\s+(.+)$/.exec(value);
|
|
358
|
+
if (hyphenRange) {
|
|
359
|
+
const lowerEnd = hyphenRange[1].trim();
|
|
360
|
+
const upperEnd = hyphenRange[2].trim();
|
|
361
|
+
if (!/^[<>]/.test(lowerEnd) && !/^[<>]/.test(upperEnd)) {
|
|
362
|
+
return termCarriesUpperBound(upperEnd);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// An explicit upper bound anywhere in a space-separated compound closes it.
|
|
367
|
+
if (/[<]/.test(value)) {
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Otherwise a compound is only as bounded as its terms: when every one of
|
|
372
|
+
// them is a wildcard or a bare lower bound, nothing caps the range and the
|
|
373
|
+
// range-like spelling is the only thing suggesting otherwise.
|
|
374
|
+
if (/\s/.test(value) && !value.split(/\s+/).some(termCarriesUpperBound)) {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// A bare lower bound is the unbounded shape this check exists to name.
|
|
379
|
+
if (/^[>]=?/.test(value)) {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Report every override in `pkgJson` expressed as an unbounded lower bound.
|
|
388
|
+
*
|
|
389
|
+
* Pure and package-manager agnostic: the caller supplies the parsed
|
|
390
|
+
* package.json, so this is unit-testable without a fixture tree.
|
|
391
|
+
*
|
|
392
|
+
* @param {unknown} pkgJson parsed package.json
|
|
393
|
+
* @returns {Array<{ field: string; package: string; bound: string }>}
|
|
394
|
+
*/
|
|
395
|
+
export function findUnboundedOverrides(pkgJson) {
|
|
396
|
+
/** @type {Array<{ field: string; package: string; bound: string }>} */
|
|
397
|
+
const findings = [];
|
|
398
|
+
|
|
399
|
+
if (pkgJson === null || typeof pkgJson !== "object") {
|
|
400
|
+
return findings;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (const field of OVERRIDE_FIELDS) {
|
|
404
|
+
/** @type {unknown} */
|
|
405
|
+
let node = pkgJson;
|
|
406
|
+
for (const segment of field.split(".")) {
|
|
407
|
+
node =
|
|
408
|
+
node !== null && typeof node === "object"
|
|
409
|
+
? /** @type {Record<string, unknown>} */ (node)[segment]
|
|
410
|
+
: undefined;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
for (const [name, spec] of Object.entries(
|
|
418
|
+
/** @type {Record<string, unknown>} */ (node),
|
|
419
|
+
)) {
|
|
420
|
+
// A nested override object scopes a bound to one dependent; recurse so a
|
|
421
|
+
// nested unbounded bound is named too, keyed by its full path.
|
|
422
|
+
if (spec !== null && typeof spec === "object" && !Array.isArray(spec)) {
|
|
423
|
+
for (const nested of findUnboundedOverrides({ overrides: spec })) {
|
|
424
|
+
findings.push({
|
|
425
|
+
field,
|
|
426
|
+
package: `${name}.${nested.package}`,
|
|
427
|
+
bound: nested.bound,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const bound = typeof spec === "string" ? spec : String(spec);
|
|
434
|
+
if (!isBoundedOverride(bound)) {
|
|
435
|
+
findings.push({ field, package: name, bound });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return findings;
|
|
441
|
+
}
|
|
442
|
+
|
|
186
443
|
/**
|
|
187
444
|
* True when `report` has the recognizable pnpm-audit shape: an object with
|
|
188
445
|
* an `advisories` object. This is the discriminator the fail-closed contract
|
|
@@ -304,23 +561,30 @@ export function evaluateReport(report, auditExitCode, suppressed) {
|
|
|
304
561
|
*
|
|
305
562
|
* @param {string[]} argv
|
|
306
563
|
* @param {string} [cwd]
|
|
307
|
-
* @returns {{ allowlistPath: string }}
|
|
564
|
+
* @returns {{ allowlistPath: string; packageJsonPath: string }}
|
|
308
565
|
*/
|
|
309
566
|
export function parseArgs(argv, cwd = process.cwd()) {
|
|
310
567
|
let allowlistPath = null;
|
|
568
|
+
let packageJsonPath = null;
|
|
311
569
|
|
|
312
570
|
for (let i = 0; i < argv.length; i++) {
|
|
313
571
|
if (argv[i] === "--allowlist" && argv[i + 1]) {
|
|
314
572
|
allowlistPath = resolve(cwd, argv[i + 1]);
|
|
315
573
|
i++;
|
|
574
|
+
} else if (argv[i] === "--package-json" && argv[i + 1]) {
|
|
575
|
+
packageJsonPath = resolve(cwd, argv[i + 1]);
|
|
576
|
+
i++;
|
|
316
577
|
}
|
|
317
578
|
}
|
|
318
579
|
|
|
319
580
|
if (allowlistPath === null) {
|
|
320
581
|
allowlistPath = resolve(cwd, "audit-allowlist.json");
|
|
321
582
|
}
|
|
583
|
+
if (packageJsonPath === null) {
|
|
584
|
+
packageJsonPath = resolve(cwd, "package.json");
|
|
585
|
+
}
|
|
322
586
|
|
|
323
|
-
return { allowlistPath };
|
|
587
|
+
return { allowlistPath, packageJsonPath };
|
|
324
588
|
}
|
|
325
589
|
|
|
326
590
|
/**
|
|
@@ -346,6 +610,57 @@ export function loadAllowlist(allowlistPath) {
|
|
|
346
610
|
return parsed;
|
|
347
611
|
}
|
|
348
612
|
|
|
613
|
+
/**
|
|
614
|
+
* Gate a package.json on unbounded dependency overrides. Returns the process
|
|
615
|
+
* exit code (0 clean, 1 blocking) and prints what is wrong and how to fix it.
|
|
616
|
+
*
|
|
617
|
+
* Split out of `runCli` so BOTH outcomes are executable in a test: the clean
|
|
618
|
+
* path returns here without ever reaching `pnpm audit`, which needs a real
|
|
619
|
+
* lockfile and a network. A missing package.json is not this gate's business —
|
|
620
|
+
* the audit is what proves the graph.
|
|
621
|
+
*
|
|
622
|
+
* @param {string} packageJsonPath
|
|
623
|
+
* @returns {number}
|
|
624
|
+
*/
|
|
625
|
+
export function lintOverrides(packageJsonPath) {
|
|
626
|
+
if (!existsSync(packageJsonPath)) {
|
|
627
|
+
return 0;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** @type {unknown} */
|
|
631
|
+
let pkgJson;
|
|
632
|
+
try {
|
|
633
|
+
pkgJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
634
|
+
} catch (err) {
|
|
635
|
+
console.error(
|
|
636
|
+
`[audit-check] ERROR: could not parse ${packageJsonPath}: ${
|
|
637
|
+
err instanceof Error ? err.message : String(err)
|
|
638
|
+
}`,
|
|
639
|
+
);
|
|
640
|
+
return 1;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const unbounded = findUnboundedOverrides(pkgJson);
|
|
644
|
+
if (unbounded.length === 0) {
|
|
645
|
+
return 0;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
console.error(
|
|
649
|
+
`[audit-check] ${unbounded.length} unbounded dependency override(s) in ${packageJsonPath}:`,
|
|
650
|
+
);
|
|
651
|
+
for (const finding of unbounded) {
|
|
652
|
+
console.error(
|
|
653
|
+
` - ${finding.field}.${finding.package}: "${finding.bound}" has no upper bound`,
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
console.error(
|
|
657
|
+
"\n[audit-check] An override rewrites a dependent's range, so a bare lower bound leaves " +
|
|
658
|
+
"the lockfile as the only pin and lets a fresh resolution cross a major. Give each " +
|
|
659
|
+
'bound an upper limit — "^1.2.3", "~1.2.3", or ">=1.2.3 <2.0.0". Exit 1.',
|
|
660
|
+
);
|
|
661
|
+
return 1;
|
|
662
|
+
}
|
|
663
|
+
|
|
349
664
|
/**
|
|
350
665
|
* Run `pnpm audit --prod --json`, returning the raw stdout and exit code.
|
|
351
666
|
* pnpm audit exits non-zero when vulnerabilities are found; we want the JSON
|
|
@@ -372,7 +687,17 @@ function runPnpmAudit() {
|
|
|
372
687
|
* @returns {number}
|
|
373
688
|
*/
|
|
374
689
|
export function runCli(argv) {
|
|
375
|
-
const { allowlistPath } = parseArgs(argv);
|
|
690
|
+
const { allowlistPath, packageJsonPath } = parseArgs(argv);
|
|
691
|
+
|
|
692
|
+
// --- Lint dependency overrides -------------------------------------------
|
|
693
|
+
//
|
|
694
|
+
// Runs BEFORE the audit: an unbounded override is a standing invitation for
|
|
695
|
+
// the next resolution to cross a major, and nothing else in the toolchain
|
|
696
|
+
// looks for one.
|
|
697
|
+
const overrideExit = lintOverrides(packageJsonPath);
|
|
698
|
+
if (overrideExit !== 0) {
|
|
699
|
+
return overrideExit;
|
|
700
|
+
}
|
|
376
701
|
|
|
377
702
|
// --- Load & validate the allowlist ---------------------------------------
|
|
378
703
|
|