fg-devkit 1.20.0 → 1.21.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 CHANGED
@@ -63,13 +63,18 @@ pnpm exec fgd fallow dead-code --json
63
63
 
64
64
  When you run `fgd sync-tooling-configs` (or `fgd sync-project-configs`), fg-devkit also writes a shared `.fallowrc.json` to the consuming repo.
65
65
 
66
- Copy the shared OWASP Dependency-Check suppressions file into a consuming repo and commit it:
66
+ Write the OWASP Dependency-Check suppressions that apply to a consuming repo and commit them:
67
67
 
68
68
  ```bash
69
- pnpm exec fgd sync-dependency-check-suppressions-commit
69
+ pnpm exec fgd sync-dependency-check-suppressions-commit # only entries matching pnpm audit findings
70
+ pnpm exec fgd sync-dependency-check-suppressions-commit --all # every entry in the shared file
70
71
  ```
71
72
 
72
- Jenkins `owaspDependencyCheck` reads `dependency-check-suppressions.xml` from the repo root when the file exists. Add another `<suppress>` element in the shared fg-devkit file for each additional accepted finding. The command overwrites the consumer copy.
73
+ The command runs `pnpm audit` and keeps only the active (not expired) `<suppress>` entries from fg-devkit's shared
74
+ `configs/dependency-check-suppressions.xml` whose GHSA id is reported in the repo, so each repo accepts exactly the
75
+ risks it has. Jenkins `owaspDependencyCheck` reads `dependency-check-suppressions.xml` from the repo root when the
76
+ file exists. Add another `<suppress>` element in the shared fg-devkit file for each additional accepted finding.
77
+ The command overwrites the consumer copy; if nothing applies and the repo has no copy, no file is created.
73
78
 
74
79
  To sync standard consumer scripts to `fgd` commands:
75
80
 
@@ -153,13 +158,32 @@ Same upgrade, then prune unused workspace overrides in a second commit if any ca
153
158
  pnpm exec fgd policy-upgrade-commit --prune-overrides
154
159
  ```
155
160
 
156
- Fix one or more vulnerability overrides interactively, prune unused overrides, and auto-commit
157
- (`vuln(package): override version`, `vuln(package): prune override`, or `vuln(): override N packages` with a body listing each change):
161
+ Fix `pnpm audit` findings with the smallest change, verify, and auto-commit:
158
162
 
159
163
  ```bash
160
- pnpm exec fgd audit-override-commit
164
+ pnpm exec fgd audit-override-commit # refresh, override, prune, verify, commit
165
+ pnpm exec fgd audit-override-commit --no-verify # skip lint/typecheck/test/build (git hooks still run)
166
+ pnpm exec fgd audit-override-commit --no-commit # leave the changes in the working tree
161
167
  ```
162
168
 
169
+ 1. Runs `pnpm audit` and refreshes the vulnerable packages in the lockfile (`pnpm update <names>`).
170
+ Each moves to the newest version its parents' ranges already allow, so no override is needed.
171
+ 2. Audits again. When a patched version exists but a parent's range blocks it, adds a tight override to
172
+ `pnpm-workspace.yaml`: `'name@>=installed <patched': 'patched'` (the lowest patched version).
173
+ 3. Reports instead of overriding when the only patch is in a new major (upgrade the parent), when it is
174
+ younger than `minimumReleaseAge` (re-run later), when the package is a direct dependency (bump it with
175
+ `policy-upgrade-commit`), or when no patch exists (candidate for `auditConfig.ignoreGhsas`).
176
+ 4. Prunes overrides that no longer change the audit.
177
+ 5. If findings that are still reported have active (not expired) `<suppress>` entries in fg-devkit's shared
178
+ `configs/dependency-check-suppressions.xml`, writes the repo's `dependency-check-suppressions.xml` with only
179
+ those entries, so the Jenkins OWASP scan accepts exactly the risks this repo has. Findings with no patch and
180
+ no suppression are listed so you can add one.
181
+ 6. Installs and runs the repo's `lint-check`, `lint:ts`, `test`, `test:unit` and `build` scripts when present.
182
+ Nothing is committed if a check fails.
183
+ 7. Commits the lockfile refresh (`vuln(package): refresh lockfile` / `vuln(): refresh N packages in lockfile`),
184
+ the override changes (`vuln(package): override version`, `vuln(): override N packages`) and the
185
+ suppressions file (`chore(repo): sync dependency-check suppressions`) separately.
186
+
163
187
  Report, remove, or remove and auto-commit workspace overrides that no longer change `pnpm audit`:
164
188
 
165
189
  ```bash
@@ -16,4 +16,21 @@
16
16
  <filePath regex="true">.*pnpm-lock\.yaml\?braces$</filePath>
17
17
  <vulnerabilityName regex="true">.*(GHSA-vfj7-8cjw-p6xm|1240992).*</vulnerabilityName>
18
18
  </suppress>
19
+ <suppress until="2026-12-01Z">
20
+ <notes><![CDATA[
21
+ sprintf-js <=1.1.3 — GHSA-hp3w-g68c-fv3c / CVE-2026-97058 (denial of service
22
+ through unbounded precision specifiers). No patched release exists; 1.1.3
23
+ is the latest version. Transitive optional devDependency only, via
24
+ vite-plugin-dts > @microsoft/api-extractor > @rushstack/ts-command-line >
25
+ argparse. Not shipped in the library runtime. Risk accepted until
26
+ sprintf-js publishes a release after 1.1.3.
27
+
28
+ pnpm audit paths do not equal the module name, so Dependency-Check creates
29
+ a virtual dependency at pnpm-lock.yaml?sprintf-js with no package URL version.
30
+ Jenkins shows the id as NPMGHSA-hp3w-g68c-fv3c (source NPM + GHSA id).
31
+ Older analyzers may use the numeric advisory id 1241202.
32
+ ]]></notes>
33
+ <filePath regex="true">.*pnpm-lock\.yaml\?sprintf-js$</filePath>
34
+ <vulnerabilityName regex="true">.*(GHSA-hp3w-g68c-fv3c|CVE-2026-97058|1241202).*</vulnerabilityName>
35
+ </suppress>
19
36
  </suppressions>
@@ -95,7 +95,7 @@
95
95
  "dotenv": "18.0.5",
96
96
  "qrcode.vue": "3.11.0",
97
97
  "tailwindcss": "4.3.3",
98
- "vite": "8.3.2",
98
+ "vite": "8.3.3",
99
99
  "vue": "3.5.43"
100
100
  }
101
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fg-devkit",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "Shared configs, scripts, and DX tooling for Vue / TypeScript frontend repos",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -116,8 +116,8 @@
116
116
  "@inquirer/input": "5.1.7",
117
117
  "@inquirer/select": "5.2.6",
118
118
  "@intlify/eslint-plugin-vue-i18n": "4.5.1",
119
- "@typescript-eslint/eslint-plugin": "8.71.0",
120
- "@typescript-eslint/parser": "8.71.0",
119
+ "@typescript-eslint/eslint-plugin": "8.71.1",
120
+ "@typescript-eslint/parser": "8.71.1",
121
121
  "@vitest/eslint-plugin": "1.6.27",
122
122
  "@vue/eslint-config-prettier": "10.2.0",
123
123
  "@vue/eslint-config-typescript": "14.9.0",
@@ -32,11 +32,11 @@
32
32
  "@intlify/unplugin-vue-i18n": "11.2.5",
33
33
  "@tanstack/vue-query-devtools": "6.3.1",
34
34
  "@types/node": "22.16.5",
35
- "fg-devkit": "1.20.0",
35
+ "fg-devkit": "1.21.0",
36
36
  "@vitejs/plugin-vue": "6.0.9",
37
37
  "tailwindcss": "4.3.3",
38
38
  "tsx": "4.23.15",
39
- "vite": "8.3.2",
39
+ "vite": "8.3.3",
40
40
  "vite-plugin-vue-inspector": "7.0.0"
41
41
  },
42
42
  "packageManager": "pnpm@12.9.1",
@@ -0,0 +1,436 @@
1
+ /**
2
+ * Pure helpers for audit-override-commit: normalize `pnpm audit --json` output, plan
3
+ * minimal overrides for what a lockfile refresh could not fix, edit the `overrides:`
4
+ * section of pnpm-workspace.yaml, and diff resolved versions between two lockfiles.
5
+ *
6
+ * No I/O here so the logic can be unit tested (audit-fix-plan.test.mjs).
7
+ */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Versions
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** Parse `1.2.3` / `1.2.3-beta.1` (build metadata ignored). Returns null when not semver. */
14
+ export function parseVersion(value) {
15
+ const match = String(value ?? '')
16
+ .trim()
17
+ .match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/)
18
+ if (!match) return null
19
+ return {
20
+ major: Number(match[1]),
21
+ minor: Number(match[2]),
22
+ patch: Number(match[3]),
23
+ prerelease: match[4] ? match[4].split('.') : [],
24
+ }
25
+ }
26
+
27
+ function comparePrerelease(a, b) {
28
+ if (a.length === 0 && b.length === 0) return 0
29
+ if (a.length === 0) return 1
30
+ if (b.length === 0) return -1
31
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
32
+ if (a[i] === undefined) return -1
33
+ if (b[i] === undefined) return 1
34
+ const aNum = /^\d+$/.test(a[i])
35
+ const bNum = /^\d+$/.test(b[i])
36
+ if (aNum && bNum) {
37
+ const diff = Number(a[i]) - Number(b[i])
38
+ if (diff !== 0) return Math.sign(diff)
39
+ } else if (aNum !== bNum) {
40
+ return aNum ? -1 : 1
41
+ } else if (a[i] !== b[i]) {
42
+ return a[i] < b[i] ? -1 : 1
43
+ }
44
+ }
45
+ return 0
46
+ }
47
+
48
+ /** Semver precedence compare. Throws on non-semver input. */
49
+ export function compareVersions(a, b) {
50
+ const left = typeof a === 'string' ? parseVersion(a) : a
51
+ const right = typeof b === 'string' ? parseVersion(b) : b
52
+ if (!left || !right) throw new Error(`cannot compare versions ${a} and ${b}`)
53
+ for (const part of ['major', 'minor', 'patch']) {
54
+ if (left[part] !== right[part]) return Math.sign(left[part] - right[part])
55
+ }
56
+ return comparePrerelease(left.prerelease, right.prerelease)
57
+ }
58
+
59
+ /**
60
+ * True when `to` is a semver-compatible upgrade of `from` (what a `^from` range accepts):
61
+ * same major for >=1.0.0, same minor for 0.x, same patch for 0.0.x.
62
+ */
63
+ export function isSameReleaseLine(from, to) {
64
+ const a = parseVersion(from)
65
+ const b = parseVersion(to)
66
+ if (!a || !b) return false
67
+ if (a.major !== b.major) return false
68
+ if (a.major > 0) return true
69
+ if (a.minor !== b.minor) return false
70
+ if (a.minor > 0) return true
71
+ return a.patch === b.patch
72
+ }
73
+
74
+ /**
75
+ * Lowest patched version strictly above `installed`, read from an advisory's
76
+ * `patched_versions` (e.g. `>=1.2.2`, `>=1.1.21 <2.0.0 || >=2.1.7`).
77
+ * Returns `{ status: 'none' }` when no patch exists, `{ status: 'unparseable' }` when the
78
+ * range is not made of `>=x.y.z` lower bounds, else `{ status: 'ok', version }`.
79
+ */
80
+ export function lowestPatchedVersion(patchedVersions, installed) {
81
+ const text = String(patchedVersions ?? '').trim()
82
+ if (!text || text === 'None' || text === '<0.0.0') return { status: 'none' }
83
+ const bounds = []
84
+ for (const alternative of text.split('||')) {
85
+ const lower = alternative.trim().match(/^>=\s*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\s|$)/)
86
+ if (!lower) return { status: 'unparseable' }
87
+ bounds.push(lower[1])
88
+ }
89
+ const candidates = bounds.filter((version) => compareVersions(version, installed) > 0).sort(compareVersions)
90
+ if (candidates.length === 0) return { status: 'unparseable' }
91
+ return { status: 'ok', version: candidates[0] }
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // pnpm audit --json
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /** Parse `pnpm audit --json` stdout (tolerates log noise around the JSON). */
99
+ export function parseAuditJson(stdout) {
100
+ const text = String(stdout ?? '').trim()
101
+ try {
102
+ return JSON.parse(text)
103
+ } catch {
104
+ const start = text.indexOf('{')
105
+ const end = text.lastIndexOf('}')
106
+ if (start >= 0 && end > start) return JSON.parse(text.slice(start, end + 1))
107
+ throw new Error(`pnpm audit --json did not return JSON\n${text.slice(0, 500)}`)
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Flatten an audit report into advisories:
113
+ * `{ id, ghsa, name, severity, title, vulnerable, patched, versions: string[] }`.
114
+ * `id` is unique per advisory *and* vulnerable range (one GHSA can cover several majors).
115
+ */
116
+ export function normalizeAdvisories(report) {
117
+ const advisories = []
118
+ for (const [key, advisory] of Object.entries(report?.advisories ?? {})) {
119
+ const ghsa = advisory.github_advisory_id || String(advisory.id ?? key)
120
+ const versions = [...new Set((advisory.findings ?? []).map((finding) => finding.version).filter(Boolean))]
121
+ advisories.push({
122
+ id: `${ghsa}|${advisory.module_name}|${advisory.vulnerable_versions}`,
123
+ ghsa,
124
+ name: advisory.module_name,
125
+ severity: advisory.severity,
126
+ title: advisory.title,
127
+ vulnerable: advisory.vulnerable_versions,
128
+ patched: advisory.patched_versions,
129
+ versions,
130
+ })
131
+ }
132
+ return advisories
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Override planning
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /**
140
+ * Decide what to do with advisories that survived the lockfile refresh.
141
+ *
142
+ * For every installed vulnerable version the target is the lowest version that fixes all
143
+ * advisories affecting it. Targets in the same release line become one override per range,
144
+ * keyed tightly (`name@>=installed <target`) so nothing outside the vulnerable span moves.
145
+ *
146
+ * @param {ReturnType<typeof normalizeAdvisories>} advisories
147
+ * @param {{ directDependencies?: Set<string> }} [options]
148
+ */
149
+ export function planOverrides(advisories, { directDependencies = new Set() } = {}) {
150
+ const plan = { overrides: [], noPatch: [], crossMajor: [], direct: [], unparseable: [] }
151
+ const byPackage = new Map()
152
+ for (const advisory of advisories) {
153
+ if (!byPackage.has(advisory.name)) byPackage.set(advisory.name, [])
154
+ byPackage.get(advisory.name).push(advisory)
155
+ }
156
+
157
+ for (const [name, packageAdvisories] of byPackage) {
158
+ const ghsas = [...new Set(packageAdvisories.map((advisory) => advisory.ghsa))]
159
+ if (directDependencies.has(name)) {
160
+ plan.direct.push({ name, ghsas, versions: collectVersions(packageAdvisories) })
161
+ continue
162
+ }
163
+
164
+ // installed version -> highest "lowest patched version" across its advisories
165
+ const targets = new Map()
166
+ const blocked = { none: new Set(), unparseable: new Set() }
167
+ for (const advisory of packageAdvisories) {
168
+ for (const installed of advisory.versions) {
169
+ if (!parseVersion(installed)) {
170
+ blocked.unparseable.add(advisory.ghsa)
171
+ continue
172
+ }
173
+ const patched = lowestPatchedVersion(advisory.patched, installed)
174
+ if (patched.status !== 'ok') {
175
+ blocked[patched.status].add(advisory.ghsa)
176
+ continue
177
+ }
178
+ const current = targets.get(installed)
179
+ if (!current) {
180
+ targets.set(installed, { target: patched.version, ghsas: new Set([advisory.ghsa]) })
181
+ continue
182
+ }
183
+ if (compareVersions(patched.version, current.target) > 0) current.target = patched.version
184
+ current.ghsas.add(advisory.ghsa)
185
+ }
186
+ }
187
+
188
+ if (blocked.none.size > 0) {
189
+ plan.noPatch.push({ name, ghsas: [...blocked.none], versions: collectVersions(packageAdvisories) })
190
+ }
191
+ if (blocked.unparseable.size > 0) {
192
+ plan.unparseable.push({ name, ghsas: [...blocked.unparseable], patched: packageAdvisories[0].patched })
193
+ }
194
+ // A package with an unfixable advisory gets no override: it would not clear the finding.
195
+ if (blocked.none.size > 0 || blocked.unparseable.size > 0) continue
196
+
197
+ const spans = [...targets.entries()]
198
+ .map(([installed, { target, ghsas: spanGhsas }]) => ({ low: installed, high: target, ghsas: spanGhsas }))
199
+ .sort((a, b) => compareVersions(a.low, b.low))
200
+
201
+ const merged = []
202
+ for (const span of spans) {
203
+ const last = merged.at(-1)
204
+ if (last && compareVersions(span.low, last.high) < 0) {
205
+ if (compareVersions(span.high, last.high) > 0) last.high = span.high
206
+ for (const ghsa of span.ghsas) last.ghsas.add(ghsa)
207
+ } else {
208
+ merged.push({ ...span, ghsas: new Set(span.ghsas) })
209
+ }
210
+ }
211
+
212
+ for (const span of merged) {
213
+ const entry = { name, from: span.low, to: span.high, ghsas: [...span.ghsas] }
214
+ if (!isSameReleaseLine(span.low, span.high)) {
215
+ plan.crossMajor.push(entry)
216
+ continue
217
+ }
218
+ plan.overrides.push({ ...entry, key: `${name}@>=${span.low} <${span.high}`, value: span.high })
219
+ }
220
+ }
221
+
222
+ return plan
223
+ }
224
+
225
+ function collectVersions(advisories) {
226
+ return [...new Set(advisories.flatMap((advisory) => advisory.versions))].sort((a, b) => {
227
+ try {
228
+ return compareVersions(a, b)
229
+ } catch {
230
+ return a < b ? -1 : 1
231
+ }
232
+ })
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // minimumReleaseAge
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /** pnpm 11+ defaults minimumReleaseAge to 1440 minutes when it is not configured. */
240
+ export const DEFAULT_MINIMUM_RELEASE_AGE_MINUTES = 1440
241
+
242
+ function globToRegExp(pattern) {
243
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
244
+ return new RegExp(`^${escaped}$`)
245
+ }
246
+
247
+ /**
248
+ * Whether `name@version` is listed in minimumReleaseAgeExclude. Entries can be a name,
249
+ * a name pattern (`@scope/*`), or `name@1.2.3 || 1.2.4`.
250
+ */
251
+ export function isReleaseAgeExcluded(excludeList, name, version) {
252
+ for (const raw of excludeList ?? []) {
253
+ const entry = String(raw).trim()
254
+ const at = entry.lastIndexOf('@')
255
+ if (at <= 0) {
256
+ if (globToRegExp(entry).test(name)) return true
257
+ continue
258
+ }
259
+ if (entry.slice(0, at) !== name) continue
260
+ const versions = entry
261
+ .slice(at + 1)
262
+ .split('||')
263
+ .map((item) => item.trim())
264
+ if (versions.includes(version)) return true
265
+ }
266
+ return false
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // pnpm-workspace.yaml overrides
271
+ // ---------------------------------------------------------------------------
272
+
273
+ function quoteYamlKey(key) {
274
+ return `'${key.replace(/'/g, "''")}'`
275
+ }
276
+
277
+ function unquoteYaml(value) {
278
+ const text = value.trim()
279
+ if (text.startsWith("'") && text.endsWith("'")) return text.slice(1, -1).replace(/''/g, "'")
280
+ if (text.startsWith('"') && text.endsWith('"')) return text.slice(1, -1)
281
+ return text
282
+ }
283
+
284
+ /**
285
+ * Add or replace entries in the top-level `overrides:` map of pnpm-workspace.yaml,
286
+ * keeping every other line untouched. Creates the section when missing.
287
+ *
288
+ * @param {string} text
289
+ * @param {{ key: string, value: string }[]} entries
290
+ */
291
+ export function upsertWorkspaceOverrides(text, entries) {
292
+ if (entries.length === 0) return text
293
+ const lines = text.replace(/\r\n/g, '\n').split('\n')
294
+ const start = lines.findIndex((line) => /^overrides:\s*(?:#.*)?$/.test(line))
295
+ const rendered = (entry) => ` ${quoteYamlKey(entry.key)}: '${entry.value}'`
296
+
297
+ if (start === -1) {
298
+ while (lines.length > 0 && lines.at(-1) === '') lines.pop()
299
+ lines.push('overrides:', ...entries.map(rendered), '')
300
+ return lines.join('\n')
301
+ }
302
+
303
+ let end = start + 1
304
+ while (end < lines.length && (lines[end] === '' || /^\s/.test(lines[end]))) end += 1
305
+ // Keep trailing blank lines outside the section.
306
+ let lastContent = end - 1
307
+ while (lastContent > start && lines[lastContent] === '') lastContent -= 1
308
+
309
+ const pending = new Map(entries.map((entry) => [entry.key, entry]))
310
+ for (let i = start + 1; i <= lastContent; i += 1) {
311
+ const match = lines[i].match(/^(\s+)((?:'(?:[^']|'')*'|"[^"]*"|[^:#]+)):/)
312
+ if (!match) continue
313
+ const key = unquoteYaml(match[2])
314
+ if (pending.has(key)) {
315
+ lines[i] = rendered(pending.get(key))
316
+ pending.delete(key)
317
+ }
318
+ }
319
+ lines.splice(lastContent + 1, 0, ...[...pending.values()].map(rendered))
320
+ return lines.join('\n')
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Lockfile diff
325
+ // ---------------------------------------------------------------------------
326
+
327
+ /** Map of package name -> Set of resolved versions in a pnpm-lock.yaml (all YAML documents). */
328
+ export function lockfileVersions(text) {
329
+ const versions = new Map()
330
+ const pattern = /^ {2}'?((?:@[^/\s']+\/)?[^@\s'()]+)@(\d+\.\d+\.\d+[^:'\s(]*)/gm
331
+ for (const match of String(text ?? '').matchAll(pattern)) {
332
+ if (!versions.has(match[1])) versions.set(match[1], new Set())
333
+ versions.get(match[1]).add(match[2])
334
+ }
335
+ return versions
336
+ }
337
+
338
+ /** `[{ name, from: string[], to: string[] }]` for packages whose resolved versions changed. */
339
+ export function diffLockfileVersions(beforeText, afterText) {
340
+ const before = lockfileVersions(beforeText)
341
+ const after = lockfileVersions(afterText)
342
+ const changes = []
343
+ for (const name of [...new Set([...before.keys(), ...after.keys()])].sort()) {
344
+ const from = [...(before.get(name) ?? [])].filter((version) => !after.get(name)?.has(version))
345
+ const to = [...(after.get(name) ?? [])].filter((version) => !before.get(name)?.has(version))
346
+ if (from.length === 0 && to.length === 0) continue
347
+ changes.push({ name, from: from.sort(compareVersionsLoose), to: to.sort(compareVersionsLoose) })
348
+ }
349
+ return changes
350
+ }
351
+
352
+ function compareVersionsLoose(a, b) {
353
+ try {
354
+ return compareVersions(a, b)
355
+ } catch {
356
+ return a < b ? -1 : 1
357
+ }
358
+ }
359
+
360
+ /** Commit message for the lockfile-refresh commit, in the repo's `vuln()` convention. */
361
+ export function buildRefreshCommitMessage(changes) {
362
+ const describe = (change) =>
363
+ `- ${change.name}: ${change.from.join(', ') || '(new)'} -> ${change.to.join(', ') || '(removed)'}`
364
+ if (changes.length === 0) {
365
+ return { subject: 'vuln(): refresh lockfile', body: '' }
366
+ }
367
+ if (changes.length === 1) {
368
+ return { subject: `vuln(${changes[0].name}): refresh lockfile`, body: describe(changes[0]) }
369
+ }
370
+ return { subject: `vuln(): refresh ${changes.length} packages in lockfile`, body: changes.map(describe).join('\n') }
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // OWASP Dependency-Check suppressions
375
+ // ---------------------------------------------------------------------------
376
+
377
+ function parseSuppressUntil(value) {
378
+ if (!value) return undefined
379
+ // Dependency-Check uses xs:date, e.g. `2026-12-01Z` or `2026-12-01+01:00`.
380
+ const match = value.trim().match(/^(\d{4}-\d{2}-\d{2})(Z|[+-]\d{2}:\d{2})?$/)
381
+ const time = match ? Date.parse(`${match[1]}T00:00:00${match[2] ?? 'Z'}`) : Date.parse(value)
382
+ return Number.isFinite(time) ? time : undefined
383
+ }
384
+
385
+ /**
386
+ * Parse the `<suppress>` entries of a dependency-check-suppressions.xml.
387
+ * GHSA ids are read from the matching elements only (not `<notes>`). An entry is
388
+ * inactive once its `until` date has passed, because Dependency-Check ignores it then.
389
+ *
390
+ * @returns {{ text: string, ghsas: string[], until?: string, active: boolean }[]}
391
+ * `text` is the entry exactly as written, including indentation and line break.
392
+ */
393
+ export function parseSuppressions(xmlText, now = Date.now()) {
394
+ const entries = []
395
+ const pattern = /^[ \t]*<suppress\b([^>]*)>([\s\S]*?)<\/suppress>[ \t]*(?:\r?\n)?/gm
396
+ for (const [text, attributes, body] of String(xmlText ?? '').matchAll(pattern)) {
397
+ const until = attributes.match(/\buntil="([^"]*)"/)?.[1]
398
+ const untilTime = parseSuppressUntil(until)
399
+ const active = !until || (untilTime !== undefined && untilTime > now)
400
+ const matchers = body.replace(/<notes>[\s\S]*?<\/notes>/g, '')
401
+ const ghsas = [
402
+ ...new Set(
403
+ [...matchers.matchAll(/GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}/gi)].map(
404
+ ([ghsa]) => `GHSA${ghsa.slice(4).toLowerCase()}`,
405
+ ),
406
+ ),
407
+ ]
408
+ entries.push({ text, ghsas, until, active })
409
+ }
410
+ return entries
411
+ }
412
+
413
+ /** GHSA id -> `until` for every active `<suppress>` entry. */
414
+ export function activeSuppressedGhsas(xmlText, now = Date.now()) {
415
+ const covered = new Map()
416
+ for (const entry of parseSuppressions(xmlText, now)) {
417
+ if (!entry.active) continue
418
+ for (const ghsa of entry.ghsas) covered.set(ghsa, entry.until)
419
+ }
420
+ return covered
421
+ }
422
+
423
+ /**
424
+ * The shared suppressions file reduced to the active entries that match one of `ghsas`,
425
+ * so a repo only accepts the risks it actually has. Header, namespace and the kept
426
+ * entries are copied verbatim.
427
+ */
428
+ export function filterSuppressions(xmlText, ghsas, now = Date.now()) {
429
+ const wanted = new Set(ghsas)
430
+ let result = String(xmlText ?? '')
431
+ for (const entry of parseSuppressions(result, now)) {
432
+ if (entry.active && entry.ghsas.some((ghsa) => wanted.has(ghsa))) continue
433
+ result = result.replace(entry.text, '')
434
+ }
435
+ return result
436
+ }