spectrum-cli 6.0.1 → 6.0.2

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 CHANGED
@@ -9,6 +9,14 @@ This is the changelog of the `spectrum-cli` npm package. Its version comes from
9
9
  `package.json`; the release commands use Git tags and `CHANGELOG.md` in the
10
10
  **target project** and do not update that project's `package.json`.
11
11
 
12
+ ## 🚀 [6.0.2] - 2026-10-29
13
+
14
+ ### Fixed
15
+
16
+ - Ночной npm audit использует временный registry-scoped user config с Nexus credentials, сохраняет существующую конфигурацию и удаляет auth-файл после команды. Ошибки registry содержат исходный HTTP-код и сообщение без секретов.
17
+ - Версия Node из runtime проверяется через Skopeo, rootless OCI unpack и запуск binary от непривилегированного uid в chroot. Сканирование больше не зависит от Buildah user/mount namespaces. Buildah остаётся инструментом сборки исправленного образа.
18
+ - При ошибке проверки Node сохраняется уже полученный Trivy-отчёт; причины неполного сканирования выводятся в job log с удалением credentials.
19
+
12
20
  ## 🚀 [6.0.1] - 2026-10-29
13
21
 
14
22
  ### Fixed
package/README.md CHANGED
@@ -612,6 +612,20 @@ npm pack --dry-run
612
612
  fixtures получены из прежнего Python-кода; исполняемый Python в пакет не входит.
613
613
  Публикация CLI, образов, компонентов и запуск живого pipeline — отдельные шаги.
614
614
 
615
+ ### npm registry и проверка runtime
616
+
617
+ `NIGHTLY_NPM_REGISTRY` задаёт доверенный HTTPS registry для npm-команд ночного процесса. При наличии
618
+ `NEXUS_USER` / `NEXUS_PASSWORD` CLI создаёт временный `.npmrc` с BASIC auth, ограниченной host/path этого registry.
619
+ Существующий user config сохраняется, исходный файл не меняется. Временный файл имеет права `0600`, удаляется
620
+ после команды и не входит в отчёт. URL registry передаётся CI-компонентом; значения инфраструктуры в CLI не зашиты.
621
+ Ошибки npm сохраняют HTTP-код и причину, при этом credentials удаляются из диагностических сообщений.
622
+
623
+ Для версии Node из runtime нужны Skopeo, umoci и chroot. CLI копирует выбранную платформу immutable OCI-образа,
624
+ распаковывает rootfs в rootless-режиме и запускает только настроенный Node binary с `--version` от uid/gid 65534.
625
+ Проверка требует доступный chroot (`CAP_SYS_CHROOT`), но не Buildah user/mount namespaces. Кросс-платформенный запуск
626
+ требует binfmt/QEMU. Buildah нужен для пересборки runtime при автоматическом исправлении; отсутствие его нужных
627
+ возможностей блокирует публикацию такого исправления и отражается в отчёте.
628
+
615
629
  ### Preflight для команд автоматизации
616
630
 
617
631
  Обязательные deployment-настройки задаются явно:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectrum-cli",
3
- "version": "6.0.1",
3
+ "version": "6.0.2",
4
4
  "description": "🚀 Modern CLI for development workflow automation - release management, versioning, and changelog generation",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -125,10 +125,19 @@ function exec(
125
125
  encoding: binary ? null : "utf8",
126
126
  windowsHide: true,
127
127
  });
128
- ensure(
129
- !result.error && allowed.includes(result.status),
130
- `${label || path.basename(args[0]) + " " + (args[1] || "")} failed (exit ${result.status})`,
131
- );
128
+ if (result.error || !allowed.includes(result.status)) {
129
+ const error = new Error(
130
+ `${label || path.basename(args[0]) + " " + (args[1] || "")} failed (exit ${result.status})`,
131
+ );
132
+ // Keep subprocess output out of generic error messages and serialization.
133
+ // Callers that know their credential context can expose redacted diagnostics.
134
+ Object.defineProperties(error, {
135
+ stdout: { value: result.stdout },
136
+ stderr: { value: result.stderr },
137
+ status: { value: result.status },
138
+ });
139
+ throw error;
140
+ }
132
141
  return {
133
142
  stdout: result.stdout,
134
143
  stderr: result.stderr,
@@ -23,7 +23,41 @@ function run(args, cwd, options = {}, env = process.env) {
23
23
  if (args[0] === "npm")
24
24
  for (const key of ["NPM_TOKEN", "NODE_AUTH_TOKEN"])
25
25
  if (env[key]) clean[key] = env[key];
26
- return exec(args, { cwd, ...options, env: clean });
26
+ try {
27
+ return exec(args, { cwd, ...options, env: clean });
28
+ } catch (error) {
29
+ const detail = diagnostic(error.stderr || "", env);
30
+ if (detail) error.message += ": " + detail;
31
+ throw error;
32
+ }
33
+ }
34
+ function diagnostic(value, env = process.env) {
35
+ let text = String(value || "");
36
+ const secrets = Object.entries(env)
37
+ .filter(
38
+ ([key, value]) =>
39
+ /TOKEN|PASSWORD|SECRET|MATTERMOST_HOOK/i.test(key) && value,
40
+ )
41
+ .map(([, value]) => String(value));
42
+ if (env.NEXUS_USER && env.NEXUS_PASSWORD)
43
+ secrets.push(
44
+ Buffer.from(env.NEXUS_USER + ":" + env.NEXUS_PASSWORD).toString("base64"),
45
+ );
46
+ for (const secret of secrets.sort((a, b) => b.length - a.length))
47
+ text = text.split(secret).join("[REDACTED]");
48
+ return text
49
+ .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/g, "$1[REDACTED]@")
50
+ .replace(
51
+ /([?&](?:token|access_token|auth|password)=)[^&\s]+/gi,
52
+ "$1[REDACTED]",
53
+ )
54
+ .replace(
55
+ /\b(Bearer|Basic)\s+(?!realm=)[A-Za-z0-9+/_=.-]+/gi,
56
+ "$1 [REDACTED]",
57
+ )
58
+ .replace(/[\r\n]+/g, " ")
59
+ .slice(0, 2000)
60
+ .trim();
27
61
  }
28
62
  async function registryAuth(env, action) {
29
63
  return temporary("nightly-registry-", async (directory) => {
@@ -56,6 +90,45 @@ async function registryAuth(env, action) {
56
90
  ).toString("base64"),
57
91
  };
58
92
  const runtime = { ...env };
93
+ if (env.NIGHTLY_NPM_REGISTRY && env.NIGHTLY_NPM_ENABLED !== "false") {
94
+ let registry;
95
+ try {
96
+ registry = new URL(env.NIGHTLY_NPM_REGISTRY);
97
+ } catch {
98
+ throw new Error("NIGHTLY_NPM_REGISTRY must be an HTTPS registry URL");
99
+ }
100
+ ensure(
101
+ registry.protocol === "https:" &&
102
+ !registry.username &&
103
+ !registry.password &&
104
+ !registry.search &&
105
+ !registry.hash,
106
+ "NIGHTLY_NPM_REGISTRY must be an HTTPS registry URL",
107
+ );
108
+ runtime.NPM_CONFIG_REGISTRY = registry.href;
109
+ if (env.NEXUS_USER && env.NEXUS_PASSWORD) {
110
+ const userconfig =
111
+ env.NPM_CONFIG_USERCONFIG ||
112
+ env.npm_config_userconfig ||
113
+ path.join(require("node:os").homedir(), ".npmrc");
114
+ const existing = fs.existsSync(userconfig)
115
+ ? fs.readFileSync(userconfig, "utf8")
116
+ : "";
117
+ const scope =
118
+ "//" + registry.host + registry.pathname.replace(/\/?$/, "/");
119
+ const auth = Buffer.from(
120
+ env.NEXUS_USER + ":" + env.NEXUS_PASSWORD,
121
+ ).toString("base64");
122
+ const file = path.join(directory, "npmrc");
123
+ fs.writeFileSync(
124
+ file,
125
+ existing.trimEnd() + "\n" + scope + ":_auth=" + auth + "\n",
126
+ { mode: 0o600 },
127
+ );
128
+ runtime.NPM_CONFIG_USERCONFIG = file;
129
+ delete runtime.npm_config_userconfig;
130
+ }
131
+ }
59
132
  if (Object.keys(auths).length) {
60
133
  const file = path.join(directory, "config.json");
61
134
  fs.writeFileSync(file, JSON.stringify({ auths }), { mode: 0o600 });
@@ -182,6 +255,7 @@ async function pages(api, location, query = {}) {
182
255
  }
183
256
  module.exports = {
184
257
  cleanEnv,
258
+ diagnostic,
185
259
  run,
186
260
  registryAuth,
187
261
  artifactRedirect,
@@ -6,10 +6,12 @@ const {
6
6
  save,
7
7
  json,
8
8
  temporary,
9
+ temp,
10
+ remove,
9
11
  repositoryPath,
10
12
  DIGEST,
11
13
  } = require("../automation");
12
- const { run, API } = require("./runtime");
14
+ const { run, API, diagnostic } = require("./runtime");
13
15
  const {
14
16
  npmFindings,
15
17
  trivyFindings,
@@ -67,12 +69,38 @@ class Scanner {
67
69
  }
68
70
  async auditNpm(root, evidence) {
69
71
  const proc = this.run(
70
- ["npm", "audit", "--omit=dev", "--audit-level=high", "--json"],
71
- root,
72
- { allowed: [0, 1] },
73
- ),
72
+ ["npm", "audit", "--omit=dev", "--audit-level=high", "--json"],
73
+ root,
74
+ { allowed: [0, 1] },
75
+ );
76
+ if (evidence)
77
+ save(evidence.replace(/\.json$/, "-command.json"), {
78
+ status: proc.status,
79
+ stderr: diagnostic(proc.stderr, this.env),
80
+ });
81
+ let report;
82
+ try {
74
83
  report = json(proc.stdout);
84
+ } catch {
85
+ throw new Error(
86
+ "npm audit returned invalid JSON" +
87
+ (proc.stderr ? ": " + diagnostic(proc.stderr, this.env) : ""),
88
+ );
89
+ }
75
90
  if (evidence) save(evidence, report);
91
+ if (!report || report.error || !report.vulnerabilities) {
92
+ const code = report?.statusCode || report?.error?.code || proc.status;
93
+ const detail = diagnostic(
94
+ report?.message ||
95
+ report?.error?.summary ||
96
+ report?.error?.detail ||
97
+ proc.stderr,
98
+ this.env,
99
+ );
100
+ throw new Error(
101
+ `npm audit returned no vulnerability report (${code})${detail ? ": " + detail : ""}`,
102
+ );
103
+ }
76
104
  const values = await npmFindings(report, this.deps.aliases);
77
105
  ensure(
78
106
  proc.status === 0 || values.length,
@@ -92,21 +120,45 @@ class Scanner {
92
120
  return image.split("@")[0] + "@" + digest;
93
121
  }
94
122
  nodeVersion(image, platform) {
95
- const container = this.run([
96
- "buildah",
97
- "from",
98
- "--platform",
99
- platform,
100
- image,
101
- ]).stdout.trim();
123
+ ensure(
124
+ ["linux/amd64", "linux/arm64"].includes(platform),
125
+ "Unsupported runtime platform",
126
+ );
127
+ const directory = temp("nightly-node-");
128
+ const imagePath = path.join(directory, "image");
129
+ const bundle = path.join(directory, "bundle");
130
+ let failure;
102
131
  try {
132
+ this.run([
133
+ "skopeo",
134
+ "--override-os",
135
+ "linux",
136
+ "--override-arch",
137
+ platform.split("/")[1],
138
+ "copy",
139
+ "docker://" + image,
140
+ "oci:" + imagePath + ":runtime",
141
+ ]);
142
+ this.run([
143
+ "umoci",
144
+ "unpack",
145
+ "--rootless",
146
+ "--image",
147
+ imagePath + ":runtime",
148
+ bundle,
149
+ ]);
150
+ const binary = this.env.NIGHTLY_NODE_BINARY || "/nodejs/bin/node";
151
+ ensure(
152
+ path.posix.isAbsolute(binary) && !binary.split("/").includes(".."),
153
+ "Expected an absolute runtime Node binary path",
154
+ );
155
+ // Execute only the runtime binary, with its own libraries and an unprivileged uid.
156
+ // This does not need a container daemon, mount namespace or Buildah storage.
103
157
  const value = this.run([
104
- "buildah",
105
- "run",
106
- "--isolation=chroot",
107
- container,
108
- "--",
109
- this.env.NIGHTLY_NODE_BINARY || "/nodejs/bin/node",
158
+ "chroot",
159
+ "--userspec=65534:65534",
160
+ path.join(bundle, "rootfs"),
161
+ binary,
110
162
  "--version",
111
163
  ])
112
164
  .stdout.trim()
@@ -116,8 +168,16 @@ class Scanner {
116
168
  "Cannot determine runtime Node.js version",
117
169
  );
118
170
  return value;
171
+ } catch (error) {
172
+ failure = error;
173
+ throw error;
119
174
  } finally {
120
- this.run(["buildah", "rm", container]);
175
+ try {
176
+ remove(directory);
177
+ } catch (cleanup) {
178
+ if (failure) failure.message += "; cleanup: " + cleanup.message;
179
+ else throw cleanup;
180
+ }
121
181
  }
122
182
  }
123
183
  async feed() {
@@ -125,7 +185,7 @@ class Scanner {
125
185
  ? this.deps.feed()
126
186
  : new API("https://raw.githubusercontent.com").get(NODE_FEED);
127
187
  }
128
- async scanImage(image, platform, feed) {
188
+ async scanImage(image, platform, feed, evidence) {
129
189
  ensure(
130
190
  ["linux/amd64", "linux/arm64"].includes(platform),
131
191
  "Unsupported runtime platform",
@@ -155,6 +215,7 @@ class Scanner {
155
215
  ]);
156
216
  return read(filename);
157
217
  });
218
+ if (evidence) save(evidence, report);
158
219
  const values = trivyFindings(report);
159
220
  let version = null;
160
221
  if ((this.env.NIGHTLY_CHECK_NODE || "true") === "true") {
@@ -209,7 +270,7 @@ class Scanner {
209
270
  tools.push("npm");
210
271
  }
211
272
  if ((this.env.NIGHTLY_CHECK_NODE || "true") === "true")
212
- tools.push("buildah");
273
+ tools.push("umoci", "chroot");
213
274
  (this.deps.checkExecutables || checkExecutables)(tools, this.env);
214
275
  const platforms = (this.env.NIGHTLY_PLATFORMS || "linux/amd64").split(",");
215
276
  ensure(
@@ -276,8 +337,12 @@ class Scanner {
276
337
  platforms = (this.env.NIGHTLY_PLATFORMS || "linux/amd64").split(",");
277
338
  report.runtime = { image, dockerfile, platforms };
278
339
  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);
340
+ const [, values, version] = await this.scanImage(
341
+ image,
342
+ platform,
343
+ undefined,
344
+ path.join(output, `runtime-${index}.json`),
345
+ );
281
346
  report.runtime[platform] = { node: version };
282
347
  report.findings.push(...values);
283
348
  }
@@ -294,6 +359,8 @@ class Scanner {
294
359
  "NIGHTLY_SCAN_JOB_ID=" + this.env.CI_JOB_ID + "\n",
295
360
  );
296
361
  }
362
+ for (const error of report.errors)
363
+ console.error("ERROR security scan: " + diagnostic(error, this.env));
297
364
  return report.errors.length ? 1 : 0;
298
365
  }
299
366
  }