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,192 @@
1
+ const io = require("../automation");
2
+ const { fs, path, ensure, json, exec, request, sleep, temporary } = io;
3
+ const { encoded } = require("../sbom/schema");
4
+ function cleanEnv(env = process.env) {
5
+ return Object.fromEntries(
6
+ Object.entries(env).filter(
7
+ ([key]) =>
8
+ !/TOKEN|PASSWORD|SECRET|MATTERMOST|AUTHFILE/i.test(key) &&
9
+ !/^(GIT_CONFIG_|CI_|GLAB_|GITLAB_|YOUTRACK_|MATTERMOST_|NEXUS_)/.test(
10
+ key,
11
+ ) &&
12
+ (!key.startsWith("TRIVY_") ||
13
+ [
14
+ "TRIVY_DB_REPOSITORY",
15
+ "TRIVY_JAVA_DB_REPOSITORY",
16
+ "TRIVY_CACHE_DIR",
17
+ ].includes(key)),
18
+ ),
19
+ );
20
+ }
21
+ function run(args, cwd, options = {}, env = process.env) {
22
+ const clean = cleanEnv(env);
23
+ if (args[0] === "npm")
24
+ for (const key of ["NPM_TOKEN", "NODE_AUTH_TOKEN"])
25
+ if (env[key]) clean[key] = env[key];
26
+ return exec(args, { cwd, ...options, env: clean });
27
+ }
28
+ async function registryAuth(env, action) {
29
+ return temporary("nightly-registry-", async (directory) => {
30
+ const auths = {};
31
+ ensure(
32
+ Boolean(env.NEXUS_USER) === Boolean(env.NEXUS_PASSWORD),
33
+ "Configure both NEXUS_USER and NEXUS_PASSWORD",
34
+ );
35
+ if (env.NEXUS_USER && env.NEXUS_PASSWORD) {
36
+ ensure(
37
+ env.NIGHTLY_REGISTRY_HOSTS?.trim(),
38
+ "NIGHTLY_REGISTRY_HOSTS is required with registry credentials",
39
+ );
40
+ for (const host of env.NIGHTLY_REGISTRY_HOSTS.split(",")) {
41
+ ensure(
42
+ /^[a-zA-Z0-9.-]+(?::\d+)?(?![\s\S])/.test(host),
43
+ "Invalid registry hostname",
44
+ );
45
+ auths[host] = {
46
+ auth: Buffer.from(env.NEXUS_USER + ":" + env.NEXUS_PASSWORD).toString(
47
+ "base64",
48
+ ),
49
+ };
50
+ }
51
+ }
52
+ if (env.CI_REGISTRY_USER && env.CI_REGISTRY_PASSWORD)
53
+ auths[env.CI_REGISTRY] = {
54
+ auth: Buffer.from(
55
+ env.CI_REGISTRY_USER + ":" + env.CI_REGISTRY_PASSWORD,
56
+ ).toString("base64"),
57
+ };
58
+ const runtime = { ...env };
59
+ if (Object.keys(auths).length) {
60
+ const file = path.join(directory, "config.json");
61
+ fs.writeFileSync(file, JSON.stringify({ auths }), { mode: 0o600 });
62
+ runtime.DOCKER_CONFIG = directory;
63
+ runtime.REGISTRY_AUTH_FILE = file;
64
+ }
65
+ return action(runtime);
66
+ });
67
+ }
68
+ async function artifactRedirect(url, transport = request) {
69
+ for (let hop = 0; hop < 4; hop++) {
70
+ const parsed = new URL(url);
71
+ ensure(
72
+ parsed.protocol === "https:" && !parsed.username && !parsed.password,
73
+ "Unsafe artifact redirect",
74
+ );
75
+ let res;
76
+ try {
77
+ res = await transport(url, {
78
+ headers: { "User-Agent": "nightly-security" },
79
+ });
80
+ } catch {
81
+ throw new Error("Artifact download connection failed");
82
+ }
83
+ if (
84
+ [301, 302, 303, 307, 308].includes(res.status) &&
85
+ res.headers.get("location")
86
+ ) {
87
+ url = new URL(res.headers.get("location"), url).href;
88
+ continue;
89
+ }
90
+ ensure(
91
+ res.status >= 200 && res.status < 300,
92
+ `Artifact download failed (HTTP ${res.status})`,
93
+ );
94
+ return res.body;
95
+ }
96
+ throw new Error("Too many artifact redirects");
97
+ }
98
+ class API {
99
+ constructor(base, headers = {}, transport = request, pause = sleep) {
100
+ const parsed = new URL(base);
101
+ ensure(
102
+ parsed.protocol === "https:" && !parsed.username && !parsed.password,
103
+ "HTTPS is required for API endpoints",
104
+ );
105
+ this.base = base.replace(/\/(?![\s\S])/, "");
106
+ this.headers = headers;
107
+ this.transport = transport;
108
+ this.pause = pause;
109
+ }
110
+ async request(method, location, data, { raw = false, query } = {}) {
111
+ let url = this.base + "/" + location.replace(/^\//, "");
112
+ if (query) url += "?" + new URLSearchParams(query);
113
+ const headers = {
114
+ Accept: "application/json",
115
+ "User-Agent": "nightly-security",
116
+ ...this.headers,
117
+ };
118
+ if (data !== undefined) headers["Content-Type"] = "application/json";
119
+ for (let attempt = 0; attempt < 3; attempt++) {
120
+ let res;
121
+ try {
122
+ res = await this.transport(url, {
123
+ method,
124
+ headers,
125
+ body: data === undefined ? undefined : JSON.stringify(data),
126
+ });
127
+ } catch {
128
+ throw new Error(`API ${method} connection failed`);
129
+ }
130
+ if (
131
+ method === "GET" &&
132
+ raw &&
133
+ [301, 302, 303, 307, 308].includes(res.status) &&
134
+ res.headers.get("location")
135
+ )
136
+ return artifactRedirect(
137
+ new URL(res.headers.get("location"), url).href,
138
+ this.transport,
139
+ );
140
+ if (
141
+ method === "GET" &&
142
+ [429, 502, 503, 504].includes(res.status) &&
143
+ attempt < 2
144
+ ) {
145
+ await this.pause(1000 * 2 ** attempt);
146
+ continue;
147
+ }
148
+ if (res.status < 200 || res.status >= 300) {
149
+ const error = new Error(`API ${method} failed (HTTP ${res.status})`);
150
+ error.status = res.status;
151
+ throw error;
152
+ }
153
+ return raw ? res.body : json(res.body.length ? res.body : "null");
154
+ }
155
+ throw new Error("API retries exhausted");
156
+ }
157
+ get(location, options) {
158
+ return this.request("GET", location, undefined, options);
159
+ }
160
+ post(location, data) {
161
+ return this.request("POST", location, data);
162
+ }
163
+ }
164
+ function glab(env = process.env) {
165
+ ensure(
166
+ env.CI_API_V4_URL && env.GLAB_BOT_PAT,
167
+ "GitLab API and GLAB_BOT_PAT are required",
168
+ );
169
+ return new API(env.CI_API_V4_URL, { "PRIVATE-TOKEN": env.GLAB_BOT_PAT });
170
+ }
171
+ const projectPath = (project) => "projects/" + encoded(String(project));
172
+ async function pages(api, location, query = {}) {
173
+ const result = [];
174
+ for (let page = 1; ; page++) {
175
+ const batch = await api.get(location, {
176
+ query: { ...query, per_page: 100, page },
177
+ });
178
+ ensure(Array.isArray(batch), "Expected paginated API list");
179
+ result.push(...batch);
180
+ if (batch.length < 100) return result;
181
+ }
182
+ }
183
+ module.exports = {
184
+ cleanEnv,
185
+ run,
186
+ registryAuth,
187
+ artifactRedirect,
188
+ API,
189
+ glab,
190
+ projectPath,
191
+ pages,
192
+ };
@@ -0,0 +1,300 @@
1
+ const {
2
+ fs,
3
+ path,
4
+ ensure,
5
+ read,
6
+ save,
7
+ json,
8
+ temporary,
9
+ repositoryPath,
10
+ DIGEST,
11
+ } = require("../automation");
12
+ const { run, API } = require("./runtime");
13
+ const {
14
+ npmFindings,
15
+ trivyFindings,
16
+ nodeFindings,
17
+ mergeFindings,
18
+ } = require("./findings");
19
+ const { required, checkExecutables } = require("../preflight/automation");
20
+ const { isDryRun, reportPreflight } = require("../cli/command-executor");
21
+ const NODE_FEED = "nodejs/security-wg/main/vuln/core/index.json";
22
+ function runtimeFromDockerfile(text) {
23
+ const args = {},
24
+ stages = {};
25
+ let last;
26
+ for (const raw of text.split(/\r?\n/)) {
27
+ const line = raw.trim();
28
+ if (!line || line.startsWith("#")) continue;
29
+ if (/^ARG /i.test(line) && !Object.keys(stages).length) {
30
+ const match = line.slice(4).match(/^([^=]+)(?:=(.*))?(?![\s\S])/);
31
+ args[match[1]] = (match[2] || "").replace(/^["']|["'](?![\s\S])/g, "");
32
+ }
33
+ if (!/^FROM /i.test(line)) continue;
34
+ // FROM accepts only literal images/aliases/global ARGs. No shell evaluation.
35
+ const parts =
36
+ line
37
+ .replace(/\s+#.*(?![\s\S])/, "")
38
+ .match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
39
+ const offset = parts[1]?.startsWith("--platform=") ? 2 : 1;
40
+ let image = (parts[offset] || "")
41
+ .replace(/^["']|["'](?![\s\S])/g, "")
42
+ .replace(
43
+ /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
44
+ (_, a, b) => args[a || b] ?? "$UNRESOLVED",
45
+ );
46
+ ensure(
47
+ image && !image.includes("$") && image !== "scratch",
48
+ "Runtime FROM cannot be resolved safely",
49
+ );
50
+ last = stages[image.toLowerCase()] || image;
51
+ const alias =
52
+ parts[offset + 1]?.toUpperCase() === "AS" && parts[offset + 2]
53
+ ? parts[offset + 2].toLowerCase()
54
+ : String(Object.keys(stages).length);
55
+ stages[alias] = last;
56
+ }
57
+ ensure(last, "No runtime FROM found");
58
+ return last;
59
+ }
60
+ class Scanner {
61
+ constructor(env = process.env, deps = {}) {
62
+ this.env = env;
63
+ this.deps = deps;
64
+ }
65
+ run(args, cwd, options) {
66
+ return (this.deps.run || run)(args, cwd, options, this.env);
67
+ }
68
+ async auditNpm(root, evidence) {
69
+ const proc = this.run(
70
+ ["npm", "audit", "--omit=dev", "--audit-level=high", "--json"],
71
+ root,
72
+ { allowed: [0, 1] },
73
+ ),
74
+ report = json(proc.stdout);
75
+ if (evidence) save(evidence, report);
76
+ const values = await npmFindings(report, this.deps.aliases);
77
+ ensure(
78
+ proc.status === 0 || values.length,
79
+ "npm audit failed without HIGH/CRITICAL findings",
80
+ );
81
+ return [report, values];
82
+ }
83
+ immutableImage(image) {
84
+ const digest = this.run([
85
+ "skopeo",
86
+ "inspect",
87
+ "--format",
88
+ "{{.Digest}}",
89
+ "docker://" + image,
90
+ ]).stdout.trim();
91
+ ensure(DIGEST.test(digest), "Registry did not return an image digest");
92
+ return image.split("@")[0] + "@" + digest;
93
+ }
94
+ nodeVersion(image, platform) {
95
+ const container = this.run([
96
+ "buildah",
97
+ "from",
98
+ "--platform",
99
+ platform,
100
+ image,
101
+ ]).stdout.trim();
102
+ try {
103
+ const value = this.run([
104
+ "buildah",
105
+ "run",
106
+ "--isolation=chroot",
107
+ container,
108
+ "--",
109
+ this.env.NIGHTLY_NODE_BINARY || "/nodejs/bin/node",
110
+ "--version",
111
+ ])
112
+ .stdout.trim()
113
+ .replace(/^v/, "");
114
+ ensure(
115
+ /^\d+\.\d+\.\d+(?![\s\S])/.test(value),
116
+ "Cannot determine runtime Node.js version",
117
+ );
118
+ return value;
119
+ } finally {
120
+ this.run(["buildah", "rm", container]);
121
+ }
122
+ }
123
+ async feed() {
124
+ return this.deps.feed
125
+ ? this.deps.feed()
126
+ : new API("https://raw.githubusercontent.com").get(NODE_FEED);
127
+ }
128
+ async scanImage(image, platform, feed) {
129
+ ensure(
130
+ ["linux/amd64", "linux/arm64"].includes(platform),
131
+ "Unsupported runtime platform",
132
+ );
133
+ const report = await temporary("nightly-trivy-", async (dir) => {
134
+ const filename = path.join(dir, "report.json");
135
+ this.run([
136
+ "trivy",
137
+ "image",
138
+ "--scanners",
139
+ "vuln",
140
+ "--severity",
141
+ "HIGH,CRITICAL",
142
+ "--skip-db-update=false",
143
+ "--ignore-unfixed=false",
144
+ "--ignorefile",
145
+ "/dev/null",
146
+ "--config",
147
+ "/dev/null",
148
+ "--platform",
149
+ platform,
150
+ "--format",
151
+ "json",
152
+ "--output",
153
+ filename,
154
+ image,
155
+ ]);
156
+ return read(filename);
157
+ });
158
+ const values = trivyFindings(report);
159
+ let version = null;
160
+ if ((this.env.NIGHTLY_CHECK_NODE || "true") === "true") {
161
+ version = this.nodeVersion(image, platform);
162
+ feed = feed || (await this.feed());
163
+ ensure(
164
+ feed && Object.keys(feed).length,
165
+ "Empty Node.js vulnerability feed",
166
+ );
167
+ values.push(...nodeFindings(version, feed));
168
+ }
169
+ return [report, mergeFindings(values), version];
170
+ }
171
+ config() {
172
+ const e = this.env;
173
+ for (const key of [
174
+ "CI_PROJECT_ID",
175
+ "CI_PROJECT_PATH",
176
+ "CI_COMMIT_SHA",
177
+ "CI_PIPELINE_ID",
178
+ "CI_PIPELINE_URL",
179
+ "CI_JOB_ID",
180
+ ])
181
+ ensure(e[key], key + " is required");
182
+ return {
183
+ project_id: e.CI_PROJECT_ID,
184
+ project_path: e.CI_PROJECT_PATH,
185
+ sha: e.CI_COMMIT_SHA,
186
+ branch: e.NIGHTLY_MAIN_BRANCH || "master",
187
+ youtrack_project: e.NIGHTLY_YOUTRACK_PROJECT || "",
188
+ subsystem: e.NIGHTLY_YOUTRACK_SUBSYSTEM || "",
189
+ time_article: e.NIGHTLY_YOUTRACK_TIME_TRACKING_ARTICLE || "",
190
+ tester: e.NIGHTLY_YOUTRACK_TESTER || "",
191
+ pipeline_id: e.CI_PIPELINE_ID,
192
+ pipeline_url: e.CI_PIPELINE_URL,
193
+ };
194
+ }
195
+ async preflight(root) {
196
+ this.config();
197
+ required(this.env, [
198
+ "NIGHTLY_YOUTRACK_PROJECT",
199
+ "NIGHTLY_YOUTRACK_TIME_TRACKING_ARTICLE",
200
+ "NIGHTLY_YOUTRACK_TESTER",
201
+ ]);
202
+ const tools = ["trivy", "skopeo"];
203
+ if ((this.env.NIGHTLY_NPM_ENABLED || "true") === "true") {
204
+ ensure(
205
+ fs.statSync(repositoryPath(root, "package-lock.json")).isFile(),
206
+ "package-lock.json is required",
207
+ );
208
+ read(repositoryPath(root, "package-lock.json"));
209
+ tools.push("npm");
210
+ }
211
+ if ((this.env.NIGHTLY_CHECK_NODE || "true") === "true")
212
+ tools.push("buildah");
213
+ (this.deps.checkExecutables || checkExecutables)(tools, this.env);
214
+ const platforms = (this.env.NIGHTLY_PLATFORMS || "linux/amd64").split(",");
215
+ ensure(
216
+ platforms.length &&
217
+ platforms.every((platform) =>
218
+ ["linux/amd64", "linux/arm64"].includes(platform),
219
+ ),
220
+ "Unsupported runtime platform",
221
+ );
222
+ const dockerfile = repositoryPath(
223
+ root,
224
+ this.env.NIGHTLY_DOCKERFILE || "build/Dockerfile",
225
+ );
226
+ this.immutableImage(
227
+ runtimeFromDockerfile(fs.readFileSync(dockerfile, "utf8")),
228
+ );
229
+ reportPreflight(
230
+ "security scan",
231
+ "scan-inputs",
232
+ "проверены настройки, файлы, инструменты и доступ к runtime-образу",
233
+ );
234
+ }
235
+ async scan(root = process.cwd(), { dry = isDryRun() } = {}) {
236
+ if (dry) {
237
+ await this.preflight(root);
238
+ return 0;
239
+ }
240
+ const output = path.join(root, "security-report"),
241
+ report = {
242
+ schemaVersion: 1,
243
+ config: this.config(),
244
+ findings: [],
245
+ errors: [],
246
+ runtime: {},
247
+ };
248
+ try {
249
+ required(this.env, [
250
+ "NIGHTLY_YOUTRACK_PROJECT",
251
+ "NIGHTLY_YOUTRACK_TIME_TRACKING_ARTICLE",
252
+ "NIGHTLY_YOUTRACK_TESTER",
253
+ ]);
254
+ if ((this.env.NIGHTLY_NPM_ENABLED || "true") === "true") {
255
+ try {
256
+ ensure(
257
+ fs.statSync(repositoryPath(root, "package-lock.json")).isFile(),
258
+ "package-lock.json is required",
259
+ );
260
+ const [, values] = await this.auditNpm(
261
+ root,
262
+ path.join(output, "npm.json"),
263
+ );
264
+ report.findings.push(...values);
265
+ } catch (error) {
266
+ report.errors.push("npm: " + error.message);
267
+ }
268
+ }
269
+ try {
270
+ const dockerfile = this.env.NIGHTLY_DOCKERFILE || "build/Dockerfile",
271
+ image = this.immutableImage(
272
+ runtimeFromDockerfile(
273
+ fs.readFileSync(repositoryPath(root, dockerfile), "utf8"),
274
+ ),
275
+ ),
276
+ platforms = (this.env.NIGHTLY_PLATFORMS || "linux/amd64").split(",");
277
+ report.runtime = { image, dockerfile, platforms };
278
+ for (const [index, platform] of platforms.entries()) {
279
+ const [raw, values, version] = await this.scanImage(image, platform);
280
+ save(path.join(output, `runtime-${index}.json`), raw);
281
+ report.runtime[platform] = { node: version };
282
+ report.findings.push(...values);
283
+ }
284
+ } catch (error) {
285
+ report.errors.push("runtime: " + error.message);
286
+ }
287
+ } catch (error) {
288
+ report.errors.push(error.message);
289
+ } finally {
290
+ report.findings = mergeFindings(report.findings);
291
+ save(path.join(output, "findings.json"), report);
292
+ fs.writeFileSync(
293
+ path.join(output, "scan.env"),
294
+ "NIGHTLY_SCAN_JOB_ID=" + this.env.CI_JOB_ID + "\n",
295
+ );
296
+ }
297
+ return report.errors.length ? 1 : 0;
298
+ }
299
+ }
300
+ module.exports = { Scanner, runtimeFromDockerfile, NODE_FEED };