fg-devkit 1.28.2 → 1.29.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
@@ -65,18 +65,6 @@ Both use fg-devkit's shared [`configs/fallowrc.json`](configs/fallowrc.json), so
65
65
  the next fg-devkit bump. A repo that has its own `.fallowrc.json` (or `.fallowrc.jsonc`, `fallow.toml`, `.fallow.toml`)
66
66
  uses that instead, and so does an explicit `--config`.
67
67
 
68
- Write the OWASP Dependency-Check suppressions that apply to a consuming repo and commit them:
69
-
70
- ```bash
71
- pnpm exec fgd sync-dependency-check-suppressions-commit # only entries matching pnpm audit findings
72
- pnpm exec fgd sync-dependency-check-suppressions-commit --all # every entry in the shared file
73
- ```
74
-
75
- The command runs `pnpm audit` and keeps only the active (not expired) `<suppress>` entries from fg-devkit's shared
76
- `configs/dependency-check-suppressions.xml` whose GHSA id is reported in the repo, so each repo accepts exactly the
77
- 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.
78
- The command overwrites the consumer copy; if nothing applies and the repo has no copy, no file is created.
79
-
80
68
  To sync standard consumer scripts to `fgd` commands:
81
69
 
82
70
  ```bash
@@ -183,7 +171,7 @@ Fix `pnpm audit` findings with the smallest change, verify, and auto-commit:
183
171
  pnpm exec fgd audit-override-commit # refresh, override, prune, verify, commit
184
172
  pnpm exec fgd audit-override-commit --no-verify # skip lint/typecheck/test/build (git hooks still run)
185
173
  pnpm exec fgd audit-override-commit --no-commit # leave the changes in the working tree
186
- pnpm exec fgd audit-override-commit --allow-major --allow-young --bump-direct # force without asking
174
+ pnpm exec fgd audit-override-commit --allow-major --allow-young --bump-direct --allow-unpatched # force without asking
187
175
  ```
188
176
 
189
177
  1. Runs `pnpm audit` and refreshes the vulnerable packages in the lockfile (`pnpm update <names>`).
@@ -201,10 +189,13 @@ pnpm exec fgd audit-override-commit --allow-major --allow-young --bump-direct #
201
189
  4. Prunes overrides that no longer change the audit.
202
190
  5. Removes `minimumReleaseAgeExclude` entries that are no longer needed (see `prune-release-age-exclude` below).
203
191
  This step also runs when `pnpm audit` is clean.
204
- 6. If findings that are still reported have active (not expired) `<suppress>` entries in fg-devkit's shared
205
- `configs/dependency-check-suppressions.xml`, writes the repo's `dependency-check-suppressions.xml` with only
206
- those entries, so OWASP Dependency-Check accepts exactly the risks this repo has. Findings with no patch and
207
- no suppression are listed so you can add one.
192
+ 6. Updates the repo's `dependency-check-suppressions.xml`, which OWASP Dependency-Check reads:
193
+ - for each high or critical finding with no patched version you are asked whether to accept it (without a
194
+ terminal only with `--allow-unpatched`). An accepted finding gets a `<suppress>` entry valid for 30 days.
195
+ The date is never pushed forward, so the finding comes back if it is still unpatched. To accept it for longer,
196
+ review it, change `until` and remove the `Auto-accepted by fg-devkit` line from its notes.
197
+ - entries whose finding is no longer reported are removed, and the file is removed when no entry is left.
198
+ - other findings with no patch are listed so you can add an entry by hand.
208
199
  7. Installs and runs the repo's `lint-check`, `lint:ts`, `test`, `test:unit` and `build` scripts when present.
209
200
  Nothing is committed if a check fails.
210
201
  8. Commits the lockfile refresh (`vuln(package): refresh lockfile` / `vuln(): refresh N packages in lockfile`),
@@ -18,7 +18,7 @@
18
18
  "@lexical/rich-text": "0.53.0",
19
19
  "@piwikpro/vue-piwik-pro": "2.0.0",
20
20
  "@sentry/vue": "11.10.0",
21
- "@tanstack/vue-query": "5.105.1",
21
+ "@tanstack/vue-query": "5.105.3",
22
22
  "@unhead/vue": "3.4.3",
23
23
  "@vuelidate/core": "2.0.3",
24
24
  "@vuelidate/validators": "2.0.4",
@@ -57,16 +57,16 @@
57
57
  "@storybook/addon-vitest": "10.6.5",
58
58
  "@storybook/vue3": "10.6.5",
59
59
  "@storybook/vue3-vite": "10.6.5",
60
- "@tanstack/vue-query-devtools": "6.3.7",
60
+ "@tanstack/vue-query-devtools": "6.3.9",
61
61
  "@types/lodash-es": "4.17.12",
62
62
  "@types/node": "26.6.6",
63
63
  "@vitejs/plugin-vue": "6.0.10",
64
- "@vitest/browser": "5.0.5",
65
- "@vitest/browser-playwright": "5.0.5",
64
+ "@vitest/browser": "5.1.0",
65
+ "@vitest/browser-playwright": "5.1.0",
66
66
  "@vue/compiler-dom": "3.5.44",
67
67
  "@vue/language-core": "3.3.14",
68
68
  "@vue/test-utils": "2.5.1",
69
- "fg-devkit": "1.28.1",
69
+ "fg-devkit": "1.28.2",
70
70
  "happy-dom": "21.0.4",
71
71
  "jsdom": "30.1.3",
72
72
  "randexp": "0.6.1",
@@ -77,15 +77,15 @@
77
77
  "vite-plugin-dts": "5.1.2",
78
78
  "vite-plugin-vue-devtools": "8.2.1",
79
79
  "vite-plugin-vue-inspector": "7.0.0",
80
- "vitest": "5.0.5"
80
+ "vitest": "5.1.0"
81
81
  }
82
82
  },
83
83
  "managedAnywhere": {
84
- "@tailwindcss/vite": "4.3.3",
84
+ "@tailwindcss/vite": "4.3.4",
85
85
  "@vue/devtools-api": "8.2.1",
86
86
  "dotenv": "18.0.9",
87
87
  "playwright": "1.64.1",
88
- "tailwindcss": "4.3.3",
88
+ "tailwindcss": "4.3.4",
89
89
  "vue": "3.5.44",
90
90
  "vue-i18n": "11.4.17",
91
91
  "vue-router": "5.4.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fg-devkit",
3
- "version": "1.28.2",
3
+ "version": "1.29.0",
4
4
  "description": "Shared configs, scripts, and DX tooling for Vue / TypeScript frontend repos",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -19,7 +19,6 @@
19
19
  "./lefthook.yml": "./configs/lefthook.yml",
20
20
  "./vscode-settings.json": "./configs/vscode-settings.json",
21
21
  "./dependency-policy.vue-workspace.json": "./configs/dependency-policy.vue-workspace.json",
22
- "./dependency-check-suppressions.xml": "./configs/dependency-check-suppressions.xml",
23
22
  "./scripts/sync-vscode-settings.mjs": "./scripts/sync-vscode-settings.mjs",
24
23
  "./scripts/fg-devkit.mjs": "./scripts/fg-devkit.mjs",
25
24
  "./release-notes/parseChangelog": "./release-notes/parseChangelog.ts",
@@ -15,8 +15,8 @@
15
15
  "poeditor:upload": "pnpm exec fg-devkit poeditor-upload"
16
16
  },
17
17
  "dependencies": {
18
- "@tailwindcss/vite": "4.3.3",
19
- "@tanstack/vue-query": "5.105.1",
18
+ "@tailwindcss/vite": "4.3.4",
19
+ "@tanstack/vue-query": "5.105.3",
20
20
  "axios": "1.21.0",
21
21
  "content-disposition-header": "0.6.0",
22
22
  "date-fns": "4.4.0",
@@ -30,11 +30,11 @@
30
30
  },
31
31
  "devDependencies": {
32
32
  "@intlify/unplugin-vue-i18n": "11.2.8",
33
- "@tanstack/vue-query-devtools": "6.3.7",
33
+ "@tanstack/vue-query-devtools": "6.3.9",
34
34
  "@types/node": "26.6.6",
35
- "fg-devkit": "1.28.2",
35
+ "fg-devkit": "1.29.0",
36
36
  "@vitejs/plugin-vue": "6.0.10",
37
- "tailwindcss": "4.3.3",
37
+ "tailwindcss": "4.3.4",
38
38
  "tsx": "4.23.15",
39
39
  "vite": "8.3.5",
40
40
  "vite-plugin-vue-inspector": "7.0.0"
@@ -16,16 +16,17 @@
16
16
  * 4. Prune overrides that no longer change the audit (prune-overrides --fix).
17
17
  * 5. Prune minimumReleaseAgeExclude entries that are no longer needed
18
18
  * (prune-release-age-exclude --fix). Runs even when the audit is clean.
19
- * 6. If findings that are still reported have active <suppress> entries in fg-devkit's
20
- * shared dependency-check-suppressions.xml, write the repo's copy with only those
21
- * entries, so OWASP Dependency-Check accepts exactly the risks this repo has.
19
+ * 6. Update the repo's dependency-check-suppressions.xml: entries whose finding is no
20
+ * longer reported are dropped, and high/critical findings with no patched version and
21
+ * no entry are asked about (or accepted with --allow-unpatched) and get an entry valid
22
+ * for 30 days that is never renewed. The file is removed when no entry applies.
22
23
  * 7. Install and run the repo's checks (lint-check, lint:ts, test, test:unit, build).
23
24
  * 8. Commit the refresh, the direct bumps, the overrides, the release-age excludes and
24
25
  * the suppressions file separately.
25
26
  *
26
27
  * Usage:
27
28
  * pnpm exec fg-devkit audit-override-commit [--no-verify] [--no-commit]
28
- * [--allow-major] [--allow-young] [--bump-direct]
29
+ * [--allow-major] [--allow-young] [--bump-direct] [--allow-unpatched]
29
30
  */
30
31
 
31
32
  import fs from 'node:fs'
@@ -34,12 +35,13 @@ import { spawnSync } from 'node:child_process'
34
35
  import { fileURLToPath } from 'node:url'
35
36
  import { parseArgs } from 'node:util'
36
37
  import {
38
+ AUTO_SUPPRESSION_DAYS,
39
+ AUTO_SUPPRESSION_SEVERITIES,
37
40
  DEFAULT_MINIMUM_RELEASE_AGE_MINUTES,
38
- activeSuppressedGhsas,
39
41
  addReleaseAgeExcludes,
40
42
  buildRefreshCommitMessage,
43
+ buildRepoSuppressions,
41
44
  bumpDependencySpec,
42
- filterSuppressions,
43
45
  diffLockfileVersions,
44
46
  isReleaseAgeExcluded,
45
47
  isSameReleaseLine,
@@ -75,6 +77,7 @@ const { values: args } = parseArgs({
75
77
  'allow-major': { type: 'boolean', default: false },
76
78
  'allow-young': { type: 'boolean', default: false },
77
79
  'bump-direct': { type: 'boolean', default: false },
80
+ 'allow-unpatched': { type: 'boolean', default: false },
78
81
  help: { type: 'boolean', short: 'h', default: false },
79
82
  },
80
83
  strict: true,
@@ -82,12 +85,13 @@ const { values: args } = parseArgs({
82
85
 
83
86
  if (args.help) {
84
87
  console.log(`Usage: pnpm exec fg-devkit audit-override-commit [--no-verify] [--no-commit]
85
- [--allow-major] [--allow-young] [--bump-direct]
88
+ [--allow-major] [--allow-young] [--bump-direct] [--allow-unpatched]
86
89
 
87
90
  Refreshes vulnerable packages in the lockfile within their existing ranges, adds a
88
91
  tight override only where a parent's range blocks the patched version, prunes unused
89
- overrides, syncs dependency-check-suppressions.xml when a remaining finding is
90
- suppressed there, runs the repo's checks, and commits each kind of change separately.
92
+ overrides, updates dependency-check-suppressions.xml (accepts high/critical findings
93
+ with no patch for 30 days, drops entries for fixed findings), runs the repo's checks,
94
+ and commits each kind of change separately.
91
95
 
92
96
  Findings that need a decision are asked about one package at a time in a terminal.
93
97
  Without a terminal they are only reported, unless a flag below says yes for them.
@@ -96,7 +100,10 @@ Without a terminal they are only reported, unless a flag below says yes for them
96
100
  --no-commit Leave the changes in the working tree
97
101
  --allow-major Override even when the patch is only in a new major
98
102
  --allow-young Allow patches younger than minimumReleaseAge (adds minimumReleaseAgeExclude)
99
- --bump-direct Bump vulnerable direct dependencies in package.json`)
103
+ --bump-direct Bump vulnerable direct dependencies in package.json
104
+ --allow-unpatched
105
+ Accept high/critical findings with no patched version for 30 days
106
+ in dependency-check-suppressions.xml`)
100
107
  process.exit(0)
101
108
  }
102
109
 
@@ -409,29 +416,32 @@ function pruneOverrides() {
409
416
  }
410
417
 
411
418
  /**
412
- * Write the repo's dependency-check-suppressions.xml with only the entries from fg-devkit's
413
- * shared file that match a finding still reported here. Untouched when nothing matches.
419
+ * Keep the entries of the repo's dependency-check-suppressions.xml that still match a finding
420
+ * and add entries for the high and critical findings with no patched version that are
421
+ * accepted. Removed when no entry applies.
414
422
  */
415
- function syncSuppressions(remaining) {
416
- const sourcePath = path.join(fgDevkitRoot, 'configs', SUPPRESSIONS_FILE)
417
- if (!fs.existsSync(sourcePath)) return { covered: [], uncovered: remaining }
418
- const shared = fs.readFileSync(sourcePath, 'utf8')
419
- const active = activeSuppressedGhsas(shared)
420
- const covered = remaining
421
- .filter((advisory) => active.has(advisory.ghsa))
422
- .map((advisory) => ({ ...advisory, until: active.get(advisory.ghsa) }))
423
- const uncovered = remaining.filter((advisory) => !active.has(advisory.ghsa))
424
- if (covered.length === 0) return { covered, uncovered }
425
- const content = filterSuppressions(
426
- shared,
427
- covered.map((advisory) => advisory.ghsa),
428
- )
429
- if (readFile(SUPPRESSIONS_FILE) !== content) {
430
- fs.writeFileSync(path.join(cwd, SUPPRESSIONS_FILE), content)
423
+ async function syncSuppressions(remaining) {
424
+ const existing = readFile(SUPPRESSIONS_FILE)
425
+ const { candidates } = buildRepoSuppressions({ existing, advisories: remaining })
426
+ const accept = new Set()
427
+ for (const advisory of candidates) {
428
+ if (accept.has(advisory.ghsa)) continue
429
+ const message = `${advisory.name} ${advisory.versions.join(', ')} has no patched version (${advisory.severity}, ${advisory.ghsa}). Accept it in ${SUPPRESSIONS_FILE} for ${AUTO_SUPPRESSION_DAYS} days?`
430
+ if (await decide(message, 'allow-unpatched')) accept.add(advisory.ghsa)
431
+ }
432
+ const result = buildRepoSuppressions({ existing, advisories: remaining, accept })
433
+ if (result.content === null) {
434
+ if (existing !== undefined) {
435
+ fs.rmSync(path.join(cwd, SUPPRESSIONS_FILE))
436
+ suppressionsWritten = true
437
+ console.log(`${TAG} Removed ${SUPPRESSIONS_FILE}, no suppression applies to this repo.`)
438
+ }
439
+ } else if (existing !== result.content) {
440
+ fs.writeFileSync(path.join(cwd, SUPPRESSIONS_FILE), result.content)
431
441
  suppressionsWritten = true
432
- console.log(`${TAG} Wrote ${SUPPRESSIONS_FILE} with the entries that apply to this repo.`)
442
+ console.log(`${TAG} Updated ${SUPPRESSIONS_FILE}.`)
433
443
  }
434
- return { covered, uncovered }
444
+ return result
435
445
  }
436
446
 
437
447
  function verify() {
@@ -451,7 +461,9 @@ function verify() {
451
461
  }
452
462
 
453
463
  function printSummary({ fixedByRefresh, plan, forced, heldBack, applied, pruned, remaining, suppressions, excludeChanges }) {
454
- const suppressed = new Set(suppressions.covered.map((advisory) => advisory.ghsa))
464
+ const suppressed = new Set(
465
+ [...suppressions.covered, ...suppressions.autoAccepted].map((advisory) => advisory.ghsa),
466
+ )
455
467
  const unique = (items) => [...new Set(items)]
456
468
  const lines = []
457
469
  const section = (title, items) => {
@@ -499,19 +511,23 @@ function printSummary({ fixedByRefresh, plan, forced, heldBack, applied, pruned,
499
511
  .map((item) => `${item.name} ${item.from} -> ${item.to} (${item.ghsas.join(', ')})`),
500
512
  )
501
513
  section(
502
- 'No patched version and not suppressed, add a <suppress> entry to fg-devkit configs/dependency-check-suppressions.xml if the risk is accepted:',
514
+ `No patched version and not suppressed (accept high/critical ones on a re-run or with --allow-unpatched, or add a <suppress> entry to ${SUPPRESSIONS_FILE} by hand):`,
503
515
  plan.noPatch
504
516
  .filter((item) => !item.ghsas.every((ghsa) => suppressed.has(ghsa)))
505
517
  .map((item) => `${item.name} ${item.versions.join(', ')} (${item.ghsas.join(', ')})`),
506
518
  )
507
519
  section(
508
- `Accepted in ${SUPPRESSIONS_FILE}${suppressionsWritten ? ' (updated from fg-devkit)' : ' (already up to date)'}:`,
520
+ `Accepted in ${SUPPRESSIONS_FILE}:`,
509
521
  unique(
510
522
  suppressions.covered.map(
511
523
  (advisory) => `${advisory.name} ${advisory.ghsa}${advisory.until ? `, until ${advisory.until}` : ''}`,
512
524
  ),
513
525
  ),
514
526
  )
527
+ section(
528
+ `Accepted for ${AUTO_SUPPRESSION_DAYS} days in ${SUPPRESSIONS_FILE} (${[...AUTO_SUPPRESSION_SEVERITIES].join('/')}, no patched version, not renewed):`,
529
+ unique(suppressions.autoAccepted.map((advisory) => `${advisory.name} ${advisory.ghsa}, until ${advisory.until}`)),
530
+ )
515
531
  section(
516
532
  'Could not read the patched range, check manually:',
517
533
  plan.unparseable.map((item) => `${item.name}: ${item.patched} (${item.ghsas.join(', ')})`),
@@ -596,7 +612,7 @@ async function main() {
596
612
 
597
613
  // 5. Release-age excludes, 6. suppressions, 7. verify.
598
614
  const excludeChanges = pruneReleaseAgeExclude()
599
- const suppressions = syncSuppressions(remaining)
615
+ const suppressions = await syncSuppressions(remaining)
600
616
  printSummary({ fixedByRefresh, plan, forced, heldBack, applied: allowed, pruned, remaining, suppressions, excludeChanges })
601
617
 
602
618
  const final = snapshot()
@@ -21,7 +21,6 @@
21
21
  * pnpm exec fg-devkit prune-release-age-exclude [--fix] [--commit]
22
22
  * pnpm exec fg-devkit sync-project-configs (VS Code + tooling sync; overwrites config files)
23
23
  * pnpm exec fg-devkit sync-tooling-configs (tooling sync only; overwrites config files)
24
- * pnpm exec fg-devkit sync-dependency-check-suppressions-commit [--all]
25
24
  * pnpm exec fg-devkit sync-vscode-settings
26
25
  * pnpm exec fg-devkit sync-consumer-scripts
27
26
  * pnpm exec fg-devkit setup-lefthook
@@ -516,7 +515,6 @@ function runSyncConfigTarget(target) {
516
515
  if (target === 'eslint') return syncFileFromTemplate('configs/eslint.sync-stub.js', 'eslint.config.js')
517
516
  if (target === 'prettier') return syncFileFromTemplate('configs/prettier.sync-stub.js', 'prettier.config.js')
518
517
  if (target === 'tsconfig') return syncFileFromTemplate('configs/tsconfig.app.sync.json', 'tsconfig.json')
519
- if (target === 'dependency-check') return runNode('sync-dependency-check-suppressions-commit.mjs')
520
518
  if (target === 'vue-inspector') return runNode('sync-vite-plugin-vue-inspector.mjs')
521
519
  if (target === 'all') return runNode('sync-project-configs.mjs')
522
520
  console.error(`[fg-devkit] Unknown config sync target: ${target}`)
@@ -547,7 +545,6 @@ const DIRECT_COMMANDS = {
547
545
  'prune-release-age-exclude': 'prune-release-age-exclude.mjs',
548
546
  'sync-project-configs': 'sync-project-configs.mjs',
549
547
  'sync-tooling-configs': 'sync-tooling-configs.mjs',
550
- 'sync-dependency-check-suppressions-commit': 'sync-dependency-check-suppressions-commit.mjs',
551
548
  'sync-vscode-settings': 'sync-vscode-settings.mjs',
552
549
  'sync-vite-plugin-vue-inspector': 'sync-vite-plugin-vue-inspector.mjs',
553
550
  'sync-consumer-scripts': 'sync-consumer-scripts.mjs',
@@ -611,8 +608,6 @@ Commands:
611
608
  Report minimumReleaseAgeExclude entries that are no longer needed; --fix removes them; --commit also auto-commits
612
609
  sync-project-configs VS Code + tooling sync (overwrites config files)
613
610
  sync-tooling-configs Tooling sync only (overwrites config files)
614
- sync-dependency-check-suppressions-commit [--all]
615
- Write the dependency-check suppressions that match pnpm audit findings and auto-commit (--all: copy every entry)
616
611
  sync-vscode-settings Copy shared VS Code settings into .vscode/settings.json
617
612
  sync-vite-plugin-vue-inspector Add/pin vite-plugin-vue-inspector, wire vite.config, install, commit
618
613
  sync-consumer-scripts Enforce standard fgd scripts in consumer package.json
@@ -1109,11 +1104,6 @@ async function runInteractiveMenu() {
1109
1104
  { name: 'ESLint', value: 'eslint', description: 'Overwrite eslint.config.js with the shared config' },
1110
1105
  { name: 'Prettier', value: 'prettier', description: 'Overwrite prettier.config.js with the shared config' },
1111
1106
  { name: 'tsconfig', value: 'tsconfig', description: 'Overwrite tsconfig.json with the shared config' },
1112
- {
1113
- name: autoCommit('Dependency-Check suppressions'),
1114
- value: 'dependency-check',
1115
- description: 'Write the suppressions that match pnpm audit findings',
1116
- },
1117
1107
  {
1118
1108
  name: autoCommit('Vue inspector'),
1119
1109
  value: 'vue-inspector',
@@ -537,29 +537,96 @@ export function parseSuppressions(xmlText, now = Date.now()) {
537
537
  return entries
538
538
  }
539
539
 
540
- /** GHSA id -> `until` for every active `<suppress>` entry. */
541
- export function activeSuppressedGhsas(xmlText, now = Date.now()) {
542
- const covered = new Map()
543
- for (const entry of parseSuppressions(xmlText, now)) {
544
- if (!entry.active) continue
545
- for (const ghsa of entry.ghsas) covered.set(ghsa, entry.until)
546
- }
547
- return covered
540
+ export const AUTO_SUPPRESSION_MARKER = 'Auto-accepted by fg-devkit'
541
+ export const AUTO_SUPPRESSION_SEVERITIES = new Set(['high', 'critical'])
542
+ export const AUTO_SUPPRESSION_DAYS = 30
543
+
544
+ const GHSA_PATTERN = /^GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}$/
545
+ const SUPPRESSIONS_TEMPLATE = [
546
+ '<?xml version="1.0" encoding="UTF-8"?>',
547
+ '<suppressions xmlns="https://jeremylong.github.io/DependencyCheck/dependency-suppression.1.3.xsd">',
548
+ '</suppressions>',
549
+ '',
550
+ ].join('\n')
551
+
552
+ function escapeRegExp(text) {
553
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
554
+ }
555
+
556
+ function renderAutoSuppression(advisory, until) {
557
+ return [
558
+ ` <suppress until="${until}">`,
559
+ ' <notes><![CDATA[',
560
+ ` ${AUTO_SUPPRESSION_MARKER}: no patched version of ${advisory.name} exists.`,
561
+ ` ${advisory.ghsa} (${advisory.severity}): ${advisory.title ?? ''}`.trimEnd(),
562
+ ' Not renewed automatically. To accept the risk for longer, review it and',
563
+ ' remove the first line of these notes so the entry is kept as written.',
564
+ ' ]]></notes>',
565
+ ` <filePath regex="true">.*pnpm-lock\\.yaml\\?${escapeRegExp(advisory.name)}$</filePath>`,
566
+ ` <vulnerabilityName regex="true">.*${advisory.ghsa}.*</vulnerabilityName>`,
567
+ ' </suppress>',
568
+ '',
569
+ ].join('\n')
548
570
  }
549
571
 
550
572
  /**
551
- * The shared suppressions file reduced to the active entries that match one of `ghsas`,
552
- * so a repo only accepts the risks it actually has. Header, namespace and the kept
553
- * entries are copied verbatim.
573
+ * The repo's dependency-check-suppressions.xml for the advisories `pnpm audit` still reports.
574
+ *
575
+ * Entries already in the file are kept while one of their GHSA ids is still reported, and
576
+ * dropped otherwise. High and critical advisories with no patched version and no entry are
577
+ * returned as `candidates`; those whose GHSA id is in `accept` get an auto-accepted entry valid
578
+ * for AUTO_SUPPRESSION_DAYS. Kept entries are never changed, so an auto-accepted `until` date is
579
+ * never pushed forward and an expired entry stays expired.
580
+ *
581
+ * @param {{ existing?: string, advisories: ReturnType<typeof normalizeAdvisories>, accept?: Set<string>, now?: number }} input
582
+ * @returns {{ content: string | null, covered: object[], autoAccepted: object[], candidates: object[], uncovered: object[] }}
583
+ * `content` is null when no entry applies (the repo needs no file).
554
584
  */
555
- export function filterSuppressions(xmlText, ghsas, now = Date.now()) {
556
- const wanted = new Set(ghsas)
557
- let result = String(xmlText ?? '')
558
- for (const entry of parseSuppressions(result, now)) {
559
- if (entry.active && entry.ghsas.some((ghsa) => wanted.has(ghsa))) continue
560
- result = result.replace(entry.text, '')
585
+ export function buildRepoSuppressions({ existing, advisories, accept = new Set(), now = Date.now() }) {
586
+ const reported = new Set(advisories.map((advisory) => advisory.ghsa))
587
+ const entryByGhsa = new Map()
588
+ let content = existing ?? SUPPRESSIONS_TEMPLATE
589
+ let kept = 0
590
+ for (const entry of parseSuppressions(content, now)) {
591
+ if (!entry.ghsas.some((ghsa) => reported.has(ghsa))) {
592
+ content = content.replace(entry.text, '')
593
+ continue
594
+ }
595
+ kept += 1
596
+ for (const ghsa of entry.ghsas) entryByGhsa.set(ghsa, entry)
561
597
  }
562
- return result
598
+ const newUntil = `${new Date(now + AUTO_SUPPRESSION_DAYS * 86400000).toISOString().slice(0, 10)}Z`
599
+
600
+ const covered = []
601
+ const autoAccepted = []
602
+ const candidates = []
603
+ const uncovered = []
604
+ const added = new Map()
605
+ for (const advisory of advisories) {
606
+ const entry = entryByGhsa.get(advisory.ghsa)
607
+ if (entry) {
608
+ if (!entry.active) uncovered.push(advisory)
609
+ else if (entry.text.includes(AUTO_SUPPRESSION_MARKER)) autoAccepted.push({ ...advisory, until: entry.until })
610
+ else covered.push({ ...advisory, until: entry.until })
611
+ continue
612
+ }
613
+ const canAutoAccept =
614
+ AUTO_SUPPRESSION_SEVERITIES.has(advisory.severity) &&
615
+ GHSA_PATTERN.test(advisory.ghsa) &&
616
+ lowestPatchedVersion(advisory.patched, '0.0.0').status === 'none'
617
+ if (canAutoAccept) candidates.push(advisory)
618
+ if (!canAutoAccept || !accept.has(advisory.ghsa)) {
619
+ uncovered.push(advisory)
620
+ continue
621
+ }
622
+ autoAccepted.push({ ...advisory, until: newUntil })
623
+ if (!added.has(advisory.ghsa)) added.set(advisory.ghsa, renderAutoSuppression(advisory, newUntil))
624
+ }
625
+
626
+ if (kept === 0 && added.size === 0) return { content: null, covered, autoAccepted, candidates, uncovered }
627
+ const closing = content.lastIndexOf('</suppressions>')
628
+ content = `${content.slice(0, closing)}${[...added.values()].join('')}${content.slice(closing)}`
629
+ return { content, covered, autoAccepted, candidates, uncovered }
563
630
  }
564
631
 
565
632
  // ---------------------------------------------------------------------------
@@ -2,18 +2,18 @@ import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
3
 
4
4
  import {
5
- activeSuppressedGhsas,
6
5
  addReleaseAgeExcludes,
7
6
  buildRefreshCommitMessage,
7
+ buildRepoSuppressions,
8
8
  bumpDependencySpec,
9
9
  compareVersions,
10
10
  diffLockfileVersions,
11
- filterSuppressions,
12
11
  isReleaseAgeExcluded,
13
12
  isSameReleaseLine,
14
13
  lowestPatchedVersion,
15
14
  normalizeAdvisories,
16
15
  overrideKey,
16
+ parseSuppressions,
17
17
  planOverrides,
18
18
  planReleaseAgeExcludePrune,
19
19
  releaseLineFloor,
@@ -383,7 +383,7 @@ test('buildRefreshCommitMessage follows the vuln() convention', () => {
383
383
  )
384
384
  })
385
385
 
386
- test('activeSuppressedGhsas reads active entries and ignores notes and expired ones', () => {
386
+ test('parseSuppressions reads GHSA ids from matchers only and marks expired entries inactive', () => {
387
387
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
388
388
  <suppressions>
389
389
  <suppress until="2026-12-01Z">
@@ -400,46 +400,69 @@ test('activeSuppressedGhsas reads active entries and ignores notes and expired o
400
400
  </suppressions>`
401
401
  const now = Date.parse('2026-10-06T12:00:00Z')
402
402
  assert.deepEqual(
403
- [...activeSuppressedGhsas(xml, now)],
403
+ parseSuppressions(xml, now).map(({ ghsas, until, active }) => ({ ghsas, until, active })),
404
404
  [
405
- ['GHSA-vfj7-8cjw-p6xm', '2026-12-01Z'],
406
- ['GHSA-aaaa-bbbb-cccc', undefined],
405
+ { ghsas: ['GHSA-vfj7-8cjw-p6xm'], until: '2026-12-01Z', active: true },
406
+ { ghsas: ['GHSA-hp3w-g68c-fv3c'], until: '2026-01-01Z', active: false },
407
+ { ghsas: ['GHSA-aaaa-bbbb-cccc'], until: undefined, active: true },
407
408
  ],
408
409
  )
409
- assert.equal(activeSuppressedGhsas(xml, Date.parse('2026-12-02T00:00:00Z')).has('GHSA-vfj7-8cjw-p6xm'), false)
410
+ assert.equal(parseSuppressions(xml, Date.parse('2026-12-02T00:00:00Z'))[0].active, false)
410
411
  })
411
412
 
412
- test('filterSuppressions keeps only active entries that match the repo findings', () => {
413
- const xml = [
413
+ test('buildRepoSuppressions keeps reported entries and adds accepted unpatched high and critical findings', () => {
414
+ const now = Date.parse('2026-11-03T12:00:00Z')
415
+ const finding = (ghsa, name, severity, patched) => ({ ...advisory({ name, ghsa, patched, versions: ['1.0.0'] }), severity })
416
+ const existing = [
414
417
  '<?xml version="1.0" encoding="UTF-8"?>',
415
418
  '<suppressions xmlns="https://jeremylong.github.io/DependencyCheck/dependency-suppression.1.3.xsd">',
416
419
  ' <suppress until="2026-12-01Z">',
417
- ' <notes><![CDATA[ braces ]]></notes>',
418
- ' <vulnerabilityName regex="true">.*(GHSA-vfj7-8cjw-p6xm|1240992).*</vulnerabilityName>',
420
+ ' <vulnerabilityName regex="true">.*(GHSA-vfj7-8cjw-p6xm).*</vulnerabilityName>',
419
421
  ' </suppress>',
420
422
  ' <suppress until="2026-12-01Z">',
421
- ' <notes><![CDATA[ sprintf-js ]]></notes>',
422
423
  ' <vulnerabilityName regex="true">.*(GHSA-hp3w-g68c-fv3c).*</vulnerabilityName>',
423
424
  ' </suppress>',
424
425
  '</suppressions>',
425
426
  '',
426
427
  ].join('\n')
427
- const now = Date.parse('2026-10-06T12:00:00Z')
428
- assert.equal(
429
- filterSuppressions(xml, ['GHSA-vfj7-8cjw-p6xm'], now),
430
- [
431
- '<?xml version="1.0" encoding="UTF-8"?>',
432
- '<suppressions xmlns="https://jeremylong.github.io/DependencyCheck/dependency-suppression.1.3.xsd">',
433
- ' <suppress until="2026-12-01Z">',
434
- ' <notes><![CDATA[ braces ]]></notes>',
435
- ' <vulnerabilityName regex="true">.*(GHSA-vfj7-8cjw-p6xm|1240992).*</vulnerabilityName>',
436
- ' </suppress>',
437
- '</suppressions>',
438
- '',
439
- ].join('\n'),
428
+ const findings = [
429
+ finding('GHSA-vfj7-8cjw-p6xm', 'braces', 'high', '<0.0.0'),
430
+ finding('GHSA-aaaa-bbbb-cccc', '@scope/pkg.js', 'critical', '<0.0.0'),
431
+ finding('GHSA-dddd-eeee-ffff', 'moderate-pkg', 'moderate', '<0.0.0'),
432
+ finding('GHSA-gggg-hhhh-iiii', 'fixable', 'high', '>=2.0.0'),
433
+ ]
434
+ const unaccepted = buildRepoSuppressions({ existing, advisories: findings, now })
435
+ assert.deepEqual(
436
+ unaccepted.candidates.map((item) => item.ghsa),
437
+ ['GHSA-aaaa-bbbb-cccc'],
440
438
  )
441
- // Expired entries are dropped even when they match.
442
- assert.doesNotMatch(filterSuppressions(xml, ['GHSA-vfj7-8cjw-p6xm'], Date.parse('2026-12-02T00:00:00Z')), /GHSA-vfj7/)
439
+ assert.equal(unaccepted.autoAccepted.length, 0)
440
+ assert.doesNotMatch(unaccepted.content, /GHSA-aaaa/)
441
+
442
+ const first = buildRepoSuppressions({ existing, advisories: findings, accept: new Set(['GHSA-aaaa-bbbb-cccc']), now })
443
+ assert.deepEqual(
444
+ first.covered.map((item) => item.ghsa),
445
+ ['GHSA-vfj7-8cjw-p6xm'],
446
+ )
447
+ assert.deepEqual(
448
+ first.autoAccepted.map((item) => [item.ghsa, item.until]),
449
+ [['GHSA-aaaa-bbbb-cccc', '2026-12-03Z']],
450
+ )
451
+ assert.deepEqual(
452
+ first.uncovered.map((item) => item.ghsa),
453
+ ['GHSA-dddd-eeee-ffff', 'GHSA-gggg-hhhh-iiii'],
454
+ )
455
+ assert.doesNotMatch(first.content, /GHSA-hp3w/)
456
+ assert.match(first.content, /pnpm-lock\\.yaml\\\?@scope\/pkg\\.js\$/)
457
+
458
+ const later = buildRepoSuppressions({ existing: first.content, advisories: findings, now: now + 10 * 86400000 })
459
+ assert.equal(later.content, first.content)
460
+ const expired = buildRepoSuppressions({ existing: first.content, advisories: findings, now: now + 40 * 86400000 })
461
+ assert.equal(expired.content, first.content)
462
+ assert.equal(expired.autoAccepted.length, 0)
463
+
464
+ assert.equal(buildRepoSuppressions({ existing: first.content, advisories: [], now }).content, null)
465
+ assert.equal(buildRepoSuppressions({ advisories: [findings[2]], now }).content, null)
443
466
  })
444
467
 
445
468
  test('planReleaseAgeExcludePrune drops versions that are old enough or no longer installed', () => {
@@ -1,36 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <suppressions xmlns="https://jeremylong.github.io/DependencyCheck/dependency-suppression.1.3.xsd">
3
- <suppress until="2026-12-01Z">
4
- <notes><![CDATA[
5
- braces <= 3.0.3 — GHSA-vfj7-8cjw-p6xm (stack exhaustion via deeply nested patterns).
6
- No patched release exists; 3.0.3 is the latest version.
7
- Transitive devDependency only, via fast-glob and micromatch
8
- (@intlify/unplugin-vue-i18n and fg-devkit). Not shipped in the app runtime.
9
- Risk accepted until braces publishes a release after 3.0.3.
10
-
11
- pnpm audit paths do not equal the module name, so Dependency-Check creates
12
- a virtual dependency at pnpm-lock.yaml?braces with no package URL version.
13
- Jenkins shows the id as NPMGHSA-vfj7-8cjw-p6xm (source NPM + GHSA id).
14
- Older analyzers may use the numeric advisory id 1240992.
15
- ]]></notes>
16
- <filePath regex="true">.*pnpm-lock\.yaml\?braces$</filePath>
17
- <vulnerabilityName regex="true">.*(GHSA-vfj7-8cjw-p6xm|1240992).*</vulnerabilityName>
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>
36
- </suppressions>
@@ -1,110 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Write dependency-check-suppressions.xml into the consumer repo and auto-commit.
4
- *
5
- * Only the active <suppress> entries from fg-devkit's shared file that match a finding
6
- * `pnpm audit` still reports in this repo are written, so OWASP Dependency-Check accepts
7
- * exactly the risks this repo has. Pass --all to copy the whole shared file instead.
8
- *
9
- * Usage:
10
- * node node_modules/fg-devkit/scripts/sync-dependency-check-suppressions-commit.mjs [--all]
11
- * pnpm exec fg-devkit sync-dependency-check-suppressions-commit [--all]
12
- */
13
-
14
- import fs from 'node:fs'
15
- import path from 'node:path'
16
- import { spawnSync } from 'node:child_process'
17
- import { parseArgs } from 'node:util'
18
- import { filterSuppressions, normalizeAdvisories, parseAuditJson, parseSuppressions } from './lib/audit-fix-plan.mjs'
19
- import { fileDiffersFromHead, findFgDevkitPackageRoot, runGit } from './lib/common.mjs'
20
-
21
- const TAG = '[sync-dependency-check-suppressions]'
22
- const FILE_NAME = 'dependency-check-suppressions.xml'
23
- const COMMIT_MESSAGE = 'chore(repo): sync dependency-check suppressions'
24
- const projectRoot = process.cwd()
25
- const GIT = { cwd: projectRoot, tag: TAG }
26
-
27
- const { values: args } = parseArgs({
28
- options: {
29
- all: { type: 'boolean', default: false },
30
- help: { type: 'boolean', short: 'h', default: false },
31
- },
32
- strict: true,
33
- })
34
-
35
- if (args.help) {
36
- console.log(`Usage: pnpm exec fg-devkit sync-dependency-check-suppressions-commit [--all]
37
-
38
- Writes ${FILE_NAME} with the entries from fg-devkit that match findings
39
- pnpm audit reports in this repo, and auto-commits it.
40
-
41
- --all Copy every entry from fg-devkit's shared file (no pnpm audit)`)
42
- process.exit(0)
43
- }
44
-
45
- /** GHSA ids `pnpm audit` reports in this repo. Exits on audit failure so nothing is written blindly. */
46
- function reportedGhsas() {
47
- const result = spawnSync('pnpm', ['audit', '--json'], {
48
- cwd: projectRoot,
49
- env: process.env,
50
- encoding: 'utf8',
51
- maxBuffer: 50 * 1024 * 1024,
52
- })
53
- if (result.error || (result.status !== 0 && result.status !== 1)) {
54
- console.error(`${TAG} pnpm audit failed, ${FILE_NAME} was not changed.`)
55
- console.error((result.error?.message ?? result.stderr ?? result.stdout ?? '').trim())
56
- process.exit(1)
57
- }
58
- return new Set(normalizeAdvisories(parseAuditJson(result.stdout)).map((advisory) => advisory.ghsa))
59
- }
60
-
61
- const fgRoot = findFgDevkitPackageRoot(projectRoot)
62
- if (!fgRoot) {
63
- console.error(`${TAG} Could not find fg-devkit (install fg-devkit or a package that bundles it, e.g. fds-vue-core).`)
64
- process.exit(1)
65
- }
66
-
67
- const sourcePath = path.join(fgRoot, 'configs', FILE_NAME)
68
- if (!fs.existsSync(sourcePath)) {
69
- console.error(`${TAG} Missing template: ${sourcePath}`)
70
- process.exit(1)
71
- }
72
-
73
- const shared = fs.readFileSync(sourcePath, 'utf8')
74
- const targetPath = path.join(projectRoot, FILE_NAME)
75
- let content = shared
76
-
77
- if (!args.all) {
78
- console.log(`${TAG} Running pnpm audit to find the entries that apply to this repo…`)
79
- const reported = reportedGhsas()
80
- const now = Date.now()
81
- const kept = parseSuppressions(shared, now).filter(
82
- (entry) => entry.active && entry.ghsas.some((ghsa) => reported.has(ghsa)),
83
- )
84
- content = filterSuppressions(shared, reported, now)
85
-
86
- if (kept.length === 0 && !fs.existsSync(targetPath)) {
87
- console.log(`${TAG} No shared suppression applies to this repo. ${FILE_NAME} not created.`)
88
- process.exit(0)
89
- }
90
- const keptIds = kept.flatMap((entry) => entry.ghsas).filter((ghsa) => reported.has(ghsa))
91
- console.log(
92
- kept.length > 0
93
- ? `${TAG} Keeping ${kept.length} entr${kept.length === 1 ? 'y' : 'ies'}: ${keptIds.join(', ')}`
94
- : `${TAG} No shared suppression applies to this repo. Writing ${FILE_NAME} without entries.`,
95
- )
96
- }
97
-
98
- fs.writeFileSync(targetPath, content)
99
- console.log(`${TAG} Updated ${FILE_NAME}.`)
100
-
101
- if (!fileDiffersFromHead(FILE_NAME, GIT)) {
102
- console.log(`${TAG} No changes to commit.`)
103
- process.exit(0)
104
- }
105
-
106
- const stage = runGit(['add', '--', FILE_NAME], GIT)
107
- if (stage.status !== 0) process.exit(stage.status)
108
-
109
- console.log(`${TAG} Auto-committing: ${COMMIT_MESSAGE}`)
110
- process.exit(runGit(['commit', '-m', COMMIT_MESSAGE], GIT).status)