spectrum-cli 5.0.2 → 5.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.
- package/CHANGELOG.md +13 -0
- package/README.md +132 -1
- package/package.json +4 -2
- package/src/automation/index.js +297 -0
- package/src/cli/commands.js +79 -1
- package/src/cli/run-action.js +18 -2
- package/src/nextjs/index.js +184 -0
- package/src/preflight/automation.js +291 -0
- package/src/sbom/index.js +4 -0
- package/src/sbom/populate.js +607 -0
- package/src/sbom/provenance.js +467 -0
- package/src/sbom/resolve.js +418 -0
- package/src/sbom/schema.js +170 -0
- package/src/sbom/tools.js +74 -0
- package/src/security/findings.js +180 -0
- package/src/security/index.js +36 -0
- package/src/security/repair.js +553 -0
- package/src/security/report.js +65 -0
- package/src/security/runtime.js +192 -0
- package/src/security/scan.js +300 -0
- package/src/security/tracker.js +248 -0
- package/src/security/tracking.js +234 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
// Central catalogue writer. Protected pipeline/resource_group remain in CI.
|
|
2
|
+
const io = require("../automation");
|
|
3
|
+
const { isDryRun } = require("../cli/command-executor");
|
|
4
|
+
const { required, checkRegistryAccess } = require("../preflight/automation");
|
|
5
|
+
const {
|
|
6
|
+
fs,
|
|
7
|
+
path,
|
|
8
|
+
ensure,
|
|
9
|
+
sha,
|
|
10
|
+
json,
|
|
11
|
+
stable,
|
|
12
|
+
exec,
|
|
13
|
+
safePath,
|
|
14
|
+
SEMVER,
|
|
15
|
+
COMMIT,
|
|
16
|
+
DIGEST,
|
|
17
|
+
temporary,
|
|
18
|
+
save,
|
|
19
|
+
} = io;
|
|
20
|
+
const schema = require("./schema");
|
|
21
|
+
const {
|
|
22
|
+
GENERATION,
|
|
23
|
+
CONFIG_TYPE,
|
|
24
|
+
OCI_TYPE,
|
|
25
|
+
MAX_PAYLOAD,
|
|
26
|
+
encoded,
|
|
27
|
+
purl,
|
|
28
|
+
canonicalRepository,
|
|
29
|
+
validateManifest,
|
|
30
|
+
validateCatalog,
|
|
31
|
+
} = schema;
|
|
32
|
+
const provenance = require("./provenance");
|
|
33
|
+
const { Unresolved } = provenance;
|
|
34
|
+
const UPSTREAM = "https://github.com/vercel/next.js.git";
|
|
35
|
+
const MAX_FILE = 32 * 1024 * 1024,
|
|
36
|
+
MAX_TARBALL = 200 * 1024 * 1024,
|
|
37
|
+
MAX_HISTORY = 256;
|
|
38
|
+
function versions(value) {
|
|
39
|
+
const values = json(value);
|
|
40
|
+
ensure(
|
|
41
|
+
Array.isArray(values) && values.length >= 1 && values.length <= 10,
|
|
42
|
+
"next_versions must contain 1..10 exact versions",
|
|
43
|
+
);
|
|
44
|
+
ensure(
|
|
45
|
+
values.every(
|
|
46
|
+
(v) => typeof v === "string" && v.length <= 100 && SEMVER.test(v),
|
|
47
|
+
),
|
|
48
|
+
"Only exact npm semver versions are accepted",
|
|
49
|
+
);
|
|
50
|
+
return [...new Set(values)];
|
|
51
|
+
}
|
|
52
|
+
function publicationRepository(value) {
|
|
53
|
+
ensure(
|
|
54
|
+
typeof value === "string" && value.length <= 255,
|
|
55
|
+
"Invalid central publication namespace",
|
|
56
|
+
);
|
|
57
|
+
canonicalRepository(value);
|
|
58
|
+
const [host, ...parts] = value.split("/"),
|
|
59
|
+
parsed = new URL("https://" + host);
|
|
60
|
+
ensure(
|
|
61
|
+
!host.includes(":") ||
|
|
62
|
+
(Number(host.split(":")[1]) > 0 && Number(host.split(":")[1]) <= 65535),
|
|
63
|
+
"Invalid central registry port",
|
|
64
|
+
);
|
|
65
|
+
ensure(
|
|
66
|
+
parsed.hostname
|
|
67
|
+
.split(".")
|
|
68
|
+
.every((label) =>
|
|
69
|
+
/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?![\s\S])/.test(label),
|
|
70
|
+
),
|
|
71
|
+
"Invalid central registry host",
|
|
72
|
+
);
|
|
73
|
+
ensure(
|
|
74
|
+
parts.every((part) =>
|
|
75
|
+
/^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?![\s\S])/.test(part),
|
|
76
|
+
),
|
|
77
|
+
"Invalid central publication namespace segment",
|
|
78
|
+
);
|
|
79
|
+
return value + "/next";
|
|
80
|
+
}
|
|
81
|
+
function readTarball(data) {
|
|
82
|
+
const result = {};
|
|
83
|
+
for (const entry of io.tarEntries(data)) {
|
|
84
|
+
ensure(safePath(entry.name), "Unsafe archive path");
|
|
85
|
+
if (entry.name === "package" && entry.directory) continue;
|
|
86
|
+
ensure(entry.name.startsWith("package/"), "Unexpected npm archive prefix");
|
|
87
|
+
if (entry.directory) continue;
|
|
88
|
+
const relative = entry.name.slice("package/".length);
|
|
89
|
+
if (relative === "package.json" || relative.startsWith("dist/compiled/")) {
|
|
90
|
+
ensure(!Object.hasOwn(result, relative), "Duplicate archive path");
|
|
91
|
+
result[relative] = Buffer.from(entry.data);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
class Git {
|
|
97
|
+
constructor(directory, remaining = (limit) => limit) {
|
|
98
|
+
this.directory = directory;
|
|
99
|
+
this.remaining = remaining;
|
|
100
|
+
this.env = {
|
|
101
|
+
...process.env,
|
|
102
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
103
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
104
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
105
|
+
};
|
|
106
|
+
// Do not allow inherited injected config, helpers or transport overrides.
|
|
107
|
+
for (const key of Object.keys(this.env))
|
|
108
|
+
if (
|
|
109
|
+
/^GIT_CONFIG_(COUNT|KEY_|VALUE_)/.test(key) ||
|
|
110
|
+
["GIT_SSH_COMMAND", "GIT_PROXY_COMMAND"].includes(key)
|
|
111
|
+
)
|
|
112
|
+
delete this.env[key];
|
|
113
|
+
this.call("init", "--bare", directory);
|
|
114
|
+
this.call("remote", "add", "origin", UPSTREAM);
|
|
115
|
+
}
|
|
116
|
+
call(...args) {
|
|
117
|
+
const result = exec(
|
|
118
|
+
[
|
|
119
|
+
"git",
|
|
120
|
+
"-C",
|
|
121
|
+
this.directory,
|
|
122
|
+
"-c",
|
|
123
|
+
"core.hooksPath=/dev/null",
|
|
124
|
+
"-c",
|
|
125
|
+
"protocol.file.allow=never",
|
|
126
|
+
"-c",
|
|
127
|
+
"protocol.ext.allow=never",
|
|
128
|
+
...args,
|
|
129
|
+
],
|
|
130
|
+
{
|
|
131
|
+
env: this.env,
|
|
132
|
+
timeout: this.remaining(300000),
|
|
133
|
+
maxBuffer: MAX_FILE,
|
|
134
|
+
binary: true,
|
|
135
|
+
allowed: [0, 128],
|
|
136
|
+
label: "Upstream Git operation",
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
if (result.status) {
|
|
140
|
+
if (
|
|
141
|
+
args[0] === "show" &&
|
|
142
|
+
String(result.stderr).includes("does not exist")
|
|
143
|
+
)
|
|
144
|
+
return null;
|
|
145
|
+
throw new Error("Upstream Git operation failed: " + args[0]);
|
|
146
|
+
}
|
|
147
|
+
return result.stdout;
|
|
148
|
+
}
|
|
149
|
+
fetch(commit) {
|
|
150
|
+
ensure(COMMIT.test(commit), "Invalid Git commit");
|
|
151
|
+
this.call("fetch", "--no-tags", "--filter=blob:none", "origin", commit);
|
|
152
|
+
ensure(
|
|
153
|
+
String(this.call("rev-parse", commit + "^{commit}")).trim() === commit,
|
|
154
|
+
"Git commit mismatch",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
releaseCommit(version, npmCommit) {
|
|
158
|
+
if (npmCommit) return npmCommit;
|
|
159
|
+
const tag = "refs/tags/v" + version,
|
|
160
|
+
refs = {};
|
|
161
|
+
for (const row of String(
|
|
162
|
+
this.call("ls-remote", "--tags", UPSTREAM, tag, tag + "^{}"),
|
|
163
|
+
)
|
|
164
|
+
.trim()
|
|
165
|
+
.split("\n")) {
|
|
166
|
+
const [commit, ref] = row.split("\t");
|
|
167
|
+
refs[ref] = commit;
|
|
168
|
+
}
|
|
169
|
+
const commit = refs[tag + "^{}"] || refs[tag];
|
|
170
|
+
ensure(
|
|
171
|
+
COMMIT.test(commit),
|
|
172
|
+
"No exact official release tag for npm publication",
|
|
173
|
+
);
|
|
174
|
+
return commit;
|
|
175
|
+
}
|
|
176
|
+
show(commit, file) {
|
|
177
|
+
ensure(safePath(file), "Unsafe Git path");
|
|
178
|
+
return this.call("show", commit + ":" + file);
|
|
179
|
+
}
|
|
180
|
+
tree(commit, location) {
|
|
181
|
+
ensure(safePath(location), "Unsafe Git tree path");
|
|
182
|
+
const result = {};
|
|
183
|
+
for (const row of String(
|
|
184
|
+
this.call("ls-tree", "-r", "-z", commit, "--", location),
|
|
185
|
+
)
|
|
186
|
+
.split("\0")
|
|
187
|
+
.filter(Boolean)) {
|
|
188
|
+
const tab = row.indexOf("\t"),
|
|
189
|
+
[mode, kind, oid] = row.slice(0, tab).split(" "),
|
|
190
|
+
filename = row.slice(tab + 1);
|
|
191
|
+
if (kind !== "blob" || !["100644", "100755"].includes(mode))
|
|
192
|
+
throw new Unresolved("non-regular file in upstream compiled tree");
|
|
193
|
+
const relative = filename.slice(location.length + 1);
|
|
194
|
+
ensure(safePath(relative), "Unsafe Git filename");
|
|
195
|
+
if (Object.keys(result).length > 2048)
|
|
196
|
+
throw new Unresolved("compiled tree file limit exceeded");
|
|
197
|
+
result[relative] = this.call("cat-file", "blob", oid);
|
|
198
|
+
}
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
*history(commit, filename) {
|
|
202
|
+
const blocks = String(
|
|
203
|
+
this.call(
|
|
204
|
+
"log",
|
|
205
|
+
"--follow",
|
|
206
|
+
"--find-renames=100%",
|
|
207
|
+
"--format=COMMIT:%H",
|
|
208
|
+
"--name-status",
|
|
209
|
+
"--max-count=" + (MAX_HISTORY + 1),
|
|
210
|
+
commit,
|
|
211
|
+
"--",
|
|
212
|
+
filename,
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
.split("COMMIT:")
|
|
216
|
+
.slice(1);
|
|
217
|
+
if (blocks.length > MAX_HISTORY)
|
|
218
|
+
throw new Unresolved("compiled history limit exceeded");
|
|
219
|
+
for (const block of blocks) {
|
|
220
|
+
const [revision, ...rows] = block.trim().split("\n");
|
|
221
|
+
for (const row of rows) {
|
|
222
|
+
const fields = row.split("\t");
|
|
223
|
+
if (fields.length >= 2 && !fields[0].startsWith("R"))
|
|
224
|
+
yield [revision, fields.at(-1)];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
class Publisher {
|
|
230
|
+
constructor(env = process.env, deps = {}) {
|
|
231
|
+
this.env = env;
|
|
232
|
+
this.deps = deps;
|
|
233
|
+
this.repository = env.SBOM_CATALOG_PUBLISH_REPOSITORY
|
|
234
|
+
? env.SBOM_CATALOG_PUBLISH_REPOSITORY + "/next"
|
|
235
|
+
: null;
|
|
236
|
+
this.registryConfig = null;
|
|
237
|
+
this.deadline = Date.now() + 1500000;
|
|
238
|
+
}
|
|
239
|
+
remaining = (limit = 120000) => {
|
|
240
|
+
const left = this.deadline - Date.now();
|
|
241
|
+
ensure(left >= 1000, "Resolver time budget exhausted");
|
|
242
|
+
return Math.min(limit, left);
|
|
243
|
+
};
|
|
244
|
+
async download(url, limit) {
|
|
245
|
+
const parsed = new URL(url);
|
|
246
|
+
const authority = url.match(/^https:\/\/([^/?#]+)/i)?.[1];
|
|
247
|
+
ensure(
|
|
248
|
+
authority?.toLowerCase() === "registry.npmjs.org" &&
|
|
249
|
+
parsed.protocol === "https:" &&
|
|
250
|
+
parsed.hostname === "registry.npmjs.org" &&
|
|
251
|
+
!parsed.port &&
|
|
252
|
+
!parsed.username &&
|
|
253
|
+
!parsed.password &&
|
|
254
|
+
!/^https:\/\/registry\.npmjs\.org:\d/.test(url),
|
|
255
|
+
"Only the official npm registry is accepted",
|
|
256
|
+
);
|
|
257
|
+
const res = await (this.deps.request || io.request)(url, {
|
|
258
|
+
limit,
|
|
259
|
+
timeout: this.remaining(60000),
|
|
260
|
+
});
|
|
261
|
+
ensure(res.status === 200, "Unexpected npm status or redirect");
|
|
262
|
+
return res.body;
|
|
263
|
+
}
|
|
264
|
+
async officialPackage(version) {
|
|
265
|
+
const metadata = json(
|
|
266
|
+
await this.download(
|
|
267
|
+
"https://registry.npmjs.org/next/" + encoded(version),
|
|
268
|
+
MAX_PAYLOAD,
|
|
269
|
+
),
|
|
270
|
+
);
|
|
271
|
+
ensure(
|
|
272
|
+
metadata.name === "next" && metadata.version === version,
|
|
273
|
+
"npm metadata identity mismatch",
|
|
274
|
+
);
|
|
275
|
+
const commit = metadata.gitHead;
|
|
276
|
+
ensure(
|
|
277
|
+
commit == null || (typeof commit === "string" && COMMIT.test(commit)),
|
|
278
|
+
"Invalid official npm gitHead",
|
|
279
|
+
);
|
|
280
|
+
const dist = metadata.dist || {},
|
|
281
|
+
integrity = dist.integrity || "";
|
|
282
|
+
ensure(
|
|
283
|
+
/^sha512-[A-Za-z0-9+/]+={0,2}(?![\s\S])/.test(integrity),
|
|
284
|
+
"Official sha512 integrity required",
|
|
285
|
+
);
|
|
286
|
+
const archive = await this.download(dist.tarball, MAX_TARBALL);
|
|
287
|
+
ensure(
|
|
288
|
+
Buffer.from(sha(archive, "sha512"), "hex").toString("base64") ===
|
|
289
|
+
integrity.slice(7),
|
|
290
|
+
"npm tarball integrity mismatch",
|
|
291
|
+
);
|
|
292
|
+
const files = readTarball(archive),
|
|
293
|
+
manifest = json(files["package.json"]);
|
|
294
|
+
ensure(
|
|
295
|
+
manifest.name === "next" && manifest.version === version,
|
|
296
|
+
"npm tarball manifest mismatch",
|
|
297
|
+
);
|
|
298
|
+
return [files, commit, integrity];
|
|
299
|
+
}
|
|
300
|
+
async verifyRegistry(name, version, proof) {
|
|
301
|
+
const metadata = json(
|
|
302
|
+
await this.download(
|
|
303
|
+
"https://registry.npmjs.org/" + encoded(name) + "/" + encoded(version),
|
|
304
|
+
MAX_PAYLOAD,
|
|
305
|
+
),
|
|
306
|
+
);
|
|
307
|
+
ensure(
|
|
308
|
+
metadata.name === name && metadata.version === version,
|
|
309
|
+
"Resolved npm metadata identity mismatch",
|
|
310
|
+
);
|
|
311
|
+
const valid = new Set(
|
|
312
|
+
[metadata.dist?.integrity].filter(
|
|
313
|
+
(value) => typeof value === "string" && value.startsWith("sha512-"),
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
if (/^[a-f0-9]{40}(?![\s\S])/.test(metadata.dist?.shasum || ""))
|
|
317
|
+
valid.add(
|
|
318
|
+
"sha1-" + Buffer.from(metadata.dist.shasum, "hex").toString("base64"),
|
|
319
|
+
);
|
|
320
|
+
if (!valid.has(proof.integrity))
|
|
321
|
+
throw new Unresolved(
|
|
322
|
+
"historical lock resolution differs from official npm integrity",
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
async buildCatalog(version, git) {
|
|
326
|
+
console.log(
|
|
327
|
+
"Next catalog " + version + ": downloading official publication",
|
|
328
|
+
);
|
|
329
|
+
const [files, npmCommit, integrity] = await this.officialPackage(version),
|
|
330
|
+
commit = git.releaseCommit(version, npmCommit);
|
|
331
|
+
git.fetch(commit);
|
|
332
|
+
const catalog = {
|
|
333
|
+
schemaVersion: 1,
|
|
334
|
+
kind: "vendored-package-catalog",
|
|
335
|
+
generation: GENERATION,
|
|
336
|
+
owner: {
|
|
337
|
+
name: "next",
|
|
338
|
+
version,
|
|
339
|
+
purl: purl("next", version),
|
|
340
|
+
manifestSha256: sha(files["package.json"]),
|
|
341
|
+
integrity,
|
|
342
|
+
sourceCommit: commit,
|
|
343
|
+
},
|
|
344
|
+
components: [],
|
|
345
|
+
unresolved: [],
|
|
346
|
+
};
|
|
347
|
+
for (const [location, manifest, contents] of provenance.compiledComponents(
|
|
348
|
+
files,
|
|
349
|
+
)) {
|
|
350
|
+
console.log("Next catalog " + version + ": resolving " + location);
|
|
351
|
+
try {
|
|
352
|
+
catalog.components.push(
|
|
353
|
+
await provenance.resolveComponent(
|
|
354
|
+
git,
|
|
355
|
+
commit,
|
|
356
|
+
location,
|
|
357
|
+
manifest,
|
|
358
|
+
contents,
|
|
359
|
+
(...args) => this.verifyRegistry(...args),
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (!(error instanceof Unresolved)) throw error;
|
|
364
|
+
catalog.unresolved.push({
|
|
365
|
+
path: location,
|
|
366
|
+
name: manifest.name || "",
|
|
367
|
+
reason: error.message,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
ensure(
|
|
372
|
+
catalog.components.length || catalog.unresolved.length,
|
|
373
|
+
"No compiled packages found; upstream layout may have changed",
|
|
374
|
+
);
|
|
375
|
+
return catalog;
|
|
376
|
+
}
|
|
377
|
+
oras(args, { input, cwd, binary = true } = {}) {
|
|
378
|
+
const config = this.registryConfig
|
|
379
|
+
? ["--registry-config", this.registryConfig]
|
|
380
|
+
: [];
|
|
381
|
+
return (this.deps.exec || exec)(["oras", ...args, ...config], {
|
|
382
|
+
input,
|
|
383
|
+
cwd,
|
|
384
|
+
binary,
|
|
385
|
+
env: this.env,
|
|
386
|
+
timeout: this.remaining(),
|
|
387
|
+
maxBuffer: MAX_PAYLOAD + 1024,
|
|
388
|
+
label: "OCI operation",
|
|
389
|
+
}).stdout;
|
|
390
|
+
}
|
|
391
|
+
descriptor(ref) {
|
|
392
|
+
const config = this.registryConfig
|
|
393
|
+
? ["--registry-config", this.registryConfig]
|
|
394
|
+
: [];
|
|
395
|
+
const result = (this.deps.exec || exec)(
|
|
396
|
+
["oras", "manifest", "fetch", "--descriptor", ref, ...config],
|
|
397
|
+
{
|
|
398
|
+
env: this.env,
|
|
399
|
+
binary: false,
|
|
400
|
+
allowed: [0, 1, 2],
|
|
401
|
+
timeout: this.remaining(60000),
|
|
402
|
+
maxBuffer: MAX_PAYLOAD + 1024,
|
|
403
|
+
label: "Catalog lookup",
|
|
404
|
+
},
|
|
405
|
+
);
|
|
406
|
+
if (result.status) {
|
|
407
|
+
ensure(
|
|
408
|
+
/manifest_unknown|manifest unknown|not found/i.test(result.stderr),
|
|
409
|
+
"Catalog lookup failed (not a cache miss)",
|
|
410
|
+
);
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
const value = json(result.stdout);
|
|
414
|
+
ensure(
|
|
415
|
+
DIGEST.test(value.digest) && value.mediaType === OCI_TYPE,
|
|
416
|
+
"Invalid OCI digest",
|
|
417
|
+
);
|
|
418
|
+
return value.digest;
|
|
419
|
+
}
|
|
420
|
+
tagFor(version) {
|
|
421
|
+
return this.repository + ":" + GENERATION + "-" + sha(version);
|
|
422
|
+
}
|
|
423
|
+
readback(digest, version, expectedPayload) {
|
|
424
|
+
ensure(DIGEST.test(digest), "Invalid readback digest");
|
|
425
|
+
const manifestBytes = Buffer.from(
|
|
426
|
+
this.oras(["manifest", "fetch", this.repository + "@" + digest]),
|
|
427
|
+
);
|
|
428
|
+
ensure(
|
|
429
|
+
"sha256:" + sha(manifestBytes) === digest,
|
|
430
|
+
"Catalog manifest readback integrity failed",
|
|
431
|
+
);
|
|
432
|
+
const layer = validateManifest(json(manifestBytes)),
|
|
433
|
+
payload = Buffer.from(
|
|
434
|
+
this.oras([
|
|
435
|
+
"blob",
|
|
436
|
+
"fetch",
|
|
437
|
+
"--output",
|
|
438
|
+
"-",
|
|
439
|
+
this.repository + "@" + layer.digest,
|
|
440
|
+
]),
|
|
441
|
+
);
|
|
442
|
+
ensure(
|
|
443
|
+
payload.length === layer.size &&
|
|
444
|
+
"sha256:" + sha(payload) === layer.digest,
|
|
445
|
+
"Catalog payload readback integrity failed",
|
|
446
|
+
);
|
|
447
|
+
ensure(
|
|
448
|
+
!expectedPayload || payload.equals(expectedPayload),
|
|
449
|
+
"Published payload differs from generated catalog",
|
|
450
|
+
);
|
|
451
|
+
return validateCatalog(json(payload), version);
|
|
452
|
+
}
|
|
453
|
+
publish(version, catalog, workdir) {
|
|
454
|
+
const ref = this.tagFor(version),
|
|
455
|
+
existing = this.descriptor(ref);
|
|
456
|
+
if (existing) {
|
|
457
|
+
this.readback(existing, version);
|
|
458
|
+
return this.repository + "@" + existing;
|
|
459
|
+
}
|
|
460
|
+
const payload = Buffer.from(stable(catalog));
|
|
461
|
+
ensure(payload.length <= MAX_PAYLOAD, "Catalog payload too large");
|
|
462
|
+
fs.writeFileSync(path.join(workdir, "catalog.json"), payload);
|
|
463
|
+
fs.writeFileSync(path.join(workdir, "config.json"), "{}");
|
|
464
|
+
this.oras(
|
|
465
|
+
[
|
|
466
|
+
"push",
|
|
467
|
+
"--image-spec",
|
|
468
|
+
"v1.0",
|
|
469
|
+
"--config",
|
|
470
|
+
"config.json:" + CONFIG_TYPE,
|
|
471
|
+
ref,
|
|
472
|
+
"catalog.json:application/json",
|
|
473
|
+
],
|
|
474
|
+
{ cwd: workdir },
|
|
475
|
+
);
|
|
476
|
+
const digest = this.descriptor(ref);
|
|
477
|
+
ensure(digest, "Published catalog cannot be read back");
|
|
478
|
+
this.readback(digest, version, payload);
|
|
479
|
+
return this.repository + "@" + digest;
|
|
480
|
+
}
|
|
481
|
+
async main({ cwd = process.cwd(), dry = isDryRun() } = {}) {
|
|
482
|
+
const report = {
|
|
483
|
+
schemaVersion: 1,
|
|
484
|
+
generation: GENERATION,
|
|
485
|
+
results: [],
|
|
486
|
+
status: "error",
|
|
487
|
+
},
|
|
488
|
+
output = path.join(cwd, "sbom-catalog-updater");
|
|
489
|
+
let code = 2;
|
|
490
|
+
try {
|
|
491
|
+
const requested = versions(this.env.NEXT_CATALOG_VERSIONS || "[]");
|
|
492
|
+
ensure(
|
|
493
|
+
this.env.CI_COMMIT_BRANCH === "main" &&
|
|
494
|
+
this.env.CI_COMMIT_REF_PROTECTED === "true",
|
|
495
|
+
"Updater requires protected main",
|
|
496
|
+
);
|
|
497
|
+
required(this.env, [
|
|
498
|
+
"SBOM_CATALOG_PUBLISH_REPOSITORY",
|
|
499
|
+
"NEXUS_USER",
|
|
500
|
+
"NEXUS_PASSWORD",
|
|
501
|
+
]);
|
|
502
|
+
this.repository = publicationRepository(
|
|
503
|
+
this.env.SBOM_CATALOG_PUBLISH_REPOSITORY,
|
|
504
|
+
);
|
|
505
|
+
report.repository = this.repository;
|
|
506
|
+
ensure(
|
|
507
|
+
this.env.NEXUS_USER && this.env.NEXUS_PASSWORD,
|
|
508
|
+
"Central push credentials NEXUS_USER/NEXUS_PASSWORD are not configured",
|
|
509
|
+
);
|
|
510
|
+
await temporary("next-catalog-", async (dir) => {
|
|
511
|
+
this.registryConfig = path.join(dir, "registry.json");
|
|
512
|
+
await (this.deps.bootstrap || require("./tools").bootstrap)(dir, [
|
|
513
|
+
"oras",
|
|
514
|
+
]);
|
|
515
|
+
this.env = {
|
|
516
|
+
...this.env,
|
|
517
|
+
PATH: dir + path.delimiter + (this.env.PATH || ""),
|
|
518
|
+
};
|
|
519
|
+
this.oras(
|
|
520
|
+
[
|
|
521
|
+
"login",
|
|
522
|
+
this.repository.split("/")[0],
|
|
523
|
+
"--username",
|
|
524
|
+
this.env.NEXUS_USER,
|
|
525
|
+
"--password-stdin",
|
|
526
|
+
],
|
|
527
|
+
{ input: this.env.NEXUS_PASSWORD },
|
|
528
|
+
);
|
|
529
|
+
report.preflight = await (
|
|
530
|
+
this.deps.checkRegistryAccess || checkRegistryAccess
|
|
531
|
+
)(this.repository, {
|
|
532
|
+
probe: () => this.descriptor(this.tagFor(requested[0])),
|
|
533
|
+
transport: this.deps.request,
|
|
534
|
+
});
|
|
535
|
+
if (dry) return;
|
|
536
|
+
const gitDir = path.join(dir, "upstream.git");
|
|
537
|
+
fs.mkdirSync(gitDir);
|
|
538
|
+
const git = this.deps.git || new Git(gitDir, this.remaining);
|
|
539
|
+
for (const version of requested) {
|
|
540
|
+
try {
|
|
541
|
+
const digest = this.descriptor(this.tagFor(version));
|
|
542
|
+
let catalog, ref, cached;
|
|
543
|
+
if (digest) {
|
|
544
|
+
catalog = this.readback(digest, version);
|
|
545
|
+
ref = this.repository + "@" + digest;
|
|
546
|
+
cached = true;
|
|
547
|
+
} else {
|
|
548
|
+
catalog = await this.buildCatalog(version, git);
|
|
549
|
+
ref = this.publish(version, catalog, dir);
|
|
550
|
+
}
|
|
551
|
+
report.results.push({
|
|
552
|
+
version,
|
|
553
|
+
status: catalog.unresolved.length ? "partial" : "complete",
|
|
554
|
+
...(cached ? { cached } : {}),
|
|
555
|
+
ref,
|
|
556
|
+
resolved: catalog.components.length,
|
|
557
|
+
unresolved: catalog.unresolved,
|
|
558
|
+
});
|
|
559
|
+
} catch (error) {
|
|
560
|
+
report.results.push({
|
|
561
|
+
version,
|
|
562
|
+
status: "error",
|
|
563
|
+
reason: error.message,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
code = report.results.some((r) => ["partial", "error"].includes(r.status))
|
|
569
|
+
? 2
|
|
570
|
+
: 0;
|
|
571
|
+
report.status = code ? "partial" : "complete";
|
|
572
|
+
} catch (error) {
|
|
573
|
+
report.error = error.message;
|
|
574
|
+
if (dry) console.error("SBOM catalog preflight: " + error.message);
|
|
575
|
+
} finally {
|
|
576
|
+
this.registryConfig = null;
|
|
577
|
+
if (!dry) {
|
|
578
|
+
save(path.join(output, "report.json"), report);
|
|
579
|
+
fs.writeFileSync(
|
|
580
|
+
path.join(output, "report.md"),
|
|
581
|
+
"# Next OCI catalog updater\n\nStatus: " +
|
|
582
|
+
report.status +
|
|
583
|
+
"\n\n```json\n" +
|
|
584
|
+
JSON.stringify(report, null, 2) +
|
|
585
|
+
"\n```\n",
|
|
586
|
+
);
|
|
587
|
+
console.log("Catalog updater: " + report.status);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return code;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async function populate(options = {}) {
|
|
594
|
+
return new Publisher(
|
|
595
|
+
{ ...process.env, ...(options.env || {}) },
|
|
596
|
+
options.deps,
|
|
597
|
+
).main(options);
|
|
598
|
+
}
|
|
599
|
+
module.exports = {
|
|
600
|
+
versions,
|
|
601
|
+
publicationRepository,
|
|
602
|
+
readTarball,
|
|
603
|
+
Git,
|
|
604
|
+
Publisher,
|
|
605
|
+
populate,
|
|
606
|
+
UPSTREAM,
|
|
607
|
+
};
|