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.
- package/CHANGELOG.md +13 -0
- package/README.md +132 -1
- package/package.json +4 -2
- package/src/automation/index.js +297 -0
- package/src/cli/commands.js +79 -1
- package/src/cli/run-action.js +18 -2
- package/src/nextjs/index.js +184 -0
- package/src/preflight/automation.js +291 -0
- package/src/sbom/index.js +4 -0
- package/src/sbom/populate.js +607 -0
- package/src/sbom/provenance.js +467 -0
- package/src/sbom/resolve.js +418 -0
- package/src/sbom/schema.js +170 -0
- package/src/sbom/tools.js +74 -0
- package/src/security/findings.js +180 -0
- package/src/security/index.js +36 -0
- package/src/security/repair.js +553 -0
- package/src/security/report.js +65 -0
- package/src/security/runtime.js +192 -0
- package/src/security/scan.js +300 -0
- package/src/security/tracker.js +248 -0
- package/src/security/tracking.js +234 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,19 @@ 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
|
+
## 🚀 [5.1.0]
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Deployment-адреса и GitLab-проекты задаются окружением: встроенные URL/namespace удалены, примеры и fixtures используют example-домены. Добавлены preflight наличия настроек, доступности, GitLab token scope/роли, YouTrack identity/проектов/полей и registry login/чтения. Права записи не объявляются доказанными по успешному GET.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Все новые automation-команды поддерживают `-d, --dry` и `-s, --silence`: dry выполняет только предпроверки без изменения исходников, отчётов и удалённых сущностей; silence скрывает успешные предпроверки, сохраняя ошибки и результаты.
|
|
21
|
+
|
|
22
|
+
- Команды `nextjs licenses`, `sbom catalog resolve/populate` и `security scan/track/reconcile/repair` перенесены из CI-компонентов в JavaScript-модули CLI. Сохранены OCI provenance/integrity, CVE и проектные задачи, Priority, изоляция проверок, проверки исправлений и отчёты для Mattermost.
|
|
23
|
+
- Автоматизация работает без интерактивных запросов и проверки обновлений; версии CLI в CI закрепляются служебными образами. Python для новых команд не требуется.
|
|
24
|
+
|
|
12
25
|
## 🚀 [5.0.2] - 2026-10-23
|
|
13
26
|
|
|
14
27
|
### Changed
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ npm install -g spectrum-cli
|
|
|
14
14
|
spectrum --help
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
Перед выполнением
|
|
17
|
+
Перед выполнением команд workflow CLI проверяет опубликованную версию `spectrum-cli`
|
|
18
18
|
в npm Registry. Если доступна новая версия, показывает текущую и новую версии и
|
|
19
19
|
команду `spectrum upgrade`. Она запускает `npm install -g spectrum-cli`.
|
|
20
20
|
Проверка ограничена 200 мс; недоступность Registry не влияет на выполнение
|
|
@@ -476,3 +476,134 @@ MIT License - см. [LICENSE](./LICENSE)
|
|
|
476
476
|
---
|
|
477
477
|
|
|
478
478
|
_Создано для автоматизации повседневных задач разработки_ 🛠️
|
|
479
|
+
|
|
480
|
+
## Команды для CI: Next.js, SBOM и security
|
|
481
|
+
|
|
482
|
+
Начиная с 5.1.0 логика команд написана на JavaScript и входит в npm-пакет.
|
|
483
|
+
Команды не задают вопросов и не проверяют обновления CLI. Они не запускают
|
|
484
|
+
release/hotfix preflight, рассчитанный на интерактивную работу разработчика.
|
|
485
|
+
|
|
486
|
+
| Команда | Назначение | Результат |
|
|
487
|
+
| --- | --- | --- |
|
|
488
|
+
| `spectrum nextjs licenses` | Сохранить исходные LICENSE уже включённых compiled-пакетов Next.js | JSON summary в stdout; код 2 при неполном результате |
|
|
489
|
+
| `spectrum sbom catalog resolve` | Найти Next в immutable образе, проверить OCI-каталоги, запросить отсутствующие версии | `sbom-catalog/<prefix>/{selection.json,report.json,report.md}` |
|
|
490
|
+
| `spectrum sbom catalog populate` | Построить каталог по официальному npm tarball и историческому Git tree | `sbom-catalog-updater/{report.json,report.md}` |
|
|
491
|
+
| `spectrum security scan` | Проверить `npm audit --omit=dev`, runtime и фактическую версию Node | `security-report/findings.json`, raw evidence, `scan.env` |
|
|
492
|
+
| `spectrum security reconcile` | Запустить центральный tracker и получить ссылки на задачи | `security-report/tracked.json` |
|
|
493
|
+
| `spectrum security track` | Найти/создать общие CVE и проектные задачи | `security-report/tracked.json` в центральном pipeline |
|
|
494
|
+
| `spectrum security repair` | Исправить одну проектную задачу за MR, выполнить проверки и повторный аудит | `security-report/{final.json,message.json}` |
|
|
495
|
+
|
|
496
|
+
Код 0 означает успешное выполнение, 1 — ошибку security/запуска, 2 — неполный
|
|
497
|
+
результат SBOM или LICENSE. Команды работают в текущем каталоге. Отсутствующие
|
|
498
|
+
credentials/некорректные отчёты приводят к ошибке, а не к чистому аудиту.
|
|
499
|
+
Автоматическое слияние MR отключено; сетевые записи не повторяются вслепую.
|
|
500
|
+
|
|
501
|
+
Компоненты передают свои inputs через job-local окружение. `security scan`
|
|
502
|
+
читает `NIGHTLY_YOUTRACK_PROJECT`, `NIGHTLY_YOUTRACK_TIME_TRACKING_ARTICLE`,
|
|
503
|
+
`NIGHTLY_YOUTRACK_TESTER`, необязательный `NIGHTLY_YOUTRACK_SUBSYSTEM`,
|
|
504
|
+
`NIGHTLY_DOCKERFILE`, `NIGHTLY_PLATFORMS` и `NIGHTLY_CHECK_COMMANDS` (JSON argv).
|
|
505
|
+
Токены `GLAB_BOT_PAT` и `YOUTRACK_PRIVATE_TOKEN` остаются секретными CI variables;
|
|
506
|
+
YouTrack-токен нужен только центральному tracker. Для registry используются
|
|
507
|
+
`NEXUS_USER`/`NEXUS_PASSWORD` либо стандартные CI registry credentials.
|
|
508
|
+
Секреты автоматизации исключаются из окружения выполняемых проверок проекта.
|
|
509
|
+
|
|
510
|
+
`sbom catalog resolve` читает `SBOM_CATALOG_CLIENT_ENABLED=true`,
|
|
511
|
+
`SBOM_CATALOG_FRAMEWORK=nextjs`, `SBOM_CATALOG_PREFIX` (по умолчанию `site:`),
|
|
512
|
+
`SBOM_CATALOG_REPOSITORY`, `DOCKER_IMAGE`, `APP_VERSION` и стандартные GitLab
|
|
513
|
+
`CI_*` variables. Чтение каталога использует `SBOM_CATALOG_USER` и
|
|
514
|
+
`SBOM_CATALOG_PASSWORD`, если заданы. `populate` требует защищённый `main`,
|
|
515
|
+
`NEXT_CATALOG_VERSIONS` (JSON-массив 1..10 точных версий) и центральные
|
|
516
|
+
`NEXUS_USER`/`NEXUS_PASSWORD`. Namespace записи задаёт только доверенная
|
|
517
|
+
project variable `SBOM_CATALOG_PUBLISH_REPOSITORY`.
|
|
518
|
+
|
|
519
|
+
Внешние инструменты: Linux amd64 для SBOM bootstrap; Git для каталога,
|
|
520
|
+
Trivy/Skopeo/Buildah для runtime-аудита и исправлений. SBOM-команды скачивают
|
|
521
|
+
закреплённые ORAS/Syft с проверкой SHA-256, ограничениями размера и времени.
|
|
522
|
+
Python для этих команд не требуется. Они не выполняют код из npm tarball
|
|
523
|
+
или upstream build-рецептов. Неоднозначное происхождение остаётся unresolved.
|
|
524
|
+
Поля CVE, Severity, Priority и Type=Group проверяются до создания подзадач;
|
|
525
|
+
CRITICAL получает Stopper (Неотложная), HIGH — Critical (Критическая).
|
|
526
|
+
|
|
527
|
+
Образы и адреса сервисов задаёт CI-конфигурация конкретной установки.
|
|
528
|
+
CLI не содержит дефолтов для YouTrack, registry или внутренних GitLab-проектов.
|
|
529
|
+
Само наличие команды `track` не сериализует процессы: job должен оставаться
|
|
530
|
+
в одном центральном проекте с `resource_group: nightly-youtrack-global`.
|
|
531
|
+
Отдельный consumer lock `nightly-security-fix` защищает исправления проекта.
|
|
532
|
+
|
|
533
|
+
Для проверки миграции:
|
|
534
|
+
|
|
535
|
+
```sh
|
|
536
|
+
npm ci
|
|
537
|
+
npm test -- --runInBand
|
|
538
|
+
npm pack --dry-run
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
Тесты используют локальные Git-репозитории и fake API. Зафиксированные parity
|
|
542
|
+
fixtures получены из прежнего Python-кода; исполняемый Python в пакет не входит.
|
|
543
|
+
Публикация CLI, образов, компонентов и запуск живого pipeline — отдельные шаги.
|
|
544
|
+
|
|
545
|
+
### Preflight для команд автоматизации
|
|
546
|
+
|
|
547
|
+
Обязательные deployment-настройки задаются явно:
|
|
548
|
+
|
|
549
|
+
| Команда | Настройки |
|
|
550
|
+
| --- | --- |
|
|
551
|
+
| `security track` | `YOUTRACK_URL`, `YOUTRACK_PRIVATE_TOKEN`, `CI_API_V4_URL`, `GLAB_BOT_PAT`, идентификаторы consumer job/project и component SHA |
|
|
552
|
+
| `security reconcile` | `NIGHTLY_TRACKER_PROJECT`, `NIGHTLY_COMPONENT_SHA`, `NIGHTLY_SCAN_JOB_ID`, `CI_PROJECT_ID`, GitLab API/token; при пустом отчёте сеть не нужна |
|
|
553
|
+
| `security repair` | GitLab API/token и результаты scan/tracking; при пустом отчёте запись и проверка токена не нужны |
|
|
554
|
+
| `sbom catalog resolve` | `SBOM_CATALOG_REPOSITORY`, `DOCKER_IMAGE`, `APP_VERSION`; `SBOM_CATALOG_UPDATER_PROJECT` для запроса отсутствующих версий |
|
|
555
|
+
| `sbom catalog populate` | `SBOM_CATALOG_PUBLISH_REPOSITORY`, `NEXUS_USER`, `NEXUS_PASSWORD`, защищённый main и точные `NEXT_CATALOG_VERSIONS` |
|
|
556
|
+
| `security scan/repair` с `NEXUS_*` | Дополнительно явный `NIGHTLY_REGISTRY_HOSTS`; один логин без пароля или наоборот считается ошибкой |
|
|
557
|
+
|
|
558
|
+
В GitLab выполняются GET `/user`, `/personal_access_tokens/self` и чтение
|
|
559
|
+
целевых проектов. Проверяются активность токена, scope `api` для записи
|
|
560
|
+
(`api`/`read_api` для чтения), эффективная роль Developer/Reporter и состояние
|
|
561
|
+
проекта. Fine-grained/прочие токены, для которых introspection не подтверждает
|
|
562
|
+
эти scope, не считаются проверенными. Защита веток/тегов дополнительно
|
|
563
|
+
проверяется сервером при конкретной операции.
|
|
564
|
+
|
|
565
|
+
В YouTrack выполняются GET `/users/me`, чтение двух проектов, их полей и
|
|
566
|
+
поиск задач. До создания первой задачи проверяются активность пользователя,
|
|
567
|
+
доступность проектов и обязательные поля. Ответы 401/403, недоступность и
|
|
568
|
+
неверный TLS приводят к ошибке с названием проверки; секреты/тела ответов
|
|
569
|
+
не выводятся. Инспекция полей не требует тестового изменения проекта.
|
|
570
|
+
|
|
571
|
+
Writer выполняет ORAS login и чтение точного namespace до скачивания исходников
|
|
572
|
+
и построения каталога. Preflight сохраняется в отчёте. Чтение/логин не доказывают
|
|
573
|
+
права Create/Update/Link Issue, доступ к приватным полям, workflow-переходам или
|
|
574
|
+
registry push: эти права проверяются соответствующими операциями. Они явно
|
|
575
|
+
помечены `checked_by_operations`/`checked_by_publish`, без ложного статуса
|
|
576
|
+
«запись проверена». Preflight не создаёт задач, MR, upload-сессий или пробных
|
|
577
|
+
каталогов. Ошибка preflight останавливает следующие изменения и остаётся
|
|
578
|
+
в JSON/Markdown/notification-артефактах, когда исходный отчёт уже доступен.
|
|
579
|
+
|
|
580
|
+
Источник контрактов: [GitLab token scopes](https://docs.gitlab.com/security/tokens/access_token_scopes/),
|
|
581
|
+
[YouTrack My User Profile](https://www.jetbrains.com/help/youtrack/devportal/resource-api-users-me.html),
|
|
582
|
+
[YouTrack project fields](https://www.jetbrains.com/help/youtrack/devportal/resource-api-admin-projects-projectID-customFields.html).
|
|
583
|
+
|
|
584
|
+
### Dry и silence для команд автоматизации
|
|
585
|
+
|
|
586
|
+
Все семь команд `nextjs licenses`, `sbom catalog resolve/populate` и
|
|
587
|
+
`security scan/track/reconcile/repair` поддерживают `-d, --dry` и
|
|
588
|
+
`-s, --silence`, включая сочетание `-ds`.
|
|
589
|
+
|
|
590
|
+
```sh
|
|
591
|
+
spectrum nextjs licenses --dry
|
|
592
|
+
spectrum sbom catalog populate --dry --silence
|
|
593
|
+
spectrum security repair -ds
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
`--dry` выполняет предпроверки настроек, файлов, инструментов и доступов,
|
|
597
|
+
после чего останавливается перед действиями команды. Он не копирует LICENSE,
|
|
598
|
+
не выполняет npm audit/Trivy/Syft scan или исправления, не создаёт задачи,
|
|
599
|
+
MR и downstream pipeline, не публикует каталоги и не перезаписывает отчёты.
|
|
600
|
+
Для проверки используются GET-запросы, чтение image descriptor и metadata
|
|
601
|
+
Git/проектов; SBOM preflight может загрузить закреплённые инструменты во
|
|
602
|
+
временную папку и выполнить registry login с временным auth-файлом.
|
|
603
|
+
Временные файлы удаляются; исходники, lockfiles и существующие артефакты
|
|
604
|
+
сохраняются. При неполных входных отчётах dry завершается с ошибкой.
|
|
605
|
+
|
|
606
|
+
`--silence` скрывает только сообщения об успешно пройденных предпроверках.
|
|
607
|
+
Ошибки, предупреждения, результаты команды и итог dry остаются видимыми.
|
|
608
|
+
Это тот же смысл флага, что у release/hotfix команд. Без dry выполняются
|
|
609
|
+
обычные действия и сохраняются прежние коды завершения 0/1/2.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spectrum-cli",
|
|
3
|
-
"version": "5.0
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "🚀 Modern CLI for development workflow automation - release management, versioning, and changelog generation",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -49,7 +49,9 @@
|
|
|
49
49
|
"url": "https://github.com/dnwSilver/spectrum-cli/issues"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"commander": "^11.1.0"
|
|
52
|
+
"commander": "^11.1.0",
|
|
53
|
+
"semver": "7.8.5",
|
|
54
|
+
"yaml": "2.9.1"
|
|
53
55
|
},
|
|
54
56
|
"engines": {
|
|
55
57
|
"node": ">=20.0.0",
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// Shared bounded IO for noninteractive CI commands. Never execute shell strings.
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const os = require("node:os");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const crypto = require("node:crypto");
|
|
6
|
+
const { spawnSync } = require("node:child_process");
|
|
7
|
+
const { gunzipSync } = require("node:zlib");
|
|
8
|
+
const { isDeepStrictEqual } = require("node:util");
|
|
9
|
+
|
|
10
|
+
const SEMVER =
|
|
11
|
+
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?![\s\S])/;
|
|
12
|
+
const DIGEST = /^sha256:[a-f0-9]{64}(?![\s\S])/;
|
|
13
|
+
const HEX64 = /^[a-f0-9]{64}(?![\s\S])/;
|
|
14
|
+
const COMMIT = /^[a-f0-9]{40}(?![\s\S])/;
|
|
15
|
+
function ensure(condition, message) {
|
|
16
|
+
if (!condition) throw new Error(message);
|
|
17
|
+
}
|
|
18
|
+
function sha(data, algorithm = "sha256") {
|
|
19
|
+
return crypto.createHash(algorithm).update(data).digest("hex");
|
|
20
|
+
}
|
|
21
|
+
function sorted(value) {
|
|
22
|
+
if (Array.isArray(value)) return value.map(sorted);
|
|
23
|
+
if (value && typeof value === "object" && !Buffer.isBuffer(value))
|
|
24
|
+
return Object.fromEntries(
|
|
25
|
+
Object.keys(value)
|
|
26
|
+
.sort()
|
|
27
|
+
.map((key) => [key, sorted(value[key])]),
|
|
28
|
+
);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function stable(value) {
|
|
32
|
+
return JSON.stringify(sorted(value));
|
|
33
|
+
}
|
|
34
|
+
function json(value) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(Buffer.isBuffer(value) ? value.toString("utf8") : value);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error("Invalid JSON data");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function read(file) {
|
|
42
|
+
return json(fs.readFileSync(file));
|
|
43
|
+
}
|
|
44
|
+
function save(file, value) {
|
|
45
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
46
|
+
const tmp = file + ".tmp";
|
|
47
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n");
|
|
48
|
+
fs.renameSync(tmp, file);
|
|
49
|
+
}
|
|
50
|
+
function temp(prefix) {
|
|
51
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
52
|
+
}
|
|
53
|
+
function remove(directory) {
|
|
54
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
async function temporary(prefix, action) {
|
|
57
|
+
const dir = temp(prefix);
|
|
58
|
+
try {
|
|
59
|
+
return await action(dir);
|
|
60
|
+
} finally {
|
|
61
|
+
remove(dir);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function safePath(value, absolute = false) {
|
|
65
|
+
return (
|
|
66
|
+
typeof value === "string" &&
|
|
67
|
+
!!value &&
|
|
68
|
+
!/[\\\x00-\x1f]/.test(value) &&
|
|
69
|
+
value.startsWith("/") === absolute &&
|
|
70
|
+
value
|
|
71
|
+
.replace(/^\//, "")
|
|
72
|
+
.split("/")
|
|
73
|
+
.every((part) => part && part !== "." && part !== "..")
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
function repositoryPath(root, relative) {
|
|
77
|
+
ensure(safePath(relative), "Expected a repository-relative path");
|
|
78
|
+
const base = fs.realpathSync(root);
|
|
79
|
+
const target = path.resolve(base, relative);
|
|
80
|
+
let cursor = base;
|
|
81
|
+
for (const part of relative.split("/")) {
|
|
82
|
+
cursor = path.join(cursor, part);
|
|
83
|
+
if (
|
|
84
|
+
fs.existsSync(cursor) ||
|
|
85
|
+
(() => {
|
|
86
|
+
try {
|
|
87
|
+
fs.lstatSync(cursor);
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
})()
|
|
93
|
+
) {
|
|
94
|
+
const real = fs.realpathSync(cursor);
|
|
95
|
+
ensure(real.startsWith(base + path.sep), "Path leaves repository");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return target;
|
|
99
|
+
}
|
|
100
|
+
function exec(
|
|
101
|
+
args,
|
|
102
|
+
{
|
|
103
|
+
cwd,
|
|
104
|
+
env = process.env,
|
|
105
|
+
input,
|
|
106
|
+
allowed = [0],
|
|
107
|
+
timeout = 1800000,
|
|
108
|
+
maxBuffer = 32 * 1024 * 1024,
|
|
109
|
+
binary = false,
|
|
110
|
+
label,
|
|
111
|
+
} = {},
|
|
112
|
+
) {
|
|
113
|
+
ensure(
|
|
114
|
+
Array.isArray(args) &&
|
|
115
|
+
args.length &&
|
|
116
|
+
args.every((arg) => typeof arg === "string" && !arg.includes("\0")),
|
|
117
|
+
"Expected command argv",
|
|
118
|
+
);
|
|
119
|
+
const result = spawnSync(args[0], args.slice(1), {
|
|
120
|
+
cwd,
|
|
121
|
+
env,
|
|
122
|
+
input,
|
|
123
|
+
timeout,
|
|
124
|
+
maxBuffer,
|
|
125
|
+
encoding: binary ? null : "utf8",
|
|
126
|
+
windowsHide: true,
|
|
127
|
+
});
|
|
128
|
+
ensure(
|
|
129
|
+
!result.error && allowed.includes(result.status),
|
|
130
|
+
`${label || path.basename(args[0]) + " " + (args[1] || "")} failed (exit ${result.status})`,
|
|
131
|
+
);
|
|
132
|
+
return {
|
|
133
|
+
stdout: result.stdout,
|
|
134
|
+
stderr: result.stderr,
|
|
135
|
+
status: result.status,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
139
|
+
async function responseBytes(response, limit) {
|
|
140
|
+
const chunks = [];
|
|
141
|
+
let size = 0;
|
|
142
|
+
for await (const chunk of response.body || []) {
|
|
143
|
+
size += chunk.length;
|
|
144
|
+
if (size > limit) {
|
|
145
|
+
await response.body?.cancel?.().catch(() => {});
|
|
146
|
+
throw new Error("Download limit exceeded");
|
|
147
|
+
}
|
|
148
|
+
chunks.push(Buffer.from(chunk));
|
|
149
|
+
}
|
|
150
|
+
return Buffer.concat(chunks);
|
|
151
|
+
}
|
|
152
|
+
async function request(
|
|
153
|
+
url,
|
|
154
|
+
{
|
|
155
|
+
method = "GET",
|
|
156
|
+
headers = {},
|
|
157
|
+
body,
|
|
158
|
+
limit = 32 * 1024 * 1024,
|
|
159
|
+
timeout = 60000,
|
|
160
|
+
fetchImpl = fetch,
|
|
161
|
+
} = {},
|
|
162
|
+
) {
|
|
163
|
+
const controller = new AbortController();
|
|
164
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
165
|
+
try {
|
|
166
|
+
const response = await fetchImpl(url, {
|
|
167
|
+
method,
|
|
168
|
+
headers,
|
|
169
|
+
body,
|
|
170
|
+
redirect: "manual",
|
|
171
|
+
signal: controller.signal,
|
|
172
|
+
});
|
|
173
|
+
const bytes = await responseBytes(response, limit);
|
|
174
|
+
return { status: response.status, headers: response.headers, body: bytes };
|
|
175
|
+
} finally {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Extract data only. No archive entry is ever written to the filesystem.
|
|
180
|
+
function tarEntries(
|
|
181
|
+
gzip,
|
|
182
|
+
{
|
|
183
|
+
maxUnpacked = 1024 * 1024 * 1024,
|
|
184
|
+
maxFile = 32 * 1024 * 1024,
|
|
185
|
+
maxEntries = 30000,
|
|
186
|
+
} = {},
|
|
187
|
+
) {
|
|
188
|
+
const bytes = gunzipSync(gzip, {
|
|
189
|
+
maxOutputLength: maxUnpacked + maxEntries * 1024,
|
|
190
|
+
});
|
|
191
|
+
const entries = [];
|
|
192
|
+
let offset = 0;
|
|
193
|
+
let total = 0;
|
|
194
|
+
let pax = {};
|
|
195
|
+
let longName;
|
|
196
|
+
const str = (data) => data.toString("utf8").replace(/\0.*(?![\s\S])/s, "");
|
|
197
|
+
while (offset + 512 <= bytes.length) {
|
|
198
|
+
const header = bytes.subarray(offset, offset + 512);
|
|
199
|
+
offset += 512;
|
|
200
|
+
if (header.every((byte) => byte === 0)) break;
|
|
201
|
+
const sizeText = str(header.subarray(124, 136)).trim();
|
|
202
|
+
ensure(/^[0-7]+(?![\s\S])/.test(sizeText), "Invalid tar entry size");
|
|
203
|
+
const size = parseInt(sizeText, 8);
|
|
204
|
+
const expected = parseInt(str(header.subarray(148, 156)).trim(), 8);
|
|
205
|
+
const sum = header.reduce(
|
|
206
|
+
(sum, b, i) => sum + (i >= 148 && i < 156 ? 32 : b),
|
|
207
|
+
0,
|
|
208
|
+
);
|
|
209
|
+
ensure(
|
|
210
|
+
expected === sum && size <= maxFile && offset + size <= bytes.length,
|
|
211
|
+
"Invalid or oversized tar entry",
|
|
212
|
+
);
|
|
213
|
+
const data = bytes.subarray(offset, offset + size);
|
|
214
|
+
offset += Math.ceil(size / 512) * 512;
|
|
215
|
+
const type = String.fromCharCode(header[156] || 48);
|
|
216
|
+
if (type === "x") {
|
|
217
|
+
pax = {};
|
|
218
|
+
let pos = 0;
|
|
219
|
+
while (pos < data.length) {
|
|
220
|
+
const space = data.indexOf(32, pos);
|
|
221
|
+
ensure(space > pos, "Invalid PAX record");
|
|
222
|
+
const len = Number(data.subarray(pos, space).toString());
|
|
223
|
+
ensure(
|
|
224
|
+
Number.isInteger(len) &&
|
|
225
|
+
len > space - pos + 1 &&
|
|
226
|
+
pos + len <= data.length &&
|
|
227
|
+
data[pos + len - 1] === 10,
|
|
228
|
+
"Invalid PAX length",
|
|
229
|
+
);
|
|
230
|
+
const row = data.subarray(space + 1, pos + len - 1).toString();
|
|
231
|
+
const split = row.indexOf("=");
|
|
232
|
+
ensure(split > 0, "Invalid PAX value");
|
|
233
|
+
pax[row.slice(0, split)] = row.slice(split + 1);
|
|
234
|
+
pos += len;
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (type === "L") {
|
|
239
|
+
longName = str(data).replace(/\n(?![\s\S])/, "");
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const prefix =
|
|
243
|
+
str(header.subarray(257, 263)) === "ustar"
|
|
244
|
+
? str(header.subarray(345, 500))
|
|
245
|
+
: "";
|
|
246
|
+
const name =
|
|
247
|
+
pax.path ||
|
|
248
|
+
longName ||
|
|
249
|
+
(prefix ? prefix + "/" : "") + str(header.subarray(0, 100));
|
|
250
|
+
ensure(
|
|
251
|
+
pax.size === undefined || Number(pax.size) === size,
|
|
252
|
+
"Unsupported PAX size",
|
|
253
|
+
);
|
|
254
|
+
pax = {};
|
|
255
|
+
longName = undefined;
|
|
256
|
+
ensure(
|
|
257
|
+
type === "0" || type === "5",
|
|
258
|
+
"Links and special archive entries are forbidden",
|
|
259
|
+
);
|
|
260
|
+
total += size;
|
|
261
|
+
ensure(
|
|
262
|
+
total <= maxUnpacked && entries.length < maxEntries,
|
|
263
|
+
"Archive expansion limit exceeded",
|
|
264
|
+
);
|
|
265
|
+
entries.push({
|
|
266
|
+
name: name.replace(/\/(?![\s\S])/, ""),
|
|
267
|
+
directory: type === "5",
|
|
268
|
+
data,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
return entries;
|
|
272
|
+
}
|
|
273
|
+
module.exports = {
|
|
274
|
+
fs,
|
|
275
|
+
path,
|
|
276
|
+
SEMVER,
|
|
277
|
+
DIGEST,
|
|
278
|
+
HEX64,
|
|
279
|
+
COMMIT,
|
|
280
|
+
ensure,
|
|
281
|
+
sha,
|
|
282
|
+
stable,
|
|
283
|
+
sorted,
|
|
284
|
+
json,
|
|
285
|
+
read,
|
|
286
|
+
save,
|
|
287
|
+
temp,
|
|
288
|
+
remove,
|
|
289
|
+
temporary,
|
|
290
|
+
safePath,
|
|
291
|
+
repositoryPath,
|
|
292
|
+
exec,
|
|
293
|
+
sleep,
|
|
294
|
+
request,
|
|
295
|
+
tarEntries,
|
|
296
|
+
equal: isDeepStrictEqual,
|
|
297
|
+
};
|
package/src/cli/commands.js
CHANGED
|
@@ -4,9 +4,12 @@ const hotfix = require('../hotfix');
|
|
|
4
4
|
const changelog = require('../changelog');
|
|
5
5
|
const chart = require('../chart');
|
|
6
6
|
const token = require('../token');
|
|
7
|
+
const nextjs = require('../nextjs');
|
|
8
|
+
const sbom = require('../sbom');
|
|
9
|
+
const security = require('../security');
|
|
7
10
|
const { upgrade } = require('./update-check');
|
|
8
11
|
const { version: pkgVersion } = require('../../package.json');
|
|
9
|
-
const { runAction } = require('./run-action');
|
|
12
|
+
const { runAction, runAutomationAction } = require('./run-action');
|
|
10
13
|
|
|
11
14
|
function registerCommands(program) {
|
|
12
15
|
program
|
|
@@ -21,6 +24,81 @@ program
|
|
|
21
24
|
.option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
|
|
22
25
|
.action((command) => runAction(upgrade, command));
|
|
23
26
|
|
|
27
|
+
// Команды Next.js
|
|
28
|
+
const nextjsCmd = program
|
|
29
|
+
.command("nextjs")
|
|
30
|
+
.description("Обработка Next.js build artifacts");
|
|
31
|
+
|
|
32
|
+
nextjsCmd
|
|
33
|
+
.command("licenses")
|
|
34
|
+
.description("Сохранить LICENSE встроенных пакетов в standalone")
|
|
35
|
+
.allowExcessArguments(false)
|
|
36
|
+
.option("-d, --dry", "Запустить только предпроверки")
|
|
37
|
+
.option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
|
|
38
|
+
.action((command) => runAutomationAction(nextjs.licenses, command));
|
|
39
|
+
|
|
40
|
+
// Команды SBOM
|
|
41
|
+
const sbomCmd = program
|
|
42
|
+
.command("sbom")
|
|
43
|
+
.description("Работа с SBOM");
|
|
44
|
+
|
|
45
|
+
const catalogCmd = sbomCmd
|
|
46
|
+
.command("catalog")
|
|
47
|
+
.description("Каталоги встроенных зависимостей Next.js");
|
|
48
|
+
|
|
49
|
+
catalogCmd
|
|
50
|
+
.command("resolve")
|
|
51
|
+
.description("Найти проверенные OCI-каталоги для образа приложения")
|
|
52
|
+
.allowExcessArguments(false)
|
|
53
|
+
.option("-d, --dry", "Запустить только предпроверки")
|
|
54
|
+
.option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
|
|
55
|
+
.action((command) => runAutomationAction(sbom.resolve, command));
|
|
56
|
+
|
|
57
|
+
catalogCmd
|
|
58
|
+
.command("populate")
|
|
59
|
+
.description("Построить и опубликовать каталог из официальных исходников")
|
|
60
|
+
.allowExcessArguments(false)
|
|
61
|
+
.option("-d, --dry", "Запустить только предпроверки")
|
|
62
|
+
.option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
|
|
63
|
+
.action((command) => runAutomationAction(sbom.populate, command));
|
|
64
|
+
|
|
65
|
+
// Команды security
|
|
66
|
+
const securityCmd = program
|
|
67
|
+
.command("security")
|
|
68
|
+
.description("Ночная проверка и исправление HIGH/CRITICAL");
|
|
69
|
+
|
|
70
|
+
securityCmd
|
|
71
|
+
.command("scan")
|
|
72
|
+
.description("Проверить production npm-зависимости и runtime-образ")
|
|
73
|
+
.allowExcessArguments(false)
|
|
74
|
+
.option("-d, --dry", "Запустить только предпроверки")
|
|
75
|
+
.option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
|
|
76
|
+
.action((command) => runAutomationAction(security.scan, command));
|
|
77
|
+
|
|
78
|
+
securityCmd
|
|
79
|
+
.command("track")
|
|
80
|
+
.description("Создать или найти YouTrack-задачи в центральном pipeline")
|
|
81
|
+
.allowExcessArguments(false)
|
|
82
|
+
.option("-d, --dry", "Запустить только предпроверки")
|
|
83
|
+
.option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
|
|
84
|
+
.action((command) => runAutomationAction(security.track, command));
|
|
85
|
+
|
|
86
|
+
securityCmd
|
|
87
|
+
.command("reconcile")
|
|
88
|
+
.description("Дождаться центрального tracker и получить номера задач")
|
|
89
|
+
.allowExcessArguments(false)
|
|
90
|
+
.option("-d, --dry", "Запустить только предпроверки")
|
|
91
|
+
.option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
|
|
92
|
+
.action((command) => runAutomationAction(security.reconcile, command));
|
|
93
|
+
|
|
94
|
+
securityCmd
|
|
95
|
+
.command("repair")
|
|
96
|
+
.description("Исправить, проверить и создать отдельный MR на проектную задачу")
|
|
97
|
+
.allowExcessArguments(false)
|
|
98
|
+
.option("-d, --dry", "Запустить только предпроверки")
|
|
99
|
+
.option("-s, --silence", "Скрыть сообщения об успешно пройденных предпроверках")
|
|
100
|
+
.action((command) => runAutomationAction(security.repair, command));
|
|
101
|
+
|
|
24
102
|
// Команды релиза
|
|
25
103
|
const releaseCmd = program
|
|
26
104
|
.command("release")
|
package/src/cli/run-action.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const { checkForUpdates } = require('./update-check');
|
|
2
|
-
const { withCommandOptions } = require('./command-executor');
|
|
2
|
+
const { withCommandOptions, reportDryRun } = require('./command-executor');
|
|
3
3
|
|
|
4
4
|
function commandOptions(command) {
|
|
5
5
|
return command && typeof command.opts === "function" ? command.opts() : command || {};
|
|
@@ -25,4 +25,20 @@ async function runAction(action, command) {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// Automation handlers return numeric exit codes, including 0 (success)
|
|
29
|
+
// and 2 (partial result). They run without the interactive update check.
|
|
30
|
+
async function runAutomationAction(action, command) {
|
|
31
|
+
const options = commandOptions(command);
|
|
32
|
+
try {
|
|
33
|
+
process.exitCode = await withCommandOptions(options, async () => {
|
|
34
|
+
const code = await action(options);
|
|
35
|
+
if (options.dry && code === 0) reportDryRun();
|
|
36
|
+
return code;
|
|
37
|
+
});
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error('ERROR: ' + error.message);
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { runAction, runAutomationAction };
|