fg-devkit 1.20.2 → 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 +30 -6
- package/configs/dependency-policy.vue-workspace.json +1 -1
- package/package.json +3 -3
- package/scaffold/vue/package.json +2 -2
- package/scripts/audit-fix-plan.mjs +436 -0
- package/scripts/audit-fix-plan.test.mjs +287 -0
- package/scripts/audit-override-commit.mjs +380 -81
- package/scripts/fg-devkit.mjs +6 -6
- package/scripts/sync-dependency-check-suppressions-commit.mjs +78 -14
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fg-devkit",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
120
|
-
"@typescript-eslint/parser": "8.71.
|
|
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.
|
|
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.
|
|
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
|
+
}
|