diff --git a/README.md b/README.md index 924e7a9..7030dcb 100755 --- a/README.md +++ b/README.md @@ -32,8 +32,10 @@ Python в репозитории остается для вспомогател runtime, OCR/content-analysis, 1C/AI/ETL integration и MCP/dev helpers. Эти части не являются ядром Rust-first runtime. -Портальный слой зафиксирован как Rust server-rendered HTML + HTMX с JSON API, -OpenAPI и TypeScript declarations для будущих React/Tauri-клиентов. +Портальный слой зафиксирован как Rust server-rendered HTML + HTMX-compatible +JSON API, OpenAPI и TypeScript declarations. Dioxus не используется и не +рассматривается для Pilot v1.0. React, Tauri и Electron также не входят в +текущий основной UI. ## Что видит оператор @@ -50,6 +52,53 @@ OpenAPI и TypeScript declarations для будущих React/Tauri-клиен - ИБ - заметить DLP-сигналы и подозрительную активность. - Администратору - проверить, что сборщики и сервер работают стабильно. +## Интерфейс + +Скриншоты ниже подготовлены на демонстрационных данных: без реальных IP-адресов, +hostname, логинов, сотрудников, подразделений заказчика и событий безопасности. + +### Главный вывод + +![Главный вывод AWatch-rus](docs/screenshots/01-executive-overview.png) + +Руководитель видит главный риск первым, затем сводку по достоверности +показателей, полноте данных, кандидатам на проверку и рискам подразделений. + +### Карта рисков подразделений + +![Карта рисков подразделений](docs/screenshots/02-risk-heatmap.png) + +Карта рисков показывает, где одновременно проседают активность, покрытие +агентов, доверие к показателям и количество ситуаций для проверки. + +### Представление безопасности + +![Представление безопасности](docs/screenshots/03-security-view.png) + +ИБ получает очередь кандидатов на проверку, связанные расследования и материалы +без просмотра сырых логов и без автоматического принятия решений. + +### Представление эксплуатации + +![Представление эксплуатации](docs/screenshots/04-operations-view.png) + +Эксплуатация видит полноту данных, качество агентского сбора, ошибки сбора и +понятный статус событий безопасности через ClickHouse. + +### Пакет расследования + +![Пакет расследования](docs/screenshots/05-investigation-pack.png) + +Пакет расследования связывает материалы, историю проверки и итоговый вывод, +который ответственный сотрудник может подтвердить вручную. + +### Итоговый отчет + +![Итоговый отчет](docs/screenshots/06-markdown-report.png) + +Markdown-отчет собирает главный вывод, риски подразделений, материалы +расследований и рекомендации в формате, удобном для передачи руководителю. + ## Если дашборд пустой Обычно это значит одно из трех: выбран слишком узкий период времени, рабочий компьютер давно не присылал события или временно не обновилась витрина в Grafana. Начните с периода `Last 24 hours`, затем переходите к техническим разделам ниже. @@ -66,6 +115,15 @@ OpenAPI и TypeScript declarations для будущих React/Tauri-клиен - [Эксплуатационный профиль](docs/OPERATIONAL_PROOF_PROFILE_RU.md) - [Коммерческие модули DetMir](docs/DETMIR_COMMERCIAL_MODULES_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_LICENSES_RU.md) - [Архитектура](docs/ARCHITECTURE_RU.md) diff --git a/adk-rust/crates/detmir-portal/src/contracts/openapi.json b/adk-rust/crates/detmir-portal/src/contracts/openapi.json index 9a5ad89..f873059 100644 --- a/adk-rust/crates/detmir-portal/src/contracts/openapi.json +++ b/adk-rust/crates/detmir-portal/src/contracts/openapi.json @@ -2,8 +2,8 @@ "openapi": "3.1.0", "info": { "title": "AWatch-rus DetMir Portal API", - "version": "2026-06-05.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." + "version": "2026-06-06.pilot-v1", + "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": [ { @@ -21,6 +21,24 @@ { "name": "reports" }, + { + "name": "roles" + }, + { + "name": "workforce" + }, + { + "name": "security" + }, + { + "name": "forensics" + }, + { + "name": "ueba" + }, + { + "name": "pfsense" + }, { "name": "incidents" }, @@ -191,6 +209,15 @@ "type": "boolean" }, "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": { @@ -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": { "get": { "tags": [ @@ -605,6 +770,45 @@ }, "components": { "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": { "type": "object", "additionalProperties": true @@ -713,6 +917,9 @@ "$ref": "#/components/schemas/JsonObject" } }, + "role_context": { + "$ref": "#/components/schemas/RoleContext" + }, "cases": { "type": "array", "items": { @@ -722,6 +929,156 @@ }, "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": { "type": "object", "required": [ diff --git a/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts b/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts index d9f36e4..bed5154 100644 --- a/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts +++ b/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts @@ -1,5 +1,6 @@ export type ISODateTime = string; +export type PortalRole = "executive" | "manager" | "security" | "forensics" | "admin"; export type RiskLevel = "LOW" | "MEDIUM" | "HIGH" | "CRITICAL" | "UNKNOWN"; export type ReviewStatus = | "NEW" @@ -36,6 +37,15 @@ export interface ContractIndex { [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 { trust_kpi_score?: number; agent_coverage_pct?: number; @@ -154,6 +164,7 @@ export interface CaseStatusRequest { export interface ReportsResponse { ok: boolean; + role_context?: RoleContext; generated_at_utc?: ISODateTime; executive_points?: string[]; executive_dashboard?: ExecutiveDashboard; @@ -166,6 +177,59 @@ export interface ReportsResponse { [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 { ok: boolean; cases: CaseItem[]; @@ -178,7 +242,13 @@ export interface DetMirPortalApi { getOperator(): Promise; getManager(): Promise; getOwner(): Promise; - getReports(options?: { anonymize?: boolean }): Promise; + getReports(options?: { anonymize?: boolean; role?: PortalRole }): Promise; + getExecutive(options?: { role?: PortalRole }): Promise; + getWorkforce(options?: { role?: PortalRole }): Promise; + getSecurity(options?: { role?: PortalRole }): Promise; + getForensics(options?: { role?: PortalRole }): Promise; + getUeba(options?: { role?: PortalRole }): Promise; + getPfsense(options?: { role?: PortalRole }): Promise; getIncidents(): Promise; getCases(): Promise; createCase(request: CreateCaseRequest): Promise; diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index 640ff2e..674e1fd 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -43,6 +43,76 @@ unsafe extern "C" { type SnapshotCache = Arc>>; +#[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 { + 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)] struct CachedSnapshot { created: Instant, @@ -1248,17 +1318,30 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) let url = request.url().to_string(); let path = normalize_path(&url); let anonymize = query_flag(&url, "anonymize"); + let role = portal_role_from_request(&request, &url); 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); } 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); } 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); } if method == Method::Post { 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); } } @@ -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"); } 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)); } 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); } 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); } 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)) } "/api/manager" => { + if !role.can_access("workforce") { + return respond_forbidden(request, role, "workforce"); + } let snapshot = cached_snapshot(args, snapshot_cache); respond_json(request, &build_manager(&snapshot)) } "/api/workforce/policy/explain" => { + if !role.can_access("workforce") { + return respond_forbidden(request, role, "workforce"); + } let snapshot = cached_snapshot(args, snapshot_cache); respond_json( request, @@ -1333,41 +1431,71 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) ) } "/api/owner" => { + if !role.can_access("security") { + return respond_forbidden(request, role, "security"); + } let snapshot = cached_snapshot(args, snapshot_cache); respond_json(request, &build_owner(&snapshot)) } "/api/reports" => { - 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); - respond_json( - request, - &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, - ), - ) + let report = build_report_payload(args, snapshot_cache, anonymize); + respond_json(request, &role_filtered_report(report, role)) + } + "/api/executive" => { + if !role.can_access("executive") { + return respond_forbidden(request, role, "executive"); + } + let report = build_report_payload(args, snapshot_cache, anonymize); + respond_json(request, &build_role_api_payload(report, role, "executive")) + } + "/api/workforce" => { + if !role.can_access("workforce") { + return respond_forbidden(request, role, "workforce"); + } + let report = build_report_payload(args, snapshot_cache, anonymize); + respond_json(request, &build_role_api_payload(report, role, "workforce")) + } + "/api/security" => { + if !role.can_access("security") { + return respond_forbidden(request, role, "security"); + } + 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" => { + if !role.can_access("incidents") { + return respond_forbidden(request, role, "incidents"); + } let snapshot = cached_snapshot(args, snapshot_cache); let incident_state = load_incident_state_best_effort(args); 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()), _ => respond_text( request, @@ -1436,16 +1564,17 @@ fn normalize_path(url: &str) -> String { fn api_contract_summary() -> Value { json!({ "ok": true, - "contract_version": "2026-06-05.v1", + "contract_version": "2026-06-06.pilot-v1", "generated_by": "detmir-portal", "api_base": "/api", "compatibility": { "policy": "additive", - "existing_html_portal": "unchanged", + "main_ui": "rust-server-rendered-html-htmx-compatible", "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": { "openapi": "/api/contracts/openapi.json", "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/operator", "purpose": "portal overview data"}, {"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/cases", "purpose": "case list"}, {"method": "POST", "path": "/api/incident-review", "purpose": "manual candidate review status"}, @@ -1602,6 +1737,45 @@ fn query_param(url: &str, key: &str) -> Option { }) } +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 { path.strip_prefix("/api/investigation-pack/") .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, + target: &mut serde_json::Map, + 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::>(), + ) + }) + }).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::>(); + 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 { + 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 { snapshot .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( metrics, workforce_policy, @@ -5789,16 +6343,17 @@ fn build_ueba_risk( policy_configured, 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!({ "score": score, "level": level, + "severity": level, "status": status, - "summary": format!("{} risk, {} reason(s)", level, reasons.len()), - "formula": format!("sum(reason_points) capped at {}", policy.score_cap.max(1)), + "summary": format!("Уровень риска: {level}; факторов: {}", reasons.len()), + "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, "risk_sources": risk_sources, "baseline_status": ueba_baseline @@ -5842,8 +6397,119 @@ fn push_risk_reason( })); } +fn ueba_reason_codes(reasons: &[Value]) -> Vec { + 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 { + 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::>(); + 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::(); + 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::>(); + let mut assigned = scaled.iter().map(|(_, base, _)| *base).sum::(); + 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::>() +} + +fn ueba_human_explanation( + level: &str, + components: &BTreeMap, + 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) { - if score >= 70 { + if score >= 85 { + ("critical", "FAIL") + } else if score >= 70 { ("high", "FAIL") } else if score >= 40 { ("medium", "WARN") @@ -9323,6 +9989,12 @@ mod tests { for path in [ "/contracts", "/reports", + "/executive", + "/workforce", + "/security", + "/forensics", + "/ueba", + "/pfsense", "/incidents", "/cases", "/readiness/latest", @@ -9336,6 +10008,9 @@ mod tests { for required_type in [ "ContractIndex", "ReportsResponse", + "RoleContext", + "UebaResponse", + "PfsenseReadinessResponse", "CaseListResponse", "IncidentReviewRequest", "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] fn links_are_gateway_relative() { let links = links(); diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index d68595e..3d7c959 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -25,6 +25,13 @@ const VIEW_MODES = { description: "Главный вывод, сводка руководителя, риски подразделений и карта рисков.", stage: "Формирование главного вывода", }, + manager: { + label: "Менеджер", + title: "Роль менеджера", + heading: "Представление менеджера", + description: "Workforce, сравнение подразделений, ответственные, тренды и отчет.", + stage: "Расчет Workforce и подразделений", + }, security: { label: "Безопасность", title: "Роль безопасности", @@ -32,6 +39,20 @@ const VIEW_MODES = { description: "Очередь проверки, расследования, аудит решений и пакеты расследований.", stage: "Подготовка разделов проверки и расследований", }, + forensics: { + label: "Расследования", + title: "Роль расследований", + heading: "Представление расследований", + description: "Карточки расследований, timeline, связка user / host / app / network event и экспорт.", + stage: "Подготовка timeline расследований", + }, + admin: { + label: "Администратор", + title: "Роль администратора", + heading: "Представление администратора", + description: "Настройки, качество данных, источники и эксплуатационные ошибки.", + stage: "Проверка настроек и источников", + }, operations: { label: "Эксплуатация", title: "Роль эксплуатации", @@ -44,7 +65,7 @@ const VIEW_MODES = { function initialViewMode() { try { 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 { return "executive"; } @@ -55,8 +76,27 @@ function apiBase() { 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) { - 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}`); return response.json(); } @@ -64,7 +104,7 @@ async function loadJson(path) { async function postJson(path, payload) { const response = await fetch(`${apiBase()}${path}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...roleHeaders() }, body: JSON.stringify(payload) }); if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`); @@ -1202,7 +1242,10 @@ function renderRoleViewSummary(meta) { } function renderOperatorRoleContent(mode, data, report, extras = {}) { + if (mode === "manager") return renderManagerView(report); 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); 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)} +
+

Markdown-отчет

экспорт управленческого среза Workforce
+
${ui((report?.markdown || "").slice(0, 2000))}
+
+ `; +} + +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) { return ` ${renderAgentCoverageSla(report?.agent_coverage_sla)} @@ -1344,7 +1411,7 @@ function renderInvestigationPacks(candidates) {
${ui(item.department || "Без подразделения")} ${ui(item.reason || "требуется проверка")} · ${ui(item.hostname || "-")} - Скачать + Скачать
`).join("") : `
@@ -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 ` +
+
+
+

Timeline событий

+

Связка user / host / app / network event для ручного расследования.

+
+ ${timelines.length} +
+
${timelines.length ? timelines.slice(0, 20).map(event => ` +
+ ${ui(event.timestamp || "-")} + ${ui(event.kind || "event")} · ${ui(event.entity || "-")} · ${ui(event.summary || "")} + ${ui(event.source || "portal")} +
+ `).join("") : ` +
+ Timeline пуст + Нет кандидатов для расследования в текущем срезе. + в норме +
+ `}
+
+ `; +} + function renderOverviewAnalytics(report) { const workforce = findSection(report, "Работа"); const insights = findSection(report, "Выводы по активности") || findSection(report, "Выводы Workforce"); @@ -1885,7 +1983,7 @@ function renderCases(cases) { function renderCaseActions(item) { 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 `
@@ -2652,7 +2750,7 @@ function renderCandidateReviewActions(item) { ["FALSE_POSITIVE", "Ложный"], ["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" ? `` : ""; @@ -2843,7 +2941,7 @@ async function loadCurrentTab() { const data = await loadJson("/operator"); state.operatorData = data; 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: [] })); } updateFilters(state.reports); diff --git a/adk-rust/crates/detmir-portal/src/static/index.html b/adk-rust/crates/detmir-portal/src/static/index.html index 7510ddb..c7e68bd 100644 --- a/adk-rust/crates/detmir-portal/src/static/index.html +++ b/adk-rust/crates/detmir-portal/src/static/index.html @@ -50,10 +50,12 @@
- Представление + Роль + - + +
diff --git a/docs/CUSTOMER_DEMO_SCENARIO_RU.md b/docs/CUSTOMER_DEMO_SCENARIO_RU.md new file mode 100644 index 0000000..119607a --- /dev/null +++ b/docs/CUSTOMER_DEMO_SCENARIO_RU.md @@ -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. + +## Что фиксировать после показа + +- Непонятные термины для руководителя. +- Где ИБ не хватает контекста расследования. +- Где эксплуатации не хватает статуса источников. +- Все замечания записывать отдельно и не исправлять во время показа. diff --git a/CUSTOMER_PILOT_PACK_RU.md b/docs/CUSTOMER_PILOT_PACK_RU.md similarity index 97% rename from CUSTOMER_PILOT_PACK_RU.md rename to docs/CUSTOMER_PILOT_PACK_RU.md index e146010..30bdd1b 100644 --- a/CUSTOMER_PILOT_PACK_RU.md +++ b/docs/CUSTOMER_PILOT_PACK_RU.md @@ -261,13 +261,13 @@ read-only интеграционный контекст. Enforcement, quarantine ## Связанные документы -- [Описание продукта](PRODUCT_DESCRIPTION_RU.md) +- [Описание продукта](../PRODUCT_DESCRIPTION_RU.md) - [Аудит готовности к пилоту](PILOT_READINESS_AUDIT_RU.md) -- [Чек-лист пилотного внедрения](docs/PILOT_DEPLOYMENT_CHECKLIST_RU.md) -- [Акт приемки пилота](docs/CUSTOMER_PILOT_ACCEPTANCE_RU.md) -- [Портал AWatch-rus](docs/PORTAL_RU.md) -- [Business Risk](docs/BUSINESS_RISK_RU.md) -- [Модель безопасности](docs/SECURITY_MODEL_RU.md) -- [Архитектура](docs/ARCHITECTURE_RU.md) -- [Развертывание агента](docs/AGENT_DEPLOYMENT_RU.md) -- [Windows Rust Agent Worktime/RDP](docs/WINDOWS_RUST_AGENT_WORKTIME_RU.md) +- [Чек-лист пилотного внедрения](PILOT_DEPLOYMENT_CHECKLIST_RU.md) +- [Акт приемки пилота](CUSTOMER_PILOT_ACCEPTANCE_RU.md) +- [Портал AWatch-rus](PORTAL_RU.md) +- [Business Risk](BUSINESS_RISK_RU.md) +- [Модель безопасности](SECURITY_MODEL_RU.md) +- [Архитектура](ARCHITECTURE_RU.md) +- [Развертывание агента](AGENT_DEPLOYMENT_RU.md) +- [Windows Rust Agent Worktime/RDP](WINDOWS_RUST_AGENT_WORKTIME_RU.md) diff --git a/docs/DEMO_RUNBOOK_RU.md b/docs/DEMO_RUNBOOK_RU.md new file mode 100644 index 0000000..330b49c --- /dev/null +++ b/docs/DEMO_RUNBOOK_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-отчет формируется. diff --git a/docs/PFSENSE_INTEGRATION_RU.md b/docs/PFSENSE_INTEGRATION_RU.md index a48dc7f..14a06a1 100644 --- a/docs/PFSENSE_INTEGRATION_RU.md +++ b/docs/PFSENSE_INTEGRATION_RU.md @@ -1,35 +1,85 @@ -# pfSense Integration +# pfSense Integration Readiness -pfSense рассматривается как интеграционный слой сетевого периметра, а не обязательная часть продукта. +## Статус Pilot v1.0 -## Режим v0.3 +pfSense в Pilot v1.0 является опциональным интеграционным слоем сетевого +периметра. Полноценный SIEM не реализуется. -- read-only; -- без изменения правил; -- без автоматического карантина; -- без управления маршрутизацией; -- без зависимости портала от pfSense. +Текущий статус: `contract_only`. -## Место в архитектуре +Это означает: -```text -pfSense / firewall telemetry - | -awatch-agent-rs --role firewall - | -POST /api/telemetry - | -Workforce/UEBA risk context -``` +- есть API-заготовка `/api/pfsense`; +- есть JSON-контракты для firewall events, vpn events, traffic summary и top destinations; +- есть демонстрационный fixture без реальных IP/hostname/login; +- нет заявления, что реальный ingestion включен; +- нет автоматического изменения firewall/VPN/routing; +- нет NAC, SOAR, quarantine и блокировок. -## Коммерческая ценность +## Контракт событий -Интеграция позволяет объяснять риски не только по активности рабочего места, но и по сетевому контексту: unusual destinations, VPN sessions, gateway status, proxy/DNS signals. +Минимальные поля firewall event: -## Не реализуется в v0.3 +- `timestamp`; +- `source_host`; +- `destination`; +- `action`; +- `protocol`; +- `rule_id`. -- NAC; -- SOAR-автоматизация; -- блокировка VLAN; -- изменение firewall rules; -- управление VPN-доступом. +Минимальные поля VPN event: + +- `timestamp`; +- `source_host`; +- `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 не используется и не рассматривается. diff --git a/docs/PILOT_GAP_ANALYSIS_RU.md b/docs/PILOT_GAP_ANALYSIS_RU.md index 81c23fa..c7eb610 100644 --- a/docs/PILOT_GAP_ANALYSIS_RU.md +++ b/docs/PILOT_GAP_ANALYSIS_RU.md @@ -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. -## 1. Готово к пилоту +## Что готово -- Executive View готов к демонстрации: главный вывод отображается первым, - управленческие блоки идут в понятном порядке, технические термины не - выводятся в пользовательском тексте. -- Security View готов к демонстрации: есть кандидаты на проверку, аудит, - материалы расследования, пакет расследования и переход к карточке - расследования. -- Operations View готов к демонстрации: видны полнота и качество данных, - ошибки сбора, состояние источников и доступность событий безопасности. -- Контур ClickHouse для событий ИБ работает на `` в 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 не выявлено. -- Обязательные проверки на текущем дереве пройдены: +- Архитектура пилота выстроена как понятная цепочка: + `агент -> телеметрия -> аналитика -> риск -> расследование -> отчет`. +- Rust workspace на текущем `origin/main` проходит обязательные проверки: -| Проверка | Итог | +| Проверка | Результат | | --- | --- | | `cargo test --workspace` | Пройдено. | | `cargo clippy --all-targets --all-features` | Пройдено. | | `cargo build --release` | Пройдено. | -| `node scripts/detmir-portal-tabs-smoke.mjs` | Пройдено против `` с `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=`, потому что старый - `target/` на `fuseblk` непригоден для `libsqlite3-sys`. +## Что критично исправить -## 3. Критично исправить до пилота +Критичных блокеров в коде, сборке и smoke-проверке для контролируемого пилота +на текущем срезе не выявлено. -Критичных технических блокеров для контролируемого демонстрационного пилота на -текущем контуре не осталось. +Критичные условия перед фактическим показом: -Единственное обязательное условие перед показом: не начинать демонстрацию без -короткого преддемо-прогона портала на том же экране и сети. Это не доработка -продукта, а операционная страховка от внешних факторов: сеть, браузер, доступ к -стенду, свежесть данных. +- Демо должно запускаться на контуре, где развернут именно commit + `9c57d6d2ce7eae1b133c937f037b24933f5797fe` или более новый проверенный срез. +- Перед показом нужен короткий преддемо-прогон на той же сети и экране: + открыть портал, дождаться `Данные готовы`, проверить кандидата, открыть + расследование и сформировать отчет. +- Нельзя продавать текущий срез как промышленно завершенную платформу без + оговорок по доступу, retention, backup, versioning и регламенту эксплуатации. -## 4. Можно перенести после пилота +## Что желательно исправить -- Полное hardening JSON/JSONL-хранилищ: блокировки, atomic write, ротация, - retention, backup/restore и нагрузочная проверка больших state-файлов. -- Формальную матрицу версий API и журнал совместимых и несовместимых изменений - полей. -- Расширенные нагрузочные тесты и sizing под промышленную эксплуатацию. -- Полировку коммерческого пакета после первой обратной связи заказчика. -- Расширенную автоматизацию мобильных скриншотов и визуальных regression tests. -- Расширенную регламентацию долгосрочного доступа к материалам расследований и - архивам. +- Русифицировать заголовки customer-facing документов: текущие документы + используют названия вроде `Customer Pilot Pack` и `Sales Positioning`, что + слабее выглядит на показе руководителю и заказчику. +- Отдельно перед демо проверить экспорт в формате, который будет показан: + текстовый отчет, печать или PDF. +- Добавить в регулярный smoke мобильный/визуальный прогон. Текущий + `detmir-portal-tabs-smoke.mjs` хорошо проверяет функциональный путь, но не + является полноценной визуальной regression-проверкой. +- Сформировать явную матрицу совместимости API: agent, portal, telemetry, + ClickHouse security events и investigation state. +- Убрать из публичного customer-facing пакета внутренние формулировки про + ``, 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 и преддемо-прогон | -| Не проверить экспорт отчета заранее | Можно потерять время на живом показе | Желательно закрыть до показа | -| Отдать внутреннюю документацию без вычитки | Лишние технические вопросы руководителя и ИБ | Желательно закрыть до показа | -| Данные изменятся перед демо и кандидатов станет меньше | Сценарий расследования может ослабнуть | Управляемо: проверить `/api/reports` перед показом | -| Внешние источники недоступны из-за сети | Потеря части операционной картины | Управляемо: преддемо health-check | +| Контур демо не совпадает с проверенным commit | Возможны расхождения UI, API или данных | Критично контролировать перед показом | +| Документы или ссылки изменятся без проверки | Путает hand-off и приемку пакета | Контролировать markdown link-check перед передачей | +| Первый запуск портала без прогрева | Может выглядеть как зависшая загрузка | Управляемо преддемо-прогоном | +| В текущих данных исчезнут кандидаты на расследование | 10-минутный сценарий потеряет самый сильный пример | Проверять `/api/reports` перед демо | +| 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, проверить +наличие кандидата на расследование, открыть пакет расследования и заранее +подготовить итоговый отчет. diff --git a/PILOT_READINESS_AUDIT_RU.md b/docs/PILOT_READINESS_AUDIT_RU.md similarity index 100% rename from PILOT_READINESS_AUDIT_RU.md rename to docs/PILOT_READINESS_AUDIT_RU.md diff --git a/docs/PILOT_V1_RU.md b/docs/PILOT_V1_RU.md new file mode 100644 index 0000000..e60a749 --- /dev/null +++ b/docs/PILOT_V1_RU.md @@ -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` diff --git a/docs/ROLES_RU.md b/docs/ROLES_RU.md new file mode 100644 index 0000000..e1ac6ee --- /dev/null +++ b/docs/ROLES_RU.md @@ -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-сценарии предыдущих сборок. diff --git a/SALES_POSITIONING_RU.md b/docs/SALES_POSITIONING_RU.md similarity index 98% rename from SALES_POSITIONING_RU.md rename to docs/SALES_POSITIONING_RU.md index 8e3a985..77570cb 100644 --- a/SALES_POSITIONING_RU.md +++ b/docs/SALES_POSITIONING_RU.md @@ -327,8 +327,8 @@ AWatch-rus / DetMir в текущем позиционировании: ## Связанные документы - [Customer Pilot Pack](CUSTOMER_PILOT_PACK_RU.md) -- [Описание продукта](PRODUCT_DESCRIPTION_RU.md) +- [Описание продукта](../PRODUCT_DESCRIPTION_RU.md) - [Аудит готовности к пилоту](PILOT_READINESS_AUDIT_RU.md) -- [Портал AWatch-rus](docs/PORTAL_RU.md) -- [Business Risk](docs/BUSINESS_RISK_RU.md) -- [Модель безопасности](docs/SECURITY_MODEL_RU.md) +- [Портал AWatch-rus](PORTAL_RU.md) +- [Business Risk](BUSINESS_RISK_RU.md) +- [Модель безопасности](SECURITY_MODEL_RU.md) diff --git a/docs/UEBA_SCORE_RU.md b/docs/UEBA_SCORE_RU.md new file mode 100644 index 0000000..762432d --- /dev/null +++ b/docs/UEBA_SCORE_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. diff --git a/docs/fixtures/pfsense-demo-events.json b/docs/fixtures/pfsense-demo-events.json new file mode 100644 index 0000000..17f283b --- /dev/null +++ b/docs/fixtures/pfsense-demo-events.json @@ -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 + } + ] +} diff --git a/docs/screenshots/01-executive-overview.png b/docs/screenshots/01-executive-overview.png new file mode 100644 index 0000000..9a7b981 Binary files /dev/null and b/docs/screenshots/01-executive-overview.png differ diff --git a/docs/screenshots/02-risk-heatmap.png b/docs/screenshots/02-risk-heatmap.png new file mode 100644 index 0000000..5e37a3b Binary files /dev/null and b/docs/screenshots/02-risk-heatmap.png differ diff --git a/docs/screenshots/03-security-view.png b/docs/screenshots/03-security-view.png new file mode 100644 index 0000000..7d99389 Binary files /dev/null and b/docs/screenshots/03-security-view.png differ diff --git a/docs/screenshots/04-operations-view.png b/docs/screenshots/04-operations-view.png new file mode 100644 index 0000000..0eea632 Binary files /dev/null and b/docs/screenshots/04-operations-view.png differ diff --git a/docs/screenshots/05-investigation-pack.png b/docs/screenshots/05-investigation-pack.png new file mode 100644 index 0000000..71d4188 Binary files /dev/null and b/docs/screenshots/05-investigation-pack.png differ diff --git a/docs/screenshots/06-markdown-report.png b/docs/screenshots/06-markdown-report.png new file mode 100644 index 0000000..549a26b Binary files /dev/null and b/docs/screenshots/06-markdown-report.png differ diff --git a/scripts/detmir-portal-tabs-smoke.mjs b/scripts/detmir-portal-tabs-smoke.mjs index 72bd693..3823b45 100644 --- a/scripts/detmir-portal-tabs-smoke.mjs +++ b/scripts/detmir-portal-tabs-smoke.mjs @@ -171,7 +171,10 @@ async function main() { }); smokeStep = "api:security_events_summary"; 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() }; }); const securitySummary = reportsPayload.json?.security_events_summary || null; @@ -252,8 +255,10 @@ async function main() { name: "role_view_switcher_present", ok: (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="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); checks.push({ @@ -268,6 +273,28 @@ async function main() { 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"; await page.click('[data-view-mode="security"]', { timeout }); await page.waitForFunction( @@ -304,8 +331,30 @@ async function main() { expected_text: expectedSecurityText, }); - smokeStep = "role:operations"; - await page.click('[data-view-mode="operations"]', { timeout }); + smokeStep = "role:forensics"; + 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( () => document.querySelector("#loadingStatus")?.dataset.loadStatus === "READY" && document.body.innerText.includes("Телеметрия"), @@ -314,9 +363,9 @@ async function main() { ); const operationsText = await page.locator("#content").innerText({ timeout }); checks.push({ - name: "operations_role_view", + name: "admin_role_view", ok: [ - "Представление эксплуатации", + "Представление администратора", "Полнота данных", "Качество данных", "События безопасности за 24 часа", @@ -334,6 +383,37 @@ async function main() { mode: securityMode, 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") { const requiredSettings = [