@shopify/shop-minis-cli 0.3.11 → 0.3.12
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/build/commands/create/examples/default/AGENTS.md +4 -0
- package/build/commands/setup/index.js +4 -0
- package/build/commands/setup/index.js.map +1 -1
- package/build/commands/verify-build/index.d.ts +2 -0
- package/build/commands/verify-build/index.js +14 -0
- package/build/commands/verify-build/index.js.map +1 -0
- package/build/data/mini-create.d.ts +1 -0
- package/build/data/mini-create.js +1 -0
- package/build/data/mini-create.js.map +1 -1
- package/build/data/mini.d.ts +1 -0
- package/build/data/mini.js +1 -0
- package/build/data/mini.js.map +1 -1
- package/build/data/types/autogenerated/shop-minis-admin-api/gql.d.ts +4 -4
- package/build/data/types/autogenerated/shop-minis-admin-api/gql.js +2 -2
- package/build/data/types/autogenerated/shop-minis-admin-api/gql.js.map +1 -1
- package/build/data/types/autogenerated/shop-minis-admin-api/graphql.d.ts +30 -0
- package/build/data/types/autogenerated/shop-minis-admin-api/graphql.js +2 -0
- package/build/data/types/autogenerated/shop-minis-admin-api/graphql.js.map +1 -1
- package/build/program.js +2 -0
- package/build/program.js.map +1 -1
- package/build/utils/allowed-dependencies.d.ts +7 -4
- package/build/utils/allowed-dependencies.js +8 -2
- package/build/utils/allowed-dependencies.js.map +1 -1
- package/build/utils/analytics.js +3 -1
- package/build/utils/analytics.js.map +1 -1
- package/build/utils/mini-uuid-cache.d.ts +2 -0
- package/build/utils/mini-uuid-cache.js +22 -0
- package/build/utils/mini-uuid-cache.js.map +1 -0
- package/build/utils/vite-config.d.ts +10 -0
- package/build/utils/vite-config.js +52 -0
- package/build/utils/vite-config.js.map +1 -1
- package/build/utils/worker-assets.d.ts +33 -0
- package/build/utils/worker-assets.js +239 -0
- package/build/utils/worker-assets.js.map +1 -0
- package/package.json +1 -1
- package/scripts/audit-dependencies.ts +419 -152
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* npx tsx audit-dependencies.ts # Audit new dependencies from git diff
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {execFile} from 'child_process'
|
|
10
10
|
import {promisify} from 'util'
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
import semver from 'semver'
|
|
13
|
+
|
|
14
|
+
const execFileAsync = promisify(execFile)
|
|
13
15
|
|
|
14
16
|
interface PackageInfo {
|
|
15
17
|
package: string
|
|
@@ -19,45 +21,182 @@ interface PackageInfo {
|
|
|
19
21
|
publishDate: string
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
type ChangeKind = 'added' | 'bumped'
|
|
25
|
+
|
|
26
|
+
// OSV's `database_specific.severity` uses GHSA labels (LOW/MODERATE/HIGH/
|
|
27
|
+
// CRITICAL). Some older OSV records (e.g. PYSEC) omit the field; those are
|
|
28
|
+
// tracked as UNKNOWN.
|
|
29
|
+
type Severity = 'CRITICAL' | 'HIGH' | 'MODERATE' | 'LOW' | 'UNKNOWN'
|
|
30
|
+
|
|
31
|
+
const SEVERITY_ORDER: Severity[] = [
|
|
32
|
+
'CRITICAL',
|
|
33
|
+
'HIGH',
|
|
34
|
+
'MODERATE',
|
|
35
|
+
'LOW',
|
|
36
|
+
'UNKNOWN',
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
interface VulnerabilityRecord {
|
|
40
|
+
id: string
|
|
41
|
+
summary: string
|
|
42
|
+
severity: Severity
|
|
43
|
+
}
|
|
44
|
+
|
|
22
45
|
interface DependencyInfo {
|
|
23
46
|
name: string
|
|
24
47
|
version: string
|
|
48
|
+
kind: ChangeKind
|
|
49
|
+
previousVersion?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Use Node's built-in fetch instead of shelling out to curl: keeps all input
|
|
53
|
+
// (package names, URLs, POST bodies) out of `/bin/sh -c`, so a malicious diff
|
|
54
|
+
// entry can't inject shell commands. 30s timeout per request via AbortSignal.
|
|
55
|
+
const FETCH_TIMEOUT_MS = 30_000
|
|
56
|
+
|
|
57
|
+
async function fetchJson(url: string, postBody?: unknown): Promise<any> {
|
|
58
|
+
let response: Response
|
|
59
|
+
try {
|
|
60
|
+
response = await fetch(url, {
|
|
61
|
+
method: postBody ? 'POST' : 'GET',
|
|
62
|
+
headers: postBody ? {'Content-Type': 'application/json'} : undefined,
|
|
63
|
+
body: postBody ? JSON.stringify(postBody) : undefined,
|
|
64
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
65
|
+
})
|
|
66
|
+
} catch (error: any) {
|
|
67
|
+
throw new Error(`Network error fetching ${url}: ${error?.message || error}`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
const body = await response.text().catch(() => '')
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Non-2xx response from ${url}: HTTP ${response.status}${
|
|
74
|
+
body ? ` — ${body.slice(0, 200)}` : ''
|
|
75
|
+
}`
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const text = await response.text()
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(text)
|
|
82
|
+
} catch (error: any) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Invalid JSON from ${url}: ${error?.message || error} (body: ${text.slice(0, 200)})`
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeSeverity(raw: unknown): Severity {
|
|
90
|
+
if (typeof raw !== 'string') return 'UNKNOWN'
|
|
91
|
+
const upper = raw.toUpperCase()
|
|
92
|
+
if (
|
|
93
|
+
upper === 'CRITICAL' ||
|
|
94
|
+
upper === 'HIGH' ||
|
|
95
|
+
upper === 'MODERATE' ||
|
|
96
|
+
upper === 'LOW'
|
|
97
|
+
) {
|
|
98
|
+
return upper
|
|
99
|
+
}
|
|
100
|
+
return 'UNKNOWN'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Convert an OSV `affected.ranges[]` entry to a semver range string. OSV uses
|
|
104
|
+
// an event-stream representation: each "introduced" opens an interval, each
|
|
105
|
+
// "fixed" or "last_affected" closes it. Returns null for unsupported types
|
|
106
|
+
// (e.g. GIT ranges) or empty event lists.
|
|
107
|
+
function osvRangeToSemver(range: any): string | null {
|
|
108
|
+
if (range?.type === 'GIT') return null
|
|
109
|
+
const events: any[] = Array.isArray(range?.events) ? range.events : []
|
|
110
|
+
const parts: string[] = []
|
|
111
|
+
let lower: string | null = null
|
|
112
|
+
for (const event of events) {
|
|
113
|
+
if (event.introduced !== undefined) {
|
|
114
|
+
lower = event.introduced === '0' ? null : event.introduced
|
|
115
|
+
} else if (event.fixed !== undefined) {
|
|
116
|
+
parts.push(`${lower ? `>=${lower} ` : ''}<${event.fixed}`)
|
|
117
|
+
lower = null
|
|
118
|
+
} else if (event.last_affected !== undefined) {
|
|
119
|
+
parts.push(`${lower ? `>=${lower} ` : ''}<=${event.last_affected}`)
|
|
120
|
+
lower = null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (lower !== null) parts.push(`>=${lower}`)
|
|
124
|
+
if (
|
|
125
|
+
lower === null &&
|
|
126
|
+
parts.length === 0 &&
|
|
127
|
+
events.some(event => event.introduced)
|
|
128
|
+
) {
|
|
129
|
+
// "introduced: 0" with no closer → all versions affected.
|
|
130
|
+
return '>=0.0.0-0'
|
|
131
|
+
}
|
|
132
|
+
return parts.length > 0 ? parts.join(' || ') : null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// True if `vuln` reports the given package as affected at `spec` (either an
|
|
136
|
+
// exact version or a semver range). Used to drop OSV results that match the
|
|
137
|
+
// package by name but only affect other versions.
|
|
138
|
+
function osvAffectsSpec(vuln: any, packageName: string, spec: string): boolean {
|
|
139
|
+
const affectedEntries = Array.isArray(vuln?.affected) ? vuln.affected : []
|
|
140
|
+
const exact = semver.valid(spec)
|
|
141
|
+
for (const affected of affectedEntries) {
|
|
142
|
+
if (affected?.package?.name !== packageName) continue
|
|
143
|
+
const ecosystem = affected?.package?.ecosystem
|
|
144
|
+
if (ecosystem && ecosystem !== 'npm') continue
|
|
145
|
+
|
|
146
|
+
if (Array.isArray(affected.versions) && affected.versions.length > 0) {
|
|
147
|
+
if (exact) {
|
|
148
|
+
if (affected.versions.includes(exact)) return true
|
|
149
|
+
} else {
|
|
150
|
+
for (const version of affected.versions) {
|
|
151
|
+
if (semver.satisfies(version, spec)) return true
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const range of affected.ranges || []) {
|
|
157
|
+
const vulnRange = osvRangeToSemver(range)
|
|
158
|
+
if (!vulnRange) continue
|
|
159
|
+
try {
|
|
160
|
+
if (exact) {
|
|
161
|
+
if (semver.satisfies(exact, vulnRange)) return true
|
|
162
|
+
} else if (semver.intersects(spec, vulnRange)) {
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
// bad range — fall through
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return false
|
|
25
171
|
}
|
|
26
172
|
|
|
27
173
|
class PackageAuditor {
|
|
28
174
|
private packageName: string
|
|
29
175
|
private version: string
|
|
176
|
+
private kind: ChangeKind
|
|
177
|
+
private previousVersion?: string
|
|
30
178
|
private riskScore = 0
|
|
31
179
|
private riskFactors: string[] = []
|
|
32
|
-
private vulnerabilities:
|
|
33
|
-
|
|
34
|
-
constructor(
|
|
180
|
+
private vulnerabilities: VulnerabilityRecord[] = []
|
|
181
|
+
|
|
182
|
+
constructor(
|
|
183
|
+
packageName: string,
|
|
184
|
+
version = 'latest',
|
|
185
|
+
kind: ChangeKind = 'added',
|
|
186
|
+
previousVersion?: string
|
|
187
|
+
) {
|
|
35
188
|
this.packageName = packageName
|
|
36
189
|
this.version = version
|
|
190
|
+
this.kind = kind
|
|
191
|
+
this.previousVersion = previousVersion
|
|
37
192
|
}
|
|
38
193
|
|
|
39
194
|
private async fetchJson(url: string): Promise<any> {
|
|
40
|
-
|
|
41
|
-
const {stdout} = await execAsync(`curl -s "${url}"`, {
|
|
42
|
-
maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large NPM responses
|
|
43
|
-
})
|
|
44
|
-
return JSON.parse(stdout)
|
|
45
|
-
} catch (error) {
|
|
46
|
-
throw new Error(`Failed to fetch ${url}: ${error}`)
|
|
47
|
-
}
|
|
195
|
+
return fetchJson(url)
|
|
48
196
|
}
|
|
49
197
|
|
|
50
198
|
private async fetchPostJson(url: string, body: any): Promise<any> {
|
|
51
|
-
|
|
52
|
-
const jsonBody = JSON.stringify(body)
|
|
53
|
-
const {stdout} = await execAsync(
|
|
54
|
-
`curl -s -X POST -H "Content-Type: application/json" -d '${jsonBody}' "${url}"`,
|
|
55
|
-
{maxBuffer: 1024 * 1024 * 10} // 10MB buffer for large responses
|
|
56
|
-
)
|
|
57
|
-
return JSON.parse(stdout)
|
|
58
|
-
} catch (error) {
|
|
59
|
-
throw new Error(`Failed to POST to ${url}: ${error}`)
|
|
60
|
-
}
|
|
199
|
+
return fetchJson(url, body)
|
|
61
200
|
}
|
|
62
201
|
|
|
63
202
|
private async fetchNpmData(): Promise<any> {
|
|
@@ -105,14 +244,22 @@ class PackageAuditor {
|
|
|
105
244
|
}
|
|
106
245
|
}
|
|
107
246
|
|
|
108
|
-
|
|
247
|
+
// Returns null when the downloads endpoint is unreachable so the caller can
|
|
248
|
+
// skip adoption scoring rather than reject the audit. Downloads are
|
|
249
|
+
// telemetry, not a security signal — an npmjs.org outage shouldn't block
|
|
250
|
+
// safe allowlist changes when OSV (the real gate) is healthy.
|
|
251
|
+
private async fetchDownloads(): Promise<number | null> {
|
|
109
252
|
try {
|
|
110
253
|
const data = await this.fetchJson(
|
|
111
254
|
`https://api.npmjs.org/downloads/point/last-week/${this.packageName}`
|
|
112
255
|
)
|
|
113
256
|
return data.downloads || 0
|
|
114
|
-
} catch {
|
|
115
|
-
|
|
257
|
+
} catch (error) {
|
|
258
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
259
|
+
console.error(
|
|
260
|
+
`⚠️ Download stats unavailable for ${this.packageName}: ${message}`
|
|
261
|
+
)
|
|
262
|
+
return null
|
|
116
263
|
}
|
|
117
264
|
}
|
|
118
265
|
|
|
@@ -149,67 +296,68 @@ class PackageAuditor {
|
|
|
149
296
|
}
|
|
150
297
|
|
|
151
298
|
private async checkPublicVulnerabilities(): Promise<void> {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
299
|
+
// OSV is the single source of vulnerability data. Errors here propagate up
|
|
300
|
+
// so the script fails loudly rather than silently reporting "no vulns
|
|
301
|
+
// found" on a network blip.
|
|
302
|
+
//
|
|
303
|
+
// The NPM advisories search endpoint and the Snyk test endpoint were
|
|
304
|
+
// previously consulted here but were removed: NPM's endpoint returns
|
|
305
|
+
// HTTP 404 ("does not exist"), and Snyk requires an auth token that
|
|
306
|
+
// isn't provisioned in CI. Both silently returned zero results.
|
|
307
|
+
//
|
|
308
|
+
// Choice of OSV as sole source matches BumperBot's ADR 008-vulnerability-source.md
|
|
309
|
+
// in shop/world (OSV aggregates GHSA, so direct GHSA integration is dedup churn).
|
|
310
|
+
const osvData = await this.fetchPostJson(`https://api.osv.dev/v1/query`, {
|
|
311
|
+
package: {name: this.packageName, ecosystem: 'npm'},
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
if (!osvData?.vulns?.length) return
|
|
315
|
+
|
|
316
|
+
// OSV's query endpoint accepts an optional `version` field that would
|
|
317
|
+
// filter server-side, but `this.version` is often a constraint string
|
|
318
|
+
// (e.g. `^1.2.3`) coming from `allowed-dependencies.ts`, which OSV won't
|
|
319
|
+
// resolve. Instead query without a version and filter client-side using
|
|
320
|
+
// each vuln's `affected[].ranges` so a safe bump isn't rejected because
|
|
321
|
+
// some unrelated release of the same package is vulnerable.
|
|
322
|
+
const affectingVulns = osvData.vulns.filter((vuln: any) =>
|
|
323
|
+
osvAffectsSpec(vuln, this.packageName, this.version)
|
|
324
|
+
)
|
|
325
|
+
if (affectingVulns.length === 0) return
|
|
326
|
+
|
|
327
|
+
this.vulnerabilities = affectingVulns.map((vuln: any) => ({
|
|
328
|
+
id: vuln.id,
|
|
329
|
+
summary: vuln.summary || 'Vulnerability',
|
|
330
|
+
severity: normalizeSeverity(vuln.database_specific?.severity),
|
|
331
|
+
}))
|
|
332
|
+
|
|
333
|
+
const counts = this.severityCounts()
|
|
334
|
+
const summary = SEVERITY_ORDER.filter(sev => counts[sev] > 0)
|
|
335
|
+
.map(sev => `${counts[sev]} ${sev}`)
|
|
336
|
+
.join(', ')
|
|
337
|
+
this.riskFactors.push(
|
|
338
|
+
`${this.vulnerabilities.length} security vulnerabilities found (${summary})`
|
|
339
|
+
)
|
|
340
|
+
// Weighted by severity so a single CRITICAL trips REJECT (score > 49).
|
|
341
|
+
// Reject threshold is 50, so CRITICAL alone is enough; two HIGH or four
|
|
342
|
+
// MODERATE also reach it. Unweighted counts let a single CRITICAL slip
|
|
343
|
+
// through as APPROVE, which is the bug binks-code-reviewer flagged.
|
|
344
|
+
const weighted =
|
|
345
|
+
counts.CRITICAL * 50 + counts.HIGH * 25 + counts.MODERATE * 5 + counts.LOW
|
|
346
|
+
this.riskScore += Math.min(weighted, 100)
|
|
347
|
+
}
|
|
196
348
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// Snyk API might require auth or have rate limits - continue without it
|
|
349
|
+
private severityCounts(): Record<Severity, number> {
|
|
350
|
+
const counts: Record<Severity, number> = {
|
|
351
|
+
CRITICAL: 0,
|
|
352
|
+
HIGH: 0,
|
|
353
|
+
MODERATE: 0,
|
|
354
|
+
LOW: 0,
|
|
355
|
+
UNKNOWN: 0,
|
|
205
356
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (totalVulnCount > 0) {
|
|
209
|
-
this.riskScore += Math.min(totalVulnCount * 5, 50)
|
|
210
|
-
this.riskFactors.push(`${totalVulnCount} security vulnerabilities found`)
|
|
211
|
-
this.vulnerabilities = vulnerabilities
|
|
357
|
+
for (const vuln of this.vulnerabilities) {
|
|
358
|
+
counts[vuln.severity]++
|
|
212
359
|
}
|
|
360
|
+
return counts
|
|
213
361
|
}
|
|
214
362
|
|
|
215
363
|
private assessMaintenanceRisk(publishDate: string): void {
|
|
@@ -228,7 +376,11 @@ class PackageAuditor {
|
|
|
228
376
|
}
|
|
229
377
|
}
|
|
230
378
|
|
|
231
|
-
private assessAdoptionRisk(downloads: number): void {
|
|
379
|
+
private assessAdoptionRisk(downloads: number | null): void {
|
|
380
|
+
if (downloads === null) {
|
|
381
|
+
this.riskFactors.push('Download stats unavailable (not scored)')
|
|
382
|
+
return
|
|
383
|
+
}
|
|
232
384
|
if (downloads < 1000) {
|
|
233
385
|
this.riskScore += 25
|
|
234
386
|
this.riskFactors.push('Low adoption (<1K downloads/week)')
|
|
@@ -250,18 +402,58 @@ class PackageAuditor {
|
|
|
250
402
|
return '❌ REJECT'
|
|
251
403
|
}
|
|
252
404
|
|
|
253
|
-
private
|
|
405
|
+
private getChangeLabel(): string {
|
|
406
|
+
if (this.kind === 'bumped' && this.previousVersion) {
|
|
407
|
+
return `🔁 Bumped from ${this.previousVersion}`
|
|
408
|
+
}
|
|
409
|
+
return '➕ Added'
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Sort vulns worst-first and show the top N; if more exist, summarize the
|
|
413
|
+
// tail by severity so a reviewer can see what's hidden.
|
|
414
|
+
private formatTopVulnerabilities(limit: number): string {
|
|
415
|
+
const sorted = [...this.vulnerabilities].sort(
|
|
416
|
+
(left, right) =>
|
|
417
|
+
SEVERITY_ORDER.indexOf(left.severity) -
|
|
418
|
+
SEVERITY_ORDER.indexOf(right.severity)
|
|
419
|
+
)
|
|
420
|
+
const lines = sorted
|
|
421
|
+
.slice(0, limit)
|
|
422
|
+
.map(vuln => ` - [${vuln.severity}] OSV: ${vuln.id} - ${vuln.summary}`)
|
|
423
|
+
if (sorted.length <= limit) return lines.join('\n')
|
|
424
|
+
|
|
425
|
+
const tailCounts: Record<Severity, number> = {
|
|
426
|
+
CRITICAL: 0,
|
|
427
|
+
HIGH: 0,
|
|
428
|
+
MODERATE: 0,
|
|
429
|
+
LOW: 0,
|
|
430
|
+
UNKNOWN: 0,
|
|
431
|
+
}
|
|
432
|
+
for (const vuln of sorted.slice(limit)) tailCounts[vuln.severity]++
|
|
433
|
+
const tailSummary = SEVERITY_ORDER.filter(sev => tailCounts[sev] > 0)
|
|
434
|
+
.map(sev => `${tailCounts[sev]} ${sev}`)
|
|
435
|
+
.join(', ')
|
|
436
|
+
return `${lines.join('\n')}\n - ... and ${
|
|
437
|
+
sorted.length - limit
|
|
438
|
+
} more (${tailSummary})`
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private generateReport(
|
|
442
|
+
packageInfo: PackageInfo,
|
|
443
|
+
downloads: number | null
|
|
444
|
+
): void {
|
|
254
445
|
const riskLevel = this.getRiskLevel()
|
|
255
446
|
const decision = this.getDecision()
|
|
256
|
-
const formattedDownloads =
|
|
447
|
+
const formattedDownloads =
|
|
448
|
+
downloads === null ? 'unavailable' : `${downloads.toLocaleString()}/week`
|
|
257
449
|
|
|
258
450
|
console.log(`
|
|
259
451
|
### 📦 ${packageInfo.package}@${packageInfo.version}
|
|
260
452
|
|
|
261
|
-
**${riskLevel}** | **${decision}**
|
|
453
|
+
**${this.getChangeLabel()}** | **${riskLevel}** | **${decision}**
|
|
262
454
|
|
|
263
|
-
📈 **Downloads:** ${formattedDownloads}
|
|
264
|
-
🔗 **Dependencies:** ${packageInfo.dependenciesCount}
|
|
455
|
+
📈 **Downloads:** ${formattedDownloads}
|
|
456
|
+
🔗 **Dependencies:** ${packageInfo.dependenciesCount}
|
|
265
457
|
📊 **Risk Score:** ${this.riskScore}/100
|
|
266
458
|
|
|
267
459
|
${
|
|
@@ -276,14 +468,7 @@ ${
|
|
|
276
468
|
this.vulnerabilities.length > 0
|
|
277
469
|
? `🚨 **Security Vulnerabilities (${
|
|
278
470
|
this.vulnerabilities.length
|
|
279
|
-
}):**\n${this.
|
|
280
|
-
.slice(0, 3)
|
|
281
|
-
.map(vuln => ` - ${vuln}`)
|
|
282
|
-
.join('\n')}${
|
|
283
|
-
this.vulnerabilities.length > 3
|
|
284
|
-
? `\n - ... and ${this.vulnerabilities.length - 3} more`
|
|
285
|
-
: ''
|
|
286
|
-
}`
|
|
471
|
+
}):**\n${this.formatTopVulnerabilities(3)}`
|
|
287
472
|
: '🛡️ **Security:** No vulnerabilities found'
|
|
288
473
|
}
|
|
289
474
|
|
|
@@ -291,7 +476,10 @@ ${
|
|
|
291
476
|
`)
|
|
292
477
|
}
|
|
293
478
|
|
|
294
|
-
|
|
479
|
+
// Returns true on success, false if the audit failed (e.g. network error).
|
|
480
|
+
// On failure, emits a ❌ REJECT report block so the failure is visible in
|
|
481
|
+
// the PR comment and trips the CI grep for `❌ REJECT`.
|
|
482
|
+
async audit(): Promise<boolean> {
|
|
295
483
|
try {
|
|
296
484
|
const npmData = await this.fetchNpmData()
|
|
297
485
|
const packageInfo = this.extractPackageInfo(npmData)
|
|
@@ -302,74 +490,151 @@ ${
|
|
|
302
490
|
this.assessAdoptionRisk(downloads)
|
|
303
491
|
|
|
304
492
|
this.generateReport(packageInfo, downloads)
|
|
493
|
+
return true
|
|
305
494
|
} catch (error) {
|
|
306
|
-
|
|
307
|
-
|
|
495
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
496
|
+
console.error(`❌ Audit failed for ${this.packageName}: ${message}`)
|
|
497
|
+
console.log(`
|
|
498
|
+
### 📦 ${this.packageName}@${this.version}
|
|
499
|
+
|
|
500
|
+
**${this.getChangeLabel()}** | **🔴 HIGH/CRITICAL** | **❌ REJECT**
|
|
501
|
+
|
|
502
|
+
🚨 **Audit could not complete:**
|
|
503
|
+
\`\`\`
|
|
504
|
+
${message}
|
|
505
|
+
\`\`\`
|
|
506
|
+
|
|
507
|
+
---
|
|
508
|
+
`)
|
|
509
|
+
return false
|
|
308
510
|
}
|
|
309
511
|
}
|
|
310
512
|
}
|
|
311
513
|
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
514
|
+
// Parse a `+`/`-` diff line for an entry like: '@scope/pkg': '1.2.3',
|
|
515
|
+
// `\s+` (not `.*`) anchors the indent so that an unquoted key like
|
|
516
|
+
// `three: '0.178.0'` isn't eaten by a greedy prefix.
|
|
517
|
+
//
|
|
518
|
+
// Both the name and version captures restrict to characters that are valid
|
|
519
|
+
// in npm package names and semver ranges. This is defense-in-depth: the
|
|
520
|
+
// captured strings are passed to fetch URLs and to the npm registry, never
|
|
521
|
+
// to a shell, but rejecting `$(){};|&\`` here keeps the parser honest.
|
|
522
|
+
const NAME_CHARS = 'a-zA-Z0-9@._/~^*+-'
|
|
523
|
+
const VERSION_CHARS = 'a-zA-Z0-9.<>=~^*+|& \\-'
|
|
524
|
+
const DEP_LINE_REGEX = new RegExp(
|
|
525
|
+
`^[+-]\\s+(?:['"\`]([${NAME_CHARS}]+)['"\`]|([${NAME_CHARS}]+))\\s*:\\s*['"\`]([${VERSION_CHARS}]+)['"\`]`
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
function parseDepLine(
|
|
529
|
+
line: string
|
|
530
|
+
): {name: string; version: string} | undefined {
|
|
531
|
+
const match = line.match(DEP_LINE_REGEX)
|
|
532
|
+
if (!match) return undefined
|
|
533
|
+
const name = match[1] || match[2]
|
|
534
|
+
const version = match[3]
|
|
535
|
+
if (!name || !version) return undefined
|
|
536
|
+
return {name, version}
|
|
537
|
+
}
|
|
319
538
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
539
|
+
// Refs come from env (or default constants), but git treats refs starting
|
|
540
|
+
// with `-` as flags. Reject anything that doesn't look like a ref to avoid
|
|
541
|
+
// surprises (e.g. `--upload-pack=…` style argv injection).
|
|
542
|
+
const REF_REGEX = /^[A-Za-z0-9_./~^@-]+$/
|
|
543
|
+
function validateRef(ref: string, label: string): string {
|
|
544
|
+
if (ref.startsWith('-') || !REF_REGEX.test(ref)) {
|
|
545
|
+
throw new Error(`Invalid ${label} ref: ${ref}`)
|
|
546
|
+
}
|
|
547
|
+
return ref
|
|
548
|
+
}
|
|
325
549
|
|
|
326
|
-
|
|
550
|
+
async function getChangedDependencies(): Promise<DependencyInfo[]> {
|
|
551
|
+
// Allow CI to pass the PR's base and head refs so the diff covers every
|
|
552
|
+
// commit in the PR, not just the last one. Fall back to HEAD~1..HEAD for
|
|
553
|
+
// local use.
|
|
554
|
+
const baseRef = validateRef(
|
|
555
|
+
process.env.AUDIT_BASE_REF || 'HEAD~1',
|
|
556
|
+
'AUDIT_BASE_REF'
|
|
557
|
+
)
|
|
558
|
+
const headRef = validateRef(
|
|
559
|
+
process.env.AUDIT_HEAD_REF || 'HEAD',
|
|
560
|
+
'AUDIT_HEAD_REF'
|
|
561
|
+
)
|
|
327
562
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
563
|
+
// `base...head` (three-dot) diffs against the merge-base of the two refs,
|
|
564
|
+
// not the current base tip. That keeps the audit stable when the base
|
|
565
|
+
// branch advances after the PR diverged: unrelated edits to
|
|
566
|
+
// allowed-dependencies.ts landing on main after this PR forked won't
|
|
567
|
+
// show up here.
|
|
568
|
+
const {stdout} = await execFileAsync(
|
|
569
|
+
'git',
|
|
570
|
+
[
|
|
571
|
+
'diff',
|
|
572
|
+
`${baseRef}...${headRef}`,
|
|
573
|
+
'--',
|
|
574
|
+
'src/utils/allowed-dependencies.ts',
|
|
575
|
+
],
|
|
576
|
+
{maxBuffer: 1024 * 1024}
|
|
577
|
+
)
|
|
332
578
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
const version = match[3]
|
|
336
|
-
addedDependencies.push({name: packageName, version})
|
|
337
|
-
}
|
|
338
|
-
}
|
|
579
|
+
const removed = new Map<string, string>()
|
|
580
|
+
const added = new Map<string, string>()
|
|
339
581
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
582
|
+
for (const line of stdout.split('\n')) {
|
|
583
|
+
if (line.startsWith('---') || line.startsWith('+++')) continue
|
|
584
|
+
const parsed = parseDepLine(line)
|
|
585
|
+
if (!parsed) continue
|
|
586
|
+
if (line.startsWith('-')) removed.set(parsed.name, parsed.version)
|
|
587
|
+
else if (line.startsWith('+')) added.set(parsed.name, parsed.version)
|
|
344
588
|
}
|
|
345
|
-
}
|
|
346
589
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
-
'## Dependency Audit Results\n✅ No new dependencies found - no audit needed.'
|
|
355
|
-
)
|
|
356
|
-
return
|
|
590
|
+
const changes: DependencyInfo[] = []
|
|
591
|
+
for (const [name, version] of added) {
|
|
592
|
+
const previousVersion = removed.get(name)
|
|
593
|
+
if (previousVersion === undefined) {
|
|
594
|
+
changes.push({name, version, kind: 'added'})
|
|
595
|
+
} else if (previousVersion !== version) {
|
|
596
|
+
changes.push({name, version, kind: 'bumped', previousVersion})
|
|
357
597
|
}
|
|
598
|
+
// Same name and version on both sides = unchanged, skip.
|
|
599
|
+
}
|
|
600
|
+
return changes
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Returns true if every audit succeeded.
|
|
604
|
+
async function auditChangedDependencies(): Promise<boolean> {
|
|
605
|
+
const depsToAudit = await getChangedDependencies()
|
|
358
606
|
|
|
607
|
+
if (depsToAudit.length === 0) {
|
|
359
608
|
console.log(
|
|
360
|
-
|
|
361
|
-
depsToAudit.length === 1 ? 'dependency' : 'dependencies'
|
|
362
|
-
}:\n`
|
|
609
|
+
'## Dependency Audit Results\n✅ No dependency changes detected — no audit needed.'
|
|
363
610
|
)
|
|
611
|
+
return true
|
|
612
|
+
}
|
|
364
613
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
614
|
+
const addedCount = depsToAudit.filter(dep => dep.kind === 'added').length
|
|
615
|
+
const bumpedCount = depsToAudit.length - addedCount
|
|
616
|
+
const summaryParts: string[] = []
|
|
617
|
+
if (addedCount) summaryParts.push(`${addedCount} added`)
|
|
618
|
+
if (bumpedCount) summaryParts.push(`${bumpedCount} version-bumped`)
|
|
619
|
+
|
|
620
|
+
console.log(
|
|
621
|
+
`## Dependency Audit Results\n🔍 Audited ${depsToAudit.length} ${
|
|
622
|
+
depsToAudit.length === 1 ? 'change' : 'changes'
|
|
623
|
+
} (${summaryParts.join(', ')}):\n`
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
let allOk = true
|
|
627
|
+
for (const dep of depsToAudit) {
|
|
628
|
+
const auditor = new PackageAuditor(
|
|
629
|
+
dep.name,
|
|
630
|
+
dep.version,
|
|
631
|
+
dep.kind,
|
|
632
|
+
dep.previousVersion
|
|
633
|
+
)
|
|
634
|
+
const ok = await auditor.audit()
|
|
635
|
+
if (!ok) allOk = false
|
|
372
636
|
}
|
|
637
|
+
return allOk
|
|
373
638
|
}
|
|
374
639
|
|
|
375
640
|
// CLI interface
|
|
@@ -392,8 +657,9 @@ async function main(): Promise<void> {
|
|
|
392
657
|
const args = process.argv.slice(2)
|
|
393
658
|
|
|
394
659
|
if (args.length === 0) {
|
|
395
|
-
// Mode: Audit new dependencies from git diff
|
|
396
|
-
await
|
|
660
|
+
// Mode: Audit new/changed dependencies from git diff
|
|
661
|
+
const ok = await auditChangedDependencies()
|
|
662
|
+
if (!ok) process.exit(1)
|
|
397
663
|
} else if (args[0] === '--help' || args[0] === '-h') {
|
|
398
664
|
showUsage()
|
|
399
665
|
} else {
|
|
@@ -401,7 +667,8 @@ async function main(): Promise<void> {
|
|
|
401
667
|
const packageName = args[0]
|
|
402
668
|
const version = args[1] || 'latest'
|
|
403
669
|
const auditor = new PackageAuditor(packageName, version)
|
|
404
|
-
await auditor.audit()
|
|
670
|
+
const ok = await auditor.audit()
|
|
671
|
+
if (!ok) process.exit(1)
|
|
405
672
|
}
|
|
406
673
|
}
|
|
407
674
|
|