fg-devkit 1.20.2 → 1.21.1
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 +32 -9
- 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/bump-node-toolchain.mjs +2 -5
- package/scripts/fg-devkit.mjs +6 -6
- package/scripts/fleet-project-repos.mjs +25 -5
- package/scripts/node-toolchain-sync.mjs +2 -2
- package/scripts/piwik-dimensions.test.mjs +0 -17
- package/scripts/sync-dependency-check-suppressions-commit.mjs +78 -14
package/README.md
CHANGED
|
@@ -63,13 +63,17 @@ 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. OWASP Dependency-Check reads the file when it is configured as a suppression file. Add another `<suppress>` element in the shared fg-devkit file for each additional accepted finding.
|
|
76
|
+
The command overwrites the consumer copy; if nothing applies and the repo has no copy, no file is created.
|
|
73
77
|
|
|
74
78
|
To sync standard consumer scripts to `fgd` commands:
|
|
75
79
|
|
|
@@ -153,13 +157,32 @@ Same upgrade, then prune unused workspace overrides in a second commit if any ca
|
|
|
153
157
|
pnpm exec fgd policy-upgrade-commit --prune-overrides
|
|
154
158
|
```
|
|
155
159
|
|
|
156
|
-
Fix
|
|
157
|
-
(`vuln(package): override version`, `vuln(package): prune override`, or `vuln(): override N packages` with a body listing each change):
|
|
160
|
+
Fix `pnpm audit` findings with the smallest change, verify, and auto-commit:
|
|
158
161
|
|
|
159
162
|
```bash
|
|
160
|
-
pnpm exec fgd audit-override-commit
|
|
163
|
+
pnpm exec fgd audit-override-commit # refresh, override, prune, verify, commit
|
|
164
|
+
pnpm exec fgd audit-override-commit --no-verify # skip lint/typecheck/test/build (git hooks still run)
|
|
165
|
+
pnpm exec fgd audit-override-commit --no-commit # leave the changes in the working tree
|
|
161
166
|
```
|
|
162
167
|
|
|
168
|
+
1. Runs `pnpm audit` and refreshes the vulnerable packages in the lockfile (`pnpm update <names>`).
|
|
169
|
+
Each moves to the newest version its parents' ranges already allow, so no override is needed.
|
|
170
|
+
2. Audits again. When a patched version exists but a parent's range blocks it, adds a tight override to
|
|
171
|
+
`pnpm-workspace.yaml`: `'name@>=installed <patched': 'patched'` (the lowest patched version).
|
|
172
|
+
3. Reports instead of overriding when the only patch is in a new major (upgrade the parent), when it is
|
|
173
|
+
younger than `minimumReleaseAge` (re-run later), when the package is a direct dependency (bump it with
|
|
174
|
+
`policy-upgrade-commit`), or when no patch exists (candidate for `auditConfig.ignoreGhsas`).
|
|
175
|
+
4. Prunes overrides that no longer change the audit.
|
|
176
|
+
5. If findings that are still reported have active (not expired) `<suppress>` entries in fg-devkit's shared
|
|
177
|
+
`configs/dependency-check-suppressions.xml`, writes the repo's `dependency-check-suppressions.xml` with only
|
|
178
|
+
those entries, so OWASP Dependency-Check accepts exactly the risks this repo has. Findings with no patch and
|
|
179
|
+
no suppression are listed so you can add one.
|
|
180
|
+
6. Installs and runs the repo's `lint-check`, `lint:ts`, `test`, `test:unit` and `build` scripts when present.
|
|
181
|
+
Nothing is committed if a check fails.
|
|
182
|
+
7. Commits the lockfile refresh (`vuln(package): refresh lockfile` / `vuln(): refresh N packages in lockfile`),
|
|
183
|
+
the override changes (`vuln(package): override version`, `vuln(): override N packages`) and the
|
|
184
|
+
suppressions file (`chore(repo): sync dependency-check suppressions`) separately.
|
|
185
|
+
|
|
163
186
|
Report, remove, or remove and auto-commit workspace overrides that no longer change `pnpm audit`:
|
|
164
187
|
|
|
165
188
|
```bash
|
|
@@ -193,9 +216,9 @@ export default {
|
|
|
193
216
|
|
|
194
217
|
## Piwik PRO custom dimensions
|
|
195
218
|
|
|
196
|
-
Compare the local slot map with a Piwik PRO site, then create missing dimensions or rename existing ones. Run it from a consuming repo
|
|
219
|
+
Compare the local slot map with a Piwik PRO site, then create missing dimensions or rename existing ones. Run it from a consuming repo.
|
|
197
220
|
|
|
198
|
-
The local map is `src/analytics/dimensions.ts` (`customDimensions` = event, `sessionDimensions` = session) or `src/util/piwikDimensions.ts` (`CustomDimensions` / `SessionCustomDimensions`). The site defaults to the first non-production entry in `src/config/env.ts`, such as preprod. Pass `--env production` to target production.
|
|
221
|
+
The local map is `src/analytics/dimensions.ts` (`customDimensions` = event, `sessionDimensions` = session) or `src/util/piwikDimensions.ts` (`CustomDimensions` / `SessionCustomDimensions`). The site defaults to the first non-production entry in `src/config/env.ts`, such as preprod. Pass `--env production` to target production.
|
|
199
222
|
|
|
200
223
|
```bash
|
|
201
224
|
pnpm exec fgd piwik list
|
|
@@ -208,7 +231,7 @@ pnpm exec fgd piwik compare --from preprod --to production
|
|
|
208
231
|
pnpm exec fgd piwik migrate --from preprod --to production --dry-run
|
|
209
232
|
```
|
|
210
233
|
|
|
211
|
-
`sync` copies the local map up to the selected Piwik site: it creates missing dimensions, then renames existing ones. It does not edit the repo. `pull` copies tracking ids from that site down into the local map for keys with the same name.
|
|
234
|
+
`sync` copies the local map up to the selected Piwik site: it creates missing dimensions, then renames existing ones. It does not edit the repo. `pull` copies tracking ids from that site down into the local map for keys with the same name.
|
|
212
235
|
|
|
213
236
|
`compare` checks two sites from `src/config/env.ts` against each other. Event and session dimensions match on `tracking_id`, so a different slot on the other site is still in sync. `migrate` copies names, descriptions, and missing dimensions from `--from` onto `--to`, one create at a time. It stops if Piwik assigns a different `tracking_id`. Dimensions that exist only on the target stay there.
|
|
214
237
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fg-devkit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.1",
|
|
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.1",
|
|
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
|
+
}
|