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. Эти
|
||||
части не являются ядром 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, логинов, сотрудников, подразделений заказчика и событий безопасности.
|
||||
|
||||
### Главный вывод
|
||||
|
||||

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

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

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

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

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

|
||||
|
||||
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)
|
||||
|
||||
@@ -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": [
|
||||
|
||||
+71
-1
@@ -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<JsonObject>;
|
||||
getManager(): 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>;
|
||||
getCases(): Promise<CaseListResponse>;
|
||||
createCase(request: CreateCaseRequest): Promise<JsonObject>;
|
||||
|
||||
@@ -43,6 +43,76 @@ unsafe extern "C" {
|
||||
|
||||
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)]
|
||||
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<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> {
|
||||
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<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> {
|
||||
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<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) {
|
||||
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();
|
||||
|
||||
@@ -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)}
|
||||
<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) {
|
||||
return `
|
||||
${renderAgentCoverageSla(report?.agent_coverage_sla)}
|
||||
@@ -1344,7 +1411,7 @@ function renderInvestigationPacks(candidates) {
|
||||
<div class="row compact-row">
|
||||
<strong>${ui(item.department || "Без подразделения")}</strong>
|
||||
<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>
|
||||
`).join("") : `
|
||||
<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) {
|
||||
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 `
|
||||
<div class="button-row compact-actions">
|
||||
<button class="small-button" data-case-status="IN_PROGRESS" data-case-id="${escapeHtml(id)}">В работу</button>
|
||||
@@ -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"
|
||||
? `<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");
|
||||
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);
|
||||
|
||||
@@ -50,10 +50,12 @@
|
||||
</label>
|
||||
</div>
|
||||
<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" 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="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 class="header-actions">
|
||||
<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)
|
||||
- [Чек-лист пилотного внедрения](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)
|
||||
@@ -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;
|
||||
- без изменения правил;
|
||||
- без автоматического карантина;
|
||||
- без управления маршрутизацией;
|
||||
- без зависимости портала от 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 не используется и не рассматривается.
|
||||
|
||||
+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.
|
||||
|
||||
## 1. Готово к пилоту
|
||||
## Что готово
|
||||
|
||||
- Executive View готов к демонстрации: главный вывод отображается первым,
|
||||
управленческие блоки идут в понятном порядке, технические термины не
|
||||
выводятся в пользовательском тексте.
|
||||
- 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 не выявлено.
|
||||
- Обязательные проверки на текущем дереве пройдены:
|
||||
- Архитектура пилота выстроена как понятная цепочка:
|
||||
`агент -> телеметрия -> аналитика -> риск -> расследование -> отчет`.
|
||||
- Rust workspace на текущем `origin/main` проходит обязательные проверки:
|
||||
|
||||
| Проверка | Итог |
|
||||
| Проверка | Результат |
|
||||
| --- | --- |
|
||||
| `cargo test --workspace` | Пройдено. |
|
||||
| `cargo clippy --all-targets --all-features` | Пройдено. |
|
||||
| `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, ротация,
|
||||
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 пакета внутренние формулировки про
|
||||
`<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 и преддемо-прогон |
|
||||
| Не проверить экспорт отчета заранее | Можно потерять время на живом показе | Желательно закрыть до показа |
|
||||
| Отдать внутреннюю документацию без вычитки | Лишние технические вопросы руководителя и ИБ | Желательно закрыть до показа |
|
||||
| Данные изменятся перед демо и кандидатов станет меньше | Сценарий расследования может ослабнуть | Управляемо: проверить `/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, проверить
|
||||
наличие кандидата на расследование, открыть пакет расследования и заранее
|
||||
подготовить итоговый отчет.
|
||||
|
||||
@@ -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)
|
||||
- [Описание продукта](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)
|
||||
@@ -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";
|
||||
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 = [
|
||||
|
||||
Reference in New Issue
Block a user