feat(portal): harden pilot v1 role contracts

This commit is contained in:
igor04091968
2026-06-06 12:54:00 +03:00
parent 9c57d6d2ce
commit 067ad0939c
24 changed files with 1991 additions and 208 deletions
+60 -2
View File
@@ -32,8 +32,10 @@ Python в репозитории остается для вспомогател
runtime, OCR/content-analysis, 1C/AI/ETL integration и MCP/dev helpers. Эти
части не являются ядром Rust-first runtime.
Портальный слой зафиксирован как Rust server-rendered HTML + HTMX с JSON API,
OpenAPI и TypeScript declarations для будущих React/Tauri-клиентов.
Портальный слой зафиксирован как Rust server-rendered HTML + HTMX-compatible
JSON API, OpenAPI и TypeScript declarations. Dioxus не используется и не
рассматривается для Pilot v1.0. React, Tauri и Electron также не входят в
текущий основной UI.
## Что видит оператор
@@ -50,6 +52,53 @@ OpenAPI и TypeScript declarations для будущих React/Tauri-клиен
- ИБ - заметить DLP-сигналы и подозрительную активность.
- Администратору - проверить, что сборщики и сервер работают стабильно.
## Интерфейс
Скриншоты ниже подготовлены на демонстрационных данных: без реальных IP-адресов,
hostname, логинов, сотрудников, подразделений заказчика и событий безопасности.
### Главный вывод
![Главный вывод AWatch-rus](docs/screenshots/01-executive-overview.png)
Руководитель видит главный риск первым, затем сводку по достоверности
показателей, полноте данных, кандидатам на проверку и рискам подразделений.
### Карта рисков подразделений
![Карта рисков подразделений](docs/screenshots/02-risk-heatmap.png)
Карта рисков показывает, где одновременно проседают активность, покрытие
агентов, доверие к показателям и количество ситуаций для проверки.
### Представление безопасности
![Представление безопасности](docs/screenshots/03-security-view.png)
ИБ получает очередь кандидатов на проверку, связанные расследования и материалы
без просмотра сырых логов и без автоматического принятия решений.
### Представление эксплуатации
![Представление эксплуатации](docs/screenshots/04-operations-view.png)
Эксплуатация видит полноту данных, качество агентского сбора, ошибки сбора и
понятный статус событий безопасности через ClickHouse.
### Пакет расследования
![Пакет расследования](docs/screenshots/05-investigation-pack.png)
Пакет расследования связывает материалы, историю проверки и итоговый вывод,
который ответственный сотрудник может подтвердить вручную.
### Итоговый отчет
![Итоговый отчет](docs/screenshots/06-markdown-report.png)
Markdown-отчет собирает главный вывод, риски подразделений, материалы
расследований и рекомендации в формате, удобном для передачи руководителю.
## Если дашборд пустой
Обычно это значит одно из трех: выбран слишком узкий период времени, рабочий компьютер давно не присылал события или временно не обновилась витрина в Grafana. Начните с периода `Last 24 hours`, затем переходите к техническим разделам ниже.
@@ -66,6 +115,15 @@ OpenAPI и TypeScript declarations для будущих React/Tauri-клиен
- [Эксплуатационный профиль](docs/OPERATIONAL_PROOF_PROFILE_RU.md)
- [Коммерческие модули DetMir](docs/DETMIR_COMMERCIAL_MODULES_RU.md)
- [Архитектурный baseline](docs/ARCHITECTURE_BASELINE_RU.md)
- [Пакет пилота для заказчика](docs/CUSTOMER_PILOT_PACK_RU.md)
- [Pilot v1.0](docs/PILOT_V1_RU.md)
- [Ролевая модель портала](docs/ROLES_RU.md)
- [UEBA Score v1](docs/UEBA_SCORE_RU.md)
- [pfSense integration readiness](docs/PFSENSE_INTEGRATION_RU.md)
- [Сценарий демонстрации заказчику](docs/CUSTOMER_DEMO_SCENARIO_RU.md)
- [Аудит готовности к пилоту](docs/PILOT_READINESS_AUDIT_RU.md)
- [Позиционирование для первой встречи](docs/SALES_POSITIONING_RU.md)
- [Преддемо-сценарий](docs/DEMO_RUNBOOK_RU.md)
- [Сторонние компоненты](THIRD_PARTY_COMPONENTS.md)
- [Сторонние лицензии](THIRD_PARTY_LICENSES_RU.md)
- [Архитектура](docs/ARCHITECTURE_RU.md)
@@ -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
View File
@@ -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>;
+793 -36
View File
@@ -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();
+105 -7
View File
@@ -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>
+39
View File
@@ -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)
+71
View File
@@ -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-отчет формируется.
+76 -26
View File
@@ -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
View File
@@ -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, проверить
наличие кандидата на расследование, открыть пакет расследования и заранее
подготовить итоговый отчет.
+67
View File
@@ -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`
+31
View File
@@ -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)
+55
View File
@@ -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.
+43
View File
@@ -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

+86 -6
View File
@@ -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 = [