@openreachtech/mentsu-npm-audit-gate 0.0.0-stage → 0.1.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.
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Judge an `npm audit` report, deliberately letting through the advisories
3
+ * the caller lists, as a stopgap until a release fixes them.
4
+ *
5
+ * It fails on an advisory the list does not name, at any severity,
6
+ * on an advisory the list names that is no longer reported,
7
+ * and on an `npm audit` that answers with an error instead of a report.
8
+ */
9
+ export default class AuditReportResponse {
10
+ /**
11
+ * @param {{
12
+ * ignoredAdvisories: Array<NpmAuditType.IgnoredAdvisory>
13
+ * report: AuditReport | null
14
+ * }} params - Parameters of this constructor.
15
+ */
16
+ constructor ({
17
+ ignoredAdvisories,
18
+ report,
19
+ }) {
20
+ this.ignoredAdvisories = ignoredAdvisories
21
+ this.report = report
22
+ }
23
+
24
+ /**
25
+ * Factory method.
26
+ *
27
+ * @template {X extends typeof AuditReportResponse ? X : never} T, X
28
+ * @param {{
29
+ * ignoredAdvisories: Array<NpmAuditType.IgnoredAdvisory>
30
+ * report: AuditReport | null
31
+ * }} params - Parameters for the factory method.
32
+ * @returns {InstanceType<T>} Instance of this class.
33
+ * @this {T}
34
+ * @public
35
+ */
36
+ static create ({
37
+ ignoredAdvisories,
38
+ report,
39
+ }) {
40
+ return /** @type {InstanceType<T>} */ (
41
+ new this({
42
+ ignoredAdvisories,
43
+ report,
44
+ })
45
+ )
46
+ }
47
+
48
+ /**
49
+ * get: Name of the file listing the advisories to let through.
50
+ *
51
+ * @returns {string} File name.
52
+ */
53
+ static get ignoredAdvisoriesFileName () {
54
+ return 'IGNORED_ADVISORIES.js'
55
+ }
56
+
57
+ /**
58
+ * get: Pattern picking a GHSA id off the end of an advisory url.
59
+ *
60
+ * @returns {RegExp} Pattern with the named group `id`.
61
+ */
62
+ static get advisoryIdPattern () {
63
+ return /(?<id>GHSA(?:-[0-9a-z]{4}){3})$/u
64
+ }
65
+
66
+ /**
67
+ * Extract the GHSA id of an advisory.
68
+ *
69
+ * @param {{
70
+ * advisory: AuditAdvisory
71
+ * }} params - Parameters.
72
+ * @returns {string} GHSA id, or the whole url where it carries none.
73
+ */
74
+ static extractAdvisoryId ({
75
+ advisory,
76
+ }) {
77
+ const matched = advisory.url.match(this.advisoryIdPattern)
78
+
79
+ return matched?.groups
80
+ ?.id
81
+ ?? advisory.url
82
+ }
83
+
84
+ /**
85
+ * get: Constructor of this class.
86
+ *
87
+ * @returns {typeof AuditReportResponse} Constructor of this class.
88
+ */
89
+ get Ctor () {
90
+ return /** @type {typeof AuditReportResponse} */ (this.constructor)
91
+ }
92
+
93
+ /**
94
+ * Check whether the report can be judged.
95
+ *
96
+ * @returns {BooleanLike} Whether the report carries vulnerabilities and no error.
97
+ * @public
98
+ */
99
+ hasUsableReport () {
100
+ if (
101
+ !this.report
102
+ || this.report.error
103
+ ) {
104
+ return false
105
+ }
106
+
107
+ return this.report.vulnerabilities
108
+ ?? false
109
+ }
110
+
111
+ /**
112
+ * Build the line saying no report came back.
113
+ *
114
+ * @returns {string} Line to print.
115
+ * @public
116
+ */
117
+ buildNoReportMessage () {
118
+ const error = this.report?.error
119
+ ?? null
120
+
121
+ return `🤔 npm audit answered with no report, so nothing was checked: ${JSON.stringify(error)}`
122
+ }
123
+
124
+ /**
125
+ * Build the lines naming each listed advisory that is still reported.
126
+ *
127
+ * @returns {Array<string>} Lines to print.
128
+ * @public
129
+ */
130
+ buildIgnoredLines () {
131
+ return this.collectMetIgnores()
132
+ .flatMap(it => [
133
+ `⚠️ ignored: ${it.id} (${it.package}) via ${it.path}`,
134
+ ` reason: ${it.reason}`,
135
+ ` remove when: ${it.removeWhen}`,
136
+ ])
137
+ }
138
+
139
+ /**
140
+ * Collect the listed advisories that are still reported.
141
+ *
142
+ * @returns {Array<NpmAuditType.IgnoredAdvisory>} Advisories of the list.
143
+ */
144
+ collectMetIgnores () {
145
+ return this.ignoredAdvisories
146
+ .filter(it => this.isReported({
147
+ id: it.id,
148
+ }))
149
+ }
150
+
151
+ /**
152
+ * Check whether the report names an advisory.
153
+ *
154
+ * @param {{
155
+ * id: string
156
+ * }} params - Parameters.
157
+ * @returns {BooleanLike} Whether the advisory is reported.
158
+ */
159
+ isReported ({
160
+ id,
161
+ }) {
162
+ return this.collectAdvisoryIds()
163
+ .includes(id)
164
+ }
165
+
166
+ /**
167
+ * Collect the ids of the advisories the report names, each once.
168
+ *
169
+ * @returns {Array<string>} GHSA ids in the order they first appear.
170
+ */
171
+ collectAdvisoryIds () {
172
+ const advisoryIds = this.collectAdvisories()
173
+ .map(it => this.Ctor.extractAdvisoryId({
174
+ advisory: it,
175
+ }))
176
+
177
+ return [
178
+ ...new Set(advisoryIds),
179
+ ]
180
+ }
181
+
182
+ /**
183
+ * Collect the advisories the report names, as often as it names them.
184
+ *
185
+ * @returns {Array<AuditAdvisory>} Advisories of the report.
186
+ */
187
+ collectAdvisories () {
188
+ const vulnerabilities = this.report?.vulnerabilities
189
+ ?? {}
190
+
191
+ return Object.values(vulnerabilities)
192
+ .flatMap(it => it.via)
193
+ .filter(it => typeof it === 'object')
194
+ }
195
+
196
+ /**
197
+ * Build the lines naming each failure.
198
+ *
199
+ * @returns {Array<string>} Lines to print.
200
+ * @public
201
+ */
202
+ buildFailureLines () {
203
+ return [
204
+ ...this.buildUnignoredLines(),
205
+ ...this.buildStaleLines(),
206
+ ]
207
+ }
208
+
209
+ /**
210
+ * Build the lines naming each reported advisory the list does not name.
211
+ *
212
+ * @returns {Array<string>} Lines to print.
213
+ */
214
+ buildUnignoredLines () {
215
+ return this.collectAdvisoryIds()
216
+ .filter(it => !this.isIgnored({
217
+ id: it,
218
+ }))
219
+ .map(it => this.buildUnignoredLine({
220
+ id: it,
221
+ }))
222
+ }
223
+
224
+ /**
225
+ * Check whether the list names an advisory.
226
+ *
227
+ * @param {{
228
+ * id: string
229
+ * }} params - Parameters.
230
+ * @returns {BooleanLike} Whether the advisory is listed.
231
+ */
232
+ isIgnored ({
233
+ id,
234
+ }) {
235
+ return this.ignoredAdvisories
236
+ .some(it => it.id === id)
237
+ }
238
+
239
+ /**
240
+ * Build the line naming a reported advisory the list does not name.
241
+ *
242
+ * @param {{
243
+ * id: string
244
+ * }} params - Parameters.
245
+ * @returns {string} Line to print.
246
+ */
247
+ buildUnignoredLine ({
248
+ id,
249
+ }) {
250
+ // The id comes from the report itself, so the advisory is always found.
251
+ const advisory = /** @type {AuditAdvisory} */ (
252
+ this.findAdvisory({
253
+ id,
254
+ })
255
+ )
256
+
257
+ return `🚨 not ignored: ${id} ${advisory.severity} ${advisory.name} ${advisory.range} — ${advisory.title}`
258
+ }
259
+
260
+ /**
261
+ * Find the advisory of an id, taking the last one the report names under it.
262
+ *
263
+ * @param {{
264
+ * id: string
265
+ * }} params - Parameters.
266
+ * @returns {AuditAdvisory | null} Advisory, or null where the report names none.
267
+ */
268
+ findAdvisory ({
269
+ id,
270
+ }) {
271
+ return this.collectAdvisories()
272
+ .findLast(it => this.Ctor.extractAdvisoryId({
273
+ advisory: it,
274
+ }) === id)
275
+ ?? null
276
+ }
277
+
278
+ /**
279
+ * Build the lines naming each listed advisory that is no longer reported.
280
+ *
281
+ * @returns {Array<string>} Lines to print.
282
+ */
283
+ buildStaleLines () {
284
+ return this.ignoredAdvisories
285
+ .filter(it => !this.isReported({
286
+ id: it.id,
287
+ }))
288
+ .map(it => `🗑️ stale ignore: ${it.id} (${it.package}) is no longer reported; remove it from ${this.Ctor.ignoredAdvisoriesFileName}`)
289
+ }
290
+
291
+ /**
292
+ * Build the line summing up the check.
293
+ *
294
+ * @returns {string} Line to print.
295
+ * @public
296
+ */
297
+ buildSummaryLine () {
298
+ const advisoryCount = this.countAdvisories()
299
+ const metIgnoreCount = this.countMetIgnores()
300
+ const failureCount = this.countFailures()
301
+
302
+ return `checked ${advisoryCount} advisory(ies), ${metIgnoreCount} ignored, ${failureCount} failure(s)`
303
+ }
304
+
305
+ /**
306
+ * Count the advisories the report names, each once.
307
+ *
308
+ * @returns {number} Number of advisories.
309
+ */
310
+ countAdvisories () {
311
+ return this.collectAdvisoryIds().length
312
+ }
313
+
314
+ /**
315
+ * Count the listed advisories that are still reported.
316
+ *
317
+ * @returns {number} Number of advisories.
318
+ */
319
+ countMetIgnores () {
320
+ return this.collectMetIgnores().length
321
+ }
322
+
323
+ /**
324
+ * Count the failures.
325
+ *
326
+ * @returns {number} Number of failures.
327
+ * @public
328
+ */
329
+ countFailures () {
330
+ return this.buildFailureLines().length
331
+ }
332
+ }
333
+
334
+ /**
335
+ * @typedef {*} BooleanLike
336
+ */
337
+
338
+ /**
339
+ * @import {
340
+ * AuditAdvisory,
341
+ * AuditReport,
342
+ * } from './AuditFetcher.js'
343
+ */
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Exit codes a process running `NpmAuditGate` ends with.
3
+ *
4
+ * @type {{
5
+ * SUCCESS: number
6
+ * FAILURE: number
7
+ * }}
8
+ */
9
+ const EXIT_CODE = {
10
+ SUCCESS: 0,
11
+ FAILURE: 1,
12
+ }
13
+
14
+ export default EXIT_CODE
@@ -0,0 +1,96 @@
1
+ import nodeUrl from 'node:url'
2
+
3
+ /**
4
+ * Load the advisories a repository lets through, from the file it keeps them in.
5
+ *
6
+ * The file is the only thing an instance holds, and its path is absolute.
7
+ */
8
+ export default class IgnoredAdvisoriesLoader {
9
+ /**
10
+ * @param {{
11
+ * ignoredAdvisoriesFilePath: string
12
+ * }} params - Parameters of this constructor.
13
+ */
14
+ constructor ({
15
+ ignoredAdvisoriesFilePath,
16
+ }) {
17
+ this.ignoredAdvisoriesFilePath = ignoredAdvisoriesFilePath
18
+ }
19
+
20
+ /**
21
+ * Factory method.
22
+ *
23
+ * @template {X extends typeof IgnoredAdvisoriesLoader ? X : never} T, X
24
+ * @param {{
25
+ * ignoredAdvisoriesFilePath: string
26
+ * }} params - Parameters for the factory method.
27
+ * @returns {InstanceType<T>} Instance of this class.
28
+ * @this {T}
29
+ * @public
30
+ */
31
+ static create ({
32
+ ignoredAdvisoriesFilePath,
33
+ }) {
34
+ return /** @type {InstanceType<T>} */ (
35
+ new this({
36
+ ignoredAdvisoriesFilePath,
37
+ })
38
+ )
39
+ }
40
+
41
+ /**
42
+ * get: `node:url` module.
43
+ *
44
+ * @returns {typeof nodeUrl} `node:url` module.
45
+ */
46
+ static get nodeUrl () {
47
+ return nodeUrl
48
+ }
49
+
50
+ /**
51
+ * get: Constructor of this class.
52
+ *
53
+ * @returns {typeof IgnoredAdvisoriesLoader} Constructor of this class.
54
+ */
55
+ get Ctor () {
56
+ return /** @type {typeof IgnoredAdvisoriesLoader} */ (this.constructor)
57
+ }
58
+
59
+ /**
60
+ * Load the ignored advisories.
61
+ *
62
+ * @returns {Promise<Array<NpmAuditType.IgnoredAdvisory> | null>} Advisories, or null when the file cannot be loaded or its default export is not an array.
63
+ * @public
64
+ */
65
+ async loadAdvisories () {
66
+ try {
67
+ const url = this.Ctor.nodeUrl.pathToFileURL(this.ignoredAdvisoriesFilePath)
68
+
69
+ const exported = await this.invokeImport({
70
+ href: url.href,
71
+ })
72
+
73
+ if (!Array.isArray(exported.default)) {
74
+ return null
75
+ }
76
+
77
+ return exported.default
78
+ } catch (error) {
79
+ return null
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Import a module by the href of its URL.
85
+ *
86
+ * @param {{
87
+ * href: string
88
+ * }} params - Parameters.
89
+ * @returns {Promise<{ default?: * }>} Module namespace.
90
+ */
91
+ async invokeImport ({
92
+ href,
93
+ }) {
94
+ return import(href)
95
+ }
96
+ }
@@ -0,0 +1,179 @@
1
+ import AuditFetcher from './AuditFetcher.js'
2
+ import AuditReportResponse from './AuditReportResponse.js'
3
+ import EXIT_CODE from './EXIT_CODE.js'
4
+ import ProcessLogger from './ProcessLogger.js'
5
+
6
+ /**
7
+ * Fetch an `npm audit` report, judge it against the advisories
8
+ * the caller lists, and print the outcome.
9
+ */
10
+ export default class NpmAuditGate {
11
+ /**
12
+ * @param {{
13
+ * ignoredAdvisories: Array<NpmAuditType.IgnoredAdvisory>
14
+ * auditFetcher: AuditFetcher
15
+ * processLogger: ProcessLogger
16
+ * }} params - Parameters of this constructor.
17
+ */
18
+ constructor ({
19
+ ignoredAdvisories,
20
+ auditFetcher,
21
+ processLogger,
22
+ }) {
23
+ this.ignoredAdvisories = ignoredAdvisories
24
+ this.auditFetcher = auditFetcher
25
+ this.processLogger = processLogger
26
+ }
27
+
28
+ /**
29
+ * Factory method.
30
+ *
31
+ * @template {X extends typeof NpmAuditGate ? X : never} T, X
32
+ * @param {{
33
+ * ignoredAdvisories: Array<NpmAuditType.IgnoredAdvisory>
34
+ * auditFetcher?: AuditFetcher
35
+ * processLogger?: ProcessLogger
36
+ * }} params - Parameters for the factory method.
37
+ * @returns {InstanceType<T>} Instance of this class.
38
+ * @this {T}
39
+ * @public
40
+ */
41
+ static create ({
42
+ ignoredAdvisories,
43
+ auditFetcher = this.createAuditFetcher(),
44
+ processLogger = this.createProcessLogger(),
45
+ }) {
46
+ return /** @type {InstanceType<T>} */ (
47
+ new this({
48
+ ignoredAdvisories,
49
+ auditFetcher,
50
+ processLogger,
51
+ })
52
+ )
53
+ }
54
+
55
+ /**
56
+ * get: Constructor of `AuditFetcher`.
57
+ *
58
+ * @returns {typeof AuditFetcher} Constructor of `AuditFetcher`.
59
+ */
60
+ static get AuditFetcherCtor () {
61
+ return AuditFetcher
62
+ }
63
+
64
+ /**
65
+ * get: Constructor of `AuditReportResponse`.
66
+ *
67
+ * @returns {typeof AuditReportResponse} Constructor of `AuditReportResponse`.
68
+ */
69
+ static get AuditReportResponseCtor () {
70
+ return AuditReportResponse
71
+ }
72
+
73
+ /**
74
+ * get: Constructor of `ProcessLogger`.
75
+ *
76
+ * @returns {typeof ProcessLogger} Constructor of `ProcessLogger`.
77
+ */
78
+ static get ProcessLoggerCtor () {
79
+ return ProcessLogger
80
+ }
81
+
82
+ /**
83
+ * Create an `AuditFetcher`.
84
+ *
85
+ * @returns {AuditFetcher} Instance of `AuditFetcher`.
86
+ */
87
+ static createAuditFetcher () {
88
+ return this.AuditFetcherCtor.create()
89
+ }
90
+
91
+ /**
92
+ * Create an `AuditReportResponse`.
93
+ *
94
+ * @param {{
95
+ * ignoredAdvisories: Array<NpmAuditType.IgnoredAdvisory>
96
+ * report: AuditReport | null
97
+ * }} params - Parameters.
98
+ * @returns {AuditReportResponse} Instance of `AuditReportResponse`.
99
+ */
100
+ static createAuditReportResponse ({
101
+ ignoredAdvisories,
102
+ report,
103
+ }) {
104
+ return this.AuditReportResponseCtor.create({
105
+ ignoredAdvisories,
106
+ report,
107
+ })
108
+ }
109
+
110
+ /**
111
+ * Create a `ProcessLogger`.
112
+ *
113
+ * @returns {ProcessLogger} Instance of `ProcessLogger`.
114
+ */
115
+ static createProcessLogger () {
116
+ return this.ProcessLoggerCtor.create()
117
+ }
118
+
119
+ /**
120
+ * get: Constructor of this class.
121
+ *
122
+ * @returns {typeof NpmAuditGate} Constructor of this class.
123
+ */
124
+ get Ctor () {
125
+ return /** @type {typeof NpmAuditGate} */ (this.constructor)
126
+ }
127
+
128
+ /**
129
+ * Fetch the report, judge it against the list, and print the outcome.
130
+ *
131
+ * @returns {number} Exit code: `EXIT_CODE.SUCCESS` when nothing fails, otherwise `EXIT_CODE.FAILURE`.
132
+ * @public
133
+ */
134
+ auditAdvisories () {
135
+ const report = this.auditFetcher.fetchReport()
136
+ const auditReportResponse = this.Ctor.createAuditReportResponse({
137
+ ignoredAdvisories: this.ignoredAdvisories,
138
+ report,
139
+ })
140
+
141
+ if (!auditReportResponse.hasUsableReport()) {
142
+ this.processLogger.logError({
143
+ text: `${auditReportResponse.buildNoReportMessage()}\n`,
144
+ })
145
+
146
+ return EXIT_CODE.FAILURE
147
+ }
148
+
149
+ auditReportResponse.buildIgnoredLines()
150
+ .forEach(it => {
151
+ this.processLogger.logMessage({
152
+ text: `${it}\n`,
153
+ })
154
+ })
155
+ auditReportResponse.buildFailureLines()
156
+ .forEach(it => {
157
+ this.processLogger.logError({
158
+ text: `${it}\n`,
159
+ })
160
+ })
161
+ this.processLogger.logMessage({
162
+ text: `${auditReportResponse.buildSummaryLine()}\n`,
163
+ })
164
+
165
+ return auditReportResponse.countFailures() === 0
166
+ ? EXIT_CODE.SUCCESS
167
+ : EXIT_CODE.FAILURE
168
+ }
169
+ }
170
+
171
+ /**
172
+ * @typedef {*} BooleanLike
173
+ */
174
+
175
+ /**
176
+ * @import {
177
+ * AuditReport,
178
+ * } from './AuditFetcher.js'
179
+ */
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Resolve the arguments the current process was started with.
3
+ */
4
+ export default class ProcessArgumentsResolver {
5
+ /**
6
+ * @param {{
7
+ * actualArguments: Array<string>
8
+ * }} params - Parameters of this constructor.
9
+ */
10
+ constructor ({
11
+ actualArguments,
12
+ }) {
13
+ this.actualArguments = actualArguments
14
+ }
15
+
16
+ /**
17
+ * Factory method.
18
+ *
19
+ * The actual arguments are those that follow the script, so the executable and the script are left out.
20
+ *
21
+ * @template {X extends typeof ProcessArgumentsResolver ? X : never} T, X
22
+ * @param {{
23
+ * rawArguments: Array<string>
24
+ * }} params - Parameters for the factory method.
25
+ * @returns {InstanceType<T>} Instance of this class.
26
+ * @this {T}
27
+ * @public
28
+ */
29
+ static create ({
30
+ rawArguments,
31
+ }) {
32
+ const actualArguments = rawArguments.slice(2)
33
+
34
+ return /** @type {InstanceType<T>} */ (
35
+ new this({
36
+ actualArguments,
37
+ })
38
+ )
39
+ }
40
+
41
+ /**
42
+ * Extract the argument that names the file listing the ignored advisories.
43
+ *
44
+ * @returns {string | null} Argument, or null when it is not given.
45
+ * @public
46
+ */
47
+ extractIgnoredAdvisoriesFilePathArgument () {
48
+ return this.extractArgumentByIndex({
49
+ index: 0,
50
+ })
51
+ }
52
+
53
+ /**
54
+ * Extract the argument at an index.
55
+ *
56
+ * @param {{
57
+ * index: number
58
+ * }} params - Parameters.
59
+ * @returns {string | null} Argument, or null when there is none at the index.
60
+ */
61
+ extractArgumentByIndex ({
62
+ index,
63
+ }) {
64
+ return this.actualArguments.at(index)
65
+ ?? null
66
+ }
67
+ }