feat(portal): harden pilot v1 role contracts
This commit is contained in:
@@ -32,8 +32,10 @@ Python в репозитории остается для вспомогател
|
|||||||
runtime, OCR/content-analysis, 1C/AI/ETL integration и MCP/dev helpers. Эти
|
runtime, OCR/content-analysis, 1C/AI/ETL integration и MCP/dev helpers. Эти
|
||||||
части не являются ядром Rust-first runtime.
|
части не являются ядром Rust-first runtime.
|
||||||
|
|
||||||
Портальный слой зафиксирован как Rust server-rendered HTML + HTMX с JSON API,
|
Портальный слой зафиксирован как Rust server-rendered HTML + HTMX-compatible
|
||||||
OpenAPI и TypeScript declarations для будущих React/Tauri-клиентов.
|
JSON API, OpenAPI и TypeScript declarations. Dioxus не используется и не
|
||||||
|
рассматривается для Pilot v1.0. React, Tauri и Electron также не входят в
|
||||||
|
текущий основной UI.
|
||||||
|
|
||||||
## Что видит оператор
|
## Что видит оператор
|
||||||
|
|
||||||
@@ -50,6 +52,53 @@ OpenAPI и TypeScript declarations для будущих React/Tauri-клиен
|
|||||||
- ИБ - заметить DLP-сигналы и подозрительную активность.
|
- ИБ - заметить DLP-сигналы и подозрительную активность.
|
||||||
- Администратору - проверить, что сборщики и сервер работают стабильно.
|
- Администратору - проверить, что сборщики и сервер работают стабильно.
|
||||||
|
|
||||||
|
## Интерфейс
|
||||||
|
|
||||||
|
Скриншоты ниже подготовлены на демонстрационных данных: без реальных IP-адресов,
|
||||||
|
hostname, логинов, сотрудников, подразделений заказчика и событий безопасности.
|
||||||
|
|
||||||
|
### Главный вывод
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Руководитель видит главный риск первым, затем сводку по достоверности
|
||||||
|
показателей, полноте данных, кандидатам на проверку и рискам подразделений.
|
||||||
|
|
||||||
|
### Карта рисков подразделений
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Карта рисков показывает, где одновременно проседают активность, покрытие
|
||||||
|
агентов, доверие к показателям и количество ситуаций для проверки.
|
||||||
|
|
||||||
|
### Представление безопасности
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
ИБ получает очередь кандидатов на проверку, связанные расследования и материалы
|
||||||
|
без просмотра сырых логов и без автоматического принятия решений.
|
||||||
|
|
||||||
|
### Представление эксплуатации
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Эксплуатация видит полноту данных, качество агентского сбора, ошибки сбора и
|
||||||
|
понятный статус событий безопасности через ClickHouse.
|
||||||
|
|
||||||
|
### Пакет расследования
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Пакет расследования связывает материалы, историю проверки и итоговый вывод,
|
||||||
|
который ответственный сотрудник может подтвердить вручную.
|
||||||
|
|
||||||
|
### Итоговый отчет
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Markdown-отчет собирает главный вывод, риски подразделений, материалы
|
||||||
|
расследований и рекомендации в формате, удобном для передачи руководителю.
|
||||||
|
|
||||||
## Если дашборд пустой
|
## Если дашборд пустой
|
||||||
|
|
||||||
Обычно это значит одно из трех: выбран слишком узкий период времени, рабочий компьютер давно не присылал события или временно не обновилась витрина в Grafana. Начните с периода `Last 24 hours`, затем переходите к техническим разделам ниже.
|
Обычно это значит одно из трех: выбран слишком узкий период времени, рабочий компьютер давно не присылал события или временно не обновилась витрина в Grafana. Начните с периода `Last 24 hours`, затем переходите к техническим разделам ниже.
|
||||||
@@ -66,6 +115,15 @@ OpenAPI и TypeScript declarations для будущих React/Tauri-клиен
|
|||||||
- [Эксплуатационный профиль](docs/OPERATIONAL_PROOF_PROFILE_RU.md)
|
- [Эксплуатационный профиль](docs/OPERATIONAL_PROOF_PROFILE_RU.md)
|
||||||
- [Коммерческие модули DetMir](docs/DETMIR_COMMERCIAL_MODULES_RU.md)
|
- [Коммерческие модули DetMir](docs/DETMIR_COMMERCIAL_MODULES_RU.md)
|
||||||
- [Архитектурный baseline](docs/ARCHITECTURE_BASELINE_RU.md)
|
- [Архитектурный baseline](docs/ARCHITECTURE_BASELINE_RU.md)
|
||||||
|
- [Пакет пилота для заказчика](docs/CUSTOMER_PILOT_PACK_RU.md)
|
||||||
|
- [Pilot v1.0](docs/PILOT_V1_RU.md)
|
||||||
|
- [Ролевая модель портала](docs/ROLES_RU.md)
|
||||||
|
- [UEBA Score v1](docs/UEBA_SCORE_RU.md)
|
||||||
|
- [pfSense integration readiness](docs/PFSENSE_INTEGRATION_RU.md)
|
||||||
|
- [Сценарий демонстрации заказчику](docs/CUSTOMER_DEMO_SCENARIO_RU.md)
|
||||||
|
- [Аудит готовности к пилоту](docs/PILOT_READINESS_AUDIT_RU.md)
|
||||||
|
- [Позиционирование для первой встречи](docs/SALES_POSITIONING_RU.md)
|
||||||
|
- [Преддемо-сценарий](docs/DEMO_RUNBOOK_RU.md)
|
||||||
- [Сторонние компоненты](THIRD_PARTY_COMPONENTS.md)
|
- [Сторонние компоненты](THIRD_PARTY_COMPONENTS.md)
|
||||||
- [Сторонние лицензии](THIRD_PARTY_LICENSES_RU.md)
|
- [Сторонние лицензии](THIRD_PARTY_LICENSES_RU.md)
|
||||||
- [Архитектура](docs/ARCHITECTURE_RU.md)
|
- [Архитектура](docs/ARCHITECTURE_RU.md)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "AWatch-rus DetMir Portal API",
|
"title": "AWatch-rus DetMir Portal API",
|
||||||
"version": "2026-06-05.v1",
|
"version": "2026-06-06.pilot-v1",
|
||||||
"description": "Stable additive API contract for the current HTML portal and future React/Tauri clients. Clients must ignore unknown fields and tolerate missing optional fields."
|
"description": "Stable additive API contract for the Rust server-rendered HTML portal and HTMX-compatible role slices. Dioxus, React, Tauri and Electron are not part of this Pilot v1 contract. Clients must ignore unknown fields and tolerate missing optional fields."
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@@ -21,6 +21,24 @@
|
|||||||
{
|
{
|
||||||
"name": "reports"
|
"name": "reports"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "roles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "workforce"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "security"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "forensics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ueba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pfsense"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "incidents"
|
"name": "incidents"
|
||||||
},
|
},
|
||||||
@@ -191,6 +209,15 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"description": "Return anonymized values when supported"
|
"description": "Return anonymized values when supported"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "role",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PortalRole"
|
||||||
|
},
|
||||||
|
"description": "Role slice. The X-AWatch-Role header is also accepted and is preferred by the portal UI."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -207,6 +234,144 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/executive": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"roles"
|
||||||
|
],
|
||||||
|
"summary": "Executive role slice",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Executive dashboard and Workforce summary without security detail",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ReportPayload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Role is not allowed to access executive scope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/workforce": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"workforce"
|
||||||
|
],
|
||||||
|
"summary": "Workforce role slice",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Workforce analytics, department comparison, owners, trends and markdown report",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ReportPayload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Role is not allowed to access workforce scope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/security": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"security"
|
||||||
|
],
|
||||||
|
"summary": "Security role slice",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Security risks, events, candidates and UEBA score without Workforce dashboard",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ReportPayload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Role is not allowed to access security scope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/forensics": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"forensics"
|
||||||
|
],
|
||||||
|
"summary": "Forensics role slice",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Investigations, timeline contract and export links",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ReportPayload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Role is not allowed to access forensics scope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/ueba": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"ueba"
|
||||||
|
],
|
||||||
|
"summary": "UEBA score v1 rule-based contract",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Transparent rule-based UEBA score",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UebaPayload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Role is not allowed to access UEBA scope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/pfsense": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"pfsense"
|
||||||
|
],
|
||||||
|
"summary": "pfSense integration readiness contract",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Contract-only pfSense readiness payload with demo fixtures",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PfsenseReadiness"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Role is not allowed to access pfSense scope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/workforce/policy/explain": {
|
"/workforce/policy/explain": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -605,6 +770,45 @@
|
|||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"PortalRole": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"executive",
|
||||||
|
"manager",
|
||||||
|
"security",
|
||||||
|
"forensics",
|
||||||
|
"admin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"RoleContext": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"role",
|
||||||
|
"scope",
|
||||||
|
"server_enforced"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"role": {
|
||||||
|
"$ref": "#/components/schemas/PortalRole"
|
||||||
|
},
|
||||||
|
"role_label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"allowed_scopes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server_enforced": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
"JsonObject": {
|
"JsonObject": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
@@ -713,6 +917,9 @@
|
|||||||
"$ref": "#/components/schemas/JsonObject"
|
"$ref": "#/components/schemas/JsonObject"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"role_context": {
|
||||||
|
"$ref": "#/components/schemas/RoleContext"
|
||||||
|
},
|
||||||
"cases": {
|
"cases": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -722,6 +929,156 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
},
|
},
|
||||||
|
"UebaPayload": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"ok",
|
||||||
|
"score",
|
||||||
|
"severity",
|
||||||
|
"score_components",
|
||||||
|
"reason_codes",
|
||||||
|
"model"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"role_context": {
|
||||||
|
"$ref": "#/components/schemas/RoleContext"
|
||||||
|
},
|
||||||
|
"score": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 100
|
||||||
|
},
|
||||||
|
"severity": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"normal",
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high",
|
||||||
|
"critical"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"score_components": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"activity_anomaly",
|
||||||
|
"time_anomaly",
|
||||||
|
"application_anomaly",
|
||||||
|
"network_anomaly",
|
||||||
|
"history_anomaly"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"activity_anomaly": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 100
|
||||||
|
},
|
||||||
|
"time_anomaly": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 100
|
||||||
|
},
|
||||||
|
"application_anomaly": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 100
|
||||||
|
},
|
||||||
|
"network_anomaly": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 100
|
||||||
|
},
|
||||||
|
"history_anomaly": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"reason_codes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"explanation": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"$ref": "#/components/schemas/JsonObject"
|
||||||
|
},
|
||||||
|
"risk": {
|
||||||
|
"$ref": "#/components/schemas/JsonObject"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"PfsenseReadiness": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"ok",
|
||||||
|
"contract_version",
|
||||||
|
"status",
|
||||||
|
"ingestion_available",
|
||||||
|
"firewall_events",
|
||||||
|
"vpn_events",
|
||||||
|
"traffic_summary",
|
||||||
|
"top_destinations"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"role_context": {
|
||||||
|
"$ref": "#/components/schemas/RoleContext"
|
||||||
|
},
|
||||||
|
"contract_version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"contract_only",
|
||||||
|
"available"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"siem": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"ingestion_available": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"firewall_events": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/JsonObject"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vpn_events": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/JsonObject"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"traffic_summary": {
|
||||||
|
"$ref": "#/components/schemas/JsonObject"
|
||||||
|
},
|
||||||
|
"top_destinations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/JsonObject"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
"IncidentReviewRequest": {
|
"IncidentReviewRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
+71
-1
@@ -1,5 +1,6 @@
|
|||||||
export type ISODateTime = string;
|
export type ISODateTime = string;
|
||||||
|
|
||||||
|
export type PortalRole = "executive" | "manager" | "security" | "forensics" | "admin";
|
||||||
export type RiskLevel = "LOW" | "MEDIUM" | "HIGH" | "CRITICAL" | "UNKNOWN";
|
export type RiskLevel = "LOW" | "MEDIUM" | "HIGH" | "CRITICAL" | "UNKNOWN";
|
||||||
export type ReviewStatus =
|
export type ReviewStatus =
|
||||||
| "NEW"
|
| "NEW"
|
||||||
@@ -36,6 +37,15 @@ export interface ContractIndex {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RoleContext {
|
||||||
|
role: PortalRole;
|
||||||
|
role_label?: string;
|
||||||
|
scope: string;
|
||||||
|
allowed_scopes?: string[];
|
||||||
|
server_enforced: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExecutiveDashboard {
|
export interface ExecutiveDashboard {
|
||||||
trust_kpi_score?: number;
|
trust_kpi_score?: number;
|
||||||
agent_coverage_pct?: number;
|
agent_coverage_pct?: number;
|
||||||
@@ -154,6 +164,7 @@ export interface CaseStatusRequest {
|
|||||||
|
|
||||||
export interface ReportsResponse {
|
export interface ReportsResponse {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
|
role_context?: RoleContext;
|
||||||
generated_at_utc?: ISODateTime;
|
generated_at_utc?: ISODateTime;
|
||||||
executive_points?: string[];
|
executive_points?: string[];
|
||||||
executive_dashboard?: ExecutiveDashboard;
|
executive_dashboard?: ExecutiveDashboard;
|
||||||
@@ -166,6 +177,59 @@ export interface ReportsResponse {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UebaResponse {
|
||||||
|
ok: boolean;
|
||||||
|
role_context?: RoleContext;
|
||||||
|
score: number | null;
|
||||||
|
severity: "normal" | "low" | "medium" | "high" | "critical" | string;
|
||||||
|
status?: string;
|
||||||
|
score_components: {
|
||||||
|
activity_anomaly: number;
|
||||||
|
time_anomaly: number;
|
||||||
|
application_anomaly: number;
|
||||||
|
network_anomaly: number;
|
||||||
|
history_anomaly: number;
|
||||||
|
};
|
||||||
|
reason_codes: string[];
|
||||||
|
explanation: string;
|
||||||
|
model: JsonObject;
|
||||||
|
risk: JsonObject;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PfsenseFirewallEvent {
|
||||||
|
timestamp: ISODateTime;
|
||||||
|
source_host: string;
|
||||||
|
destination: string;
|
||||||
|
action: string;
|
||||||
|
rule_id?: string;
|
||||||
|
protocol?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PfsenseVpnEvent {
|
||||||
|
timestamp: ISODateTime;
|
||||||
|
source_host: string;
|
||||||
|
user_ref?: string;
|
||||||
|
action: string;
|
||||||
|
tunnel?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PfsenseReadinessResponse {
|
||||||
|
ok: boolean;
|
||||||
|
role_context?: RoleContext;
|
||||||
|
contract_version: string;
|
||||||
|
status: "contract_only" | "available" | string;
|
||||||
|
siem: boolean;
|
||||||
|
ingestion_available: boolean;
|
||||||
|
firewall_events: PfsenseFirewallEvent[];
|
||||||
|
vpn_events: PfsenseVpnEvent[];
|
||||||
|
traffic_summary: JsonObject;
|
||||||
|
top_destinations: JsonObject[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CaseListResponse {
|
export interface CaseListResponse {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
cases: CaseItem[];
|
cases: CaseItem[];
|
||||||
@@ -178,7 +242,13 @@ export interface DetMirPortalApi {
|
|||||||
getOperator(): Promise<JsonObject>;
|
getOperator(): Promise<JsonObject>;
|
||||||
getManager(): Promise<JsonObject>;
|
getManager(): Promise<JsonObject>;
|
||||||
getOwner(): Promise<JsonObject>;
|
getOwner(): Promise<JsonObject>;
|
||||||
getReports(options?: { anonymize?: boolean }): Promise<ReportsResponse>;
|
getReports(options?: { anonymize?: boolean; role?: PortalRole }): Promise<ReportsResponse>;
|
||||||
|
getExecutive(options?: { role?: PortalRole }): Promise<ReportsResponse>;
|
||||||
|
getWorkforce(options?: { role?: PortalRole }): Promise<ReportsResponse>;
|
||||||
|
getSecurity(options?: { role?: PortalRole }): Promise<ReportsResponse>;
|
||||||
|
getForensics(options?: { role?: PortalRole }): Promise<ReportsResponse>;
|
||||||
|
getUeba(options?: { role?: PortalRole }): Promise<UebaResponse>;
|
||||||
|
getPfsense(options?: { role?: PortalRole }): Promise<PfsenseReadinessResponse>;
|
||||||
getIncidents(): Promise<JsonObject>;
|
getIncidents(): Promise<JsonObject>;
|
||||||
getCases(): Promise<CaseListResponse>;
|
getCases(): Promise<CaseListResponse>;
|
||||||
createCase(request: CreateCaseRequest): Promise<JsonObject>;
|
createCase(request: CreateCaseRequest): Promise<JsonObject>;
|
||||||
|
|||||||
@@ -43,6 +43,76 @@ unsafe extern "C" {
|
|||||||
|
|
||||||
type SnapshotCache = Arc<Mutex<Option<CachedSnapshot>>>;
|
type SnapshotCache = Arc<Mutex<Option<CachedSnapshot>>>;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum PortalRole {
|
||||||
|
Executive,
|
||||||
|
Manager,
|
||||||
|
Security,
|
||||||
|
Forensics,
|
||||||
|
Admin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PortalRole {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"executive" | "owner" | "rukovoditel" | "руководитель" => {
|
||||||
|
Some(Self::Executive)
|
||||||
|
}
|
||||||
|
"manager" | "workforce" | "руководитель_подразделения" => {
|
||||||
|
Some(Self::Manager)
|
||||||
|
}
|
||||||
|
"security" | "ib" | "soc" | "безопасность" => Some(Self::Security),
|
||||||
|
"forensics" | "investigation" | "расследования" => Some(Self::Forensics),
|
||||||
|
"admin" | "operations" | "operator" | "эксплуатация" => Some(Self::Admin),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Executive => "executive",
|
||||||
|
Self::Manager => "manager",
|
||||||
|
Self::Security => "security",
|
||||||
|
Self::Forensics => "forensics",
|
||||||
|
Self::Admin => "admin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label_ru(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Executive => "Руководитель",
|
||||||
|
Self::Manager => "Руководитель подразделения",
|
||||||
|
Self::Security => "Безопасность",
|
||||||
|
Self::Forensics => "Расследования",
|
||||||
|
Self::Admin => "Администратор",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allowed_scopes(self) -> &'static [&'static str] {
|
||||||
|
match self {
|
||||||
|
Self::Executive => &["executive", "workforce"],
|
||||||
|
Self::Manager => &["executive", "workforce"],
|
||||||
|
Self::Security => &["security", "incidents", "ueba", "pfsense"],
|
||||||
|
Self::Forensics => &["forensics", "incidents", "ueba"],
|
||||||
|
Self::Admin => &[
|
||||||
|
"executive",
|
||||||
|
"workforce",
|
||||||
|
"security",
|
||||||
|
"forensics",
|
||||||
|
"incidents",
|
||||||
|
"ueba",
|
||||||
|
"pfsense",
|
||||||
|
"admin",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_access(self, scope: &str) -> bool {
|
||||||
|
self.allowed_scopes().contains(&scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct CachedSnapshot {
|
struct CachedSnapshot {
|
||||||
created: Instant,
|
created: Instant,
|
||||||
@@ -1248,17 +1318,30 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
|||||||
let url = request.url().to_string();
|
let url = request.url().to_string();
|
||||||
let path = normalize_path(&url);
|
let path = normalize_path(&url);
|
||||||
let anonymize = query_flag(&url, "anonymize");
|
let anonymize = query_flag(&url, "anonymize");
|
||||||
|
let role = portal_role_from_request(&request, &url);
|
||||||
if method == Method::Post && path == "/api/incidents/action" {
|
if method == Method::Post && path == "/api/incidents/action" {
|
||||||
|
if !role.can_access("incidents") {
|
||||||
|
return respond_forbidden(request, role, "incidents");
|
||||||
|
}
|
||||||
return handle_incident_action(request, args);
|
return handle_incident_action(request, args);
|
||||||
}
|
}
|
||||||
if method == Method::Post && path == "/api/incident-review" {
|
if method == Method::Post && path == "/api/incident-review" {
|
||||||
|
if !role.can_access("security") {
|
||||||
|
return respond_forbidden(request, role, "security");
|
||||||
|
}
|
||||||
return handle_incident_review(request, args);
|
return handle_incident_review(request, args);
|
||||||
}
|
}
|
||||||
if method == Method::Post && path == "/api/cases" {
|
if method == Method::Post && path == "/api/cases" {
|
||||||
|
if !role.can_access("forensics") && !role.can_access("incidents") {
|
||||||
|
return respond_forbidden(request, role, "forensics");
|
||||||
|
}
|
||||||
return handle_create_case(request, args, snapshot_cache);
|
return handle_create_case(request, args, snapshot_cache);
|
||||||
}
|
}
|
||||||
if method == Method::Post {
|
if method == Method::Post {
|
||||||
if let Some(case_id) = parse_case_status_path(&path) {
|
if let Some(case_id) = parse_case_status_path(&path) {
|
||||||
|
if !role.can_access("forensics") && !role.can_access("incidents") {
|
||||||
|
return respond_forbidden(request, role, "forensics");
|
||||||
|
}
|
||||||
return handle_case_status(request, args, &case_id);
|
return handle_case_status(request, args, &case_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1269,12 +1352,21 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
|||||||
return respond_text(request, StatusCode(405), "Method Not Allowed", "text/plain");
|
return respond_text(request, StatusCode(405), "Method Not Allowed", "text/plain");
|
||||||
}
|
}
|
||||||
if path == "/api/dlp/evidence" {
|
if path == "/api/dlp/evidence" {
|
||||||
|
if !role.can_access("forensics") && !role.can_access("incidents") {
|
||||||
|
return respond_forbidden(request, role, "forensics");
|
||||||
|
}
|
||||||
return respond_json(request, &build_dlp_evidence_response(args));
|
return respond_json(request, &build_dlp_evidence_response(args));
|
||||||
}
|
}
|
||||||
if let Some(candidate_id) = parse_investigation_pack_path(&path) {
|
if let Some(candidate_id) = parse_investigation_pack_path(&path) {
|
||||||
|
if !role.can_access("forensics") && !role.can_access("incidents") {
|
||||||
|
return respond_forbidden(request, role, "forensics");
|
||||||
|
}
|
||||||
return handle_investigation_pack(request, args, snapshot_cache, &url, &candidate_id);
|
return handle_investigation_pack(request, args, snapshot_cache, &url, &candidate_id);
|
||||||
}
|
}
|
||||||
if let Some(case_id) = parse_case_path(&path) {
|
if let Some(case_id) = parse_case_path(&path) {
|
||||||
|
if !role.can_access("forensics") && !role.can_access("incidents") {
|
||||||
|
return respond_forbidden(request, role, "forensics");
|
||||||
|
}
|
||||||
return handle_case_details(request, args, snapshot_cache, &url, &case_id);
|
return handle_case_details(request, args, snapshot_cache, &url, &case_id);
|
||||||
}
|
}
|
||||||
if let Some((evidence_id, download)) = parse_evidence_screenshot_path(&path) {
|
if let Some((evidence_id, download)) = parse_evidence_screenshot_path(&path) {
|
||||||
@@ -1322,10 +1414,16 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
|||||||
respond_json(request, &build_operator(&snapshot, &incident_state))
|
respond_json(request, &build_operator(&snapshot, &incident_state))
|
||||||
}
|
}
|
||||||
"/api/manager" => {
|
"/api/manager" => {
|
||||||
|
if !role.can_access("workforce") {
|
||||||
|
return respond_forbidden(request, role, "workforce");
|
||||||
|
}
|
||||||
let snapshot = cached_snapshot(args, snapshot_cache);
|
let snapshot = cached_snapshot(args, snapshot_cache);
|
||||||
respond_json(request, &build_manager(&snapshot))
|
respond_json(request, &build_manager(&snapshot))
|
||||||
}
|
}
|
||||||
"/api/workforce/policy/explain" => {
|
"/api/workforce/policy/explain" => {
|
||||||
|
if !role.can_access("workforce") {
|
||||||
|
return respond_forbidden(request, role, "workforce");
|
||||||
|
}
|
||||||
let snapshot = cached_snapshot(args, snapshot_cache);
|
let snapshot = cached_snapshot(args, snapshot_cache);
|
||||||
respond_json(
|
respond_json(
|
||||||
request,
|
request,
|
||||||
@@ -1333,41 +1431,71 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
"/api/owner" => {
|
"/api/owner" => {
|
||||||
|
if !role.can_access("security") {
|
||||||
|
return respond_forbidden(request, role, "security");
|
||||||
|
}
|
||||||
let snapshot = cached_snapshot(args, snapshot_cache);
|
let snapshot = cached_snapshot(args, snapshot_cache);
|
||||||
respond_json(request, &build_owner(&snapshot))
|
respond_json(request, &build_owner(&snapshot))
|
||||||
}
|
}
|
||||||
"/api/reports" => {
|
"/api/reports" => {
|
||||||
let snapshot = cached_snapshot(args, snapshot_cache);
|
let report = build_report_payload(args, snapshot_cache, anonymize);
|
||||||
let incident_state = load_incident_state_best_effort(args);
|
respond_json(request, &role_filtered_report(report, role))
|
||||||
let incident_reviews = load_incident_review_best_effort(args);
|
}
|
||||||
let incident_review_audit = load_incident_review_audit_best_effort(args);
|
"/api/executive" => {
|
||||||
let cases = load_cases_best_effort(args);
|
if !role.can_access("executive") {
|
||||||
let evidence = build_dlp_evidence_response(args);
|
return respond_forbidden(request, role, "executive");
|
||||||
let ueba_baseline_path = ueba_baseline_state_path(args);
|
}
|
||||||
respond_json(
|
let report = build_report_payload(args, snapshot_cache, anonymize);
|
||||||
request,
|
respond_json(request, &build_role_api_payload(report, role, "executive"))
|
||||||
&build_reports(
|
}
|
||||||
&snapshot,
|
"/api/workforce" => {
|
||||||
ReportRuntimeInputs {
|
if !role.can_access("workforce") {
|
||||||
incident_state: &incident_state,
|
return respond_forbidden(request, role, "workforce");
|
||||||
incident_reviews: &incident_reviews,
|
}
|
||||||
incident_review_audit: &incident_review_audit,
|
let report = build_report_payload(args, snapshot_cache, anonymize);
|
||||||
cases: &cases,
|
respond_json(request, &build_role_api_payload(report, role, "workforce"))
|
||||||
evidence: &evidence,
|
}
|
||||||
},
|
"/api/security" => {
|
||||||
&args.workforce_policy_path,
|
if !role.can_access("security") {
|
||||||
&args.ueba_policy_path,
|
return respond_forbidden(request, role, "security");
|
||||||
&ueba_baseline_path,
|
}
|
||||||
anonymize,
|
let report = build_report_payload(args, snapshot_cache, anonymize);
|
||||||
),
|
respond_json(request, &build_role_api_payload(report, role, "security"))
|
||||||
)
|
}
|
||||||
|
"/api/forensics" => {
|
||||||
|
if !role.can_access("forensics") {
|
||||||
|
return respond_forbidden(request, role, "forensics");
|
||||||
|
}
|
||||||
|
let report = build_report_payload(args, snapshot_cache, anonymize);
|
||||||
|
respond_json(request, &build_role_api_payload(report, role, "forensics"))
|
||||||
|
}
|
||||||
|
"/api/ueba" => {
|
||||||
|
if !role.can_access("ueba") {
|
||||||
|
return respond_forbidden(request, role, "ueba");
|
||||||
|
}
|
||||||
|
let report = build_report_payload(args, snapshot_cache, anonymize);
|
||||||
|
respond_json(request, &build_ueba_api_payload(&report, role))
|
||||||
|
}
|
||||||
|
"/api/pfsense" => {
|
||||||
|
if !role.can_access("pfsense") {
|
||||||
|
return respond_forbidden(request, role, "pfsense");
|
||||||
|
}
|
||||||
|
respond_json(request, &build_pfsense_readiness_payload(role))
|
||||||
}
|
}
|
||||||
"/api/incidents" => {
|
"/api/incidents" => {
|
||||||
|
if !role.can_access("incidents") {
|
||||||
|
return respond_forbidden(request, role, "incidents");
|
||||||
|
}
|
||||||
let snapshot = cached_snapshot(args, snapshot_cache);
|
let snapshot = cached_snapshot(args, snapshot_cache);
|
||||||
let incident_state = load_incident_state_best_effort(args);
|
let incident_state = load_incident_state_best_effort(args);
|
||||||
respond_json(request, &build_incidents(&snapshot, &incident_state))
|
respond_json(request, &build_incidents(&snapshot, &incident_state))
|
||||||
}
|
}
|
||||||
"/api/cases" => respond_json(request, &build_case_list(args)),
|
"/api/cases" => {
|
||||||
|
if !role.can_access("forensics") && !role.can_access("incidents") {
|
||||||
|
return respond_forbidden(request, role, "forensics");
|
||||||
|
}
|
||||||
|
respond_json(request, &build_case_list(args))
|
||||||
|
}
|
||||||
"/api/links" => respond_json(request, &links()),
|
"/api/links" => respond_json(request, &links()),
|
||||||
_ => respond_text(
|
_ => respond_text(
|
||||||
request,
|
request,
|
||||||
@@ -1436,16 +1564,17 @@ fn normalize_path(url: &str) -> String {
|
|||||||
fn api_contract_summary() -> Value {
|
fn api_contract_summary() -> Value {
|
||||||
json!({
|
json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"contract_version": "2026-06-05.v1",
|
"contract_version": "2026-06-06.pilot-v1",
|
||||||
"generated_by": "detmir-portal",
|
"generated_by": "detmir-portal",
|
||||||
"api_base": "/api",
|
"api_base": "/api",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"policy": "additive",
|
"policy": "additive",
|
||||||
"existing_html_portal": "unchanged",
|
"main_ui": "rust-server-rendered-html-htmx-compatible",
|
||||||
"unknown_fields": "clients must ignore unknown fields",
|
"unknown_fields": "clients must ignore unknown fields",
|
||||||
"nullable_fields": "clients must tolerate null and missing optional fields"
|
"nullable_fields": "clients must tolerate null and missing optional fields",
|
||||||
|
"forbidden_ui_stacks": ["dioxus", "react", "tauri", "electron"]
|
||||||
},
|
},
|
||||||
"targets": ["current-html", "future-react", "future-tauri"],
|
"targets": ["rust-html", "htmx-compatible"],
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"openapi": "/api/contracts/openapi.json",
|
"openapi": "/api/contracts/openapi.json",
|
||||||
"typescript": "/api/contracts/typescript.d.ts"
|
"typescript": "/api/contracts/typescript.d.ts"
|
||||||
@@ -1457,6 +1586,12 @@ fn api_contract_summary() -> Value {
|
|||||||
{"method": "GET", "path": "/api/contracts/typescript.d.ts", "purpose": "TypeScript declarations"},
|
{"method": "GET", "path": "/api/contracts/typescript.d.ts", "purpose": "TypeScript declarations"},
|
||||||
{"method": "GET", "path": "/api/operator", "purpose": "portal overview data"},
|
{"method": "GET", "path": "/api/operator", "purpose": "portal overview data"},
|
||||||
{"method": "GET", "path": "/api/reports", "purpose": "management report payload"},
|
{"method": "GET", "path": "/api/reports", "purpose": "management report payload"},
|
||||||
|
{"method": "GET", "path": "/api/executive", "purpose": "executive role payload"},
|
||||||
|
{"method": "GET", "path": "/api/workforce", "purpose": "workforce role payload"},
|
||||||
|
{"method": "GET", "path": "/api/security", "purpose": "security role payload"},
|
||||||
|
{"method": "GET", "path": "/api/forensics", "purpose": "forensics role payload"},
|
||||||
|
{"method": "GET", "path": "/api/ueba", "purpose": "rule-based UEBA score v1"},
|
||||||
|
{"method": "GET", "path": "/api/pfsense", "purpose": "pfSense readiness contracts and demo fixtures"},
|
||||||
{"method": "GET", "path": "/api/incidents", "purpose": "incident and DLP evidence summary"},
|
{"method": "GET", "path": "/api/incidents", "purpose": "incident and DLP evidence summary"},
|
||||||
{"method": "GET", "path": "/api/cases", "purpose": "case list"},
|
{"method": "GET", "path": "/api/cases", "purpose": "case list"},
|
||||||
{"method": "POST", "path": "/api/incident-review", "purpose": "manual candidate review status"},
|
{"method": "POST", "path": "/api/incident-review", "purpose": "manual candidate review status"},
|
||||||
@@ -1602,6 +1737,45 @@ fn query_param(url: &str, key: &str) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn portal_role_from_request(request: &Request, url: &str) -> PortalRole {
|
||||||
|
query_param(url, "role")
|
||||||
|
.as_deref()
|
||||||
|
.and_then(PortalRole::parse)
|
||||||
|
.or_else(|| {
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.find(|header| header.field.equiv("X-AWatch-Role"))
|
||||||
|
.and_then(|header| PortalRole::parse(header.value.as_str()))
|
||||||
|
})
|
||||||
|
.unwrap_or(PortalRole::Executive)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_envelope(role: PortalRole, scope: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"role": role.as_str(),
|
||||||
|
"role_label": role.label_ru(),
|
||||||
|
"scope": scope,
|
||||||
|
"allowed_scopes": role.allowed_scopes(),
|
||||||
|
"server_enforced": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn respond_forbidden(request: Request, role: PortalRole, scope: &str) -> Result<()> {
|
||||||
|
respond_json_status(
|
||||||
|
request,
|
||||||
|
StatusCode(403),
|
||||||
|
&json!({
|
||||||
|
"ok": false,
|
||||||
|
"error": "forbidden",
|
||||||
|
"message": format!("Роль {} не имеет доступа к контуру {scope}", role.label_ru()),
|
||||||
|
"role": role.as_str(),
|
||||||
|
"scope": scope,
|
||||||
|
"server_enforced": true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_investigation_pack_path(path: &str) -> Option<String> {
|
fn parse_investigation_pack_path(path: &str) -> Option<String> {
|
||||||
path.strip_prefix("/api/investigation-pack/")
|
path.strip_prefix("/api/investigation-pack/")
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
@@ -3185,6 +3359,380 @@ fn build_reports(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_report_payload(args: &Cli, snapshot_cache: &SnapshotCache, anonymize: bool) -> Value {
|
||||||
|
let snapshot = cached_snapshot(args, snapshot_cache);
|
||||||
|
let incident_state = load_incident_state_best_effort(args);
|
||||||
|
let incident_reviews = load_incident_review_best_effort(args);
|
||||||
|
let incident_review_audit = load_incident_review_audit_best_effort(args);
|
||||||
|
let cases = load_cases_best_effort(args);
|
||||||
|
let evidence = build_dlp_evidence_response(args);
|
||||||
|
let ueba_baseline_path = ueba_baseline_state_path(args);
|
||||||
|
build_reports(
|
||||||
|
&snapshot,
|
||||||
|
ReportRuntimeInputs {
|
||||||
|
incident_state: &incident_state,
|
||||||
|
incident_reviews: &incident_reviews,
|
||||||
|
incident_review_audit: &incident_review_audit,
|
||||||
|
cases: &cases,
|
||||||
|
evidence: &evidence,
|
||||||
|
},
|
||||||
|
&args.workforce_policy_path,
|
||||||
|
&args.ueba_policy_path,
|
||||||
|
&ueba_baseline_path,
|
||||||
|
anonymize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_filtered_report(report: Value, role: PortalRole) -> Value {
|
||||||
|
if role == PortalRole::Admin {
|
||||||
|
let mut full = report;
|
||||||
|
if let Some(object) = full.as_object_mut() {
|
||||||
|
object.insert("ok".to_string(), Value::Bool(true));
|
||||||
|
object.insert("role_context".to_string(), role_envelope(role, "admin"));
|
||||||
|
}
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = serde_json::Map::new();
|
||||||
|
let Some(object) = report.as_object() else {
|
||||||
|
out.insert("ok".to_string(), Value::Bool(false));
|
||||||
|
out.insert("role_context".to_string(), role_envelope(role, "unknown"));
|
||||||
|
return Value::Object(out);
|
||||||
|
};
|
||||||
|
|
||||||
|
let base_keys = [
|
||||||
|
"generated_at_utc",
|
||||||
|
"period",
|
||||||
|
"anonymized",
|
||||||
|
"severity",
|
||||||
|
"operator_ok",
|
||||||
|
"headline",
|
||||||
|
"links",
|
||||||
|
];
|
||||||
|
for key in base_keys {
|
||||||
|
copy_json_key(object, &mut out, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
match role {
|
||||||
|
PortalRole::Executive => {
|
||||||
|
for key in [
|
||||||
|
"executive_points",
|
||||||
|
"executive_dashboard",
|
||||||
|
"kpis",
|
||||||
|
"business_risk",
|
||||||
|
"business_risk_history_summary",
|
||||||
|
"risk_heatmap",
|
||||||
|
"workforce",
|
||||||
|
"markdown",
|
||||||
|
] {
|
||||||
|
copy_json_key(object, &mut out, key);
|
||||||
|
}
|
||||||
|
out.insert(
|
||||||
|
"scope_note".to_string(),
|
||||||
|
json!("Руководитель видит управленческий вывод и Workforce без ИБ-детализации."),
|
||||||
|
);
|
||||||
|
out.insert("role_context".to_string(), role_envelope(role, "executive"));
|
||||||
|
}
|
||||||
|
PortalRole::Manager => {
|
||||||
|
for key in [
|
||||||
|
"executive_dashboard",
|
||||||
|
"kpis",
|
||||||
|
"business_risk",
|
||||||
|
"risk_heatmap",
|
||||||
|
"workforce",
|
||||||
|
"workforce_policy",
|
||||||
|
"markdown",
|
||||||
|
] {
|
||||||
|
copy_json_key(object, &mut out, key);
|
||||||
|
}
|
||||||
|
out.insert(
|
||||||
|
"scope_note".to_string(),
|
||||||
|
json!("Руководитель подразделения видит Workforce и Executive Dashboard без очереди ИБ."),
|
||||||
|
);
|
||||||
|
out.insert("role_context".to_string(), role_envelope(role, "workforce"));
|
||||||
|
}
|
||||||
|
PortalRole::Security => {
|
||||||
|
for key in [
|
||||||
|
"ueba_risk",
|
||||||
|
"ueba_baseline",
|
||||||
|
"security_events_summary",
|
||||||
|
"security_correlation",
|
||||||
|
"risk_incident_candidates",
|
||||||
|
"incident_review_audit_summary",
|
||||||
|
"business_risk",
|
||||||
|
"risk_heatmap",
|
||||||
|
"markdown",
|
||||||
|
] {
|
||||||
|
copy_json_key(object, &mut out, key);
|
||||||
|
}
|
||||||
|
out.insert(
|
||||||
|
"scope_note".to_string(),
|
||||||
|
json!("Безопасность видит риски, события и кандидатов без управленческого Workforce Dashboard."),
|
||||||
|
);
|
||||||
|
out.insert("role_context".to_string(), role_envelope(role, "security"));
|
||||||
|
}
|
||||||
|
PortalRole::Forensics => {
|
||||||
|
for key in [
|
||||||
|
"risk_incident_candidates",
|
||||||
|
"incident_review_audit_summary",
|
||||||
|
"security_events_summary",
|
||||||
|
"ueba_risk",
|
||||||
|
"markdown",
|
||||||
|
] {
|
||||||
|
copy_json_key(object, &mut out, key);
|
||||||
|
}
|
||||||
|
out.insert(
|
||||||
|
"forensics".to_string(),
|
||||||
|
build_forensics_contract_payload(&report),
|
||||||
|
);
|
||||||
|
out.insert(
|
||||||
|
"scope_note".to_string(),
|
||||||
|
json!("Расследования видят карточки, timeline и экспорт материалов без управленческого Workforce Dashboard."),
|
||||||
|
);
|
||||||
|
out.insert("role_context".to_string(), role_envelope(role, "forensics"));
|
||||||
|
}
|
||||||
|
PortalRole::Admin => {}
|
||||||
|
}
|
||||||
|
out.insert("ok".to_string(), Value::Bool(true));
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_json_key(
|
||||||
|
source: &serde_json::Map<String, Value>,
|
||||||
|
target: &mut serde_json::Map<String, Value>,
|
||||||
|
key: &str,
|
||||||
|
) {
|
||||||
|
if let Some(value) = source.get(key) {
|
||||||
|
target.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_role_api_payload(report: Value, role: PortalRole, scope: &str) -> Value {
|
||||||
|
let mut payload = role_filtered_report(report, role);
|
||||||
|
if let Some(object) = payload.as_object_mut() {
|
||||||
|
object.insert("role_context".to_string(), role_envelope(role, scope));
|
||||||
|
}
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_ueba_api_payload(report: &Value, role: PortalRole) -> Value {
|
||||||
|
json!({
|
||||||
|
"ok": true,
|
||||||
|
"role_context": role_envelope(role, "ueba"),
|
||||||
|
"score": report.pointer("/ueba_risk/score").cloned().unwrap_or(Value::Null),
|
||||||
|
"severity": report.pointer("/ueba_risk/level").cloned().unwrap_or_else(|| json!("normal")),
|
||||||
|
"status": report.pointer("/ueba_risk/status").cloned().unwrap_or_else(|| json!("OK")),
|
||||||
|
"score_components": report.pointer("/ueba_risk/score_components").cloned().unwrap_or_else(|| json!({
|
||||||
|
"activity_anomaly": 0,
|
||||||
|
"time_anomaly": 0,
|
||||||
|
"application_anomaly": 0,
|
||||||
|
"network_anomaly": 0,
|
||||||
|
"history_anomaly": 0,
|
||||||
|
})),
|
||||||
|
"reason_codes": report.pointer("/ueba_risk/reason_codes").cloned().or_else(|| {
|
||||||
|
report
|
||||||
|
.pointer("/ueba_risk/reasons")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
Value::Array(
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item.get("code").and_then(Value::as_str).map(Value::from))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}).unwrap_or_else(|| json!([])),
|
||||||
|
"explanation": report.pointer("/ueba_risk/human_explanation")
|
||||||
|
.or_else(|| report.pointer("/ueba_risk/summary"))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!("Оценка риска по правилам v1.")),
|
||||||
|
"model": {
|
||||||
|
"version": "ueba-score-v1",
|
||||||
|
"type": "rule_based",
|
||||||
|
"formula": "activity anomaly + time anomaly + application anomaly + network anomaly + history anomaly",
|
||||||
|
"ml_used": false,
|
||||||
|
"llm_used": false
|
||||||
|
},
|
||||||
|
"risk": report.get("ueba_risk").cloned().unwrap_or_else(|| json!({})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_forensics_contract_payload(report: &Value) -> Value {
|
||||||
|
let candidates = report
|
||||||
|
.get("risk_incident_candidates")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let investigations = candidates
|
||||||
|
.iter()
|
||||||
|
.take(20)
|
||||||
|
.map(|candidate| {
|
||||||
|
let id = candidate
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("candidate-unknown");
|
||||||
|
let department = candidate
|
||||||
|
.get("department")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or(DEFAULT_DEPARTMENT_LABEL);
|
||||||
|
let host = candidate
|
||||||
|
.get("hostname")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("host-demo");
|
||||||
|
json!({
|
||||||
|
"investigation_id": format!("case-{id}"),
|
||||||
|
"candidate_id": id,
|
||||||
|
"title": format!("Проверка кандидата {id}"),
|
||||||
|
"status": candidate.pointer("/incident_review/status").and_then(Value::as_str).unwrap_or("NEW"),
|
||||||
|
"department": department,
|
||||||
|
"risk_level": candidate.get("risk_level").cloned().unwrap_or_else(|| json!("UNKNOWN")),
|
||||||
|
"summary": candidate.get("reason").cloned().unwrap_or_else(|| json!("требуется проверка")),
|
||||||
|
"timeline": forensics_timeline_for_candidate(candidate),
|
||||||
|
"links": {
|
||||||
|
"markdown": format!("/portal/api/investigation-pack/{id}?format=markdown"),
|
||||||
|
"json": format!("/portal/api/investigation-pack/{id}")
|
||||||
|
},
|
||||||
|
"entities": {
|
||||||
|
"user": candidate.get("owner").cloned().unwrap_or_else(|| json!("employee-demo")),
|
||||||
|
"host": host,
|
||||||
|
"app": "activity-source",
|
||||||
|
"network_event": "not_available"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
json!({
|
||||||
|
"contract_version": "forensics-v1",
|
||||||
|
"investigations": investigations,
|
||||||
|
"timeline_schema": ["timestamp", "kind", "entity", "summary", "source"],
|
||||||
|
"demo_privacy": "В demo-режиме использовать только обезличенные user/host/app/network identifiers.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forensics_timeline_for_candidate(candidate: &Value) -> Vec<Value> {
|
||||||
|
let first_seen = candidate
|
||||||
|
.get("first_seen_utc")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("2026-06-01T09:00:00Z");
|
||||||
|
let last_seen = candidate
|
||||||
|
.get("last_seen_utc")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or(first_seen);
|
||||||
|
let id = candidate
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("candidate-unknown");
|
||||||
|
vec![
|
||||||
|
json!({
|
||||||
|
"timestamp": first_seen,
|
||||||
|
"kind": "candidate_created",
|
||||||
|
"entity": id,
|
||||||
|
"summary": candidate.get("reason").cloned().unwrap_or_else(|| json!("кандидат требует проверки")),
|
||||||
|
"source": "risk_rules"
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"timestamp": last_seen,
|
||||||
|
"kind": "evidence_snapshot",
|
||||||
|
"entity": candidate.get("hostname").and_then(Value::as_str).unwrap_or("host-demo"),
|
||||||
|
"summary": "Связка user / host / app / network event подготовлена для ручного расследования.",
|
||||||
|
"source": "portal_contract"
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
struct PfsenseFirewallEvent {
|
||||||
|
timestamp: String,
|
||||||
|
source_host: String,
|
||||||
|
destination: String,
|
||||||
|
action: String,
|
||||||
|
rule_id: String,
|
||||||
|
protocol: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
struct PfsenseVpnEvent {
|
||||||
|
timestamp: String,
|
||||||
|
source_host: String,
|
||||||
|
user_ref: String,
|
||||||
|
action: String,
|
||||||
|
tunnel: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
struct PfsenseTopDestination {
|
||||||
|
destination: String,
|
||||||
|
bytes: u64,
|
||||||
|
connections: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_pfsense_readiness_payload(role: PortalRole) -> Value {
|
||||||
|
let firewall_events = vec![
|
||||||
|
PfsenseFirewallEvent {
|
||||||
|
timestamp: "2026-06-01T10:00:00Z".to_string(),
|
||||||
|
source_host: "host-demo-01".to_string(),
|
||||||
|
destination: "203.0.113.10:443".to_string(),
|
||||||
|
action: "pass".to_string(),
|
||||||
|
rule_id: "demo-fw-allow-web".to_string(),
|
||||||
|
protocol: "tcp".to_string(),
|
||||||
|
},
|
||||||
|
PfsenseFirewallEvent {
|
||||||
|
timestamp: "2026-06-01T10:05:00Z".to_string(),
|
||||||
|
source_host: "host-demo-02".to_string(),
|
||||||
|
destination: "198.51.100.25:22".to_string(),
|
||||||
|
action: "block".to_string(),
|
||||||
|
rule_id: "demo-fw-block-admin".to_string(),
|
||||||
|
protocol: "tcp".to_string(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let vpn_events = vec![PfsenseVpnEvent {
|
||||||
|
timestamp: "2026-06-01T08:30:00Z".to_string(),
|
||||||
|
source_host: "198.51.100.77".to_string(),
|
||||||
|
user_ref: "employee-demo-001".to_string(),
|
||||||
|
action: "connect".to_string(),
|
||||||
|
tunnel: "vpn-demo".to_string(),
|
||||||
|
}];
|
||||||
|
let top_destinations = vec![
|
||||||
|
PfsenseTopDestination {
|
||||||
|
destination: "203.0.113.10".to_string(),
|
||||||
|
bytes: 42_000,
|
||||||
|
connections: 12,
|
||||||
|
},
|
||||||
|
PfsenseTopDestination {
|
||||||
|
destination: "198.51.100.25".to_string(),
|
||||||
|
bytes: 8_000,
|
||||||
|
connections: 3,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
json!({
|
||||||
|
"ok": true,
|
||||||
|
"role_context": role_envelope(role, "pfsense"),
|
||||||
|
"contract_version": "pfsense-readiness-v1",
|
||||||
|
"status": "contract_only",
|
||||||
|
"siem": false,
|
||||||
|
"ingestion_available": false,
|
||||||
|
"ingestion_note": "Реальный ingestion не заявлен: подготовлены только контракт, fixtures и API-заготовка.",
|
||||||
|
"schemas": {
|
||||||
|
"firewall_event": ["timestamp", "source_host", "destination", "action", "rule_id", "protocol"],
|
||||||
|
"vpn_event": ["timestamp", "source_host", "user_ref", "action", "tunnel"],
|
||||||
|
"traffic_summary": ["timestamp", "source_host", "destination", "bytes", "connections"],
|
||||||
|
"top_destination": ["destination", "bytes", "connections"]
|
||||||
|
},
|
||||||
|
"firewall_events": firewall_events,
|
||||||
|
"vpn_events": vpn_events,
|
||||||
|
"traffic_summary": {
|
||||||
|
"timestamp": "2026-06-01T10:10:00Z",
|
||||||
|
"source_host": "host-demo-01",
|
||||||
|
"destination": "203.0.113.10",
|
||||||
|
"action": "summary",
|
||||||
|
"bytes": 42000,
|
||||||
|
"connections": 12
|
||||||
|
},
|
||||||
|
"top_destinations": top_destinations,
|
||||||
|
"demo_data_policy": "Используются только RFC 5737 documentation IP ranges и обезличенные identifiers.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn workforce_rollup_items(snapshot: &Snapshot, key: &str) -> Vec<Value> {
|
fn workforce_rollup_items(snapshot: &Snapshot, key: &str) -> Vec<Value> {
|
||||||
snapshot
|
snapshot
|
||||||
.worktime_management
|
.worktime_management
|
||||||
@@ -5781,6 +6329,12 @@ fn build_ueba_risk(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let confidence = ueba_confidence(metrics, workforce_policy, snapshot, &policy);
|
||||||
|
let risk_sources = risk_sources(&reasons);
|
||||||
|
let score = score.min(policy.score_cap.max(1));
|
||||||
|
let (level, status) = ueba_risk_level(score);
|
||||||
|
let score_components = ueba_score_components(&reasons, score);
|
||||||
|
let reason_codes = ueba_reason_codes(&reasons);
|
||||||
let calculated_from = ueba_calculated_from(
|
let calculated_from = ueba_calculated_from(
|
||||||
metrics,
|
metrics,
|
||||||
workforce_policy,
|
workforce_policy,
|
||||||
@@ -5789,16 +6343,17 @@ fn build_ueba_risk(
|
|||||||
policy_configured,
|
policy_configured,
|
||||||
policy_error.as_deref(),
|
policy_error.as_deref(),
|
||||||
);
|
);
|
||||||
let confidence = ueba_confidence(metrics, workforce_policy, snapshot, &policy);
|
|
||||||
let risk_sources = risk_sources(&reasons);
|
|
||||||
let score = score.min(policy.score_cap.max(1));
|
|
||||||
let (level, status) = ueba_risk_level(score);
|
|
||||||
json!({
|
json!({
|
||||||
"score": score,
|
"score": score,
|
||||||
"level": level,
|
"level": level,
|
||||||
|
"severity": level,
|
||||||
"status": status,
|
"status": status,
|
||||||
"summary": format!("{} risk, {} reason(s)", level, reasons.len()),
|
"summary": format!("Уровень риска: {level}; факторов: {}", reasons.len()),
|
||||||
"formula": format!("sum(reason_points) capped at {}", policy.score_cap.max(1)),
|
"human_explanation": ueba_human_explanation(level, &score_components, reasons.len()),
|
||||||
|
"formula": "activity anomaly + time anomaly + application anomaly + network anomaly + history anomaly",
|
||||||
|
"score_cap": policy.score_cap.max(1),
|
||||||
|
"score_components": score_components,
|
||||||
|
"reason_codes": reason_codes,
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
"risk_sources": risk_sources,
|
"risk_sources": risk_sources,
|
||||||
"baseline_status": ueba_baseline
|
"baseline_status": ueba_baseline
|
||||||
@@ -5842,8 +6397,119 @@ fn push_risk_reason(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ueba_reason_codes(reasons: &[Value]) -> Vec<String> {
|
||||||
|
reasons
|
||||||
|
.iter()
|
||||||
|
.filter_map(|reason| reason.get("code").and_then(Value::as_str))
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ueba_component_for_reason(code: &str, source: &str) -> &'static str {
|
||||||
|
if matches!(code, "night_activity" | "weekend_activity") {
|
||||||
|
"time_anomaly"
|
||||||
|
} else if matches!(
|
||||||
|
code,
|
||||||
|
"dlp_fail"
|
||||||
|
| "dlp_warn"
|
||||||
|
| "application_classification_gap"
|
||||||
|
| "application_classification_gap_large"
|
||||||
|
) {
|
||||||
|
"application_anomaly"
|
||||||
|
} else if matches!(code, "open_incidents" | "baseline_deviation") {
|
||||||
|
"history_anomaly"
|
||||||
|
} else if source.contains("network")
|
||||||
|
|| source.contains("pfsense")
|
||||||
|
|| source.contains("firewall")
|
||||||
|
|| source.contains("vpn")
|
||||||
|
|| code.contains("network")
|
||||||
|
|| code.contains("firewall")
|
||||||
|
|| code.contains("vpn")
|
||||||
|
{
|
||||||
|
"network_anomaly"
|
||||||
|
} else {
|
||||||
|
"activity_anomaly"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ueba_score_components(reasons: &[Value], target_score: u64) -> BTreeMap<String, u64> {
|
||||||
|
let keys = [
|
||||||
|
"activity_anomaly",
|
||||||
|
"time_anomaly",
|
||||||
|
"application_anomaly",
|
||||||
|
"network_anomaly",
|
||||||
|
"history_anomaly",
|
||||||
|
];
|
||||||
|
let mut raw = keys
|
||||||
|
.iter()
|
||||||
|
.map(|key| ((*key).to_string(), 0_u64))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
for reason in reasons {
|
||||||
|
let code = reason
|
||||||
|
.get("code")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let source = reason
|
||||||
|
.get("source")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let points = reason.get("points").and_then(Value::as_u64).unwrap_or(0);
|
||||||
|
let component = ueba_component_for_reason(code, source).to_string();
|
||||||
|
*raw.entry(component).or_insert(0) += points;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_total = raw.values().sum::<u64>();
|
||||||
|
if raw_total == 0 || target_score >= raw_total {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut scaled = raw
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| {
|
||||||
|
let product = value.saturating_mul(target_score);
|
||||||
|
let base = product / raw_total;
|
||||||
|
let remainder = product % raw_total;
|
||||||
|
(key.clone(), base, remainder)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut assigned = scaled.iter().map(|(_, base, _)| *base).sum::<u64>();
|
||||||
|
scaled.sort_by(|left, right| right.2.cmp(&left.2).then_with(|| left.0.cmp(&right.0)));
|
||||||
|
for (_, base, _) in scaled.iter_mut() {
|
||||||
|
if assigned >= target_score {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
*base += 1;
|
||||||
|
assigned += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
scaled
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, value, _)| (key, value))
|
||||||
|
.collect::<BTreeMap<_, _>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ueba_human_explanation(
|
||||||
|
level: &str,
|
||||||
|
components: &BTreeMap<String, u64>,
|
||||||
|
reason_count: usize,
|
||||||
|
) -> String {
|
||||||
|
let top_component = components
|
||||||
|
.iter()
|
||||||
|
.max_by_key(|(_, points)| *points)
|
||||||
|
.filter(|(_, points)| **points > 0)
|
||||||
|
.map(|(component, points)| format!("{component}: {points}"));
|
||||||
|
match top_component {
|
||||||
|
Some(component) => format!(
|
||||||
|
"UEBA v1 по правилам: уровень {level}, факторов {reason_count}, основной вклад {component}."
|
||||||
|
),
|
||||||
|
None => format!("UEBA v1 по правилам: уровень {level}, значимых факторов нет."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn ueba_risk_level(score: u64) -> (&'static str, &'static str) {
|
fn ueba_risk_level(score: u64) -> (&'static str, &'static str) {
|
||||||
if score >= 70 {
|
if score >= 85 {
|
||||||
|
("critical", "FAIL")
|
||||||
|
} else if score >= 70 {
|
||||||
("high", "FAIL")
|
("high", "FAIL")
|
||||||
} else if score >= 40 {
|
} else if score >= 40 {
|
||||||
("medium", "WARN")
|
("medium", "WARN")
|
||||||
@@ -9323,6 +9989,12 @@ mod tests {
|
|||||||
for path in [
|
for path in [
|
||||||
"/contracts",
|
"/contracts",
|
||||||
"/reports",
|
"/reports",
|
||||||
|
"/executive",
|
||||||
|
"/workforce",
|
||||||
|
"/security",
|
||||||
|
"/forensics",
|
||||||
|
"/ueba",
|
||||||
|
"/pfsense",
|
||||||
"/incidents",
|
"/incidents",
|
||||||
"/cases",
|
"/cases",
|
||||||
"/readiness/latest",
|
"/readiness/latest",
|
||||||
@@ -9336,6 +10008,9 @@ mod tests {
|
|||||||
for required_type in [
|
for required_type in [
|
||||||
"ContractIndex",
|
"ContractIndex",
|
||||||
"ReportsResponse",
|
"ReportsResponse",
|
||||||
|
"RoleContext",
|
||||||
|
"UebaResponse",
|
||||||
|
"PfsenseReadinessResponse",
|
||||||
"CaseListResponse",
|
"CaseListResponse",
|
||||||
"IncidentReviewRequest",
|
"IncidentReviewRequest",
|
||||||
"export interface DetMirPortalApi",
|
"export interface DetMirPortalApi",
|
||||||
@@ -9368,6 +10043,88 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portal_roles_parse_and_enforce_scopes() {
|
||||||
|
assert_eq!(PortalRole::parse("executive"), Some(PortalRole::Executive));
|
||||||
|
assert_eq!(PortalRole::parse("operations"), Some(PortalRole::Admin));
|
||||||
|
assert!(PortalRole::Executive.can_access("workforce"));
|
||||||
|
assert!(!PortalRole::Executive.can_access("security"));
|
||||||
|
assert!(PortalRole::Security.can_access("incidents"));
|
||||||
|
assert!(!PortalRole::Security.can_access("workforce"));
|
||||||
|
assert!(PortalRole::Forensics.can_access("forensics"));
|
||||||
|
assert!(PortalRole::Admin.can_access("pfsense"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_filtered_reports_do_not_cross_default_scopes() {
|
||||||
|
let report = json!({
|
||||||
|
"generated_at_utc": "2026-06-06T00:00:00Z",
|
||||||
|
"headline": "demo",
|
||||||
|
"executive_dashboard": {"summary": {"main_risk": "demo"}},
|
||||||
|
"workforce": {"department_comparison": []},
|
||||||
|
"workforce_policy": {"configured": false},
|
||||||
|
"security_events_summary": {"events_24h": 1},
|
||||||
|
"security_correlation": [],
|
||||||
|
"risk_incident_candidates": [{"id": "candidate-demo"}],
|
||||||
|
"ueba_risk": {"score": 10, "level": "low", "status": "WARN", "reasons": [{"code": "activity_anomaly"}]},
|
||||||
|
"incident_review_audit_summary": {"total_changes": 0}
|
||||||
|
});
|
||||||
|
|
||||||
|
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("risk_incident_candidates").is_none());
|
||||||
|
assert!(executive.get("security_correlation").is_none());
|
||||||
|
|
||||||
|
let security = role_filtered_report(report.clone(), PortalRole::Security);
|
||||||
|
assert!(security.get("ueba_risk").is_some());
|
||||||
|
assert!(security.get("risk_incident_candidates").is_some());
|
||||||
|
assert!(security.get("workforce").is_none());
|
||||||
|
assert!(security.get("workforce_policy").is_none());
|
||||||
|
|
||||||
|
let forensics = role_filtered_report(report, PortalRole::Forensics);
|
||||||
|
assert!(forensics.get("forensics").is_some());
|
||||||
|
assert!(forensics.get("workforce").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ueba_and_pfsense_contracts_are_stable_demo_safe() {
|
||||||
|
let report = json!({
|
||||||
|
"ueba_risk": {
|
||||||
|
"score": 55,
|
||||||
|
"level": "medium",
|
||||||
|
"status": "WARN",
|
||||||
|
"summary": "medium risk, 1 reason(s)",
|
||||||
|
"score_components": {
|
||||||
|
"activity_anomaly": 15,
|
||||||
|
"time_anomaly": 0,
|
||||||
|
"application_anomaly": 20,
|
||||||
|
"network_anomaly": 0,
|
||||||
|
"history_anomaly": 20
|
||||||
|
},
|
||||||
|
"reasons": [{"code": "activity_anomaly"}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let ueba = build_ueba_api_payload(&report, PortalRole::Security);
|
||||||
|
assert_eq!(ueba["score"], 55);
|
||||||
|
assert_eq!(ueba["severity"], "medium");
|
||||||
|
assert_eq!(ueba["score_components"]["activity_anomaly"], 15);
|
||||||
|
assert_eq!(ueba["score_components"]["application_anomaly"], 20);
|
||||||
|
assert_eq!(ueba["reason_codes"][0], "activity_anomaly");
|
||||||
|
assert_eq!(ueba["model"]["ml_used"], false);
|
||||||
|
assert_eq!(ueba["model"]["llm_used"], false);
|
||||||
|
|
||||||
|
let pfsense = build_pfsense_readiness_payload(PortalRole::Security);
|
||||||
|
assert_eq!(pfsense["status"], "contract_only");
|
||||||
|
assert_eq!(pfsense["siem"], false);
|
||||||
|
assert_eq!(pfsense["ingestion_available"], false);
|
||||||
|
let text = pfsense.to_string();
|
||||||
|
assert!(text.contains("203.0.113."));
|
||||||
|
assert!(text.contains("198.51.100."));
|
||||||
|
assert!(!text.contains("10.10."));
|
||||||
|
assert!(!text.contains("192.168."));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn links_are_gateway_relative() {
|
fn links_are_gateway_relative() {
|
||||||
let links = links();
|
let links = links();
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ const VIEW_MODES = {
|
|||||||
description: "Главный вывод, сводка руководителя, риски подразделений и карта рисков.",
|
description: "Главный вывод, сводка руководителя, риски подразделений и карта рисков.",
|
||||||
stage: "Формирование главного вывода",
|
stage: "Формирование главного вывода",
|
||||||
},
|
},
|
||||||
|
manager: {
|
||||||
|
label: "Менеджер",
|
||||||
|
title: "Роль менеджера",
|
||||||
|
heading: "Представление менеджера",
|
||||||
|
description: "Workforce, сравнение подразделений, ответственные, тренды и отчет.",
|
||||||
|
stage: "Расчет Workforce и подразделений",
|
||||||
|
},
|
||||||
security: {
|
security: {
|
||||||
label: "Безопасность",
|
label: "Безопасность",
|
||||||
title: "Роль безопасности",
|
title: "Роль безопасности",
|
||||||
@@ -32,6 +39,20 @@ const VIEW_MODES = {
|
|||||||
description: "Очередь проверки, расследования, аудит решений и пакеты расследований.",
|
description: "Очередь проверки, расследования, аудит решений и пакеты расследований.",
|
||||||
stage: "Подготовка разделов проверки и расследований",
|
stage: "Подготовка разделов проверки и расследований",
|
||||||
},
|
},
|
||||||
|
forensics: {
|
||||||
|
label: "Расследования",
|
||||||
|
title: "Роль расследований",
|
||||||
|
heading: "Представление расследований",
|
||||||
|
description: "Карточки расследований, timeline, связка user / host / app / network event и экспорт.",
|
||||||
|
stage: "Подготовка timeline расследований",
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
label: "Администратор",
|
||||||
|
title: "Роль администратора",
|
||||||
|
heading: "Представление администратора",
|
||||||
|
description: "Настройки, качество данных, источники и эксплуатационные ошибки.",
|
||||||
|
stage: "Проверка настроек и источников",
|
||||||
|
},
|
||||||
operations: {
|
operations: {
|
||||||
label: "Эксплуатация",
|
label: "Эксплуатация",
|
||||||
title: "Роль эксплуатации",
|
title: "Роль эксплуатации",
|
||||||
@@ -44,7 +65,7 @@ const VIEW_MODES = {
|
|||||||
function initialViewMode() {
|
function initialViewMode() {
|
||||||
try {
|
try {
|
||||||
const stored = window.localStorage?.getItem("detmir.portal.viewMode");
|
const stored = window.localStorage?.getItem("detmir.portal.viewMode");
|
||||||
return ["executive", "security", "operations"].includes(stored) ? stored : "executive";
|
return ["executive", "manager", "security", "forensics", "admin", "operations"].includes(stored) ? stored : "executive";
|
||||||
} catch {
|
} catch {
|
||||||
return "executive";
|
return "executive";
|
||||||
}
|
}
|
||||||
@@ -55,8 +76,27 @@ function apiBase() {
|
|||||||
return path.startsWith("/portal") ? "/portal/api" : "/api";
|
return path.startsWith("/portal") ? "/portal/api" : "/api";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function apiRole() {
|
||||||
|
if (state.tab === "employees" || state.tab === "departments") return "manager";
|
||||||
|
if (state.tab === "owner" || state.tab === "perimeter") return "security";
|
||||||
|
if (state.tab === "incidents") return "forensics";
|
||||||
|
if (state.tab === "settings") return "admin";
|
||||||
|
const mode = currentViewMode();
|
||||||
|
if (mode === "operations") return "admin";
|
||||||
|
return ["executive", "manager", "security", "forensics", "admin"].includes(mode) ? mode : "executive";
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleHeaders() {
|
||||||
|
return { "X-AWatch-Role": apiRole() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function withRole(path, role = apiRole()) {
|
||||||
|
const separator = path.includes("?") ? "&" : "?";
|
||||||
|
return `${path}${separator}role=${encodeURIComponent(role)}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadJson(path) {
|
async function loadJson(path) {
|
||||||
const response = await fetch(`${apiBase()}${path}`, { cache: "no-store" });
|
const response = await fetch(`${apiBase()}${path}`, { cache: "no-store", headers: roleHeaders() });
|
||||||
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
@@ -64,7 +104,7 @@ async function loadJson(path) {
|
|||||||
async function postJson(path, payload) {
|
async function postJson(path, payload) {
|
||||||
const response = await fetch(`${apiBase()}${path}`, {
|
const response = await fetch(`${apiBase()}${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...roleHeaders() },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
|
||||||
@@ -1202,7 +1242,10 @@ function renderRoleViewSummary(meta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderOperatorRoleContent(mode, data, report, extras = {}) {
|
function renderOperatorRoleContent(mode, data, report, extras = {}) {
|
||||||
|
if (mode === "manager") return renderManagerView(report);
|
||||||
if (mode === "security") return renderSecurityView(data, report, extras);
|
if (mode === "security") return renderSecurityView(data, report, extras);
|
||||||
|
if (mode === "forensics") return renderForensicsView(data, report, extras);
|
||||||
|
if (mode === "admin") return renderOperationsView(data, report);
|
||||||
if (mode === "operations") return renderOperationsView(data, report);
|
if (mode === "operations") return renderOperationsView(data, report);
|
||||||
return renderExecutiveView(report, extras.incidents || []);
|
return renderExecutiveView(report, extras.incidents || []);
|
||||||
}
|
}
|
||||||
@@ -1234,6 +1277,30 @@ function renderSecurityView(data, report, extras = {}) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderManagerView(report) {
|
||||||
|
return `
|
||||||
|
${renderExecutiveDashboard(report)}
|
||||||
|
${renderDepartmentRanking(report)}
|
||||||
|
${renderDepartmentHeatMap(report)}
|
||||||
|
${renderOverviewAnalytics(report)}
|
||||||
|
<section class="dashboard-band">
|
||||||
|
<div class="band-head"><h3>Markdown-отчет</h3><span class="muted">экспорт управленческого среза Workforce</span></div>
|
||||||
|
<pre class="markdown-preview">${ui((report?.markdown || "").slice(0, 2000))}</pre>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderForensicsView(data, report, extras = {}) {
|
||||||
|
const cases = Array.isArray(extras.cases?.cases) ? extras.cases.cases : [];
|
||||||
|
return `
|
||||||
|
${renderRiskIncidentCandidates(report?.risk_incident_candidates)}
|
||||||
|
${renderCases(cases)}
|
||||||
|
${renderInvestigationTimeline(report)}
|
||||||
|
${renderInvestigationPacks(report?.risk_incident_candidates)}
|
||||||
|
${renderIncidentReviewAuditSummary(report)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderOperationsView(data, report) {
|
function renderOperationsView(data, report) {
|
||||||
return `
|
return `
|
||||||
${renderAgentCoverageSla(report?.agent_coverage_sla)}
|
${renderAgentCoverageSla(report?.agent_coverage_sla)}
|
||||||
@@ -1344,7 +1411,7 @@ function renderInvestigationPacks(candidates) {
|
|||||||
<div class="row compact-row">
|
<div class="row compact-row">
|
||||||
<strong>${ui(item.department || "Без подразделения")}</strong>
|
<strong>${ui(item.department || "Без подразделения")}</strong>
|
||||||
<span class="muted">${ui(item.reason || "требуется проверка")} · ${ui(item.hostname || "-")}</span>
|
<span class="muted">${ui(item.reason || "требуется проверка")} · ${ui(item.hostname || "-")}</span>
|
||||||
<a class="small-button" href="${apiBase()}/investigation-pack/${encodeURIComponent(item.id || "")}?format=markdown" download>Скачать</a>
|
<a class="small-button" href="${apiBase()}${withRole(`/investigation-pack/${encodeURIComponent(item.id || "")}?format=markdown`, "forensics")}" download>Скачать</a>
|
||||||
</div>
|
</div>
|
||||||
`).join("") : `
|
`).join("") : `
|
||||||
<div class="row compact-row">
|
<div class="row compact-row">
|
||||||
@@ -1357,6 +1424,37 @@ function renderInvestigationPacks(candidates) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderInvestigationTimeline(report) {
|
||||||
|
const investigations = Array.isArray(report?.forensics?.investigations) ? report.forensics.investigations : [];
|
||||||
|
const timelines = investigations.flatMap(item => Array.isArray(item.timeline)
|
||||||
|
? item.timeline.map(event => ({ ...event, investigation_id: item.investigation_id }))
|
||||||
|
: []);
|
||||||
|
return `
|
||||||
|
<section class="card investigation-timeline-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h3>Timeline событий</h3>
|
||||||
|
<p class="muted">Связка user / host / app / network event для ручного расследования.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge ${statusClass(timelines.length ? "INFO" : "UNKNOWN")}">${timelines.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="list compact-list">${timelines.length ? timelines.slice(0, 20).map(event => `
|
||||||
|
<div class="row compact-row">
|
||||||
|
<strong>${ui(event.timestamp || "-")}</strong>
|
||||||
|
<span class="muted">${ui(event.kind || "event")} · ${ui(event.entity || "-")} · ${ui(event.summary || "")}</span>
|
||||||
|
<span class="badge status-ok">${ui(event.source || "portal")}</span>
|
||||||
|
</div>
|
||||||
|
`).join("") : `
|
||||||
|
<div class="row compact-row">
|
||||||
|
<strong>Timeline пуст</strong>
|
||||||
|
<span class="muted">Нет кандидатов для расследования в текущем срезе.</span>
|
||||||
|
<span class="badge status-ok">в норме</span>
|
||||||
|
</div>
|
||||||
|
`}</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderOverviewAnalytics(report) {
|
function renderOverviewAnalytics(report) {
|
||||||
const workforce = findSection(report, "Работа");
|
const workforce = findSection(report, "Работа");
|
||||||
const insights = findSection(report, "Выводы по активности") || findSection(report, "Выводы Workforce");
|
const insights = findSection(report, "Выводы по активности") || findSection(report, "Выводы Workforce");
|
||||||
@@ -1885,7 +1983,7 @@ function renderCases(cases) {
|
|||||||
|
|
||||||
function renderCaseActions(item) {
|
function renderCaseActions(item) {
|
||||||
const id = item?.case_id || "";
|
const id = item?.case_id || "";
|
||||||
const packUrl = `/portal/api/cases/${encodeURIComponent(id)}?format=markdown`;
|
const packUrl = `/portal/api/cases/${encodeURIComponent(id)}?format=markdown&role=forensics`;
|
||||||
return `
|
return `
|
||||||
<div class="button-row compact-actions">
|
<div class="button-row compact-actions">
|
||||||
<button class="small-button" data-case-status="IN_PROGRESS" data-case-id="${escapeHtml(id)}">В работу</button>
|
<button class="small-button" data-case-status="IN_PROGRESS" data-case-id="${escapeHtml(id)}">В работу</button>
|
||||||
@@ -2652,7 +2750,7 @@ function renderCandidateReviewActions(item) {
|
|||||||
["FALSE_POSITIVE", "Ложный"],
|
["FALSE_POSITIVE", "Ложный"],
|
||||||
["POSTPONED", "Отложить"],
|
["POSTPONED", "Отложить"],
|
||||||
];
|
];
|
||||||
const packUrl = `/portal/api/investigation-pack/${encodeURIComponent(id)}?format=markdown`;
|
const packUrl = `/portal/api/investigation-pack/${encodeURIComponent(id)}?format=markdown&role=forensics`;
|
||||||
const createCase = reviewStatus === "CONFIRMED"
|
const createCase = reviewStatus === "CONFIRMED"
|
||||||
? `<button class="small-button investigation-pack-button primary" data-create-case="true" data-candidate-id="${escapeHtml(id)}">Создать дело</button>`
|
? `<button class="small-button investigation-pack-button primary" data-create-case="true" data-candidate-id="${escapeHtml(id)}">Создать дело</button>`
|
||||||
: "";
|
: "";
|
||||||
@@ -2843,7 +2941,7 @@ async function loadCurrentTab() {
|
|||||||
const data = await loadJson("/operator");
|
const data = await loadJson("/operator");
|
||||||
state.operatorData = data;
|
state.operatorData = data;
|
||||||
state.reports = await loadJson("/reports").catch(() => state.reports);
|
state.reports = await loadJson("/reports").catch(() => state.reports);
|
||||||
if (currentViewMode() === "security") {
|
if (currentViewMode() === "security" || currentViewMode() === "forensics") {
|
||||||
state.cases = await loadJson("/cases").catch(error => ({ ok: false, error: error.message, cases: [] }));
|
state.cases = await loadJson("/cases").catch(error => ({ ok: false, error: error.message, cases: [] }));
|
||||||
}
|
}
|
||||||
updateFilters(state.reports);
|
updateFilters(state.reports);
|
||||||
|
|||||||
@@ -50,10 +50,12 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="view-switch" aria-label="Ролевое представление">
|
<div class="view-switch" aria-label="Ролевое представление">
|
||||||
<span>Представление</span>
|
<span>Роль</span>
|
||||||
<button class="view-button is-active" data-view-mode="executive" type="button">Руководитель</button>
|
<button class="view-button is-active" data-view-mode="executive" type="button">Руководитель</button>
|
||||||
|
<button class="view-button" data-view-mode="manager" type="button">Менеджер</button>
|
||||||
<button class="view-button" data-view-mode="security" type="button">Безопасность</button>
|
<button class="view-button" data-view-mode="security" type="button">Безопасность</button>
|
||||||
<button class="view-button" data-view-mode="operations" type="button">Эксплуатация</button>
|
<button class="view-button" data-view-mode="forensics" type="button">Расследования</button>
|
||||||
|
<button class="view-button" data-view-mode="admin" type="button">Админ</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="small-button primary" data-open-reports="true">Сформировать отчет</button>
|
<button class="small-button primary" data-open-reports="true">Сформировать отчет</button>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Сценарий демонстрации заказчику
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
За 10 минут показать, что AWatch-rus Pilot v1.0 отвечает на управленческие,
|
||||||
|
ИБ и эксплуатационные вопросы без перегруза техническими деталями.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
1. Открыть [DEMO_RUNBOOK_RU.md](DEMO_RUNBOOK_RU.md).
|
||||||
|
2. Выполнить обязательный преддемо-прогрев.
|
||||||
|
3. Убедиться, что smoke-тест прошел успешно.
|
||||||
|
4. Открыть портал на `/portal`.
|
||||||
|
|
||||||
|
## Показ
|
||||||
|
|
||||||
|
| Время | Действие | Что сказать |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0:00-1:30 | Роль `Руководитель` | Главный вывод отображается первым, дальше идут риски подразделений и достоверность данных. |
|
||||||
|
| 1:30-3:00 | Роль `Менеджер` | Показать Workforce: активность, сравнение подразделений, перегруз и недогруз. |
|
||||||
|
| 3:00-5:00 | Роль `Безопасность` | Показать кандидата на проверку, risk score, причину риска и аудит решений. |
|
||||||
|
| 5:00-7:00 | Роль `Расследования` | Показать карточку расследования, timeline и пакет материалов. |
|
||||||
|
| 7:00-8:30 | Роль `Админ` | Показать полноту данных, качество сбора, ClickHouse/fallback-статус и ошибки. |
|
||||||
|
| 8:30-10:00 | Markdown-отчет | Показать итоговый управленческий вывод и формат выгрузки. |
|
||||||
|
|
||||||
|
## Ограничения в коммуникации
|
||||||
|
|
||||||
|
- Не называть продукт SIEM.
|
||||||
|
- Не называть продукт классическим DLP.
|
||||||
|
- Не обещать автоматическую блокировку, карантин или сетевые изменения.
|
||||||
|
- pfSense описывать как опциональный интеграционный слой.
|
||||||
|
- Dioxus не используется и не рассматривается для Pilot v1.0.
|
||||||
|
|
||||||
|
## Что фиксировать после показа
|
||||||
|
|
||||||
|
- Непонятные термины для руководителя.
|
||||||
|
- Где ИБ не хватает контекста расследования.
|
||||||
|
- Где эксплуатации не хватает статуса источников.
|
||||||
|
- Все замечания записывать отдельно и не исправлять во время показа.
|
||||||
@@ -261,13 +261,13 @@ read-only интеграционный контекст. Enforcement, quarantine
|
|||||||
|
|
||||||
## Связанные документы
|
## Связанные документы
|
||||||
|
|
||||||
- [Описание продукта](PRODUCT_DESCRIPTION_RU.md)
|
- [Описание продукта](../PRODUCT_DESCRIPTION_RU.md)
|
||||||
- [Аудит готовности к пилоту](PILOT_READINESS_AUDIT_RU.md)
|
- [Аудит готовности к пилоту](PILOT_READINESS_AUDIT_RU.md)
|
||||||
- [Чек-лист пилотного внедрения](docs/PILOT_DEPLOYMENT_CHECKLIST_RU.md)
|
- [Чек-лист пилотного внедрения](PILOT_DEPLOYMENT_CHECKLIST_RU.md)
|
||||||
- [Акт приемки пилота](docs/CUSTOMER_PILOT_ACCEPTANCE_RU.md)
|
- [Акт приемки пилота](CUSTOMER_PILOT_ACCEPTANCE_RU.md)
|
||||||
- [Портал AWatch-rus](docs/PORTAL_RU.md)
|
- [Портал AWatch-rus](PORTAL_RU.md)
|
||||||
- [Business Risk](docs/BUSINESS_RISK_RU.md)
|
- [Business Risk](BUSINESS_RISK_RU.md)
|
||||||
- [Модель безопасности](docs/SECURITY_MODEL_RU.md)
|
- [Модель безопасности](SECURITY_MODEL_RU.md)
|
||||||
- [Архитектура](docs/ARCHITECTURE_RU.md)
|
- [Архитектура](ARCHITECTURE_RU.md)
|
||||||
- [Развертывание агента](docs/AGENT_DEPLOYMENT_RU.md)
|
- [Развертывание агента](AGENT_DEPLOYMENT_RU.md)
|
||||||
- [Windows Rust Agent Worktime/RDP](docs/WINDOWS_RUST_AGENT_WORKTIME_RU.md)
|
- [Windows Rust Agent Worktime/RDP](WINDOWS_RUST_AGENT_WORKTIME_RU.md)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Преддемо-сценарий AWatch-rus
|
||||||
|
|
||||||
|
Цель: за 10 минут показать руководителю, ИБ и эксплуатации единый путь от
|
||||||
|
главного риска до итогового отчета.
|
||||||
|
|
||||||
|
## Перед началом
|
||||||
|
|
||||||
|
- Открыть портал на рабочем демонстрационном URL.
|
||||||
|
- Дождаться статуса `Данные готовы`.
|
||||||
|
- Убедиться, что выбран актуальный период и есть хотя бы один кандидат на
|
||||||
|
проверку.
|
||||||
|
|
||||||
|
## Обязательный преддемо-прогрев
|
||||||
|
|
||||||
|
Холодный первый запуск портала или временного туннеля может не дождаться
|
||||||
|
`READY` в smoke-тесте и упасть на шаге `wait_initial_ready`. Это не должно
|
||||||
|
происходить уже перед заказчиком, поэтому прогрев выполняется заранее.
|
||||||
|
|
||||||
|
Порядок:
|
||||||
|
|
||||||
|
1. Запустить портал или открыть доступ к рабочему порталу через утвержденный
|
||||||
|
демонстрационный маршрут.
|
||||||
|
2. Выполнить первый smoke-прогон:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/detmir-portal-tabs-smoke.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Если первый прогон упал на `wait_initial_ready`, подождать 30 секунд и
|
||||||
|
повторить smoke-прогон.
|
||||||
|
4. Убедиться, что второй прогон завершился с `ok=true`.
|
||||||
|
5. Открыть `/portal` в браузере.
|
||||||
|
6. Переключить роли `Руководитель`, `Безопасность`, `Эксплуатация`.
|
||||||
|
7. Проверить, что в представлении `Руководитель` блок `Главный вывод`
|
||||||
|
отображается первым.
|
||||||
|
8. Только после этого начинать демонстрацию.
|
||||||
|
|
||||||
|
## За 10 минут до показа
|
||||||
|
|
||||||
|
- Портал открыт.
|
||||||
|
- Данные загружены.
|
||||||
|
- ClickHouse-статус понятен и не требует объяснения на ходу.
|
||||||
|
- Markdown-отчет формируется.
|
||||||
|
- Smoke-тест завершился с `ok=true`.
|
||||||
|
- `DEMO_RUNBOOK_RU.md` открыт рядом с демонстрационным окном.
|
||||||
|
|
||||||
|
## Сценарий показа
|
||||||
|
|
||||||
|
1. Открыть вкладку `Обзор`.
|
||||||
|
2. В переключателе представлений выбрать `Руководитель`.
|
||||||
|
3. Показать блок `Главный вывод`: что является главным риском и почему это
|
||||||
|
важно для управления.
|
||||||
|
4. Показать `Риски подразделений`: какое подразделение требует внимания и
|
||||||
|
какая причина риска указана в отчете.
|
||||||
|
5. Переключиться на представление `Безопасность`.
|
||||||
|
6. Показать кандидата на проверку и открыть связанное расследование.
|
||||||
|
7. Показать материалы расследования и пакет расследования.
|
||||||
|
8. Переключиться на представление `Эксплуатация`.
|
||||||
|
9. Показать качество данных, полноту данных и статус событий безопасности через
|
||||||
|
ClickHouse.
|
||||||
|
10. Открыть итоговый markdown-отчет и завершить показ управленческим выводом:
|
||||||
|
что требует проверки, кто отвечает и какие данные это подтверждают.
|
||||||
|
|
||||||
|
## Контроль перед показом
|
||||||
|
|
||||||
|
- Портал открывается без ошибок.
|
||||||
|
- Статус загрузки: `Данные готовы`.
|
||||||
|
- События безопасности доступны.
|
||||||
|
- Есть кандидат на проверку.
|
||||||
|
- Расследование открывается из карточки риска.
|
||||||
|
- Markdown-отчет формируется.
|
||||||
@@ -1,35 +1,85 @@
|
|||||||
# pfSense Integration
|
# pfSense Integration Readiness
|
||||||
|
|
||||||
pfSense рассматривается как интеграционный слой сетевого периметра, а не обязательная часть продукта.
|
## Статус Pilot v1.0
|
||||||
|
|
||||||
## Режим v0.3
|
pfSense в Pilot v1.0 является опциональным интеграционным слоем сетевого
|
||||||
|
периметра. Полноценный SIEM не реализуется.
|
||||||
|
|
||||||
- read-only;
|
Текущий статус: `contract_only`.
|
||||||
- без изменения правил;
|
|
||||||
- без автоматического карантина;
|
|
||||||
- без управления маршрутизацией;
|
|
||||||
- без зависимости портала от pfSense.
|
|
||||||
|
|
||||||
## Место в архитектуре
|
Это означает:
|
||||||
|
|
||||||
```text
|
- есть API-заготовка `/api/pfsense`;
|
||||||
pfSense / firewall telemetry
|
- есть JSON-контракты для firewall events, vpn events, traffic summary и top destinations;
|
||||||
|
|
- есть демонстрационный fixture без реальных IP/hostname/login;
|
||||||
awatch-agent-rs --role firewall
|
- нет заявления, что реальный ingestion включен;
|
||||||
|
|
- нет автоматического изменения firewall/VPN/routing;
|
||||||
POST /api/telemetry
|
- нет NAC, SOAR, quarantine и блокировок.
|
||||||
|
|
|
||||||
Workforce/UEBA risk context
|
|
||||||
```
|
|
||||||
|
|
||||||
## Коммерческая ценность
|
## Контракт событий
|
||||||
|
|
||||||
Интеграция позволяет объяснять риски не только по активности рабочего места, но и по сетевому контексту: unusual destinations, VPN sessions, gateway status, proxy/DNS signals.
|
Минимальные поля firewall event:
|
||||||
|
|
||||||
## Не реализуется в v0.3
|
- `timestamp`;
|
||||||
|
- `source_host`;
|
||||||
|
- `destination`;
|
||||||
|
- `action`;
|
||||||
|
- `protocol`;
|
||||||
|
- `rule_id`.
|
||||||
|
|
||||||
- NAC;
|
Минимальные поля VPN event:
|
||||||
- SOAR-автоматизация;
|
|
||||||
- блокировка VLAN;
|
- `timestamp`;
|
||||||
- изменение firewall rules;
|
- `source_host`;
|
||||||
- управление VPN-доступом.
|
- `user_ref`;
|
||||||
|
- `action`;
|
||||||
|
- `tunnel`.
|
||||||
|
|
||||||
|
Traffic summary:
|
||||||
|
|
||||||
|
- период;
|
||||||
|
- количество событий;
|
||||||
|
- действия pass/block;
|
||||||
|
- объем трафика, если источник его предоставляет.
|
||||||
|
|
||||||
|
Top destinations:
|
||||||
|
|
||||||
|
- `destination`;
|
||||||
|
- `events`;
|
||||||
|
- `bytes`.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
`GET /api/pfsense` возвращает:
|
||||||
|
|
||||||
|
- `status`;
|
||||||
|
- `siem=false`;
|
||||||
|
- `ingestion_available`;
|
||||||
|
- `firewall_events`;
|
||||||
|
- `vpn_events`;
|
||||||
|
- `traffic_summary`;
|
||||||
|
- `top_destinations`;
|
||||||
|
- `schemas`;
|
||||||
|
- `demo_data_policy`.
|
||||||
|
|
||||||
|
Если реальный ingestion появится позже, статус должен измениться только после
|
||||||
|
проверки источника, свежести данных и отсутствия чувствительных значений в
|
||||||
|
demo-режиме.
|
||||||
|
|
||||||
|
## Demo fixture
|
||||||
|
|
||||||
|
Файл: [fixtures/pfsense-demo-events.json](fixtures/pfsense-demo-events.json).
|
||||||
|
|
||||||
|
Для демонстрации используются только специальные адресные диапазоны RFC 5737:
|
||||||
|
|
||||||
|
- `192.0.2.0/24`;
|
||||||
|
- `198.51.100.0/24`;
|
||||||
|
- `203.0.113.0/24`.
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
AWatch-rus не является SIEM и не заменяет pfSense, NAC, SOAR или классический
|
||||||
|
DLP. pfSense-контракт нужен только для будущего обогащения Workforce Analytics
|
||||||
|
+ Security Analytics + Forensics сетевым контекстом.
|
||||||
|
|
||||||
|
Для Pilot v1.0 Dioxus не используется и не рассматривается.
|
||||||
|
|||||||
+118
-113
@@ -1,140 +1,145 @@
|
|||||||
# Анализ разрывов готовности пилота: AWatch-rus
|
# Анализ разрывов готовности пилота AWatch-rus
|
||||||
|
|
||||||
Дата аудита: 2026-06-05.
|
Дата аудита: 2026-06-06.
|
||||||
|
|
||||||
Назначение: финальная проверка готовности AWatch-rus к демонстрационному
|
Аудируемый срез: `origin/main`,
|
||||||
пилоту для руководителя, ИБ и эксплуатации. После первичного аудита закрыты
|
`9c57d6d2ce7eae1b133c937f037b24933f5797fe`.
|
||||||
демонстрационные разрывы без добавления новых сущностей, новых интеграций и
|
|
||||||
архитектурных изменений.
|
Цель: объективно определить готовность AWatch-rus к контролируемой пилотной
|
||||||
|
эксплуатации и демонстрации руководителю, ИБ и эксплуатации.
|
||||||
|
|
||||||
|
Ограничения аудита: код, архитектура, сущности и интеграции не изменялись.
|
||||||
|
Сформирован только этот аудитный документ.
|
||||||
|
|
||||||
Не входило в аудит: pfSense, Telegram, Grafana, InfluxDB.
|
Не входило в аудит: pfSense, Telegram, Grafana, InfluxDB.
|
||||||
|
|
||||||
## 1. Готово к пилоту
|
## Что готово
|
||||||
|
|
||||||
- Executive View готов к демонстрации: главный вывод отображается первым,
|
- Архитектура пилота выстроена как понятная цепочка:
|
||||||
управленческие блоки идут в понятном порядке, технические термины не
|
`агент -> телеметрия -> аналитика -> риск -> расследование -> отчет`.
|
||||||
выводятся в пользовательском тексте.
|
- Rust workspace на текущем `origin/main` проходит обязательные проверки:
|
||||||
- Security View готов к демонстрации: есть кандидаты на проверку, аудит,
|
|
||||||
материалы расследования, пакет расследования и переход к карточке
|
|
||||||
расследования.
|
|
||||||
- Operations View готов к демонстрации: видны полнота и качество данных,
|
|
||||||
ошибки сбора, состояние источников и доступность событий безопасности.
|
|
||||||
- Контур ClickHouse для событий ИБ работает на `<GATEWAY_HOST>` в Docker-контейнере
|
|
||||||
`aw-rus-1c-clickhouse`; портал читает события через ClickHouse, fallback не
|
|
||||||
используется.
|
|
||||||
- `/api/health` возвращает `ok=true`; источники `detmir_check`,
|
|
||||||
`detmir_status`, `worktime_api`, `worktime_management`, `one_c`,
|
|
||||||
`dlp_health`, `security_events` находятся в рабочем состоянии.
|
|
||||||
- В текущем отчете есть 3 кандидата на проверку; smoke подтвердил 3 кнопки
|
|
||||||
перехода к расследованию.
|
|
||||||
- Первый отчет прогревается при старте `detmir-portal.service`; после прогрева
|
|
||||||
`/api/reports` отвечает за доли секунды.
|
|
||||||
- Фоновое обновление больше не переводит готовый экран обратно в состояние
|
|
||||||
"Загрузка данных"; 70-секундная браузерная проверка сохранила `READY`.
|
|
||||||
- Мобильная проверка 390px проходит: страница доходит до `READY`, глобального
|
|
||||||
горизонтального расползания body не выявлено.
|
|
||||||
- Обязательные проверки на текущем дереве пройдены:
|
|
||||||
|
|
||||||
| Проверка | Итог |
|
| Проверка | Результат |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `cargo test --workspace` | Пройдено. |
|
| `cargo test --workspace` | Пройдено. |
|
||||||
| `cargo clippy --all-targets --all-features` | Пройдено. |
|
| `cargo clippy --all-targets --all-features` | Пройдено. |
|
||||||
| `cargo build --release` | Пройдено. |
|
| `cargo build --release` | Пройдено. |
|
||||||
| `node scripts/detmir-portal-tabs-smoke.mjs` | Пройдено против `<PORTAL_URL>` с `security_events=available`. |
|
| `node scripts/detmir-portal-tabs-smoke.mjs` | Пройдено против рабочего портала через временный туннель; `security_events=available`. |
|
||||||
|
|
||||||
## 2. Желательно исправить до пилота
|
- Portal UX проходит smoke: вкладки `Обзор`, `Сотрудники`,
|
||||||
|
`Подразделения`, `Риски`, `Расследования`, `Сетевой периметр`, `Отчеты`,
|
||||||
|
`Настройки` открываются без ошибок консоли и HTTP 4xx/5xx.
|
||||||
|
- Executive View готов к показу руководителю: главный вывод расположен первым,
|
||||||
|
порядок управленческих блоков подтвержден smoke, англоязычные и лишние
|
||||||
|
технические термины в проверяемом пользовательском слое не обнаружены.
|
||||||
|
- Security View готов к показу ИБ: есть кандидаты на проверку, расследования,
|
||||||
|
аудит, материалы расследования и переход из риска в карточку расследования.
|
||||||
|
Smoke подтвердил 3 кнопки перехода к расследованию.
|
||||||
|
- Operations View готов к показу эксплуатации: видны полнота и качество
|
||||||
|
данных, состояние источников, режим событий безопасности и ошибки сбора.
|
||||||
|
- ClickHouse integration работает в пилотном контуре: `/api/health`
|
||||||
|
возвращает `ok=true`, источник `security_events` активен, портал получает
|
||||||
|
агрегированные события безопасности через ClickHouse.
|
||||||
|
- Сбор данных 1С и DLP зафиксирован как Rust-first runtime: `aw-1c-ingest-rust`
|
||||||
|
и Windows telemetry/evidence sync работают с 15-минутным циклом; скриншоты
|
||||||
|
1С не копируются, доказательные PNG остаются только для DLP-событий.
|
||||||
|
- Agent/Rust migration закрывает критичный runtime-риск: основной сбор
|
||||||
|
worktime, browser/domain, URL/domain extraction, clipboard/USB/print
|
||||||
|
incident semantics и DLP evidence path покрыты Rust-бинарниками и тестами.
|
||||||
|
- Документы `docs/PORTAL_RU.md` и `docs/SECURITY_EVENTS_CLICKHOUSE_RU.md`
|
||||||
|
присутствуют и отражают текущую модель портала и ClickHouse-событий.
|
||||||
|
- Пилотные документы находятся в единой структуре `docs/`:
|
||||||
|
`docs/CUSTOMER_PILOT_PACK_RU.md`, `docs/PILOT_READINESS_AUDIT_RU.md`,
|
||||||
|
`docs/SALES_POSITIONING_RU.md`, `docs/DEMO_RUNBOOK_RU.md`.
|
||||||
|
- 10-минутный демонстрационный путь технически доступен: главный риск,
|
||||||
|
подразделение, причина риска, кандидат, расследование, пакет расследования и
|
||||||
|
итоговый управленческий вывод показываются в портале.
|
||||||
|
|
||||||
- Передать заказчику только вычитанные customer-facing документы. Внутренние
|
## Что критично исправить
|
||||||
документы могут содержать технические обозначения, но демонстрационный пакет
|
|
||||||
для руководителя должен использовать русские деловые формулировки.
|
|
||||||
- Отдельно проверить экспорт итогового отчета в том формате, который будет
|
|
||||||
показан заказчику: текстовый отчет, печать или PDF.
|
|
||||||
- Зафиксировать демонстрационный сценарий в виде короткой шпаргалки оператора:
|
|
||||||
с какой вкладки начинать, какой кандидат открывается, какой пакет скачивать,
|
|
||||||
какой итоговый вывод озвучивать.
|
|
||||||
- Зафиксировать hand-off правило для Rust-сборки: на этой машине использовать
|
|
||||||
`CARGO_TARGET_DIR=<LOCAL_CARGO_TARGET_DIR>`, потому что старый
|
|
||||||
`target/` на `fuseblk` непригоден для `libsqlite3-sys`.
|
|
||||||
|
|
||||||
## 3. Критично исправить до пилота
|
Критичных блокеров в коде, сборке и smoke-проверке для контролируемого пилота
|
||||||
|
на текущем срезе не выявлено.
|
||||||
|
|
||||||
Критичных технических блокеров для контролируемого демонстрационного пилота на
|
Критичные условия перед фактическим показом:
|
||||||
текущем контуре не осталось.
|
|
||||||
|
|
||||||
Единственное обязательное условие перед показом: не начинать демонстрацию без
|
- Демо должно запускаться на контуре, где развернут именно commit
|
||||||
короткого преддемо-прогона портала на том же экране и сети. Это не доработка
|
`9c57d6d2ce7eae1b133c937f037b24933f5797fe` или более новый проверенный срез.
|
||||||
продукта, а операционная страховка от внешних факторов: сеть, браузер, доступ к
|
- Перед показом нужен короткий преддемо-прогон на той же сети и экране:
|
||||||
стенду, свежесть данных.
|
открыть портал, дождаться `Данные готовы`, проверить кандидата, открыть
|
||||||
|
расследование и сформировать отчет.
|
||||||
|
- Нельзя продавать текущий срез как промышленно завершенную платформу без
|
||||||
|
оговорок по доступу, retention, backup, versioning и регламенту эксплуатации.
|
||||||
|
|
||||||
## 4. Можно перенести после пилота
|
## Что желательно исправить
|
||||||
|
|
||||||
- Полное hardening JSON/JSONL-хранилищ: блокировки, atomic write, ротация,
|
- Русифицировать заголовки customer-facing документов: текущие документы
|
||||||
retention, backup/restore и нагрузочная проверка больших state-файлов.
|
используют названия вроде `Customer Pilot Pack` и `Sales Positioning`, что
|
||||||
- Формальную матрицу версий API и журнал совместимых и несовместимых изменений
|
слабее выглядит на показе руководителю и заказчику.
|
||||||
полей.
|
- Отдельно перед демо проверить экспорт в формате, который будет показан:
|
||||||
- Расширенные нагрузочные тесты и sizing под промышленную эксплуатацию.
|
текстовый отчет, печать или PDF.
|
||||||
- Полировку коммерческого пакета после первой обратной связи заказчика.
|
- Добавить в регулярный smoke мобильный/визуальный прогон. Текущий
|
||||||
- Расширенную автоматизацию мобильных скриншотов и визуальных regression tests.
|
`detmir-portal-tabs-smoke.mjs` хорошо проверяет функциональный путь, но не
|
||||||
- Расширенную регламентацию долгосрочного доступа к материалам расследований и
|
является полноценной визуальной regression-проверкой.
|
||||||
архивам.
|
- Сформировать явную матрицу совместимости API: agent, portal, telemetry,
|
||||||
|
ClickHouse security events и investigation state.
|
||||||
|
- Убрать из публичного customer-facing пакета внутренние формулировки про
|
||||||
|
`<LOCAL_CARGO_TARGET_DIR>`, private contour и служебные runtime-детали.
|
||||||
|
|
||||||
## 5. Общая оценка готовности
|
## Что перенести после пилота
|
||||||
|
|
||||||
AWatch-rus готов к контролируемому демонстрационному пилоту на текущем DetMir
|
- Полное hardening локальных JSON/JSONL/state-хранилищ: atomic write,
|
||||||
контуре. Архитектура сохранена, обратная совместимость не нарушена, новые
|
блокировки, retention, backup/restore и нагрузочные тесты больших файлов.
|
||||||
интеграции и сущности не добавлялись.
|
- Промышленный app-layer RBAC для портала. Сейчас ролевые представления есть в
|
||||||
|
UX, но контроль доступа должен оставаться задачей защищенного контура или
|
||||||
|
reverse proxy до отдельного product hardening.
|
||||||
|
- Полное удаление оставшихся PowerShell deploy/bootstrap/fallback-элементов.
|
||||||
|
Для пилота важно отсутствие PowerShell как основного runtime-сборщика; полная
|
||||||
|
зачистка deploy-истории не является блокером показа.
|
||||||
|
- Load/sizing-профиль под промышленную эксплуатацию: количество рабочих мест,
|
||||||
|
объем событий, глубина хранения, требования к ClickHouse и файловому evidence.
|
||||||
|
- Формальные регламенты хранения и доступа к материалам расследований.
|
||||||
|
- CI-упаковку customer-facing документов с проверкой путей, ссылок и русских
|
||||||
|
заголовков.
|
||||||
|
|
||||||
Практическая оценка готовности: 90% для демонстрационного пилота. Оставшиеся
|
## Риски пилота
|
||||||
10% относятся не к блокерам кода, а к дисциплине показа: прогрев, заранее
|
|
||||||
открытый сценарий, проверенный экспорт и вычитанный customer-facing пакет.
|
|
||||||
|
|
||||||
## 6. Оценка Executive View
|
| Риск | Влияние | Оценка |
|
||||||
|
|
||||||
- Главный вывод расположен первым и подтвержден smoke-проверкой порядка блоков.
|
|
||||||
- Представление понятно руководителю без знаний ИБ: риск описывается через
|
|
||||||
подразделение, причину, подтверждающие слои и рекомендуемое действие.
|
|
||||||
- Англоязычные и внутренние технические обозначения в пользовательском слое
|
|
||||||
smoke не обнаружил.
|
|
||||||
- Итог: Executive View можно показывать руководителю.
|
|
||||||
|
|
||||||
## 7. Оценка Security View
|
|
||||||
|
|
||||||
- Есть очередь кандидатов на проверку, аудит, материалы расследования и пакет
|
|
||||||
расследования.
|
|
||||||
- Ручное подтверждение сохранено: система не создает инциденты автоматически
|
|
||||||
без решения ответственного.
|
|
||||||
- Переход "кандидат -> расследование" проверен smoke: найдено 3 кнопки перехода.
|
|
||||||
- Итог: Security View можно показывать ИБ как пилотный процесс ручной проверки.
|
|
||||||
|
|
||||||
## 8. Оценка Operations View
|
|
||||||
|
|
||||||
- Operations View показывает полноту данных, качество данных, ошибки сбора,
|
|
||||||
состояние источников и режим событий безопасности.
|
|
||||||
- ClickHouse-события доступны через портал: `backend=clickhouse`, `status=ok`,
|
|
||||||
`fallback_used=false`.
|
|
||||||
- `/api/health` зеленый на рабочем контуре.
|
|
||||||
- Фоновое обновление не ломает готовое состояние экрана.
|
|
||||||
- Итог: Operations View можно показывать эксплуатации.
|
|
||||||
|
|
||||||
## 9. Риски демонстрации
|
|
||||||
|
|
||||||
| Риск | Влияние | Статус |
|
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Открыть портал без прогрева или при нестабильной сети | Может выглядеть как долгая загрузка | Управляемо: service prewarm и преддемо-прогон |
|
| Контур демо не совпадает с проверенным commit | Возможны расхождения UI, API или данных | Критично контролировать перед показом |
|
||||||
| Не проверить экспорт отчета заранее | Можно потерять время на живом показе | Желательно закрыть до показа |
|
| Документы или ссылки изменятся без проверки | Путает hand-off и приемку пакета | Контролировать markdown link-check перед передачей |
|
||||||
| Отдать внутреннюю документацию без вычитки | Лишние технические вопросы руководителя и ИБ | Желательно закрыть до показа |
|
| Первый запуск портала без прогрева | Может выглядеть как зависшая загрузка | Управляемо преддемо-прогоном |
|
||||||
| Данные изменятся перед демо и кандидатов станет меньше | Сценарий расследования может ослабнуть | Управляемо: проверить `/api/reports` перед показом |
|
| В текущих данных исчезнут кандидаты на расследование | 10-минутный сценарий потеряет самый сильный пример | Проверять `/api/reports` перед демо |
|
||||||
| Внешние источники недоступны из-за сети | Потеря части операционной картины | Управляемо: преддемо health-check |
|
| ClickHouse станет недоступен | Operations View покажет fallback/ошибку вместо полного ИБ-среза | Не блокер UI, но слабее для ИБ |
|
||||||
|
| Нет промышленного RBAC внутри приложения | Нельзя открывать портал вне защищенного контура | Для контролируемого пилота приемлемо |
|
||||||
|
| Остаточные PowerShell deploy/fallback артефакты | Могут вызвать вопросы эксплуатации о миграции | Объяснять как legacy/fallback, не runtime-core |
|
||||||
|
| Нет полной визуальной mobile regression-проверки в обязательном smoke | Возможны мелкие UX-дефекты на нестандартном экране | Проверить вручную перед показом с планшета/ноутбука |
|
||||||
|
|
||||||
10-минутный сценарий сейчас подтвержден: главный риск, подразделение, причина
|
Дополнительное наблюдение: один первичный запуск smoke через только что
|
||||||
риска, кандидат на проверку, расследование, пакет расследования и итоговый
|
созданный туннель один раз не дождался `READY` за 30 секунд. Повтор с тем же
|
||||||
управленческий вывод доступны в портале.
|
30-секундным таймаутом прошел примерно за 8 секунд, `/portal/api/reports`
|
||||||
|
отвечал примерно за 0.4 секунды. Это не выглядит как продуктовый блокер, но
|
||||||
|
подтверждает необходимость преддемо-прогона.
|
||||||
|
|
||||||
## 10. Итоговая рекомендация
|
## Итоговая оценка готовности
|
||||||
|
|
||||||
Допустить AWatch-rus к контролируемому демонстрационному пилоту.
|
Статус: AWatch-rus готов к контролируемому демонстрационному пилоту на текущем
|
||||||
|
`origin/main`.
|
||||||
|
|
||||||
Перед показом выполнить короткий операторский прогон: открыть портал, убедиться
|
Оценка готовности: 88%.
|
||||||
в `Данные готовы`, проверить наличие кандидатов, открыть расследование, скачать
|
|
||||||
пакет расследования и открыть итоговый отчет. Это достаточно для уверенного
|
Обоснование:
|
||||||
показа руководителю, ИБ и эксплуатации.
|
|
||||||
|
- Сборка, тесты, clippy, release build и portal smoke на проверенном срезе
|
||||||
|
успешны.
|
||||||
|
- Executive/Security/Operations View закрывают основной сценарий для
|
||||||
|
руководителя, ИБ и эксплуатации.
|
||||||
|
- Rust runtime migration убрал главный риск зависимости от PowerShell в
|
||||||
|
рабочих сборщиках.
|
||||||
|
- ClickHouse-события безопасности доступны и отражаются в портале.
|
||||||
|
- Оставшиеся разрывы относятся в основном к вычитке документов, преддемо
|
||||||
|
дисциплине, промышленному hardening и формальному управлению доступом.
|
||||||
|
|
||||||
|
Рекомендация: допустить к пилоту только как контролируемый демонстрационный
|
||||||
|
контур. Перед показом выполнить короткий smoke на рабочем URL, проверить
|
||||||
|
наличие кандидата на расследование, открыть пакет расследования и заранее
|
||||||
|
подготовить итоговый отчет.
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# AWatch-rus Pilot v1.0
|
||||||
|
|
||||||
|
## Позиционирование
|
||||||
|
|
||||||
|
AWatch-rus Pilot v1.0 показывается заказчику как связка:
|
||||||
|
|
||||||
|
- Workforce Analytics - активность, загрузка, сравнение подразделений и отчетность.
|
||||||
|
- Security Analytics - кандидаты на проверку, события безопасности и объяснимый риск.
|
||||||
|
- Forensics - карточки расследований, timeline и выгрузка материалов.
|
||||||
|
|
||||||
|
Это не SIEM, не классический DLP, не EDR/XDR и не сертифицированная СЗИ.
|
||||||
|
Сетевой периметр pfSense является опциональным интеграционным слоем, а не
|
||||||
|
обязательной частью пилота.
|
||||||
|
|
||||||
|
Основной интерфейс Pilot v1.0: Rust server-rendered HTML + JSON API. Dioxus не
|
||||||
|
используется и не рассматривается для этого контура. React, Tauri и Electron
|
||||||
|
также не входят в текущий пилотный UI.
|
||||||
|
|
||||||
|
## Готовые контуры
|
||||||
|
|
||||||
|
- Executive Dashboard: главный вывод, общий статус, риски подразделений и
|
||||||
|
краткий управленческий срез.
|
||||||
|
- Workforce Portal: индекс активности, подразделения, ответственные, тренды,
|
||||||
|
признаки перегруза и недогруза, Markdown-отчет.
|
||||||
|
- Security Portal: кандидаты на проверку, risk score, события безопасности,
|
||||||
|
аномалии и аудит ручных решений.
|
||||||
|
- Forensics Portal: карточки расследований, timeline, пакет материалов и
|
||||||
|
Markdown export.
|
||||||
|
- UEBA Score v1: прозрачная rule-based модель без ML, LLM и внешних SaaS.
|
||||||
|
- pfSense readiness: contracts, demo fixtures, docs и API-заготовка без
|
||||||
|
заявления о включенном ingestion.
|
||||||
|
|
||||||
|
## API v1
|
||||||
|
|
||||||
|
Ролевые endpoint-ы:
|
||||||
|
|
||||||
|
- `/api/executive`
|
||||||
|
- `/api/workforce`
|
||||||
|
- `/api/security`
|
||||||
|
- `/api/forensics`
|
||||||
|
- `/api/ueba`
|
||||||
|
- `/api/pfsense`
|
||||||
|
- `/api/reports`
|
||||||
|
|
||||||
|
Все endpoint-ы возвращают стабильные JSON-структуры. Клиент должен игнорировать
|
||||||
|
неизвестные поля и не должен трактовать отсутствие опционального поля как ошибку.
|
||||||
|
|
||||||
|
## Роли
|
||||||
|
|
||||||
|
Роли описаны в [ROLES_RU.md](ROLES_RU.md). Проверка доступа выполняется на
|
||||||
|
сервере, а не только скрытием кнопок в HTML.
|
||||||
|
|
||||||
|
## Demo data
|
||||||
|
|
||||||
|
Демонстрационные данные не должны содержать реальные IP-адреса, hostname,
|
||||||
|
логины, ФИО, подразделения заказчика или реальные события безопасности. Для
|
||||||
|
сетевых примеров используются адреса из RFC 5737: `192.0.2.0/24`,
|
||||||
|
`198.51.100.0/24`, `203.0.113.0/24`.
|
||||||
|
|
||||||
|
## Проверки перед показом
|
||||||
|
|
||||||
|
- `cargo fmt --all --check`
|
||||||
|
- `cargo clippy --workspace --all-targets -- -D warnings`
|
||||||
|
- `cargo test --workspace`
|
||||||
|
- `cargo build --workspace --release`
|
||||||
|
- `git diff --check`
|
||||||
|
- `node scripts/detmir-portal-tabs-smoke.mjs`
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Ролевая модель портала
|
||||||
|
|
||||||
|
## Принцип
|
||||||
|
|
||||||
|
Портал разделяет управленческий, ИБ, расследовательский и административный
|
||||||
|
контуры. Ограничение доступа выполняется сервером через роль в `X-AWatch-Role`
|
||||||
|
или query-параметре `role`. HTML-кнопки являются только удобством интерфейса.
|
||||||
|
|
||||||
|
## Роли
|
||||||
|
|
||||||
|
| Роль | Что видит | Что скрыто по умолчанию |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `executive` | Executive Dashboard, Workforce summary, главный вывод, риски подразделений | ИБ-детализация, расследования, технические настройки |
|
||||||
|
| `manager` | Workforce Portal, сравнение подразделений и ответственных, тренды, Markdown-отчет | ИБ-очередь, материалы расследования, админский health |
|
||||||
|
| `security` | Security Portal, кандидаты на проверку, risk score, события безопасности, аудит | Управленческий Workforce Dashboard |
|
||||||
|
| `forensics` | Карточки расследований, timeline, материалы, Markdown export | Управленческие Workforce-разрезы |
|
||||||
|
| `admin` | Эксплуатационный статус, настройки, качество данных, ClickHouse/fallback-состояния | Не используется как управленческая или ИБ-роль |
|
||||||
|
|
||||||
|
## Серверные правила
|
||||||
|
|
||||||
|
- `executive` и `manager` имеют доступ к Workforce/Executive срезам.
|
||||||
|
- `security` имеет доступ к Security, UEBA и incident queue.
|
||||||
|
- `forensics` имеет доступ к расследованиям, timeline и материалам.
|
||||||
|
- `admin` имеет доступ к эксплуатационным и контрактным срезам.
|
||||||
|
- Руководитель не получает ИБ-детализацию по умолчанию.
|
||||||
|
- Безопасник не получает управленческий Workforce Dashboard по умолчанию.
|
||||||
|
|
||||||
|
## Совместимость
|
||||||
|
|
||||||
|
Старые alias-ы роли `operations` мапятся на `admin`, чтобы не ломать внешние
|
||||||
|
ссылки и smoke-сценарии предыдущих сборок.
|
||||||
@@ -327,8 +327,8 @@ AWatch-rus / DetMir в текущем позиционировании:
|
|||||||
## Связанные документы
|
## Связанные документы
|
||||||
|
|
||||||
- [Customer Pilot Pack](CUSTOMER_PILOT_PACK_RU.md)
|
- [Customer Pilot Pack](CUSTOMER_PILOT_PACK_RU.md)
|
||||||
- [Описание продукта](PRODUCT_DESCRIPTION_RU.md)
|
- [Описание продукта](../PRODUCT_DESCRIPTION_RU.md)
|
||||||
- [Аудит готовности к пилоту](PILOT_READINESS_AUDIT_RU.md)
|
- [Аудит готовности к пилоту](PILOT_READINESS_AUDIT_RU.md)
|
||||||
- [Портал AWatch-rus](docs/PORTAL_RU.md)
|
- [Портал AWatch-rus](PORTAL_RU.md)
|
||||||
- [Business Risk](docs/BUSINESS_RISK_RU.md)
|
- [Business Risk](BUSINESS_RISK_RU.md)
|
||||||
- [Модель безопасности](docs/SECURITY_MODEL_RU.md)
|
- [Модель безопасности](SECURITY_MODEL_RU.md)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# UEBA Score v1
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
UEBA Score v1 нужен для приоритизации ручной проверки. Он не блокирует
|
||||||
|
пользователей, не меняет сетевые правила и не принимает автоматические решения.
|
||||||
|
|
||||||
|
Это rule-based модель без ML, LLM и внешних SaaS-зависимостей.
|
||||||
|
|
||||||
|
## Формула
|
||||||
|
|
||||||
|
```text
|
||||||
|
Risk Score =
|
||||||
|
activity anomaly
|
||||||
|
+ time anomaly
|
||||||
|
+ application anomaly
|
||||||
|
+ network anomaly
|
||||||
|
+ history anomaly
|
||||||
|
```
|
||||||
|
|
||||||
|
Компоненты возвращаются в `/api/ueba` как `score_components`:
|
||||||
|
|
||||||
|
- `activity_anomaly` - просадка/аномалия активности, проблемы Worktime.
|
||||||
|
- `time_anomaly` - ночная активность, работа вне согласованного окна, выходные.
|
||||||
|
- `application_anomaly` - DLP-lite сигналы и приложения без явного правила.
|
||||||
|
- `network_anomaly` - сетевой контекст, если он доступен через интеграции.
|
||||||
|
- `history_anomaly` - открытая очередь проверки и отклонение от baseline.
|
||||||
|
|
||||||
|
## Уровни
|
||||||
|
|
||||||
|
| Score | Severity | Статус |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0-14 | `normal` | `OK` |
|
||||||
|
| 15-39 | `low` | `WARN` |
|
||||||
|
| 40-69 | `medium` | `WARN` |
|
||||||
|
| 70-84 | `high` | `FAIL` |
|
||||||
|
| 85-100 | `critical` | `FAIL` |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
`GET /api/ueba` возвращает:
|
||||||
|
|
||||||
|
- `score` - число 0-100;
|
||||||
|
- `severity` - `normal`, `low`, `medium`, `high` или `critical`;
|
||||||
|
- `score_components` - пять компонент формулы;
|
||||||
|
- `reason_codes` - коды сработавших правил;
|
||||||
|
- `explanation` - человекочитаемое объяснение;
|
||||||
|
- `model.ml_used=false`;
|
||||||
|
- `model.llm_used=false`.
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
UEBA v1 не является SIEM-корреляцией и не является классическим DLP. Это
|
||||||
|
объяснимый слой ранжирования риска для Workforce Analytics + Security Analytics
|
||||||
|
+ Forensics.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"contract_version": "pfsense-readiness-v1",
|
||||||
|
"status": "contract_only",
|
||||||
|
"siem": false,
|
||||||
|
"ingestion_available": false,
|
||||||
|
"firewall_events": [
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T08:00:00Z",
|
||||||
|
"source_host": "demo-host-01",
|
||||||
|
"destination": "203.0.113.10",
|
||||||
|
"action": "pass",
|
||||||
|
"protocol": "tcp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T08:05:00Z",
|
||||||
|
"source_host": "demo-host-02",
|
||||||
|
"destination": "198.51.100.25",
|
||||||
|
"action": "block",
|
||||||
|
"protocol": "udp"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vpn_events": [
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T08:10:00Z",
|
||||||
|
"source_host": "198.51.100.44",
|
||||||
|
"user_ref": "demo-user-a",
|
||||||
|
"action": "login",
|
||||||
|
"tunnel": "demo-vpn"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"traffic_summary": {
|
||||||
|
"window": "24h",
|
||||||
|
"events": 3,
|
||||||
|
"top_action": "pass"
|
||||||
|
},
|
||||||
|
"top_destinations": [
|
||||||
|
{
|
||||||
|
"destination": "203.0.113.10",
|
||||||
|
"events": 12,
|
||||||
|
"bytes": 245760
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
@@ -171,7 +171,10 @@ async function main() {
|
|||||||
});
|
});
|
||||||
smokeStep = "api:security_events_summary";
|
smokeStep = "api:security_events_summary";
|
||||||
const reportsPayload = await page.evaluate(async () => {
|
const reportsPayload = await page.evaluate(async () => {
|
||||||
const response = await fetch("api/reports", { cache: "no-store" });
|
const response = await fetch("api/reports?role=security", {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "X-AWatch-Role": "security" },
|
||||||
|
});
|
||||||
return { ok: response.ok, status: response.status, json: await response.json() };
|
return { ok: response.ok, status: response.status, json: await response.json() };
|
||||||
});
|
});
|
||||||
const securitySummary = reportsPayload.json?.security_events_summary || null;
|
const securitySummary = reportsPayload.json?.security_events_summary || null;
|
||||||
@@ -252,8 +255,10 @@ async function main() {
|
|||||||
name: "role_view_switcher_present",
|
name: "role_view_switcher_present",
|
||||||
ok:
|
ok:
|
||||||
(await page.locator('[data-view-mode="executive"]').count()) === 1
|
(await page.locator('[data-view-mode="executive"]').count()) === 1
|
||||||
|
&& (await page.locator('[data-view-mode="manager"]').count()) === 1
|
||||||
&& (await page.locator('[data-view-mode="security"]').count()) === 1
|
&& (await page.locator('[data-view-mode="security"]').count()) === 1
|
||||||
&& (await page.locator('[data-view-mode="operations"]').count()) === 1,
|
&& (await page.locator('[data-view-mode="forensics"]').count()) === 1
|
||||||
|
&& (await page.locator('[data-view-mode="admin"]').count()) === 1,
|
||||||
});
|
});
|
||||||
const expectedSecurityText = expectedSecurityEventsText(securityMode);
|
const expectedSecurityText = expectedSecurityEventsText(securityMode);
|
||||||
checks.push({
|
checks.push({
|
||||||
@@ -268,6 +273,28 @@ async function main() {
|
|||||||
expected_text: expectedSecurityText,
|
expected_text: expectedSecurityText,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
smokeStep = "role:manager";
|
||||||
|
await page.click('[data-view-mode="manager"]', { timeout });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelector("#loadingStatus")?.dataset.loadStatus === "READY"
|
||||||
|
&& document.body.innerText.includes("ТОП-5 лучших подразделений"),
|
||||||
|
null,
|
||||||
|
{ timeout },
|
||||||
|
);
|
||||||
|
const managerText = await page.locator("#content").innerText({ timeout });
|
||||||
|
checks.push({
|
||||||
|
name: "manager_role_view",
|
||||||
|
ok: [
|
||||||
|
"Представление менеджера",
|
||||||
|
"Сводка руководителя",
|
||||||
|
"ТОП-5 лучших подразделений",
|
||||||
|
"ТОП-5 проблемных подразделений",
|
||||||
|
"Карта рисков",
|
||||||
|
"Markdown-отчет",
|
||||||
|
].every((marker) => containsText(managerText, marker))
|
||||||
|
&& !containsText(managerText, "Материалы расследования"),
|
||||||
|
});
|
||||||
|
|
||||||
smokeStep = "role:security";
|
smokeStep = "role:security";
|
||||||
await page.click('[data-view-mode="security"]', { timeout });
|
await page.click('[data-view-mode="security"]', { timeout });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
@@ -304,8 +331,30 @@ async function main() {
|
|||||||
expected_text: expectedSecurityText,
|
expected_text: expectedSecurityText,
|
||||||
});
|
});
|
||||||
|
|
||||||
smokeStep = "role:operations";
|
smokeStep = "role:forensics";
|
||||||
await page.click('[data-view-mode="operations"]', { timeout });
|
await page.click('[data-view-mode="forensics"]', { timeout });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelector("#loadingStatus")?.dataset.loadStatus === "READY"
|
||||||
|
&& document.body.innerText.includes("Timeline событий"),
|
||||||
|
null,
|
||||||
|
{ timeout },
|
||||||
|
);
|
||||||
|
const forensicsText = await page.locator("#content").innerText({ timeout });
|
||||||
|
checks.push({
|
||||||
|
name: "forensics_role_view",
|
||||||
|
ok: [
|
||||||
|
"Представление расследований",
|
||||||
|
"Требует проверки",
|
||||||
|
"Расследования",
|
||||||
|
"Timeline событий",
|
||||||
|
"Материалы расследования",
|
||||||
|
"Аудит",
|
||||||
|
].every((marker) => containsText(forensicsText, marker))
|
||||||
|
&& !containsText(forensicsText, "Рейтинг подразделений"),
|
||||||
|
});
|
||||||
|
|
||||||
|
smokeStep = "role:admin";
|
||||||
|
await page.click('[data-view-mode="admin"]', { timeout });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => document.querySelector("#loadingStatus")?.dataset.loadStatus === "READY"
|
() => document.querySelector("#loadingStatus")?.dataset.loadStatus === "READY"
|
||||||
&& document.body.innerText.includes("Телеметрия"),
|
&& document.body.innerText.includes("Телеметрия"),
|
||||||
@@ -314,9 +363,9 @@ async function main() {
|
|||||||
);
|
);
|
||||||
const operationsText = await page.locator("#content").innerText({ timeout });
|
const operationsText = await page.locator("#content").innerText({ timeout });
|
||||||
checks.push({
|
checks.push({
|
||||||
name: "operations_role_view",
|
name: "admin_role_view",
|
||||||
ok: [
|
ok: [
|
||||||
"Представление эксплуатации",
|
"Представление администратора",
|
||||||
"Полнота данных",
|
"Полнота данных",
|
||||||
"Качество данных",
|
"Качество данных",
|
||||||
"События безопасности за 24 часа",
|
"События безопасности за 24 часа",
|
||||||
@@ -334,6 +383,37 @@ async function main() {
|
|||||||
mode: securityMode,
|
mode: securityMode,
|
||||||
expected_text: expectedSecurityText,
|
expected_text: expectedSecurityText,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
smokeStep = "api:role_gates";
|
||||||
|
const apiUrl = (path) => new URL(path, page.url()).toString();
|
||||||
|
const [managerSecurity, securityWorkforce, forensicsOk, uebaOk] = await Promise.all([
|
||||||
|
context.request.get(apiUrl("api/security"), {
|
||||||
|
headers: { ...authHeaders(), "X-AWatch-Role": "manager" },
|
||||||
|
}),
|
||||||
|
context.request.get(apiUrl("api/workforce"), {
|
||||||
|
headers: { ...authHeaders(), "X-AWatch-Role": "security" },
|
||||||
|
}),
|
||||||
|
context.request.get(apiUrl("api/forensics"), {
|
||||||
|
headers: { ...authHeaders(), "X-AWatch-Role": "forensics" },
|
||||||
|
}),
|
||||||
|
context.request.get(apiUrl("api/ueba"), {
|
||||||
|
headers: { ...authHeaders(), "X-AWatch-Role": "security" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
checks.push({
|
||||||
|
name: "server_role_gates",
|
||||||
|
ok:
|
||||||
|
managerSecurity.status() === 403
|
||||||
|
&& securityWorkforce.status() === 403
|
||||||
|
&& forensicsOk.ok()
|
||||||
|
&& uebaOk.ok(),
|
||||||
|
statuses: {
|
||||||
|
manager_security: managerSecurity.status(),
|
||||||
|
security_workforce: securityWorkforce.status(),
|
||||||
|
forensics: forensicsOk.status(),
|
||||||
|
ueba: uebaOk.status(),
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (item.tab === "settings") {
|
if (item.tab === "settings") {
|
||||||
const requiredSettings = [
|
const requiredSettings = [
|
||||||
|
|||||||
Reference in New Issue
Block a user