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.
@@ -0,0 +1,248 @@
1
+ const { ensure } = require("../automation");
2
+ const { HIGH, ids, intersects } = require("./findings");
3
+ class Tracker {
4
+ static FIELDS =
5
+ "id,idReadable,summary,description,resolved,project(id,shortName),customFields(name,value(name))";
6
+ constructor(api, base, env = process.env) {
7
+ this.api = api;
8
+ this.base = base.replace(/\/(?![\s\S])/, "");
9
+ this.env = env;
10
+ }
11
+ async search(query) {
12
+ const result = [];
13
+ for (let offset = 0; ; offset += 100) {
14
+ const batch = await this.api.get("issues", {
15
+ query: { query, fields: Tracker.FIELDS, $top: 100, $skip: offset },
16
+ });
17
+ ensure(Array.isArray(batch), "Invalid YouTrack search response");
18
+ result.push(...batch);
19
+ if (batch.length < 100) return result;
20
+ }
21
+ }
22
+ decorate(issue) {
23
+ return {
24
+ id: issue.id,
25
+ key: issue.idReadable,
26
+ url: this.base + "/issue/" + issue.idReadable,
27
+ };
28
+ }
29
+ async linkedChildren(parent) {
30
+ const links = await this.api.get("issues/" + parent.id + "/links", {
31
+ query: {
32
+ fields:
33
+ "id,direction,linkType(name,sourceToTarget),issues(" +
34
+ Tracker.FIELDS +
35
+ ")",
36
+ $top: 100,
37
+ },
38
+ });
39
+ const matching = links.filter(
40
+ (link) =>
41
+ link.direction === "OUTWARD" &&
42
+ link.linkType?.sourceToTarget === "parent for",
43
+ );
44
+ ensure(
45
+ matching.length === 1,
46
+ "Cannot identify the YouTrack parent-for link",
47
+ );
48
+ const result = [];
49
+ for (let offset = 0; ; offset += 100) {
50
+ const batch = await this.api.get(
51
+ "issues/" + parent.id + "/links/" + matching[0].id + "/issues",
52
+ { query: { fields: Tracker.FIELDS, $top: 100, $skip: offset } },
53
+ );
54
+ ensure(Array.isArray(batch), "Invalid YouTrack linked issues");
55
+ result.push(...batch);
56
+ if (batch.length < 100) return [matching[0].id, result];
57
+ }
58
+ }
59
+ async create(project, summary, description, customFields) {
60
+ const projects = await this.api.get("admin/projects", {
61
+ query: { query: project, fields: "id,shortName", $top: 100 },
62
+ });
63
+ const exact = projects.filter((item) => item.shortName === project);
64
+ ensure(exact.length === 1, "Cannot resolve YouTrack project " + project);
65
+ const body = { project: { id: exact[0].id }, summary, description };
66
+ if (customFields !== undefined) body.customFields = customFields;
67
+ return this.api.request("POST", "issues", body, {
68
+ query: { fields: Tracker.FIELDS },
69
+ });
70
+ }
71
+ static parentFields(value, parent = {}) {
72
+ ensure(
73
+ HIGH.has(value.severity),
74
+ "CVE parent requires HIGH or CRITICAL severity",
75
+ );
76
+ const critical = (parent.customFields || []).some(
77
+ (field) =>
78
+ field.name === "Severity" &&
79
+ field.value?.name?.toLowerCase() === "critical",
80
+ );
81
+ const severity =
82
+ critical || value.severity === "CRITICAL" ? "Critical" : "High";
83
+ return [
84
+ { name: "CVE ID", $type: "SimpleIssueCustomField", value: value.id },
85
+ {
86
+ name: "Severity",
87
+ $type: "SingleEnumIssueCustomField",
88
+ value: { name: severity },
89
+ },
90
+ {
91
+ name: "Priority",
92
+ $type: "SingleEnumIssueCustomField",
93
+ value: { name: severity === "Critical" ? "Stopper" : "Critical" },
94
+ },
95
+ {
96
+ name: "Type",
97
+ $type: "SingleEnumIssueCustomField",
98
+ value: { name: "Group" },
99
+ },
100
+ ];
101
+ }
102
+ async ensureParentFields(parent, value) {
103
+ const location = "issues/" + parent.id;
104
+ parent = await this.api.get(location, {
105
+ query: { fields: Tracker.FIELDS },
106
+ });
107
+ const expected = Tracker.parentFields(value, parent),
108
+ scalar = (value) =>
109
+ value && typeof value === "object" ? value.name : value;
110
+ const matches = (issue) => {
111
+ const actual = Object.fromEntries(
112
+ (issue.customFields || []).map((field) => [
113
+ field.name,
114
+ scalar(field.value),
115
+ ]),
116
+ );
117
+ return expected.every(
118
+ (field) => actual[field.name] === scalar(field.value),
119
+ );
120
+ };
121
+ if (!matches(parent)) {
122
+ await this.api.post(location, { customFields: expected });
123
+ parent = await this.api.get(location, {
124
+ query: { fields: Tracker.FIELDS },
125
+ });
126
+ }
127
+ ensure(
128
+ matches(parent),
129
+ "YouTrack did not persist required parent fields: CVE ID, Severity, Priority, Type=Group",
130
+ );
131
+ return parent;
132
+ }
133
+ async ensure(value, cfg) {
134
+ const parentProject = this.env.NIGHTLY_CVE_PROJECT || "CVE";
135
+ ensure(
136
+ /^[A-Za-z0-9_-]+(?![\s\S])/.test(cfg.youtrack_project) &&
137
+ /^[A-Za-z0-9_-]+(?![\s\S])/.test(parentProject),
138
+ "Invalid YouTrack project key",
139
+ );
140
+ ensure(
141
+ cfg.time_article && cfg.tester,
142
+ "Configure nightly-audit inputs youtrack_time_tracking_article and youtrack_tester",
143
+ );
144
+ ensure(
145
+ !["time_article", "tester", "subsystem"].some((key) =>
146
+ /[{}\r\n]/.test(cfg[key] || ""),
147
+ ),
148
+ "YouTrack field values cannot contain braces or newlines",
149
+ );
150
+ const parents = new Map();
151
+ for (const alias of value.aliases)
152
+ for (const issue of await this.search(
153
+ `project: {${parentProject}} "${alias}"`,
154
+ )) {
155
+ const known = ids(issue.summary);
156
+ for (const field of issue.customFields || [])
157
+ if (field.name === "CVE ID" && typeof field.value === "string")
158
+ known.push(...ids(field.value));
159
+ if (
160
+ intersects(
161
+ known.map((id) => id.toUpperCase()),
162
+ value.aliases,
163
+ )
164
+ )
165
+ parents.set(issue.id, issue);
166
+ }
167
+ ensure(parents.size <= 1, "Multiple parent issues match advisory aliases");
168
+ const details =
169
+ "Automated scan evidence (not an exploitability assessment).\n\n" +
170
+ JSON.stringify(value, null, 2) +
171
+ "\n\nPipeline: " +
172
+ cfg.pipeline_url;
173
+ const createdParent = !parents.size;
174
+ let parent =
175
+ parents.values().next().value ||
176
+ (await this.create(
177
+ parentProject,
178
+ `[${value.id}] ${value.severity}: устранение уязвимости`,
179
+ "<!-- nightly-security parent -->\n\n" + details,
180
+ Tracker.parentFields(value),
181
+ ));
182
+ if (
183
+ createdParent ||
184
+ (parent.description || "").includes("<!-- nightly-security parent -->")
185
+ )
186
+ parent = await this.ensureParentFields(parent, value);
187
+ const [link, children] = await this.linkedChildren(parent),
188
+ marker = `<!-- nightly-security project=${cfg.project_id} -->`,
189
+ slug = cfg.project_path.split("/").at(-1);
190
+ const escapedSlug = slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
191
+ const belongs = (issue) =>
192
+ issue.project?.shortName === cfg.youtrack_project &&
193
+ ((issue.description || "").includes(marker) ||
194
+ new RegExp("в проекте\\s+" + escapedSlug + "(?:\\s|$)", "i").test(
195
+ issue.summary,
196
+ ));
197
+ const matches = new Map(
198
+ children.filter(belongs).map((issue) => [issue.id, issue]),
199
+ );
200
+ for (const alias of value.aliases)
201
+ for (const issue of await this.search(
202
+ `project: {${cfg.youtrack_project}} "${alias}"`,
203
+ ))
204
+ if (
205
+ (issue.description || "").includes(marker) &&
206
+ intersects(
207
+ ids(issue.summary).map((id) => id.toUpperCase()),
208
+ value.aliases,
209
+ )
210
+ )
211
+ matches.set(issue.id, issue);
212
+ ensure(matches.size <= 1, "Multiple project tasks match the same advisory");
213
+ const child =
214
+ matches.values().next().value ||
215
+ (await this.create(
216
+ cfg.youtrack_project,
217
+ `[${value.id}] Устранение уязвимости ${value.severity} в проекте ${slug}`,
218
+ marker + "\n\nРодитель: " + parent.idReadable + "\n\n" + details,
219
+ ));
220
+ await this.api.post("issues/" + parent.id + "/links/" + link + "/issues", {
221
+ id: child.id,
222
+ });
223
+ if ((child.description || "").includes(marker)) {
224
+ const commands = [
225
+ "Статья учета времени {" + cfg.time_article + "}",
226
+ "Tester {" + cfg.tester + "}",
227
+ ];
228
+ if (cfg.subsystem) commands.push("Subsystem {" + cfg.subsystem + "}");
229
+ await this.api.post("commands", {
230
+ query: commands.join(" "),
231
+ issues: [{ id: child.id }],
232
+ });
233
+ }
234
+ if (child.resolved) {
235
+ const command = this.env.NIGHTLY_REOPEN_COMMAND ?? "State {Новая}";
236
+ ensure(
237
+ command,
238
+ "Resolved task still has findings; configure a reopen command",
239
+ );
240
+ await this.api.post("commands", {
241
+ query: command,
242
+ issues: [{ id: child.id }],
243
+ });
244
+ }
245
+ return { parent: this.decorate(parent), task: this.decorate(child) };
246
+ }
247
+ }
248
+ module.exports = { Tracker };
@@ -0,0 +1,234 @@
1
+ const {
2
+ path,
3
+ ensure,
4
+ read,
5
+ json,
6
+ save,
7
+ COMMIT,
8
+ sleep,
9
+ equal,
10
+ } = require("../automation");
11
+ const { encoded } = require("../sbom/schema");
12
+ const { glab, projectPath, pages, API } = require("./runtime");
13
+ const { HIGH } = require("./findings");
14
+ const { Tracker } = require("./tracker");
15
+ const { isDryRun, reportPreflight } = require("../cli/command-executor");
16
+ const {
17
+ required,
18
+ httpsEndpoint,
19
+ checkGitlabAccess,
20
+ checkYouTrackAccess,
21
+ } = require("../preflight/automation");
22
+ async function track({
23
+ env = process.env,
24
+ cwd = process.cwd(),
25
+ deps = {},
26
+ dry = isDryRun(),
27
+ } = {}) {
28
+ ensure(
29
+ env.NIGHTLY_EXPECTED_COMPONENT_SHA &&
30
+ env.NIGHTLY_EXPECTED_COMPONENT_SHA === env.CI_COMMIT_SHA,
31
+ "Tracker must run the same commit as the included component",
32
+ );
33
+ required(env, [
34
+ "YOUTRACK_URL",
35
+ "YOUTRACK_PRIVATE_TOKEN",
36
+ "NIGHTLY_CONSUMER_PROJECT",
37
+ "NIGHTLY_CONSUMER_SCAN_JOB",
38
+ ]);
39
+ httpsEndpoint(env.YOUTRACK_URL, "YOUTRACK_URL");
40
+ const api = deps.api || glab(env);
41
+ await (deps.checkGitlabAccess || checkGitlabAccess)(
42
+ env,
43
+ [env.NIGHTLY_CONSUMER_PROJECT],
44
+ { api },
45
+ );
46
+ const consumer = projectPath(env.NIGHTLY_CONSUMER_PROJECT),
47
+ jobId = env.NIGHTLY_CONSUMER_SCAN_JOB;
48
+ ensure(/^\d+(?![\s\S])/.test(jobId || ""), "Invalid consumer job ID");
49
+ const job = await api.get(consumer + "/jobs/" + jobId),
50
+ pipeline = await api.get(consumer + "/pipelines/" + job.pipeline.id);
51
+ const report = json(
52
+ await api.get(
53
+ consumer +
54
+ "/jobs/" +
55
+ jobId +
56
+ "/artifacts/security-report/findings.json",
57
+ { raw: true },
58
+ ),
59
+ ),
60
+ cfg = report.config;
61
+ ensure(
62
+ report.schemaVersion === 1 &&
63
+ String(cfg.project_id) === env.NIGHTLY_CONSUMER_PROJECT,
64
+ "Consumer report identity mismatch",
65
+ );
66
+ ensure(
67
+ pipeline.source === "schedule" &&
68
+ pipeline.ref === cfg.branch &&
69
+ pipeline.sha === cfg.sha &&
70
+ String(pipeline.id) === String(cfg.pipeline_id),
71
+ "Tracking is restricted to matching scheduled branch pipelines",
72
+ );
73
+ const branch = await api.get(
74
+ consumer + "/repository/branches/" + encoded(cfg.branch),
75
+ );
76
+ ensure(
77
+ branch.protected,
78
+ "Nightly automation requires a protected production branch",
79
+ );
80
+ const base = httpsEndpoint(env.YOUTRACK_URL, "YOUTRACK_URL");
81
+ const youtrack = new API(base + "/api", {
82
+ Authorization: "Bearer " + env.YOUTRACK_PRIVATE_TOKEN,
83
+ });
84
+ const tracker = deps.tracker || new Tracker(youtrack, base, env);
85
+ try {
86
+ report.preflight = {
87
+ youtrack: await (deps.checkYouTrackAccess || checkYouTrackAccess)(
88
+ env,
89
+ cfg,
90
+ youtrack,
91
+ ),
92
+ };
93
+ } catch (error) {
94
+ if (dry) throw error;
95
+ report.errors.push(error.message);
96
+ save(path.join(cwd, "security-report/tracked.json"), report);
97
+ return 1;
98
+ }
99
+ if (dry) {
100
+ ensure(!report.errors.length, "Incomplete scan evidence blocks tracking");
101
+ reportPreflight(
102
+ "security track",
103
+ "consumer-identity",
104
+ "проверены scheduled pipeline, protected branch и источник отчёта",
105
+ );
106
+ return 0;
107
+ }
108
+ for (const value of report.findings) {
109
+ try {
110
+ ensure(HIGH.has(value.severity), "Unexpected finding severity");
111
+ Object.assign(value, await tracker.ensure(value, cfg));
112
+ } catch (error) {
113
+ value.tracking_error = error.message;
114
+ report.errors.push(value.id + ": " + error.message);
115
+ }
116
+ }
117
+ save(path.join(cwd, "security-report/tracked.json"), report);
118
+ return report.errors.length ? 1 : 0;
119
+ }
120
+ async function reconcile({
121
+ env = process.env,
122
+ cwd = process.cwd(),
123
+ deps = {},
124
+ dry = isDryRun(),
125
+ } = {}) {
126
+ const file = path.join(cwd, "security-report"),
127
+ reportFile = path.join(file, "tracked.json");
128
+ let report = read(path.join(file, "findings.json"));
129
+ if (dry)
130
+ ensure(!report.errors.length, "Incomplete scan evidence blocks tracking");
131
+ try {
132
+ if (!report.findings.length) return report.errors.length ? 1 : 0;
133
+ required(env, [
134
+ "NIGHTLY_TRACKER_PROJECT",
135
+ "NIGHTLY_COMPONENT_SHA",
136
+ "NIGHTLY_SCAN_JOB_ID",
137
+ "CI_PROJECT_ID",
138
+ ]);
139
+ const api = deps.api || glab(env);
140
+ report.preflight = {
141
+ gitlab: await (deps.checkGitlabAccess || checkGitlabAccess)(
142
+ env,
143
+ [env.NIGHTLY_TRACKER_PROJECT],
144
+ { api, write: true, purpose: "pipeline" },
145
+ ),
146
+ };
147
+ const target = projectPath(env.NIGHTLY_TRACKER_PROJECT),
148
+ sha = env.NIGHTLY_COMPONENT_SHA;
149
+ ensure(COMMIT.test(sha), "Invalid component SHA");
150
+ let ref = env.NIGHTLY_TRACKER_REF;
151
+ if (!ref) {
152
+ const refs = await pages(
153
+ api,
154
+ target + "/repository/commits/" + sha + "/refs",
155
+ { type: "tag" },
156
+ ),
157
+ names = refs
158
+ .map((item) => item.name)
159
+ .filter((name) => /^v\d+\.\d+\.\d+(?![\s\S])/.test(name))
160
+ .sort();
161
+ ensure(
162
+ names.length,
163
+ "Release the component or explicitly configure tracker_ref for a pilot",
164
+ );
165
+ ref = names.at(-1);
166
+ }
167
+ const commit = await api.get(
168
+ target + "/repository/commits/" + encoded(ref),
169
+ );
170
+ ensure(
171
+ commit.id === sha,
172
+ "Tracker reference does not match the included component",
173
+ );
174
+ reportPreflight(
175
+ "security reconcile",
176
+ "tracker-reference",
177
+ "ref очереди соответствует SHA компонента",
178
+ );
179
+ if (dry) return report.errors.length ? 1 : 0;
180
+ let pipeline = await api.post(target + "/pipeline", {
181
+ ref,
182
+ inputs: {
183
+ operation: "nightly-track",
184
+ consumer_project: env.CI_PROJECT_ID,
185
+ consumer_scan_job: env.NIGHTLY_SCAN_JOB_ID,
186
+ component_sha: sha,
187
+ },
188
+ });
189
+ ensure(
190
+ pipeline.sha === sha,
191
+ "Tracker ref changed while creating its pipeline",
192
+ );
193
+ const deadline = Date.now() + 1200000;
194
+ while (
195
+ !["success", "failed", "canceled", "skipped"].includes(pipeline.status)
196
+ ) {
197
+ ensure(Date.now() < deadline, "Timed out waiting for the YouTrack queue");
198
+ console.log("Waiting for serialized YouTrack tracking");
199
+ await (deps.sleep || sleep)(10000);
200
+ pipeline = await api.get(target + "/pipelines/" + pipeline.id);
201
+ }
202
+ const jobs = await pages(
203
+ api,
204
+ target + "/pipelines/" + pipeline.id + "/jobs",
205
+ ),
206
+ matches = jobs.filter((job) => job.name === "nightly:track");
207
+ ensure(matches.length === 1, "Tracker job missing or ambiguous");
208
+ const tracked = json(
209
+ await api.get(
210
+ target +
211
+ "/jobs/" +
212
+ matches[0].id +
213
+ "/artifacts/security-report/tracked.json",
214
+ { raw: true },
215
+ ),
216
+ );
217
+ ensure(
218
+ equal(tracked.config, report.config),
219
+ "Tracker result identity mismatch",
220
+ );
221
+ report = tracked;
222
+ ensure(
223
+ pipeline.status === "success",
224
+ "YouTrack tracking completed with errors",
225
+ );
226
+ } catch (error) {
227
+ if (dry) throw error;
228
+ report.errors.push(error.message);
229
+ } finally {
230
+ if (!dry) save(reportFile, report);
231
+ }
232
+ return report.errors.length ? 1 : 0;
233
+ }
234
+ module.exports = { track, reconcile };