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 +8 -0
- package/README.md +14 -0
- package/package.json +1 -1
- package/src/automation/index.js +13 -4
- package/src/security/runtime.js +75 -1
- package/src/security/scan.js +90 -23
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
package/src/automation/index.js
CHANGED
|
@@ -125,10 +125,19 @@ function exec(
|
|
|
125
125
|
encoding: binary ? null : "utf8",
|
|
126
126
|
windowsHide: true,
|
|
127
127
|
});
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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,
|
package/src/security/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/security/scan.js
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
"
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
|
|
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
|
-
|
|
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("
|
|
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 [
|
|
280
|
-
|
|
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
|
}
|