spectrum-cli 5.0.2 → 6.0.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 +28 -0
- package/README.md +228 -22
- package/package.json +4 -2
- package/src/automation/index.js +297 -0
- package/src/changelog/commands.js +1 -1
- package/src/changelog/open-release.js +9 -1
- package/src/chart/deploy.js +3 -3
- package/src/chart/start.js +2 -2
- package/src/chart/verify.js +5 -5
- package/src/cli/command-executor.js +33 -4
- package/src/cli/commands.js +101 -2
- package/src/cli/help.js +4 -1
- package/src/cli/run-action.js +19 -3
- package/src/git/index.js +1 -1
- package/src/hotfix/checks.js +27 -4
- package/src/hotfix/close.js +3 -3
- package/src/hotfix/start.js +2 -2
- package/src/nextjs/index.js +184 -0
- package/src/preflight/automation.js +291 -0
- package/src/preflight/helmrelease.js +11 -5
- package/src/preflight/source.js +13 -14
- package/src/preflight/tags.js +9 -1
- package/src/release/start.js +14 -9
- 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 +238 -0
- package/src/token/rotate.js +2 -2
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
const {
|
|
2
|
+
fs,
|
|
3
|
+
path,
|
|
4
|
+
ensure,
|
|
5
|
+
read,
|
|
6
|
+
save,
|
|
7
|
+
json,
|
|
8
|
+
temporary,
|
|
9
|
+
repositoryPath,
|
|
10
|
+
equal,
|
|
11
|
+
} = require("../automation");
|
|
12
|
+
const { encoded } = require("../sbom/schema");
|
|
13
|
+
const { glab, pages, projectPath } = require("./runtime");
|
|
14
|
+
const { Scanner, runtimeFromDockerfile } = require("./scan");
|
|
15
|
+
const { intersects, trivyFindings, nodeFindings } = require("./findings");
|
|
16
|
+
const { formatReport, messageParts } = require("./report");
|
|
17
|
+
const {
|
|
18
|
+
checkGitlabAccess,
|
|
19
|
+
checkExecutables,
|
|
20
|
+
} = require("../preflight/automation");
|
|
21
|
+
const { isDryRun, isVerificationSkipped, reportPreflight } = require("../cli/command-executor");
|
|
22
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
23
|
+
const pkgName = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+(?![\s\S])/;
|
|
24
|
+
const tuple = (value) => value.split(".").map(Number);
|
|
25
|
+
function compareTags(a, b) {
|
|
26
|
+
const aa = tuple(a),
|
|
27
|
+
bb = tuple(b);
|
|
28
|
+
for (let i = 0; i < Math.max(aa.length, bb.length); i++) {
|
|
29
|
+
if (i >= aa.length) return -1;
|
|
30
|
+
if (i >= bb.length) return 1;
|
|
31
|
+
if (aa[i] !== bb[i]) return aa[i] - bb[i];
|
|
32
|
+
}
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
class Repairer extends Scanner {
|
|
36
|
+
imageCandidates(image) {
|
|
37
|
+
const base = image.split("@")[0],
|
|
38
|
+
offset = base.lastIndexOf(":"),
|
|
39
|
+
repository = base.slice(0, offset),
|
|
40
|
+
tag = base.slice(offset + 1);
|
|
41
|
+
ensure(
|
|
42
|
+
offset !== -1 && !tag.includes("/"),
|
|
43
|
+
"Runtime image must have an explicit tag",
|
|
44
|
+
);
|
|
45
|
+
if (/^\d+(?:\.\d+){2,3}(?![\s\S])/.test(tag)) {
|
|
46
|
+
const tags = json(
|
|
47
|
+
this.run(["skopeo", "list-tags", "docker://" + repository]).stdout,
|
|
48
|
+
).Tags;
|
|
49
|
+
return tags
|
|
50
|
+
.filter(
|
|
51
|
+
(item) =>
|
|
52
|
+
/^\d+(?:\.\d+){2,3}(?![\s\S])/.test(item) &&
|
|
53
|
+
tuple(item)[0] === tuple(tag)[0] &&
|
|
54
|
+
compareTags(item, tag) > 0,
|
|
55
|
+
)
|
|
56
|
+
.sort((a, b) => compareTags(b, a))
|
|
57
|
+
.slice(0, 20)
|
|
58
|
+
.map((item) => repository + ":" + item);
|
|
59
|
+
}
|
|
60
|
+
return [base];
|
|
61
|
+
}
|
|
62
|
+
async fixNpm(root, value) {
|
|
63
|
+
const occurrences = value.occurrences.filter((entry) =>
|
|
64
|
+
Object.hasOwn(entry, "entry"),
|
|
65
|
+
);
|
|
66
|
+
ensure(occurrences.length, "npm finding has no package evidence");
|
|
67
|
+
const packages = [
|
|
68
|
+
...new Set(occurrences.map((entry) => entry.package)),
|
|
69
|
+
].sort();
|
|
70
|
+
ensure(
|
|
71
|
+
packages.every((name) => pkgName.test(name)),
|
|
72
|
+
"Invalid npm package name",
|
|
73
|
+
);
|
|
74
|
+
this.run(
|
|
75
|
+
[
|
|
76
|
+
"npm",
|
|
77
|
+
"update",
|
|
78
|
+
"--package-lock-only",
|
|
79
|
+
"--ignore-scripts",
|
|
80
|
+
"--no-audit",
|
|
81
|
+
"--",
|
|
82
|
+
...packages,
|
|
83
|
+
],
|
|
84
|
+
root,
|
|
85
|
+
);
|
|
86
|
+
const [, remaining] = await this.auditNpm(root);
|
|
87
|
+
if (remaining.some((item) => intersects(item.aliases, value.aliases))) {
|
|
88
|
+
const fixes = Object.fromEntries(
|
|
89
|
+
occurrences
|
|
90
|
+
.filter((entry) => entry.fix && typeof entry.fix === "object")
|
|
91
|
+
.map((entry) => [entry.fix.name, entry.fix]),
|
|
92
|
+
);
|
|
93
|
+
ensure(
|
|
94
|
+
Object.keys(fixes).length,
|
|
95
|
+
"No automatic compatible npm remediation is available",
|
|
96
|
+
);
|
|
97
|
+
for (const [name, fix] of Object.entries(fixes)) {
|
|
98
|
+
ensure(
|
|
99
|
+
!fix.isSemVerMajor,
|
|
100
|
+
"Remediation requires a major dependency upgrade",
|
|
101
|
+
);
|
|
102
|
+
ensure(
|
|
103
|
+
pkgName.test(name) && /^\d+\.\d+\.\d+(?![\s\S])/.test(fix.version),
|
|
104
|
+
"Invalid npm remediation",
|
|
105
|
+
);
|
|
106
|
+
const manifest = read(path.join(root, "package.json"));
|
|
107
|
+
ensure(
|
|
108
|
+
Object.hasOwn(manifest.dependencies || {}, name) ||
|
|
109
|
+
Object.hasOwn(manifest.optionalDependencies || {}, name),
|
|
110
|
+
"Automatic manifest updates require a direct production dependency",
|
|
111
|
+
);
|
|
112
|
+
const flag = Object.hasOwn(manifest.optionalDependencies || {}, name)
|
|
113
|
+
? "--save-optional"
|
|
114
|
+
: "--save-prod";
|
|
115
|
+
this.run(
|
|
116
|
+
[
|
|
117
|
+
"npm",
|
|
118
|
+
"install",
|
|
119
|
+
"--package-lock-only",
|
|
120
|
+
"--ignore-scripts",
|
|
121
|
+
"--no-audit",
|
|
122
|
+
"--save-exact",
|
|
123
|
+
flag,
|
|
124
|
+
"--",
|
|
125
|
+
name + "@" + fix.version,
|
|
126
|
+
],
|
|
127
|
+
root,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async fixRuntime(root, value, report) {
|
|
133
|
+
const runtime = report.runtime,
|
|
134
|
+
candidates = this.imageCandidates(runtime.image);
|
|
135
|
+
ensure(
|
|
136
|
+
candidates.length,
|
|
137
|
+
"No newer published runtime image; fix and publish the runtime owner first",
|
|
138
|
+
);
|
|
139
|
+
let selected,
|
|
140
|
+
versions = [];
|
|
141
|
+
const baseline = new Set(
|
|
142
|
+
report.findings
|
|
143
|
+
.filter((entry) => intersects(entry.sources, ["runtime", "node"]))
|
|
144
|
+
.flatMap((entry) => entry.aliases),
|
|
145
|
+
);
|
|
146
|
+
for (let candidate of candidates) {
|
|
147
|
+
candidate = this.immutableImage(candidate);
|
|
148
|
+
if (candidate === runtime.image) continue;
|
|
149
|
+
let safe = true;
|
|
150
|
+
versions = [];
|
|
151
|
+
for (const platform of runtime.platforms) {
|
|
152
|
+
const [, entries, version] = await this.scanImage(candidate, platform),
|
|
153
|
+
original = runtime[platform]?.node;
|
|
154
|
+
if (
|
|
155
|
+
original &&
|
|
156
|
+
(!version || version.split(".")[0] !== original.split(".")[0])
|
|
157
|
+
) {
|
|
158
|
+
safe = false;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
const aliases = entries.flatMap((entry) => entry.aliases);
|
|
162
|
+
if (
|
|
163
|
+
intersects(aliases, value.aliases) ||
|
|
164
|
+
aliases.some((alias) => !baseline.has(alias))
|
|
165
|
+
) {
|
|
166
|
+
safe = false;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
versions.push(version);
|
|
170
|
+
}
|
|
171
|
+
if (safe) {
|
|
172
|
+
selected = candidate;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
ensure(selected, "No verified safe runtime candidate is published");
|
|
177
|
+
const dockerfile = repositoryPath(root, runtime.dockerfile),
|
|
178
|
+
text = fs.readFileSync(dockerfile, "utf8"),
|
|
179
|
+
original = runtimeFromDockerfile(text);
|
|
180
|
+
const regex = new RegExp(
|
|
181
|
+
"^(FROM\\s+(?:--platform=\\S+\\s+)?)" + escape(original) + "(?=\\s|$)",
|
|
182
|
+
"gim",
|
|
183
|
+
);
|
|
184
|
+
let count = 0;
|
|
185
|
+
const changed = text.replace(regex, (_, prefix) => {
|
|
186
|
+
count++;
|
|
187
|
+
return prefix + selected;
|
|
188
|
+
});
|
|
189
|
+
ensure(
|
|
190
|
+
count === 1,
|
|
191
|
+
"Runtime FROM must be a single literal image for automatic replacement",
|
|
192
|
+
);
|
|
193
|
+
fs.writeFileSync(dockerfile, changed);
|
|
194
|
+
const variable = this.env.NIGHTLY_RUNTIME_PUBLISH_VARIABLE;
|
|
195
|
+
if (variable) {
|
|
196
|
+
ensure(
|
|
197
|
+
new Set(versions).size === 1 && versions[0],
|
|
198
|
+
"Runtime platform versions differ",
|
|
199
|
+
);
|
|
200
|
+
const pipeline = repositoryPath(root, ".gitlab-ci.yml"),
|
|
201
|
+
current = fs.readFileSync(pipeline, "utf8"),
|
|
202
|
+
pattern = new RegExp(
|
|
203
|
+
"^( " +
|
|
204
|
+
escape(variable) +
|
|
205
|
+
":\\s*)[\"']?(\\d+\\.\\d+\\.\\d+)\\.(\\d+)[\"']?[ \\t]*$",
|
|
206
|
+
"m",
|
|
207
|
+
);
|
|
208
|
+
const match = current.match(pattern);
|
|
209
|
+
ensure(match, "Cannot find runtime publication tag variable");
|
|
210
|
+
const tag = versions[0] + "." + (Number(match[3]) + 1);
|
|
211
|
+
fs.writeFileSync(
|
|
212
|
+
pipeline,
|
|
213
|
+
current.replace(pattern, (_, prefix) => prefix + '"' + tag + '"'),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return selected;
|
|
217
|
+
}
|
|
218
|
+
commitActions(root, allowed) {
|
|
219
|
+
const changed = this.run(["git", "diff", "--name-only", "-z"], root)
|
|
220
|
+
.stdout.split("\0")
|
|
221
|
+
.filter(Boolean);
|
|
222
|
+
ensure(
|
|
223
|
+
changed.every((file) => allowed.has(file)),
|
|
224
|
+
"Repair modified files outside its permitted scope",
|
|
225
|
+
);
|
|
226
|
+
return changed.map((file) => {
|
|
227
|
+
const target = repositoryPath(root, file);
|
|
228
|
+
ensure(
|
|
229
|
+
fs.lstatSync(target).isFile() && !fs.lstatSync(target).isSymbolicLink(),
|
|
230
|
+
"Repair may only update regular files",
|
|
231
|
+
);
|
|
232
|
+
return {
|
|
233
|
+
action: "update",
|
|
234
|
+
file_path: file,
|
|
235
|
+
content: fs.readFileSync(target).toString("base64"),
|
|
236
|
+
encoding: "base64",
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
async repairOne(api, value, report, root) {
|
|
241
|
+
const cfg = report.config,
|
|
242
|
+
project = projectPath(cfg.project_id),
|
|
243
|
+
branch = "hotfix/" + value.task.key + "-security";
|
|
244
|
+
const existing = await pages(api, project + "/merge_requests", {
|
|
245
|
+
state: "opened",
|
|
246
|
+
source_branch: branch,
|
|
247
|
+
target_branch: cfg.branch,
|
|
248
|
+
});
|
|
249
|
+
ensure(existing.length <= 1, "Multiple hotfix MRs exist for this task");
|
|
250
|
+
if (existing.length) return { status: "existing", mr: existing[0].web_url };
|
|
251
|
+
const branches = (
|
|
252
|
+
await pages(api, project + "/repository/branches", {
|
|
253
|
+
search: "^" + escape(branch) + "$",
|
|
254
|
+
})
|
|
255
|
+
).filter((item) => item.name === branch);
|
|
256
|
+
ensure(branches.length <= 1, "Ambiguous hotfix branch");
|
|
257
|
+
const previous = branches[0]?.commit,
|
|
258
|
+
expectedMessage = `fix(${value.task.key}): remediate ${value.id} [skip ci]`;
|
|
259
|
+
if (previous)
|
|
260
|
+
ensure(
|
|
261
|
+
equal(previous.parent_ids, [cfg.sha]) &&
|
|
262
|
+
previous.message?.trim() === expectedMessage,
|
|
263
|
+
"Hotfix branch was modified outside this workflow; manual review required",
|
|
264
|
+
);
|
|
265
|
+
const head = await api.get(
|
|
266
|
+
project + "/repository/branches/" + encoded(cfg.branch),
|
|
267
|
+
);
|
|
268
|
+
ensure(
|
|
269
|
+
head.commit.id === cfg.sha,
|
|
270
|
+
"Production branch moved after scan; rerun the nightly pipeline",
|
|
271
|
+
);
|
|
272
|
+
return temporary("nightly-fix-", async (directory) => {
|
|
273
|
+
const work = path.join(directory, "repo");
|
|
274
|
+
this.run(["git", "clone", "--no-hardlinks", "--no-checkout", root, work]);
|
|
275
|
+
this.run(["git", "checkout", "--detach", cfg.sha], work);
|
|
276
|
+
const allowed = new Set(["package.json", "package-lock.json"]);
|
|
277
|
+
if (value.sources.includes("npm")) await this.fixNpm(work, value);
|
|
278
|
+
if (intersects(value.sources, ["runtime", "node"])) {
|
|
279
|
+
await this.fixRuntime(work, value, report);
|
|
280
|
+
allowed.add(report.runtime.dockerfile);
|
|
281
|
+
if (this.env.NIGHTLY_RUNTIME_PUBLISH_VARIABLE)
|
|
282
|
+
allowed.add(".gitlab-ci.yml");
|
|
283
|
+
}
|
|
284
|
+
ensure(
|
|
285
|
+
this.commitActions(work, allowed).length,
|
|
286
|
+
"Repair produced no changes",
|
|
287
|
+
);
|
|
288
|
+
if ((this.env.NIGHTLY_NPM_ENABLED || "true") === "true")
|
|
289
|
+
this.run(
|
|
290
|
+
["npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund"],
|
|
291
|
+
work,
|
|
292
|
+
);
|
|
293
|
+
const checks = json(this.env.NIGHTLY_CHECK_COMMANDS || "[]");
|
|
294
|
+
ensure(
|
|
295
|
+
Array.isArray(checks) &&
|
|
296
|
+
checks.length &&
|
|
297
|
+
checks.every(
|
|
298
|
+
(check) =>
|
|
299
|
+
Array.isArray(check) &&
|
|
300
|
+
check.length &&
|
|
301
|
+
check.every((arg) => typeof arg === "string"),
|
|
302
|
+
),
|
|
303
|
+
"Configure nonempty check_commands as argv arrays",
|
|
304
|
+
);
|
|
305
|
+
for (const command of checks) this.run(command, work);
|
|
306
|
+
const after = [];
|
|
307
|
+
if ((this.env.NIGHTLY_NPM_ENABLED || "true") === "true") {
|
|
308
|
+
const [, entries] = await this.auditNpm(work);
|
|
309
|
+
after.push(...entries);
|
|
310
|
+
}
|
|
311
|
+
if (intersects(value.sources, ["runtime", "node"]))
|
|
312
|
+
for (const platform of report.runtime.platforms) {
|
|
313
|
+
const tag = "localhost/nightly-security:" + process.pid;
|
|
314
|
+
this.run(
|
|
315
|
+
[
|
|
316
|
+
"buildah",
|
|
317
|
+
"bud",
|
|
318
|
+
"--isolation=chroot",
|
|
319
|
+
"--platform",
|
|
320
|
+
platform,
|
|
321
|
+
"--format=docker",
|
|
322
|
+
"--tag",
|
|
323
|
+
tag,
|
|
324
|
+
"--file",
|
|
325
|
+
report.runtime.dockerfile,
|
|
326
|
+
".",
|
|
327
|
+
],
|
|
328
|
+
work,
|
|
329
|
+
);
|
|
330
|
+
try {
|
|
331
|
+
const archive = path.join(directory, "fixed-image.tar"),
|
|
332
|
+
filename = path.join(directory, "fixed-trivy.json");
|
|
333
|
+
this.run(["buildah", "push", tag, "docker-archive:" + archive]);
|
|
334
|
+
this.run([
|
|
335
|
+
"trivy",
|
|
336
|
+
"image",
|
|
337
|
+
"--input",
|
|
338
|
+
archive,
|
|
339
|
+
"--scanners",
|
|
340
|
+
"vuln",
|
|
341
|
+
"--severity",
|
|
342
|
+
"HIGH,CRITICAL",
|
|
343
|
+
"--skip-db-update=false",
|
|
344
|
+
"--ignore-unfixed=false",
|
|
345
|
+
"--ignorefile",
|
|
346
|
+
"/dev/null",
|
|
347
|
+
"--config",
|
|
348
|
+
"/dev/null",
|
|
349
|
+
"--format",
|
|
350
|
+
"json",
|
|
351
|
+
"--output",
|
|
352
|
+
filename,
|
|
353
|
+
]);
|
|
354
|
+
after.push(...trivyFindings(read(filename)));
|
|
355
|
+
if ((this.env.NIGHTLY_CHECK_NODE || "true") === "true")
|
|
356
|
+
after.push(
|
|
357
|
+
...nodeFindings(
|
|
358
|
+
this.nodeVersion(tag, platform),
|
|
359
|
+
await this.feed(),
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
} finally {
|
|
363
|
+
this.run(["buildah", "rmi", tag]);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const aliases = after.flatMap((entry) => entry.aliases),
|
|
367
|
+
baseline = new Set(report.findings.flatMap((entry) => entry.aliases));
|
|
368
|
+
ensure(
|
|
369
|
+
!intersects(aliases, value.aliases),
|
|
370
|
+
"Target advisory remains after repair",
|
|
371
|
+
);
|
|
372
|
+
ensure(
|
|
373
|
+
!aliases.some((alias) => !baseline.has(alias)),
|
|
374
|
+
"Repair introduced new HIGH/CRITICAL advisories",
|
|
375
|
+
);
|
|
376
|
+
const actions = this.commitActions(work, allowed),
|
|
377
|
+
fragment = ".changelog/" + value.task.key + ".security.md";
|
|
378
|
+
ensure(
|
|
379
|
+
!fs.existsSync(repositoryPath(work, fragment)),
|
|
380
|
+
"Security changelog fragment already exists",
|
|
381
|
+
);
|
|
382
|
+
actions.push({
|
|
383
|
+
action: "create",
|
|
384
|
+
file_path: fragment,
|
|
385
|
+
content: `- Устранена ${value.id}: обновлены зависимости или runtime-образ.\n`,
|
|
386
|
+
});
|
|
387
|
+
const current = await api.get(
|
|
388
|
+
project + "/repository/branches/" + encoded(cfg.branch),
|
|
389
|
+
);
|
|
390
|
+
ensure(
|
|
391
|
+
current.commit.id === cfg.sha,
|
|
392
|
+
"Production branch moved during repair; rerun",
|
|
393
|
+
);
|
|
394
|
+
if (previous) {
|
|
395
|
+
const differences = await pages(
|
|
396
|
+
api,
|
|
397
|
+
project + "/repository/commits/" + previous.id + "/diff",
|
|
398
|
+
);
|
|
399
|
+
ensure(
|
|
400
|
+
equal(
|
|
401
|
+
new Set(differences.map((item) => item.new_path)),
|
|
402
|
+
new Set(actions.map((action) => action.file_path)),
|
|
403
|
+
),
|
|
404
|
+
"Existing hotfix commit has unexpected files",
|
|
405
|
+
);
|
|
406
|
+
for (const action of actions) {
|
|
407
|
+
const expected =
|
|
408
|
+
action.encoding === "base64"
|
|
409
|
+
? Buffer.from(action.content, "base64")
|
|
410
|
+
: Buffer.from(action.content),
|
|
411
|
+
actual = await api.get(
|
|
412
|
+
project +
|
|
413
|
+
"/repository/files/" +
|
|
414
|
+
encoded(action.file_path) +
|
|
415
|
+
"/raw",
|
|
416
|
+
{ raw: true, query: { ref: previous.id } },
|
|
417
|
+
);
|
|
418
|
+
ensure(
|
|
419
|
+
Buffer.from(actual).equals(expected),
|
|
420
|
+
"Existing hotfix differs from the verified repair",
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
} else
|
|
424
|
+
await api.post(project + "/repository/commits", {
|
|
425
|
+
branch,
|
|
426
|
+
start_sha: cfg.sha,
|
|
427
|
+
commit_message: expectedMessage,
|
|
428
|
+
actions,
|
|
429
|
+
});
|
|
430
|
+
const description = `Устранение ${value.id} (${value.severity}).\n\nЗадача: ${value.task.url}\nРодитель: ${value.parent.url}\n\nПроверки проекта и повторный аудит выполнены ночной автоматизацией.\nИсходный commit: \`${cfg.sha}\`\nPipeline: ${cfg.pipeline_url}\n\nАвтоматическое слияние отключено. Необходимо ревью.`;
|
|
431
|
+
const mr = await api.post(project + "/merge_requests", {
|
|
432
|
+
source_branch: branch,
|
|
433
|
+
target_branch: cfg.branch,
|
|
434
|
+
title: `Draft: 🚑 (${value.task.key}): устранение ${value.id}`,
|
|
435
|
+
description,
|
|
436
|
+
remove_source_branch: true,
|
|
437
|
+
});
|
|
438
|
+
return { status: "verified", mr: mr.web_url, checks, sha: cfg.sha };
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
async preflightRepair(api, report, root) {
|
|
442
|
+
const checks = json(this.env.NIGHTLY_CHECK_COMMANDS || "[]");
|
|
443
|
+
ensure(
|
|
444
|
+
Array.isArray(checks) &&
|
|
445
|
+
checks.length &&
|
|
446
|
+
checks.every(
|
|
447
|
+
(command) =>
|
|
448
|
+
Array.isArray(command) &&
|
|
449
|
+
command.length &&
|
|
450
|
+
command.every((arg) => typeof arg === "string"),
|
|
451
|
+
),
|
|
452
|
+
"Configure nonempty check_commands as argv arrays",
|
|
453
|
+
);
|
|
454
|
+
const tools = new Set(["git"]);
|
|
455
|
+
for (const value of report.findings) {
|
|
456
|
+
ensure(value.task && value.parent, "YouTrack task was not resolved");
|
|
457
|
+
if (value.sources.includes("npm")) {
|
|
458
|
+
tools.add("npm");
|
|
459
|
+
read(repositoryPath(root, "package.json"));
|
|
460
|
+
read(repositoryPath(root, "package-lock.json"));
|
|
461
|
+
}
|
|
462
|
+
if (intersects(value.sources, ["runtime", "node"])) {
|
|
463
|
+
for (const tool of ["skopeo", "trivy", "buildah"]) tools.add(tool);
|
|
464
|
+
runtimeFromDockerfile(
|
|
465
|
+
fs.readFileSync(
|
|
466
|
+
repositoryPath(root, report.runtime.dockerfile),
|
|
467
|
+
"utf8",
|
|
468
|
+
),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
(this.deps.checkExecutables || checkExecutables)([...tools], this.env);
|
|
473
|
+
const cfg = report.config;
|
|
474
|
+
ensure(
|
|
475
|
+
/^[a-f0-9]{40}(?![\s\S])/.test(cfg.sha),
|
|
476
|
+
"Invalid source commit SHA",
|
|
477
|
+
);
|
|
478
|
+
const head = await api.get(
|
|
479
|
+
projectPath(cfg.project_id) +
|
|
480
|
+
"/repository/branches/" +
|
|
481
|
+
encoded(cfg.branch),
|
|
482
|
+
);
|
|
483
|
+
ensure(
|
|
484
|
+
head.commit.id === cfg.sha,
|
|
485
|
+
"Production branch moved after scan; rerun the nightly pipeline",
|
|
486
|
+
);
|
|
487
|
+
this.run(["git", "cat-file", "-e", cfg.sha + "^{commit}"], root);
|
|
488
|
+
reportPreflight(
|
|
489
|
+
"security repair",
|
|
490
|
+
"repair-inputs",
|
|
491
|
+
"проверены задачи, инструменты, argv проверок и исходный commit",
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
async repair(root = process.cwd(), { dry = isDryRun() } = {}) {
|
|
495
|
+
const output = path.join(root, "security-report"),
|
|
496
|
+
report = read(path.join(output, "tracked.json"));
|
|
497
|
+
try {
|
|
498
|
+
if (dry)
|
|
499
|
+
ensure(
|
|
500
|
+
!report.errors.length,
|
|
501
|
+
"Incomplete scan/tracking; automatic repair is blocked",
|
|
502
|
+
);
|
|
503
|
+
if (!report.findings.length) return report.errors.length ? 1 : 0;
|
|
504
|
+
ensure(
|
|
505
|
+
!report.errors.length,
|
|
506
|
+
"Incomplete scan/tracking; automatic repair is blocked",
|
|
507
|
+
);
|
|
508
|
+
const api = this.deps.api || glab(this.env);
|
|
509
|
+
if (!isVerificationSkipped()) report.preflight = {
|
|
510
|
+
...report.preflight,
|
|
511
|
+
gitlab_repair: await (this.deps.checkGitlabAccess || checkGitlabAccess)(
|
|
512
|
+
this.env,
|
|
513
|
+
[String(report.config.project_id)],
|
|
514
|
+
{ api, write: true },
|
|
515
|
+
),
|
|
516
|
+
};
|
|
517
|
+
if (dry) {
|
|
518
|
+
await this.preflightRepair(api, report, root);
|
|
519
|
+
return 0;
|
|
520
|
+
}
|
|
521
|
+
for (const value of report.findings) {
|
|
522
|
+
try {
|
|
523
|
+
ensure(value.task && value.parent, "YouTrack task was not resolved");
|
|
524
|
+
ensure(
|
|
525
|
+
!report.errors.length,
|
|
526
|
+
"Incomplete scan/tracking; automatic repair is blocked",
|
|
527
|
+
);
|
|
528
|
+
value.repair = await this.repairOne(api, value, report, root);
|
|
529
|
+
} catch (error) {
|
|
530
|
+
value.repair = { status: "blocked", reason: error.message };
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
} catch (error) {
|
|
534
|
+
if (dry) throw error;
|
|
535
|
+
report.errors.push(error.message);
|
|
536
|
+
for (const value of report.findings)
|
|
537
|
+
if (!value.repair)
|
|
538
|
+
value.repair = { status: "blocked", reason: error.message };
|
|
539
|
+
} finally {
|
|
540
|
+
if (!dry) {
|
|
541
|
+
save(path.join(output, "final.json"), report);
|
|
542
|
+
save(path.join(output, "message.json"), {
|
|
543
|
+
messages: messageParts(formatReport(report)),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return report.errors.length ||
|
|
548
|
+
report.findings.some((value) => value.repair?.status === "blocked")
|
|
549
|
+
? 1
|
|
550
|
+
: 0;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
module.exports = { Repairer, compareTags };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const { ensure } = require("../automation");
|
|
2
|
+
function formatReport(report) {
|
|
3
|
+
const cfg = report.config,
|
|
4
|
+
lines = [
|
|
5
|
+
`🛡️ **${cfg.project_path}** · [ночная проверка](${cfg.pipeline_url})`,
|
|
6
|
+
];
|
|
7
|
+
for (const value of report.findings) {
|
|
8
|
+
lines.push(
|
|
9
|
+
"",
|
|
10
|
+
`**${value.severity} · ${value.id}** (${value.sources.join(", ")})`,
|
|
11
|
+
);
|
|
12
|
+
const packages = [
|
|
13
|
+
...new Set(
|
|
14
|
+
value.occurrences.map((entry) => entry.package || "").filter(Boolean),
|
|
15
|
+
),
|
|
16
|
+
].sort();
|
|
17
|
+
if (packages.length)
|
|
18
|
+
lines.push(
|
|
19
|
+
"Компоненты: " +
|
|
20
|
+
packages.map((name) => name.replace(/`/g, "")).join(", "),
|
|
21
|
+
);
|
|
22
|
+
for (const [field, label] of [
|
|
23
|
+
["parent", "CVE"],
|
|
24
|
+
["task", "Исправление"],
|
|
25
|
+
])
|
|
26
|
+
if (value[field])
|
|
27
|
+
lines.push(`${label}: [${value[field].key}](${value[field].url})`);
|
|
28
|
+
const repair = value.repair || {};
|
|
29
|
+
if (repair.mr)
|
|
30
|
+
lines.push(
|
|
31
|
+
`MR: ${repair.mr} · ` +
|
|
32
|
+
(repair.status === "verified" ? "проверки пройдены" : "уже открыт"),
|
|
33
|
+
);
|
|
34
|
+
if (repair.reason) lines.push("Ожидает исправления: " + repair.reason);
|
|
35
|
+
if (value.tracking_error)
|
|
36
|
+
lines.push("Ошибка YouTrack: " + value.tracking_error);
|
|
37
|
+
}
|
|
38
|
+
if (report.errors.length)
|
|
39
|
+
lines.push(
|
|
40
|
+
"",
|
|
41
|
+
"⚠️ Проверка завершена не полностью:",
|
|
42
|
+
...report.errors.map((error) => "• " + error),
|
|
43
|
+
);
|
|
44
|
+
else if (!report.findings.length)
|
|
45
|
+
lines.push("", "HIGH/CRITICAL не обнаружены.");
|
|
46
|
+
return lines.join("\n");
|
|
47
|
+
}
|
|
48
|
+
function messageParts(message) {
|
|
49
|
+
const parts = [];
|
|
50
|
+
let current = "";
|
|
51
|
+
for (const section of message.split("\n\n")) {
|
|
52
|
+
ensure(
|
|
53
|
+
[...section].length <= 10000,
|
|
54
|
+
"Report section exceeds Mattermost message budget",
|
|
55
|
+
);
|
|
56
|
+
if (current && [...current].length + [...section].length + 2 > 10000) {
|
|
57
|
+
parts.push(current);
|
|
58
|
+
current = "";
|
|
59
|
+
}
|
|
60
|
+
current += (current ? "\n\n" : "") + section;
|
|
61
|
+
}
|
|
62
|
+
if (current) parts.push(current);
|
|
63
|
+
return parts;
|
|
64
|
+
}
|
|
65
|
+
module.exports = { formatReport, messageParts };
|