@zairakai/js-dev-tools 3.7.1 → 3.9.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.
|
@@ -473,7 +473,23 @@ deps:update:
|
|
|
473
473
|
- '[ "${JS_DEV_TOOLS_SCRIPTS}" = "scripts" ] || npm install --no-save --no-audit --no-fund "@zairakai/js-dev-tools@latest"'
|
|
474
474
|
- node "${JS_DEV_TOOLS_SCRIPTS}/update-dependencies.mjs" update
|
|
475
475
|
rules:
|
|
476
|
-
- if: $UPDATE_DEPENDENCIES == "true" && $CI_COMMIT_BRANCH == "develop"
|
|
476
|
+
- if: $UPDATE_DEPENDENCIES == "true" && $AUDIT_DEPENDENCIES != "true" && $CI_COMMIT_BRANCH == "develop"
|
|
477
|
+
|
|
478
|
+
# A daily schedule with UPDATE_DEPENDENCIES=true and AUDIT_DEPENDENCIES=true looks for security advisories: one issue per
|
|
479
|
+
# advisory (ntfy alert, closed when it is gone) and, with the group variable AUTO_FIX=true, an update started to fix it.
|
|
480
|
+
# The token FRESHNESS_TOKEN of the group (else GITLAB_TOKEN) opens the issues and starts the update; NTFY_URL is the alert.
|
|
481
|
+
deps:audit:
|
|
482
|
+
<<: *base_job
|
|
483
|
+
stage: install
|
|
484
|
+
image: ${NODE_UPDATE_IMAGE}
|
|
485
|
+
needs: []
|
|
486
|
+
script:
|
|
487
|
+
- node --version
|
|
488
|
+
- "[ -d node_modules ] || npm ci --no-audit --no-fund"
|
|
489
|
+
- '[ "${JS_DEV_TOOLS_SCRIPTS}" = "scripts" ] || npm install --no-save --no-audit --no-fund "@zairakai/js-dev-tools@latest"'
|
|
490
|
+
- node "${JS_DEV_TOOLS_SCRIPTS}/update-dependencies.mjs" audit
|
|
491
|
+
rules:
|
|
492
|
+
- if: $UPDATE_DEPENDENCIES == "true" && $AUDIT_DEPENDENCIES == "true" && $CI_COMMIT_BRANCH == "develop"
|
|
477
493
|
|
|
478
494
|
# After the merge of an update into develop, bring develop into main.
|
|
479
495
|
deps:release:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zairakai/js-dev-tools",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.0",
|
|
4
4
|
"description": "The unified and centralized development toolkit for JavaScript/TypeScript projects - ESLint, Prettier, Stylelint, Knip, Vitest, Git Hooks, and GitLab CI pipeline templates",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dev-tools",
|
|
@@ -88,15 +88,15 @@
|
|
|
88
88
|
"validate": "make quality"
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
|
-
"@stanislas-poisson/js-dev-tools": "
|
|
92
|
-
"eslint": "^10.
|
|
93
|
-
"markdownlint-cli2": "^0.
|
|
91
|
+
"@stanislas-poisson/js-dev-tools": "~1.0.0",
|
|
92
|
+
"eslint": "^10.13.0",
|
|
93
|
+
"markdownlint-cli2": "^0.24.0",
|
|
94
94
|
"prettier": "^3.9.10",
|
|
95
95
|
"stylelint": "^17.16.0"
|
|
96
96
|
},
|
|
97
97
|
"devDependencies": {
|
|
98
98
|
"@vitest/coverage-v8": "^5.0.5",
|
|
99
|
-
"knip": "^6.
|
|
99
|
+
"knip": "^6.43.0",
|
|
100
100
|
"sort-package-json": "^4.1.0",
|
|
101
101
|
"typescript": "^6.0.3",
|
|
102
102
|
"vitest": "^5.0.5",
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Automatic updates of the dependencies of a package of the zairakai group.
|
|
4
4
|
*
|
|
5
|
-
* Usage: node update-dependencies.mjs update | release | tag | cascade
|
|
5
|
+
* Usage: node update-dependencies.mjs update | release | tag | cascade | audit
|
|
6
6
|
*
|
|
7
7
|
* - update: prepare a branch with the minor and patch updates, open a merge request into develop and merge it when its
|
|
8
8
|
* pipeline succeeds. The pipeline of the merge request is the gate: nothing is tested here.
|
|
9
9
|
* - release: open the merge request of develop into main after an update, and merge it when its pipeline succeeds.
|
|
10
10
|
* - tag: tag main when a runtime range changed since the last tag (see `tag` in .dependency-update.json).
|
|
11
11
|
* - cascade: after the publication of this package, start the update of the projects that use it.
|
|
12
|
+
* - audit: look for security advisories, open one issue per advisory and, with AUTO_FIX=true, start an update to fix them
|
|
13
|
+
* (UPDATE_URGENT=true: the new versions are taken without waiting, and `npm audit fix` is run).
|
|
12
14
|
*
|
|
13
15
|
* It reads `.dependency-update.json` at the root of the project (everything is optional):
|
|
14
16
|
*
|
|
@@ -19,7 +21,8 @@
|
|
|
19
21
|
* "cascade": { "groups": ["zairakai/npm-packages"] } projects to start (opt-in: they have the same file)
|
|
20
22
|
* }
|
|
21
23
|
*
|
|
22
|
-
* Environment: GITLAB_TOKEN (group variable), and the variables of GitLab CI.
|
|
24
|
+
* Environment: GITLAB_TOKEN (group variable), and the variables of GitLab CI. The audit also reads FRESHNESS_TOKEN (else
|
|
25
|
+
* GITLAB_TOKEN), NTFY_URL and AUTO_FIX.
|
|
23
26
|
*/
|
|
24
27
|
import { execFileSync } from 'node:child_process'
|
|
25
28
|
import { existsSync, readFileSync } from 'node:fs'
|
|
@@ -29,6 +32,8 @@ const CONFIG_FILE = '.dependency-update.json'
|
|
|
29
32
|
const PIPELINE_FILE = '.gitlab-ci.yml'
|
|
30
33
|
const COMMIT_SUBJECT = 'chore(deps): update the dependencies'
|
|
31
34
|
const BRANCH_PREFIX = 'chore/update-dependencies'
|
|
35
|
+
const ADVISORY_TITLE_PREFIX = 'Security advisory: '
|
|
36
|
+
const ISSUE_LABELS = 'Kind::Security,Area::Security,Automated'
|
|
32
37
|
|
|
33
38
|
const DEFAULTS = {
|
|
34
39
|
reject: ['typescript'],
|
|
@@ -163,14 +168,75 @@ export function isRefOnlyChange(diff) {
|
|
|
163
168
|
return 2 === changed.length && changed.every((line) => /^[+-]\s*ref:\s*\S+\s*$/.test(line))
|
|
164
169
|
}
|
|
165
170
|
|
|
171
|
+
/** The id of an advisory: the last part of its URL (GHSA-xxxx-xxxx-xxxx), else the number npm gives it. */
|
|
172
|
+
function advisoryId(via) {
|
|
173
|
+
const fromUrl = 'string' === typeof via.url ? via.url.split('/').filter(Boolean).pop() : ''
|
|
174
|
+
|
|
175
|
+
return fromUrl || String(via.source ?? '')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The advisories of the JSON report of `npm audit`, one row per advisory and package, sorted. */
|
|
179
|
+
export function parseNpmAudit(report) {
|
|
180
|
+
const rows = new Map()
|
|
181
|
+
|
|
182
|
+
for (const entry of Object.values(report?.vulnerabilities ?? {})) {
|
|
183
|
+
for (const via of entry.via ?? []) {
|
|
184
|
+
// A string only names another vulnerable package: the advisory itself is on that one.
|
|
185
|
+
if ('object' !== typeof via || null === via) continue
|
|
186
|
+
|
|
187
|
+
const row = {
|
|
188
|
+
id: advisoryId(via),
|
|
189
|
+
package: via.name ?? entry.name,
|
|
190
|
+
title: via.title ?? '',
|
|
191
|
+
link: via.url ?? '',
|
|
192
|
+
severity: String(via.severity ?? entry.severity ?? '').toLowerCase(),
|
|
193
|
+
range: via.range ?? '',
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
rows.set(`${row.package}|${row.id}`, row)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return [...rows.values()].sort((a, b) => `${a.package}|${a.id}`.localeCompare(`${b.package}|${b.id}`))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The title of the issue of an advisory. It is also the key that keeps the issue from being opened twice. */
|
|
204
|
+
export function advisoryTitle(row) {
|
|
205
|
+
return `${ADVISORY_TITLE_PREFIX}${row.package} (${row.id})`
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Whether an advisory is fixed without waiting for the next weekly update. An unknown severity counts as urgent. */
|
|
209
|
+
export function isUrgent(severity) {
|
|
210
|
+
return ['', 'high', 'critical'].includes(severity)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The body of the issue opened for an advisory. */
|
|
214
|
+
export function advisoryIssueBody(row, pipelineUrl, autoFix) {
|
|
215
|
+
return [
|
|
216
|
+
`${row.title}.`,
|
|
217
|
+
'',
|
|
218
|
+
`- Package: \`${row.package}\``,
|
|
219
|
+
`- Affected versions: \`${row.range || 'unknown'}\``,
|
|
220
|
+
`- Severity: ${row.severity || 'not given'}`,
|
|
221
|
+
`- Advisory: ${row.link || row.id}`,
|
|
222
|
+
`- Automatic fix: ${autoFix && isUrgent(row.severity) ? 'started (an update of the dependencies, taken without waiting)' : 'off for this advisory: update by hand'}`,
|
|
223
|
+
'',
|
|
224
|
+
`Pipeline: ${pipelineUrl}`,
|
|
225
|
+
'',
|
|
226
|
+
'The issue is closed by the next audit that no longer finds this advisory.',
|
|
227
|
+
].join('\n')
|
|
228
|
+
}
|
|
229
|
+
|
|
166
230
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
167
231
|
|
|
168
232
|
const log = (message) => console.info(message)
|
|
169
233
|
|
|
170
234
|
function secret(text) {
|
|
171
|
-
|
|
235
|
+
return ['GITLAB_TOKEN', 'FRESHNESS_TOKEN'].reduce((masked, name) => {
|
|
236
|
+
const token = process.env[name]
|
|
172
237
|
|
|
173
|
-
|
|
238
|
+
return token ? masked.split(token).join('***') : masked
|
|
239
|
+
}, String(text))
|
|
174
240
|
}
|
|
175
241
|
|
|
176
242
|
function run(command, args = [], options = {}) {
|
|
@@ -181,10 +247,25 @@ function run(command, args = [], options = {}) {
|
|
|
181
247
|
}
|
|
182
248
|
}
|
|
183
249
|
|
|
184
|
-
|
|
250
|
+
/** The output of a command that exits with 1 when it finds something (npm audit): only a missing output is a failure. */
|
|
251
|
+
function capture(command, args = []) {
|
|
252
|
+
try {
|
|
253
|
+
return execFileSync(command, args, {
|
|
254
|
+
encoding: 'utf8',
|
|
255
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
256
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
257
|
+
})
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error.stdout) return error.stdout
|
|
260
|
+
|
|
261
|
+
throw new Error(secret(`${command} ${args.join(' ')} failed: ${error.stderr || error.message}`), { cause: error })
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function api(method, path, body, token = process.env.GITLAB_TOKEN) {
|
|
185
266
|
const response = await fetch(`${process.env.CI_API_V4_URL ?? 'https://gitlab.com/api/v4'}${path}`, {
|
|
186
267
|
method,
|
|
187
|
-
headers: { 'PRIVATE-TOKEN':
|
|
268
|
+
headers: { 'PRIVATE-TOKEN': token ?? '', 'Content-Type': 'application/json' },
|
|
188
269
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
189
270
|
})
|
|
190
271
|
const text = await response.text()
|
|
@@ -336,9 +417,19 @@ async function update(config) {
|
|
|
336
417
|
if ('package.json' === ecosystem.file) {
|
|
337
418
|
// The new versions wait for the cooling period. The packages of the group are trusted: no waiting for them.
|
|
338
419
|
const reject = 0 < config.reject.length ? ['--reject', config.reject.join(',')] : []
|
|
339
|
-
|
|
420
|
+
// An update started by the audit (UPDATE_URGENT=true) fixes an advisory: the fix is not made to wait.
|
|
421
|
+
const urgent = 'true' === process.env.UPDATE_URGENT
|
|
422
|
+
ncu([...(urgent ? [] : ['--cooldown', config.cooldown]), ...reject])
|
|
340
423
|
ncu(['--filter', '@zairakai/*', ...reject])
|
|
341
424
|
run('npm', ['install', '--no-audit', '--no-fund'])
|
|
425
|
+
|
|
426
|
+
if (urgent) {
|
|
427
|
+
try {
|
|
428
|
+
run('npm', ['audit', 'fix', '--no-fund'])
|
|
429
|
+
} catch (error) {
|
|
430
|
+
log(`npm audit fix did not apply: ${secret(error.message).split('\n')[0]}`)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
342
433
|
}
|
|
343
434
|
|
|
344
435
|
const after = JSON.parse(readFileSync(ecosystem.file, 'utf8'))
|
|
@@ -510,11 +601,133 @@ async function cascade(config) {
|
|
|
510
601
|
}
|
|
511
602
|
}
|
|
512
603
|
|
|
513
|
-
|
|
604
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
605
|
+
|
|
606
|
+
async function issueExists(title, token) {
|
|
607
|
+
const query = new URLSearchParams({ state: 'opened', search: title, in: 'title' })
|
|
608
|
+
const result = await api('GET', `/projects/${projectId()}/issues?${query}`, undefined, token)
|
|
609
|
+
|
|
610
|
+
return (Array.isArray(result.data) ? result.data : []).some((issue) => issue.title === title)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function createIssue(title, description, token) {
|
|
614
|
+
// The assignee is given by the variable ISSUE_ASSIGNEE_ID; without it, the user of the token.
|
|
615
|
+
let assignee = Number(process.env.ISSUE_ASSIGNEE_ID) || 0
|
|
616
|
+
|
|
617
|
+
if (0 === assignee) assignee = (await api('GET', '/user', undefined, token)).data?.id ?? 0
|
|
618
|
+
|
|
619
|
+
const issue = await api(
|
|
620
|
+
'POST',
|
|
621
|
+
`/projects/${projectId()}/issues`,
|
|
622
|
+
{ title, description, labels: ISSUE_LABELS, assignee_ids: 0 < assignee ? [assignee] : [] },
|
|
623
|
+
token
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
if (!issue.ok) throw new Error(`The issue could not be created: ${secret(JSON.stringify(issue.data))}`)
|
|
627
|
+
|
|
628
|
+
return issue.data.web_url
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Close the open issues of the audit whose advisory is not found any more. */
|
|
632
|
+
async function closeResolved(titles, token) {
|
|
633
|
+
const query = new URLSearchParams({
|
|
634
|
+
state: 'opened',
|
|
635
|
+
labels: 'Automated',
|
|
636
|
+
search: ADVISORY_TITLE_PREFIX,
|
|
637
|
+
in: 'title',
|
|
638
|
+
per_page: '100',
|
|
639
|
+
})
|
|
640
|
+
const result = await api('GET', `/projects/${projectId()}/issues?${query}`, undefined, token)
|
|
641
|
+
|
|
642
|
+
for (const issue of Array.isArray(result.data) ? result.data : []) {
|
|
643
|
+
if (!issue.title.startsWith(ADVISORY_TITLE_PREFIX) || titles.includes(issue.title)) continue
|
|
644
|
+
|
|
645
|
+
const path = `/projects/${projectId()}/issues/${issue.iid}`
|
|
646
|
+
await api(
|
|
647
|
+
'POST',
|
|
648
|
+
`${path}/notes`,
|
|
649
|
+
{ body: `The audit of ${process.env.CI_PIPELINE_URL} no longer finds this advisory.` },
|
|
650
|
+
token
|
|
651
|
+
)
|
|
652
|
+
const closed = await api('PUT', path, { state_event: 'close' }, token)
|
|
653
|
+
log(closed.ok ? `Issue closed: ${issue.title}` : `Issue not closed (${closed.status}): ${issue.title}`)
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** A push notification through ntfy, when NTFY_URL is set. A failure never stops the audit. */
|
|
658
|
+
async function notify(title, message) {
|
|
659
|
+
if (!process.env.NTFY_URL) return
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
await fetch(process.env.NTFY_URL, {
|
|
663
|
+
method: 'POST',
|
|
664
|
+
headers: { Title: title },
|
|
665
|
+
body: message,
|
|
666
|
+
signal: AbortSignal.timeout(15000),
|
|
667
|
+
})
|
|
668
|
+
} catch {
|
|
669
|
+
log('The ntfy alert could not be sent.')
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function audit() {
|
|
674
|
+
assertEnvironment()
|
|
675
|
+
|
|
676
|
+
const found = parseNpmAudit(JSON.parse(capture('npm', ['audit', '--json'])))
|
|
677
|
+
const auto = 'true' === process.env.AUTO_FIX
|
|
678
|
+
const token = process.env.FRESHNESS_TOKEN || process.env.GITLAB_TOKEN
|
|
679
|
+
const titles = []
|
|
680
|
+
let urgent = false
|
|
681
|
+
|
|
682
|
+
log(`${found.length} security advisory(ies) found.`)
|
|
683
|
+
|
|
684
|
+
for (const row of found) {
|
|
685
|
+
const title = advisoryTitle(row)
|
|
686
|
+
titles.push(title)
|
|
687
|
+
urgent ||= isUrgent(row.severity)
|
|
688
|
+
|
|
689
|
+
if (await issueExists(title, token)) {
|
|
690
|
+
log(`Issue already open: ${title}`)
|
|
691
|
+
continue
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const url = await createIssue(title, advisoryIssueBody(row, process.env.CI_PIPELINE_URL ?? '', auto), token)
|
|
695
|
+
log(`Issue created: ${url}`)
|
|
696
|
+
await notify(title, `${process.env.CI_PROJECT_PATH}: ${url}`)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
await closeResolved(titles, token)
|
|
700
|
+
|
|
701
|
+
if (!auto || !urgent) {
|
|
702
|
+
log(auto ? 'Nothing urgent: the weekly update takes the rest.' : 'AUTO_FIX is off: update by hand.')
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if ((await openMergeRequests()).some((request) => request.source_branch.startsWith(BRANCH_PREFIX))) {
|
|
707
|
+
log('An update is already open: nothing to start.')
|
|
708
|
+
return
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const started = await api(
|
|
712
|
+
'POST',
|
|
713
|
+
`/projects/${projectId()}/pipeline`,
|
|
714
|
+
{
|
|
715
|
+
ref: 'develop',
|
|
716
|
+
variables: [
|
|
717
|
+
{ key: 'UPDATE_DEPENDENCIES', value: 'true' },
|
|
718
|
+
{ key: 'UPDATE_URGENT', value: 'true' },
|
|
719
|
+
],
|
|
720
|
+
},
|
|
721
|
+
token
|
|
722
|
+
)
|
|
723
|
+
log(started.ok ? 'Update started.' : `The update could not be started (${started.status}).`)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const modes = { update, release, tag, cascade, audit }
|
|
514
727
|
|
|
515
728
|
async function main(mode) {
|
|
516
729
|
if (!modes[mode]) {
|
|
517
|
-
console.error('Usage: node update-dependencies.mjs update | release | tag | cascade')
|
|
730
|
+
console.error('Usage: node update-dependencies.mjs update | release | tag | cascade | audit')
|
|
518
731
|
process.exitCode = 1
|
|
519
732
|
return
|
|
520
733
|
}
|