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,184 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+ const { isDryRun, reportPreflight } = require("../cli/command-executor");
4
+ function licenses({
5
+ cwd = process.cwd(),
6
+ reportOnly = false,
7
+ dry = isDryRun(),
8
+ } = {}) {
9
+ const workspace = fs.realpathSync(cwd);
10
+ const standalone = path.join(workspace, "dist/standalone");
11
+ const result = { copied: 0, existing: 0, withoutLicense: 0, warnings: [] };
12
+ if (dry) result.planned = 0;
13
+
14
+ function warn(file, error) {
15
+ result.warnings.push({
16
+ path: path.relative(workspace, file),
17
+ reason: error.message,
18
+ });
19
+ }
20
+
21
+ // Reject symlinks at every level; never read or write outside this checkout.
22
+ function checked(file, kind) {
23
+ const relative = path.relative(workspace, file);
24
+ if (
25
+ !relative ||
26
+ relative === ".." ||
27
+ relative.startsWith(".." + path.sep) ||
28
+ path.isAbsolute(relative)
29
+ ) {
30
+ throw new Error("path outside checkout");
31
+ }
32
+ let current = workspace;
33
+ const parts = relative.split(path.sep);
34
+ for (let i = 0; i < parts.length; i++) {
35
+ current = path.join(current, parts[i]);
36
+ const stat = fs.lstatSync(current);
37
+ if (
38
+ stat.isSymbolicLink() ||
39
+ (i < parts.length - 1 && !stat.isDirectory())
40
+ ) {
41
+ throw new Error("unsafe symlink or parent path");
42
+ }
43
+ if (
44
+ i === parts.length - 1 &&
45
+ !(kind === "directory" ? stat.isDirectory() : stat.isFile())
46
+ ) {
47
+ throw new Error("unexpected file type");
48
+ }
49
+ }
50
+ return file;
51
+ }
52
+
53
+ function read(file) {
54
+ return fs.readFileSync(checked(file, "file"));
55
+ }
56
+
57
+ function identical(left, right) {
58
+ if (!read(left).equals(read(right)))
59
+ throw new Error("source and standalone files differ");
60
+ }
61
+
62
+ function files(directory) {
63
+ checked(directory, "directory");
64
+ const found = [];
65
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
66
+ const file = path.join(directory, entry.name);
67
+ if (entry.isDirectory()) found.push(...files(file));
68
+ else if (entry.isFile()) found.push(checked(file, "file"));
69
+ else throw new Error("non-regular compiled package entry");
70
+ }
71
+ return found;
72
+ }
73
+
74
+ function preserve(nextRoot) {
75
+ const sourceRoot = path.join(
76
+ workspace,
77
+ path.relative(standalone, nextRoot),
78
+ );
79
+ const owner = JSON.parse(read(path.join(nextRoot, "package.json")));
80
+ if (
81
+ owner.name !== "next" ||
82
+ typeof owner.version !== "string" ||
83
+ !owner.version
84
+ ) {
85
+ throw new Error("invalid standalone Next identity");
86
+ }
87
+ identical(
88
+ path.join(nextRoot, "package.json"),
89
+ path.join(sourceRoot, "package.json"),
90
+ );
91
+ const compiled = path.join(nextRoot, "dist/compiled");
92
+ const manifests = files(compiled).filter(
93
+ (file) => path.basename(file) === "package.json",
94
+ );
95
+ for (const manifest of manifests) {
96
+ try {
97
+ const target = path.dirname(manifest);
98
+ const source = path.join(sourceRoot, path.relative(nextRoot, target));
99
+ const pkg = JSON.parse(read(manifest));
100
+ if (typeof pkg.name !== "string" || !pkg.name)
101
+ throw new Error("invalid compiled package identity");
102
+ identical(manifest, path.join(source, "package.json"));
103
+ const license = path.join(source, "LICENSE");
104
+ // Some published compiled packages legitimately have no LICENSE file.
105
+ // Validate the parent first so ENOENT cannot hide an unsafe source path.
106
+ checked(source, "directory");
107
+ try {
108
+ checked(license, "file");
109
+ } catch (error) {
110
+ if (error.code !== "ENOENT") throw error;
111
+ result.withoutLicense++;
112
+ continue;
113
+ }
114
+ // No repair of modified code, assets or existing license files.
115
+ for (const file of files(target)) {
116
+ identical(file, path.join(source, path.relative(target, file)));
117
+ }
118
+ const destination = path.join(target, "LICENSE");
119
+ try {
120
+ checked(destination, "file");
121
+ identical(destination, license);
122
+ result.existing++;
123
+ continue;
124
+ } catch (error) {
125
+ if (error.code !== "ENOENT") throw error;
126
+ }
127
+ checked(target, "directory");
128
+ if (dry) result.planned++;
129
+ else {
130
+ fs.copyFileSync(license, destination, fs.constants.COPYFILE_EXCL);
131
+ result.copied++;
132
+ }
133
+ } catch (error) {
134
+ warn(manifest, error);
135
+ }
136
+ }
137
+ }
138
+
139
+ function discover(directory) {
140
+ checked(directory, "directory");
141
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
142
+ const file = path.join(directory, entry.name);
143
+ if (entry.isSymbolicLink()) {
144
+ // Standalone .bin links are not package roots and are never followed.
145
+ if (entry.name === "next" || entry.name === "node_modules")
146
+ warn(file, new Error("symlinked package root"));
147
+ continue;
148
+ }
149
+ if (!entry.isDirectory()) continue;
150
+ if (
151
+ entry.name === "next" &&
152
+ path.basename(directory) === "node_modules"
153
+ ) {
154
+ try {
155
+ preserve(file);
156
+ } catch (error) {
157
+ warn(file, error);
158
+ }
159
+ }
160
+ discover(file);
161
+ }
162
+ }
163
+
164
+ try {
165
+ discover(standalone);
166
+ } catch (error) {
167
+ warn(standalone, error);
168
+ }
169
+ if (dry) {
170
+ if (result.warnings.length)
171
+ console.error(
172
+ "Standalone LICENSE preflight: " + JSON.stringify(result.warnings),
173
+ );
174
+ else
175
+ reportPreflight(
176
+ "nextjs licenses",
177
+ "standalone-metadata",
178
+ "проверены исходники, standalone и LICENSE",
179
+ );
180
+ } else
181
+ console.log("Standalone LICENSE preservation: " + JSON.stringify(result));
182
+ return reportOnly ? result : result.warnings.length ? 2 : 0;
183
+ }
184
+ module.exports = { licenses };
@@ -0,0 +1,291 @@
1
+ // Noninteractive preflights. Remote checks are read-only and never log credentials.
2
+ const { fs, path, ensure, request } = require("../automation");
3
+ const { API, projectPath } = require("../security/runtime");
4
+ const { encoded } = require("../sbom/schema");
5
+ const { reportPreflight } = require("../cli/command-executor");
6
+ class PreflightError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "PreflightError";
10
+ }
11
+ }
12
+ function check(condition, message) {
13
+ if (!condition) throw new PreflightError(message);
14
+ }
15
+ function required(env, names) {
16
+ const missing = names.filter(
17
+ (name) => typeof env[name] !== "string" || !env[name].trim(),
18
+ );
19
+ check(!missing.length, "Missing required variables: " + missing.join(", "));
20
+ return env;
21
+ }
22
+ function credentialPair(env, user, password) {
23
+ check(
24
+ Boolean(env[user]) === Boolean(env[password]),
25
+ `Configure both ${user} and ${password}`,
26
+ );
27
+ }
28
+ function httpsEndpoint(value, name) {
29
+ let parsed;
30
+ try {
31
+ parsed = new URL(value);
32
+ } catch {
33
+ throw new PreflightError(name + " must be an explicit HTTPS URL");
34
+ }
35
+ check(
36
+ typeof value === "string" &&
37
+ !/[\x00-\x20]/.test(value) &&
38
+ parsed.protocol === "https:" &&
39
+ !parsed.username &&
40
+ !parsed.password &&
41
+ !parsed.search &&
42
+ !parsed.hash,
43
+ name + " must be an HTTPS URL without credentials, query or fragment",
44
+ );
45
+ return value.replace(/\/+$/, "");
46
+ }
47
+ async function get(api, location, options, label) {
48
+ try {
49
+ return await api.get(location, options);
50
+ } catch (error) {
51
+ const status = Number.isInteger(error.status)
52
+ ? ` (HTTP ${error.status})`
53
+ : "";
54
+ throw new PreflightError(
55
+ `${label}: connection, authentication or access check failed${status}`,
56
+ );
57
+ }
58
+ }
59
+ async function checkGitlabAccess(
60
+ env,
61
+ projects,
62
+ { write = false, purpose = "repository", api } = {},
63
+ ) {
64
+ required(env, ["CI_API_V4_URL", "GLAB_BOT_PAT"]);
65
+ const base = httpsEndpoint(env.CI_API_V4_URL, "CI_API_V4_URL");
66
+ if (env.CI_SERVER_URL)
67
+ check(
68
+ base === httpsEndpoint(env.CI_SERVER_URL, "CI_SERVER_URL") + "/api/v4",
69
+ "GitLab API URL does not match CI_SERVER_URL",
70
+ );
71
+ check(base.endsWith("/api/v4"), "CI_API_V4_URL must end with /api/v4");
72
+ const client = api || new API(base, { "PRIVATE-TOKEN": env.GLAB_BOT_PAT });
73
+ const user = await get(client, "user", undefined, "GitLab identity");
74
+ check(
75
+ user?.id && user.state === "active",
76
+ "GitLab token does not belong to an active user",
77
+ );
78
+ const token = await get(
79
+ client,
80
+ "personal_access_tokens/self",
81
+ undefined,
82
+ "GitLab token scope",
83
+ );
84
+ check(
85
+ token?.active === true &&
86
+ token.revoked !== true &&
87
+ Array.isArray(token.scopes),
88
+ "GitLab token is inactive, revoked or cannot be inspected",
89
+ );
90
+ check(
91
+ token.scopes.includes("api") ||
92
+ (!write && token.scopes.includes("read_api")),
93
+ write
94
+ ? "GitLab token requires the api scope for pipeline/repository writes"
95
+ : "GitLab token requires read_api or api scope",
96
+ );
97
+ for (const project of [...new Set(projects)]) {
98
+ check(
99
+ typeof project === "string" && project.trim() && !/[\r\n]/.test(project),
100
+ "GitLab project must be configured explicitly",
101
+ );
102
+ const data = await get(
103
+ client,
104
+ projectPath(project),
105
+ undefined,
106
+ "GitLab project access",
107
+ );
108
+ const level = user.is_admin
109
+ ? 50
110
+ : Math.max(
111
+ data.permissions?.project_access?.access_level || 0,
112
+ data.permissions?.group_access?.access_level || 0,
113
+ );
114
+ check(
115
+ level >= (write ? 30 : 20),
116
+ write
117
+ ? "GitLab project requires Developer access or higher"
118
+ : "GitLab project requires Reporter access or higher",
119
+ );
120
+ if (write) {
121
+ check(data.archived !== true, "GitLab project is archived");
122
+ if (purpose === "pipeline")
123
+ check(
124
+ data.builds_access_level !== "disabled",
125
+ "GitLab pipeline access is disabled",
126
+ );
127
+ else
128
+ check(
129
+ data.repository_access_level !== "disabled" &&
130
+ data.merge_requests_access_level !== "disabled" &&
131
+ data.can_create_merge_request_in !== false,
132
+ "GitLab repository/MR access is disabled",
133
+ );
134
+ }
135
+ }
136
+ reportPreflight(
137
+ "automation",
138
+ "gitlab-access",
139
+ "проверены GitLab token, scope и роль в проектах",
140
+ );
141
+ return {
142
+ identity: "verified",
143
+ token_scope: "verified",
144
+ project_role: "verified",
145
+ protected_branch_policies: "checked_by_operations",
146
+ };
147
+ }
148
+ async function checkYouTrackAccess(env, cfg, api) {
149
+ required(env, ["YOUTRACK_URL", "YOUTRACK_PRIVATE_TOKEN"]);
150
+ const base = httpsEndpoint(env.YOUTRACK_URL, "YOUTRACK_URL");
151
+ check(
152
+ /^[A-Za-z0-9_-]+$/.test(cfg.youtrack_project || ""),
153
+ "YouTrack project key is required",
154
+ );
155
+ const client =
156
+ api ||
157
+ new API(base + "/api", {
158
+ Authorization: "Bearer " + env.YOUTRACK_PRIVATE_TOKEN,
159
+ });
160
+ const user = await get(
161
+ client,
162
+ "users/me",
163
+ { query: { fields: "id,login,guest,banned" } },
164
+ "YouTrack identity",
165
+ );
166
+ check(
167
+ user?.id && user.login && user.guest !== true && user.banned !== true,
168
+ "YouTrack token must belong to an active, non-guest user",
169
+ );
170
+ const parentProject = env.NIGHTLY_CVE_PROJECT || "CVE";
171
+ check(
172
+ /^[A-Za-z0-9_-]+$/.test(parentProject),
173
+ "Invalid parent YouTrack project",
174
+ );
175
+ for (const project of [...new Set([parentProject, cfg.youtrack_project])]) {
176
+ const projects = await get(
177
+ client,
178
+ "admin/projects",
179
+ { query: { query: project, fields: "id,shortName,archived", $top: 100 } },
180
+ "YouTrack project access",
181
+ );
182
+ check(Array.isArray(projects), "Invalid YouTrack project response");
183
+ const matches = projects.filter((item) => item.shortName === project);
184
+ check(
185
+ matches.length === 1 && matches[0].archived !== true,
186
+ "YouTrack project is missing, inaccessible or archived",
187
+ );
188
+ const fields = await get(
189
+ client,
190
+ "admin/projects/" + encoded(matches[0].id) + "/customFields",
191
+ { query: { fields: "field(name)", $top: 100 } },
192
+ "YouTrack custom fields",
193
+ );
194
+ check(Array.isArray(fields), "Invalid YouTrack custom field response");
195
+ const names = new Set(fields.map((field) => field.field?.name));
196
+ const expected =
197
+ project === parentProject
198
+ ? ["CVE ID", "Severity", "Priority", "Type"]
199
+ : [
200
+ "Tester",
201
+ "Статья учета времени",
202
+ ...(cfg.subsystem ? ["Subsystem"] : []),
203
+ ];
204
+ const missing = expected.filter((name) => !names.has(name));
205
+ check(
206
+ !missing.length,
207
+ "YouTrack project is missing required fields: " + missing.join(", "),
208
+ );
209
+ await get(
210
+ client,
211
+ "issues",
212
+ { query: { query: `project: {${project}}`, fields: "id", $top: 1 } },
213
+ "YouTrack issue read access",
214
+ );
215
+ }
216
+ // GET access cannot prove create/update/link permission or workflow acceptance.
217
+ reportPreflight(
218
+ "security track",
219
+ "youtrack-access",
220
+ "проверены вход в YouTrack, проекты, поля и чтение",
221
+ );
222
+ return {
223
+ identity: "verified",
224
+ projects: "read_verified",
225
+ required_fields: "verified",
226
+ write_permissions: "checked_by_operations",
227
+ };
228
+ }
229
+ async function checkRegistryAccess(
230
+ namespace,
231
+ { transport = request, probe } = {},
232
+ ) {
233
+ const host = namespace.split("/")[0];
234
+ const base = httpsEndpoint("https://" + host, "Registry");
235
+ let response;
236
+ try {
237
+ response = await transport(base + "/v2/", {
238
+ timeout: 15000,
239
+ limit: 1024 * 1024,
240
+ });
241
+ } catch {
242
+ throw new PreflightError(
243
+ "Registry is unavailable or TLS validation failed",
244
+ );
245
+ }
246
+ check(
247
+ response.status === 200 ||
248
+ (response.status === 401 && !!response.headers?.get("www-authenticate")),
249
+ "Registry v2 endpoint is unavailable",
250
+ );
251
+ // ORAS handles registry authentication; an exact target read distinguishes
252
+ // auth failures from an empty/missing catalogue before upstream work begins.
253
+ const existing = await probe();
254
+ reportPreflight(
255
+ "sbom catalog populate",
256
+ "registry-access",
257
+ "проверены доступность registry и чтение namespace",
258
+ );
259
+ return {
260
+ endpoint: "reachable",
261
+ authentication: "oras_login_completed",
262
+ namespace_read: existing ? "verified" : "missing_or_hidden",
263
+ push_permissions: "checked_by_publish",
264
+ };
265
+ }
266
+ function checkExecutables(names, env = process.env) {
267
+ for (const name of names) {
268
+ const found = (env.PATH || "").split(path.delimiter).some((directory) => {
269
+ const file = path.join(directory, name);
270
+ try {
271
+ return (
272
+ fs.statSync(file).isFile() &&
273
+ (fs.accessSync(file, fs.constants.X_OK), true)
274
+ );
275
+ } catch {
276
+ return false;
277
+ }
278
+ });
279
+ check(found, "Required executable is unavailable: " + name);
280
+ }
281
+ }
282
+ module.exports = {
283
+ checkExecutables,
284
+ PreflightError,
285
+ required,
286
+ credentialPair,
287
+ httpsEndpoint,
288
+ checkGitlabAccess,
289
+ checkYouTrackAccess,
290
+ checkRegistryAccess,
291
+ };
@@ -0,0 +1,4 @@
1
+ const { resolve } = require('./resolve');
2
+ const { populate } = require('./populate');
3
+
4
+ module.exports = { resolve, populate };