docs+portal: add pilot v1 demo pack
This commit is contained in:
@@ -73,6 +73,28 @@ Future:
|
||||
- React/TypeScript Enterprise UI.
|
||||
- Tauri Desktop Forensics.
|
||||
|
||||
## Pilot v1 demo
|
||||
|
||||
Pilot v1 demo показывает AWatch-rus как рабочую платформу Workforce Analytics +
|
||||
Security Analytics + Forensics для ролей `executive`, `manager`, `security`,
|
||||
`forensics` и `admin`.
|
||||
|
||||
Демо-материалы:
|
||||
|
||||
- [сценарий Pilot v1 demo](docs/PILOT_DEMO_SCENARIO_RU.md);
|
||||
- [demo seed data](docs/fixtures/pilot-v1-demo/demo-seed-data.json);
|
||||
- [demo evidence pack](docs/fixtures/pilot-v1-demo/evidence-pack/);
|
||||
- [преддемо-runbook](docs/DEMO_RUNBOOK_RU.md).
|
||||
|
||||
Границы показа:
|
||||
|
||||
- pfSense показывается только как `contract_only/readiness`, без заявления
|
||||
production ingestion или SIEM;
|
||||
- UEBA Score v1 является прозрачной rule-based моделью, без ML/LLM;
|
||||
- demo fixtures не содержат реальных IP-адресов, hostname, логинов, ФИО,
|
||||
подразделений заказчика или событий безопасности;
|
||||
- planned/future providers не являются реализованными collectors.
|
||||
|
||||
## Что видит оператор
|
||||
|
||||
- Работал ли пользователь за компьютером или в удаленной сессии.
|
||||
@@ -172,6 +194,7 @@ collectors.
|
||||
- [Архитектурный baseline](docs/ARCHITECTURE_BASELINE_RU.md)
|
||||
- [Пакет пилота для заказчика](docs/CUSTOMER_PILOT_PACK_RU.md)
|
||||
- [Pilot v1.0](docs/PILOT_V1_RU.md)
|
||||
- [Pilot v1 demo](docs/PILOT_DEMO_SCENARIO_RU.md)
|
||||
- [Pilot v1.0 acceptance checklist](docs/PILOT_V1_ACCEPTANCE_CHECKLIST_RU.md)
|
||||
- [Pilot v1.0 evidence](docs/PILOT_V1_EVIDENCE_RU.md)
|
||||
- [Позиционирование продукта](docs/PRODUCT_POSITIONING_RU.md)
|
||||
|
||||
@@ -3441,6 +3441,7 @@ fn role_filtered_report(report: Value, role: PortalRole) -> Value {
|
||||
"business_risk",
|
||||
"business_risk_history_summary",
|
||||
"risk_heatmap",
|
||||
"security_events_summary",
|
||||
"workforce",
|
||||
"markdown",
|
||||
] {
|
||||
@@ -10037,6 +10038,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portal_pilot_demo_navigation_is_present() {
|
||||
for marker in [
|
||||
"Pilot v1 demo",
|
||||
"Executive demo",
|
||||
"Manager demo",
|
||||
"Security demo",
|
||||
"Forensics demo",
|
||||
"Admin demo",
|
||||
"data-demo-view-mode=\"executive\"",
|
||||
"data-demo-view-mode=\"security\"",
|
||||
"data-demo-view-mode=\"forensics\"",
|
||||
"data-demo-view-mode=\"admin\"",
|
||||
] {
|
||||
assert!(
|
||||
INDEX_HTML.contains(marker),
|
||||
"demo navigation missing {marker}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_contract_artifacts_are_valid_and_future_ui_ready() {
|
||||
let openapi: Value =
|
||||
@@ -10134,6 +10156,7 @@ mod tests {
|
||||
let executive = role_filtered_report(report.clone(), PortalRole::Executive);
|
||||
assert!(executive.get("workforce").is_some());
|
||||
assert!(executive.get("executive_dashboard").is_some());
|
||||
assert!(executive.get("security_events_summary").is_some());
|
||||
assert!(executive.get("risk_incident_candidates").is_none());
|
||||
assert!(executive.get("security_correlation").is_none());
|
||||
|
||||
|
||||
@@ -82,13 +82,17 @@ body.security-mode .small-button,
|
||||
body.security-mode a.button,
|
||||
body.security-mode .tab,
|
||||
body.security-mode .nav-link,
|
||||
body.security-mode .view-button,
|
||||
body.security-mode .demo-button,
|
||||
body.security-mode .control-strip select {
|
||||
background: #111827;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
body.security-mode .tab.is-active,
|
||||
body.security-mode .nav-link.is-active {
|
||||
body.security-mode .nav-link.is-active,
|
||||
body.security-mode .view-button.is-active,
|
||||
body.security-mode .demo-button.is-active {
|
||||
background: #0f2a3a;
|
||||
color: var(--link);
|
||||
}
|
||||
@@ -145,14 +149,16 @@ h1 {
|
||||
|
||||
.control-strip,
|
||||
.header-actions,
|
||||
.view-switch {
|
||||
.view-switch,
|
||||
.demo-switch {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.view-switch {
|
||||
.view-switch,
|
||||
.demo-switch {
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--line);
|
||||
@@ -160,14 +166,16 @@ h1 {
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.view-switch > span {
|
||||
.view-switch > span,
|
||||
.demo-switch > span {
|
||||
padding: 0 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.view-button {
|
||||
.view-button,
|
||||
.demo-button {
|
||||
min-height: 30px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
@@ -180,18 +188,21 @@ h1 {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.view-button.is-active {
|
||||
.view-button.is-active,
|
||||
.demo-button.is-active {
|
||||
border-color: #1d4ed8;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.view-button:hover {
|
||||
.view-button:hover,
|
||||
.demo-button:hover {
|
||||
color: var(--ink);
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
.view-button.is-active:hover {
|
||||
.view-button.is-active:hover,
|
||||
.demo-button.is-active:hover {
|
||||
color: #ffffff;
|
||||
background: #1d4ed8;
|
||||
}
|
||||
@@ -1210,10 +1221,13 @@ pre {
|
||||
.topbar-side { justify-items: stretch; min-width: 0; width: 100%; }
|
||||
.control-strip,
|
||||
.header-actions,
|
||||
.view-switch { justify-content: flex-start; }
|
||||
.view-switch,
|
||||
.demo-switch { justify-content: flex-start; }
|
||||
.control-strip label { min-width: 100%; }
|
||||
.view-switch > span { flex-basis: 100%; }
|
||||
.view-button { flex: 1 1 120px; }
|
||||
.view-switch > span,
|
||||
.demo-switch > span { flex-basis: 100%; }
|
||||
.view-button,
|
||||
.demo-button { flex: 1 1 120px; }
|
||||
h1 { font-size: 24px; }
|
||||
.page-head { flex-direction: column; }
|
||||
.period-banner { flex-direction: column; align-items: flex-start; }
|
||||
|
||||
@@ -248,6 +248,9 @@ function updateViewModeButtons() {
|
||||
document.querySelectorAll("[data-view-mode]").forEach(button => {
|
||||
button.classList.toggle("is-active", button.dataset.viewMode === currentViewMode());
|
||||
});
|
||||
document.querySelectorAll("[data-demo-view-mode]").forEach(button => {
|
||||
button.classList.toggle("is-active", button.dataset.demoViewMode === currentViewMode());
|
||||
});
|
||||
}
|
||||
|
||||
function setViewMode(mode) {
|
||||
@@ -2298,9 +2301,7 @@ function renderSecurityEventsSummary(summary, options = {}) {
|
||||
const title = options.compact
|
||||
? "События безопасности"
|
||||
: "События безопасности за 24 часа";
|
||||
const compactStateText = disabled && options.compact
|
||||
? "Детализация скрыта для роли"
|
||||
: stateText;
|
||||
const compactStateText = stateText;
|
||||
const compactModeText = disabled && options.compact
|
||||
? "Без ИБ-детализации"
|
||||
: disabled
|
||||
@@ -3052,6 +3053,10 @@ document.querySelectorAll("[data-view-mode]").forEach(btn => {
|
||||
btn.addEventListener("click", () => setViewMode(btn.dataset.viewMode));
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-demo-view-mode]").forEach(btn => {
|
||||
btn.addEventListener("click", () => setViewMode(btn.dataset.demoViewMode));
|
||||
});
|
||||
|
||||
document.getElementById("periodFilter")?.addEventListener("change", event => {
|
||||
state.period = periodFromSelectValue(event.target.value);
|
||||
refresh({ stage: "Получение данных за выбранный период" });
|
||||
|
||||
@@ -58,6 +58,14 @@
|
||||
<button class="view-button" data-view-mode="forensics" type="button">Расследования</button>
|
||||
<button class="view-button" data-view-mode="admin" type="button">Админ</button>
|
||||
</div>
|
||||
<div class="demo-switch" aria-label="Pilot v1 demo navigation">
|
||||
<span>Pilot v1 demo</span>
|
||||
<button class="demo-button is-active" data-demo-view-mode="executive" type="button">Executive demo</button>
|
||||
<button class="demo-button" data-demo-view-mode="manager" type="button">Manager demo</button>
|
||||
<button class="demo-button" data-demo-view-mode="security" type="button">Security demo</button>
|
||||
<button class="demo-button" data-demo-view-mode="forensics" type="button">Forensics demo</button>
|
||||
<button class="demo-button" data-demo-view-mode="admin" type="button">Admin demo</button>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="small-button primary" data-open-reports="true">Сформировать отчет</button>
|
||||
<button class="small-button" data-export-markdown="true">Текстовый отчет</button>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
Цель: за 10 минут показать руководителю, ИБ и эксплуатации единый путь от
|
||||
главного риска до итогового отчета.
|
||||
|
||||
Полный ролевой сценарий Pilot v1 demo:
|
||||
[PILOT_DEMO_SCENARIO_RU.md](PILOT_DEMO_SCENARIO_RU.md).
|
||||
|
||||
## Перед началом
|
||||
|
||||
- Открыть портал на рабочем демонстрационном URL.
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# Pilot v1 Demo Scenario
|
||||
|
||||
Цель сценария: за 10-12 минут показать AWatch-rus как рабочую платформу
|
||||
Workforce Analytics + Security Analytics + Forensics без ложных заявлений о
|
||||
SIEM, классическом DLP, ML/LLM или готовом pfSense ingestion.
|
||||
|
||||
Демонстрация проводится только на обезличенных данных. Demo fixtures не
|
||||
являются live ingestion и не подменяют промышленную настройку источников.
|
||||
|
||||
## Перед показом
|
||||
|
||||
1. Открыть `docs/DEMO_RUNBOOK_RU.md`.
|
||||
2. Выполнить обязательный преддемо-прогрев.
|
||||
3. Убедиться, что smoke-тест завершился с `ok=true`.
|
||||
4. Открыть портал AWatch-rus.
|
||||
5. Проверить, что данные загружены и главный вывод отображается первым.
|
||||
6. Держать рядом итоговый Markdown-отчет или evidence pack.
|
||||
|
||||
## Руководитель
|
||||
|
||||
Экран: `Executive demo` / роль `Руководитель`.
|
||||
|
||||
Показать:
|
||||
|
||||
- блок `Главный вывод`;
|
||||
- сводку руководителя;
|
||||
- риски подразделений;
|
||||
- карту рисков;
|
||||
- итоговый управленческий Markdown-отчет.
|
||||
|
||||
Ценность:
|
||||
|
||||
- руководитель видит главный риск без чтения технических логов;
|
||||
- видно, какое подразделение требует внимания;
|
||||
- риск объясняется через активность, полноту данных и подтверждающие слои.
|
||||
|
||||
Честные ограничения:
|
||||
|
||||
- экран не является HR-оценкой сотрудника;
|
||||
- вывод требует управленческой интерпретации;
|
||||
- качество вывода зависит от свежести и полноты источников.
|
||||
|
||||
Что не заявлять:
|
||||
|
||||
- не говорить, что система автоматически принимает кадровые решения;
|
||||
- не утверждать, что все события ИБ уже подтверждены как инциденты;
|
||||
- не показывать техническую ИБ-детализацию как обязательную часть Executive View.
|
||||
|
||||
## Менеджер
|
||||
|
||||
Экран: `Manager demo` / роль `Менеджер`.
|
||||
|
||||
Показать:
|
||||
|
||||
- Workforce Dashboard;
|
||||
- сравнение подразделений;
|
||||
- ответственных;
|
||||
- daily/weekly/monthly тренды;
|
||||
- признаки перегрузки и недозагрузки;
|
||||
- Markdown-отчет по управленческому срезу.
|
||||
|
||||
Ценность:
|
||||
|
||||
- менеджер видит загрузку подразделения и отклонения;
|
||||
- можно отделить просадку активности от проблемы сбора данных;
|
||||
- отчет можно передать руководителю без сырых событий.
|
||||
|
||||
Честные ограничения:
|
||||
|
||||
- это аналитика активности, а не автоматическая оценка эффективности;
|
||||
- без корректного справочника подразделений часть данных может попасть в
|
||||
категорию `Не привязано к подразделению`;
|
||||
- точность зависит от настроенной политики рабочего времени.
|
||||
|
||||
Что не заявлять:
|
||||
|
||||
- не обещать готовую интеграцию со всеми HR/ERP-системами;
|
||||
- не выдавать planned providers за реализованные collectors;
|
||||
- не показывать security-only очередь расследований менеджеру.
|
||||
|
||||
## Безопасность
|
||||
|
||||
Экран: `Security demo` / роль `Безопасность`.
|
||||
|
||||
Показать:
|
||||
|
||||
- список рисков;
|
||||
- UEBA Score v1;
|
||||
- severity `low` / `medium` / `high` / `critical`;
|
||||
- события безопасности, если источник доступен;
|
||||
- кандидатов на проверку;
|
||||
- pfSense readiness как `contract_only`.
|
||||
|
||||
Ценность:
|
||||
|
||||
- ИБ получает объяснимый риск-скоринг без ML/LLM;
|
||||
- кандидаты на проверку отделены от управленческого Workforce Dashboard;
|
||||
- pfSense показан честно как подготовленный контракт, а не как готовый SIEM.
|
||||
|
||||
Честные ограничения:
|
||||
|
||||
- UEBA v1 является rule-based моделью;
|
||||
- pfSense readiness не означает production ingestion;
|
||||
- риск является сигналом для проверки, а не автоматическим вердиктом.
|
||||
|
||||
Что не заявлять:
|
||||
|
||||
- не называть продукт SIEM;
|
||||
- не утверждать, что pfSense ingestion уже работает;
|
||||
- не говорить, что UEBA использует ML, LLM или внешние SaaS-модели.
|
||||
|
||||
## Расследования
|
||||
|
||||
Экран: `Forensics demo` / роль `Расследования`.
|
||||
|
||||
Показать:
|
||||
|
||||
- карточку расследования;
|
||||
- timeline событий;
|
||||
- связку `user / host / app / network event`;
|
||||
- evidence package;
|
||||
- экспорт Markdown-отчета.
|
||||
|
||||
Ценность:
|
||||
|
||||
- расследование собирает управляемый пакет материалов;
|
||||
- timeline помогает объяснить, почему кандидат попал в проверку;
|
||||
- отчет пригоден для ручного разбора ответственным сотрудником.
|
||||
|
||||
Честные ограничения:
|
||||
|
||||
- evidence package не является процессуальной экспертизой;
|
||||
- скриншоты и доказательства в demo-контуре обезличены;
|
||||
- окончательное решение принимает человек.
|
||||
|
||||
Что не заявлять:
|
||||
|
||||
- не говорить, что система сама доказывает нарушение;
|
||||
- не показывать demo identifiers как реальные пользователи или хосты;
|
||||
- не смешивать Forensics View с управленческим Workforce Dashboard.
|
||||
|
||||
## Администратор
|
||||
|
||||
Экран: `Admin demo` / роль `Админ`.
|
||||
|
||||
Показать:
|
||||
|
||||
- техническую готовность;
|
||||
- полноту данных;
|
||||
- качество сбора;
|
||||
- состояние контрактов;
|
||||
- server-side role gates;
|
||||
- статус ClickHouse или понятный fallback, если источник недоступен.
|
||||
|
||||
Ценность:
|
||||
|
||||
- эксплуатация понимает, можно ли доверять текущей витрине;
|
||||
- видны ошибки сбора и устаревшие данные;
|
||||
- есть проверочный путь перед пилотным показом.
|
||||
|
||||
Честные ограничения:
|
||||
|
||||
- admin view не заменяет полноценный NOC/SIEM;
|
||||
- часть источников может быть в readiness/contract-only статусе;
|
||||
- эксплуатационные выводы требуют регулярной проверки smoke-тестами.
|
||||
|
||||
Что не заявлять:
|
||||
|
||||
- не обещать автоматическое восстановление всех источников;
|
||||
- не скрывать fallback-режимы;
|
||||
- не утверждать готовность planned/future providers.
|
||||
|
||||
## Evidence Pack
|
||||
|
||||
Демонстрационные материалы:
|
||||
|
||||
- [demo-seed-data.json](fixtures/pilot-v1-demo/demo-seed-data.json);
|
||||
- [executive-summary.md](fixtures/pilot-v1-demo/evidence-pack/executive-summary.md);
|
||||
- [security-technical-summary.md](fixtures/pilot-v1-demo/evidence-pack/security-technical-summary.md);
|
||||
- [investigation-report.md](fixtures/pilot-v1-demo/evidence-pack/investigation-report.md);
|
||||
- [investigation-contract.json](fixtures/pilot-v1-demo/evidence-pack/investigation-contract.json).
|
||||
|
||||
Все материалы demo-only. Для сетевых примеров используются только RFC 5737
|
||||
адреса `192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`.
|
||||
|
||||
## Финальный тезис
|
||||
|
||||
AWatch-rus Pilot v1 демонстрирует управленческий контур, контур ИБ,
|
||||
расследования, ролевые ограничения и честный readiness-слой для будущих
|
||||
интеграций. Это не SIEM, не классический DLP и не ML-платформа.
|
||||
@@ -18,6 +18,7 @@ contracts, UEBA Score v1 и pfSense readiness.
|
||||
| UEBA | [UEBA_SCORE_RU.md](UEBA_SCORE_RU.md) | rule-based scoring без ML/LLM |
|
||||
| pfSense | [PFSENSE_INTEGRATION_RU.md](PFSENSE_INTEGRATION_RU.md) | readiness=`contract_only`, без SIEM/ingestion claims |
|
||||
| Demo сценарий | [CUSTOMER_DEMO_SCENARIO_RU.md](CUSTOMER_DEMO_SCENARIO_RU.md) | 10-минутный показ заказчику |
|
||||
| Pilot v1 demo | [PILOT_DEMO_SCENARIO_RU.md](PILOT_DEMO_SCENARIO_RU.md) | воспроизводимый demo-pack по ролям |
|
||||
| Преддемо | [DEMO_RUNBOOK_RU.md](DEMO_RUNBOOK_RU.md) | прогрев и порядок проверки перед показом |
|
||||
| Gap analysis | [PILOT_GAP_ANALYSIS_RU.md](PILOT_GAP_ANALYSIS_RU.md) | известные риски и остаточные ограничения |
|
||||
|
||||
@@ -52,6 +53,11 @@ contracts, UEBA Score v1 и pfSense readiness.
|
||||
- `docs/screenshots/06-markdown-report.png`;
|
||||
- `docs/screenshots/07-product-architecture.png`;
|
||||
- `docs/fixtures/pfsense-demo-events.json`.
|
||||
- `docs/fixtures/pilot-v1-demo/demo-seed-data.json`;
|
||||
- `docs/fixtures/pilot-v1-demo/evidence-pack/executive-summary.md`;
|
||||
- `docs/fixtures/pilot-v1-demo/evidence-pack/security-technical-summary.md`;
|
||||
- `docs/fixtures/pilot-v1-demo/evidence-pack/investigation-report.md`;
|
||||
- `docs/fixtures/pilot-v1-demo/evidence-pack/investigation-contract.json`.
|
||||
|
||||
Требования:
|
||||
|
||||
@@ -81,6 +87,7 @@ cargo build --workspace --release
|
||||
cd ..
|
||||
git diff --check
|
||||
node scripts/detmir-portal-tabs-smoke.mjs
|
||||
node scripts/detmir-pilot-demo-smoke.mjs
|
||||
```
|
||||
|
||||
Что подтверждают команды:
|
||||
@@ -93,6 +100,8 @@ node scripts/detmir-portal-tabs-smoke.mjs
|
||||
- `git diff --check` - нет whitespace-ошибок в diff.
|
||||
- `node scripts/detmir-portal-tabs-smoke.mjs` - портал, вкладки, роли и
|
||||
server-side role gates проходят smoke.
|
||||
- `node scripts/detmir-pilot-demo-smoke.mjs` - demo-навигация, API contracts и
|
||||
role-filtering для Pilot v1 demo проходят smoke.
|
||||
|
||||
## Приемочный вывод
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Pilot v1 Demo Fixtures
|
||||
|
||||
Этот каталог содержит воспроизводимые обезличенные материалы для демонстрации
|
||||
AWatch-rus Pilot v1.
|
||||
|
||||
Состав:
|
||||
|
||||
- `demo-seed-data.json` - демонстрационный набор сигналов Workforce, Security,
|
||||
Forensics, UEBA и pfSense readiness;
|
||||
- `evidence-pack/executive-summary.md` - краткий управленческий вывод;
|
||||
- `evidence-pack/security-technical-summary.md` - техническая сводка для ИБ;
|
||||
- `evidence-pack/investigation-report.md` - Markdown-отчет расследования;
|
||||
- `evidence-pack/investigation-contract.json` - пример JSON-контракта
|
||||
investigation/evidence package.
|
||||
|
||||
Границы:
|
||||
|
||||
- это не live ingestion;
|
||||
- это не production data;
|
||||
- это не новый collector;
|
||||
- pfSense остается `contract_only/readiness`;
|
||||
- UEBA Score v1 остается rule-based без ML/LLM.
|
||||
|
||||
В demo fixtures запрещены реальные IP-адреса, hostname, логины, ФИО,
|
||||
подразделения заказчика и реальные события безопасности.
|
||||
@@ -0,0 +1,223 @@
|
||||
{
|
||||
"dataset": "awatch-rus-pilot-v1-demo",
|
||||
"generated_at_utc": "2026-06-06T09:00:00Z",
|
||||
"demo_only": true,
|
||||
"privacy": {
|
||||
"real_personal_data": false,
|
||||
"real_customer_hosts": false,
|
||||
"real_customer_networks": false,
|
||||
"allowed_network_examples": [
|
||||
"192.0.2.0/24",
|
||||
"198.51.100.0/24",
|
||||
"203.0.113.0/24"
|
||||
]
|
||||
},
|
||||
"departments": [
|
||||
{
|
||||
"department_id": "dept-sales-demo",
|
||||
"name": "Отдел продаж",
|
||||
"activity_index": 82,
|
||||
"load_state": "normal",
|
||||
"risk": "low"
|
||||
},
|
||||
{
|
||||
"department_id": "dept-accounting-demo",
|
||||
"name": "Бухгалтерия",
|
||||
"activity_index": 68,
|
||||
"load_state": "normal",
|
||||
"risk": "medium"
|
||||
},
|
||||
{
|
||||
"department_id": "dept-warehouse-demo",
|
||||
"name": "Склад",
|
||||
"activity_index": 41,
|
||||
"load_state": "low_activity",
|
||||
"risk": "high"
|
||||
},
|
||||
{
|
||||
"department_id": "dept-support-demo",
|
||||
"name": "Поддержка",
|
||||
"activity_index": 91,
|
||||
"load_state": "overload",
|
||||
"risk": "medium"
|
||||
},
|
||||
{
|
||||
"department_id": "dept-it-demo",
|
||||
"name": "ИТ",
|
||||
"activity_index": 74,
|
||||
"load_state": "normal",
|
||||
"risk": "critical"
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"user_ref": "demo-user-001",
|
||||
"department_id": "dept-sales-demo",
|
||||
"host": "HOST-DEMO-01",
|
||||
"responsible": "manager-demo-sales"
|
||||
},
|
||||
{
|
||||
"user_ref": "demo-user-002",
|
||||
"department_id": "dept-accounting-demo",
|
||||
"host": "HOST-DEMO-02",
|
||||
"responsible": "manager-demo-accounting"
|
||||
},
|
||||
{
|
||||
"user_ref": "demo-user-003",
|
||||
"department_id": "dept-warehouse-demo",
|
||||
"host": "HOST-DEMO-03",
|
||||
"responsible": "manager-demo-warehouse"
|
||||
},
|
||||
{
|
||||
"user_ref": "demo-user-004",
|
||||
"department_id": "dept-support-demo",
|
||||
"host": "HOST-DEMO-04",
|
||||
"responsible": "manager-demo-support"
|
||||
},
|
||||
{
|
||||
"user_ref": "demo-user-005",
|
||||
"department_id": "dept-it-demo",
|
||||
"host": "HOST-DEMO-05",
|
||||
"responsible": "manager-demo-it"
|
||||
}
|
||||
],
|
||||
"activity_events": [
|
||||
{
|
||||
"timestamp": "2026-06-06T08:55:00Z",
|
||||
"user_ref": "demo-user-001",
|
||||
"host": "HOST-DEMO-01",
|
||||
"app": "crm-demo.exe",
|
||||
"kind": "normal_activity",
|
||||
"duration_minutes": 42
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-06T09:10:00Z",
|
||||
"user_ref": "demo-user-002",
|
||||
"host": "HOST-DEMO-02",
|
||||
"app": "1cv8.exe",
|
||||
"kind": "business_app_activity",
|
||||
"duration_minutes": 67
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-06T10:25:00Z",
|
||||
"user_ref": "demo-user-003",
|
||||
"host": "HOST-DEMO-03",
|
||||
"app": "rdp-session",
|
||||
"kind": "low_activity",
|
||||
"duration_minutes": 8
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-06T21:40:00Z",
|
||||
"user_ref": "demo-user-005",
|
||||
"host": "HOST-DEMO-05",
|
||||
"app": "admin-tool-demo.exe",
|
||||
"kind": "night_access_anomaly",
|
||||
"duration_minutes": 24
|
||||
}
|
||||
],
|
||||
"security_events": [
|
||||
{
|
||||
"event_id": "sec-demo-001",
|
||||
"timestamp": "2026-06-06T11:05:00Z",
|
||||
"severity": "low",
|
||||
"user_ref": "demo-user-001",
|
||||
"host": "HOST-DEMO-01",
|
||||
"reason_code": "activity_anomaly",
|
||||
"description": "Повышенная активность без сетевых признаков."
|
||||
},
|
||||
{
|
||||
"event_id": "sec-demo-002",
|
||||
"timestamp": "2026-06-06T12:20:00Z",
|
||||
"severity": "medium",
|
||||
"user_ref": "demo-user-002",
|
||||
"host": "HOST-DEMO-02",
|
||||
"reason_code": "application_anomaly",
|
||||
"description": "Редкое приложение в рабочем контексте."
|
||||
},
|
||||
{
|
||||
"event_id": "sec-demo-003",
|
||||
"timestamp": "2026-06-06T18:35:00Z",
|
||||
"severity": "high",
|
||||
"user_ref": "demo-user-003",
|
||||
"host": "HOST-DEMO-03",
|
||||
"reason_code": "low_activity_with_late_rdp",
|
||||
"description": "Низкая дневная активность и поздняя RDP-сессия."
|
||||
},
|
||||
{
|
||||
"event_id": "sec-demo-004",
|
||||
"timestamp": "2026-06-06T21:45:00Z",
|
||||
"severity": "critical",
|
||||
"user_ref": "demo-user-005",
|
||||
"host": "HOST-DEMO-05",
|
||||
"reason_code": "night_network_admin_activity",
|
||||
"description": "Ночное администрирование и сетевой признак требуют ручной проверки."
|
||||
}
|
||||
],
|
||||
"network_events": [
|
||||
{
|
||||
"timestamp": "2026-06-06T21:46:00Z",
|
||||
"source_host": "HOST-DEMO-05",
|
||||
"source_ip": "192.0.2.15",
|
||||
"destination": "203.0.113.20:443",
|
||||
"action": "pass",
|
||||
"provider_status": "contract_only"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-06T21:47:00Z",
|
||||
"source_host": "HOST-DEMO-05",
|
||||
"source_ip": "192.0.2.15",
|
||||
"destination": "198.51.100.25:22",
|
||||
"action": "block",
|
||||
"provider_status": "contract_only"
|
||||
}
|
||||
],
|
||||
"ueba_score_examples": [
|
||||
{
|
||||
"user_ref": "demo-user-001",
|
||||
"score": 18,
|
||||
"severity": "low",
|
||||
"reason_codes": [
|
||||
"activity_anomaly"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user_ref": "demo-user-002",
|
||||
"score": 42,
|
||||
"severity": "medium",
|
||||
"reason_codes": [
|
||||
"application_anomaly",
|
||||
"history_anomaly"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user_ref": "demo-user-003",
|
||||
"score": 63,
|
||||
"severity": "high",
|
||||
"reason_codes": [
|
||||
"activity_anomaly",
|
||||
"time_anomaly"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user_ref": "demo-user-005",
|
||||
"score": 82,
|
||||
"severity": "critical",
|
||||
"reason_codes": [
|
||||
"time_anomaly",
|
||||
"network_anomaly",
|
||||
"history_anomaly"
|
||||
]
|
||||
}
|
||||
],
|
||||
"investigation": {
|
||||
"investigation_id": "case-demo-004",
|
||||
"candidate_id": "sec-demo-004",
|
||||
"status": "in_review",
|
||||
"entities": {
|
||||
"user": "demo-user-005",
|
||||
"host": "HOST-DEMO-05",
|
||||
"app": "admin-tool-demo.exe",
|
||||
"network_event": "203.0.113.20:443"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Executive Summary
|
||||
|
||||
Demo-only evidence pack for AWatch-rus Pilot v1.
|
||||
|
||||
## Главный вывод
|
||||
|
||||
В демонстрационном периоде главный управленческий риск связан с сочетанием
|
||||
ночной административной активности, сетевого признака и отклонения от обычной
|
||||
истории работы. Риск требует ручной проверки, но не является автоматическим
|
||||
вердиктом о нарушении.
|
||||
|
||||
## Что видно руководителю
|
||||
|
||||
- общий индекс активности по подразделениям;
|
||||
- подразделение с повышенным риском;
|
||||
- причина риска в понятных управленческих терминах;
|
||||
- краткий вывод без технических логов;
|
||||
- ссылка на evidence pack для ИБ и расследований.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- demo data обезличены;
|
||||
- UEBA Score v1 является rule-based моделью;
|
||||
- pfSense readiness остается `contract_only`;
|
||||
- AWatch-rus не заявляется как SIEM, классический DLP или ML-платформа.
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"contract_version": "forensics-v1",
|
||||
"demo_only": true,
|
||||
"investigation": {
|
||||
"investigation_id": "case-demo-004",
|
||||
"candidate_id": "sec-demo-004",
|
||||
"status": "in_review",
|
||||
"severity": "critical",
|
||||
"summary": "Ночная административная активность и сетевой признак требуют ручной проверки.",
|
||||
"entities": {
|
||||
"user": "demo-user-005",
|
||||
"host": "HOST-DEMO-05",
|
||||
"app": "admin-tool-demo.exe",
|
||||
"network_event": "203.0.113.20:443"
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"timestamp": "2026-06-06T21:40:00Z",
|
||||
"kind": "activity",
|
||||
"entity": "demo-user-005",
|
||||
"summary": "Ночная активность в административном приложении.",
|
||||
"source": "activity_rules"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-06T21:45:00Z",
|
||||
"kind": "risk",
|
||||
"entity": "sec-demo-004",
|
||||
"summary": "UEBA Score v1 severity critical.",
|
||||
"source": "ueba-score-v1"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-06T21:46:00Z",
|
||||
"kind": "network",
|
||||
"entity": "HOST-DEMO-05",
|
||||
"summary": "Сетевой признак по readiness-контракту.",
|
||||
"source": "pfsense-contract"
|
||||
}
|
||||
],
|
||||
"exports": {
|
||||
"markdown": "investigation-report.md",
|
||||
"executive_summary": "executive-summary.md",
|
||||
"security_summary": "security-technical-summary.md"
|
||||
}
|
||||
},
|
||||
"claims": {
|
||||
"siem": false,
|
||||
"classic_dlp": false,
|
||||
"ml_used": false,
|
||||
"llm_used": false,
|
||||
"pfsense_ingestion": false,
|
||||
"pfsense_status": "contract_only"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Investigation Report
|
||||
|
||||
Demo-only investigation report for AWatch-rus Pilot v1.
|
||||
|
||||
## Карточка расследования
|
||||
|
||||
- Investigation ID: `case-demo-004`
|
||||
- Candidate ID: `sec-demo-004`
|
||||
- Status: `in_review`
|
||||
- Severity: `critical`
|
||||
- User: `demo-user-005`
|
||||
- Host: `HOST-DEMO-05`
|
||||
- App: `admin-tool-demo.exe`
|
||||
- Network event: `203.0.113.20:443`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Время UTC | Тип | Сущность | Кратко | Источник |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2026-06-06T21:40:00Z | activity | `demo-user-005` | Ночная активность в административном приложении | activity_rules |
|
||||
| 2026-06-06T21:45:00Z | risk | `sec-demo-004` | UEBA severity `critical` | ueba-score-v1 |
|
||||
| 2026-06-06T21:46:00Z | network | `HOST-DEMO-05` | Сетевой признак по readiness-контракту | pfsense-contract |
|
||||
| 2026-06-06T21:50:00Z | review | `case-demo-004` | Кейс требует ручной проверки | portal_contract |
|
||||
|
||||
## Вывод расследования
|
||||
|
||||
Демонстрационный кейс показывает, как AWatch-rus связывает активность,
|
||||
приложение, хост и сетевой признак в единый пакет проверки. Данные обезличены.
|
||||
Система не выносит автоматический вердикт и не заявляет production pfSense
|
||||
ingestion.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Security Technical Summary
|
||||
|
||||
Demo-only security summary for AWatch-rus Pilot v1.
|
||||
|
||||
## Сигнал
|
||||
|
||||
Кандидат `sec-demo-004` получил severity `critical` из-за комбинации:
|
||||
|
||||
- `time_anomaly`;
|
||||
- `network_anomaly`;
|
||||
- `history_anomaly`.
|
||||
|
||||
## UEBA Score v1
|
||||
|
||||
```json
|
||||
{
|
||||
"score": 82,
|
||||
"severity": "critical",
|
||||
"model": "ueba-score-v1",
|
||||
"type": "rule_based",
|
||||
"ml_used": false,
|
||||
"llm_used": false
|
||||
}
|
||||
```
|
||||
|
||||
## Network Context
|
||||
|
||||
Сетевые признаки показаны на RFC 5737 адресах и относятся к readiness-контракту:
|
||||
|
||||
- source host: `HOST-DEMO-05`;
|
||||
- source IP: `192.0.2.15`;
|
||||
- destination: `203.0.113.20:443`;
|
||||
- provider status: `contract_only`.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- это не production pfSense ingestion;
|
||||
- это не SIEM-correlation;
|
||||
- риск является кандидатом на ручную проверку;
|
||||
- окончательное решение принимает специалист.
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
if (["1", "true", "yes"].includes(String(process.env.DETMIR_PORTAL_SMOKE_INSECURE_TLS || "").toLowerCase())) {
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
||||
}
|
||||
|
||||
function env(name, fallback = "") {
|
||||
const value = process.env[name];
|
||||
return value && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const explicit = env("DETMIR_PORTAL_SMOKE_AUTH_HEADER");
|
||||
if (explicit) return { Authorization: explicit };
|
||||
const basic = env("DETMIR_PORTAL_SMOKE_BASIC_AUTH", env("DETMIR_BASIC_AUTH"));
|
||||
if (basic) return { Authorization: `Basic ${basic}` };
|
||||
return {};
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(raw) {
|
||||
const value = raw.endsWith("/") ? raw : `${raw}/`;
|
||||
return new URL(value);
|
||||
}
|
||||
|
||||
function contains(text, marker) {
|
||||
return String(text || "").toLowerCase().includes(String(marker || "").toLowerCase());
|
||||
}
|
||||
|
||||
function hasOwn(object, key) {
|
||||
return Boolean(object && Object.prototype.hasOwnProperty.call(object, key));
|
||||
}
|
||||
|
||||
async function fetchText(baseUrl, relativePath, role = "executive") {
|
||||
const url = new URL(relativePath, baseUrl);
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
...authHeaders(),
|
||||
"X-AWatch-Role": role,
|
||||
Accept: "text/html,application/json",
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
// HTML and text error bodies are expected for some checks.
|
||||
}
|
||||
return { url: url.toString(), status: response.status, ok: response.ok, text, json };
|
||||
}
|
||||
|
||||
function readDemoFiles() {
|
||||
const files = [
|
||||
"docs/PILOT_DEMO_SCENARIO_RU.md",
|
||||
"docs/fixtures/pilot-v1-demo/README_RU.md",
|
||||
"docs/fixtures/pilot-v1-demo/demo-seed-data.json",
|
||||
"docs/fixtures/pilot-v1-demo/evidence-pack/executive-summary.md",
|
||||
"docs/fixtures/pilot-v1-demo/evidence-pack/security-technical-summary.md",
|
||||
"docs/fixtures/pilot-v1-demo/evidence-pack/investigation-report.md",
|
||||
"docs/fixtures/pilot-v1-demo/evidence-pack/investigation-contract.json",
|
||||
];
|
||||
return files.map((file) => ({
|
||||
file,
|
||||
text: fs.readFileSync(path.join(root, file), "utf8"),
|
||||
}));
|
||||
}
|
||||
|
||||
function scanDemoFiles() {
|
||||
const forbidden = [
|
||||
/10\.10\.\d+\.\d+/,
|
||||
/192\.168\.\d+\.\d+/,
|
||||
/172\.(1[6-9]|2\d|3[0-1])\.\d+\.\d+/,
|
||||
new RegExp(["dm", "\\.iri"].join(""), "i"),
|
||||
new RegExp(["shar", "kon"].join(""), "i"),
|
||||
new RegExp(["/home/", "igor"].join(""), "i"),
|
||||
/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i,
|
||||
new RegExp(["Det", "Mir"].join("")),
|
||||
];
|
||||
const findings = [];
|
||||
for (const item of readDemoFiles()) {
|
||||
for (const pattern of forbidden) {
|
||||
if (pattern.test(item.text)) {
|
||||
findings.push({ file: item.file, pattern: String(pattern) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function pass(checks, name, ok, details = {}) {
|
||||
checks.push({ name, ok: Boolean(ok), ...details });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baseUrl = normalizeBaseUrl(env("DETMIR_PORTAL_SMOKE_URL", "http://127.0.0.1:8720/portal/"));
|
||||
const checks = [];
|
||||
|
||||
const html = await fetchText(baseUrl, "", "executive");
|
||||
pass(checks, "portal_html_available", html.ok && contains(html.text, "AWatch-rus"), {
|
||||
status: html.status,
|
||||
url: html.url,
|
||||
});
|
||||
for (const marker of [
|
||||
"Pilot v1 demo",
|
||||
"Executive demo",
|
||||
"Manager demo",
|
||||
"Security demo",
|
||||
"Forensics demo",
|
||||
"Admin demo",
|
||||
]) {
|
||||
pass(checks, `demo_navigation:${marker}`, contains(html.text, marker), { marker });
|
||||
}
|
||||
|
||||
const executiveReports = await fetchText(baseUrl, "api/reports?role=executive", "executive");
|
||||
pass(checks, "executive_reports_ok", executiveReports.ok, { status: executiveReports.status });
|
||||
pass(
|
||||
checks,
|
||||
"executive_no_forensics_only_data",
|
||||
executiveReports.ok
|
||||
&& !hasOwn(executiveReports.json, "forensics")
|
||||
&& !hasOwn(executiveReports.json, "risk_incident_candidates")
|
||||
&& !hasOwn(executiveReports.json, "incident_review_audit_summary")
|
||||
&& !hasOwn(executiveReports.json, "security_correlation"),
|
||||
);
|
||||
const executiveForensics = await fetchText(baseUrl, "api/forensics?role=executive", "executive");
|
||||
pass(checks, "executive_forensics_forbidden", executiveForensics.status === 403, {
|
||||
status: executiveForensics.status,
|
||||
});
|
||||
|
||||
const managerReports = await fetchText(baseUrl, "api/reports?role=manager", "manager");
|
||||
pass(
|
||||
checks,
|
||||
"manager_no_security_only_data",
|
||||
managerReports.ok
|
||||
&& !hasOwn(managerReports.json, "ueba_risk")
|
||||
&& !hasOwn(managerReports.json, "security_events_summary")
|
||||
&& !hasOwn(managerReports.json, "risk_incident_candidates")
|
||||
&& !hasOwn(managerReports.json, "forensics"),
|
||||
{ status: managerReports.status },
|
||||
);
|
||||
const managerSecurity = await fetchText(baseUrl, "api/security?role=manager", "manager");
|
||||
pass(checks, "manager_security_forbidden", managerSecurity.status === 403, {
|
||||
status: managerSecurity.status,
|
||||
});
|
||||
|
||||
const securityWorkforce = await fetchText(baseUrl, "api/workforce?role=security", "security");
|
||||
pass(checks, "security_workforce_forbidden", securityWorkforce.status === 403, {
|
||||
status: securityWorkforce.status,
|
||||
});
|
||||
const securityPayload = await fetchText(baseUrl, "api/security?role=security", "security");
|
||||
pass(
|
||||
checks,
|
||||
"security_payload_ok",
|
||||
securityPayload.ok
|
||||
&& hasOwn(securityPayload.json, "ueba_risk")
|
||||
&& hasOwn(securityPayload.json, "security_events_summary")
|
||||
&& !hasOwn(securityPayload.json, "workforce"),
|
||||
{ status: securityPayload.status },
|
||||
);
|
||||
const uebaPayload = await fetchText(baseUrl, "api/ueba?role=security", "security");
|
||||
pass(
|
||||
checks,
|
||||
"security_ueba_contract_ok",
|
||||
uebaPayload.ok
|
||||
&& hasOwn(uebaPayload.json, "score")
|
||||
&& hasOwn(uebaPayload.json, "severity")
|
||||
&& uebaPayload.json?.model?.type === "rule_based"
|
||||
&& uebaPayload.json?.model?.ml_used === false
|
||||
&& uebaPayload.json?.model?.llm_used === false,
|
||||
{ status: uebaPayload.status },
|
||||
);
|
||||
const pfsensePayload = await fetchText(baseUrl, "api/pfsense?role=security", "security");
|
||||
pass(
|
||||
checks,
|
||||
"security_pfsense_readiness_contract_only",
|
||||
pfsensePayload.ok
|
||||
&& pfsensePayload.json?.status === "contract_only"
|
||||
&& pfsensePayload.json?.siem === false
|
||||
&& pfsensePayload.json?.ingestion_available === false,
|
||||
{ status: pfsensePayload.status },
|
||||
);
|
||||
|
||||
const forensicsPayload = await fetchText(baseUrl, "api/forensics?role=forensics", "forensics");
|
||||
pass(
|
||||
checks,
|
||||
"forensics_contract_ok",
|
||||
forensicsPayload.ok
|
||||
&& forensicsPayload.json?.forensics?.contract_version === "forensics-v1"
|
||||
&& Array.isArray(forensicsPayload.json?.forensics?.investigations),
|
||||
{ status: forensicsPayload.status },
|
||||
);
|
||||
const evidencePayload = await fetchText(baseUrl, "api/dlp/evidence?role=forensics", "forensics");
|
||||
pass(checks, "forensics_evidence_endpoint_ok", evidencePayload.ok && Boolean(evidencePayload.json), {
|
||||
status: evidencePayload.status,
|
||||
});
|
||||
|
||||
const adminReports = await fetchText(baseUrl, "api/reports?role=admin", "admin");
|
||||
pass(
|
||||
checks,
|
||||
"admin_technical_readiness_visible",
|
||||
adminReports.ok
|
||||
&& adminReports.json?.role_context?.role === "admin"
|
||||
&& (
|
||||
hasOwn(adminReports.json, "agent_quality")
|
||||
|| hasOwn(adminReports.json, "agent_coverage_sla")
|
||||
|| hasOwn(adminReports.json, "security_events_summary")
|
||||
|| hasOwn(adminReports.json, "operator_ok")
|
||||
),
|
||||
{ status: adminReports.status },
|
||||
);
|
||||
const readinessPayload = await fetchText(baseUrl, "api/readiness/latest?role=admin", "admin");
|
||||
pass(checks, "admin_readiness_endpoint_ok", readinessPayload.ok && Boolean(readinessPayload.json), {
|
||||
status: readinessPayload.status,
|
||||
});
|
||||
|
||||
const sensitivityFindings = scanDemoFiles();
|
||||
pass(checks, "demo_pack_sensitive_string_scan", sensitivityFindings.length === 0, {
|
||||
findings: sensitivityFindings,
|
||||
});
|
||||
|
||||
const ok = checks.every((check) => check.ok);
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok,
|
||||
generated_at_utc: new Date().toISOString(),
|
||||
url: baseUrl.toString(),
|
||||
checks,
|
||||
}, null, 2)}\n`);
|
||||
process.exitCode = ok ? 0 : 2;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: false,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
}, null, 2)}\n`);
|
||||
process.exitCode = 2;
|
||||
});
|
||||
Reference in New Issue
Block a user