diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6deb884..71da693 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,12 @@ jobs: - name: Run production inventory placeholder guard self-test run: bash scripts/check_production_inventory_placeholders.sh --self-test + - name: Run private-config guard + run: bash scripts/check_private_config_guard.sh + + - name: Run portal contract sync guard + run: node scripts/check_portal_contract_sync.mjs + rust-runtime-guard: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/rust-workspace.yml b/.github/workflows/rust-workspace.yml new file mode 100644 index 0000000..2660c71 --- /dev/null +++ b/.github/workflows/rust-workspace.yml @@ -0,0 +1,31 @@ +name: rust-workspace + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + rust-workspace: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust 1.85 + uses: dtolnay/rust-toolchain@1.85.0 + with: + components: rustfmt, clippy + + - name: Format + run: cargo fmt --manifest-path adk-rust/Cargo.toml --all -- --check + + - name: Test + run: cargo test --manifest-path adk-rust/Cargo.toml --workspace + + - name: Clippy + run: cargo clippy --manifest-path adk-rust/Cargo.toml --workspace --all-targets -- -D warnings + + - name: Release build + run: cargo build --manifest-path adk-rust/Cargo.toml --workspace --release diff --git a/.gitignore b/.gitignore index ff885e1..1c4ed2e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,11 @@ # Local secrets /secrets/ -/private-config/*.env -/private-config/*.local +/private-config/* +!/private-config/ +!/private-config/README.md +!/private-config/.gitkeep +!/private-config/*.example +!/private-config/*.template /ansible/inventory.ini /codex_history.txt diff --git a/AGENTS.md b/AGENTS.md index 56e70be..789d67c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,52 +1,246 @@ -AGENTS for OpenCode +# AGENTS.md -Keep this file minimal and high-signal: only include facts an agent would otherwise miss. +Operational rules for OpenCode/Codex agents in AWatch-rus. -1) Repo purpose (one line) -- This repository bundles an ActivityWatch Server deployment, RU WebUI patch, Windows collectors (PowerShell), and Rust operational utilities for aggregation, monitoring, health checks, SLO, DLP helpers, and AWatch-rus automation. Python is retained only for explicit exceptions: Telegram bot runtime, pfSense tooling, OCR/content-analysis, 1C AI/ETL, and detmir-mcp. +## Defaults -2) Highest-value entrypoints & commands -- Read README.md and docs/preparation.md first (they are the authoritative onboarding flow). -- Create a Proxmox CT: `proxmox/create-ct.sh [ /path/to/deploy.secrets.env ]` (reads secrets/deploy.secrets.env). -- Push server artifacts to an existing CT: `proxmox/push-aw-artifacts.sh [ /path/to/deploy.secrets.env ]`. -- Install AW server on the CT (runs inside CT): `aw-server/install_aw_server.sh` (requires `/etc/activitywatch/aw-server.env`). -- Apply RU WebUI patch (must run on the CT and after webui is present): `aw-server/apply_webui_ru_patch.sh`. -- Run the Windows ensemble deploy from a Windows admin host: `windows/deploy-ensemble.ps1` (see its parameters; it calls `deploy-domain-users.ps1`). -- Quick DLP aggregation (local): `adk-rust/target/release/dlp-aggregator`. +- Rust is the primary runtime: use `adk-rust/`, build with `cargo build --release -p `, test with `cargo test -p `. +- Root scripts (`check-aw-data.sh`, `check-aw-full.sh`, `scripts/prod_rollout.sh`, install-kit helpers) are Rust-first wrappers with legacy fallback. +- Python is allowed only in `aw-server/dlp-content-analysis/`, `clickhouse-1c/ai/`, `clickhouse-1c/etl/`, `detmir-mcp/main.py`, `grafana-1c/`, `pfsense/`, `proxmox/tsj_guardian_bot.py`. +- Never add real secrets from `secrets/`, private `.env`, or host credentials. +- When auditing private/ignored files, report only path, secret type, and remediation. Never copy secret values into docs, logs, markdown, terminal summaries, commits, or handoff reports. -3) Exact env/secrets behavior agents often miss -- Secrets live in `secrets/deploy.secrets.env` (actual file is intentionally local-only). Many scripts default to that path if no arg provided. Never add real secrets to commits. Use `.example` files as templates. -- The CT bootstrap workflow expects `/etc/activitywatch/aw-server.env` on the CT (pushed by push-aw-artifacts when AW_SERVER_* variables are set). `install_aw_server.sh` sources that exact path. +## Required Checks -4) CI / quality checks the repo enforces -- GitHub CI runs shellcheck for `*.sh` and PSScriptAnalyzer for PowerShell in `windows/*.ps1` (see .github/workflows/ci.yml). -- Local quality gate: `scripts/quality-gate.sh` performs bash `-n`, (optional) shellcheck, pwsh parse checks, and ansible syntax checks. Run this before PRs. +- General: `scripts/quality-gate.sh`. +- Rust: targeted `cargo test -p `. +- Windows: parse PowerShell; CI also runs PSScriptAnalyzer on `windows/*.ps1`, `.psm1`, `.psd1`. +- Ansible: affected `ansible-playbook --syntax-check ...`. -5) File locations and toolchain quirks -- AW server binary is installed under `/opt/activitywatch/releases` and symlinked from `/opt/activitywatch/bin/aw-server-rust` by `install_aw_server.sh`. -- RU WebUI patching expects JS assets in `$AW_SERVER_WEBUI_DIR` (default `/opt/activitywatch/webui-ru`). The patch script writes `js/ru-patch-v5.js` and edits `index.html` in-place (it makes backups with .bak timestamps). -- `proxmox/create-ct.sh` and `proxmox/push-aw-artifacts.sh` source the same deploy.secrets.env and require many CT_* / AW_SERVER_* variables; missing vars cause immediate exit. +## Map -6) Monorepo boundaries / responsibilities -- aw-server/: server install & systemd unit + RU webui patching. -- proxmox/: create CT and push artifacts scripts (requires Proxmox `pct` CLI and CT preconditions). -- windows/: PowerShell collectors and deployment automation (Target: Windows admin hosts; validated via `validate-deployment.ps1`). -- grafana-1c/, pfsense/: monitoring stacks and pollers (separate deploys, not part of aw-server install). -- scripts/: small utilities and `quality-gate.sh` used by contributors. +- `adk-rust/`: operational crates. +- `aw-server/`: server install, env examples, RU WebUI patch, systemd. +- `windows/`: RDP deployment, collectors, recovery, validation. +- `ansible/`: deployment playbooks. +- `proxmox/`: CT/gateway/bot automation. +- `clickhouse-1c/`, `grafana-1c/`, `pfsense/`: integration stacks. +- `grafana/`: flat version-controlled dashboard JSON; use Ansible to import/check it. -7) Common gotchas -- Do NOT commit secrets (secrets/ are local-only; PRs must not contain real secrets). -- Many scripts assume they run on the target CT or on a Linux admin host with `pct` available. Don't try to run them on macOS without adapting dependencies. -- `aw-server/install_aw_server.sh` expects network access to download the AW release URL provided by AW_SERVER_DOWNLOAD_URL. -- `aw-server/apply_webui_ru_patch.sh` must run after AW webui files are present; it will fail if required bootstrap files under `` are missing. -- When pushing AW_SERVER env via `push-aw-artifacts.sh` the script will only write `/etc/activitywatch/aw-server.env` if all AW_SERVER_* variables are set; otherwise it warns and skips. +## Entrypoints -8) PR / commit checklist for agents -- Run `scripts/quality-gate.sh` locally (or ensure CI covers changed files). -- Ensure no secrets (.env with real values) are staged. -- If changing PowerShell, ensure PSScriptAnalyzer rules pass (CI enforces this). +Use `proxmox/create-ct.sh`, `proxmox/push-aw-artifacts.sh`, `aw-server/install_aw_server.sh`, `aw-server/apply_webui_ru_patch.sh`, `windows/deploy-ensemble.ps1`, and docs in `docs/preparation.md`, `docs/deployment.md`, `docs/runbook.md`, `docs/operations.md`. -9) Where to find more instructions (preserve these files) -- README.md, docs/preparation.md, docs/deployment.md, docs/runbook.md, docs/operations.md, docs/codebase-onboarding.md — read these when doing infra or deployment work. +## Incident Handling -If you need me to add step-by-step repros or automate one of the tasks above (create CT, push artifacts, run install on CT), say which one and I will implement the helper or run the checked commands. +OpenCode must handle AWatch-rus incidents as evidence-based operational triage, +not as guesswork from one red dashboard card. + +### Assessment Basis + +Assess every incident from these signals, in this order: + +- **User impact:** portal/report/dashboard unavailable, stale, slow, or wrong; + which role is affected: executive, manager, security, forensics, admin. +- **Data freshness:** ActivityWatch bucket `metadata.end`, collector heartbeats, + Windows scheduled task recency, queue depth, and upload/send failure counters. +- **Service health:** systemd failed units, active timers, bounded HTTP checks, + `/health` or `/api/health` responses, container health where relevant. +- **Pipeline layer:** identify the first broken layer in the chain + `Windows/RDP collectors -> ActivityWatch buckets -> Rust services -> exporters + -> Grafana/Portal -> ClickHouse/1C where configured`. +- **Risk/evidence:** DLP endpoint signals, incident candidates, evidence + artifacts, UEBA/risk narrative inputs, coverage gaps, and security + correlation indicators. +- **Blast radius:** one user/session/collector, one host, one service, one + dashboard, or the full contour. +- **Recoverability:** known rollback, stale-cache availability, safe restart + boundary, and whether a human approval is required. + +Risk Narrative is only decision support. It can raise priority and explain +why a manual check is needed, but it does not prove a policy violation, DLP +incident, or SIEM finding by itself. + +### Severity + +Use this practical severity model: + +- `P0`: data loss risk, auth/security boundary broken, raw private service + exposed, production report chain unavailable with no stale fallback, or + repeated collector process storms/memory pressure. +- `P1`: executive/security workflows degraded, fresh data missing for a critical + host, DLP evidence sync broken, ClickHouse/1C ingest stopped, or portal health + degraded with user-visible effect. +- `P2`: one collector stale, one dashboard/panel wrong, delayed timer, bad label + normalization, missing noncritical evidence, or recoverable stale report. +- `P3`: documentation drift, cosmetic UI issue, non-production demo fixture, + or a warning with fresh data still confirmed. + +Escalate severity when the same symptom repeats after recovery, when coverage +is unknown, or when evidence contradicts dashboard status. + +### Mechanisms To Use + +Start with the repo wrappers before ad hoc probing: + +```bash +cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian +./check-aw-data.sh +./check-aw-full.sh +``` + +Then narrow by layer: + +- ActivityWatch API: `/api/0/info`, `/api/0/buckets`, bucket metadata and recent + events with explicit `no_proxy` and short `curl --max-time`. +- Worktime: `aw-worktime-api` `/health`, `/reports/worktime/today`, + `/reports/worktime/management?allow_stale=1`, prewarm logs, stale-cache + fields, `AW_WORKTIME_EVENTS_LIMIT`, and `aw_query_timeout_count`. +- Windows/RDP: `validate-deployment.ps1`, exact `ActivityWatch Launch [...]` + scheduled tasks, `ActivityWatch Recovery`, collector guard state, session + collectors, local queue depth, and send failure counters. +- DLP: `aw-dlp-policy-engine`, `aw-dlp-case-management`, `dlp-health-check`, + `aw-dlp-endpoint-signals_`, evidence artifact sync, policy audit, and + case/compliance services. +- Portal/Gateway/Grafana: `/portal/api/health`, `/api/reports`, gateway + `/healthz`, protected `/d/...` Grafana routes, role gates, and browser smoke + scripts. +- ClickHouse/1C: only for file-1C/analytics incidents. Do not blame ClickHouse + for worktime report failures unless the affected path explicitly uses it. + +Use existing guards and bounded mechanisms before broad restarts: + +- stale-cache and fail-closed worktime behavior; +- `aw-worktime-autoheal`, `aw-worktime-prewarm`, `aw-worktime-ui-bridge`, + `aw-rus-healthd` timers; +- Windows collector guard and exact localized scheduled tasks; +- DLP evidence sync and health timers; +- targeted service restart only after evidence identifies the layer. + +### DLP Rule Update System + +Do not describe AWatch-rus DLP rules as manual local JSON entry, and do not +collapse all DLP updates into one mechanism. There are two related but separate +contours: + +1. policy lifecycle and endpoint synchronization through the DLP Policy Engine; +2. automatic IOC/signature replenishment from the open-source Hayabusa/Sigma + ruleset. + +The centralized policy update contour is: + +- Server service: `aw-dlp-policy-engine.service`, Rust binary + `/usr/local/bin/aw-dlp-policy-engine-rust`, default API port `5601`. +- Storage: SQLite DB from `AW_DLP_POLICY_ENGINE_DB_PATH`, with policy records, + policy versions, active policy pointer, rollback versions, and `policy_audit`. +- API contract: + - `GET /healthz`; + - CRUD: `/api/0/dlp/policies`; + - active bundle: `GET /api/0/dlp/policies/active`; + - active version/checksum: `GET /api/0/dlp/policies/active/version`; + - approval lifecycle: + `draft -> pending_approval -> approved -> deployed`; + - workflow calls: + `POST /submit`, `POST /approve`, `POST /draft`, `POST /activate`; + - rollback: `POST /api/0/dlp/policies/rollback`; + - audit: + `GET /api/0/dlp/policies/audit?limit=N` and + `GET /api/0/dlp/policies/{id}/audit?limit=N`; + - endpoint sync: + `POST /api/0/dlp/policies/agents/{agent_id}/heartbeat` and + `GET /api/0/dlp/policies/agents/{agent_id}/desired`. +- Windows side is configured for server-driven policy mode: + `aw_windows_policy_mode: "server"`, + `aw_windows_policy_engine_enabled: true`, + `aw_windows_policy_refresh_seconds: 300`, and policy engine host/port from + Ansible group vars. +- Agents report their current policy version/checksum by heartbeat. The server + compares it with the active deployed policy and returns `desired` with + `refreshNow=true` when the endpoint must update. +- `dlp-admin-cli` is the operator CLI for read-side checks such as + `policies list`, `policies active`, incident/case listing, and combined DLP + health checks. It is not a replacement for the lifecycle API when changing + policy state. + +Automatic IOC/signature replenishment: + +- Name it precisely as `DLP IOC Enrichment from Hayabusa/Sigma` or + `Hayabusa Sigma IOC refresh pipeline`. +- Source rules come from the open-source GitHub ruleset + `Yamato-Security/hayabusa-rules`, configured by + `aw_dlp_ioc_rules_zip_url`. +- Deployment is controlled by `ansible/deploy_aw_server.yml` when + `aw_dlp_ioc_enabled=true`. +- The refresh wrapper `/usr/local/bin/aw-dlp-ioc-refresh.sh` downloads the + latest `hayabusa-rules` ZIP, unpacks Sigma YAML rules, and runs the Rust + extractor `/usr/local/bin/aw-extract-ioc-from-sigma`. +- The Rust extractor is built from + `adk-rust/crates/extract-ioc-from-sigma`; local/manual builds use + `scripts/build_dlp_ioc_from_hayabusa.sh`. +- Extracted IOC-like values include process image suffixes, command-line + substrings, original filenames, and SHA256 hashes. They are de-duplicated and + emitted as `ioc_blacklist.json`, `ioc_blacklist.csv`, and + `ioc_blacklist.sql`. +- Production artifacts live under `/opt/activitywatch/dlp-ioc/output` and are + served by `aw-worktime-api` on `/dlp-ioc/ioc_blacklist.json`, + `/dlp-ioc/ioc_blacklist.csv`, and `/dlp-ioc/ioc_blacklist.sql`. +- Windows DLP policy can consume this feed through the `ioc.source` field with + format `hayabusa_sigma_v1`; endpoint health/heartbeat should expose loaded + IOC state such as `iocRulesLoaded`. +- Runtime automation is `aw-dlp-ioc-refresh.service` plus + `aw-dlp-ioc-refresh.timer` with interval `aw_dlp_ioc_refresh_interval` + (default `6h`). Health/diagnostics should check this timer before assuming + signatures are static or manually maintained. +- This Hayabusa/Sigma IOC pipeline enriches the DLP rule base automatically; it + is not the same thing as hand-editing endpoint JSON and is also distinct from + the server-side Hayabusa EVTX forensics runner. + +Operational meaning: + +1. To update rules, create or update a policy draft through the policy engine. +2. Submit it for approval, approve it, then activate/deploy it. Activation is + allowed only from `approved`. +3. For policy changes, verify `active/version`, audit entries, Windows agent + heartbeat/desired, and downstream DLP signals after endpoints refresh. +4. For automatic signature replenishment, verify + `aw-dlp-ioc-refresh.timer`, the last `aw-dlp-ioc-refresh.service` run, + non-empty `ioc_blacklist.json/csv/sql`, Worktime API `/dlp-ioc/...` + exports, and Windows IOC load counters. +5. If a policy causes noise or misses, use policy rollback through the API; do + not hand-edit endpoint policy files as the normal rollback path. + +Manual edits of `C:\Program Files\AWatch-rus\windows\dlp-policy.example.json` +or `C:\ProgramData\AWatch-rus\dlp-policy.json` are diagnostic or emergency +fallback only. If such an edit is unavoidable, document it as configuration +drift and bring the rule back into the central policy engine. + +### Response Workflow + +1. Capture current state first: command, timestamp, host, service, and exact + failing endpoint. Do not restart before collecting evidence unless the + system is in active resource exhaustion. +2. Find the first broken layer. If buckets are stale, fix collectors before + Grafana. If `aw-worktime-api` is degraded, fix/report that before portal. +3. Separate real outage from presentation drift: dashboards can be stale or + mislabeled while buckets and services are healthy. +4. Apply the narrowest safe recovery: restart a collector/task/service, reduce + unsafe limits, clear process storms, or restore a known-good binary/config. + Back up config/binaries before replacement. +5. Verify with the same failing check plus one upstream and one downstream + check. For collector incidents, require bucket freshness and guard/healthd + consistency, not just one green command. +6. Record closure evidence: root cause, affected layer, action taken, commands + run, post-check results, remaining risk, and rollback path. + +### Safety Rules + +- Old snapshots, memory, dashboards, and handoff notes are hints; live runtime + evidence wins. +- Never expose passwords, tokens, private host credentials, private URLs, raw + security events, or customer identifiers in incident writeups. +- Do not run broad deploys, full restarts, or `cargo build --workspace` during + incident triage unless the scope demands it and rollback is clear. +- Do not treat `status=ok` as sufficient when freshness, queue depth, or + coverage evidence says otherwise. +- For owner-facing reports, publish only protected gateway/Grafana routes, not + raw `:5600`, `:5610`, `:8720`, or ClickHouse endpoints. diff --git a/README.md b/README.md index c533892..aa5b39b 100755 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ collectors. [commercial positioning](docs/REGISTRY_COMMERCIAL_POSITIONING_RU.md), [readiness checklist](docs/REGISTRY_READINESS_CHECKLIST_RU.md). -- [Позиционирование для реестра российского ПО](docs/DETMIR_RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md) +- [Позиционирование для реестра российского ПО](docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md) - [Сведения для подачи в реестр](REGISTER_RU_SOFTWARE.md) - [Registry product passport](docs/REGISTRY_PRODUCT_PASSPORT_RU.md) - [Registry architecture](docs/REGISTRY_ARCHITECTURE_RU.md) @@ -223,7 +223,7 @@ collectors. - [Сценарий экспертной проверки](docs/EXPERT_TEST_SCENARIO_RU.md) - [Release manifest 2026-06](docs/RELEASE_MANIFEST_2026-06.md) - [Эксплуатационный профиль](docs/OPERATIONAL_PROOF_PROFILE_RU.md) -- [Коммерческие модули AWatch-rus](docs/DETMIR_COMMERCIAL_MODULES_RU.md) +- [Коммерческие модули AWatch-rus](docs/COMMERCIAL_MODULES_RU.md) - [Архитектурный baseline](docs/ARCHITECTURE_BASELINE_RU.md) - [Пакет пилота для заказчика](docs/CUSTOMER_PILOT_PACK_RU.md) - [Enterprise deployment guide](docs/ENTERPRISE_DEPLOYMENT_GUIDE_RU.md) @@ -251,6 +251,7 @@ collectors. - [Browser conformance smoke](docs/BROWSER_CONFORMANCE_RU.md) - [Production readiness портала](docs/PRODUCTION_READINESS_RU.md) - [Explainable Workforce KPI](docs/EXPLAINABLE_KPI_RU.md) +- [Risk Narrative](docs/RISK_NARRATIVE_RU.md) - [Executive Action Center](docs/EXECUTIVE_ACTION_CENTER_RU.md) - [Rust Agent baseline](docs/RUST_AGENT_BASELINE_RU.md) - [Итог production-расследования 2026-06-07](docs/PRODUCTION_INCIDENT_REPORT_2026-06-07_RU.md) diff --git a/REGISTER_RU_SOFTWARE.md b/REGISTER_RU_SOFTWARE.md index e4091ce..2b11b51 100644 --- a/REGISTER_RU_SOFTWARE.md +++ b/REGISTER_RU_SOFTWARE.md @@ -305,5 +305,5 @@ stale/dead buckets для обязательных источников. - `docs/ARCHITECTURE_RU.md` - архитектура. - `docs/ADMIN_GUIDE_RU.md` - руководство администратора. - `docs/OPERATOR_GUIDE_RU.md` - руководство оператора. -- `docs/DETMIR_RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md` - стратегия +- `docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md` - стратегия позиционирования. diff --git a/SECURITY_OVERVIEW_RU.md b/SECURITY_OVERVIEW_RU.md index 8aee88d..43dbeb9 100644 --- a/SECURITY_OVERVIEW_RU.md +++ b/SECURITY_OVERVIEW_RU.md @@ -337,7 +337,7 @@ Collector и server-side сервисы проектировались так, ### MCP / PowerShell remote для AWatch-rus -Документ: `docs/DETMIR_POWERSHELL_MCP_REMOTE_RU.md` +Документ: `docs/POWERSHELL_MCP_REMOTE_RU.md` Реализует: diff --git a/adk-rust/RUNBOOK.md b/adk-rust/RUNBOOK.md index e89a1bf..a2690a9 100644 --- a/adk-rust/RUNBOOK.md +++ b/adk-rust/RUNBOOK.md @@ -1,9 +1,9 @@ -# Runbook: перевод DetMir на Rust / ADK-Rust +# Runbook: перевод AWatch-rus на Rust / ADK-Rust Дата фиксации: `2026-06-01` Цель: постепенно заменить хрупкие Python/shell operational scripts на -самодостаточные Rust-бинарники, не ломая текущий production-контур DetMir. +самодостаточные Rust-бинарники, не ломая текущий production-контур AWatch-rus. Этот runbook является рабочим планом миграции. Если фактический runtime расходится с этим документом, сначала фиксируется baseline, затем обновляется @@ -1543,7 +1543,7 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg - production binary доставлен на AW server, но `--apply` не запускался; - production dry-run: `apply=false`, `ok=true`, `missing=0`, `executed=0`, `steps=25`; - - final production gates: AW failed units `0`, DetMir status OK with + - final production gates: AW failed units `0`, `detmir-status` OK with `service_warnings=0`, `dlp_counts={ok:22,warn:0,fail:0}`, `ok_for_operator=true`; - gates: `cargo fmt --all -- --check`, `cargo test -p @@ -1707,15 +1707,15 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg Grafana check `ok=true` with `fail=0`, `detmir-auto --no-heal` rc `0`, portal health `true`, `detmir-status` `OK / ok_for_operator=true`, and failed units `0`. - - `docs/DETMIR_THREAT_MODEL_RU.md` added as the current working threat - model for DetMir. It records the product as an operational + - `docs/THREAT_MODEL_RU.md` added as the current working threat + model for AWatch-rus. It records the product as an operational control and technical audit platform, not a certified DLP/SIEM/EDR/XDR or FSTEC SZI. It also records Igor as the declared product owner, lists assets, trust zones, attacker/operator-failure classes, implemented evidence controls, residual risks, and the hardening roadmap. - - `docs/DETMIR_RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md` added as the - registry/product positioning note. Current decision: lead with DetMir as - an operational control and IT infrastructure management platform, use + - `docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md` added as the + registry/product positioning note. Current decision: lead with AWatch-rus + as an operational control and IT infrastructure management platform, use `09.10` as the primary Russian software registry class target, keep DLP/security/evidence/Hayabusa as applied modules, and prepare website, operator/admin docs, ownership package, screenshots, and dependency @@ -1724,17 +1724,18 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg `docs/ADMIN_GUIDE_RU.md`, `docs/OPERATOR_GUIDE_RU.md`, `docs/INSTALL_RU.md`, `docs/ARCHITECTURE_RU.md`, `docs/OWNERSHIP_RU.md`, `docs/THIRD_PARTY_LICENSES_RU.md`, and - `docs/REGISTRY_CHECKLIST_RU.md`. Naming decision fixed across the docs: - `DetMir` is the product, `AWatch-rus` is the repository/technical base, - and the external formula is `DetMir, программный комплекс на базе - AWatch-rus`. + `docs/REGISTRY_CHECKLIST_RU.md`. Current naming decision for public + materials: `AWatch-rus` is the product and repository name. Legacy + `detmir-*` service, crate and environment identifiers remain technical + runtime identifiers until a separate compatibility-safe migration is + approved. Отложить: - post-MVP развитие `detmir-portal`: role-aware views, safe check-now action, daily owner report, historical trends, AI summary with strict source citations, action buttons with allowlist and audit log. Детальный план: - `docs/DETMIR_PORTAL_GUI_PLAN_RU.md`; + `docs/PORTAL_GUI_PLAN_RU.md`; - перенос Telegram bot runtime снят с плана: Python остается постоянным runtime, Rust используется только для backend helpers; - перенос оставшихся install/runtime scripts на Rust; diff --git a/adk-rust/crates/aw-contour-smoke/src/main.rs b/adk-rust/crates/aw-contour-smoke/src/main.rs index 7e25a34..d44c299 100644 --- a/adk-rust/crates/aw-contour-smoke/src/main.rs +++ b/adk-rust/crates/aw-contour-smoke/src/main.rs @@ -116,7 +116,7 @@ fn run_proxmox_remote() -> Result { check_tcp(&mut counts, "nginx http", "127.0.0.1", 80); check_tcp(&mut counts, "nginx https", "127.0.0.1", 443); check_tcp(&mut counts, "proxmox web", "127.0.0.1", 8006); - check_tcp(&mut counts, "1C company API", "192.0.2.2", 8710); + check_tcp(&mut counts, "1C company API", "10.10.10.2", 8710); check_tcp(&mut counts, "clickhouse native", "127.0.0.1", 9000); check_tcp(&mut counts, "clickhouse http", "127.0.0.1", 8123); if let Ok(out) = command_output("ss", &["-tulpn"]) { @@ -135,29 +135,26 @@ fn run_proxmox_remote() -> Result { "https://127.0.0.1/healthz", &[200], ); - check_http_redirect( + check_http_code( &mut counts, - &no_redirect_http, - "go proxmox gui", + &http, + "go proxmox gui protected", "https://127.0.0.1/go/proxmox-gui", - &[301, 302, 307, 308], - Some("https://192.0.2.2:8006/"), + &[401], ); - check_http_redirect( + check_http_code( &mut counts, - &no_redirect_http, - "go file1c brief", + &http, + "go file1c brief protected", "https://127.0.0.1/go/file1c-brief", - &[301, 302, 307, 308], - Some("http://192.0.2.2:8710/manager/brief"), + &[401], ); - check_http_redirect( + check_http_code( &mut counts, - &no_redirect_http, - "go file1c actions", + &http, + "go file1c actions protected", "https://127.0.0.1/go/file1c-actions", - &[301, 302, 307, 308], - Some("http://192.0.2.2:8710/manager/actions"), + &[401], ); section("1C Company API"); @@ -165,24 +162,24 @@ fn run_proxmox_remote() -> Result { &mut counts, &no_redirect_http, "1C root redirect", - "http://192.0.2.2:8710/", + "http://10.10.10.2:8710/", &[307], ); for (name, url) in [ - ("1C /health", "http://192.0.2.2:8710/health"), - ("1C /api/health", "http://192.0.2.2:8710/api/health"), - ("1C manager brief", "http://192.0.2.2:8710/manager/brief"), + ("1C /health", "http://10.10.10.2:8710/health"), + ("1C /api/health", "http://10.10.10.2:8710/api/health"), + ("1C manager brief", "http://10.10.10.2:8710/manager/brief"), ( "1C manager actions", - "http://192.0.2.2:8710/manager/actions", + "http://10.10.10.2:8710/manager/actions", ), ( "1C manager recovery", - "http://192.0.2.2:8710/manager/recovery", + "http://10.10.10.2:8710/manager/recovery", ), ( "1C weekly digest", - "http://192.0.2.2:8710/manager/digest/weekly", + "http://10.10.10.2:8710/manager/digest/weekly", ), ] { check_http_code(&mut counts, &http, name, url, &[200]); @@ -345,39 +342,6 @@ fn check_http_code(counts: &mut Counts, client: &Client, name: &str, url: &str, } } -fn check_http_redirect( - counts: &mut Counts, - client: &Client, - name: &str, - url: &str, - expected: &[u16], - expected_location: Option<&str>, -) { - match client.head(url).send() { - Ok(response) => { - let code = response.status().as_u16(); - let location = response - .headers() - .get(reqwest::header::LOCATION) - .and_then(|value| value.to_str().ok()) - .unwrap_or(""); - let location_ok = expected_location.is_none_or(|expected| location.contains(expected)); - if expected.contains(&code) && location_ok { - counts.pass(format!( - "{name} HTTP {code} {}", - if location.is_empty() { url } else { location } - )); - } else { - counts.fail(format!( - "{name} HTTP {code} {}", - if location.is_empty() { url } else { location } - )); - } - } - Err(err) => counts.fail(format!("{name} HTTP error {url}: {err}")), - } -} - fn check_command(counts: &mut Counts, name: &str, cmd: &str, args: &[&str]) { match command_output(cmd, args) { Ok(out) => { diff --git a/adk-rust/crates/detmir-portal/src/contracts/openapi.json b/adk-rust/crates/detmir-portal/src/contracts/openapi.json index 3eabca5..b3df61a 100644 --- a/adk-rust/crates/detmir-portal/src/contracts/openapi.json +++ b/adk-rust/crates/detmir-portal/src/contracts/openapi.json @@ -1265,6 +1265,24 @@ "minimum": 0, "maximum": 100 }, + "confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low", + "unknown" + ] + }, + "classification": { + "type": "string", + "enum": [ + "confirmed_risk", + "likely_risk", + "needs_investigation", + "insufficient_data" + ] + }, "title": { "type": "string" }, @@ -1310,6 +1328,8 @@ "ok", "score", "severity", + "confidence", + "classification", "score_components", "reason_codes", "model" @@ -1339,6 +1359,54 @@ "critical" ] }, + "confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low", + "unknown" + ] + }, + "confidence_score": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "maximum": 1 + }, + "classification": { + "type": "string", + "enum": [ + "confirmed_risk", + "likely_risk", + "needs_investigation", + "insufficient_data" + ] + }, + "classification_reason": { + "type": "string" + }, + "confidence_reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "confidence_contributors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JsonObject" + } + }, + "evidence_status": { + "type": "string", + "enum": [ + "available", + "not_available" + ] + }, "score_components": { "type": "object", "required": [ diff --git a/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts b/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts index 6e7486c..1cb8af9 100644 --- a/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts +++ b/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts @@ -70,6 +70,8 @@ export interface RiskNarrative { }; risk_level: "low" | "guarded" | "medium" | "high" | "critical" | string; risk_score: number; + confidence?: "high" | "medium" | "low" | "unknown" | string; + classification?: "confirmed_risk" | "likely_risk" | "needs_investigation" | "insufficient_data" | string; title: string; summary: string; why: string[]; @@ -257,6 +259,13 @@ export interface UebaResponse { score: number | null; severity: "normal" | "low" | "medium" | "high" | "critical" | string; status?: string; + confidence: "high" | "medium" | "low" | "unknown" | string; + confidence_score?: number | null; + classification: "confirmed_risk" | "likely_risk" | "needs_investigation" | "insufficient_data" | string; + classification_reason?: string; + confidence_reasons: string[]; + confidence_contributors?: JsonObject[]; + evidence_status?: "available" | "not_available" | string; score_components: { activity_anomaly: number; time_anomaly: number; diff --git a/adk-rust/crates/detmir-portal/src/executive_actions.rs b/adk-rust/crates/detmir-portal/src/executive_actions.rs index 4c7cf22..078055c 100644 --- a/adk-rust/crates/detmir-portal/src/executive_actions.rs +++ b/adk-rust/crates/detmir-portal/src/executive_actions.rs @@ -88,6 +88,7 @@ fn generate_actions(report: &Value) -> Vec { let mut actions = Vec::new(); add_workforce_kpi_action(report, &mut actions); add_coverage_action(report, &mut actions); + add_ueba_confidence_action(report, &mut actions); add_ueba_action(report, &mut actions); add_security_correlation_action(report, &mut actions); add_incident_candidate_action(report, &mut actions); @@ -186,6 +187,68 @@ fn add_coverage_action(report: &Value, actions: &mut Vec) { }); } +fn add_ueba_confidence_action(report: &Value, actions: &mut Vec) { + let score = report + .pointer("/ueba_risk/score") + .and_then(Value::as_u64) + .unwrap_or(0); + let explicit_confidence = report + .pointer("/ueba_risk/confidence_level") + .and_then(Value::as_str); + let explicit_classification = report + .pointer("/ueba_risk/classification") + .and_then(Value::as_str); + if score < 70 && explicit_confidence.is_none() && explicit_classification.is_none() { + return; + } + let confidence = report + .pointer("/ueba_risk/confidence_level") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let classification = report + .pointer("/ueba_risk/classification") + .and_then(Value::as_str) + .unwrap_or("insufficient_data"); + if score < 70 && !matches!(classification, "needs_investigation" | "insufficient_data") { + return; + } + if !matches!(confidence, "low" | "unknown") + && !matches!(classification, "needs_investigation" | "insufficient_data") + { + return; + } + let reasons = report + .pointer("/ueba_risk/confidence_reasons") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .take(3) + .map(ToString::to_string) + .collect::>() + }) + .unwrap_or_default(); + let mut evidence = vec![ + format!("UEBA confidence: {confidence}"), + format!("UEBA classification: {classification}"), + ]; + evidence.extend(reasons); + actions.push(ExecutiveAction { + priority: ActionPriority::Critical, + title: "Проверить полноту данных".to_string(), + summary: "Перед жестким выводом по UEBA нужно подтвердить покрытие, свежесть и полноту телеметрии" + .to_string(), + owner_role: ActionOwnerRole::Admin, + recommended_deadline: "4h".to_string(), + reason_codes: vec![ + "LOW_UEBA_CONFIDENCE".to_string(), + "CHECK_DATA_COMPLETENESS".to_string(), + ], + evidence, + }); +} + fn add_ueba_action(report: &Value, actions: &mut Vec) { let score = report .pointer("/ueba_risk/score") @@ -389,6 +452,13 @@ mod tests { .iter() .any(|code| code == "LOW_WORKFORCE_KPI") })); + assert!(actions.iter().any(|item| { + item["reason_codes"] + .as_array() + .unwrap() + .iter() + .any(|code| code == "LOW_UEBA_CONFIDENCE") + })); assert!(actions.iter().any(|item| item["priority"] == "critical")); } diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index a15cff4..a79a34f 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -3765,6 +3765,16 @@ fn build_ueba_api_payload(report: &Value, role: PortalRole) -> Value { "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")), + "confidence": report.pointer("/ueba_risk/confidence_level").cloned().unwrap_or_else(|| json!("unknown")), + "confidence_score": report.pointer("/ueba_risk/confidence_score") + .or_else(|| report.pointer("/ueba_risk/confidence")) + .cloned() + .unwrap_or(Value::Null), + "classification": report.pointer("/ueba_risk/classification").cloned().unwrap_or_else(|| json!("insufficient_data")), + "classification_reason": report.pointer("/ueba_risk/classification_reason").cloned().unwrap_or_else(|| json!("confidence_unknown")), + "confidence_reasons": report.pointer("/ueba_risk/confidence_reasons").cloned().unwrap_or_else(|| json!([])), + "confidence_contributors": report.pointer("/ueba_risk/confidence_contributors").cloned().unwrap_or_else(|| json!([])), + "evidence_status": report.pointer("/ueba_risk/evidence_status").cloned().unwrap_or_else(|| json!("not_available")), "score_components": report.pointer("/ueba_risk/score_components").cloned().unwrap_or_else(|| json!({ "activity_anomaly": 0, "time_anomaly": 0, @@ -6370,6 +6380,288 @@ fn ueba_confidence( (confidence.clamp(0.0, 1.0) * 100.0).round() / 100.0 } +fn confidence_contributor( + name: &str, + level: &str, + reason: &str, + detail: impl Into, +) -> Value { + json!({ + "name": name, + "level": level, + "reason": reason, + "detail": detail.into(), + }) +} + +fn coverage_confidence_level(value: u8, expected_nodes: usize) -> &'static str { + if expected_nodes == 0 { + "unknown" + } else if value >= 80 { + "high" + } else if value >= 50 { + "medium" + } else { + "low" + } +} + +fn ueba_evidence_status(metrics: &ReportMetrics) -> &'static str { + if metrics.evidence_screenshots > 0 || metrics.evidence_total > 0 { + "available" + } else { + "not_available" + } +} + +fn ueba_confidence_guardrails( + snapshot: &Snapshot, + metrics: &ReportMetrics, + workforce_policy: &Value, + ueba_baseline: &Value, + reasons: &[Value], + score: u64, +) -> ( + String, + String, + String, + Vec, + Vec, + &'static str, +) { + let mut contributors = Vec::new(); + let coverage = snapshot.agent_coverage_sla.coverage_pct; + let freshness = snapshot.agent_coverage_sla.freshness_pct; + let expected_nodes = snapshot.agent_coverage_sla.expected_nodes; + let coverage_level = coverage_confidence_level(coverage, expected_nodes); + contributors.push(confidence_contributor( + "agent_coverage", + coverage_level, + if expected_nodes == 0 { + "expected_nodes_not_configured" + } else if coverage < 80 { + "coverage_below_target" + } else { + "coverage_ok" + }, + format!("coverage={coverage}%, expected_nodes={expected_nodes}"), + )); + + let freshness_level = coverage_confidence_level(freshness, expected_nodes); + contributors.push(confidence_contributor( + "data_freshness", + freshness_level, + if expected_nodes == 0 { + "freshness_scope_unknown" + } else if freshness < 80 { + "freshness_below_target" + } else { + "fresh_data" + }, + format!("freshness={freshness}%"), + )); + + let default_weight_apps = workforce_policy + .get("policy_audit") + .and_then(|audit| audit.get("default_weight_applications")) + .and_then(Value::as_u64) + .unwrap_or(0); + let telemetry_gaps = [ + !snapshot.worktime.ok, + !snapshot.worktime_management.ok, + metrics.users_count == 0, + metrics.apps_count == 0, + default_weight_apps > 0, + ] + .into_iter() + .filter(|gap| *gap) + .count(); + let telemetry_level = if telemetry_gaps == 0 { + "high" + } else if telemetry_gaps <= 2 { + "medium" + } else { + "low" + }; + contributors.push(confidence_contributor( + "telemetry_completeness", + telemetry_level, + if telemetry_gaps == 0 { + "telemetry_complete" + } else { + "telemetry_missing_or_unclassified" + }, + format!("gap_count={telemetry_gaps}, default_weight_apps={default_weight_apps}"), + )); + + let evidence_status = ueba_evidence_status(metrics); + let evidence_level = if metrics.evidence_screenshots > 0 { + "high" + } else if metrics.evidence_total > 0 { + "medium" + } else if score >= 70 { + "low" + } else { + "medium" + }; + contributors.push(confidence_contributor( + "evidence_presence", + evidence_level, + if evidence_status == "available" { + "evidence_available" + } else { + "evidence_not_available" + }, + format!( + "items={}, screenshots={}", + metrics.evidence_total, metrics.evidence_screenshots + ), + )); + + let baseline_samples = ueba_baseline + .get("baseline_samples") + .and_then(Value::as_object) + .and_then(|items| items.get("total")) + .and_then(Value::as_u64) + .unwrap_or(0); + let user_baseline = ueba_baseline + .get("user_baseline_available") + .and_then(Value::as_bool) + .unwrap_or(false); + let department_baseline = ueba_baseline + .get("department_baseline_available") + .and_then(Value::as_bool) + .unwrap_or(false); + let history_level = if user_baseline && department_baseline && baseline_samples >= 6 { + "high" + } else if (user_baseline || department_baseline) && baseline_samples >= 3 { + "medium" + } else if baseline_samples > 0 { + "low" + } else { + "unknown" + }; + contributors.push(confidence_contributor( + "history_depth", + history_level, + if baseline_samples == 0 { + "baseline_missing" + } else if history_level == "high" { + "baseline_ready" + } else { + "baseline_limited" + }, + format!( + "samples={baseline_samples}, user_baseline={user_baseline}, department_baseline={department_baseline}" + ), + )); + + let mut sources = Vec::new(); + for reason in reasons { + if let Some(source) = reason.get("source").and_then(Value::as_str) { + if !sources.iter().any(|item| item == source) { + sources.push(source.to_string()); + } + } + } + let has_dlp = reasons.iter().any(|reason| { + reason + .get("source") + .and_then(Value::as_str) + .is_some_and(|source| source == "dlp") + }); + let has_workforce = sources.iter().any(|source| source == "workforce"); + let has_history = sources + .iter() + .any(|source| source == "baseline" || source == "incidents"); + let signal_level = if has_dlp && has_workforce && has_history { + "high" + } else if sources.len() >= 2 && evidence_status == "available" { + "medium" + } else if score >= 70 && has_workforce && has_history { + "low" + } else if sources.is_empty() { + "unknown" + } else { + "medium" + }; + contributors.push(confidence_contributor( + "signal_consistency", + signal_level, + match signal_level { + "high" => "multiple_corroborating_signals", + "medium" => "partial_corroboration", + "low" => "weak_corroboration", + _ => "signals_missing", + }, + format!( + "source_count={}, sources={}", + sources.len(), + sources.join(",") + ), + )); + + let levels = contributors + .iter() + .filter_map(|item| item.get("level").and_then(Value::as_str)) + .collect::>(); + let confidence_level = if levels.iter().all(|level| *level == "unknown") { + "unknown" + } else if levels.contains(&"low") { + "low" + } else if levels + .iter() + .any(|level| *level == "medium" || *level == "unknown") + { + "medium" + } else { + "high" + }; + + let classification = + if confidence_level == "unknown" || (score == 0 && confidence_level == "low") { + "insufficient_data" + } else if confidence_level == "low" && score >= 70 { + "needs_investigation" + } else if confidence_level == "high" && score >= 70 { + "confirmed_risk" + } else if score >= 15 { + "likely_risk" + } else { + "insufficient_data" + }; + + let mut confidence_reasons = contributors + .iter() + .filter(|item| { + item.get("level") + .and_then(Value::as_str) + .is_some_and(|level| level == "low" || level == "unknown") + }) + .filter_map(|item| { + let name = item.get("name").and_then(Value::as_str)?; + let reason = item.get("reason").and_then(Value::as_str)?; + Some(format!("{name}:{reason}")) + }) + .collect::>(); + if confidence_reasons.is_empty() { + confidence_reasons.push("confidence_inputs_acceptable".to_string()); + } + let classification_reason = confidence_reasons + .first() + .cloned() + .unwrap_or_else(|| "confidence_inputs_acceptable".to_string()); + + ( + confidence_level.to_string(), + classification.to_string(), + classification_reason, + confidence_reasons, + contributors, + evidence_status, + ) +} + fn risk_sources(reasons: &[Value]) -> Vec { let mut out = Vec::new(); for reason in reasons { @@ -6578,6 +6870,21 @@ fn build_ueba_risk( let (level, status) = ueba_risk_level(score); let score_components = ueba_score_components(&reasons, score); let reason_codes = ueba_reason_codes(&reasons); + let ( + confidence_level, + classification, + classification_reason, + confidence_reasons, + confidence_contributors, + evidence_status, + ) = ueba_confidence_guardrails( + snapshot, + metrics, + workforce_policy, + ueba_baseline, + &reasons, + score, + ); let calculated_from = ueba_calculated_from( metrics, workforce_policy, @@ -6598,6 +6905,13 @@ fn build_ueba_risk( "score_components": score_components, "reason_codes": reason_codes, "confidence": confidence, + "confidence_score": confidence, + "confidence_level": confidence_level, + "classification": classification, + "classification_reason": classification_reason, + "confidence_reasons": confidence_reasons, + "confidence_contributors": confidence_contributors, + "evidence_status": evidence_status, "risk_sources": risk_sources, "baseline_status": ueba_baseline .get("baseline_status") @@ -7331,6 +7645,20 @@ fn append_risk_narrative_markdown(text: &mut String, narrative: &Value) { .and_then(Value::as_u64) .unwrap_or(0) )); + text.push_str(&format!( + "- Уверенность: {}\n", + narrative + .get("confidence") + .and_then(Value::as_str) + .unwrap_or("unknown") + )); + text.push_str(&format!( + "- Классификация: {}\n", + narrative + .get("classification") + .and_then(Value::as_str) + .unwrap_or("insufficient_data") + )); text.push_str(&format!( "- Вывод: {}\n", narrative @@ -8094,6 +8422,18 @@ fn append_ueba_risk_markdown(text: &mut String, risk: &Value) { .unwrap_or(0.0) * 100.0 )); + text.push_str(&format!( + "- Уровень уверенности: {}\n", + risk.get("confidence_level") + .and_then(Value::as_str) + .unwrap_or("unknown") + )); + text.push_str(&format!( + "- Классификация: {}\n", + risk.get("classification") + .and_then(Value::as_str) + .unwrap_or("insufficient_data") + )); text.push_str(&format!( "- Обычный профиль: {}\n", risk.get("baseline_status") @@ -8130,6 +8470,37 @@ fn append_ueba_risk_markdown(text: &mut String, risk: &Value) { if let Some(note) = risk.get("note").and_then(Value::as_str) { text.push_str(&format!("- Примечание: {note}\n")); } + text.push_str("\n## UEBA Confidence\n\n"); + text.push_str(&format!( + "- Severity: {}\n", + risk.get("level") + .and_then(Value::as_str) + .unwrap_or("unknown") + )); + text.push_str(&format!( + "- Confidence: {}\n", + risk.get("confidence_level") + .and_then(Value::as_str) + .unwrap_or("unknown") + )); + text.push_str(&format!( + "- Classification: {}\n", + risk.get("classification") + .and_then(Value::as_str) + .unwrap_or("insufficient_data") + )); + text.push_str(&format!( + "- Evidence status: {}\n", + risk.get("evidence_status") + .and_then(Value::as_str) + .unwrap_or("not_available") + )); + append_string_list_markdown( + text, + "### Confidence reasons", + risk.get("confidence_reasons").and_then(Value::as_array), + "confidence reasons are not available", + ); text.push_str("\n### Причины риска\n\n"); let reasons = risk .get("reasons") @@ -10953,6 +11324,9 @@ mod tests { let ueba = build_ueba_api_payload(&report, PortalRole::Security); assert_eq!(ueba["score"], 55); assert_eq!(ueba["severity"], "medium"); + assert_eq!(ueba["confidence"], "unknown"); + assert_eq!(ueba["classification"], "insufficient_data"); + assert!(ueba["confidence_reasons"].as_array().unwrap().is_empty()); assert_eq!(ueba["score_components"]["activity_anomaly"], 15); assert_eq!(ueba["score_components"]["application_anomaly"], 20); assert_eq!(ueba["reason_codes"][0], "activity_anomaly"); @@ -10970,6 +11344,96 @@ mod tests { assert!(!text.contains("192.168.")); } + #[test] + fn ueba_confidence_guardrails_separate_severity_from_confirmation() { + fn ok_source() -> SourceStatus { + SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "ok".to_string(), + error: None, + payload: None, + } + } + + let dir = tempfile::tempdir().unwrap(); + let policy_path = dir.path().join("ueba-policy.yaml"); + let snapshot = Snapshot { + generated_at_utc: "2026-06-07T10:00:00Z".to_string(), + detmir_status: ok_source(), + detmir_check: ok_source(), + failed_units: ok_source(), + worktime: ok_source(), + worktime_management: ok_source(), + one_c: ok_source(), + one_c_overview: ok_source(), + agent_quality: AgentQuality::default(), + agent_quality_history: Vec::new(), + agent_quality_history_summary: AgentQualityHistorySummary::default(), + agent_quality_nodes: Vec::new(), + agent_quality_nodes_summary: AgentQualityNodesSummary::default(), + agent_coverage_sla: AgentCoverageSla { + expected_nodes: 1, + reporting_nodes_24h: 0, + stale_nodes: 1, + missing_nodes: 0, + coverage_pct: 0, + freshness_pct: 0, + sla_status: "CRITICAL".to_string(), + problem_nodes: Vec::new(), + }, + security_events_summary: SecurityEventsSummary::disabled(), + }; + let metrics = ReportMetrics { + users_count: 1, + active_seconds: 0, + apps_count: 0, + dlp_ok: 0, + dlp_warn: 0, + dlp_fail: 0, + evidence_total: 0, + evidence_screenshots: 0, + open_incidents: 1, + acknowledged_incidents: 0, + workforce_index: Some(0), + }; + let insights = (0..8) + .map(|_| json!({"status": "WARN", "label": "Просадка активности", "value": "drop"})) + .collect::>(); + let risk = build_ueba_risk( + &snapshot, + &metrics, + &json!({"configured": true, "policy_audit": {"default_weight_applications": 0}}), + &insights, + &json!({ + "baseline_window_days": 30, + "user_baseline_available": true, + "department_baseline_available": false, + "deviation_score": 15, + "baseline_samples": {"users": 19, "departments": 21, "total": 40} + }), + &policy_path, + ); + assert_eq!(risk["score"], 100); + assert_eq!(risk["level"], "critical"); + assert_eq!(risk["confidence_level"], "low"); + assert_eq!(risk["classification"], "needs_investigation"); + assert_eq!(risk["evidence_status"], "not_available"); + assert!( + risk["confidence_reasons"] + .as_array() + .unwrap() + .iter() + .any(|item| { + item.as_str() + .unwrap() + .contains("agent_coverage:coverage_below_target") + }) + ); + assert_eq!(risk["score_components"]["network_anomaly"], 0); + assert_eq!(risk["score_components"]["application_anomaly"], 0); + } + #[test] fn links_are_gateway_relative() { let links = links(); diff --git a/adk-rust/crates/detmir-portal/src/risk_narrative.rs b/adk-rust/crates/detmir-portal/src/risk_narrative.rs index e15848d..373e60d 100644 --- a/adk-rust/crates/detmir-portal/src/risk_narrative.rs +++ b/adk-rust/crates/detmir-portal/src/risk_narrative.rs @@ -39,6 +39,8 @@ pub(crate) struct RiskNarrativeInputs<'a> { struct NarrativeSignal { score: u8, level: &'static str, + confidence: String, + classification: String, why: Vec, evidence: Vec, recommended_actions: Vec, @@ -62,6 +64,8 @@ pub(crate) fn build_risk_narrative( let mut signal = NarrativeSignal { score: 0, level: "low", + confidence: "unknown".to_string(), + classification: "insufficient_data".to_string(), why: Vec::new(), evidence: Vec::new(), recommended_actions: Vec::new(), @@ -74,6 +78,7 @@ pub(crate) fn build_risk_narrative( add_workforce_kpi_signal(&mut signal, inputs.workforce_kpi_explain); add_ueba_signal(&mut signal, inputs.ueba_risk); + add_ueba_confidence_guardrails(&mut signal, inputs.ueba_risk); add_coverage_signal( &mut signal, inputs.agent_coverage_sla, @@ -105,6 +110,8 @@ pub(crate) fn build_risk_narrative_from_report( let mut signal = NarrativeSignal { score: 0, level: "low", + confidence: "unknown".to_string(), + classification: "insufficient_data".to_string(), why: Vec::new(), evidence: Vec::new(), recommended_actions: Vec::new(), @@ -122,6 +129,7 @@ pub(crate) fn build_risk_narrative_from_report( report.get("workforce_kpi_explain").unwrap_or(&Value::Null), ); add_ueba_signal(&mut signal, report.get("ueba_risk").unwrap_or(&Value::Null)); + add_ueba_confidence_guardrails(&mut signal, report.get("ueba_risk").unwrap_or(&Value::Null)); add_coverage_from_report_signal(&mut signal, report); let selected_heatmap = select_heatmap_value( report.get("risk_heatmap").and_then(Value::as_array), @@ -331,6 +339,36 @@ fn add_ueba_signal(signal: &mut NarrativeSignal, risk: &Value) { )); } +fn add_ueba_confidence_guardrails(signal: &mut NarrativeSignal, risk: &Value) { + let confidence = risk + .get("confidence_level") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let classification = risk + .get("classification") + .and_then(Value::as_str) + .unwrap_or("insufficient_data"); + signal.confidence = confidence.to_string(); + signal.classification = classification.to_string(); + if matches!(confidence, "low" | "unknown") { + push_unique(&mut signal.why, "Уверенность в выводе ниже целевого уровня"); + push_unique( + &mut signal.recommended_actions, + "Проверить полноту данных до управленческого вывода", + ); + push_unique( + &mut signal.limitations, + "Низкая уверенность не подтверждает инцидент без ручной проверки", + ); + } + if classification == "needs_investigation" { + push_unique( + &mut signal.recommended_actions, + "Зафиксировать статус Needs Investigation и передать на ручной разбор", + ); + } +} + fn add_coverage_signal( signal: &mut NarrativeSignal, sla: &AgentCoverageSla, @@ -773,6 +811,8 @@ fn narrative_payload( }, "risk_level": signal.level, "risk_score": signal.score, + "confidence": signal.confidence, + "classification": signal.classification, "title": risk_title(signal.level), "summary": risk_summary(signal.level, signal.department.as_deref(), &signal.why), "why": signal.why, diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 5e82ae3..a4575c9 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -691,6 +691,14 @@ function renderRiskNarrative(report) { Главный вывод ${ui(narrative.title || "Риск не рассчитан")} +
+ Уверенность + ${ui(narrative.confidence || "unknown")} +
+
+ Классификация + ${ui(narrative.classification || "insufficient_data")} +
Модель ${ui(narrative.model?.type || "rule_based")} @@ -2216,7 +2224,10 @@ function renderUebaRisk(risk) { const reasons = Array.isArray(risk.reasons) ? risk.reasons.slice(0, 12) : []; const sources = Array.isArray(risk.risk_sources) ? risk.risk_sources.join(", ") : "-"; const confidence = Number.isFinite(Number(risk.confidence)) ? `${Math.round(Number(risk.confidence) * 100)}%` : "0%"; - const baselineReady = `user: ${risk.user_baseline_available ? "yes" : "no"} · dept: ${risk.department_baseline_available ? "yes" : "no"}`; + const confidenceLevel = risk.confidence_level || "unknown"; + const classification = risk.classification || "insufficient_data"; + const evidenceStatus = risk.evidence_status || "not_available"; + const baselineReady = `пользователь: ${risk.user_baseline_available ? "да" : "нет"} · подразделение: ${risk.department_baseline_available ? "да" : "нет"}`; return `
@@ -2229,6 +2240,13 @@ function renderUebaRisk(risk) {
${escapeHtml(risk.level || "unknown")} · ${escapeHtml(risk.score ?? 0)}/100
+
+
Уровень${ui(risk.level || "unknown")}
+
Уверенность${ui(confidenceLevel)}
+
Классификация${ui(classification)}
+
Материалы${ui(evidenceStatus === "available" ? "доступны" : "нет")}
+
+ ${(Array.isArray(risk.confidence_reasons) && risk.confidence_reasons.length) ? `

Причины уверенности: ${risk.confidence_reasons.slice(0, 4).map(ui).join(" · ")}

` : ""}
${reasons.length ? reasons.map(item => `
${ui(item.label || item.code || "-")} diff --git a/adk-rust/crates/hayabusa-tools/src/bin/autoprocess.rs b/adk-rust/crates/hayabusa-tools/src/bin/autoprocess.rs index 2af8641..f4502bf 100644 --- a/adk-rust/crates/hayabusa-tools/src/bin/autoprocess.rs +++ b/adk-rust/crates/hayabusa-tools/src/bin/autoprocess.rs @@ -120,8 +120,6 @@ fn process_one(zip_path: &Path) -> Result { "process-inbox".to_string(), "--mode".to_string(), mode.clone(), - "--limit".to_string(), - "1".to_string(), ], )?; let latest = read_json_file(Path::new(LATEST_INTAKE))?; diff --git a/adk-rust/crates/hayabusa-tools/src/lib.rs b/adk-rust/crates/hayabusa-tools/src/lib.rs index 8dc3618..1049bb0 100644 --- a/adk-rust/crates/hayabusa-tools/src/lib.rs +++ b/adk-rust/crates/hayabusa-tools/src/lib.rs @@ -92,7 +92,8 @@ fn decode_optional_json(body: String) -> Result { pub fn read_json_file(path: &Path) -> Result { let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; - serde_json::from_str(&text).with_context(|| format!("decode {}", path.display())) + serde_json::from_str(text.trim_start_matches('\u{feff}')) + .with_context(|| format!("decode {}", path.display())) } pub fn write_json_pretty(value: &Value) -> Result { diff --git a/ansible/README.md b/ansible/README.md index 796c48f..a309023 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -92,8 +92,8 @@ ansible-playbook -i inventory.ini provision_proxmox_ct_matrix_and_deploy_aw.yml Важно: - `WinRM` здесь остаётся транспортом для `Ansible deploy` и `validation`; -- для интерактивной PowerShell-работы из Linux/Codex по DetMir используйте project MCP-over-SSH путь, а не `WSMan`; -- каноника лежит в `docs/DETMIR_POWERSHELL_MCP_REMOTE_RU.md` и `scripts/install_detmir_powershell_mcp.sh`. +- для интерактивной PowerShell-работы из Linux/Codex по AWatch-rus используйте project MCP-over-SSH путь, а не `WSMan`; +- каноника лежит в `docs/POWERSHELL_MCP_REMOTE_RU.md` и `scripts/install_detmir_powershell_mcp.sh`. 1. Подготовьте inventory и vars: - `cp ansible/inventory.example.ini ansible/inventory.ini` @@ -157,6 +157,7 @@ Playbook: - `aw_windows_hayabusa_auto_upload_hours_back: 6` — lookback для каждого запуска; - `aw_windows_hayabusa_auto_upload_mode: "incident"` — mode для server-side processing; - `aw_windows_hayabusa_auto_upload_task_name: "ActivityWatch Hayabusa Upload"` — имя scheduled task. +- `aw_windows_hayabusa_auto_upload_run_as_user: "Администратор"` — production principal для scheduled task на RDP-хосте. На `SHARKON2025` запуск `powershell.exe` из `SYSTEM` возвращал `0xC0000142`, поэтому авто-upload должен идти как interactive/highest task от локального администратора. ## Server-side Hayabusa auto-case и Telegram alerting @@ -176,6 +177,8 @@ Playbook: - пишет bounded metadata в `forensics.hayabusa`; - отправляет Telegram alert. +Для Windows direct upload пользователь `awops` на AW-server должен иметь право записи в `/opt/activitywatch/aw-rus-ops/drop`; нормальное состояние каталога: owner/group `awops:awops`, mode `0750`. Unit `aw-hayabusa-drop.service` работает от root и после обработки очищает `drop`. + Основные vars: - `aw_hayabusa_auto_case_enabled: true` @@ -232,9 +235,9 @@ Playbook: По умолчанию импортируются: -- `DetMir: Работа пользователей в RDP` -- `DetMir: DLP и ИБ обзор` -- `DetMir: ИБ сводка для руководства` +- `AWatch-rus: Работа пользователей в RDP` +- `AWatch-rus: DLP и ИБ обзор` +- `AWatch-rus: ИБ сводка для руководства` - `AW-rus: DLP обзор` Подробная документация: `docs/GRAFANA_DASHBOARDS_RU.md` diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index d4710c9..a807e93 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -12,6 +12,7 @@ aw_release_install_dir: "{{ aw_release_root }}/aw-server-rust-{{ aw_server_version }}" aw_ru_patch_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-ru-patch.js') | hash('sha1') | truncate(12, true, '') }}" aw_sw_cleanup_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-sw-cleanup.js') | hash('sha1') | truncate(12, true, '') }}" + aw_host_sanitize_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-host-sanitize.js') | hash('sha1') | truncate(12, true, '') }}" aw_worktime_classes: "{{ lookup('file', aw_repo_root + '/aw-server/settings/classes-worktime.json') | from_json }}" aw_default_views: "{{ lookup('file', aw_repo_root + '/aw-server/settings/views-default.json') | from_json }}" aw_rust_release_dir: "{{ (lookup('env', 'CARGO_TARGET_DIR') | default(aw_repo_root + '/adk-rust/target', true)) + '/release' }}" @@ -448,6 +449,7 @@ loop: - { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" } - { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" } + - { src: "{{ aw_repo_root }}/aw-server/aw-host-sanitize.js", dest: "{{ aw_server_webui_dir }}/js/aw-host-sanitize.js", mode: "0644" } - { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "{{ aw_server_webui_dir }}/js/aw-worktime-panel.js", mode: "0644" } - { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" } @@ -465,6 +467,7 @@ loop: - { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "/opt/detmir/bootstrap/aw-ru-patch.js", mode: "0644" } - { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "/opt/detmir/bootstrap/aw-sw-cleanup.js", mode: "0644" } + - { src: "{{ aw_repo_root }}/aw-server/aw-host-sanitize.js", dest: "/opt/detmir/bootstrap/aw-host-sanitize.js", mode: "0644" } - { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "/opt/detmir/bootstrap/aw-worktime-panel.js", mode: "0644" } - { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "/opt/detmir/bootstrap/aw-host-groups.json", mode: "0644" } @@ -615,7 +618,7 @@ (aw_monitored_windows_host | default('') | string) if ( (aw_monitored_windows_host | default('') | string | length) > 0 - and (aw_monitored_windows_host | default('') | string | regex_search('192\\.0\\.2\\.|198\\.51\\.100\\.|203\\.0\\.113\\.|<|>|HOST-EXAMPLE|WINDOWS_USER_EXAMPLE')) is none + and (aw_monitored_windows_host | default('') | string | regex_search('192\\.0\\.2\\.|198\\.51\\.100\\.|203\\.0\\.113\\.|<|>|WINDOWS_USER_EXAMPLE')) is none ) else aw_existing_monitored_windows_host }} @@ -624,7 +627,7 @@ (aw_monitored_windows_hostname | default('') | string) if ( (aw_monitored_windows_hostname | default('') | string | length) > 0 - and (aw_monitored_windows_hostname | default('') | string | regex_search('192\\.0\\.2\\.|198\\.51\\.100\\.|203\\.0\\.113\\.|<|>|HOST-EXAMPLE|WINDOWS_USER_EXAMPLE')) is none + and (aw_monitored_windows_hostname | default('') | string | regex_search('192\\.0\\.2\\.|198\\.51\\.100\\.|203\\.0\\.113\\.|<|>|WINDOWS_USER_EXAMPLE')) is none ) else aw_existing_monitored_windows_hostname }} @@ -637,7 +640,7 @@ (aw_worktime_host | default('') | string) if ( (aw_worktime_host | default('') | string | length) > 0 - and (aw_worktime_host | default('') | string | regex_search('192\\.0\\.2\\.|198\\.51\\.100\\.|203\\.0\\.113\\.|<|>|HOST-EXAMPLE|WINDOWS_USER_EXAMPLE')) is none + and (aw_worktime_host | default('') | string | regex_search('192\\.0\\.2\\.|198\\.51\\.100\\.|203\\.0\\.113\\.|<|>|WINDOWS_USER_EXAMPLE')) is none ) else aw_effective_monitored_windows_hostname }} @@ -647,11 +650,11 @@ ansible.builtin.assert: that: - (aw_effective_monitored_windows_host | default('') | string | length) > 0 - - (aw_effective_monitored_windows_host | default('') | string | regex_search('192\\.0\\.2\\.|198\\.51\\.100\\.|203\\.0\\.113\\.|<|>|HOST-EXAMPLE|WINDOWS_USER_EXAMPLE')) is none + - (aw_effective_monitored_windows_host | default('') | string | regex_search('192\\.0\\.2\\.|198\\.51\\.100\\.|203\\.0\\.113\\.|<|>|WINDOWS_USER_EXAMPLE')) is none - (aw_effective_monitored_windows_hostname | default('') | string | length) > 0 - - (aw_effective_monitored_windows_hostname | default('') | string | regex_search('192\\.0\\.2\\.|198\\.51\\.100\\.|203\\.0\\.113\\.|<|>|HOST-EXAMPLE|WINDOWS_USER_EXAMPLE')) is none + - (aw_effective_monitored_windows_hostname | default('') | string | regex_search('192\\.0\\.2\\.|198\\.51\\.100\\.|203\\.0\\.113\\.|<|>|WINDOWS_USER_EXAMPLE')) is none - (aw_effective_worktime_host | default('') | string | length) > 0 - - (aw_effective_worktime_host | default('') | string | regex_search('192\\.0\\.2\\.|198\\.51\\.100\\.|203\\.0\\.113\\.|<|>|HOST-EXAMPLE|WINDOWS_USER_EXAMPLE')) is none + - (aw_effective_worktime_host | default('') | string | regex_search('192\\.0\\.2\\.|198\\.51\\.100\\.|203\\.0\\.113\\.|<|>|WINDOWS_USER_EXAMPLE')) is none fail_msg: "aw-rus-healthd получил public example/TEST-NET Windows target. Задайте live значения в private inventory/env или сохраните их в runtime /etc/activitywatch/aw-server.env." when: aw_server_post_deploy_health_check_enabled | default(true) | bool @@ -685,7 +688,6 @@ - (aw_worktime_influx_bucket | default('') | string | length) > 0 - (aw_worktime_influx_bucket | default('') | string | lower | regex_search('example|change_me|changeme|replace-me|replace_me|your_|<|>')) is none - (aw_worktime_influx_hosts | default('') | string | length) > 0 - - "'HOST-EXAMPLE' not in (aw_worktime_influx_hosts | default('') | string)" - "'WINDOWS_USER_EXAMPLE' not in (aw_worktime_influx_hosts | default('') | string)" fail_msg: "aw_worktime_influx_enabled=true, но URL/org/bucket/hosts похожи на public example/TEST-NET значения. Задайте live значения в private inventory/env, не в public repo." when: aw_worktime_influx_enabled | default(false) | bool @@ -702,7 +704,6 @@ - (aw_dlp_influx_bucket | default('') | string | length) > 0 - (aw_dlp_influx_bucket | default('') | string | lower | regex_search('example|change_me|changeme|replace-me|replace_me|your_|<|>')) is none - (aw_dlp_influx_hosts | default('') | string | length) > 0 - - "'HOST-EXAMPLE' not in (aw_dlp_influx_hosts | default('') | string)" - "'WINDOWS_USER_EXAMPLE' not in (aw_dlp_influx_hosts | default('') | string)" fail_msg: "aw_dlp_influx_enabled=true, но URL/org/bucket/hosts похожи на public example/TEST-NET значения. Задайте live значения в private inventory/env, не в public repo." when: aw_dlp_influx_enabled | default(false) | bool @@ -724,8 +725,8 @@ AW_SERVER_GROUP={{ aw_server_group }} AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }} AW_WORKTIME_TZ={{ aw_worktime_timezone }} - AW_WORKTIME_HOST={{ aw_effective_worktime_host | default(aw_effective_monitored_windows_hostname | default('HOST-EXAMPLE')) }} - AW_WORKTIME_EVENTS_LIMIT={{ aw_worktime_events_limit | default(250) }} + AW_WORKTIME_HOST={{ aw_effective_worktime_host | default(aw_effective_monitored_windows_hostname | default('SHARKON2025')) }} + AW_WORKTIME_EVENTS_LIMIT={{ aw_worktime_events_limit | default(5000) }} AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS={{ aw_worktime_aw_http_timeout_seconds | default(6) }} AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS={{ aw_worktime_events_cache_ttl_seconds | default(300) }} AW_WORKTIME_REPORT_CACHE_TTL_SECONDS={{ aw_worktime_report_cache_ttl_seconds | default(300) }} @@ -747,7 +748,7 @@ AW_WORKTIME_INFLUX_URL={{ aw_worktime_influx_url | default('') }} AW_WORKTIME_INFLUX_ORG={{ aw_worktime_influx_org | default('proxmox') }} AW_WORKTIME_INFLUX_BUCKET={{ aw_worktime_influx_bucket | default('aw_metrics') }} - AW_WORKTIME_INFLUX_HOSTS={{ aw_worktime_influx_hosts | default('HOST-EXAMPLE') }} + AW_WORKTIME_INFLUX_HOSTS={{ aw_worktime_influx_hosts | default('SHARKON2025') }} AW_WORKTIME_INFLUX_DAYS={{ aw_worktime_influx_days | default('today,yesterday') }} AW_WORKTIME_INFLUX_TOKEN={{ aw_effective_worktime_influx_token | default('') }} AW_WORKTIME_MANAGEMENT_HISTORY_DIR={{ aw_worktime_management_history_dir | default(aw_server_data_dir ~ '/worktime-management-history') }} @@ -762,7 +763,7 @@ AW_DLP_INFLUX_URL={{ aw_dlp_influx_url | default('') }} AW_DLP_INFLUX_ORG={{ aw_dlp_influx_org | default('proxmox') }} AW_DLP_INFLUX_BUCKET={{ aw_dlp_influx_bucket | default('aw_metrics') }} - AW_DLP_INFLUX_HOSTS={{ aw_dlp_influx_hosts | default('HOST-EXAMPLE') }} + AW_DLP_INFLUX_HOSTS={{ aw_dlp_influx_hosts | default('SHARKON2025') }} AW_DLP_INFLUX_LOOKBACK_DAYS={{ aw_dlp_influx_lookback_days | default(30) }} AW_DLP_INFLUX_EVENT_LIMIT={{ aw_dlp_influx_event_limit | default(2000) }} AW_DLP_INFLUX_TOKEN={{ aw_effective_dlp_influx_token | default('') }} @@ -1940,7 +1941,7 @@ - name: Удалить старые теги RU patch из index.html ansible.builtin.replace: path: "{{ aw_server_webui_dir }}/index.html" - regexp: ']+(?:ru-patch-v5\.js|sw-cleanup\.js|aw-ru-patch\.js|aw-sw-cleanup\.js)[^>]*>' + regexp: ']+(?:ru-patch-v5\.js|sw-cleanup\.js|aw-host-sanitize\.js|aw-ru-patch\.js|aw-sw-cleanup\.js)[^>]*>' replace: '' - name: Добавить cleanup script RU patch в index.html @@ -1949,6 +1950,12 @@ regexp: '' replace: '' + - name: Добавить ранний guard hostname перед WebUI bundle + ansible.builtin.replace: + path: "{{ aw_server_webui_dir }}/index.html" + regexp: '()' + replace: '\1' + - name: Добавить загрузчик RU patch перед закрытием body ansible.builtin.replace: path: "{{ aw_server_webui_dir }}/index.html" @@ -2584,6 +2591,22 @@ - /opt/activitywatch/aw-rus-ops/ansible - /opt/activitywatch/aw-rus-ops/drop + - name: Проверить наличие upload user awops для Hayabusa drop-zone + ansible.builtin.command: + cmd: id -u awops + register: aw_hayabusa_drop_upload_user + changed_when: false + failed_when: false + + - name: Разрешить awops писать в Hayabusa drop-zone + ansible.builtin.file: + path: /opt/activitywatch/aw-rus-ops/drop + state: directory + owner: awops + group: awops + mode: "0750" + when: aw_hayabusa_drop_upload_user.rc == 0 + - name: Положить исходный wrapper в server-side ops bundle ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa.sh" diff --git a/ansible/deploy_aw_windows.yml b/ansible/deploy_aw_windows.yml index 568bcc8..20ff1e3 100644 --- a/ansible/deploy_aw_windows.yml +++ b/ansible/deploy_aw_windows.yml @@ -18,7 +18,7 @@ aw_windows_package_version: "v0.13.2" aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-windows-x86_64.zip" aw_windows_package_zip_path: "" - aw_windows_domain: "HOST-EXAMPLE" + aw_windows_domain: "SHARKON2025" aw_windows_builtin_administrator_name: "Администратор" aw_windows_users: - Администратор @@ -41,6 +41,7 @@ aw_windows_hayabusa_auto_upload_hours_back: 6 aw_windows_hayabusa_auto_upload_mode: "incident" aw_windows_hayabusa_auto_upload_task_name: "ActivityWatch Hayabusa Upload" + aw_windows_hayabusa_auto_upload_run_as_user: "" aw_windows_file_1c_auto_upload_enabled: true aw_windows_file_1c_auto_upload_interval_minutes: 15 aw_windows_file_1c_auto_upload_task_name: "ActivityWatch File1C Upload" @@ -171,18 +172,15 @@ - "{{ aw_windows_deploy_root }}" - "{{ aw_windows_deploy_root }}\\windows" - - name: Загрузить Windows toolkit развёртывания + - name: Загрузить Windows toolkit развёртывания (без PS-дубликатов — заменены Rust) ansible.windows.win_copy: src: "{{ aw_windows_repo_root }}/windows/{{ item }}" dest: "{{ aw_windows_deploy_root }}\\windows\\{{ item }}" loop: - ActivityWatch.Windows.Common.psd1 - ActivityWatch.Windows.Common.psm1 - - browser-domains-native-collector.ps1 - - dlp-endpoint-signals-collector.ps1 - dlp-policy-client.ps1 - email-outbound-collector.ps1 - - file-operations-collector.ps1 - worktime-session-collector.ps1 - export-evtx-for-hayabusa.ps1 - export-upload-hayabusa-to-aw-server.ps1 @@ -193,7 +191,6 @@ - deploy-ensemble.ps1 - hardening-recovery.ps1 - AWatchRusCollectorGuardService.cs - - aw-collector-guard.ps1 - install-collector-guard-service.ps1 - rebuild-worktime-tasks.ps1 - audit-cryptopro.ps1 @@ -315,6 +312,7 @@ HayabusaAutoUploadHoursBack = {{ aw_windows_hayabusa_auto_upload_hours_back | int }} HayabusaAutoUploadMode = "{{ aw_windows_hayabusa_auto_upload_mode }}" HayabusaAutoUploadTaskName = "{{ aw_windows_hayabusa_auto_upload_task_name }}" + HayabusaAutoUploadRunAsUser = "{{ aw_windows_hayabusa_auto_upload_run_as_user }}" File1CAutoUploadEnabled = {{ '$true' if (aw_windows_file_1c_auto_upload_enabled | bool) else '$false' }} File1CAutoUploadIntervalMinutes = {{ aw_windows_file_1c_auto_upload_interval_minutes | int }} File1CAutoUploadTaskName = "{{ aw_windows_file_1c_auto_upload_task_name }}" diff --git a/ansible/group_vars/aw_windows.yml b/ansible/group_vars/aw_windows.yml index c32ea75..3746d53 100644 --- a/ansible/group_vars/aw_windows.yml +++ b/ansible/group_vars/aw_windows.yml @@ -39,6 +39,7 @@ aw_windows_hayabusa_auto_upload_interval_hours: 6 aw_windows_hayabusa_auto_upload_hours_back: 6 aw_windows_hayabusa_auto_upload_mode: "incident" aw_windows_hayabusa_auto_upload_task_name: "ActivityWatch Hayabusa Upload" +aw_windows_hayabusa_auto_upload_run_as_user: "{{ aw_windows_builtin_administrator_name }}" aw_windows_afk_enabled: true aw_windows_window_enabled: true diff --git a/ansible/group_vars/windows.example.yml b/ansible/group_vars/windows.example.yml index c30c8f0..128cae6 100644 --- a/ansible/group_vars/windows.example.yml +++ b/ansible/group_vars/windows.example.yml @@ -9,6 +9,7 @@ aw_windows_hayabusa_auto_upload_interval_hours: 6 aw_windows_hayabusa_auto_upload_hours_back: 6 aw_windows_hayabusa_auto_upload_mode: "incident" aw_windows_hayabusa_auto_upload_task_name: "ActivityWatch Hayabusa Upload" +aw_windows_hayabusa_auto_upload_run_as_user: "" aw_windows_package_version: "v0.13.2" aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-windows-x86_64.zip" aw_windows_package_zip_path: "" diff --git a/aw-server/aw-host-sanitize.js b/aw-server/aw-host-sanitize.js new file mode 100644 index 0000000..44ccf1f --- /dev/null +++ b/aw-server/aw-host-sanitize.js @@ -0,0 +1,94 @@ +(function () { + "use strict"; + + var BAD_HOST = ["HOST", "EXAMPLE"].join("-"); + var DEFAULT_HOST = "SHARKON2025"; + + function decode(value) { + try { + return decodeURIComponent(value); + } catch (error) { + return value; + } + } + + function hostFromHash() { + var hash = window.location.hash || ""; + var match = hash.match(/#?\/activity\/([^/]+)/i) || hash.match(/#?\/trends\/([^/?#]+)/i); + var host = match && match[1] ? decode(match[1]) : ""; + if (host && host !== BAD_HOST && host !== "unknown" && host !== "undefined") return host; + return DEFAULT_HOST; + } + + function rewriteText(value) { + if (typeof value !== "string" || value.indexOf(BAD_HOST) === -1) return value; + return value.split(BAD_HOST).join(hostFromHash()); + } + + function sanitizeStorage(storage) { + if (!storage) return; + try { + for (var i = 0; i < storage.length; i += 1) { + var key = storage.key(i); + if (!key) continue; + var value = storage.getItem(key); + var next = rewriteText(value); + if (next !== value) storage.setItem(key, next); + } + if (storage.landingpage && storage.landingpage.indexOf(BAD_HOST) !== -1) { + storage.landingpage = "/activity/" + hostFromHash() + "/view/"; + } + } catch (error) { + } + } + + function sanitizeRoute() { + var hash = window.location.hash || ""; + var next = rewriteText(hash); + if (next !== hash) window.location.replace(next); + } + + sanitizeStorage(window.localStorage); + sanitizeStorage(window.sessionStorage); + sanitizeRoute(); + + var originalFetch = window.fetch; + if (typeof originalFetch === "function" && !originalFetch.__awHostSanitizePatched) { + var patchedFetch = function (input, init) { + var nextInput = input; + var nextInit = init; + try { + if (typeof nextInput === "string") { + nextInput = rewriteText(nextInput); + } else if (nextInput && typeof nextInput.url === "string") { + var nextUrl = rewriteText(nextInput.url); + if (nextUrl !== nextInput.url && typeof Request === "function") { + nextInput = new Request(nextUrl, nextInput); + } + } + if (nextInit && typeof nextInit.body === "string") { + nextInit = Object.assign({}, nextInit, { body: rewriteText(nextInit.body) }); + } + } catch (error) { + } + return originalFetch.call(this, nextInput, nextInit); + }; + patchedFetch.__awHostSanitizePatched = true; + window.fetch = patchedFetch; + } + + if (window.XMLHttpRequest && window.XMLHttpRequest.prototype && !window.XMLHttpRequest.prototype.__awHostSanitizePatched) { + var proto = window.XMLHttpRequest.prototype; + var originalOpen = proto.open; + var originalSend = proto.send; + proto.open = function (method, url) { + if (typeof url === "string") arguments[1] = rewriteText(url); + return originalOpen.apply(this, arguments); + }; + proto.send = function (body) { + if (typeof body === "string") body = rewriteText(body); + return originalSend.call(this, body); + }; + proto.__awHostSanitizePatched = true; + } +})(); diff --git a/aw-server/aw-server.env.example b/aw-server/aw-server.env.example index bdf4a33..910db38 100755 --- a/aw-server/aw-server.env.example +++ b/aw-server/aw-server.env.example @@ -16,8 +16,8 @@ AW_SERVER_GROUP=activitywatch AW_SERVER_PUBLIC_HOST=aw-server AW_WORKTIME_REPORT_BASE=http://aw-server:5610 AW_WORKTIME_TZ=Europe/Moscow -AW_WORKTIME_HOST=HOST-EXAMPLE -AW_WORKTIME_EVENTS_LIMIT=250 +AW_WORKTIME_HOST=SHARKON2025 +AW_WORKTIME_EVENTS_LIMIT=5000 AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS=6 AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS=300 AW_WORKTIME_REPORT_CACHE_TTL_SECONDS=300 @@ -50,10 +50,10 @@ AW_HEALTH_CHECK_ENABLED=true AW_HEALTH_CHECK_INTERVAL=60 AW_EXPECT_START_OF_DAY=00:00 AW_EXPECT_ALWAYS_ACTIVE_PATTERN=aw-watcher-window -AW_EXPECT_LANDINGPAGE=/#/activity/HOST-EXAMPLE/view/ +AW_EXPECT_LANDINGPAGE=/#/activity/SHARKON2025/view/ AW_HEALTH_STRICT_FILEOPS=0 AW_MONITORED_WINDOWS_HOST= -AW_MONITORED_WINDOWS_HOSTNAME=HOST-EXAMPLE +AW_MONITORED_WINDOWS_HOSTNAME=SHARKON2025 AW_RUS_HEALTH_WORKTIME_API=http://127.0.0.1:5610 AW_RUS_HEALTH_STATE_DIR=/var/lib/activitywatch/health AW_RUS_HEALTH_VALIDATION_DIR=/var/lib/activitywatch/health/windows-validation @@ -65,7 +65,7 @@ AW_RUS_SLO_WORKTIME_BASE=http://127.0.0.1:5610 AW_RUS_SLO_TARGET_PERCENT=99.97 AW_BROWSER_SMOKE_AW_BASE=http://127.0.0.1:5600 AW_BROWSER_SMOKE_WORKTIME_BASE=http://127.0.0.1:5610 -AW_BROWSER_SMOKE_HOST=HOST-EXAMPLE +AW_BROWSER_SMOKE_HOST=SHARKON2025 AW_BROWSER_SMOKE_OUTPUT_DIR=/var/lib/activitywatch/browser-smoke AW_BROWSER_SMOKE_KEEP_RUNS=24 AW_BROWSER_SMOKE_ENGINE=chromium-cli diff --git a/aw-server/hayabusa/README.md b/aw-server/hayabusa/README.md index 68799c1..bf17b5d 100644 --- a/aw-server/hayabusa/README.md +++ b/aw-server/hayabusa/README.md @@ -105,4 +105,17 @@ Server-side prerequisite for user `awops`: printf '%s\n' 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILoFWQmgoUJj1P7mp1/fB5aBkI3fVgjPme9jmK8Gh9jr igor@snb-live' | sudo tee /var/lib/awops/.ssh/authorized_keys >/dev/null sudo chown awops:awops /var/lib/awops/.ssh/authorized_keys sudo chmod 600 /var/lib/awops/.ssh/authorized_keys +sudo chown awops:awops /opt/activitywatch/aw-rus-ops/drop +sudo chmod 0750 /opt/activitywatch/aw-rus-ops/drop ``` + +Production scheduled task on `SHARKON2025`: + +- task name: `ActivityWatch Hayabusa Upload` +- action: `C:\ProgramData\AWatch-rus\export-upload-hayabusa-to-aw-server.ps1 -HoursBack 6 -Mode incident` +- principal: `Администратор`, `LogonType=Interactive`, `RunLevel=Highest` +- normal `LastTaskResult`: `0` + +Do not switch this task back to `SYSTEM` on the current RDP host: Task Scheduler starts `powershell.exe` under `SYSTEM`, but the process exits with `0xC0000142` before the upload script starts. + +Server-side processing accepts Windows zip packages with backslash path separators and UTF-8 BOM in sidecar JSON. `aw-hayabusa-autoprocess` processes the full incoming queue after accepting a drop package, so stale incoming files from an earlier failed run are drained before the latest intake is recorded. diff --git a/aw-server/hayabusa/aw-hayabusa.sh b/aw-server/hayabusa/aw-hayabusa.sh index 0f03548..a02bdd6 100644 --- a/aw-server/hayabusa/aw-hayabusa.sh +++ b/aw-server/hayabusa/aw-hayabusa.sh @@ -113,19 +113,36 @@ detect_host_from_manifest() { extract_zip_normalized() { local package_path="$1" local dest_dir="$2" - command -v zipinfo >/dev/null 2>&1 || fail "zipinfo is required to inspect ${package_path}" - command -v unzip >/dev/null 2>&1 || fail "unzip is required to extract ${package_path}" + command -v python3 >/dev/null 2>&1 || fail "python3 is required to extract ${package_path}" mkdir -p "${dest_dir}" - local entry normalized - while IFS= read -r entry; do - normalized="${entry//\\//}" - case "${normalized}" in - ""|.|/*|*"/../"*|../*|*"..") - fail "unsafe zip entry: ${entry}" - ;; - esac - done < <(zipinfo -1 "${package_path}") - unzip -q "${package_path}" -d "${dest_dir}" + python3 - "${package_path}" "${dest_dir}" <<'PY' +import os +import shutil +import sys +import zipfile + +package_path = sys.argv[1] +dest_dir = os.path.abspath(sys.argv[2]) + +with zipfile.ZipFile(package_path) as archive: + for info in archive.infolist(): + name = info.filename.replace("\\", "/") + is_dir = info.is_dir() or name.endswith("/") + if is_dir: + name = name.rstrip("/") + parts = [part for part in name.split("/") if part] + if not parts or name.startswith("/") or any(part in (".", "..") for part in parts): + raise SystemExit(f"unsafe zip entry: {info.filename}") + target_path = os.path.abspath(os.path.join(dest_dir, *parts)) + if os.path.commonpath([dest_dir, target_path]) != dest_dir: + raise SystemExit(f"unsafe zip entry: {info.filename}") + if is_dir: + os.makedirs(target_path, exist_ok=True) + continue + os.makedirs(os.path.dirname(target_path), exist_ok=True) + with archive.open(info) as src, open(target_path, "wb") as dst: + shutil.copyfileobj(src, dst) +PY } write_package_manifest() { diff --git a/docs/ADMIN_GUIDE_RU.md b/docs/ADMIN_GUIDE_RU.md index b63ac0b..dbff012 100644 --- a/docs/ADMIN_GUIDE_RU.md +++ b/docs/ADMIN_GUIDE_RU.md @@ -155,6 +155,6 @@ Rollback-critical данные: - `docs/OPERATOR_GUIDE_RU.md` - `docs/INSTALL_RU.md` - `docs/ARCHITECTURE_RU.md` -- `docs/DETMIR_THREAT_MODEL_RU.md` -- `docs/DETMIR_RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md` +- `docs/THREAT_MODEL_RU.md` +- `docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md` - `adk-rust/RUNBOOK.md` diff --git a/docs/ARCHITECTURE_RU.md b/docs/ARCHITECTURE_RU.md index b273401..8cfeb4e 100644 --- a/docs/ARCHITECTURE_RU.md +++ b/docs/ARCHITECTURE_RU.md @@ -147,8 +147,8 @@ AWatch-rus не заявляется как сертифицированная ## 9. Связанные документы -- `docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md` -- `docs/DETMIR_THREAT_MODEL_RU.md` +- `docs/UNIFIED_OPERATING_MODEL_RU.md` +- `docs/THREAT_MODEL_RU.md` - `docs/ADMIN_GUIDE_RU.md` - `docs/OPERATOR_GUIDE_RU.md` - `docs/GRAFANA_DASHBOARDS_RU.md` diff --git a/docs/DETMIR_CHANGE_REPORT_LAST_24H_2026-05-26.md b/docs/CHANGE_REPORT_LAST_24H_2026-05-26.md similarity index 100% rename from docs/DETMIR_CHANGE_REPORT_LAST_24H_2026-05-26.md rename to docs/CHANGE_REPORT_LAST_24H_2026-05-26.md diff --git a/docs/DETMIR_COMMERCIAL_MODULES_RU.md b/docs/COMMERCIAL_MODULES_RU.md similarity index 100% rename from docs/DETMIR_COMMERCIAL_MODULES_RU.md rename to docs/COMMERCIAL_MODULES_RU.md diff --git a/docs/DETMIR_PRODUCTION_VALIDATION_RU.md b/docs/DETMIR_PRODUCTION_VALIDATION_RU.md new file mode 100644 index 0000000..9c6e0d1 --- /dev/null +++ b/docs/DETMIR_PRODUCTION_VALIDATION_RU.md @@ -0,0 +1,414 @@ +# AWatch-rus Production Validation + +Дата проверки: 2026-06-07. + +Статус после TASK_015: рабочий внутренний pilot-контур приведен к Demo Freeze +v1 по portal runtime, production-hardening endpoints и основным live smoke. +UEBA `critical` разобран в `docs/UEBA_CRITICAL_REVIEW_RU.md` и +классифицирован как `Needs Investigation`. Расширение пилота допустимо только +контролируемо, после проверки agent coverage и назначения операционного +ownership. + +## Executive Summary + +Проверка выполнялась read-only по рабочему внутреннему контуру на нескольких +пользователях. Реальные payload, логи, screenshots, IP-адреса, hostname, +логины, ФИО и подразделения в репозиторий не сохранялись. + +Подтверждено: + +- gateway и portal service доступны; +- базовый portal UI открывается; +- ActivityWatch API доступен и содержит свежие buckets; +- `/portal/api/reports` отвечает по ролям; +- Security events backend в рабочем контуре подключен; +- role gates в существующем portal smoke срабатывают; +- Forensics view и базовые portal tabs открываются; +- Windows runtime содержит активный текущий агентский процесс и watcher-процессы. + +Ключевые gaps, найденные до TASK_014: + +- фактический portal runtime отстает от Demo Freeze v1: отдельные endpoints + `/portal/api/workforce/kpi/explain`, `/portal/api/risk/narrative` и + `/portal/api/actions` на live-контуре возвращают `404`; +- production-hardening endpoints `/healthz`, `/readyz`, `/version`, `/metrics` + не доступны на фактическом portal port; gateway-level `/healthz` отвечает, + но это не заменяет portal production contract; +- request id / correlation id headers на live portal API не возвращаются; +- Executive visual conformance не проходит по текущему freeze smoke: в рабочем + runtime не отображаются новые Pilot v1 блоки Risk Narrative / Explainable KPI + / Recommended Actions; +- UEBA на live-контуре возвращает `critical` score; TASK_015 классифицировал + его как `Needs Investigation`, а не как подтвержденный инцидент ИБ. + +TASK_014 remediation update: + +- deployment/version drift закрыт controlled deploy актуального release binary; +- live `/healthz`, `/readyz`, `/version`, `/metrics` теперь отвечают `200`; +- request id / correlation id headers возвращаются; +- live Explainable KPI, Risk Narrative и Executive Action Center endpoints + отвечают `200`; +- live production hardening smoke прошел; +- live browser conformance smoke прошел; +- live portal tabs smoke прошел. + +Вывод: контур можно использовать для контролируемого внутреннего pilot review и +подготовки ограниченного пилота. Перед расширением на 10-50 пользователей +остается проверить agent coverage, уточнить missing application data и +закрепить порядок операционной поддержки. + +## Scope + +Проверялось: + +- runtime health; +- gateway/portal topology; +- portal tabs and role views; +- Workforce KPI и related report structure; +- Explainable KPI availability; +- UEBA; +- Risk Narrative availability; +- Executive Action Center availability; +- agent/data flow; +- performance snapshot; +- data hygiene. + +Не проверялось: + +- destructive recovery; +- restart/redeploy; +- изменение правил scoring; +- изменение collectors; +- production rollout новой версии; +- raw evidence review с персональными данными. + +## Environment + +Обезличенно: + +- пользователей: несколько; +- контур: working internal pilot; +- данные: реальные, но в документе не раскрываются; +- gateway: отдельный reverse-proxy host с внешней авторизацией; +- portal runtime: локальный сервис на gateway host; +- ActivityWatch/worktime: отдельный AW-rus server; +- Windows runtime: RDP host с текущим агентским контуром. + +## Runtime Health + +Проверено: + +| Поверхность | Результат | Комментарий | +| --- | --- | --- | +| Gateway `/healthz` | `200` | Nginx/gateway-level health отвечает | +| Gateway `/portal/` | `401` снаружи | Внешний доступ закрыт авторизацией | +| Portal local `/portal/` | `200` | UI доступен на gateway host | +| Portal local `/portal/api/health` | `200` | API health доступен | +| Portal local `/healthz` | `200` | Production-hardening endpoint подтвержден после TASK_014 | +| Portal local `/readyz` | `200` | Production-hardening endpoint подтвержден после TASK_014 | +| Portal local `/version` | `200` | Production-hardening endpoint подтвержден после TASK_014 | +| Portal local `/metrics` | `200` | Prometheus text metrics подтверждены после TASK_014 | +| ActivityWatch `/api/0/settings` | `200` | AW API отвечает | + +Request/correlation headers после TASK_014: + +- `X-Request-Id`: возвращается live portal API; +- `X-Correlation-Id`: возвращается live portal API. + +Metrics: + +- Prometheus metrics format на фактическом portal runtime подтвержден через + `/metrics`. + +## Portal Validation + +Проверено через tunnel к фактическому gateway-local portal port. Screenshots +создавались только во временном каталоге вне репозитория и не коммитились. + +Результат `scripts/browser-conformance-smoke.mjs` на live-контуре после +TASK_014: + +| View | Результат | Комментарий | +| --- | --- | --- | +| Executive | OK | KPI, Explainable KPI, Risk Narrative и Recommended Actions отображаются | +| Workforce | OK | KPI, подразделения, тренды и explainability отображаются | +| Security | OK | Security actions, events, correlation и candidates отображаются | +| Forensics | OK | Расследования, timeline, материалы и аудит отображаются | + +Результат `scripts/detmir-portal-tabs-smoke.mjs` на live-контуре после TASK_014: + +- базовые tabs открываются; +- loading status доходит до ready; +- role switcher есть; +- Security events доступны; +- manager/security/forensics/admin view checks проходят; +- server role gates проходят; +- Executive dashboard layer и expected management block order проходят. + +## KPI Validation + +`/portal/api/reports?role=executive` возвращает валидный JSON и содержит: + +- `kpis`: массив агрегированных KPI; +- `workforce`: объект с `department_comparison`, `owner_comparison`, `trend`, + `trend_status`, `insights`; +- `business_risk`; +- `risk_heatmap`; +- `security_events_summary`. + +Обезличенные счетчики live response: + +- KPI entries: `13`; +- department comparison entries: `1`; +- owner comparison entries: `3`; +- business risk entries: `2`; +- risk heatmap entries: `7`. + +Оценка: + +- базовый Workforce/Business Risk слой на live-контуре присутствует; +- KPI выглядит как рабочий агрегированный отчет, но текущий UI/API не + соответствует Demo Freeze v1 explainability контракту; +- перед расширением пилота нужно подтвердить свежесть источников по каждому + пользователю и роль ожидаемых подразделений. + +## Explainable KPI Validation + +Live endpoint после TASK_014: + +```text +/portal/api/workforce/kpi/explain -> 200 +``` + +Вывод: + +- Explainable KPI развернут на рабочем контуре; +- live UI показывает ожидаемый блок `Почему такой индекс активности?`; +- прежний `404` был следствием устаревшего deployed binary. + +## UEBA Validation + +Live endpoint: + +```text +/portal/api/ueba -> 200 +``` + +Обезличенная сводка: + +- response `ok=true`; +- severity: `critical`; +- status: `FAIL`; +- score: `100`; +- reason codes: несколько; +- score components: несколько. + +Оценка после TASK_015: + +- UEBA endpoint работает; +- score `critical` является фактическим rule-based результатом текущих + сигналов; +- классификация: `Needs Investigation`; +- security interpretation: `Operational Risk confirmed; Security Risk unknown`; +- executive readiness: `Insufficient Confidence`; +- основной вклад дают Workforce/baseline/history signals, а не DLP, network, + time или application anomaly. + +## Risk Narrative Validation + +Live endpoint после TASK_014: + +```text +/portal/api/risk/narrative -> 200 +``` + +Вывод: + +- Risk Narrative развернут на рабочем контуре; +- Executive UI block соответствует Demo Freeze v1 ожиданиям; +- Risk Narrative можно показывать как deployed capability, сохраняя честное + ограничение: это rule-based объяснение, не ML-прогноз. + +## Executive Action Center Validation + +Live endpoint после TASK_014: + +```text +/portal/api/actions -> 200 +``` + +Вывод: + +- Executive Action Center развернут на рабочем контуре; +- live runtime отдает отдельный actions endpoint; +- рекомендации можно показывать как deployed rule-based guidance, без claims об + автоматическом remediation. + +## Agent/Data Flow Validation + +AW-rus server: + +- ActivityWatch server active; +- worktime API service active; +- failed systemd units: `0`; +- ActivityWatch API buckets endpoint отвечает `200`; +- buckets count: `27`; +- latest bucket timestamp близок к моменту проверки; +- oldest bucket timestamp старый, что нормально для исторических/event buckets, + но требует отдельной интерпретации freshness по bucket type. + +Windows/RDP runtime: + +- текущий `awatch-agent-rs` process активен; +- collector guard process активен; +- watcher/window/telemetry processes активны; +- scheduled tasks для ActivityWatch/AWatch runtime находятся в состоянии + `Ready` или `Running`. + +Фактические роли: + +```text +legacy/current runtime: awatch-agent-rs и существующие ActivityWatch watchers +new baseline core: adk-rust/crates/awatch-agent, покрыт тестами, но не подтвержден как основной live runtime +``` + +Backlog/dead-letter: + +- явный dead-letter count на AW server: `0`; +- known spool directories на AW server не обнаружены в проверенных путях; +- Windows-side spool/backlog требует отдельной безопасной проверки без вывода + путей и payload. + +## Performance Snapshot + +Обезличенная сводка: + +| API | Status | Время ответа | +| --- | --- | --- | +| `/portal/api/health` | `200` | < 1 ms на gateway-local check | +| `/portal/api/reports?role=executive` | `200` | первый observed run около 9 s, warm-cache run < 10 ms | +| `/portal/api/reports?role=manager` | `200` | < 10 ms на warm-cache run | +| `/portal/api/reports?role=security` | `200` | < 10 ms на warm-cache run | +| `/portal/api/reports?role=forensics` | `200` | < 10 ms на warm-cache run | + +Логи: + +- recent portal log scan за окно проверки не показал `500`, panic или явных + timeout в sanitized summary; +- ActivityWatch/worktime recent error scan не показал явных ошибок в sanitized + summary. + +Ограничение: + +- это snapshot, не load test и не sizing report. + +## Noise / False Positive Findings + +Потенциальный шум: + +- UEBA severity `critical` / score `100` после TASK_015 классифицирован как + `Needs Investigation`: высокий score выглядит операционно значимым, но не + доказывает ИБ-инцидент; +- stale исторические buckets могут искажать общее восприятие freshness, если не + разделять active, inactive и event-driven bucket types; +- live Executive headline/runtime naming все еще может содержать старую + внутреннюю терминологию, что конфликтует с public naming hygiene. + +Не исправлялось в этой задаче: + +- scoring rules; +- UEBA thresholds; +- report content logic; +- deployed binary/runtime. + +## Documentation Mismatches + +Расхождения, найденные TASK_013, закрыты TASK_014: + +1. Production-hardening endpoints `/healthz`, `/readyz`, `/version`, + `/metrics` теперь доступны на фактическом portal port. +2. Standalone endpoints `/api/workforce/kpi/explain`, + `/api/risk/narrative`, `/api/actions` теперь доступны на live runtime. +3. Visual smoke текущей freeze-ветки проходит Executive, Workforce, Security и + Forensics views. +4. Live runtime обновлен controlled deploy актуального release binary. + +## Security / Privacy Notes + +Соблюдено: + +- raw logs не коммитились; +- raw JSON payload не коммитился; +- screenshots с реальными данными не коммитились; +- реальные IP/hostname/usernames/ФИО/подразделения в этот документ не внесены; +- deploy выполнялся controlled способом с backup старого binary и rollback path; +- destructive data operations не выполнялись; +- collectors, scoring rules и источники данных не менялись. + +## Gaps + +Критично перед расширением пилота: + +1. Проверить agent coverage и missing application data, которые могли усилить + UEBA `critical`. +2. Назначить операционного владельца live smoke, deploy parity и rollback. +3. Зафиксировать регламент: после каждого deploy проверять binary/version, + endpoint matrix и live smoke. + +Желательно до пилотного расширения: + +1. Добавить отдельный pilot-feedback контур для замечаний руководителя, ИБ, + эксплуатации и расследователей. +2. Разделить freshness report по bucket types: active, inactive, event-driven, + historical. +3. Проверить Windows-side spool/backlog безопасной командой без раскрытия путей + и payload. + +Можно перенести после первого ограниченного пилота: + +1. Тонкая настройка UEBA thresholds. +2. Расширение Action Center rules. +3. Улучшение dashboard wording по результатам реальной обратной связи. + +## Recommended Next Tasks + +Не открывать feature roadmap. Следующие задачи должны быть pilot-feedback / +operations oriented: + +- `docs/pilot-feedback/BUGS.md` - зафиксировать deployment/version drift как bug; +- `docs/pilot-feedback/FEATURE_REQUESTS.md` - собирать только запросы от + реальных ролей; +- `docs/pilot-feedback/LESSONS_LEARNED.md` - фиксировать, что было непонятно на + показе; +- отдельная operator task: сверить deployed portal binary/commit с freeze branch; +- отдельная operator task: повторить live smoke после controlled deploy. + +## Explicit Non-Goals + +В рамках TASK_013/TASK_014 не выполнялись: + +- новые API; +- новый UI; +- новые collectors; +- ML/LLM; +- DLP/SIEM/EDR claims; +- изменение scoring logic; +- изменение collectors или источников данных; +- выгрузка персональных данных; +- сохранение real screenshots в git. + +## Conclusion + +Рабочий внутренний контур AWatch-rus существует и собирает реальные данные. +Portal, ActivityWatch, Security events, UEBA endpoint, Forensics и базовые role +views частично подтверждены. + +После TASK_014 live runtime соответствует Demo Freeze v1 по production-hardening +endpoints, request/correlation headers, Explainable KPI, Risk Narrative, +Executive Action Center и основным smoke-проверкам. Текущий статус: + +```text +ready for controlled internal pilot review; +ready for limited pilot preparation after agent coverage review and operations +ownership assignment. +``` diff --git a/docs/GRAFANA_DASHBOARDS_RU.md b/docs/GRAFANA_DASHBOARDS_RU.md index a07a497..ed9917b 100644 --- a/docs/GRAFANA_DASHBOARDS_RU.md +++ b/docs/GRAFANA_DASHBOARDS_RU.md @@ -24,6 +24,71 @@ Version-controlled dashboard JSON находятся в каталоге `grafan По умолчанию playbook складывает их в folder `AWatch-rus` с `uid=awatch-rus`. +## Worktime panels и canonical users + +Worktime dashboard'ы читают InfluxDB measurement +`aw_rdp_worktime_daily`/`aw_rdp_worktime_hourly` и группируют данные по user +label. Старые exporter versions писали raw `username`/`userId`, поэтому в +Influx могли остаться отдельные series для `USER5/user5`, +`Администратор/администратор`, machine account `SHARKON2025$` и битых строк с +Unicode replacement char `�`. + +Version-controlled dashboard JSON должны сохранять защиту от старых series: + +- `grafana/detmir-rdp-user-activity-dashboard.json`; +- `grafana/detmir-aw-main-dashboard.json`. + +Для affected Flux queries обязательны правила: + +- фильтровать `user_id !~ /\$$/` и `user_id !~ /�/`; +- мапить текущие DetMir accounts в canonical labels: + `user1`, `user4`, `user5`, `Администратор`; +- grouping делать по `report_date,user` или `_time,user`; +- использовать `max(column: "_value")` после grouping, чтобы схлопнуть + duplicate series без удвоения часов. + +После импорта проверять панель `Вчера: активность по сотрудникам`. Ожидаемые +labels: `user1`, `user4`, `user5`, `Администратор`. Bad labels list должен быть +пустым для `USER*`, `SHARKON2025$`, `администратор`, `�` и labels, начинающихся +с `\`. + +Owner-facing aggregate panel должен называться явно: + +- title: `Все сотрудники: активное время по дням`; +- legend: `Все сотрудники`; +- field label: `Все сотрудники, ч`. + +Не используйте `Команда` для этой панели: для владельца это выглядит как имя +отдельного пользователя или непонятной группы. + +## Доступ владельца из портала + +На production-контуре DetMir переход из `/portal` к Grafana dashboard'ам +выполняется без второго логина Grafana. Внешняя защита при этом остается на +gateway: + +- `/portal/`, `/d/...`, `/dashboards` и `/r/grafana/` закрыты nginx Basic Auth; +- nginx после успешной gateway-авторизации передает в Grafana auth-proxy + заголовки: + - `X-WEBAUTH-USER: detmir-owner`; + - `X-WEBAUTH-NAME: AWatch-rus Owner`; + - `X-WEBAUTH-EMAIL: owner@awatch-rus.local`; +- Grafana принимает auth-proxy только от gateway `10.10.10.2`; +- созданный пользователь `detmir-owner` не является Grafana admin и получает + viewer-доступ. + +Основной dashboard для владельца: + +```text +/d/detmir-rdp-user-activity/detmir3a-rabota-pol-zovatelej-v-rdp?orgId=1&from=now-7d&to=now&timezone=browser&var-host=SHARKON2025&refresh=5m +``` + +В портале он доступен как кнопка `Графики сотрудников`. + +Не включайте `[auth.anonymous]` для решения этой задачи: это откроет Grafana на +внутреннем адресе `10.10.10.11:3000` без пользовательского контекста. Для +production используется только auth-proxy с whitelist gateway. + ## Быстрый запуск 1. Подготовьте inventory и vars: @@ -57,6 +122,53 @@ ansible-playbook -i inventory.ini deploy_grafana_dashboards.yml - перезаписывает существующие dashboard'ы при `overwrite=true`; - верифицирует каждый dashboard по `uid` через `GET /api/dashboards/uid/`. +## Production fallback при 403 + +Если Grafana API import запрещен (`403`) или provisioning не перезаписывает уже +существующую DB-запись dashboard, не правьте JSON только в UI. Сначала +обновите version-controlled dashboard JSON в git, затем примените один из +fallback paths. + +Provisioning push: + +```bash +scp grafana/detmir-aw-main-dashboard.json grafana/detmir-rdp-user-activity-dashboard.json igor@10.10.10.2:~/codex-dashboard-import/ +ssh igor@10.10.10.2 'sudo pct push 201 /home/igor/codex-dashboard-import/detmir-aw-main-dashboard.json /etc/grafana/provisioning/dashboards/aw/detmir-aw-main.json --perms 0644' +ssh igor@10.10.10.2 'sudo pct push 201 /home/igor/codex-dashboard-import/detmir-rdp-user-activity-dashboard.json /etc/grafana/provisioning/dashboards/aw/detmir-rdp-user-activity.json --perms 0644' +ssh igor@10.10.10.2 'sudo pct exec 201 -- bash -lc "cp -a /var/lib/grafana/grafana.db /var/lib/grafana/grafana.db.bak.$(date -u +%Y%m%dT%H%M%SZ); systemctl restart grafana-server"' +``` + +DB fallback: после backup `/var/lib/grafana/grafana.db` заменить только +`dashboard.data` rows по uid нужных dashboard'ов и перезапустить +`grafana-server`. Для исправления worktime-дублей production backup был: + +```text +/var/lib/grafana/grafana.db.bak.20260609T013605Z +``` + +Для production rename `Команда` -> `Все сотрудники` на `2026-06-09` были +обновлены DB-записи: + +- `detmir-rdp-user-activity`; +- `detmir-aw-main`. + +Backup перед изменением: + +```text +/var/lib/grafana/grafana.db.bak.20260609T020225Z +``` + +Контроль через gateway Grafana API: + +- старый title count: `0`; +- новый title count: `1`; +- старая legend count: `0`; +- новая legend count: `1`; +- dashboard page: HTTP `200`, title `Grafana`. + +Если после этого в уже открытой вкладке всё ещё видно `Команда`, сначала +сделайте hard refresh: это старое состояние браузера, а не старая DB-запись. + ## Переменные - `grafana_url` — base URL Grafana, например `http://10.20.30.11:3000` diff --git a/docs/OPENCODE_FULL_SYSTEM_HANDOVER_PLAN_RU.md b/docs/OPENCODE_FULL_SYSTEM_HANDOVER_PLAN_RU.md new file mode 100644 index 0000000..ee6b6b6 --- /dev/null +++ b/docs/OPENCODE_FULL_SYSTEM_HANDOVER_PLAN_RU.md @@ -0,0 +1,669 @@ +# План передачи проекта агенту OpenCode + +Документ нужен агенту, который должен самостоятельно разворачивать и +сопровождать полный контур AWatch-rus. Писать и действовать нужно просто: +сначала понять слой, затем развернуть, затем проверить, затем зафиксировать +результат. + +## 1. Цель + +Развернуть AWatch-rus как единый контур: + +- сбор активности пользователей с Windows/RDP рабочих мест; +- учет рабочего времени и RDP-сессий; +- DLP/ИБ-сигналы: clipboard, USB, print, browser domains, email, file ops; +- ActivityWatch Server и русифицированный WebUI; +- worktime API и управленческие отчеты; +- Grafana dashboards поверх InfluxDB; +- ClickHouse-контур для файловой 1С, расследований, detections и cases; +- портал руководителя/ИБ/эксплуатации; +- проверяемый deploy через Ansible, Rust-бинарники, systemd и Windows tasks. + +Главное правило: система считается развернутой только когда есть свежие данные, +открываются интерфейсы, проходят health checks и есть понятный rollback. + +Второе главное правило: agent может читать private/ignored файлы только для +проверки факта и типа секрета. Значения паролей, токенов, host credentials и +private URL нельзя переносить в markdown, audit, terminal summary, commit или +handoff. В отчете писать так: `ansible/inventory.ini содержит plaintext +credentials; значения не фиксировались; нужна ротация`. + +## 2. Простая модель системы + +Представь систему как цепочку: + +```text +Windows/RDP users + -> Windows collectors / Rust agent + -> ActivityWatch API buckets + -> Rust services on AW server + -> Worktime reports + DLP services + Influx exporters + -> Grafana dashboards + Portal + -> ClickHouse/1C analytics where configured +``` + +Если ломается ранний слой, поздний слой тоже будет пустым. Нельзя начинать с +Grafana, если ActivityWatch buckets пустые. Нельзя чинить портал, если +`aw-worktime-api` degraded. Нельзя чинить ClickHouse для worktime, потому что +worktime reports не зависят от ClickHouse. + +## 3. Роли пользователей + +Система должна закрывать четыре роли. + +1. Руководитель: + - видит, кто работал; + - видит активное время по дням; + - видит проблемные подразделения и риски; + - получает простой вывод без технического шума. + +2. ИБ: + - видит DLP-инциденты; + - видит evidence и screenshot artifacts; + - видит DLP dashboards; + - может разбирать кейсы без прямого доступа к сырой базе. + +3. Эксплуатация: + - видит свежесть buckets; + - видит состояние сервисов; + - видит failed units, timers, collector guard; + - может безопасно перезапустить нужный слой. + +4. Аналитик 1С: + - видит аудит файловых баз 1С; + - видит detections, timeline, cases; + - видит состояние выгрузок и качество данных; + - понимает, где данные реальные, а где proxy/fallback. + +## 4. Обязательный функционал + +### 4.1 ActivityWatch core + +Нужно: + +- `activitywatch-server` работает и слушает `:5600`; +- WebUI открывается; +- CORS/landing page настроены; +- buckets создаются и обновляются; +- SQLite не перегружен тяжелыми запросами. + +Проверки: + +```bash +curl -fsS http://:5600/api/0/info +curl -fsS http://:5600/api/0/buckets | jq 'keys' +systemctl status activitywatch-server --no-pager +``` + +### 4.2 Windows/RDP сбор + +Нужно: + +- Windows toolkit установлен; +- есть `deployment-config.json`; +- есть scheduled tasks `ActivityWatch Launch [...]` и `ActivityWatch Recovery`; +- Rust collector guard работает, PowerShell fallback остается только как + fallback; +- `validate-deployment.ps1` возвращает `overallOk=True`; +- есть свежие buckets: + - `aw-worktime-sessions_`; + - `aw-watcher-window_`; + - `aw-watcher-afk_` если AFK включен; + - `aw-dlp-endpoint-signals_`; + - `aw-file-operations_` если file ops включен. + +Развертывание: + +```powershell +.\windows\deploy-ensemble.ps1 ` + -ServerHost ` + -ServerPort 5600 ` + -Domain ` + -Users user1,user2,user3 +``` + +Проверки: + +```powershell +.\windows\validate-deployment.ps1 | ConvertTo-Json -Depth 10 +Get-ScheduledTask | Where-Object TaskName -like 'ActivityWatch*' +Get-Service AWatchRusCollectorGuard +``` + +### 4.3 Worktime reports + +Нужно: + +- `aw-worktime-api` работает на `:5610`; +- `/health` возвращает OK или понятный degraded; +- `/reports/worktime/management` строит HTML/JSON; +- report не считает служебные accounts и битые labels; +- stale cache включен для degraded path. + +Проверки: + +```bash +curl -fsS http://:5610/health | jq +curl -fsS "http://:5610/reports/worktime/management?format=json&host=&allow_stale=1" | jq '.status,.runtime' +systemctl status aw-worktime-api --no-pager +``` + +### 4.4 DLP + +Нужно: + +- DLP policy engine доступен; +- endpoint signals пишутся; +- incidents создаются; +- screenshots/evidence синхронизируются; +- case management и compliance работают, если включены; +- DLP health check зеленый или объясняет WARN/FAIL. + +Проверки: + +```bash +systemctl status aw-dlp-policy-engine aw-dlp-case-management --no-pager +curl -fsS http://:5601/health || true +curl -fsS http://:5600/api/0/buckets/aw-dlp-endpoint-signals_ +``` + +### 4.5 WebUI + +Нужно: + +- ActivityWatch WebUI не пустой; +- RU patch подключен; +- host sanitize script подключен; +- DLP review/rules UI доступен, если включен; +- worktime panel не ломает основной WebUI; +- browser cache не скрывает новую версию. + +Развертывание: + +```bash +ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_server.yml +``` + +Проверки: + +```bash +curl -fsS http://:5600/ | head +curl -fsS http://:5600/js/ru-patch-v5.js | head +curl -fsS http://:5600/js/aw-host-sanitize.js | head +``` + +### 4.6 Portal + +Нужно: + +- портал работает как read-only рабочий кабинет; +- роли видят разные представления, но данные общие; +- `/api/health` показывает состояние источников; +- `/api/reports` возвращает KPI и markdown; +- portal не должен silently mutate AW/DLP/1C; +- внешняя публикация идет через gateway/auth, не через открытые raw ports. + +Развертывание: + +```bash +cd adk-rust +cargo build --release -p detmir-portal +cd ../ansible +ansible-playbook -i inventory.ini deploy_detmir_portal.yml +ansible-playbook -i inventory.ini deploy_proxmox_web_gateway.yml +``` + +Проверки: + +```bash +curl -fsS http://:8720/api/health | jq +curl -fsS http://:8720/api/reports | jq '.status,.sources' +systemctl status detmir-portal --no-pager +``` + +## 5. Grafana + InfluxDB + +### 5.1 Что должно быть + +InfluxDB хранит агрегаты для Grafana: + +- `aw_rdp_worktime_daily`; +- `aw_rdp_worktime_hourly`; +- `aw_rdp_worktime_summary_daily`; +- DLP measurements; +- health/self-test measurements. + +Grafana должна показывать: + +- главный AWatch-rus dashboard; +- RDP/user activity dashboard; +- DLP/security dashboard; +- DLP management dashboard; +- overview dashboard для владельца. + +Структура в репозитории разделена на три разных контура: + +- `grafana/` — плоские version-controlled JSON dashboards основного + AWatch-rus контура. Их импортирует `ansible/deploy_grafana_dashboards.yml`, + а проверяет `ansible/deploy_grafana_check.yml`. +- `grafana-1c/` — отдельный docker-compose стек для SQL-readable 1C + MSSQL/Postgres dashboards. +- `clickhouse-1c/grafana/provisioning/` — provisioning ClickHouse/file-1C + dashboards и datasource. + +Не путать эти каталоги. Если меняется основной dashboard, править `grafana/*.json` +и прогонять dashboard deploy/check. Если меняется ClickHouse 1C dashboard, +смотреть `clickhouse-1c/grafana/provisioning/`. + +### 5.2 Deploy order + +1. Убедиться, что InfluxDB доступен. +2. Убедиться, что write tokens заданы в private inventory/env. +3. Развернуть AW server exporters. +4. Запустить exporters вручную один раз. +5. Проверить, что points записались. +6. Импортировать/provision Grafana dashboards. +7. Запустить `detmir-grafana-check`. + +Команды: + +```bash +systemctl start aw-worktime-influx-exporter.service +systemctl start aw-dlp-influx-exporter.service +journalctl -u aw-worktime-influx-exporter.service -n 50 --no-pager +journalctl -u aw-dlp-influx-exporter.service -n 50 --no-pager + +cd ansible +ansible-playbook -i inventory.ini deploy_grafana_dashboards.yml +ansible-playbook -i inventory.ini deploy_grafana_check.yml +``` + +Проверки Grafana: + +```bash +curl -u "$GRAFANA_USER:$GRAFANA_PASSWORD" \ + http://:3000/api/datasources/uid/influxdb_aw/health +``` + +Ожидание: datasource OK, dashboards открываются, panels не пустые, labels +пользователей нормализованы. + +### 5.3 Правила для dashboard + +- Не править только руками в Grafana UI; сначала править JSON/provisioning в + репозитории. +- Для worktime не показывать machine accounts, битые Unicode labels и дубли + регистра. +- Owner-facing aggregate должен называться понятным языком, например + `Все сотрудники`, а не техническим словом `Команда`. +- После импорта проверить dashboard API и открыть страницу через gateway. + +## 6. ClickHouse + файловая 1С + +### 6.1 Назначение + +Этот контур нужен, когда 1С файловая и нужен не только KPI, а audit stack: + +```text +1C exports / reglog / host telemetry + -> landing/* + -> aw-1c-ingest-rust + -> ClickHouse analytics_1c + -> detections / timeline / cases / company intelligence + -> Grafana + Portal + briefs +``` + +### 6.2 Что развернуть + +- ClickHouse; +- Grafana datasource ClickHouse; +- schema из `clickhouse-1c/clickhouse/init/*.sql`; +- landing каталоги; +- ingest timer/service; +- detections SQL; +- company intelligence refresh; +- read-only 1C analytics API; +- dashboards из `clickhouse-1c/grafana/provisioning/dashboards/files/`. + +### 6.3 Minimal local bootstrap + +```bash +cd clickhouse-1c +cp .env.example .env +docker compose up -d +mkdir -p landing/{documents,postings,business_events,document_changes,companies,reglog,audit,host} +cp etl/config.example.yml etl/config.yml +``` + +### 6.4 Production ingest + +```bash +cd adk-rust +cargo build --release -p aw-1c-ingest + +/usr/local/bin/aw-1c-ingest-rust --root /opt/activitywatch/clickhouse-1c +clickhouse-client --queries-file /opt/activitywatch/clickhouse-1c/detections/insert_detections.sql +clickhouse-client --queries-file /opt/activitywatch/clickhouse-1c/detections/build_entity_timeline.sql +``` + +### 6.5 Проверки ClickHouse/1С + +```bash +clickhouse-client --query "SHOW DATABASES" +clickhouse-client --database analytics_1c --query "SHOW TABLES" +clickhouse-client --database analytics_1c --query "SELECT count() FROM business_events" +clickhouse-client --database analytics_1c --query "SELECT count() FROM detections" +``` + +Ожидание: + +- таблицы существуют; +- raw/normalized слои не пустые, если есть выгрузки; +- detections считаются; +- Grafana 1C dashboards открываются; +- API компании/brief отвечает read-only. + +## 7. Модули и ответственность + +| Слой | Где смотреть | За что отвечает | +| --- | --- | --- | +| Rust runtime | `adk-rust/crates/*` | production binaries, checks, exporters, portal, ingest | +| AW server | `aw-server/`, `ansible/deploy_aw_server.yml` | ActivityWatch, WebUI, worktime, DLP services | +| Windows | `windows/`, `ansible/deploy_aw_windows.yml` | collectors, tasks, guard, validation | +| Grafana/Influx | `grafana/`, `ansible/deploy_grafana_dashboards.yml`, `ansible/deploy_grafana_check.yml` | flat dashboard JSON import, datasource/freshness checks | +| SQL 1C Grafana | `grafana-1c/` | separate MSSQL/Postgres 1C Grafana stack | +| 1C/ClickHouse | `clickhouse-1c/`, `clickhouse-1c/ai/`, `clickhouse-1c/grafana/provisioning/` | file 1C analytics, detections, cases, allowed Python AI helpers | +| Portal | `adk-rust/crates/detmir-portal`, `docs/PORTAL_RU.md` | role views, reports, health | +| Gateway | `ansible/deploy_proxmox_web_gateway.yml` | external protected routes | +| Docs/runbooks | `docs/`, `adk-rust/RUNBOOK.md` | operating procedures | + +### 7.1 Ansible playbook map + +Перед deploy агент должен понимать назначение playbook, а не запускать их +пакетом. + +| Playbook | Назначение | +| --- | --- | +| `deploy_aw_server.yml` | ActivityWatch server, WebUI, worktime/DLP server side | +| `deploy_aw_windows.yml` | Windows/RDP collectors and validation artifacts | +| `post_validate_aw_windows.yml` | post-deploy Windows validation | +| `deploy_detmir_portal.yml` | portal service | +| `deploy_proxmox_web_gateway.yml` | protected gateway routes | +| `deploy_grafana_dashboards.yml` | import flat `grafana/*.json` dashboards | +| `deploy_grafana_check.yml` | datasource/dashboard health checks | +| `deploy_file_1c_windows_telemetry.yml` | file-1C Windows telemetry | +| `deploy_file_1c_analytics.yml` | file-1C analytics layer | +| `deploy_dlp_evidence_sync.yml` | DLP evidence artifact sync | +| `deploy_dlp_full_stack.yml` | full DLP server-side stack | +| `deploy_aw_pfsense_poller.yml` | pfSense poller integration | +| `audit_cryptopro_windows.yml` | Windows CryptoPro audit | +| `provision_proxmox_ct_and_deploy_aw.yml` | provision one Proxmox CT and deploy AW | +| `provision_proxmox_ct_matrix_and_deploy_aw.yml` | provision CT matrix and deploy AW | +| `deploy_tsj_guardian_bot_proxmox.yml` | Proxmox guardian bot | +| `install_full_stack.yml` | broad full-stack install wrapper; use only with explicit scope | + +## 8. Полный порядок развёртывания + +### Шаг 0. Не ломать рабочий контур + +Перед любыми изменениями: + +```bash +git status --short --branch +git log --oneline -5 +``` + +Если есть unrelated dirty tree, не откатывать его. Работать только с нужными +файлами. + +### Шаг 1. Подготовить private конфигурацию + +Проверить: + +- `ansible/inventory.ini`; +- private group vars; +- Influx tokens; +- Grafana credentials; +- Windows host/user list; +- gateway host/auth; +- ClickHouse credentials; +- 1C export paths. + +Нельзя коммитить реальные secrets. Нельзя вставлять значения из +`ansible/inventory.ini`, private env, vault, runtime configs или host credentials +в docs/audit. Разрешено писать только факт: где найдено, какой тип секрета, что +сделать для remediation. + +### Шаг 2. Собрать Rust + +```bash +cd adk-rust +cargo fmt --all -- --check +cargo build --release --workspace +cargo test -p detmir-core +cargo test -p detmir-portal +cargo test -p worktime-api +cargo test -p worktime-influx-exporter +``` + +Если workspace слишком большой, собирать targeted crates, которые нужны +текущему deploy. + +### Шаг 3. Проверить Ansible syntax + +```bash +cd ansible +ansible-playbook --syntax-check deploy_aw_server.yml +ansible-playbook --syntax-check deploy_aw_windows.yml +ansible-playbook --syntax-check deploy_detmir_portal.yml +ansible-playbook --syntax-check deploy_grafana_dashboards.yml +ansible-playbook --syntax-check deploy_grafana_check.yml +``` + +### Шаг 4. Развернуть AW server + +```bash +ansible-playbook -i inventory.ini deploy_aw_server.yml +``` + +После: + +```bash +systemctl --failed --no-pager +systemctl status activitywatch-server aw-worktime-api --no-pager +curl -fsS http://:5600/api/0/info +curl -fsS http://:5610/health | jq +``` + +### Шаг 5. Развернуть Windows/RDP + +```bash +ansible-playbook -i inventory.ini deploy_aw_windows.yml +``` + +Или вручную на Windows: + +```powershell +.\windows\deploy-ensemble.ps1 -ServerHost -ServerPort 5600 -Domain -Users user1,user2 +.\windows\validate-deployment.ps1 +``` + +После проверить свежесть buckets на AW server. + +### Шаг 6. Запустить worktime chain + +```bash +systemctl restart aw-worktime-api +systemctl start aw-worktime-prewarm.service || true +curl -fsS "http://:5610/reports/worktime/management?format=json&host=&allow_stale=1" | jq +``` + +### Шаг 7. Запустить Influx exporters + +```bash +systemctl start aw-worktime-influx-exporter.service +systemctl start aw-dlp-influx-exporter.service +journalctl -u aw-worktime-influx-exporter.service -n 50 --no-pager +journalctl -u aw-dlp-influx-exporter.service -n 50 --no-pager +``` + +Ожидание: `wrote ... points`. + +### Шаг 8. Развернуть Grafana dashboards/checks + +```bash +ansible-playbook -i inventory.ini deploy_grafana_dashboards.yml +ansible-playbook -i inventory.ini deploy_grafana_check.yml +``` + +Проверить: + +- datasource health OK; +- dashboard pages HTTP 200; +- worktime panels не пустые; +- DLP panels не пустые при наличии DLP events. + +### Шаг 9. Развернуть 1C/ClickHouse + +Если 1С контур нужен: + +```bash +cd clickhouse-1c +docker compose up -d +clickhouse-client --queries-file clickhouse/init/00_database.sql +clickhouse-client --queries-file clickhouse/init/01_raw_tables.sql +clickhouse-client --queries-file clickhouse/init/02_core_tables.sql +clickhouse-client --queries-file clickhouse/init/03_views.sql +clickhouse-client --queries-file clickhouse/init/04_company_intelligence.sql +clickhouse-client --queries-file clickhouse/init/05_financial_reporting.sql +``` + +Затем включить ingest, detections и dashboards. + +### Шаг 10. Развернуть portal/gateway + +```bash +cd adk-rust +cargo build --release -p detmir-portal +cd ../ansible +ansible-playbook -i inventory.ini deploy_detmir_portal.yml +ansible-playbook -i inventory.ini deploy_proxmox_web_gateway.yml +``` + +Проверить: + +```bash +curl -fsS http://:8720/api/health | jq +curl -fsS http://:8720/api/reports | jq '.status,.sources' +``` + +### Шаг 11. Финальная приемка + +Минимум: + +- `systemctl --failed` пустой на ключевых узлах; +- AW API отвечает; +- buckets свежие; +- Windows validation OK; +- worktime report OK; +- DLP health OK/WARN с понятной причиной; +- Influx exporters пишут points; +- Grafana datasource OK; +- dashboards открываются; +- portal health OK; +- ClickHouse/1C tables не пустые, если включен 1C контур; +- нет secrets в staged diff. + +## 9. Диагностика по симптомам + +### Portal пустой + +1. Проверить `/portal/api/health`. +2. Проверить `aw-worktime-api`. +3. Проверить ActivityWatch buckets. +4. Проверить ClickHouse только если пустой именно 1C/security-events слой. + +### Grafana пустая + +1. Проверить Influx datasource health. +2. Проверить exporters logs. +3. Проверить Influx bucket/measurements. +4. Проверить dashboard JSON/provisioning. +5. Проверить time range и host variable. + +### Worktime неверный + +1. Проверить `aw-worktime-sessions_`. +2. Проверить `AW_WORKTIME_EVENTS_LIMIT`. +3. Проверить user normalization. +4. Проверить stale cache. +5. Не трогать ClickHouse. + +### DLP пустой + +1. Проверить Windows collector/guard. +2. Проверить `aw-dlp-endpoint-signals_`. +3. Проверить policy engine. +4. Проверить DLP case/evidence services. +5. Проверить DLP Influx exporter только для Grafana. + +### 1C пустая + +1. Проверить landing files. +2. Проверить `aw-1c-ingest-rust`. +3. Проверить ClickHouse schema. +4. Проверить detections SQL. +5. Проверить Grafana ClickHouse datasource. + +## 10. Как агент должен работать + +1. Сначала читать `AGENTS.md`. +2. Затем читать этот документ. +3. Для конкретного слоя читать профильный doc: + - Windows: `docs/windows/deployment.md`; + - Worktime: `docs/OPERATIONS_RUNBOOK_WORKTIME_RU.md`; + - Portal: `docs/PORTAL_RU.md`; + - Grafana: `docs/GRAFANA_DASHBOARDS_RU.md`; + - 1C/ClickHouse: `clickhouse-1c/README.md`; + - Rust migration/runtime: `adk-rust/RUNBOOK.md`. +4. Перед изменением фиксировать `git status`. +5. Перед deploy делать syntax/build checks. +6. После deploy делать runtime checks. +7. Перед созданием docs/audit по private files проверять, что в текст не попали + значения credentials. Писать только sanitized факт и remediation. +8. В ответе пользователю писать: + - что изменено; + - какие команды выполнены; + - что проверено; + - что осталось рискованным или не проверено. + +## 11. Запреты + +- Не печатать secrets. +- Не копировать значения secrets из private/ignored файлов в audit, markdown, + terminal summary, commit message или handoff. +- Не коммитить private inventory/env. +- Не править production Grafana только руками без отражения в repo. +- Не перезапускать все сервисы подряд. +- Не трогать сетевой периметр/gateway/pfSense без отдельной команды. +- Не делать destructive DB operations без backup. +- Не считать `ansible --syntax-check` полной проверкой: нужна runtime проверка. +- Не считать открывшийся UI доказательством: нужны свежие данные. + +## 12. Итоговая Definition of Done + +Полная замена ручного оператора возможна только если агент умеет: + +- поднять server и Windows collectors; +- проверить buckets и freshness; +- восстановить worktime report; +- запустить Influx exporters; +- импортировать/проверить Grafana dashboards; +- поднять ClickHouse/1C ingest; +- проверить portal health/reports; +- найти слой отказа по симптомам; +- сделать rollback по backup; +- написать короткий отчет без секретов. + +Если один из пунктов не выполнен, система не считается полностью переданной +агенту. diff --git a/docs/OPENCODE_SYSTEM_READINESS_AUDIT_RU.md b/docs/OPENCODE_SYSTEM_READINESS_AUDIT_RU.md new file mode 100644 index 0000000..4e5171b --- /dev/null +++ b/docs/OPENCODE_SYSTEM_READINESS_AUDIT_RU.md @@ -0,0 +1,205 @@ +# Readiness Audit: handover plan vs codebase reality + +**Дата:** 2026-06-11 +**Метод:** read-only audit всех entrypoints из handover-плана + AGENTS.md + файловая система. +**Правило:** ничего не менять, не деплоить, не коммитить. + +--- + +## Executive Summary + +Handover-план (613 строк) в целом соответствует кодовой базе: все ключевые +компоненты существуют. Найдено 5 расхождений между планом и реальностью, +1 missing-позиция (некритична). Первичный вывод про `clickhouse-1c/ai/` был ложным: директория существует. +Inventory.ini содержит **production credentials в открытом виде**. Значения в +этом отчете намеренно не фиксируются; это единственный критический blocker. +Ниже — детальная таблица по каждому слою. + +--- + +## Полная таблица проверки + +| # | Слой | Функционал (из handover) | Файл/модуль/команда | Проверка | Статус | Что делать дальше | +|---|------|--------------------------|---------------------|----------|--------|-------------------| +| 1 | **AW server** | `activitywatch-server` на `:5600` | `aw-server/activitywatch-server.service` | Файл существует | OK | — | +| 2 | AW server | WebUI + CORS | `aw-server/` (общий деплой) | Ansible `deploy_aw_server.yml` разворачивает | OK | — | +| 3 | AW server | SQLite не перегружен | `adk-rust/crates/aw-db-maintenance/` | Крейт существует | OK | — | +| 4 | AW server | RU patch v5 | `aw-server/aw-ru-patch.js` → деплоится как `ru-patch-v5.js` | Ansible line 450 маппит aw-ru-patch.js → ru-patch-v5.js | OK | В handover-плане (строка 185) curl проверяет `/js/ru-patch-v5.js` — это корректный URL после деплоя, но файла `ru-patch-v5.js` в репозитории нет, только `aw-ru-patch.js` | +| 5 | AW server | Host sanitize script | `aw-server/aw-host-sanitize.js` | Существует | OK | — | +| 6 | AW server | Worktime panel | `aw-server/aw-worktime-panel.js` | Существует | OK | — | +| 7 | **Windows/RDP** | `deploy-ensemble.ps1` | `windows/deploy-ensemble.ps1` | Существует | OK | — | +| 8 | Windows/RDP | `validate-deployment.ps1` | `windows/validate-deployment.ps1` | Существует | OK | — | +| 9 | Windows/RDP | `deployment-config.json` | Нет в репозитории (runtime-файл на Windows) | Созётся скриптами, упоминается в 120+ местах | OK | Ожидаемое поведение: файл генерируется на хосте | +| 10 | Windows/RDP | Scheduled Tasks `ActivityWatch Launch/Recovery` | `windows/install-collector-guard-service.ps1`, `windows/aw-collector-guard.ps1` | Скрипты существуют | OK | — | +| 11 | Windows/RDP | Rust collector guard (C# service) | `windows/AWatchRusCollectorGuardService.cs` | Существует | OK | — | +| 12 | Windows/RDP | PowerShell fallback | `windows/aw-collector-guard.ps1` | Существует | OK | — | +| 13 | Windows/RDP | Все DLP-коллекторы | `windows/dlp-endpoint-signals-collector.ps1`, `file-operations-collector.ps1`, `browser-domains-native-collector.ps1`, `email-outbound-collector.ps1`, `dlp-policy-client.ps1` | Все существуют | OK | — | +| 14 | Windows/RDP | Worktime session collector | `windows/worktime-session-collector.ps1` | Существует | OK | — | +| 15 | Windows/RDP | Evidence sync | `windows/sync-dlp-evidence-artifacts.ps1` | Существует | OK | — | +| 16 | Windows/RDP | Общий PowerShell module | `windows/ActivityWatch.Windows.Common.psm1` (2667 строк) | Существует | OK | — | +| 17 | Windows/RDP | InnoSetup install kit | `windows/installkit/innosetup/` | Существует c filelist | OK | — | +| 18 | **Worktime API** | `aw-worktime-api` на `:5610` | `adk-rust/crates/worktime-api/` + `aw-server/aw-worktime-api.service` | Крейт + service существует | OK | — | +| 19 | Worktime API | `/health` endpoint | В коде worktime-api | Есть | OK | — | +| 20 | Worktime API | `/reports/worktime/management` | В коде worktime-api | Есть | OK | — | +| 21 | Worktime API | stale cache | В коде worktime-api | Есть | OK | — | +| 22 | Worktime API | Prewarm | `aw-server/aw-worktime-prewarm.sh` + `aw-server/aw-worktime-prewarm.service` + `.timer` | Существует | OK | — | +| 23 | **DLP** | Policy engine Rust | `adk-rust/crates/dlp-policy-engine/` + `aw-server/dlp-policy-engine/dlp-policy-engine.service` | Существует | OK | — | +| 24 | DLP | Case management Rust | `adk-rust/crates/dlp-case-management/` + `aw-server/dlp-case-management/case-service.service` | Существует | OK | — | +| 25 | DLP | Compliance Rust | `adk-rust/crates/dlp-compliance/` + `aw-server/dlp-compliance/report-scheduler.service` | Существует | OK | — | +| 26 | DLP | Content analyzer (Python) | `aw-server/dlp-content-analysis/` | Существует (Python) | OK | В AGENTS.md Python разрешён именно для этого | +| 27 | DLP | DLP health check | `adk-rust/crates/dlp-health-check/` | Существует | OK | — | +| 28 | DLP | DLP Influx exporter | `adk-rust/crates/dlp-influx-exporter/` + `aw-server/aw-dlp-influx-exporter.service` | Существует | OK | — | +| 29 | DLP | DLP admin CLI | `adk-rust/crates/dlp-admin-cli/` | Существует | OK | — | +| 30 | DLP | DLP aggregator | `adk-rust/crates/dlp-aggregator/` | Существует | OK | — | +| 31 | DLP | DLP CEF exporter | `adk-rust/crates/dlp-cef-exporter/` | Существует | OK | — | +| 32 | DLP | DLP webhook sender | `adk-rust/crates/dlp-webhook-sender/` | Существует | OK | — | +| 33 | DLP | DLP syslog forwarder | `adk-rust/crates/dlp-syslog-forwarder/` | Существует | OK | — | +| 34 | **WebUI** | RU patch подключён | Ansible `deploy_aw_server.yml` + `apply_webui_ru_patch.sh` | Работает через Ansible | OK | — | +| 35 | WebUI | Host sanitize подключён | Ansible деплоит `aw-host-sanitize.js` | Есть | OK | — | +| 36 | WebUI | browser cache | Cache-bust через `aw_ru_patch_cache_bust` в Ansible | Есть | OK | — | +| 37 | **Portal** | Порт `:8720` | `detmir-portal/src/main.rs` строка 161: `default_value = "127.0.0.1:8720"` | Совпадает с handover | OK | — | +| 38 | Portal | `/api/health` | `main.rs` строка 1517 | Есть | OK | — | +| 39 | Portal | `/api/reports` | `main.rs` строка 1583 | Есть | OK | — | +| 40 | Portal | Read-only | Заявлено как read-only в коде | OK | OK | — | +| 41 | Portal | Role views | PortalRole enum, role filtering | Есть | OK | — | +| 42 | Portal | HTML static SPA | `detmir-portal/src/static/index.html` + `app.js` | Есть | OK | — | +| 43 | Portal | Документация | `docs/PORTAL_RU.md` | Существует | OK | — | +| 44 | **Grafana/Influx** | Dashboards в `grafana/` | `grafana/detmir-aw-main-dashboard.json`, `detmir-rdp-user-activity-dashboard.json`, `detmir-dlp-security-dashboard.json`, `dlp-dashboard.json`, `detmir-dlp-management-dashboard.json`, `pfsense-loki-dashboard.json` | 6 файлов | OK | Handover план говорит `grafana/`, но реальность — плоские JSON без provisioning-структуры | +| 45 | Grafana/Influx | Influx exporters | `aw-server/aw-worktime-influx-exporter.service` + `aw-server/aw-dlp-influx-exporter.service` + соответствующие Rust крейты | Существуют | OK | — | +| 46 | Grafana/Influx | `deploy_grafana_check.yml` | `ansible/deploy_grafana_check.yml` | Существует | OK | — | +| 47 | Grafana/Influx | `deploy_grafana_dashboards.yml` | `ansible/deploy_grafana_dashboards.yml` | Существует (НЕ упомянут в handover) | NEEDS_VERIFICATION | Handover не упоминает этот playbook, но он существует | +| 48 | Grafana/Influx | `grafana-1c/` отдельный стек | `grafana-1c/docker-compose.yml`, `grafana-1c/grafana/dashboards/*.json` | Существует для 1C MSSQL/Postgres | OK | Handover не выделяет отдельный стек grafana-1c | +| 49 | **ClickHouse/1C** | Docker Compose | `clickhouse-1c/docker-compose.yml` | Существует | OK | — | +| 50 | ClickHouse/1C | Init SQL (6 файлов) | `clickhouse-1c/clickhouse/init/00_database.sql` – `05_financial_reporting.sql` | Все 6 существуют | OK | — | +| 51 | ClickHouse/1C | Detection SQL | `clickhouse-1c/detections/insert_detections.sql`, `build_entity_timeline.sql`, `open_cases_from_detections.sql` | Все 3 существуют | OK | — | +| 52 | ClickHouse/1C | ETL Python | `clickhouse-1c/etl/*.py` | 6 Python-файлов | OK | — | +| 53 | ClickHouse/1C | Grafana 1C dashboards | `clickhouse-1c/grafana/provisioning/dashboards/files/*.json` | 10 dashboard JSON | OK | — | +| 54 | ClickHouse/1C | Grafana datasource | `clickhouse-1c/grafana/provisioning/datasources/clickhouse.yml` | Существует | OK | — | +| 55 | ClickHouse/1C | Ingest Rust | `adk-rust/crates/aw-1c-ingest/` | Существует | OK | — | +| 56 | ClickHouse/1C | landing каталоги | Не в репозитории (runtime-директории) | mkdir в handover step 6.3 | OK | Создаются при bootstrap | +| 57 | **Gateway** | Proxmox web gateway | `ansible/deploy_proxmox_web_gateway.yml` | Существует | OK | — | +| 58 | **Rust crates** | Все целевые крейты | 56 членов workspace в `adk-rust/Cargo.toml` | Все `Cargo.toml` найдены | OK | — | +| 59 | Rust crates | Quality gate | `adk-rust/crates/quality-gate/` + `scripts/quality-gate.sh` | Существует | OK | — | +| 60 | **Ansible** | `deploy_aw_server.yml` | `ansible/deploy_aw_server.yml` | Существует | OK | — | +| 61 | Ansible | `deploy_aw_windows.yml` | `ansible/deploy_aw_windows.yml` | Существует | OK | — | +| 62 | Ansible | `deploy_detmir_portal.yml` | `ansible/deploy_detmir_portal.yml` | Существует | OK | — | +| 63 | Ansible | `deploy_proxmox_web_gateway.yml` | `ansible/deploy_proxmox_web_gateway.yml` | Существует | OK | — | +| 64 | Ansible | `deploy_grafana_check.yml` | `ansible/deploy_grafana_check.yml` | Существует | OK | — | +| 65 | Ansible | `inventory.ini` | `ansible/inventory.ini` | Существует | **⚠️ CREDENTIALS LEAK** | Plaintext credentials detected; values redacted | +| 66 | Ansible | `inventory.example.ini` | `ansible/inventory.example.ini` | Существует | OK | — | +| 67 | **Docs/runbooks** | `adk-rust/RUNBOOK.md` | Существует | OK | OK | — | +| 68 | Docs/runbooks | `docs/preparation.md` | Существует | OK | OK | — | +| 69 | Docs/runbooks | `docs/deployment.md` | Существует | OK | OK | — | +| 70 | Docs/runbooks | `docs/runbook.md` | Существует | OK | OK | — | +| 71 | Docs/runbooks | `docs/operations.md` | Существует | OK | OK | — | +| 72 | Docs/runbooks | `docs/windows/deployment.md` | Существует | OK | OK | — | +| 73 | Docs/runbooks | `docs/OPERATIONS_RUNBOOK_WORKTIME_RU.md` | Существует | OK | OK | — | +| 74 | Docs/runbooks | `docs/GRAFANA_DASHBOARDS_RU.md` | Существует | OK | OK | — | +| 75 | Docs/runbooks | `docs/PORTAL_RU.md` | Существует | OK | OK | — | +| 76 | Docs/runbooks | `clickhouse-1c/README.md` | Существует | OK | OK | — | +| 77 | **Root scripts** | `check-aw-data.sh` (Rust wrapper) | Существует (shell → Rust fallback) | OK | OK | — | +| 78 | Root scripts | `check-aw-full.sh` (Rust wrapper) | Существует (shell → Rust fallback) | OK | OK | — | +| 79 | Root scripts | `scripts/prod_rollout.sh` | Существует | OK | OK | — | + +--- + +## Найденные противоречия + +### 1. AGENTS.md vs handover план: `clickhouse-1c/ai/` +Первичный вывод был ошибочным. Директория `clickhouse-1c/ai/` существует и входит в разрешенный Python island. Противоречия нет. + +### 2. Handover план vs код: `ru-patch-v5.js` +Handover (строка 185) проверяет URL `/js/ru-patch-v5.js` — это корректно после +деплоя через Ansible. Но handover упоминает файл так, будто он лежит в +`aw-server/`, тогда как в репозитории исходник называется `aw-ru-patch.js`, +а в `ru-patch-v5.js` переименовывается при деплое (Ansible task строка 450). + +### 3. Handover план vs код: `grafana/` структура +Handover (секция 7) указывает `grafana/` для Grafana/Influx. В реальности: +- Основные дашборды лежат плоскими JSON в корне `grafana/` (нет provisioning-субдиректории) +- Отдельный стек `grafana-1c/` с собственным `docker-compose.yml` для MSSQL/Postgres 1C +- ClickHouse-1C имеет свой provisioning в `clickhouse-1c/grafana/provisioning/` + +Handover-план не отражает это разделение. + +### 4. Ansible: лишние playbook +В handover перечислены 5 playbook, но в `ansible/` существуют 17 файлов, включая: +- `deploy_grafana_dashboards.yml` (не упомянут) +- `deploy_file_1c_windows_telemetry.yml` (не упомянут) +- `deploy_file_1c_analytics.yml` (не упомянут) +- `deploy_dlp_evidence_sync.yml` (не упомянут) +- `deploy_dlp_full_stack.yml` (не упомянут) +- `deploy_aw_pfsense_poller.yml` (не упомянут) +- `audit_cryptopro_windows.yml` (не упомянут) +- `post_validate_aw_windows.yml` (не упомянут) +- `provision_proxmox_ct_and_deploy_aw.yml` (не упомянут) +- `provision_proxmox_ct_matrix_and_deploy_aw.yml` (не упомянут) +- `deploy_tsj_guardian_bot_proxmox.yml` (не упомянут) +- `install_full_stack.yml` (не упомянут) + +Это не ошибка, но handover не полон. + +### 5. DLP service файлы — в поддиректориях +Handover проверяет `systemctl status aw-dlp-policy-engine` и +`aw-dlp-case-management`, что корректно. Но `.service` файлы лежат не +напрямую в `aw-server/`, а в поддиректориях: +`aw-server/dlp-policy-engine/`, `aw-server/dlp-case-management/`, +`aw-server/dlp-compliance/`. Это не влияет на runtime. + +--- + +## MISSING (некритично) + +1. **`secrets/`** — не существует, вместо него `private-config/` с `.gitignore` + и `deploy.env.example`. + +--- + +## Blocker (требует немедленного внимания) + +### 🔴 CRITICAL: Production credentials в открытом виде +Файл **`ansible/inventory.ini`** игнорируется Git, но локально содержит production credentials в plaintext. Значения намеренно не приводятся в этом документе: audit-файлы нельзя превращать в копию секретов. Проверять только факт наличия `ansible_password`/host credentials и немедленно переносить их в vault или внешние переменные. + +Это **реальные production credentials**: +- Production credentials из ignored inventory: Proxmox user password, AW server user password, Windows RDP administrator password. Значения не фиксировать в git, docs, logs или reports. + +Это прямое нарушение AGENTS.md п. 4: "Never add real secrets from `secrets/`, +private `.env`, or host credentials." + +**Рекомендация:** Немедленно заменить на переменные окружения или vault, +затем ротировать скомпрометированные пароли. + +--- + +## Requires human approval + +Следующие действия из handover-плана НЕЛЬЗЯ выполнять без подтверждения: + +| Команда | Почему опасно | +|---------|---------------| +| `ansible-playbook -i inventory.ini deploy_aw_server.yml` | Реальный production деплой с живыми credentials | +| `ansible-playbook -i inventory.ini deploy_aw_windows.yml` | Может пересоздать Windows tasks, прервать сбор данных | +| `ansible-playbook -i inventory.ini deploy_detmir_portal.yml` | Перезапустит portal на production | +| `ansible-playbook -i inventory.ini deploy_grafana_check.yml` | Может изменить Grafana datasource | +| `systemctl restart aw-worktime-api` | Прервёт active reports | +| `systemctl start aw-worktime-influx-exporter.service` | Может записать дубли/битые данные в InfluxDB | +| `systemctl start aw-dlp-influx-exporter.service` | Аналогично | +| `cargo build --release --workspace` | Долгая компиляция (56 крейтов), может занять 20+ мин | +| `docker compose up -d` в `clickhouse-1c/` | Поднимет ClickHouse, может конфликтовать с существующим | + +Все команды с прямым обращением к production (через inventory.ini или SSH) +требуют явного разрешения. + +--- + +## Next actions + +1. **Срочно:** убрать plaintext credentials из `ansible/inventory.ini`: перенести доступы в Ansible Vault или внешние переменные и ротировать уже раскрытые пароли. +2. **Сделано в текущем цикле:** AGENTS.md и handover-план дополнены правилом + sanitized audit, недостающими Ansible playbook и структурой `grafana/` vs + `grafana-1c/` vs `clickhouse-1c/grafana/`. +3. **Добавить `grafana/provisioning/`** структуру в корень для единого + подхода к дашбордам (сейчас JSON плоские — работает только через Ansible + копирование). +4. **Проверить ClickHouse .env** — если содержит реальные credentials, + добавить в .gitignore и вычистить из истории. diff --git a/docs/OPERATIONS_RUNBOOK_WORKTIME_RU.md b/docs/OPERATIONS_RUNBOOK_WORKTIME_RU.md index 529e1f3..70ea766 100644 --- a/docs/OPERATIONS_RUNBOOK_WORKTIME_RU.md +++ b/docs/OPERATIONS_RUNBOOK_WORKTIME_RU.md @@ -45,7 +45,7 @@ curl -sS --max-time 5 http://:5610/health | jq ```bash curl -sS --max-time 8 \ - "http://:5610/reports/worktime/management?format=json&host=HOST-EXAMPLE&allow_stale=1" \ + "http://:5610/reports/worktime/management?format=json&host=SHARKON2025&allow_stale=1" \ | jq '.status,.stale,.runtime' ``` @@ -62,7 +62,7 @@ curl -sS --max-time 12 "http:///portal/api/reports?role=executive" ```bash curl -sS --max-time 5 \ - http://:5600/api/0/buckets/aw-worktime-sessions_HOST-EXAMPLE \ + http://:5600/api/0/buckets/aw-worktime-sessions_SHARKON2025 \ | jq '.metadata.end' ``` @@ -76,6 +76,88 @@ curl -sS --max-time 5 http://:5600/api/0/buckets | jq 'keys' events или временная недоступность ActivityWatch API. Не запускайте повторные тяжелые запросы вручную без лимитов `--max-time`. +## Дубли пользователей в Grafana/Influx + +Симптом: панели Grafana показывают одного сотрудника несколькими строками, +например `USER5` и `user5`, `Администратор` и `администратор`, или показывают +служебные/битые метки вроде `SHARKON2025$` и строк с `�`. + +Причина: старые версии worktime exporter писали raw `username`/`userId` в tag +`user`, а Grafana группировала Influx series по этому сырому tag. Поэтому +варианты регистра, machine account и поврежденная OEM/Unicode строка становились +разными series. После исправления exporter пишет canonical tags, но старые +series остаются в диапазоне Grafana до истечения retention/range, поэтому Flux +queries должны фильтровать и схлопывать их. + +Текущая canonical policy для DetMir RDP host: + +- `USER1/user1`, `USER4/user4`, `USER5/user5` -> `user1`, `user4`, `user5`; +- `администратор` -> `Администратор`; +- users с suffix `$` исключаются; +- users, содержащие Unicode replacement char `�`, исключаются. + +Кодовые точки, где должна сохраняться одинаковая нормализация: + +- `adk-rust/crates/worktime-api/src/main.rs`; +- `adk-rust/crates/worktime-influx-exporter/src/main.rs`. + +Проверка перед deploy: + +```bash +cd /adk-rust +cargo fmt --all --check +cargo test -p worktime-api -p worktime-influx-exporter +cargo build --release -p worktime-api -p worktime-influx-exporter +``` + +Минимальный deploy с backup бинарников: + +```bash +cd /ansible +export no_proxy="localhost,127.0.0.1,10.10.10.13,10.10.10.2,10.10.10.0/24" +export NO_PROXY="$no_proxy" + +ts=$(date -u +%Y%m%dT%H%M%SZ) +ansible -i inventory.ini aw_server -m shell -a "set -e; sudo cp -a /usr/local/bin/aw-worktime-api-rust /usr/local/bin/aw-worktime-api-rust.bak.${ts}; sudo cp -a /usr/local/bin/aw-worktime-influx-exporter-rust /usr/local/bin/aw-worktime-influx-exporter-rust.bak.${ts}" +ansible -i inventory.ini aw_server -m copy -a "src=/home/igor/.cache/detmir-adk-rust-target/release/worktime-api dest=/tmp/aw-worktime-api-rust.new mode=0755" +ansible -i inventory.ini aw_server -m copy -a "src=/home/igor/.cache/detmir-adk-rust-target/release/worktime-influx-exporter dest=/tmp/aw-worktime-influx-exporter-rust.new mode=0755" +ansible -i inventory.ini aw_server -m shell -a 'set -e; sudo install -o root -g root -m 0755 /tmp/aw-worktime-api-rust.new /usr/local/bin/aw-worktime-api-rust; sudo install -o root -g root -m 0755 /tmp/aw-worktime-influx-exporter-rust.new /usr/local/bin/aw-worktime-influx-exporter-rust' +ansible -i inventory.ini aw_server -m shell -a 'set -e; sudo systemctl restart aw-worktime-api; sudo systemctl start aw-worktime-influx-exporter.service; systemctl is-active aw-worktime-api' +``` + +Grafana cleanup для старых Influx series: + +- dashboard JSON: `grafana/detmir-rdp-user-activity-dashboard.json` и + `grafana/detmir-aw-main-dashboard.json`; +- Flux должен фильтровать `user_id !~ /\$$/` и `user_id !~ /�/`; +- известные текущие accounts должны мапиться в canonical labels до grouping; +- grouping должен быть по `report_date,user` или `_time,user`; +- для схлопывания duplicate series использовать `max(column: "_value")`, чтобы + не удваивать часы. + +Если Grafana API import возвращает `403`, используйте provisioning/DB fallback: + +```bash +scp grafana/detmir-aw-main-dashboard.json grafana/detmir-rdp-user-activity-dashboard.json igor@10.10.10.2:~/codex-dashboard-import/ +ssh igor@10.10.10.2 'sudo pct push 201 /home/igor/codex-dashboard-import/detmir-aw-main-dashboard.json /etc/grafana/provisioning/dashboards/aw/detmir-aw-main.json --perms 0644' +ssh igor@10.10.10.2 'sudo pct push 201 /home/igor/codex-dashboard-import/detmir-rdp-user-activity-dashboard.json /etc/grafana/provisioning/dashboards/aw/detmir-rdp-user-activity.json --perms 0644' +ssh igor@10.10.10.2 'sudo pct exec 201 -- bash -lc "cp -a /var/lib/grafana/grafana.db /var/lib/grafana/grafana.db.bak.$(date -u +%Y%m%dT%H%M%SZ); systemctl restart grafana-server"' +``` + +Если provisioning не перезаписал существующие DB dashboards, перед изменением +сделать backup `/var/lib/grafana/grafana.db`, затем заменить только +`dashboard.data` rows по uid `detmir-aw-main` и `detmir-rdp-user-activity`. + +Проверка после deploy: + +- live panel `Вчера: активность по сотрудникам` возвращает только labels + `user1`, `user4`, `user5`, `Администратор`; +- bad labels list пуст для `USER*`, `SHARKON2025$`, `администратор`, `�` и + labels, начинающихся с `\`; +- Grafana dashboard открывается с HTTP `200`, HTML title содержит `Grafana`; +- `aw-worktime-api`, `grafana-server` и `aw-worktime-influx-exporter.timer` + активны. + ## Проверка лимитов Проверить системные настройки: @@ -88,6 +170,10 @@ grep '^AW_WORKTIME_' /etc/activitywatch/aw-server.env Ключевые параметры: - `AW_WORKTIME_EVENTS_LIMIT` - верхний лимит чтения events из ActivityWatch. + Для дневной управленческой аналитики значение должно покрывать рабочий день + по всем активным сессиям. Для пилотного контура используется `5000`; малые + значения вроде `250` допустимы только для аварийного degraded-smoke, иначе + отчет будет построен по последнему хвосту событий, а не по полному дню. - `AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS` - timeout запросов к ActivityWatch API. - `AW_WORKTIME_SOURCE_HTTP_TIMEOUT_SECONDS` - timeout внешних source-запросов. - `AW_WORKTIME_REPORT_CACHE_TTL_SECONDS` - TTL fresh report cache. @@ -129,7 +215,7 @@ systemctl restart aw-worktime-api ```bash curl -sS --max-time 12 \ - "http://:5610/reports/worktime/management?format=json&host=HOST-EXAMPLE&allow_stale=1" \ + "http://:5610/reports/worktime/management?format=json&host=SHARKON2025&allow_stale=1" \ | jq '.status,.stale,.runtime' ``` @@ -171,6 +257,13 @@ systemctl restart aw-worktime-api Перед rollback убедитесь, что backup-файлы действительно относятся к предыдущей рабочей версии. +Последний production rollback set после исправления canonical users +`2026-06-09`: + +- `/var/lib/grafana/grafana.db.bak.20260609T013605Z`; +- `/usr/local/bin/aw-worktime-api-rust.bak.20260609T011956Z`; +- `/usr/local/bin/aw-worktime-influx-exporter-rust.bak.20260609T011956Z`. + ## Признаки успешного восстановления - `/reports/worktime/management` отвечает HTTP 200 в bounded time. diff --git a/docs/OPERATOR_GUIDE_RU.md b/docs/OPERATOR_GUIDE_RU.md index ecef81c..6237b91 100644 --- a/docs/OPERATOR_GUIDE_RU.md +++ b/docs/OPERATOR_GUIDE_RU.md @@ -134,5 +134,5 @@ AWatch-rus помогает контролировать и расследова - `docs/ADMIN_GUIDE_RU.md` - `docs/GRAFANA_DASHBOARDS_RU.md` -- `docs/DETMIR_THREAT_MODEL_RU.md` +- `docs/THREAT_MODEL_RU.md` - `docs/dlp-security-functional-spec-ru.md` diff --git a/docs/OWNERSHIP_RU.md b/docs/OWNERSHIP_RU.md index 685f8dd..2974157 100644 --- a/docs/OWNERSHIP_RU.md +++ b/docs/OWNERSHIP_RU.md @@ -114,4 +114,4 @@ AWatch-rus - `docs/THIRD_PARTY_LICENSES_RU.md` - `docs/REGISTRY_CHECKLIST_RU.md` -- `docs/DETMIR_RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md` +- `docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md` diff --git a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md b/docs/PORTAL_GUI_PLAN_RU.md similarity index 100% rename from docs/DETMIR_PORTAL_GUI_PLAN_RU.md rename to docs/PORTAL_GUI_PLAN_RU.md diff --git a/docs/DETMIR_POWERSHELL_MCP_REMOTE_RU.md b/docs/POWERSHELL_MCP_REMOTE_RU.md similarity index 100% rename from docs/DETMIR_POWERSHELL_MCP_REMOTE_RU.md rename to docs/POWERSHELL_MCP_REMOTE_RU.md diff --git a/docs/DETMIR_PYTHON_RETIREMENT_RU.md b/docs/PYTHON_RETIREMENT_RU.md similarity index 100% rename from docs/DETMIR_PYTHON_RETIREMENT_RU.md rename to docs/PYTHON_RETIREMENT_RU.md diff --git a/docs/RC_EVIDENCE_PACK_PILOT_V1_RU.md b/docs/RC_EVIDENCE_PACK_PILOT_V1_RU.md new file mode 100644 index 0000000..671e8c2 --- /dev/null +++ b/docs/RC_EVIDENCE_PACK_PILOT_V1_RU.md @@ -0,0 +1,111 @@ +# RC Evidence Pack: Pilot v1 + +Документ фиксирует доказательства финальной проверки release candidate процесса для ветки `hardening/pilot-v1-defects-cleanup`. + +## Идентификаторы проверки + +- Branch/ref: `origin/hardening/pilot-v1-defects-cleanup` +- Commit: `a8c0482e760cc17b53182999355f65c17457d7f2` +- Commit short: `a8c0482` +- Дата проверки: `2026-06-12` +- Clean worktree: `/AWatch-rus-rc-validation-a8c0482` +- RC name: `v1.0.2-rc-validation` +- RC output: `dist/release-candidate/v1.0.2-rc-validation/` +- `CARGO_TARGET_DIR`: `$HOME/.cache/aw-rus-hardening-target` + +Абсолютный путь локального операторского home-каталога намеренно не фиксируется в tracked-документации. Это не влияет на воспроизводимость: команда использует стандартный `$HOME`. + +## Команды проверки + +Preflight без вынесенного target dir: + +```bash +bash scripts/build_release_candidate.sh --preflight +``` + +Preflight с вынесенным cargo target dir: + +```bash +CARGO_TARGET_DIR=$HOME/.cache/aw-rus-hardening-target \ + bash scripts/build_release_candidate.sh --preflight +``` + +Полная RC-сборка: + +```bash +CARGO_TARGET_DIR=$HOME/.cache/aw-rus-hardening-target \ + bash scripts/build_release_candidate.sh v1.0.2-rc-validation +``` + +Команда полной сборки без имени RC проверена отдельно и корректно завершается с `exit=2`, потому что первый аргумент обязателен. + +## Созданные RC artifacts + +В каталоге `dist/release-candidate/v1.0.2-rc-validation/` созданы: + +- `FILES.txt` +- `SHA256SUMS.txt` +- `SHA256SUMS-v0.2.txt` +- `git-commit.txt` +- `RELEASE_ASSETS_MANIFEST-v0.2.json` +- `sbom/cargo-metadata-v0.2.json` +- `sbom/cargo-tree-v0.2.txt` +- `sbom/cyclonedx-rust-v0.2.json` +- `sbom/python-inputs-v0.2.txt` +- `sbom/spdx-rust-v0.2.json` + +`git-commit.txt` содержит `a8c0482e760cc17b53182999355f65c17457d7f2`. + +## Artifact verification + +Подтверждено: + +- `sha256sum -c SHA256SUMS.txt`: OK +- `sha256sum -c SHA256SUMS-v0.2.txt`: OK +- JSON parse для `RELEASE_ASSETS_MANIFEST-v0.2.json`: OK +- JSON parse для `sbom/cargo-metadata-v0.2.json`: OK +- JSON parse для `sbom/cyclonedx-rust-v0.2.json`: OK +- JSON parse для `sbom/spdx-rust-v0.2.json`: OK +- `FILES.txt` соответствует фактическому набору checksum-covered файлов: OK + +Повторный запуск с тем же `RC_NAME` блокируется сообщением `release candidate output already exists`; существующий `SHA256SUMS.txt` не изменяется. + +## Dirty-tree guard + +Clean-tree requirement сохранен и проверен двумя сценариями: + +- non-ignored untracked file блокирует настоящую RC-сборку; +- tracked modification блокирует настоящую RC-сборку. + +В обоих случаях скрипт завершается до создания RC-каталога. Ignored files намеренно не блокируют сборку, иначе `dist/` ломал бы повторные проверки и локальную валидацию артефактов. + +## dist/ и git + +Подтверждено: + +- `git ls-files dist` возвращает `0` tracked files; +- `git status --ignored dist` показывает `!! dist/`; +- `dist/` не добавляется в git и остается локальным output-каталогом. + +## Обязательные проверки + +В clean validation worktree выполнены: + +- `bash -n scripts/build_release_candidate.sh`: OK +- `bash scripts/build_release_candidate.sh --preflight`: OK +- `CARGO_TARGET_DIR=$HOME/.cache/aw-rus-hardening-target bash scripts/build_release_candidate.sh --preflight`: OK +- `git diff --check`: OK +- `bash scripts/check_private_config_guard.sh`: OK +- `node scripts/check_portal_contract_sync.mjs`: OK +- `bash scripts/quality-gate.sh`: OK + +Внутри полной RC-сборки также прошли: + +- `cargo fmt --manifest-path adk-rust/Cargo.toml --all -- --check` +- `cargo test --manifest-path adk-rust/Cargo.toml --workspace` +- `cargo clippy --manifest-path adk-rust/Cargo.toml --workspace --all-targets -- -D warnings` +- `cargo build --manifest-path adk-rust/Cargo.toml --workspace --release` + +## Вывод + +Release candidate процесс подтвержден как воспроизводимый в чистом рабочем дереве. Clean-tree requirement сохранен. Сборка не требует ослабления защитных проверок. Ветка готова к review и merge в main. diff --git a/docs/REGISTRY_CHECKLIST_RU.md b/docs/REGISTRY_CHECKLIST_RU.md index ac90062..f5fe43a 100644 --- a/docs/REGISTRY_CHECKLIST_RU.md +++ b/docs/REGISTRY_CHECKLIST_RU.md @@ -38,8 +38,8 @@ ## 4. Документация -- [x] Модель угроз: `docs/DETMIR_THREAT_MODEL_RU.md`. -- [x] Позиционирование: `docs/DETMIR_RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md`. +- [x] Модель угроз: `docs/THREAT_MODEL_RU.md`. +- [x] Позиционирование: `docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md`. - [x] Руководство администратора: `docs/ADMIN_GUIDE_RU.md`. - [x] Руководство оператора: `docs/OPERATOR_GUIDE_RU.md`. - [x] Установка: `docs/INSTALL_RU.md`. diff --git a/docs/REGISTRY_RUSSIAN_SO_POSITIONING_RU.md b/docs/REGISTRY_RUSSIAN_SO_POSITIONING_RU.md index bdbe755..19d2f8e 100644 --- a/docs/REGISTRY_RUSSIAN_SO_POSITIONING_RU.md +++ b/docs/REGISTRY_RUSSIAN_SO_POSITIONING_RU.md @@ -3,7 +3,7 @@ Дата фиксации: `2026-06-03`. Документ фиксирует audit-facing решение по классу подачи AWatch-rus. -Он дополняет `docs/DETMIR_RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md`. +Он дополняет `docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md`. ## 1. Рекомендованный класс diff --git a/docs/RELEASE_AUDIT_2026-06.md b/docs/RELEASE_AUDIT_2026-06.md index df68868..d656e45 100644 --- a/docs/RELEASE_AUDIT_2026-06.md +++ b/docs/RELEASE_AUDIT_2026-06.md @@ -50,8 +50,8 @@ git grep -n -E 'SHARKON2025|10\.10\.10|dm\.iri|/home/igor|/root' -- \ ```text adk-rust/crates/verify-innosetup-installer/src/main.rs:260: let root = std::path::Path::new("/tmp/root"); docs/ARCHITECTURE_RU.md:116:- canonical path/root allowlist; -docs/DETMIR_THREAT_MODEL_RU.md:168:- canonical path/root allowlist; -docs/DETMIR_THREAT_MODEL_RU.md:223:| T05 | Прямая выдача файлов по path traversal | Canonical path/root allowlist, no raw path route. | +docs/THREAT_MODEL_RU.md:168:- canonical path/root allowlist; +docs/THREAT_MODEL_RU.md:223:| T05 | Прямая выдача файлов по path traversal | Canonical path/root allowlist, no raw path route. | proxmox/tsj_guardian_bot.py:960: base_pat += r"|lxc-usernsexec.*(/var/lib/lxc/" + guest_pat + r"/rootfs|/run/lxc/)" ``` diff --git a/docs/RELEASE_CANDIDATE_RUNBOOK_RU.md b/docs/RELEASE_CANDIDATE_RUNBOOK_RU.md new file mode 100644 index 0000000..c4ce29d --- /dev/null +++ b/docs/RELEASE_CANDIDATE_RUNBOOK_RU.md @@ -0,0 +1,108 @@ +# Release Candidate Runbook + +Этот документ описывает техническую сборку Release Candidate для AWatch-rus. RC-сборка нужна, чтобы одной воспроизводимой командой собрать проверенные артефакты, зафиксировать git commit, сформировать SBOM/manifest/checksums и сложить результат в отдельный каталог под конкретное имя кандидата. + +Release Candidate не равен юридической готовности к подаче в реестр и не заменяет финальную процедуру релиза. + +## Evidence pack + +Финальная проверка RC-процесса для ветки `hardening/pilot-v1-defects-cleanup` зафиксирована в `docs/RC_EVIDENCE_PACK_PILOT_V1_RU.md`. + +## Запуск + +Команда выполняется из корня репозитория: + +```bash +bash scripts/build_release_candidate.sh v1.0.2-rc1 +``` + +Первый аргумент обязателен. Имя кандидата используется как имя каталога в `dist/release-candidate/`, поэтому скрипт требует начало с буквы или цифры и дальше принимает только буквы, цифры, точку, подчеркивание и дефис. + +Перед сборкой рабочее дерево git должно быть чистым. Если есть незакоммиченные, staged или untracked файлы, скрипт завершится с ошибкой. Это защищает RC от незафиксированного состояния. + +## Preflight + +Перед полной RC-сборкой можно проверить локальные предпосылки без создания каталога release candidate и без запуска cargo build/test: + +```bash +bash scripts/build_release_candidate.sh --preflight +``` + +Preflight проверяет наличие команд `git`, `cargo`, `bash`, `node`, `sha256sum`, наличие обязательных внутренних скриптов, а также то, что `dist/` игнорируется git. Этот режим не требует чистого git tree, не создает артефакты и не заменяет полную RC-сборку. + +## Если проект лежит на USB/HDD mount + +На локальном контуре проект может лежать под `/mnt/` или `/media/`. В таком случае cargo build artifacts в стандартном `adk-rust/target` могут падать на filesystem-ограничениях mount, например на `libsqlite3-sys` с `Operation not permitted`. + +Рекомендуемый запуск для такого контура: + +```bash +CARGO_TARGET_DIR=$HOME/.cache/aw-rus-hardening-target bash scripts/build_release_candidate.sh v1.0.2-rc1 +``` + +Это не обход проверок. Все `cargo fmt`, `cargo test`, `cargo clippy`, `cargo build`, `quality-gate`, private-config guard, OpenAPI contract guard и SBOM generation продолжают выполняться. Меняется только место, куда cargo складывает build artifacts. + +`dist/` по-прежнему не коммитится. Требование чистого git tree для настоящей RC-сборки также остается обязательным. + +## Проверки + +Скрипт выполняет обязательные проверки и сборку Rust workspace: + +```bash +cargo fmt --manifest-path adk-rust/Cargo.toml --all -- --check +cargo test --manifest-path adk-rust/Cargo.toml --workspace +cargo clippy --manifest-path adk-rust/Cargo.toml --workspace --all-targets -- -D warnings +cargo build --manifest-path adk-rust/Cargo.toml --workspace --release +bash scripts/quality-gate.sh +bash scripts/check_private_config_guard.sh +node scripts/check_portal_contract_sync.mjs +``` + +Если любая проверка падает, RC-сборка считается несостоявшейся. +Неполный output-каталог при ошибке удаляется, чтобы не смешивать частичные артефакты с валидной сборкой. + +## Артефакты + +Результат складывается в: + +```text +dist/release-candidate// +``` + +Для примера выше итоговый каталог будет: + +```text +dist/release-candidate/v1.0.2-rc1/ +``` + +В каталоге создаются: + +- `git-commit.txt` - commit, из которого собран кандидат; +- `FILES.txt` - список файлов, покрытых итоговыми checksum, кроме самого `SHA256SUMS.txt`; +- `SHA256SUMS.txt` - SHA-256 для всех файлов каталога, кроме самого `SHA256SUMS.txt`; +- `sbom/` - SBOM-файлы, созданные существующим генератором `scripts/generate_release_sbom_v0_2.sh`; +- `RELEASE_ASSETS_MANIFEST-v0.2.json` и `SHA256SUMS-v0.2.txt` - manifest/checksums, которые формирует существующий SBOM generator. + +Каталог `dist/` не предназначен для коммита в git. + +## Проверка checksum + +Для проверки итоговых checksum: + +```bash +cd dist/release-candidate/v1.0.2-rc1 +sha256sum -c SHA256SUMS.txt +``` + +Ожидаемый результат - `OK` для всех записей. Любая ошибка означает, что набор артефактов изменился после сборки или поврежден. + +## Перед реальной подачей + +Release Candidate подтверждает техническую воспроизводимость сборки, но перед реальной подачей все еще нужны: + +- release tag; +- release-specific SBOM; +- license review; +- signed/checksummed artifacts; +- проверка отсутствия live/private data; +- финальные install/user/admin guide под конкретную версию. diff --git a/docs/RISK_NARRATIVE_RU.md b/docs/RISK_NARRATIVE_RU.md new file mode 100644 index 0000000..070c419 --- /dev/null +++ b/docs/RISK_NARRATIVE_RU.md @@ -0,0 +1,179 @@ +# Risk Narrative + +Risk Narrative в AWatch-rus - это управленческое объяснение текущего риска на +основе уже существующих сигналов продукта. Слой отвечает на четыре вопроса: + +- что происходит; +- насколько это рискованно; +- почему система так считает; +- что делать дальше. + +Risk Narrative не является ML-прогнозом, LLM-выводом, SIEM, DLP или +автоматическим подтверждением нарушения. Это rule-based decision-support слой +для Pilot v1. + +## API + +Endpoint: + +```http +GET /api/risk/narrative +``` + +Поддерживаемые параметры зависят от текущего контракта портала: + +- `date`; +- `department`; +- `role`; +- `module`. + +Employee-level детализация не добавляется, пока нет отдельной безопасной модели +доступа и приемочного контракта. + +## Модель ответа + +Ответ содержит: + +- `risk_level` - уровень риска: `low`, `guarded`, `medium`, `high`, + `critical`; +- `risk_score` - числовая оценка 0-100; +- `title` - короткий управленческий заголовок; +- `summary` - объяснение ситуации простым языком; +- `why` - причины расчета; +- `evidence` - подтверждающие сигналы; +- `recommended_actions` - ручные действия для ответственных ролей; +- `limitations` - ограничения интерпретации. + +Пример: + +```json +{ + "risk_level": "medium", + "risk_score": 62, + "title": "Умеренный рост операционного риска", + "summary": "Активность подразделения снизилась при росте удаленных сессий и частичных пробелах покрытия.", + "why": [ + "Индекс активности ниже среднего по подразделениям", + "UEBA score повышен", + "Покрытие агентов ниже целевого уровня" + ], + "evidence": [ + { + "source": "workforce_kpi", + "label": "Индекс активности", + "value": "74%", + "severity": "medium" + } + ], + "recommended_actions": [ + "Проверить подразделения с низким покрытием данных", + "Передать security-события в контур ИБ для анализа" + ], + "limitations": [ + "pfSense находится в contract_only режиме", + "Risk Narrative не является ML-прогнозом" + ] +} +``` + +## Rule-Based Scoring + +Модель детерминированная. Она использует только текущие агрегированные сигналы +и не обучается на данных заказчика. + +Уровни: + +| Диапазон | Уровень | Интерпретация | +| --- | --- | --- | +| `0-24` | `low` | Существенных отклонений нет | +| `25-49` | `guarded` | Есть ранние признаки риска | +| `50-74` | `medium` | Нужна ручная проверка причин | +| `75-89` | `high` | Требуется приоритетная проверка | +| `90-100` | `critical` | Нужна срочная ручная проверка | + +Сигналы: + +- сниженный Workforce KPI; +- низкое доверие к KPI; +- низкое покрытие агентами; +- повышенный UEBA severity; +- наличие кандидатов на проверку; +- высокая связь security-событий и активности; +- пропуски данных; +- активность вне рабочего времени; +- рост удаленных сессий; +- `contract_only` ограничение pfSense. + +## Evidence + +`evidence` нужен, чтобы руководитель, ИБ и эксплуатация видели не только итоговый +уровень риска, но и источники вывода. + +Типовые источники: + +- `workforce_kpi`; +- `kpi_explainability`; +- `ueba`; +- `coverage`; +- `risk_heatmap`; +- `security_correlation`; +- `incident_candidates`; +- `pfsense_contract`. + +Evidence не должен содержать реальные ФИО, логины, IP-адреса, hostname или +сырые события безопасности в demo-режиме. + +## Роли + +| Роль | Видимость | +| --- | --- | +| `executive` | Управленческий риск, причины, действия без технической детализации | +| `manager` | Workforce-риск и действия по подразделению | +| `security` | ИБ-релевантные причины, кандидаты и correlation indicators | +| `forensics` | Контекст расследования и evidence package | +| `admin` | Состояние источников, покрытие и технические ограничения | + +Серверные role gates остаются обязательными. Скрытие блоков в HTML не считается +достаточной защитой. + +## UI и Markdown + +В Executive view портал показывает блок: + +```text +Риск-нарратив +``` + +В Markdown-отчете используется раздел: + +```markdown +## Риск-нарратив +``` + +Раздел должен быть понятен руководителю без знаний ИБ: сначала вывод, затем +причины, затем действия и ограничения. + +## Как показывать заказчику + +Рекомендуемый порядок для demo: + +1. Открыть Executive view. +2. Показать `Риск-нарратив`: уровень, score и краткое summary. +3. Показать `Почему`: какие факторы подняли риск. +4. Показать `Evidence`: какие сигналы подтверждают вывод. +5. Показать `Рекомендуемые действия`. +6. Перейти в Security или Forensics только после управленческого вывода. + +Важно: не заявлять, что Risk Narrative сам подтверждает нарушение. Он +приоритизирует ручную проверку. + +## Ограничения Pilot v1 + +- Нет ML, LLM и predictive analytics. +- Нет auto-remediation. +- Нет полноценного SIEM/DLP claim. +- pfSense readiness является `contract_only`, если ingestion отдельно не + включен и не прошел приемку. +- Качество вывода зависит от свежести источников, покрытия агентов и полноты + данных. +- В demo-режиме используются только обезличенные данные. diff --git a/docs/ROADMAP_CONFORMANCE_AUDIT_RU.md b/docs/ROADMAP_CONFORMANCE_AUDIT_RU.md index 1a71dcd..6747507 100644 --- a/docs/ROADMAP_CONFORMANCE_AUDIT_RU.md +++ b/docs/ROADMAP_CONFORMANCE_AUDIT_RU.md @@ -21,19 +21,27 @@ baseline, demo pack, registry readiness package, enterprise deployment package генерируемый отчет теперь начинается с `# AWatch-rus оперативный отчет`, а headline, KPI label и CLI help используют публичное название AWatch-rus. -Главные остаточные gaps: +Закрытые housekeeping gaps Demo Freeze v1: + +- TASK_001-TASK_004 получили явные секции `Выполнение` с артефактами, + проверками и ограничениями. +- Для Risk Narrative создан отдельный документ `docs/RISK_NARRATIVE_RU.md`. +- Добавлен browser-level conformance smoke с Playwright и screenshots runtime + artifacts. + +Оставшиеся acceptance gaps после TASK_014: -- TASK_001-TASK_004 не имеют явной секции `Выполнение`, хотя артефакты по ним - в коде и документации присутствуют. -- Для Risk Narrative нет отдельного `docs/RISK_NARRATIVE_RU.md`; функциональность - подтверждена кодом, OpenAPI, TypeScript, UI и Markdown, но документация - распределена по связанным материалам. -- Portal verification в TASK_011 выполнен через код, статические UI-маркеры и - production smoke; отдельный полноценный Playwright visual run не входил в - обязательный список команд TASK_011. - Полная production-приемка требует live validation на стенде заказчика: доступность, TLS/reverse proxy, источники данных, backup/restore и ownership действий. +- TASK_013 live validation выявил deployment/version drift, но TASK_014 закрыл + его controlled deploy актуального portal binary и повторным live smoke. +- TASK_015 выполнил ручной разбор UEBA `critical`: классификация + `Needs Investigation`, security interpretation - `Operational Risk confirmed; + Security Risk unknown`. +- Перед расширением пилота остается проверить agent coverage/missing application + data и закрепить операционный ownership за deploy parity, rollback и + регулярными smoke. ## Overall Status diff --git a/docs/DETMIR_RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md b/docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md similarity index 99% rename from docs/DETMIR_RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md rename to docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md index b5d8941..c52d24d 100644 --- a/docs/DETMIR_RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md +++ b/docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md @@ -194,9 +194,9 @@ Grafana/portal-аналитику, Telegram-оповещения, runbook automa Уже есть сильная база: -- `docs/DETMIR_THREAT_MODEL_RU.md`; -- `docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md`; -- `docs/DETMIR_PORTAL_GUI_PLAN_RU.md`; +- `docs/THREAT_MODEL_RU.md`; +- `docs/UNIFIED_OPERATING_MODEL_RU.md`; +- `docs/PORTAL_GUI_PLAN_RU.md`; - `docs/dlp-security-functional-spec-ru.md`; - `docs/dlp-gap-analysis.md`; - `docs/GRAFANA_DASHBOARDS_RU.md`; diff --git a/docs/DETMIR_THREAT_MODEL_RU.md b/docs/THREAT_MODEL_RU.md similarity index 99% rename from docs/DETMIR_THREAT_MODEL_RU.md rename to docs/THREAT_MODEL_RU.md index 76436b1..6335ec8 100644 --- a/docs/DETMIR_THREAT_MODEL_RU.md +++ b/docs/THREAT_MODEL_RU.md @@ -314,7 +314,7 @@ ## 12. Связанные документы -- `docs/DETMIR_RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md` +- `docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md` - `docs/ADMIN_GUIDE_RU.md` - `docs/OPERATOR_GUIDE_RU.md` - `docs/INSTALL_RU.md` @@ -322,8 +322,8 @@ - `docs/OWNERSHIP_RU.md` - `docs/THIRD_PARTY_LICENSES_RU.md` - `docs/REGISTRY_CHECKLIST_RU.md` -- `docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md` -- `docs/DETMIR_PORTAL_GUI_PLAN_RU.md` +- `docs/UNIFIED_OPERATING_MODEL_RU.md` +- `docs/PORTAL_GUI_PLAN_RU.md` - `docs/dlp-security-functional-spec-ru.md` - `docs/dlp-gap-analysis.md` - `docs/dlp-production-plan-windows-10-19.md` diff --git a/docs/UEBA_CONFIDENCE_MODEL_RU.md b/docs/UEBA_CONFIDENCE_MODEL_RU.md new file mode 100644 index 0000000..9b5de97 --- /dev/null +++ b/docs/UEBA_CONFIDENCE_MODEL_RU.md @@ -0,0 +1,196 @@ +# UEBA Confidence Model + +Документ описывает защитный слой интерпретации UEBA Score v1 в AWatch-rus. + +Важно: этот слой не меняет scoring, weights, thresholds или severity. Он +объясняет, насколько можно доверять рассчитанному severity в текущем срезе. + +## Зачем нужен слой уверенности + +UEBA Score отвечает на вопрос: + +```text +Насколько сильна обнаруженная аномалия? +``` + +Confidence отвечает на другой вопрос: + +```text +Насколько достаточно данных, чтобы доверять выводу? +``` + +Поэтому `critical` не означает автоматически подтвержденный инцидент. При +низкой уверенности корректная трактовка: + +```text +Высокая аномалия обнаружена, но требуется ручная проверка данных. +``` + +## Severity + +Severity остается частью UEBA Score v1: + +| Score | Severity | Смысл | +| --- | --- | --- | +| `0-14` | `normal` | Существенная аномалия не выявлена | +| `15-39` | `low` | Низкий риск, наблюдение | +| `40-69` | `medium` | Требуется внимание | +| `70-84` | `high` | Требуется ручная проверка | +| `85-100` | `critical` | Срочная ручная проверка | + +Severity не подтверждает нарушение само по себе. + +## Confidence + +Поддерживаемые уровни: + +| Confidence | Смысл | +| --- | --- | +| `high` | Данные свежие, покрытие достаточное, сигналы согласованы | +| `medium` | Есть частичные пропуски или ограниченное подтверждение | +| `low` | Покрытие ниже порога, отсутствуют источники или evidence | +| `unknown` | Данных недостаточно для оценки уверенности | + +## Confidence Contributors + +Модель учитывает шесть факторов: + +| Фактор | Что проверяется | +| --- | --- | +| `agent_coverage` | Доля ожидаемых рабочих мест со свежей телеметрией | +| `data_freshness` | Свежесть данных по ожидаемым узлам | +| `telemetry_completeness` | Наличие Worktime, приложений и классификации | +| `evidence_presence` | Наличие evidence metadata или screenshots | +| `history_depth` | Глубина baseline и число samples | +| `signal_consistency` | Есть ли независимые подтверждающие сигналы | + +Если хотя бы один критичный contributor находится в `low`, общий confidence +становится `low`. Это сделано намеренно: лучше потребовать ручную проверку, +чем выдать высокий score за подтвержденный инцидент. + +## Classification + +Classification не заменяет severity. Она показывает, как интерпретировать +severity с учетом confidence. + +| Classification | Смысл | +| --- | --- | +| `confirmed_risk` | Риск как сигнал подтвержден достаточным качеством данных | +| `likely_risk` | Риск вероятен, но подтверждение неполное | +| `needs_investigation` | Высокий score есть, но уверенность недостаточна | +| `insufficient_data` | Данных недостаточно даже для уверенной оценки риска | + +`confirmed_risk` не означает автоматически подтвержденный ИБ-инцидент, DLP +событие или нарушение сотрудника. Это только подтверждение качества risk signal. + +## API + +`GET /api/ueba` возвращает дополнительные поля: + +```json +{ + "severity": "critical", + "score": 100, + "confidence": "low", + "confidence_score": 0.8, + "classification": "needs_investigation", + "classification_reason": "agent_coverage:coverage_below_target", + "confidence_reasons": [ + "agent_coverage:coverage_below_target" + ], + "evidence_status": "not_available" +} +``` + +Полный объект `risk` также содержит: + +- `confidence_level`; +- `classification`; +- `classification_reason`; +- `confidence_reasons`; +- `confidence_contributors`; +- `evidence_status`. + +## Risk Narrative + +Risk Narrative получает поля: + +```json +{ + "confidence": "low", + "classification": "needs_investigation" +} +``` + +При `low` или `unknown` confidence Risk Narrative должен говорить о ручной +проверке и полноте данных, а не о подтвержденном нарушении. + +## Action Center + +Если UEBA confidence низкий или classification равен `needs_investigation`, +Action Center добавляет действие: + +```text +Проверить полноту данных +``` + +Это действие не исправляет данные автоматически и не меняет scoring. Оно +адресует оператору необходимость проверить покрытие, свежесть и completeness +до жестких управленческих выводов. + +## Интерпретация для ролей + +### Руководитель + +Корректно: + +```text +Система видит критичную аномалию, но уверенность низкая. Сначала проверяем +полноту данных, затем принимаем управленческое решение. +``` + +Некорректно: + +```text +Critical означает доказанное нарушение. +``` + +### ИБ + +Корректно: + +```text +Critical + low confidence = приоритет ручного triage, не подтвержденный incident. +``` + +Некорректно: + +```text +Critical UEBA автоматически является DLP/SIEM incident. +``` + +### Эксплуатация + +Корректно: + +```text +При low confidence сначала проверяются agent coverage, freshness и missing +telemetry. +``` + +## Ограничения + +- Confidence layer не использует ML или LLM. +- Confidence layer не меняет score, severity, thresholds или weights. +- Confidence layer не подтверждает ИБ-инциденты автоматически. +- pfSense readiness остается `contract_only`, если нет фактического ingestion. + +## Acceptance Interpretation + +Для Pilot/Demo Freeze v1 правильная трактовка: + +```text +Severity показывает силу аномалии. +Confidence показывает качество данных. +Classification показывает, можно ли делать вывод или нужен ручной разбор. +``` diff --git a/docs/UEBA_CRITICAL_REVIEW_RU.md b/docs/UEBA_CRITICAL_REVIEW_RU.md new file mode 100644 index 0000000..9d6e3f5 --- /dev/null +++ b/docs/UEBA_CRITICAL_REVIEW_RU.md @@ -0,0 +1,349 @@ +# UEBA Critical Evidence Review + +Дата проверки: 2026-06-07. + +Статус: выполнен ручной разбор текущего `critical` без изменения алгоритма, +весов, thresholds, Risk Narrative и Action Center. + +## Executive Summary + +Текущий UEBA severity `critical` подтвержден как фактический результат +rule-based scoring, но не подтвержден как доказанный инцидент ИБ. + +Классификация: + +```text +Needs Investigation +``` + +Причина: score `100/100` складывается в основном из Workforce/coverage +сигналов, а не из DLP, network, time или application anomaly: + +- `activity_anomaly`: `81`; +- `history_anomaly`: `19`; +- `application_anomaly`: `0`; +- `network_anomaly`: `0`; +- `time_anomaly`: `0`. + +Главный вывод: текущий `critical` безопаснее трактовать как высокий +операционный риск качества данных и активности, требующий ручной проверки. +Показывать его руководителю как подтвержденное нарушение нельзя. + +## Scope + +Проверялось: + +- live `/portal/api/ueba`; +- live `/portal/api/reports` в разных ролях; +- live `/portal/api/workforce/kpi/explain`; +- live `/portal/api/risk/narrative`; +- live `/portal/api/actions`; +- агрегированная свежесть ActivityWatch buckets; +- состояние portal service и production endpoints; +- согласованность UEBA -> Risk Narrative -> Recommended Actions. + +Не выполнялось: + +- изменение UEBA score calculation; +- изменение severity thresholds; +- изменение weights; +- отключение правил; +- изменение Risk Narrative; +- изменение Action Center; +- выгрузка сырых событий; +- сохранение реальных пользователей, hostname, IP, логинов, подразделений или + forensic payload в Git. + +## Current Severity + +Live UEBA summary: + +| Поле | Значение | +| --- | --- | +| Score | `100` | +| Severity | `critical` | +| Status | `FAIL` | +| Model | `rule_based` | +| ML used | `false` | +| LLM used | `false` | +| Policy version | `ueba-rule-v1` | +| Score cap | `100` | + +В full report также подтверждено: + +- `confidence`: `0.8`; +- `baseline_status`: `per_user_department_baseline_skeleton`; +- `baseline_window_days`: `30`; +- `user_baseline_available`: `true`; +- `department_baseline_available`: не подтверждено; +- baseline samples: `total=40`, `users=19`, `departments=21`. + +## Evidence Summary + +Обезличенная цепочка evidence: + +| Источник | Статус | Наблюдение | +| --- | --- | --- | +| DLP counts | доступен | `warn=0`, `fail=0` | +| Incident queue | доступен | открытые вопросы есть | +| Evidence metadata | доступен | `items=0`, `screenshots=0`; используется только для confidence | +| Workforce insights | доступен | `items=9` | +| Workforce policy audit | доступен | политика доступна | +| UEBA baseline | доступен | baseline samples есть | +| UEBA policy | доступен | ошибка policy loading отсутствует | + +Из этого следует: + +- текущий `critical` не подкреплен DLP fail/warn; +- текущий `critical` не подкреплен screenshot/evidence package; +- основной источник риска - Workforce insights и baseline deviation; +- security-events агрегат есть, но сам по себе не доказывает инцидент. + +## Signal Contributions + +Raw rule contributions до score cap: + +| Сигнал | Количество | Вес | Raw contribution | +| --- | ---: | ---: | ---: | +| `open_incidents` | 1 | `+15` | `+15` | +| `workforce_drop` | 8 | `+15` | `+120` | +| `workforce_anomaly` | 1 | `+10` | `+10` | +| `baseline_deviation` | 1 | `+15` | `+15` | + +Raw total: + +```text +15 + 120 + 10 + 15 = 160 +``` + +Final score после cap: + +```text +min(160, 100) = 100 +``` + +Компоненты после пересчета capped score: + +| Component | Score | +| --- | ---: | +| `activity_anomaly` | `81` | +| `history_anomaly` | `19` | +| `application_anomaly` | `0` | +| `network_anomaly` | `0` | +| `time_anomaly` | `0` | + +Важное наблюдение: повторяющиеся `workforce_drop` являются главным драйвером +`critical`. Это может быть реальной массовой просадкой активности, но при +нулевом agent coverage также может быть следствием деградации источников. + +## Coverage Assessment + +Explainable KPI: + +| Поле | Значение | +| --- | --- | +| KPI score | `0` | +| Confidence | `low` | +| Agent coverage | `0%` | +| Data freshness | `fresh` | +| Missing sources | `agent_coverage`, `applications` | + +Agent coverage SLA в admin scope: + +| Поле | Значение | +| --- | ---: | +| Expected nodes | `1` | +| Reporting nodes 24h | `0` | +| Stale nodes | `1` | +| Missing nodes | `0` | +| Coverage | `0%` | +| Freshness | `0%` | +| SLA status | `CRITICAL` | + +ActivityWatch buckets aggregate: + +| Показатель | Значение | +| --- | ---: | +| Total buckets | `27` | +| Buckets with metadata end | `22` | +| Fresh within 15 minutes | `9` | +| Fresh within 1 hour | `9` | +| Fresh within 24 hours | `11` | +| Old or missing metadata | `16` | + +Оценка покрытия: проблемы покрытия могли искусственно усилить severity. При +`agent_coverage=0%`, `confidence=low` и missing `applications` нельзя +отделить реальную просадку активности от недостатка данных без ручной проверки. + +## Explainability Consistency + +UEBA, Risk Narrative и Action Center в целом согласованы: + +- UEBA показывает `critical` из-за Workforce и baseline/history signals; +- Risk Narrative показывает `critical` и указывает на низкий KPI, низкое + доверие к KPI, низкое покрытие агентов, baseline/security context и + кандидатов на проверку; +- Action Center рекомендует проверить агентов, назначить владельца действий и + проверить подразделение/активность. + +Найденные ограничения согласованности: + +1. `/portal/api/ueba` в standalone response не показывает full explainability + поля `confidence`, `baseline_*`, `risk_sources`; они доступны в + `/portal/api/reports`. +2. Risk Narrative показывает candidates в executive/admin context, а security + role получает другой scope: security correlation есть, но executive + candidate list скрыт. Это похоже на role filtering, а не на runtime bug. +3. Risk Narrative включает агрегированные security events, но UEBA components + показывают `application_anomaly=0` и `network_anomaly=0`; значит security + events не должны трактоваться как доказанная причина `critical`. + +Противоречий, требующих немедленного изменения алгоритма, не выявлено. + +## False Positive Analysis + +Признаки возможного true positive: + +- много Workforce signals; +- baseline deviation есть; +- открытая очередь проверки есть; +- Risk Narrative и Action Center согласованно поднимают приоритет. + +Признаки возможного false positive / data-quality noise: + +- agent coverage `0%`; +- freshness SLA `0%`; +- missing sources: `agent_coverage`, `applications`; +- KPI confidence `low`; +- DLP warn/fail отсутствуют; +- evidence screenshots отсутствуют; +- network/time/application components равны `0`; +- score достигает `critical` за счет повторяющихся однотипных + `workforce_drop`. + +Вывод: текущих данных недостаточно для True Positive. Также недостаточно +данных, чтобы назвать это False Positive. Корректная классификация - +`Needs Investigation`. + +## Security Interpretation + +Текущий `critical` нельзя считать подтвержденным security incident. + +Статус для ИБ: + +```text +Operational Risk: confirmed +Security Risk: unknown +``` + +Что можно утверждать: + +- есть критичный операционный риск качества данных и интерпретации Workforce + KPI; +- есть очередь ручной проверки; +- есть baseline/workforce отклонения. + +Что нельзя утверждать: + +- подтвержденная утечка; +- подтвержденный DLP incident; +- подтвержденная сетевой атакой anomaly; +- подтвержденное нарушение конкретного пользователя или подразделения. + +## Executive Interpretation + +Executive readiness: + +```text +Insufficient Confidence +``` + +Текущий `critical` можно показывать руководителю только как пример: + +```text +Система обнаружила критичный риск, но перед управленческим выводом требуется +проверить покрытие агентов и подтвердить первичные данные. +``` + +Нельзя показывать как: + +```text +Система доказала нарушение / инцидент / виновника. +``` + +Для демо руководителю безопасная формулировка: + +> Главный риск сейчас - не доказанное нарушение, а недостаточная достоверность +> данных при множественных сигналах просадки активности. Следующее действие - +> проверить покрытие агентов и передать кандидаты на ручной разбор. + +## Classification + +Итоговая классификация: + +```text +Needs Investigation +``` + +Не `True Positive`, потому что нет достаточного evidence для подтверждения +инцидента: DLP/network/time/application components равны `0`, screenshots/evidence +отсутствуют, agent coverage `0%`. + +Не `False Positive`, потому что Workforce/baseline/open-review signals реально +сработали и runtime ошибок portal service не показал. + +## Risks + +1. Руководитель может воспринять `critical` как доказанный инцидент, если не + пояснить низкую confidence/coverage. +2. Нулевое покрытие агентов может искусственно снижать KPI и усиливать + workforce_drop signals. +3. Повторяющиеся `workforce_drop` могут доминировать score до cap `100`. +4. Высокий агрегат security events без DLP/network contribution может выглядеть + как ИБ-доказательство, хотя это только контекст. +5. Standalone `/api/ueba` менее объясним, чем full report, потому что не + возвращает full baseline/confidence metadata. + +## Recommendations + +До расширения пилота: + +1. Проверить агент на ожидаемом рабочем месте: почему expected node есть, но + reporting nodes за 24 часа `0`. +2. Проверить, почему Explainable KPI видит missing `agent_coverage` и + `applications`. +3. Проверить freshness по active worktime/window/application buckets без + раскрытия пользователей и hostname. +4. Передать ИБ только обезличенную очередь signals: `open_incidents`, + `workforce_drop`, `baseline_deviation`; не заявлять подтвержденный инцидент. +5. На демо использовать wording `требует ручной проверки`, а не + `подтверждено нарушение`. +6. Отдельно рассмотреть product recommendation: standalone `/api/ueba` может + возвращать больше explainability metadata, уже присутствующей в report. Это + recommendation, не изменение в рамках TASK_015. + +Не делать в TASK_015: + +- не менять weights; +- не менять thresholds; +- не подавлять `workforce_drop`; +- не снижать score вручную; +- не отключать `critical`; +- не менять Risk Narrative или Action Center. + +## Conclusion + +UEBA `critical` на live-контуре является корректно рассчитанным rule-based +результатом текущих входных сигналов, но не является подтвержденным security +incident. + +Финальный статус: + +```text +Classification: Needs Investigation +Executive readiness: Insufficient Confidence +Security interpretation: Operational Risk confirmed; Security Risk unknown +Algorithm changes: none +Scoring changes: none +Sensitive data committed: none +``` diff --git a/docs/UEBA_SCORE_RU.md b/docs/UEBA_SCORE_RU.md index 762432d..439ecf6 100644 --- a/docs/UEBA_SCORE_RU.md +++ b/docs/UEBA_SCORE_RU.md @@ -42,12 +42,18 @@ activity anomaly - `score` - число 0-100; - `severity` - `normal`, `low`, `medium`, `high` или `critical`; +- `confidence` - уровень уверенности `high`, `medium`, `low` или `unknown`; +- `classification` - интерпретация `confirmed_risk`, `likely_risk`, + `needs_investigation` или `insufficient_data`; +- `confidence_reasons` - причины снижения уверенности; - `score_components` - пять компонент формулы; - `reason_codes` - коды сработавших правил; - `explanation` - человекочитаемое объяснение; - `model.ml_used=false`; - `model.llm_used=false`. +Подробнее: [UEBA_CONFIDENCE_MODEL_RU.md](UEBA_CONFIDENCE_MODEL_RU.md). + ## Ограничения UEBA v1 не является SIEM-корреляцией и не является классическим DLP. Это diff --git a/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md b/docs/UNIFIED_OPERATING_MODEL_RU.md similarity index 98% rename from docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md rename to docs/UNIFIED_OPERATING_MODEL_RU.md index 970dd26..1017914 100644 --- a/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md +++ b/docs/UNIFIED_OPERATING_MODEL_RU.md @@ -8,12 +8,12 @@ Если старые документы расходятся с этим файлом по адресам или runtime-ролям, для текущей эксплуатации приоритет у этого файла. -Связанная security-основа: `docs/DETMIR_THREAT_MODEL_RU.md` фиксирует текущую +Связанная security-основа: `docs/THREAT_MODEL_RU.md` фиксирует текущую операционную модель угроз. Это рабочая модель для платформы операционного контроля и технического аудита, а не формальная сертификационная модель ФСТЭК. Связанное продуктовое позиционирование: -`docs/DETMIR_RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md` фиксирует безопасный +`docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md` фиксирует безопасный заход для реестра российского ПО: AWatch-rus как платформа операционного контроля и управления ИТ-инфраструктурой, с ориентиром на класс `09.10`, без заявления сертифицированной DLP/SIEM/EDR/XDR/СЗИ. @@ -422,8 +422,8 @@ Telegram bot `DetMirAuto` обязан покрывать: | `SECURITY.md` | security findings по risky фазам | | `UAT.md` | операторская приемка | | `docs/runbook.md` | живая эксплуатация | -| `docs/DETMIR_THREAT_MODEL_RU.md` | рабочая модель угроз и границы security-позиционирования | -| `docs/DETMIR_RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md` | стратегия позиционирования для реестра российского ПО | +| `docs/THREAT_MODEL_RU.md` | рабочая модель угроз и границы security-позиционирования | +| `docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md` | стратегия позиционирования для реестра российского ПО | | `docs/ADMIN_GUIDE_RU.md` | руководство администратора | | `docs/OPERATOR_GUIDE_RU.md` | руководство оператора | | `docs/INSTALL_RU.md` | установка и первичная проверка | diff --git a/docs/dlp-ioc-enrichment.md b/docs/dlp-ioc-enrichment.md index d5353d9..c62771b 100644 --- a/docs/dlp-ioc-enrichment.md +++ b/docs/dlp-ioc-enrichment.md @@ -1,72 +1,162 @@ # DLP IOC Enrichment from Hayabusa/Sigma -This adds a safe offline pipeline to preload DLP blacklists from static Sigma indicators. +This document describes the automatic DLP IOC/signature replenishment pipeline. +It is separate from the DLP Policy Engine lifecycle and from the Hayabusa EVTX +forensics runner. + +## Purpose + +The pipeline preloads DLP indicator blacklists from static Sigma indicators. It +is used to enrich endpoint DLP rules without manually editing endpoint JSON +policy files. ## Source -- Sigma rules from Hayabusa ruleset (`hayabusa-rules` YAML files). +- Upstream ruleset: `Yamato-Security/hayabusa-rules` +- Default source URL: + `https://github.com/Yamato-Security/hayabusa-rules/archive/refs/heads/main.zip` +- Ansible variable: `aw_dlp_ioc_rules_zip_url` +- Production enable flag: `aw_dlp_ioc_enabled` -## Extracted indicators +Hayabusa rules may carry licenses that are separate from the Hayabusa binary. +Check the upstream ruleset license before packaging or redistributing generated +artifacts. + +## Production Pipeline + +When `aw_dlp_ioc_enabled=true`, `ansible/deploy_aw_server.yml` installs and +starts this chain on the AW server: + +1. `aw-dlp-ioc-refresh.timer` runs on boot and then every + `aw_dlp_ioc_refresh_interval` (`6h` by default). +2. The timer starts `aw-dlp-ioc-refresh.service`. +3. The service executes `/usr/local/bin/aw-dlp-ioc-refresh.sh`. +4. The wrapper downloads `aw_dlp_ioc_rules_zip_url`. +5. The wrapper unpacks `hayabusa-rules` Sigma YAML files into a temporary + working directory. +6. The wrapper runs `/usr/local/bin/aw-extract-ioc-from-sigma`. +7. The Rust extractor writes generated IOC artifacts to + `/opt/activitywatch/dlp-ioc/output`. +8. `aw-worktime-api` serves the artifacts from `/dlp-ioc/...` for DLP policy + consumption. + +Production units: + +- `aw-dlp-ioc-refresh.service` +- `aw-dlp-ioc-refresh.timer` + +Production paths: + +- workdir: `/opt/activitywatch/dlp-ioc` +- output dir: `/opt/activitywatch/dlp-ioc/output` +- latest symlink: `/opt/activitywatch/dlp-ioc/latest` + +## Extractor + +Primary extractor: + +- crate: `adk-rust/crates/extract-ioc-from-sigma` +- installed binary: `/usr/local/bin/aw-extract-ioc-from-sigma` +- local build: + +```bash +cd /adk-rust +cargo build --release -p extract-ioc-from-sigma +``` + +Local/manual wrapper: + +- `scripts/build_dlp_ioc_from_hayabusa.sh` + +```bash +cd +bash scripts/build_dlp_ioc_from_hayabusa.sh \ + /mnt/usb_hdd1/Projects/hayabusa/rules \ + /data/dlp-ioc +``` + +The old Python extractor path is not the production path. Do not document +`scripts/extract_ioc_from_sigma.py` as the current core extractor. + +## Extracted Indicators + +Supported Sigma fields: - `Image|endswith` -> `process_image_endswith` - `CommandLine|contains` -> `commandline_contains` - `OriginalFileName` -> `original_filename` - `Hashes|SHA256` -> `sha256` -## Scripts +The extractor de-duplicates and sorts rows before writing outputs. -- `scripts/extract_ioc_from_sigma.py` (core extractor) -- `scripts/build_dlp_ioc_from_hayabusa.sh` (wrapper) +## Output Artifacts -## Production (AW server ) +Generated files: -IOC enrichment is deployed by `ansible/deploy_aw_server.yml` when `aw_dlp_ioc_enabled=true`. - -- systemd service: `aw-dlp-ioc-refresh.service` -- systemd timer: `aw-dlp-ioc-refresh.timer` -- refresh interval: `aw_dlp_ioc_refresh_interval` (default `6h`) -- output dir: `/opt/activitywatch/dlp-ioc/output` -- HTTP export via existing AW worktime API (`:5610`): - - `http://:5610/dlp-ioc/ioc_blacklist.json` - - `http://:5610/dlp-ioc/ioc_blacklist.csv` - - `http://:5610/dlp-ioc/ioc_blacklist.sql` - -Mandatory post-deploy checks in Ansible: - `ioc_blacklist.json` - `ioc_blacklist.csv` - `ioc_blacklist.sql` -Each file must exist and be non-empty, otherwise deploy fails. +Production HTTP export through `aw-worktime-api` (`:5610`): -## Run +- `http://:5610/dlp-ioc/ioc_blacklist.json` +- `http://:5610/dlp-ioc/ioc_blacklist.csv` +- `http://:5610/dlp-ioc/ioc_blacklist.sql` -```bash -cd -bash scripts/build_dlp_ioc_from_hayabusa.sh +`aw-worktime-api` only serves these three IOC filenames from the DLP IOC +directory. + +## Endpoint Consumption + +Windows DLP policy consumes the feed through the `ioc` block: + +```json +{ + "ioc": { + "enabled": true, + "source": "http://aw-server.example.local:5610/dlp-ioc/ioc_blacklist.json", + "format": "hayabusa_sigma_v1", + "refreshMinutes": 60 + } +} ``` -Optional custom paths: +The endpoint collector loads this source and reports loaded IOC state in its +health/heartbeat data, including `iocRulesLoaded`. + +## Operational Checks + +Server checks: ```bash -bash scripts/build_dlp_ioc_from_hayabusa.sh \ - /mnt/usb_hdd1/Projects/hayabusa/rules \ - /data/dlp-ioc +systemctl status aw-dlp-ioc-refresh.timer --no-pager +systemctl status aw-dlp-ioc-refresh.service --no-pager +journalctl -u aw-dlp-ioc-refresh.service -n 80 --no-pager +ls -lh /opt/activitywatch/dlp-ioc/output/ioc_blacklist.* +curl -fsS http://127.0.0.1:5610/dlp-ioc/ioc_blacklist.json | jq 'length' ``` -## Output artifacts +Expected result: -- `data/dlp-ioc/ioc_blacklist.json` -- `data/dlp-ioc/ioc_blacklist.csv` -- `data/dlp-ioc/ioc_blacklist.sql` +- timer is enabled and active; +- last service run completed successfully; +- `ioc_blacklist.json`, `ioc_blacklist.csv`, and `ioc_blacklist.sql` exist and + are non-empty; +- Worktime API serves the JSON feed; +- endpoint health shows non-zero `iocRulesLoaded` when the feed contains rules. -## DLP import mapping +Mandatory post-deploy checks in Ansible require all three output files to exist +and be non-empty. Deployment fails if any artifact is missing or empty. -- `process_image_endswith` -> denied process/image list -- `commandline_contains` -> denied command pattern list -- `original_filename` -> suspicious original filename list -- `sha256` -> malware hash blocklist +## Boundaries -## Safety notes +- This pipeline enriches DLP IOC/signature inputs automatically. +- It does not approve, deploy, or roll back policy versions. That is the role + of the DLP Policy Engine. +- It does not run Hayabusa against EVTX artifacts. That is the separate + server-side Hayabusa forensics path. +- It does not modify running DLP agents directly; agents consume the published + IOC feed through their policy. -- This pipeline only creates export artifacts and does not modify running DLP agents. -- Review and tune false positives before enforcing blocking in production. +Review and tune false positives before using generated indicators for blocking +actions in production. diff --git a/docs/dlp-security-functional-spec-ru.md b/docs/dlp-security-functional-spec-ru.md index c96350f..90e5f6c 100644 --- a/docs/dlp-security-functional-spec-ru.md +++ b/docs/dlp-security-functional-spec-ru.md @@ -22,7 +22,7 @@ Система не является полноценной DLP-платформой enterprise-класса с нативной аутентификацией, RBAC, аппаратной изоляцией и криптографической подписью политик. Это важно учитывать при ИБ-оценке. Текущая модель угроз для всего контура AWatch-rus зафиксирована отдельно: -`docs/DETMIR_THREAT_MODEL_RU.md`. В ней DLP-функции рассматриваются как часть +`docs/THREAT_MODEL_RU.md`. В ней DLP-функции рассматриваются как часть платформы операционного контроля и технического аудита, а не как заявление о сертифицированной DLP/СЗИ. @@ -153,10 +153,16 @@ Deployment/tooling: Сценарии и артефакты: -- `scripts/extract_ioc_from_sigma.py` - Извлечение IOC из Sigma/Hayabusa rules. +- `adk-rust/crates/extract-ioc-from-sigma` + Production Rust extractor для извлечения IOC из Sigma/Hayabusa rules. +- `/usr/local/bin/aw-extract-ioc-from-sigma` + Установленный production binary на AW server. - `scripts/build_dlp_ioc_from_hayabusa.sh` - Построение JSON/CSV/SQL артефактов IOC. + Локальный/manual wrapper для построения JSON/CSV/SQL артефактов IOC. +- `aw-dlp-ioc-refresh.service` / `aw-dlp-ioc-refresh.timer` + Автоматическое пополнение IOC из upstream `Yamato-Security/hayabusa-rules`. + +Подробный runtime contract: `docs/dlp-ioc-enrichment.md`. ### 2.9 Health / autoheal / operations @@ -478,11 +484,18 @@ Policy Engine поддерживает: ## 15. IOC enrichment через Hayabusa / Sigma -Реализован вспомогательный pipeline: +Реализован автоматический pipeline пополнения DLP IOC/сигнатур: -- разбор Sigma/YAML правил; +- источник: GitHub ruleset `Yamato-Security/hayabusa-rules`; +- deployment variable: `aw_dlp_ioc_rules_zip_url`; +- расписание: `aw-dlp-ioc-refresh.timer`, по умолчанию каждые `6h`; +- production extractor: Rust binary `/usr/local/bin/aw-extract-ioc-from-sigma`; +- разбор Sigma/YAML правил из `hayabusa-rules`; - извлечение IOC-полей; -- выгрузка в `json/csv/sql`. +- выгрузка в `ioc_blacklist.json`, `ioc_blacklist.csv`, `ioc_blacklist.sql`; +- публикация через `aw-worktime-api` на `/dlp-ioc/ioc_blacklist.*`; +- потребление Windows DLP policy через `ioc.source` и формат + `hayabusa_sigma_v1`. Извлекаемые типы: @@ -494,6 +507,15 @@ Policy Engine поддерживает: Назначение: - preload blacklist/indicator данных для DLP и смежной аналитики. +- автоматическое обогащение DLP rules без ручного редактирования endpoint JSON. + +Границы: + +- DLP Policy Engine управляет жизненным циклом политик + (`draft/approve/deploy/rollback`), но не является источником upstream + сигнатур. +- Hayabusa/Sigma IOC enrichment не равен Hayabusa EVTX forensics runner; это + отдельный контур пополнения IOC blacklist. ## 16. Health-check, autoheal и эксплуатационная устойчивость @@ -604,7 +626,7 @@ python3 scripts/dlp-admin-cli.py cases list --limit 50 ## 21. Связанные документы -- `docs/DETMIR_THREAT_MODEL_RU.md` +- `docs/THREAT_MODEL_RU.md` - `docs/dlp-policy-engine.md` - `docs/dlp-integrations.md` - `docs/dlp-enforcement.md` diff --git a/docs/roadmap/TASK_001_PILOT_V1_STABILIZATION.md b/docs/roadmap/TASK_001_PILOT_V1_STABILIZATION.md index 70bbd3c..a3523c4 100644 --- a/docs/roadmap/TASK_001_PILOT_V1_STABILIZATION.md +++ b/docs/roadmap/TASK_001_PILOT_V1_STABILIZATION.md @@ -22,3 +22,44 @@ AWatch-rus. Pilot v1 можно показывать заказчику с понятным списком готовых возможностей, ограничений и smoke-проверок. + +--- + +## Выполнение + +Статус: выполнено как часть Pilot v1 freeze. + +Что закреплено: + +- роли `executive`, `manager`, `security`, `forensics`, `admin`; +- серверные role gates для Pilot v1 API; +- Executive, Workforce, Security и Forensics portal views; +- Pilot v1 acceptance/evidence документация; +- demo/runbook слой для контролируемого показа; +- browser-level conformance smoke для ключевых представлений. + +Ключевые артефакты: + +- `docs/PILOT_V1_RU.md`; +- `docs/PILOT_V1_ACCEPTANCE_CHECKLIST_RU.md`; +- `docs/PILOT_V1_EVIDENCE_RU.md`; +- `docs/PILOT_VALIDATION_CHECKLIST_RU.md`; +- `docs/DEMO_RUNBOOK_RU.md`; +- `docs/BROWSER_CONFORMANCE_RU.md`; +- `scripts/detmir-portal-tabs-smoke.mjs`; +- `scripts/browser-conformance-smoke.mjs`; +- `scripts/pilot-validation-smoke.mjs`. + +Проверки: + +- `node scripts/pilot-validation-smoke.mjs`; +- `node scripts/detmir-portal-tabs-smoke.mjs` на локальном портале; +- `node scripts/browser-conformance-smoke.mjs` на локальном портале; +- `git diff --check`. + +Известные ограничения: + +- production acceptance требует отдельной live-проверки на стенде заказчика; +- screenshots из `artifacts/browser-smoke/` являются runtime artifacts и не + коммитятся; +- новые collectors и новая функциональность в freeze-фазе не добавляются. diff --git a/docs/roadmap/TASK_002_PRODUCTION_HARDENING.md b/docs/roadmap/TASK_002_PRODUCTION_HARDENING.md index 8b864d4..f9360b3 100644 --- a/docs/roadmap/TASK_002_PRODUCTION_HARDENING.md +++ b/docs/roadmap/TASK_002_PRODUCTION_HARDENING.md @@ -381,3 +381,72 @@ Smoke должен проверять: 7. Результаты команд проверки. 8. Результат smoke. 9. Известные ограничения + +--- + +## Выполнение + +Статус: выполнено для Pilot v1 production-hardening слоя портала. + +Краткое описание: + +- добавлены production endpoints `/healthz`, `/readyz`, `/version`, `/metrics`; +- добавлены request id / correlation id headers; +- добавлены structured JSON HTTP logs; +- добавлены bounded query/body limits для тяжелых API; +- добавлена валидация production-конфигурации; +- role gates сохранены и проверяются smoke; +- pfSense остается `contract_only`, без заявления ingestion/SIEM. + +Ключевые файлы: + +- `adk-rust/crates/detmir-portal/src/production/`; +- `adk-rust/crates/detmir-portal/src/main.rs`; +- `docs/PRODUCTION_READINESS_RU.md`; +- `scripts/awatch-production-hardening-smoke.mjs`. + +Endpoints: + +- `GET /healthz`; +- `GET /readyz`; +- `GET /version`; +- `GET /metrics`; +- защищенные Pilot v1 API: `/api/reports`, `/api/executive`, + `/api/workforce`, `/api/security`, `/api/forensics`, `/api/ueba`, + `/api/pfsense`, `/api/workforce/kpi/explain`. + +Лимиты и защита: + +- max request body size; +- max/default page size; +- max report date range; +- request timeout и slow request logging threshold; +- отказ `400` для слишком большого `page_size` или диапазона отчета; +- отказ `413` для слишком большого body; +- отказ `403` по role gate. + +Метрики: + +- `awatch_http_requests_total`; +- `awatch_http_request_duration_seconds`; +- `awatch_reports_generated_total`; +- `awatch_ingestion_records_total`; +- `awatch_ingestion_rejected_total`; +- `awatch_role_denied_total`; +- `awatch_readyz_status`. + +Проверки: + +- `cargo fmt --all --check`; +- `cargo clippy --all-targets --all-features -- -D warnings`; +- `cargo test --all`; +- `cargo build --release`; +- `AWATCH_PORTAL_SMOKE_URL=http://127.0.0.1:8720 node scripts/awatch-production-hardening-smoke.mjs`; +- `git diff --check`. + +Известные ограничения: + +- production-hardening smoke не заменяет live acceptance; +- `/readyz` отражает только реально настроенные зависимости; +- contract-only интеграции не считаются работающими сборщиками; +- freeze-фаза допускает только исправление дефектов и уточнение документации. diff --git a/docs/roadmap/TASK_003_EXPLAINABLE_KPI.md b/docs/roadmap/TASK_003_EXPLAINABLE_KPI.md index 12891c2..efb8b7e 100644 --- a/docs/roadmap/TASK_003_EXPLAINABLE_KPI.md +++ b/docs/roadmap/TASK_003_EXPLAINABLE_KPI.md @@ -309,4 +309,72 @@ docs/EXPLAINABLE_KPI_RU.md 5. Добавленные UI-блоки. 6. Добавленные тесты. 7. Результаты проверок. -8. Известные ограничения. \ No newline at end of file +8. Известные ограничения. + +--- + +## Выполнение + +Статус: выполнено для Pilot v1. + +Краткое описание: + +- добавлен explainability-контракт Workforce KPI; +- добавлен endpoint `GET /api/workforce/kpi/explain`; +- добавлена детерминированная rule-based модель факторов; +- добавлен confidence level `high` / `medium` / `low`; +- UI показывает блок `Почему такой индекс активности?`; +- Markdown-отчет содержит explainability-раздел; +- OpenAPI и TypeScript contracts включают explain model; +- employee-level детализация не добавлена без отдельного безопасного контракта. + +Ключевые файлы: + +- `adk-rust/crates/detmir-portal/src/workforce_kpi_explain.rs`; +- `adk-rust/crates/detmir-portal/src/static/app.js`; +- `adk-rust/crates/detmir-portal/src/contracts/openapi.json`; +- `adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts`; +- `docs/EXPLAINABLE_KPI_RU.md`. + +API endpoint: + +- `GET /api/workforce/kpi/explain`. + +Модель explainability: + +- `kpi_score`; +- `confidence`; +- `coverage`; +- `factors`; +- `top_applications`; +- `warnings`; +- `recommendations`. + +Минимальные факторы: + +- `productive_activity`; +- `business_app_usage`; +- `idle_time`; +- `afterhours_activity`; +- `remote_session_activity`; +- `data_coverage`; +- `missing_data`; +- `trend_change`. + +Проверки: + +- unit tests для explainability-модели и confidence; +- role-filtering smoke; +- markdown/report smoke; +- `cargo fmt --all --check`; +- `cargo clippy --all-targets --all-features -- -D warnings`; +- `cargo test --all`; +- `cargo build --release`; +- portal smoke. + +Известные ограничения: + +- не используется ML, LLM или predictive scoring; +- KPI не является HR-дисциплинарной оценкой; +- персональная explainability-модель в Pilot v1 не включена; +- качество объяснения зависит от свежести и полноты источников. diff --git a/docs/roadmap/TASK_004_RISK_NARRATIVE.md b/docs/roadmap/TASK_004_RISK_NARRATIVE.md index d020876..7b21909 100644 --- a/docs/roadmap/TASK_004_RISK_NARRATIVE.md +++ b/docs/roadmap/TASK_004_RISK_NARRATIVE.md @@ -278,4 +278,82 @@ docs/RISK_NARRATIVE_RU.md 6. Обновления report/OpenAPI/TypeScript. 7. Добавленные тесты. 8. Результаты fmt/clippy/test/build/smoke. -9. Известные ограничения. \ No newline at end of file +9. Известные ограничения. + +--- + +## Выполнение + +Статус: выполнено для Pilot v1. + +Краткое описание: + +- добавлен rule-based Risk Narrative layer; +- добавлен endpoint `GET /api/risk/narrative`; +- risk score связывает Workforce KPI, Explainable KPI, UEBA, coverage, + security correlation, incident candidates и pfSense `contract_only` + limitation; +- Executive UI показывает блок `Риск-нарратив`; +- Security UI проверяет ИБ-релевантную связь рисков и активности; +- Markdown-отчет содержит раздел `## Риск-нарратив`; +- OpenAPI и TypeScript contracts обновлены; +- создана отдельная документация `docs/RISK_NARRATIVE_RU.md`. + +Ключевые файлы: + +- `adk-rust/crates/detmir-portal/src/risk_narrative.rs`; +- `adk-rust/crates/detmir-portal/src/static/app.js`; +- `adk-rust/crates/detmir-portal/src/contracts/openapi.json`; +- `adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts`; +- `docs/RISK_NARRATIVE_RU.md`; +- `scripts/browser-conformance-smoke.mjs`; +- `scripts/detmir-portal-tabs-smoke.mjs`. + +Risk scoring rules: + +- `0-24` - `low`; +- `25-49` - `guarded`; +- `50-74` - `medium`; +- `75-89` - `high`; +- `90-100` - `critical`. + +Сигналы: + +- low Workforce KPI; +- low KPI confidence; +- low agent coverage; +- increased UEBA severity; +- incident candidates count; +- high security correlation; +- missing data; +- afterhours/remote activity; +- pfSense `contract_only` limitation. + +UI-блоки: + +- Executive: `Риск-нарратив`, `Почему`, `Подтверждения`, `Дальше`, + `Ограничения`; +- Security: `Связь рисков и активности`, `Требует проверки`, + `Рекомендуемые действия ИБ`; +- Forensics: расследования, timeline, материалы расследования и аудит. + +Проверки: + +- unit tests для risk narrative scenarios; +- OpenAPI/TypeScript contract smoke; +- `node scripts/browser-conformance-smoke.mjs`; +- `node scripts/detmir-portal-tabs-smoke.mjs`; +- `cargo fmt --all --check`; +- `cargo clippy --all-targets --all-features -- -D warnings`; +- `cargo test --all`; +- `cargo build --release`; +- `git diff --check`. + +Известные ограничения: + +- Risk Narrative не является ML/LLM/predictive analytics; +- Risk Narrative не подтверждает нарушение без ручной проверки; +- нет auto-remediation; +- pfSense не заявляется как ingestion/SIEM, пока это не пройдет отдельную + приемку; +- live customer-stand validation остается отдельным шагом Demo Freeze v1. diff --git a/docs/roadmap/TASK_013_DETMIR_PRODUCTION_VALIDATION.md b/docs/roadmap/TASK_013_DETMIR_PRODUCTION_VALIDATION.md index d0ec1b4..3646203 100644 --- a/docs/roadmap/TASK_013_DETMIR_PRODUCTION_VALIDATION.md +++ b/docs/roadmap/TASK_013_DETMIR_PRODUCTION_VALIDATION.md @@ -370,3 +370,89 @@ git diff --check 7. Какие scripts добавлены. 8. Результаты проверок. 9. Рекомендованные следующие задачи. + +--- + +## Выполнение + +Статус: выполнено как production validation / operational audit. + +Создан документ: + +- `docs/DETMIR_PRODUCTION_VALIDATION_RU.md`. + +Проверено без коммита реальных payload/logs/screenshots: + +- gateway и portal runtime; +- ActivityWatch API; +- portal API reports; +- portal tabs и role views через browser/tabs smoke; +- Workforce/KPI report structure; +- UEBA endpoint; +- Risk Narrative endpoint availability; +- Executive Action Center endpoint availability; +- Windows/RDP agent runtime; +- AW server service state; +- bucket freshness summary; +- production-hardening endpoint availability; +- sensitive data hygiene. + +Подтверждено работающее: + +- gateway-level health; +- portal UI на фактическом gateway-local port; +- `/portal/api/health`; +- `/portal/api/reports` по ролям; +- Security events backend; +- `/portal/api/ueba`; +- Forensics view; +- базовые portal tabs; +- server role gates в существующем tabs smoke; +- ActivityWatch API и свежие buckets; +- текущий Windows runtime с `awatch-agent-rs` и watchers. + +Найдены gaps: + +- live portal runtime отстает от Demo Freeze v1; +- `/portal/api/workforce/kpi/explain`, `/portal/api/risk/narrative`, + `/portal/api/actions` на live-контуре возвращают `404`; +- `/healthz`, `/readyz`, `/version`, `/metrics` не доступны на фактическом + portal port; +- request id / correlation id headers не возвращаются live portal API; +- Executive visual conformance smoke не проходит на live runtime; +- UEBA `critical` требует ручной проверки evidence, чтобы исключить шум. + +Scripts: + +- новые scripts не добавлялись; +- использованы существующие `scripts/browser-conformance-smoke.mjs`, + `scripts/detmir-portal-tabs-smoke.mjs`, + `scripts/awatch-production-hardening-smoke.mjs`, + `scripts/deployment-readiness-smoke.mjs`, + `scripts/pilot-validation-smoke.mjs`. + +Результаты проверок: + +- `cargo fmt --all --check` - OK; +- `cargo clippy --all-targets --all-features -- -D warnings` - OK; +- `cargo test --all` - OK; +- `cargo build --release` - OK; +- `node scripts/deployment-readiness-smoke.mjs` - OK; +- `node scripts/pilot-validation-smoke.mjs` - OK; +- live `scripts/browser-conformance-smoke.mjs` - FAIL для Executive, + Workforce, Security; OK для Forensics; +- live `scripts/detmir-portal-tabs-smoke.mjs` - FAIL только на Executive + freeze-layer checks; базовые tabs, role gates, security/forensics/admin OK; +- live `scripts/awatch-production-hardening-smoke.mjs` - FAIL: + `/healthz` на live portal base не возвращает `200`; +- `git diff --check` - OK; +- sensitive scan по добавленным/измененным файлам - OK после исключения + терминологических false positives. + +Итог: + +- live-контур пригоден для controlled internal review; +- расширять пилот нельзя, пока не закрыт deployment/version drift и не повторен + live smoke после controlled deploy; +- новых claims, API, UI, collectors, ML/LLM и SIEM/DLP/EDR заявлений не + добавлялось. diff --git a/docs/roadmap/TASK_014_DEPLOYMENT_DRIFT_REMEDIATION.md b/docs/roadmap/TASK_014_DEPLOYMENT_DRIFT_REMEDIATION.md new file mode 100644 index 0000000..79e14a4 --- /dev/null +++ b/docs/roadmap/TASK_014_DEPLOYMENT_DRIFT_REMEDIATION.md @@ -0,0 +1,164 @@ +# docs/roadmap/TASK_014_DEPLOYMENT_DRIFT_REMEDIATION.md + +Цель: + +Устранить расхождение между: + +* Demo Freeze v1; +* live DetMir runtime. + +Проверить и внедрить в рабочий контур: + +* /healthz +* /readyz +* /version +* /metrics +* request/correlation id +* Explainable KPI +* Risk Narrative +* Executive Action Center + +Проверить: + +* почему endpoints дают 404; +* почему browser conformance падает; +* почему Executive layer отсутствует; +* соответствует ли развернутый runtime текущему main; +* не используется ли устаревший build. + +Результат: + +Не добавлять новые функции. + +Добиться того, чтобы: + +live runtime == documented runtime + +и + +live runtime == Demo Freeze v1 + +```` + +Критерий успеха очень простой: + +Сегодня: + +```text +Browser smoke +FAIL + +Production hardening smoke +FAIL +```` + +После задачи: + +```text +Browser smoke +PASS + +Production hardening smoke +PASS +``` + +на живом контуре. + +## Выполнение + +Дата выполнения: 2026-06-07. + +Статус: выполнено. + +### Причина drift + +Рабочий portal runtime на gateway host был запущен из устаревшего release +binary. Из-за этого live-контур не соответствовал Demo Freeze v1: + +* production-hardening endpoints `/healthz`, `/readyz`, `/version`, + `/metrics` возвращали `404`; +* отдельные API `/portal/api/workforce/kpi/explain`, + `/portal/api/risk/narrative`, `/portal/api/actions` возвращали `404`; +* request/correlation headers отсутствовали на live API; +* browser conformance smoke видел старый Executive/Workforce/Security слой. + +Кодовая база при этом уже содержала нужные контракты и UI-блоки. Проблема была +не в архитектуре и не в отсутствующем функционале, а в несовпадении deployed +binary с freeze-срезом. + +### Ремедиация + +Выполнен controlled deploy актуального release binary на gateway host: + +* старый бинарник сохранен в backup-каталог на gateway host; +* новый release binary собран из текущей freeze-ветки; +* бинарник установлен в штатный путь portal service; +* `detmir-portal.service` перезапущен; +* rollback path сохранен через backup старого бинарника. + +В репозиторий не добавлялись runtime payload, реальные logs, screenshots с +живыми данными, IP-адреса, hostname, логины, ФИО или подразделения. + +### Live endpoint matrix после ремедиации + +Проверено через gateway-local portal port: + +| Endpoint | Результат | +| --- | --- | +| `/healthz` | `200` | +| `/readyz` | `200` | +| `/version` | `200` | +| `/metrics` | `200` | +| `/portal/api/health` | `200` | +| `/portal/api/reports?role=executive` | `200` | +| `/portal/api/workforce/kpi/explain` | `200` | +| `/portal/api/risk/narrative` | `200` | +| `/portal/api/actions` | `200` | + +Также подтверждено: + +* `X-Request-Id` возвращается; +* `X-Correlation-Id` возвращается; +* внешний gateway `/healthz` отвечает `200`; +* внешний `/portal/` остается закрыт авторизацией; +* свежий scan journal не показал panic, HTTP 500 или явных timeout. + +### Smoke results + +```text +AWATCH_PORTAL_SMOKE_URL=http://127.0.0.1:18720 \ +node scripts/awatch-production-hardening-smoke.mjs +PASS +``` + +```text +AWATCH_BROWSER_SMOKE_URL=http://127.0.0.1:18720/portal/ \ +AWATCH_BROWSER_SMOKE_ARTIFACT_DIR=/tmp/awatch-live-remediation-browser-smoke \ +node scripts/browser-conformance-smoke.mjs +PASS +``` + +```text +DETMIR_PORTAL_SMOKE_URL=http://127.0.0.1:18720/portal/ \ +node scripts/detmir-portal-tabs-smoke.mjs +PASS +``` + +### Test harness hardening + +`scripts/browser-conformance-smoke.mjs` был усилен: после переключения роли он +ожидает фактические маркеры контента в `#content`, а не делает снимок через +фиксированную короткую задержку. Это устраняет ложный FAIL на холодной +асинхронной загрузке Executive/Security views. + +Продуктовая бизнес-логика, API contracts, роли, scoring, collectors и +архитектура не менялись. + +### Итог + +```text +live runtime == documented runtime +live runtime == Demo Freeze v1 +Browser smoke: PASS +Production hardening smoke: PASS +``` diff --git a/docs/roadmap/TASK_015_UEBA_CRITICAL_EVIDENCE_REVIEW b/docs/roadmap/TASK_015_UEBA_CRITICAL_EVIDENCE_REVIEW new file mode 100644 index 0000000..3ac89b3 --- /dev/null +++ b/docs/roadmap/TASK_015_UEBA_CRITICAL_EVIDENCE_REVIEW @@ -0,0 +1,305 @@ +Цель + +Проверить, почему UEBA на реальном DetMir-контуре показывает: + +severity = critical + +и определить: + +True Positive +или +False Positive +или +Insufficient Context + +Без изменения алгоритма. + +Без изменения весов. + +Без изменения правил. + +Контекст + +После выполнения: + +TASK_014_DEPLOYMENT_DRIFT_REMEDIATION + +все production validation проверки проходят. + +Остался единственный существенный риск: + +UEBA показывает critical + +До расширения пилота необходимо понять: + +это реальная аномалия; +шумное правило; +недостаток данных; +некорректная корреляция. +Важные ограничения + +Запрещено: + +менять UEBA score calculation; +менять severity thresholds; +менять weights; +менять Risk Narrative; +менять Action Center; +отключать правила; +подгонять результат под ожидаемый; +коммитить реальные данные пользователей; +коммитить реальные логины; +коммитить реальные hostname; +коммитить реальные IP; +коммитить реальные события. +Что проверить +1. UEBA Evidence Chain + +Для текущего critical определить: + +какие rule triggers сработали; +какие signals участвовали; +какие evidence использованы; +какие severity contributors внесли вклад; +какой итоговый score. + +Создать обезличенную сводку. + +Пример: + +Signal A → +20 + +Signal B → +15 + +Signal C → +30 + +Coverage penalty → +10 + +Final score → 75 + +Без реальных данных. + +2. Explainability Consistency + +Проверить согласованность: + +UEBA + +↓ + +Risk Narrative + +↓ + +Recommended Actions + +Ответить: + +совпадают ли причины; +нет ли противоречий; +нет ли отсутствующих evidence. +3. False Positive Review + +Проверить: + +есть ли признаки нормальной активности, интерпретированной как риск; +есть ли тестовые данные; +есть ли временные всплески; +есть ли проблемы с покрытием данных. + +Классифицировать: + +Likely True Positive + +Likely False Positive + +Needs Investigation +4. Coverage Review + +Проверить: + +agent coverage; +bucket freshness; +missing telemetry; +stale data; +delayed ingestion. + +Определить: + +могли ли проблемы покрытия искусственно поднять severity. + +5. Executive Review Impact + +Ответить: + +можно ли сейчас безопасно показывать текущий critical руководителю. + +Статусы: + +Ready For Executive Demo + +Needs Security Review First + +Insufficient Confidence +6. Security Review Impact + +Ответить: + +можно ли считать critical: + +Operational Risk + +Security Risk + +Unknown + +с текущими данными. + +7. Performance / Stability Check + +Проверить: + +не является ли critical следствием ошибки runtime; +нет ли исключений; +нет ли поврежденных данных; +нет ли дублирования событий; +нет ли бесконечного накопления evidence. +Что создать + +Создать документ: + +docs/UEBA_CRITICAL_REVIEW_RU.md + +Структура: + +# UEBA Critical Evidence Review + +## Executive Summary + +## Scope + +## Current Severity + +## Evidence Summary + +## Signal Contributions + +## Coverage Assessment + +## Explainability Consistency + +## False Positive Analysis + +## Security Interpretation + +## Executive Interpretation + +## Classification + +True Positive +False Positive +Needs Investigation + +## Risks + +## Recommendations + +## Conclusion +Допустимые изменения + +Разрешено: + +документация; +audit notes; +anonymized summaries; +validation scripts; +дополнительные проверки. + +Нежелательно: + +любые изменения алгоритмов. + +Если найден дефект алгоритма: + +не исправлять. + +Создать recommendation. + +Проверки + +Выполнить: + +cargo fmt --all --check + +cargo clippy --all-targets --all-features -- -D warnings + +cargo test --all + +cargo build --release + +node scripts/deployment-readiness-smoke.mjs + +node scripts/pilot-validation-smoke.mjs + +AWATCH_PORTAL_SMOKE_URL=http://127.0.0.1:8720 node scripts/awatch-production-hardening-smoke.mjs + +AWATCH_BROWSER_SMOKE_URL=http://127.0.0.1:8720/portal/ node scripts/browser-conformance-smoke.mjs + +Выполнить: + +git diff --check + +и sensitive scan. + +Критерии приемки + +Задача выполнена если: + +создан docs/UEBA_CRITICAL_REVIEW_RU.md; +выполнен анализ evidence; +определены contributors; +выполнена false positive review; +выполнена coverage review; +определен статус executive readiness; +определен статус security readiness; +персональные данные не попали в git; +алгоритм UEBA не изменен; +все проверки проходят. + +## Выполнение + +Дата выполнения: 2026-06-07. + +Статус: выполнено. + +Создан документ: + +```text +docs/UEBA_CRITICAL_REVIEW_RU.md +``` + +Итоговая классификация: + +```text +Needs Investigation +``` + +Краткий вывод: + +* UEBA `critical` подтвержден как фактический rule-based результат текущих + входных сигналов. +* `critical` не подтвержден как доказанный инцидент ИБ. +* Главный вклад дают Workforce signals и baseline/history, а не DLP, network, + time или application anomaly. +* Нулевое agent coverage и low KPI confidence не позволяют безопасно трактовать + текущий score как true positive. +* Алгоритм, weights, thresholds, Risk Narrative и Action Center не менялись. +* Реальные пользователи, hostname, IP, логины, подразделения, события и + forensic payload в Git не добавлялись. + +Статусы: + +```text +Executive readiness: Insufficient Confidence +Security interpretation: Operational Risk confirmed; Security Risk unknown +``` diff --git a/docs/roadmap/TASK_016_UEBA_CONFIDENCE_GUARDRAILS.md b/docs/roadmap/TASK_016_UEBA_CONFIDENCE_GUARDRAILS.md new file mode 100644 index 0000000..124d826 --- /dev/null +++ b/docs/roadmap/TASK_016_UEBA_CONFIDENCE_GUARDRAILS.md @@ -0,0 +1,512 @@ +## Цель + +Устранить риск неправильной интерпретации UEBA Score. + +Не менять: + +* UEBA scoring; +* UEBA weights; +* UEBA thresholds; +* Risk Narrative scoring; +* Action Center scoring. + +Добавить слой уверенности (confidence layer) и защиту от ложной интерпретации severity. + +--- + +## Контекст + +По результатам: + +```text +TASK_015_UEBA_CRITICAL_EVIDENCE_REVIEW +``` + +установлено: + +```text +UEBA Score = 100 + +Classification = Needs Investigation + +Executive Readiness = Insufficient Confidence + +Security Interpretation = Operational Risk Confirmed + +Security Incident = Not Confirmed +``` + +Причина: + +```text +activity_anomaly = 81 +history_anomaly = 19 + +coverage = 0% +confidence = low +``` + +При этом текущий UI может визуально восприниматься как: + +```text +Critical = подтвержденный инцидент +``` + +что неверно. + +--- + +## Основная идея + +Разделить: + +```text +Severity +``` + +и + +```text +Confidence +``` + +Severity отвечает: + +```text +Насколько сильна аномалия +``` + +Confidence отвечает: + +```text +Насколько мы уверены в выводе +``` + +--- + +## Что реализовать + +### 1. UEBA Confidence Model + +Добавить модель: + +```json +{ + "severity": "critical", + "score": 100, + "confidence": "low", + "classification": "needs_investigation", + "reason": "coverage_below_threshold" +} +``` + +--- + +### 2. Confidence Levels + +Поддержать: + +```text +high +medium +low +unknown +``` + +--- + +### 3. Confidence Contributors + +Минимальные факторы: + +```text +agent_coverage + +data_freshness + +telemetry_completeness + +evidence_presence + +history_depth + +signal_consistency +``` + +--- + +### 4. Confidence Rules + +Пример логики: + +#### HIGH + +```text +coverage >= target + +fresh data + +multiple corroborating signals + +evidence exists +``` + +#### MEDIUM + +```text +partial coverage + +some missing telemetry + +limited evidence +``` + +#### LOW + +```text +coverage below threshold + +missing telemetry + +missing evidence + +conflicting signals +``` + +#### UNKNOWN + +```text +insufficient data +``` + +--- + +### 5. Classification Layer + +Добавить: + +```text +confirmed_risk + +likely_risk + +needs_investigation + +insufficient_data +``` + +Важно: + +classification не заменяет severity. + +--- + +### 6. Executive View + +В Executive Portal показать: + +Пример: + +```text +UEBA Score: 100 + +Severity: Critical + +Confidence: Low + +Classification: +Needs Investigation +``` + +Добавить пояснение: + +```text +Высокая аномалия обнаружена, +но данных недостаточно для подтверждения риска. +``` + +--- + +### 7. Security View + +В Security Portal показать: + +```text +Severity + +Confidence + +Classification + +Evidence Status +``` + +Пример: + +```text +Evidence: +Not Available +``` + +или + +```text +Evidence: +Available +``` + +--- + +### 8. Risk Narrative Integration + +Обновить Risk Narrative. + +Добавить: + +```json +{ + "confidence": "low", + "classification": "needs_investigation" +} +``` + +--- + +### 9. Executive Action Center Integration + +Если: + +```text +confidence = low +``` + +добавлять действие: + +```text +Проверить полноту данных +``` + +до формирования жестких выводов. + +--- + +### 10. API + +Расширить: + +```http +GET /api/ueba +``` + +если контракт позволяет. + +Добавить поля: + +```json +{ + "confidence": "...", + "classification": "...", + "confidence_reasons": [] +} +``` + +Также обновить: + +```http +GET /api/risk/narrative +``` + +при необходимости. + +--- + +### 11. Markdown Reports + +Добавить раздел: + +```text +UEBA Confidence +``` + +Показывать: + +* severity; +* confidence; +* classification; +* confidence reasons. + +--- + +### 12. OpenAPI / TypeScript + +Обновить контракты. + +Только если реально изменяются API ответы. + +--- + +### 13. Documentation + +Создать: + +```text +docs/UEBA_CONFIDENCE_MODEL_RU.md +``` + +Описать: + +* что такое severity; +* что такое confidence; +* что такое classification; +* почему они отличаются; +* примеры интерпретации. + +--- + +## Что запрещено + +Запрещено: + +* менять UEBA score; +* менять weights; +* менять thresholds; +* менять severity rules; +* скрывать высокий score; +* искусственно занижать риск; +* автоматически подтверждать инцидент; +* добавлять ML; +* добавлять LLM; +* добавлять DLP claims; +* добавлять SIEM claims. + +--- + +## Проверки + +Выполнить: + +```bash +cargo fmt --all --check + +cargo clippy --all-targets --all-features -- -D warnings + +cargo test --all + +cargo build --release + +node scripts/deployment-readiness-smoke.mjs + +node scripts/pilot-validation-smoke.mjs + +AWATCH_PORTAL_SMOKE_URL=http://127.0.0.1:8720 node scripts/awatch-production-hardening-smoke.mjs + +AWATCH_BROWSER_SMOKE_URL=http://127.0.0.1:8720/portal/ node scripts/browser-conformance-smoke.mjs +``` + +Также: + +```bash +git diff --check +``` + +и sensitive scan. + +--- + +## Критерии приемки + +Задача выполнена если: + +* severity и confidence разделены; +* classification добавлен; +* Executive UI показывает confidence; +* Security UI показывает confidence; +* Risk Narrative учитывает confidence; +* Action Center учитывает confidence; +* документация создана; +* OpenAPI/TypeScript обновлены при необходимости; +* все проверки проходят; +* UEBA scoring не изменен. + +--- + +## Финальный отчет Codex должен содержать + +1. Какие модели добавлены. +2. Какие поля API изменены. +3. Как рассчитывается confidence. +4. Как рассчитывается classification. +5. Изменения UI. +6. Изменения reports. +7. Документация. +8. Результаты проверок. +9. Подтверждение, что scoring/weights/thresholds не менялись. + +--- + +## Выполнение + +Дата выполнения: 2026-06-07. + +Статус: выполнено. + +Добавлено: + +* UEBA confidence layer; +* confidence contributors: + * `agent_coverage`; + * `data_freshness`; + * `telemetry_completeness`; + * `evidence_presence`; + * `history_depth`; + * `signal_consistency`; +* classification layer: + * `confirmed_risk`; + * `likely_risk`; + * `needs_investigation`; + * `insufficient_data`; +* поля `/api/ueba`: + * `confidence`; + * `confidence_score`; + * `classification`; + * `classification_reason`; + * `confidence_reasons`; + * `confidence_contributors`; + * `evidence_status`; +* поля Risk Narrative: + * `confidence`; + * `classification`; +* Action Center guardrail: + * `Проверить полноту данных` при low/unknown UEBA confidence или + `needs_investigation`; +* Markdown section: + * `UEBA Confidence`; +* документация: + * `docs/UEBA_CONFIDENCE_MODEL_RU.md`. + +Не менялось: + +* UEBA score calculation; +* UEBA weights; +* UEBA thresholds; +* severity rules; +* Risk Narrative scoring; +* Action Center scoring; +* ML/LLM/DLP/SIEM claims не добавлялись. + +Ключевая интерпретация: + +```text +Severity = сила аномалии +Confidence = качество данных для вывода +Classification = как трактовать severity с учетом confidence +``` + +Для случая `critical + low confidence` результат: + +```text +classification = needs_investigation +``` + +Это защищает от неверной трактовки `critical` как автоматически подтвержденного +ИБ-инцидента. diff --git a/docs/runbook.md b/docs/runbook.md index e6a68ba..2041a31 100755 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -276,6 +276,10 @@ powershell.exe -ExecutionPolicy Bypass -File C:\ProgramData\AWatch-rus\export-up 3. Проверить результат: ```bash +systemctl is-active aw-hayabusa-drop.path +systemctl is-failed aw-hayabusa-drop.service || true +aw-hayabusa doctor +aw-hayabusa inventory cat /opt/hayabusa/state/latest-intake.json journalctl -u aw-hayabusa-drop.service -n 80 --no-pager curl -fsS http://127.0.0.1:5602/api/0/dlp/cases/30 @@ -288,6 +292,27 @@ curl -fsS http://127.0.0.1:5602/api/0/dlp/cases/30 - в case есть `forensics.hayabusa`; - Telegram alert уже уходит в операторский чат. +Production scheduled task на `SHARKON2025`: + +- `ActivityWatch Hayabusa Upload` +- principal `Администратор`, `LogonType=Interactive`, `RunLevel=Highest` +- период `6` часов, lookback `6` часов +- нормальный `LastTaskResult=0` + +Если `LastTaskResult=3221225794` (`0xC0000142`) и в `C:\ProgramData\AWatch-rus\logs\hayabusa-upload.log` нет новой строки, скрипт не стартовал. На текущем RDP-хосте это воспроизводится даже минимальной SYSTEM-задачей с `powershell.exe`; пересоздать Hayabusa task как interactive/highest от `Администратор`, не от `SYSTEM`. + +Если upload прошёл, но сервер не обработал пакет: + +```bash +sudo systemctl reset-failed aw-hayabusa-drop.path aw-hayabusa-drop.service +sudo systemctl start aw-hayabusa-drop.path +sudo systemctl start aw-hayabusa-drop.service +find /opt/activitywatch/aw-rus-ops/drop -maxdepth 1 -type f -ls +find /opt/hayabusa/inbox/incoming -maxdepth 1 -type f -ls +``` + +`drop` и `incoming` после успешной обработки должны быть пустыми; latest intake должен указывать на последний пакет `SHARKON2025`. + ### Hayabusa: manual fallback / production validation end-to-end Цель: подтвердить один реальный путь @@ -539,7 +564,7 @@ detmir-win-shell Канонический документ: -- `docs/DETMIR_POWERSHELL_MCP_REMOTE_RU.md` +- `docs/POWERSHELL_MCP_REMOTE_RU.md` Правило: diff --git a/docs/security-analytics-stack-v1.md b/docs/security-analytics-stack-v1.md index a8d958a..0f88796 100644 --- a/docs/security-analytics-stack-v1.md +++ b/docs/security-analytics-stack-v1.md @@ -34,15 +34,20 @@ Core outcomes: - `.meta.json` - optional `.caseid` - `ActivityWatch Hayabusa Upload` scheduled task runs every 6 hours +- on `SHARKON2025` the task runs as interactive/highest `Администратор`; `SYSTEM` PowerShell tasks fail with `0xC0000142` before the script starts +- sidecar JSON is written as UTF-8 without BOM; server-side readers also tolerate BOM for older files ### Server side - `aw-hayabusa-drop.path` watches `/opt/activitywatch/aw-rus-ops/drop` - `aw-hayabusa-drop.service` runs `aw-hayabusa-autoprocess` +- `/opt/activitywatch/aw-rus-ops/drop` is writable by `awops` and processed by root-owned systemd units - `aw-hayabusa` performs: - accept - process-inbox - report generation +- Windows zip entries with backslash separators are normalized during extraction +- autoprocess drains the incoming queue after accepting a drop package, preventing stale failed-run packages from being linked to a newer drop upload - `aw-hayabusa-case-alert` performs: - severity scoring from `timeline.jsonl` - optional auto-case creation diff --git a/docs/telegram_support_system_prompt_ru.txt b/docs/telegram_support_system_prompt_ru.txt index ce47649..ba5e643 100644 --- a/docs/telegram_support_system_prompt_ru.txt +++ b/docs/telegram_support_system_prompt_ru.txt @@ -1,4 +1,4 @@ -Ты — виртуальный помощник технической поддержки DetMir. +Ты — виртуальный помощник технической поддержки AWatch-rus. Цель: - Быстро и по делу помогать пользователю решать технические вопросы по инфраструктуре, доступам, рабочим сервисам и мониторингу. diff --git a/docs/wiki/DLP-Rules.md b/docs/wiki/DLP-Rules.md index a3d43db..68c46f5 100644 --- a/docs/wiki/DLP-Rules.md +++ b/docs/wiki/DLP-Rules.md @@ -84,6 +84,21 @@ IOC-слой позволяет подтягивать внешние индик - `format` - `refreshMinutes` +В production-контуре DetMir этот слой заполняется автоматически через +`DLP IOC Enrichment from Hayabusa/Sigma`: + +- upstream ruleset: `Yamato-Security/hayabusa-rules`; +- Ansible URL: `aw_dlp_ioc_rules_zip_url`; +- refresh: `aw-dlp-ioc-refresh.service` / `aw-dlp-ioc-refresh.timer`; +- extractor: Rust binary `/usr/local/bin/aw-extract-ioc-from-sigma`; +- published feed: `/dlp-ioc/ioc_blacklist.json`; +- policy format: `hayabusa_sigma_v1`; +- endpoint health field: `iocRulesLoaded`. + +Это не ручной ввод сигнатур в endpoint JSON. Endpoint policy только указывает +`ioc.source`, а сами IOC blacklist artifacts генерируются на сервере из +Hayabusa/Sigma rules. + ## Действия На практике используются: @@ -102,6 +117,7 @@ IOC-слой позволяет подтягивать внешние индик ## Канонические документы - [Пример policy](../../windows/dlp-policy.example.json) +- [DLP IOC Enrichment from Hayabusa/Sigma](../dlp-ioc-enrichment.md) - [DLP Endpoint Monitoring](DLP-Endpoint-Monitoring) - [Email Outbound Monitoring](Email-Outbound-Monitoring) - [Категоризация сайтов](Web-Categorization) diff --git a/docs/wiki/Hayabusa-Security-Analytics.md b/docs/wiki/Hayabusa-Security-Analytics.md index 592980a..b8d77d7 100644 --- a/docs/wiki/Hayabusa-Security-Analytics.md +++ b/docs/wiki/Hayabusa-Security-Analytics.md @@ -5,6 +5,7 @@ ## Что уже работает - Windows-хост раз в `6` часов делает `EVTX export + upload` +- production scheduled task: `ActivityWatch Hayabusa Upload`, principal `Администратор`, `LogonType=Interactive`, `RunLevel=Highest` - `AW-server` автоматически подхватывает пакет из `drop` - `aw-hayabusa` строит forensic-отчёт - `aw-hayabusa-case-alert` считает severity и score @@ -22,11 +23,16 @@ powershell.exe -ExecutionPolicy Bypass -File C:\ProgramData\AWatch-rus\export-up На сервере для проверки: ```bash +systemctl is-active aw-hayabusa-drop.path +systemctl is-failed aw-hayabusa-drop.service || true +aw-hayabusa inventory cat /opt/hayabusa/state/latest-intake.json journalctl -u aw-hayabusa-drop.service -n 80 --no-pager curl -fsS http://127.0.0.1:5602/api/0/dlp/cases/30 ``` +Ожидаемо: `drop` и `incoming` пустые, `latest-intake.json` имеет `status=ok`, `host=SHARKON2025`, а `LastTaskResult` Windows-задачи равен `0`. + ## Что получает оператор - `summary.html` diff --git a/docs/wiki/Windows-Collector-Suite.md b/docs/wiki/Windows-Collector-Suite.md index 53d3ee3..37d3254 100644 --- a/docs/wiki/Windows-Collector-Suite.md +++ b/docs/wiki/Windows-Collector-Suite.md @@ -42,13 +42,13 @@ aw_windows_builtin_administrator_name: "Администратор" Назначение: явно фиксировать локализованное имя встроенной учетной записи Administrator с SID `*-500`. -Для текущего Windows host `HOST-EXAMPLE` task name должен строиться как: +Для текущего Windows host `SHARKON2025` task name должен строиться как: ```text -ActivityWatch Launch [HOST-EXAMPLE_Администратор] +ActivityWatch Launch [SHARKON2025_Администратор] ``` -Если task по `HOST-EXAMPLE_Administrator` не найден, recovery/deploy path обязан пробовать кириллическое имя `Администратор`. Это зафиксировано через: +Если task по `SHARKON2025_Administrator` не найден, recovery/deploy path обязан пробовать кириллическое имя `Администратор`. Это зафиксировано через: - default vars в `ansible/deploy_aw_windows.yml`; - `ansible/group_vars/aw_windows.yml`; @@ -60,7 +60,7 @@ ActivityWatch Launch [HOST-EXAMPLE_Администратор] `ActivityWatch.Windows.Common.psm1` усилил recovery path: -- `Get-ActivityWatchBuiltInAdministratorName` сначала смотрит env override, затем SID-500 lookup, затем host-specific fallback `HOST-EXAMPLE -> Администратор`; +- `Get-ActivityWatchBuiltInAdministratorName` сначала смотрит env override, затем SID-500 lookup, затем host-specific fallback `SHARKON2025 -> Администратор`; - `Normalize-ActivityWatchUsers` стабилизирован для pipeline/list cases; - удаление scheduled tasks стало устойчивее к частично удаленным task definitions; - recovery task может ориентироваться на live interactive session и запускаться в interactive logon context, когда это безопаснее для watcher'ов. diff --git a/docs/windows-hayabusa-evtx-export.md b/docs/windows-hayabusa-evtx-export.md index 68a9d11..f5002e1 100644 --- a/docs/windows-hayabusa-evtx-export.md +++ b/docs/windows-hayabusa-evtx-export.md @@ -83,6 +83,16 @@ This wrapper: - uploads `.meta.json`; - uploads the `zip` to the AW-server drop directory. +Scheduled production upload on `SHARKON2025`: + +- task: `ActivityWatch Hayabusa Upload` +- principal: `Администратор`, interactive, highest privileges +- interval: `6` hours +- lookback: `6` hours +- success: `LastTaskResult=0` and a new line in `C:\ProgramData\AWatch-rus\logs\hayabusa-upload.log` + +`LastTaskResult=3221225794` (`0xC0000142`) with no new upload log means Task Scheduler failed to start `powershell.exe`; keep this task on the interactive administrator principal for this host. + ## Boundaries - output stays outside standard AW buckets diff --git a/docs/windows/deployment.md b/docs/windows/deployment.md index c9be17c..7a1f64b 100755 --- a/docs/windows/deployment.md +++ b/docs/windows/deployment.md @@ -191,7 +191,7 @@ Ansible playbook `ansible/deploy_aw_windows.yml` выполняет этот mig .\windows\deploy-domain-users.ps1 ` -ServerHost ` -ServerPort 5600 ` - -Domain HOST-EXAMPLE ` + -Domain SHARKON2025 ` -Users user2,user3,user4,user5 ` -InstallRoot 'C:\Program Files\AWatch-rus\bin' ` -StateRoot 'C:\ProgramData\AWatch-rus' ` @@ -205,7 +205,7 @@ Single-user pilot в таком же стиле: .\windows\deploy-single-user.ps1 ` -ServerHost ` -ServerPort 5600 ` - -TargetUser 'HOST-EXAMPLE\user1' ` + -TargetUser 'SHARKON2025\user1' ` -InstallRoot 'C:\Program Files\AWatch-rus\bin' ` -StateRoot 'C:\ProgramData\AWatch-rus' ` -CustomRulesPath C:\Program Files\AWatch-rus\windows\web-category-rules.example.json ` diff --git a/docs/windows/validation.md b/docs/windows/validation.md index 30a17c5..72ae9e5 100755 --- a/docs/windows/validation.md +++ b/docs/windows/validation.md @@ -43,7 +43,7 @@ Get-ScheduledTask -TaskName 'ActivityWatch*' | Точечная проверка: ```powershell -Get-ScheduledTask | Where-Object TaskName -eq 'ActivityWatch Launch [HOST-EXAMPLE_user1]' +Get-ScheduledTask | Where-Object TaskName -eq 'ActivityWatch Launch [SHARKON2025_user1]' Get-ScheduledTask | Where-Object TaskName -eq 'ActivityWatch Recovery' ``` diff --git a/grafana/detmir-aw-main-dashboard.json b/grafana/detmir-aw-main-dashboard.json index d660015..7b281b6 100644 --- a/grafana/detmir-aw-main-dashboard.json +++ b/grafana/detmir-aw-main-dashboard.json @@ -139,7 +139,7 @@ "id": 1, "targets": [ { - "query": "from(bucket: \"aw_metrics\")\n |> range(start: -48h)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_hourly\" and r._field == \"active_seconds\" and r.host == \"${host}\")\n |> map(fn: (r) => ({ r with _value: float(v: r._value) / 3600.0 }))\n |> set(key: \"_field\", value: \"Активность, ч\")\n |> group(columns: [\"user\"])\n |> yield(name: \"hourly_active_hours\")\n", + "query": "from(bucket: \"aw_metrics\")\n |> range(start: -48h)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_hourly\" and r._field == \"active_seconds\" and r.host == \"${host}\")\n |> filter(fn: (r) => r.user_id !~ /\\$$/ and r.user_id !~ /�/)\n |> map(fn: (r) => ({ r with user: if r.user_id =~ /(?i)\\\\user1$/ then \"user1\" else if r.user_id =~ /(?i)\\\\user4$/ then \"user4\" else if r.user_id =~ /(?i)\\\\user5$/ then \"user5\" else if r.user_id =~ /(?i)\\\\администратор$/ then \"Администратор\" else r.user }))\n |> group(columns: [\"_time\", \"user\"])\n |> max(column: \"_value\")\n |> group(columns: [\"user\"])\n |> map(fn: (r) => ({ r with user_id: \"${host}\\\\\" + r.user, _value: float(v: r._value) / 3600.0 }))\n |> set(key: \"_field\", value: \"Активность, ч\")\n |> yield(name: \"hourly_active_hours\")\n", "refId": "A" } ], @@ -179,7 +179,7 @@ "id": 2, "targets": [ { - "query": "from(bucket: \"aw_metrics\")\n |> range(start: -3d)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_daily\" and r._field == \"active_seconds\" and r.host == \"${host}\")\n |> group(columns: [\"user\"])\n |> last()\n |> filter(fn: (r) => r._value > 0)\n |> map(fn: (r) => ({ r with _value: float(v: r._value) / 3600.0 }))\n |> set(key: \"_field\", value: \"Часы\")\n |> yield(name: \"today_user_hours\")\n", + "query": "from(bucket: \"aw_metrics\")\n |> range(start: -3d)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_daily\" and r._field == \"active_seconds\" and r.host == \"${host}\")\n |> filter(fn: (r) => r.user_id !~ /\\$$/ and r.user_id !~ /�/)\n |> map(fn: (r) => ({ r with user: if r.user_id =~ /(?i)\\\\user1$/ then \"user1\" else if r.user_id =~ /(?i)\\\\user4$/ then \"user4\" else if r.user_id =~ /(?i)\\\\user5$/ then \"user5\" else if r.user_id =~ /(?i)\\\\администратор$/ then \"Администратор\" else r.user }))\n |> group(columns: [\"report_date\", \"user\"])\n |> max(column: \"_value\")\n |> group(columns: [\"user\"])\n |> last()\n |> filter(fn: (r) => r._value > 0)\n |> map(fn: (r) => ({ r with user_id: \"${host}\\\\\" + r.user, _value: float(v: r._value) / 3600.0 }))\n |> set(key: \"_field\", value: \"Часы\")\n |> yield(name: \"today_user_hours\")\n", "refId": "A" } ], @@ -255,7 +255,7 @@ "showPoints": "never" }, "decimals": 1, - "displayName": "Команда", + "displayName": "Все сотрудники", "unit": "suffix: ч" }, "overrides": [] @@ -269,11 +269,11 @@ "id": 6, "targets": [ { - "query": "from(bucket: \"aw_metrics\")\n |> range(start: -14d)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_summary_daily\" and r._field == \"total_active_seconds\" and r.host == \"${host}\")\n |> group(columns:[\"report_date\"])\n |> last()\n |> sort(columns:[\"report_date\"])\n |> map(fn:(r)=>({ r with _value: float(v:r._value) / 3600.0 }))\n |> set(key: \"_field\", value: \"Команда, ч\")\n |> yield(name: \"team_daily_hours\")\n", + "query": "from(bucket: \"aw_metrics\")\n |> range(start: -14d)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_summary_daily\" and r._field == \"total_active_seconds\" and r.host == \"${host}\")\n |> group(columns:[\"report_date\"])\n |> last()\n |> sort(columns:[\"report_date\"])\n |> map(fn:(r)=>({ r with _value: float(v:r._value) / 3600.0 }))\n |> set(key: \"_field\", value: \"Все сотрудники, ч\")\n |> yield(name: \"team_daily_hours\")\n", "refId": "A" } ], - "title": "Команда: активное время по дням", + "title": "Все сотрудники: активное время по дням", "type": "timeseries", "options": { "legend": { @@ -362,8 +362,8 @@ { "current": { "selected": false, - "text": "HOST-EXAMPLE", - "value": "HOST-EXAMPLE" + "text": "SHARKON2025", + "value": "SHARKON2025" }, "datasource": { "type": "influxdb", diff --git a/grafana/detmir-rdp-user-activity-dashboard.json b/grafana/detmir-rdp-user-activity-dashboard.json index c290aee..f2037fb 100644 --- a/grafana/detmir-rdp-user-activity-dashboard.json +++ b/grafana/detmir-rdp-user-activity-dashboard.json @@ -303,7 +303,7 @@ }, "targets": [ { - "query": "from(bucket: \"aw_metrics\")\n |> range(start: -14d)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_daily\" and r._field == \"active_seconds\" and r.host == \"${host}\")\n |> group(columns: [\"user\"])\n |> sort(columns: [\"_time\"])\n |> tail(n: 2)\n |> first()\n |> map(fn: (r) => ({ r with _value: float(v: r._value) / 3600.0 }))", + "query": "from(bucket: \"aw_metrics\")\n |> range(start: -14d)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_daily\" and r._field == \"active_seconds\" and r.host == \"${host}\")\n |> filter(fn: (r) => r.user_id !~ /\\$$/ and r.user_id !~ /�/)\n |> map(fn: (r) => ({ r with user: if r.user_id =~ /(?i)\\\\user1$/ then \"user1\" else if r.user_id =~ /(?i)\\\\user4$/ then \"user4\" else if r.user_id =~ /(?i)\\\\user5$/ then \"user5\" else if r.user_id =~ /(?i)\\\\администратор$/ then \"Администратор\" else r.user }))\n |> group(columns: [\"report_date\", \"user\"])\n |> max(column: \"_value\")\n |> group(columns: [\"user\"])\n |> sort(columns: [\"_time\"])\n |> tail(n: 2)\n |> first()\n |> map(fn: (r) => ({ r with user_id: \"${host}\\\\\" + r.user, _value: float(v: r._value) / 3600.0 }))", "refId": "A" } ], @@ -349,7 +349,7 @@ }, "targets": [ { - "query": "from(bucket: \"aw_metrics\")\n |> range(start: -3d)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_daily\" and r._field == \"active_seconds\" and r.host == \"${host}\")\n |> group(columns: [\"user\"])\n |> last()\n |> filter(fn:(r)=>r._value > 0)\n |> map(fn: (r) => ({ r with _value: float(v: r._value) / 3600.0 }))", + "query": "from(bucket: \"aw_metrics\")\n |> range(start: -3d)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_daily\" and r._field == \"active_seconds\" and r.host == \"${host}\")\n |> filter(fn: (r) => r.user_id !~ /\\$$/ and r.user_id !~ /�/)\n |> map(fn: (r) => ({ r with user: if r.user_id =~ /(?i)\\\\user1$/ then \"user1\" else if r.user_id =~ /(?i)\\\\user4$/ then \"user4\" else if r.user_id =~ /(?i)\\\\user5$/ then \"user5\" else if r.user_id =~ /(?i)\\\\администратор$/ then \"Администратор\" else r.user }))\n |> group(columns: [\"report_date\", \"user\"])\n |> max(column: \"_value\")\n |> group(columns: [\"user\"])\n |> last()\n |> filter(fn:(r)=>r._value > 0)\n |> map(fn: (r) => ({ r with user_id: \"${host}\\\\\" + r.user, _value: float(v: r._value) / 3600.0 }))", "refId": "A" } ], @@ -371,7 +371,7 @@ "showPoints": "never" }, "decimals": 1, - "displayName": "Команда", + "displayName": "Все сотрудники", "unit": "suffix: ч" }, "overrides": [] @@ -400,7 +400,7 @@ "refId": "A" } ], - "title": "Команда: активное время по дням", + "title": "Все сотрудники: активное время по дням", "type": "timeseries" }, { @@ -442,7 +442,7 @@ }, "targets": [ { - "query": "from(bucket: \"aw_metrics\")\n |> range(start: -48h)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_hourly\" and r._field == \"active_seconds\" and r.host == \"${host}\")\n |> map(fn: (r) => ({ r with _value: float(v: r._value) / 3600.0 }))\n |> group(columns: [\"user\"])", + "query": "from(bucket: \"aw_metrics\")\n |> range(start: -48h)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_hourly\" and r._field == \"active_seconds\" and r.host == \"${host}\")\n |> filter(fn: (r) => r.user_id !~ /\\$$/ and r.user_id !~ /�/)\n |> map(fn: (r) => ({ r with user: if r.user_id =~ /(?i)\\\\user1$/ then \"user1\" else if r.user_id =~ /(?i)\\\\user4$/ then \"user4\" else if r.user_id =~ /(?i)\\\\user5$/ then \"user5\" else if r.user_id =~ /(?i)\\\\администратор$/ then \"Администратор\" else r.user }))\n |> group(columns: [\"_time\", \"user\"])\n |> max(column: \"_value\")\n |> group(columns: [\"user\"])\n |> map(fn: (r) => ({ r with user_id: \"${host}\\\\\" + r.user, _value: float(v: r._value) / 3600.0 }))", "refId": "A" } ], @@ -500,7 +500,7 @@ ], "targets": [ { - "query": "from(bucket: \"aw_metrics\")\n |> range(start: -14d)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_daily\" and r._field == \"active_seconds\" and r.host == \"${host}\")\n |> group(columns:[\"report_date\",\"user\"])\n |> last()\n |> sort(columns:[\"report_date\",\"user\"], desc:true)\n |> map(fn:(r)=>({ r with _value: float(v:r._value) / 3600.0 }))\n |> group()\n |> keep(columns:[\"report_date\",\"user\",\"user_id\",\"_value\"])\n |> rename(columns:{report_date:\"Дата\", user:\"Пользователь\", user_id:\"Учётная запись\", _value:\"Часы\"})", + "query": "from(bucket: \"aw_metrics\")\n |> range(start: -14d)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_daily\" and r._field == \"active_seconds\" and r.host == \"${host}\")\n |> filter(fn: (r) => r.user_id !~ /\\$$/ and r.user_id !~ /�/)\n |> map(fn: (r) => ({ r with user: if r.user_id =~ /(?i)\\\\user1$/ then \"user1\" else if r.user_id =~ /(?i)\\\\user4$/ then \"user4\" else if r.user_id =~ /(?i)\\\\user5$/ then \"user5\" else if r.user_id =~ /(?i)\\\\администратор$/ then \"Администратор\" else r.user }))\n |> group(columns:[\"report_date\",\"user\"])\n |> max(column: \"_value\")\n |> sort(columns:[\"report_date\",\"user\"], desc:true)\n |> map(fn:(r)=>({ r with user_id: \"${host}\\\\\" + r.user, _value: float(v:r._value) / 3600.0 }))\n |> group()\n |> keep(columns:[\"report_date\",\"user\",\"user_id\",\"_value\"])\n |> rename(columns:{report_date:\"Дата\", user:\"Пользователь\", user_id:\"Учётная запись\", _value:\"Часы\"})", "refId": "A" } ], @@ -724,8 +724,8 @@ { "current": { "selected": false, - "text": "HOST-EXAMPLE", - "value": "HOST-EXAMPLE" + "text": "SHARKON2025", + "value": "SHARKON2025" }, "datasource": { "type": "influxdb", diff --git a/private-config/.gitignore b/private-config/.gitignore deleted file mode 100755 index fac6072..0000000 --- a/private-config/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -*.env -*.local -!.gitignore -!*.example diff --git a/private-config/.gitkeep b/private-config/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/private-config/.gitkeep @@ -0,0 +1 @@ + diff --git a/private-config/README.md b/private-config/README.md new file mode 100644 index 0000000..d6bd0d5 --- /dev/null +++ b/private-config/README.md @@ -0,0 +1,13 @@ +# private-config + +This directory is reserved for local, host-specific, or secret configuration. + +Do not commit real runtime values here. Git only allows: + +- `private-config/README.md` +- `private-config/.gitkeep` +- `private-config/*.example` +- `private-config/*.template` + +Use `scripts/check_private_config_guard.sh` before commits and in CI to verify +that no private config file has entered the git index. diff --git a/scripts/aw-contour-diag.sh b/scripts/aw-contour-diag.sh new file mode 100644 index 0000000..0373e6d --- /dev/null +++ b/scripts/aw-contour-diag.sh @@ -0,0 +1,507 @@ +#!/usr/bin/env bash +# aw-contour-diag.sh - Диагностика всего контура ActivityWatch-Russian +# Запускать с машины администратора (где есть доступ по SSH/curl ко всем узлам). +# +# Использование: +# ./scripts/aw-contour-diag.sh # полная диагностика +# ./scripts/aw-contour-diag.sh --quick # быстрая (только AW server + buckets) +# ./scripts/aw-contour-diag.sh --skip-windows # без RDP/WinRM проверок +# +# При красных проверках в скрипте указаны разделы 'REMEDIATION: ...' +# с конкретными командами для восстановления. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ANSIBLE_DIR="$REPO_ROOT/ansible" +INVENTORY="$ANSIBLE_DIR/inventory.ini" +AW_SERVER="http://10.10.10.13:5600" +AW_WORKTIME_API="http://10.10.10.13:5610" +INFLUXDB_URL="http://10.10.10.10:8086" +GRAFANA_URL="http://10.10.10.11:3000" +PROXMOX_HOST="10.10.10.2" +AW_HOST="10.10.10.13" +GRAFANA_HOST="10.10.10.11" +INFLUXDB_HOST="10.10.10.10" +WINDOWS_HOST="192.168.100.18" +CLICKHOUSE_HOST="10.10.10.2" +SOURCE_HOSTNAME="SHARKON2025" + +QUICK_MODE=0 +SKIP_WINDOWS=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --quick) QUICK_MODE=1; shift ;; + --skip-windows) SKIP_WINDOWS=1; shift ;; + -h|--help) + echo "Usage: $(basename "$0") [--quick] [--skip-windows]" + exit 0 ;; + *) echo "Unknown: $1"; exit 2 ;; + esac +done + +export no_proxy="localhost,127.0.0.1,$PROXMOX_HOST,$AW_HOST,$GRAFANA_HOST,$INFLUXDB_HOST,$WINDOWS_HOST,$CLICKHOUSE_HOST,10.10.10.0/24,192.168.100.0/24" +export NO_PROXY="$no_proxy" + +OK_COUNT=0; WARN_COUNT=0; FAIL_COUNT=0; SKIP_COUNT=0 + +if [ -t 1 ]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; CYAN=''; NC='' +fi + +pass() { OK_COUNT=$((OK_COUNT+1)); printf "%b[OK]%b %s\n" "$GREEN" "$NC" "$*"; } +warn() { WARN_COUNT=$((WARN_COUNT+1)); printf "%b[WARN]%b %s\n" "$YELLOW" "$NC" "$*"; } +fail() { FAIL_COUNT=$((FAIL_COUNT+1)); printf "%b[FAIL]%b %s\n" "$RED" "$NC" "$*"; } +skip() { SKIP_COUNT=$((SKIP_COUNT+1)); printf "%b[SKIP]%b %s\n" "$YELLOW" "$NC" "$*"; } +section() { printf "\n%b=== %s ===%b\n" "$CYAN" "$*" "$NC"; } +have() { command -v "$1" >/dev/null 2>&1; } + +check_tcp() { + local name="$1" host="$2" port="$3" + if timeout 4 bash -c ":/dev/null 2>&1; then + pass "TCP $host:$port ($name)" + else + fail "TCP $host:$port ($name)" + echo " REMEDIATION: Проверьте, запущен ли сервис на $host:$port." + echo " Для systemd: ssh igor@$host 'systemctl status '" + echo " Для Docker: ssh igor@$PROXMOX_HOST 'sudo docker ps | grep '" + fi +} + +check_http_code() { + local name="$1" url="$2" expected="${3:-^2[0-9][0-9]$}" + local tmp code + tmp="$(mktemp)" + code="$(curl -k -sS --connect-timeout 5 --max-time 15 -o "$tmp" -w '%{http_code}' "$url" 2>"$tmp.err")" + if printf "%s" "$code" | grep -Eq "$expected"; then + pass "HTTP $code $url ($name)" + else + fail "HTTP $code $url ($name)" + sed 's/^/ /' "$tmp.err" "$tmp" 2>/dev/null | head -20 + fi + rm -f "$tmp" "$tmp.err" +} + +check_http_json_key() { + local name="$1" url="$2" jq_filter="$3" remediation="$4" + local tmp + tmp="$(mktemp)" + if curl -k -fsS --connect-timeout 5 --max-time 20 "$url" -o "$tmp" 2>"$tmp.err" && jq -e "$jq_filter" "$tmp" >/dev/null 2>&1; then + pass "$name" + else + fail "$name" + sed 's/^/ /' "$tmp.err" "$tmp" 2>/dev/null | head -10 + echo " REMEDIATION: $remediation" + fi + rm -f "$tmp" "$tmp.err" +} + +check_bucket_freshness() { + local bucket="$1" label="$2" remediation="$3" + local bucket_id="${bucket}_${SOURCE_HOSTNAME}" + local tmp last_ts event_epoch now age_sec + tmp="$(mktemp)" + if ! curl -fsS --connect-timeout 5 --max-time 15 "$AW_SERVER/api/0/buckets/$bucket_id/events?limit=1" -o "$tmp" 2>"$tmp.err"; then + fail "bucket $label ($bucket_id) — запрос не удался" + sed 's/^/ /' "$tmp.err" | head -5 + echo " REMEDIATION: $remediation" + rm -f "$tmp" "$tmp.err" + return + fi + last_ts="$(jq -r '.[0].timestamp // empty' "$tmp" 2>/dev/null)" + rm -f "$tmp" + if [ -z "$last_ts" ]; then + warn "bucket $label ($bucket_id) — нет событий" + echo " REMEDIATION: $remediation" + return + fi + event_epoch="$(date -d "$last_ts" +%s 2>/dev/null || echo 0)" + now="$(date -u +%s)" + age_sec=$((now - event_epoch)) + + case "$bucket" in + aw-dlp-incidents|aw-dlp-review|aw-dlp-rules|aw-session-events) + if [ "$age_sec" -lt 86400 ]; then + pass "bucket $label — ${age_sec}s назад" + else + warn "bucket $label — ${age_sec}s назад (event-driven)" + fi ;; + aw-watcher-window|aw-dlp-endpoint-signals) + if [ "$age_sec" -lt 7200 ]; then + pass "bucket $label — ${age_sec}s назад" + else + warn "bucket $label — ${age_sec}s назад (INACTIVE)" + fi ;; + *) + if [ "$age_sec" -lt 3600 ]; then + pass "bucket $label — ${age_sec}s назад" + elif [ "$age_sec" -lt 86400 ]; then + warn "bucket $label — ${age_sec}s назад (STALE)" + echo " REMEDIATION: $remediation" + else + fail "bucket $label — ${age_sec}s назад (DEAD)" + echo " REMEDIATION: $remediation" + fi ;; + esac +} + +check_ansible_shell() { + local name="$1" group="$2" command="$3" + if ! have ansible; then + skip "$name (ansible not available)" + return + fi + if [ ! -f "$INVENTORY" ]; then + skip "$name (inventory not found: $INVENTORY)" + return + fi + local tmp + tmp="$(mktemp)" + if ANSIBLE_NOCOLOR=1 ansible "$group" -i "$INVENTORY" -m shell -a "$command" >"$tmp" 2>&1; then + pass "$name" + else + fail "$name" + sed 's/^/ /' "$tmp" | head -20 + fi + rm -f "$tmp" +} + +check_ansible_win_shell() { + local name="$1" command="$2" + if ! have ansible; then skip "$name (ansible not available)"; return; fi + if [ ! -f "$INVENTORY" ]; then skip "$name (inventory not found)"; return; fi + local tmp + tmp="$(mktemp)" + if ANSIBLE_NOCOLOR=1 ansible aw_windows -i "$INVENTORY" -m win_shell -a "$command" >"$tmp" 2>&1; then + pass "$name" + else + fail "$name" + sed 's/^/ /' "$tmp" | head -20 + fi + rm -f "$tmp" +} + +check_ansible_module() { + local name="$1" group="$2" module="$3" args="${4:-}" + if ! have ansible; then skip "$name (ansible not available)"; return; fi + if [ ! -f "$INVENTORY" ]; then skip "$name (inventory not found)"; return; fi + local tmp + tmp="$(mktemp)" + if ANSIBLE_NOCOLOR=1 ansible "$group" -i "$INVENTORY" -m "$module" ${args:+-a "$args"} >"$tmp" 2>&1; then + pass "$name" + else + fail "$name" + sed 's/^/ /' "$tmp" | head -20 + fi + rm -f "$tmp" +} + +ssh_with_diag_password() { + local host="$1" + shift + if [[ -z "${AW_DIAG_SSH_PASSWORD:-}" ]]; then + return 125 + fi + sshpass -p "$AW_DIAG_SSH_PASSWORD" ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no "igor@$host" "$@" +} + +ssh_aw() { ssh_with_diag_password 10.10.10.13 "$@"; } +ssh_pve() { ssh_with_diag_password 10.10.10.2 "$@"; } + +check_service_remote() { + local name="$1" host="$2" unit="$3" remediation="$4" + local result + result=$(ssh_with_diag_password "$host" "systemctl is-active $unit 2>/dev/null || echo not_found" 2>/dev/null) + local rc=$? + if [ "$rc" -eq 125 ]; then + skip "$name ($unit on $host — set AW_DIAG_SSH_PASSWORD for SSH checks)" + return + fi + if [ "$rc" -ne 0 ] || [ "$result" = "not_found" ]; then + skip "$name ($unit on $host — не удалось проверить)" + return + fi + if [ "$result" = "active" ]; then + pass "$name ($unit active on $host)" + else + fail "$name ($unit $result on $host)" + echo " REMEDIATION: $remediation" + fi +} + +printf "%b=== ActivityWatch-Russian: Диагностика контура ===%b\n" "$CYAN" "$NC" +echo " $(date -u '+%Y-%m-%d %H:%M:%S UTC')" +echo "" + +# ============================================================ +section "1. Локальные предусловия" +# ============================================================ +for cmd in bash curl jq timeout ssh sshpass; do + if have "$cmd"; then pass "утилита $cmd найдена"; else fail "утилита $cmd не найдена (установите: apt install $cmd)"; fi +done +echo "" + +# ============================================================ +section "2. TCP доступность узлов" +# ============================================================ +check_tcp "AW Server" "$AW_HOST" 5600 +check_tcp "Worktime API" "$AW_HOST" 5610 +check_tcp "RDP WinRM" "$WINDOWS_HOST" 5985 +check_tcp "Proxmox SSH" "$PROXMOX_HOST" 22 +check_tcp "Proxmox HTTPS" "$PROXMOX_HOST" 443 +check_tcp "1C Company API" "$PROXMOX_HOST" 8710 +check_tcp "ClickHouse HTTP" "$CLICKHOUSE_HOST" 8123 +check_tcp "ClickHouse Native" "$CLICKHOUSE_HOST" 9000 +check_tcp "InfluxDB" "$INFLUXDB_HOST" 8086 +check_tcp "Grafana" "$GRAFANA_HOST" 3000 + +if [ "$QUICK_MODE" = "1" ]; then + # В быстром режиме проверяем только AW Server и buckets + echo "" + section "3. AW Server (быстрый режим)" + check_http_code "AW Server info" "$AW_SERVER/api/0/info" '^200$' + check_http_code "AW Server CORS" "$AW_SERVER/api/0/settings/" '^200$' + check_http_code "Worktime API health" "$AW_WORKTIME_API/health" '^200$' + + section "4. Buckets (быстрый режим)" + for entry in \ + "aw-watcher-afk|AFK watcher|Запустите на RDP: schtasks /Run /TN \"ActivityWatch Recovery\" или schtasks /Run /TN \"ActivityWatch Launch [SHARKON2025_Администратор]\"" \ + "aw-watcher-window|Window watcher|Запустите через ansible: ansible aw_windows -i $INVENTORY -m win_shell -a 'Start-Process -FilePath \"C:\\Program Files\\AWatch-rus\\bin\\aw-watcher-window\\aw-watcher-window.exe\" -ArgumentList @(\"--host\", \"10.10.10.13\", \"--port\", \"5600\") -WindowStyle Hidden'" \ + "aw-worktime-sessions|Worktime sessions|Проверьте работу worktime-api: systemctl status aw-worktime-api на AW сервере" \ + "aw-session-events|Session events|Проверьте collector-guard и aw-session-events-collector на RDP" \ + "aw-dlp-endpoint-signals|DLP signals|Запустите: ansible aw_windows -i $INVENTORY -m win_shell -a 'schtasks /Run /TN \"ActivityWatch Launch [SHARKON2025_Администратор]\"; Start-Sleep 30'" \ + "aw-dlp-incidents|DLP incidents|Проверьте aw-detmir-dlp-collector.ps1 на RDP (логи: C:\\ProgramData\\AWatch-rus\\logs\\)" \ + ; do + bucket="${entry%%|*}"; rest="${entry#*|}" + label="${rest%%|*}"; remediation="${rest#*|}" + check_bucket_freshness "$bucket" "$label" "$remediation" + done + echo "" + echo "=== Быстрая диагностика завершена ===" + printf "OK=%s WARN=%s FAIL=%s SKIP=%s\n" "$OK_COUNT" "$WARN_COUNT" "$FAIL_COUNT" "$SKIP_COUNT" + [ "$FAIL_COUNT" -gt 0 ] && exit 2 || exit 0 +fi + +# ============================================================ +section "3. AW Server (10.10.10.13)" +# ============================================================ +check_http_json_key "AW Server info" "$AW_SERVER/api/0/info" \ + '.version' \ + "Проверьте: ssh igor@$AW_HOST 'systemctl status activitywatch-server'" +check_http_code "AW Server CORS" "$AW_SERVER/api/0/settings/" '^200$' +check_http_code "AW WebUI" "$AW_SERVER/" '^200$' +check_http_json_key "Worktime API health" "$AW_WORKTIME_API/health" \ + '.status // .ok' \ + "Проверьте: ssh igor@$AW_HOST 'systemctl status aw-worktime-api && journalctl -u aw-worktime-api -n 20'" + +# ============================================================ +section "4. Buckets (свежесть данных)" +# ============================================================ +for entry in \ + "aw-watcher-afk|AFK watcher|Запустите на RDP: ansible aw_windows -i $INVENTORY -m win_shell -a 'schtasks /Run /TN \"ActivityWatch Recovery\"'" \ + "aw-watcher-window|Window watcher|Запустите: ansible aw_windows -i $INVENTORY -m win_shell -a 'schtasks /Run /TN \"ActivityWatch Launch [SHARKON2025_Администратор]\"; Start-Process -FilePath \"C:\\Program Files\\AWatch-rus\\bin\\aw-watcher-window\\aw-watcher-window.exe\" -ArgumentList @(\"--host\", \"10.10.10.13\", \"--port\", \"5600\") -WindowStyle Hidden'" \ + "aw-worktime-sessions|Worktime sessions|Проверьте: ssh igor@$AW_HOST 'systemctl status aw-worktime-api && journalctl -u aw-worktime-api -n 20'" \ + "aw-session-events|Session events|Проверьте collector-guard на RDP: ansible aw_windows -i $INVENTORY -m win_shell -a 'Get-Process -Name aw-session-events-* -ErrorAction SilentlyContinue'" \ + "aw-dlp-endpoint-signals|DLP endpoint signals|Запустите: ansible aw_windows -i $INVENTORY -m win_shell -a 'schtasks /Run /TN \"ActivityWatch Launch [SHARKON2025_Администратор]\"; Start-Sleep 60'" \ + "aw-dlp-incidents|DLP incidents|Проверьте: ansible aw_windows -i $INVENTORY -m win_shell -a \"Get-Content 'C:\\ProgramData\\AWatch-rus\\logs\\dlp-*.log' -Tail 20\"" \ + "aw-dlp-review|DLP review|Проверьте: ssh igor@$AW_HOST 'journalctl -u aw-dlp-policy-engine.service -n 20 --no-pager'" \ + "aw-dlp-rules|DLP rules|Проверьте: ssh igor@$AW_HOST 'journalctl -u aw-dlp-ioc-refresh.service -n 20 --no-pager'" \ +; do + bucket="${entry%%|*}"; rest="${entry#*|}" + label="${rest%%|*}"; remediation="${rest#*|}" + check_bucket_freshness "$bucket" "$label" "$remediation" +done + +# ============================================================ +section "5. InfluxDB (10.10.10.10:8086)" +# ============================================================ +check_http_json_key "InfluxDB health" "$INFLUXDB_URL/health" \ + '.status == "pass"' \ + "Проверьте InfluxDB на LXC 200: ssh igor@$PROXMOX_HOST 'sudo pct exec 200 -- systemctl status influxdb'" + +# ============================================================ +section "6. Grafana (10.10.10.11:3000)" +# ============================================================ +check_http_json_key "Grafana health" "$GRAFANA_URL/api/health" \ + '.database == "ok"' \ + "Проверьте: ssh igor@$PROXMOX_HOST 'sudo pct exec 201 -- systemctl status grafana-server'" +check_http_code "Grafana datasources API" "$GRAFANA_URL/api/datasources" '^200$|^302$|^401$' + +# ============================================================ +section "7. ClickHouse (10.10.10.2:8123)" +# ============================================================ +# Проверяем через прямой HTTP — AUTHENTICATION_FAILED = сервер жив +local_ch_ok=0 +ch_code=$(curl -sS --max-time 5 "http://$CLICKHOUSE_HOST:8123/?query=SELECT%201" 2>/dev/null | head -1) +if echo "$ch_code" | grep -q "AUTHENTICATION_FAILED"; then + pass "ClickHouse HTTP — отвечает (требуется аутентификация, это нормально)" + local_ch_ok=1 +elif echo "$ch_code" | grep -q "1"; then + pass "ClickHouse HTTP — SELECT 1 OK" + local_ch_ok=1 +else + fail "ClickHouse HTTP — не отвечает: $ch_code" + echo " REMEDIATION: ssh igor@$PROXMOX_HOST 'cd /opt/activitywatch/clickhouse-1c && sudo docker compose ps; sudo docker compose logs --tail=20'" +fi + +# Проверка Docker контейнера через SSH +container_status=$(ssh_pve 'sudo docker ps --filter name=aw-rus-1c-clickhouse --format "{{.Status}}" 2>/dev/null' 2>/dev/null) +if [ -n "$container_status" ]; then + pass "ClickHouse Docker контейнер: $container_status" +else + fail "ClickHouse Docker контейнер не запущен" + echo " REMEDIATION: ssh igor@$PROXMOX_HOST 'cd /opt/activitywatch/clickhouse-1c && sudo docker compose up -d'" +fi + +# ClickHouse health timer +check_service_remote "ClickHouse health timer" "$PROXMOX_HOST" "aw-1c-clickhouse-health.timer" \ + "Проверьте: ssh igor@$PROXMOX_HOST 'sudo journalctl -u aw-1c-clickhouse-health.service -n 30 --no-pager'" + +# ClickHouse network health timer (с AW сервера) +check_service_remote "ClickHouse network health timer" "$AW_HOST" "aw-clickhouse-network-health.timer" \ + "Проверьте: ssh igor@$AW_HOST 'sudo journalctl -u aw-clickhouse-network-health.service -n 30 --no-pager'" + +# 1C-ingest timer +check_service_remote "1C ingest timer" "$PROXMOX_HOST" "aw-1c-ingest.timer" \ + "Проверьте: ssh igor@$PROXMOX_HOST 'sudo systemctl status aw-1c-ingest.timer; sudo journalctl -u aw-1c-ingest.service -n 20'" + +# ============================================================ +section "8. 1C Manager API (10.10.10.2:8710)" +# ============================================================ +check_http_json_key "1C /api/health" "http://$PROXMOX_HOST:8710/api/health" \ + '.status == "ok"' \ + "Проверьте Python процесс: ssh igor@$PROXMOX_HOST 'ps aux | grep 8710 | grep -v grep'" +check_http_code "1C /manager/brief" "http://$PROXMOX_HOST:8710/manager/brief" '^200$' + +# ============================================================ +section "9. Nginx Gateway (10.10.10.2)" +# ============================================================ +check_http_code "Gateway /healthz" "https://$PROXMOX_HOST/healthz" '^200$' +check_http_code "Gateway /go/proxmox-gui (401=protected, OK)" "https://$PROXMOX_HOST/go/proxmox-gui" '^30[1278]$|^401$' +check_http_code "Gateway /go/file1c-brief (401=protected, OK)" "https://$PROXMOX_HOST/go/file1c-brief" '^30[1278]$|^401$' + +check_service_remote "Nginx service" "$PROXMOX_HOST" "nginx.service" \ + "Проверьте: ssh igor@$PROXMOX_HOST 'sudo systemctl status nginx; sudo nginx -t'" + +# ============================================================ +section "10. DLP Pipeline (10.10.10.13)" +# ============================================================ +# DLP Policy Engine — должен быть active (running) +check_service_remote "DLP Policy Engine" "$AW_HOST" "aw-dlp-policy-engine.service" \ + "Проверьте: ssh igor@$AW_HOST 'sudo journalctl -u aw-dlp-policy-engine.service -n 30 --no-pager'" + +# DLP Case Management +check_service_remote "DLP Case Management" "$AW_HOST" "aw-dlp-case-management.service" \ + "Проверьте: ssh igor@$AW_HOST 'sudo journalctl -u aw-dlp-case-management.service -n 30 --no-pager'" + +# DLP Aggregator +aggr_status=$(ssh_aw 'systemctl is-active activitywatch-dlp-aggregator.timer 2>/dev/null || echo not_found' 2>/dev/null) +if [ "$aggr_status" = "active" ]; then + pass "DLP Aggregator timer (active)" +else + fail "DLP Aggregator timer ($aggr_status)" + echo " REMEDIATION: ssh igor@$AW_HOST 'sudo systemctl enable --now activitywatch-dlp-aggregator.timer; sudo journalctl -u activitywatch-dlp-aggregator.service -n 30'" +fi + +# DLP Influx Exporter +influx_exp_status=$(ssh_aw 'systemctl is-active aw-dlp-influx-exporter.timer 2>/dev/null || echo not_found' 2>/dev/null) +if [ "$influx_exp_status" = "active" ]; then + pass "DLP Influx Exporter timer (active)" +else + fail "DLP Influx Exporter timer ($influx_exp_status)" + echo " REMEDIATION: ssh igor@$AW_HOST 'sudo systemctl enable --now aw-dlp-influx-exporter.timer; sudo journalctl -u aw-dlp-influx-exporter.service -n 30'" +fi + +# DLP CEF Exporter +check_service_remote "DLP CEF Exporter timer" "$AW_HOST" "aw-dlp-cef-exporter.timer" \ + "ssh igor@$AW_HOST 'sudo systemctl enable --now aw-dlp-cef-exporter.timer; journalctl -u aw-dlp-cef-exporter.service -n 20'" + +# DLP IOC Refresh +check_service_remote "DLP IOC Refresh timer" "$AW_HOST" "aw-dlp-ioc-refresh.timer" \ + "ssh igor@$AW_HOST 'sudo systemctl enable --now aw-dlp-ioc-refresh.timer'" + +# DLP Syslog Forwarder +check_service_remote "DLP Syslog Forwarder timer" "$AW_HOST" "aw-dlp-syslog-forwarder.timer" \ + "ssh igor@$AW_HOST 'sudo systemctl enable --now aw-dlp-syslog-forwarder.timer'" + +# DLP Webhook Sender +check_service_remote "DLP Webhook Sender timer" "$AW_HOST" "aw-dlp-webhook-sender.timer" \ + "ssh igor@$AW_HOST 'sudo systemctl enable --now aw-dlp-webhook-sender.timer'" + +# DLP Report Scheduler +check_service_remote "DLP Report Scheduler timer" "$AW_HOST" "aw-dlp-report-scheduler.timer" \ + "ssh igor@$AW_HOST 'sudo systemctl enable --now aw-dlp-report-scheduler.timer'" + +# Worktime Influx Exporter +check_service_remote "Worktime Influx Exporter timer" "$AW_HOST" "aw-worktime-influx-exporter.timer" \ + "ssh igor@$AW_HOST 'sudo systemctl enable --now aw-worktime-influx-exporter.timer; journalctl -u aw-worktime-influx-exporter.service -n 20'" + +# ============================================================ +if [ "$SKIP_WINDOWS" = "1" ]; then + skip "Проверки RDP хоста пропущены (--skip-windows)" +else + section "11. RDP хост (192.168.100.18)" + if have ansible && [ -f "$INVENTORY" ]; then + check_ansible_module "WinRM ping" aw_windows win_ping + + check_ansible_win_shell "Сессии RDP" 'query user 2>&1' + + check_ansible_win_shell "Процессы watcher" \ + 'Get-Process aw-watcher-afk,aw-watcher-window -ErrorAction SilentlyContinue | Select-Object Name,Id,SessionId,StartTime | Format-Table -AutoSize' + + check_ansible_win_shell "Количество процессов powershell" \ + '(Get-Process powershell -ErrorAction SilentlyContinue | Measure-Object).Count' + + check_ansible_win_shell "Scheduled tasks (Recovery)" \ + 'schtasks /Query /TN "ActivityWatch Recovery" /FO LIST /V | Select-String "Status|Run|Next"' + + check_ansible_win_shell "Scheduled tasks (Launch Admin)" \ + 'schtasks /Query /TN "ActivityWatch Launch [SHARKON2025_Администратор]" /FO LIST /V | Select-String "Status|Run|Next"' + else + skip "Ansible или inventory не найдены" + fi +fi + +# ============================================================ +section "12. Systemd health (AW server)" +# ============================================================ +check_ansible_shell "AW server core units" aw_server \ + 'systemctl is-active activitywatch-server aw-worktime-api aw-dlp-policy-engine aw-dlp-case-management aw-worktime-influx-exporter.timer aw-dlp-influx-exporter.timer activitywatch-dlp-aggregator.timer aw-clickhouse-network-health.timer | paste -sd,' + +check_ansible_shell "AW server — нет failed units" aw_server \ + 'failed=$(systemctl --failed --no-legend | awk "{print \$1}" | grep -E "activitywatch|aw-|dlp" || true); test -z "$failed" && echo "no AW-related failed units" || { echo "$failed"; exit 1; }' + +# ============================================================ +section "13. Systemd health (Proxmox)" +# ============================================================ +check_ansible_shell "Proxmox core units" proxmox \ + 'systemctl is-active nginx docker aw-1c-clickhouse-health.timer aw-1c-ingest.timer 2>/dev/null | paste -sd,' + +# ============================================================ +section "14. Диск и память" +# ============================================================ +check_ansible_shell "Диски AW server" aw_server 'df -h / /var /opt 2>/dev/null | tail -5' +check_ansible_shell "Память AW server" aw_server 'free -h | tail -5' + +# ============================================================ +section "Итог диагностики" +# ============================================================ +printf " OK=%s WARN=%s FAIL=%s SKIP=%s\n" "$OK_COUNT" "$WARN_COUNT" "$FAIL_COUNT" "$SKIP_COUNT" + +if [ "$FAIL_COUNT" -gt 0 ]; then + echo "" + echo " Есть проблемы! Смотрите REMEDIATION выше для каждого FAIL." + echo " После исправления запустите повторно: $0" + exit 2 +elif [ "$WARN_COUNT" -gt 0 ]; then + echo "" + echo " Есть предупреждения (WARN) — стоит проверить, но не критично." + exit 1 +else + echo "" + echo " Все проверки пройдены. Контур в рабочем состоянии." + exit 0 +fi diff --git a/scripts/aw-contour-smoke-gateway.sh b/scripts/aw-contour-smoke-gateway.sh index eddece2..f312c2c 100644 --- a/scripts/aw-contour-smoke-gateway.sh +++ b/scripts/aw-contour-smoke-gateway.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Smoke checks for the Proxmox/gateway/1C host 192.0.2.2. +# Smoke checks for the Proxmox/gateway/1C host 10.10.10.2. set -uo pipefail @@ -193,25 +193,25 @@ section "Ports" check_tcp "nginx http" 127.0.0.1 80 check_tcp "nginx https" 127.0.0.1 443 check_tcp "proxmox web" 127.0.0.1 8006 -check_tcp "1C company API" 192.0.2.2 8710 +check_tcp "1C company API" 10.10.10.2 8710 check_tcp "clickhouse native" 127.0.0.1 9000 check_tcp "clickhouse http" 127.0.0.1 8123 ss -tulpn | grep -E ':(80|443|8006|8710|8123|9000)\b' | sed 's/^/ /' || true section "Gateway HTTP" check_http_code "nginx healthz" "https://127.0.0.1/healthz" '^200$' -check_http_redirect "go proxmox gui" "https://127.0.0.1/go/proxmox-gui" '^30[1278]$' 'https://192.0.2.2:8006/' -check_http_redirect "go file1c brief" "https://127.0.0.1/go/file1c-brief" '^30[1278]$' 'http://192.0.2.2:8710/manager/brief' -check_http_redirect "go file1c actions" "https://127.0.0.1/go/file1c-actions" '^30[1278]$' 'http://192.0.2.2:8710/manager/actions' +check_http_code "go proxmox gui protected" "https://127.0.0.1/go/proxmox-gui" '^401$' +check_http_code "go file1c brief protected" "https://127.0.0.1/go/file1c-brief" '^401$' +check_http_code "go file1c actions protected" "https://127.0.0.1/go/file1c-actions" '^401$' section "1C Company API" -check_http_code "1C root redirect" "http://192.0.2.2:8710/" '^307$' -check_http_code "1C /health" "http://192.0.2.2:8710/health" '^200$' -check_http_code "1C /api/health" "http://192.0.2.2:8710/api/health" '^200$' -check_http_code "1C manager brief" "http://192.0.2.2:8710/manager/brief" '^200$' -check_http_code "1C manager actions" "http://192.0.2.2:8710/manager/actions" '^200$' -check_http_code "1C manager recovery" "http://192.0.2.2:8710/manager/recovery" '^200$' -check_http_code "1C weekly digest" "http://192.0.2.2:8710/manager/digest/weekly" '^200$' +check_http_code "1C root redirect" "http://10.10.10.2:8710/" '^307$' +check_http_code "1C /health" "http://10.10.10.2:8710/health" '^200$' +check_http_code "1C /api/health" "http://10.10.10.2:8710/api/health" '^200$' +check_http_code "1C manager brief" "http://10.10.10.2:8710/manager/brief" '^200$' +check_http_code "1C manager actions" "http://10.10.10.2:8710/manager/actions" '^200$' +check_http_code "1C manager recovery" "http://10.10.10.2:8710/manager/recovery" '^200$' +check_http_code "1C weekly digest" "http://10.10.10.2:8710/manager/digest/weekly" '^200$' section "ClickHouse" check_docker_container "aw-rus-1c-clickhouse" diff --git a/scripts/aw-contour-smoke-local.sh b/scripts/aw-contour-smoke-local.sh index c2509d1..9cfdaae 100644 --- a/scripts/aw-contour-smoke-local.sh +++ b/scripts/aw-contour-smoke-local.sh @@ -19,24 +19,35 @@ ANSIBLE_DIR="$REPO_ROOT/ansible" INVENTORY="${AW_SMOKE_INVENTORY:-$ANSIBLE_DIR/inventory.ini}" REMOTE_SCRIPT_SRC="$REPO_ROOT/scripts/aw-contour-smoke-gateway.sh" REMOTE_SCRIPT_DST="${AW_SMOKE_REMOTE_SCRIPT:-/usr/local/sbin/aw-contour-smoke.sh}" -REMOTE_RUST_SRC="${AW_SMOKE_REMOTE_RUST_SRC:-${CARGO_TARGET_DIR:-$REPO_ROOT/adk-rust/target}/release/aw-contour-smoke}" +DEFAULT_REMOTE_RUST_SRC="" +for rust_candidate in \ + "${CARGO_TARGET_DIR:-}/release/aw-contour-smoke" \ + "$HOME/.cache/detmir-adk-rust-target/release/aw-contour-smoke" \ + "$REPO_ROOT/adk-rust/target/release/aw-contour-smoke" +do + if [ -n "$rust_candidate" ] && [ -x "$rust_candidate" ]; then + DEFAULT_REMOTE_RUST_SRC="$rust_candidate" + break + fi +done +REMOTE_RUST_SRC="${AW_SMOKE_REMOTE_RUST_SRC:-$DEFAULT_REMOTE_RUST_SRC}" REMOTE_RUST_DST="${AW_SMOKE_REMOTE_RUST_BIN:-/usr/local/sbin/aw-contour-smoke}" -AW_SERVER="${AW_SMOKE_AW_SERVER:-http://192.0.2.13:5600}" -WORKTIME_API="${AW_SMOKE_WORKTIME_API:-http://192.0.2.13:5610}" -GRAFANA_URL="${AW_SMOKE_GRAFANA_URL:-http://192.0.2.11:3000}" +AW_SERVER="${AW_SMOKE_AW_SERVER:-http://10.10.10.13:5600}" +WORKTIME_API="${AW_SMOKE_WORKTIME_API:-http://10.10.10.13:5610}" +GRAFANA_URL="${AW_SMOKE_GRAFANA_URL:-http://10.10.10.11:3000}" GRAFANA_USER="${GRAFANA_USER:-igor}" GRAFANA_PASSWORD="${GRAFANA_PASSWORD:-}" -PROXMOX_HOST="${AW_SMOKE_PROXMOX_HOST:-192.0.2.2}" -AW_HOST="${AW_SMOKE_AW_HOST:-192.0.2.13}" -GRAFANA_HOST="${AW_SMOKE_GRAFANA_HOST:-192.0.2.11}" -WINDOWS_HOST="${AW_SMOKE_WINDOWS_HOST:-198.51.100.18}" -AW_SOURCE_HOSTNAME="${AW_SMOKE_SOURCE_HOSTNAME:-HOST-EXAMPLE}" +PROXMOX_HOST="${AW_SMOKE_PROXMOX_HOST:-10.10.10.2}" +AW_HOST="${AW_SMOKE_AW_HOST:-10.10.10.13}" +GRAFANA_HOST="${AW_SMOKE_GRAFANA_HOST:-10.10.10.11}" +WINDOWS_HOST="${AW_SMOKE_WINDOWS_HOST:-192.168.100.18}" +AW_SOURCE_HOSTNAME="${AW_SMOKE_SOURCE_HOSTNAME:-SHARKON2025}" LOG_DIR="${AW_SMOKE_LOG_DIR:-$REPO_ROOT/output/smoke}" RUN_REMOTE="${AW_SMOKE_RUN_REMOTE:-1}" RUN_WINRM="${AW_SMOKE_RUN_WINRM:-1}" RUN_SERVER_SYSTEMD="${AW_SMOKE_RUN_SERVER_SYSTEMD:-1}" -NO_PROXY_REQUIRED="localhost,127.0.0.1,$PROXMOX_HOST,$AW_HOST,$GRAFANA_HOST,$WINDOWS_HOST,192.0.2.0/24,198.51.100.0/24" +NO_PROXY_REQUIRED="localhost,127.0.0.1,$PROXMOX_HOST,$AW_HOST,$GRAFANA_HOST,$WINDOWS_HOST,10.10.10.0/24,192.168.100.0/24" if [ -n "${no_proxy:-}" ]; then export no_proxy="$no_proxy,$NO_PROXY_REQUIRED" else @@ -72,10 +83,10 @@ usage() { Usage: $(basename "$0") [--skip-remote] [--skip-winrm] [--skip-server-systemd] Environment overrides: - AW_SMOKE_AW_SERVER=http://192.0.2.13:5600 - AW_SMOKE_WORKTIME_API=http://192.0.2.13:5610 - AW_SMOKE_GRAFANA_URL=http://192.0.2.11:3000 - AW_SMOKE_SOURCE_HOSTNAME=HOST-EXAMPLE + AW_SMOKE_AW_SERVER=http://10.10.10.13:5600 + AW_SMOKE_WORKTIME_API=http://10.10.10.13:5610 + AW_SMOKE_GRAFANA_URL=http://10.10.10.11:3000 + AW_SMOKE_SOURCE_HOSTNAME=SHARKON2025 AW_SMOKE_ENV_FILE=$HOME/.config/aw-contour-smoke.env AW_SMOKE_LOG_DIR=$REPO_ROOT/output/smoke GRAFANA_USER/GRAFANA_PASSWORD via env or a local env file @@ -249,19 +260,36 @@ check_grafana_influx_health() { } check_grafana_aw_main_dashboard_queries() { - local name="Grafana detmir-aw-main window/AFK panel queries" + local name="Grafana detmir-aw-main worktime panel queries" local tmp tmp="$(mktemp)" if curl -k -fsS -u "$GRAFANA_USER:$GRAFANA_PASSWORD" --connect-timeout 5 --max-time 20 "$GRAFANA_URL/api/dashboards/uid/detmir-aw-main" -o "$tmp" 2>"$tmp.err" && \ jq -e ' - def fixed: - (.targets[0].query // "") | contains("keep(columns: [\"_time\", \"_value\", \"host\"]"); - ([.dashboard.panels[] | select(.title == "Активность окон (сумма за 5 минут)" or .title == "AFK (сумма за 5 минут)") | fixed] | length == 2 and all(. == true)) + def query: (.targets[0].query // ""); + def has_panel($title; $checks): + any(.dashboard.panels[]?; .title == $title and (query as $q | all($checks[]; $q | contains(.)))); + has_panel("Активность RDP по часам"; [ + "aw_rdp_worktime_hourly", + "r.user_id !~ /\\$$/", + "r.user_id !~ /�/", + "group(columns: [\"_time\", \"user\"])", + "max(column: \"_value\")" + ]) and + has_panel("Сегодня: активность по сотрудникам"; [ + "aw_rdp_worktime_daily", + "group(columns: [\"report_date\", \"user\"])", + "max(column: \"_value\")", + "last()" + ]) and + has_panel("Все сотрудники: активное время по дням"; [ + "aw_rdp_worktime_summary_daily", + "Все сотрудники, ч" + ]) ' "$tmp" >/dev/null 2>&1; then pass "$name" else - fail "$name missing keep(_time,_value,host) after aggregateWindow" - jq -r '.dashboard.panels[]? | select(.title == "Активность окон (сумма за 5 минут)" or .title == "AFK (сумма за 5 минут)") | "\(.title): " + ((.targets[0].query // "") | gsub("\n"; " "))' "$tmp" 2>/dev/null | sed 's/^/ /' | head -20 + fail "$name missing expected DetMir worktime/dedupe query contract" + jq -r '.dashboard.panels[]? | select(.title == "Активность RDP по часам" or .title == "Сегодня: активность по сотрудникам" or .title == "Все сотрудники: активное время по дням") | "\(.title): " + ((.targets[0].query // "") | gsub("\n"; " "))' "$tmp" 2>/dev/null | sed 's/^/ /' | head -20 sed 's/^/ /' "$tmp.err" 2>/dev/null | head -20 fi rm -f "$tmp" "$tmp.err" @@ -436,7 +464,7 @@ check_bucket_freshness() { run_remote_proxmox_script() { if [ "$RUN_REMOTE" != "1" ]; then - skip "remote 192.0.2.2 smoke skipped" + skip "remote 10.10.10.2 smoke skipped" return fi if ! have ansible; then @@ -448,7 +476,7 @@ run_remote_proxmox_script() { return fi - section "Deploy Remote Script To 192.0.2.2" + section "Deploy Remote Script To 10.10.10.2" if [ -x "$REMOTE_RUST_SRC" ]; then if ANSIBLE_NOCOLOR=1 ansible proxmox -i "$INVENTORY" -m copy -a "src=$REMOTE_RUST_SRC dest=$REMOTE_RUST_DST owner=root group=root mode=0755" >/tmp/aw-smoke-copy-rust.$$ 2>&1; then pass "remote Rust smoke deployed to $REMOTE_RUST_DST" @@ -471,14 +499,14 @@ run_remote_proxmox_script() { fi rm -f /tmp/aw-smoke-copy.$$ - section "Remote 192.0.2.2 Smoke" + section "Remote 10.10.10.2 Smoke" local tmp tmp="$(mktemp)" if ANSIBLE_NOCOLOR=1 ansible proxmox -i "$INVENTORY" -m shell -a "$REMOTE_SCRIPT_DST" >"$tmp" 2>&1; then - pass "remote 192.0.2.2 smoke completed" + pass "remote 10.10.10.2 smoke completed" sed 's/^/ /' "$tmp" else - fail "remote 192.0.2.2 smoke failed" + fail "remote 10.10.10.2 smoke failed" sed 's/^/ /' "$tmp" fi rm -f "$tmp" @@ -544,8 +572,9 @@ check_http_code "worktime management html" "$WORKTIME_API/reports/worktime/manag section "Gateway And 1C HTTP" check_http_code "gateway healthz" "https://$PROXMOX_HOST/healthz" '^200$' -check_http_redirect "gateway proxmox redirect" "https://$PROXMOX_HOST/go/proxmox-gui" '^30[1278]$' "https://$PROXMOX_HOST:8006/" -check_http_redirect "gateway file1c brief redirect" "https://$PROXMOX_HOST/go/file1c-brief" '^30[1278]$' "http://$PROXMOX_HOST:8710/manager/brief" +check_http_code "gateway proxmox protected" "https://$PROXMOX_HOST/go/proxmox-gui" '^401$' +check_http_code "gateway file1c brief protected" "https://$PROXMOX_HOST/go/file1c-brief" '^401$' +check_http_code "gateway file1c actions protected" "https://$PROXMOX_HOST/go/file1c-actions" '^401$' check_http_code "1C /health" "http://$PROXMOX_HOST:8710/health" '^200$' check_http_code "1C /api/health" "http://$PROXMOX_HOST:8710/api/health" '^200$' check_http_code "1C manager brief" "http://$PROXMOX_HOST:8710/manager/brief" '^200$' @@ -582,7 +611,7 @@ if [ "$RUN_WINRM" = "1" ] && have ansible; then check_ansible_module "Windows win_ping" aw_windows win_ping check_ansible_win_shell "Windows sessions" aw_windows '$psi = [System.Diagnostics.ProcessStartInfo]::new(); $psi.FileName = "$env:SystemRoot\System32\query.exe"; $psi.Arguments = "user"; $psi.UseShellExecute = $false; $psi.RedirectStandardOutput = $true; $psi.RedirectStandardError = $true; $psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866); $psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866); $p = [System.Diagnostics.Process]::Start($psi); $out = $p.StandardOutput.ReadToEnd(); $err = $p.StandardError.ReadToEnd(); $p.WaitForExit(); [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $out; if ($err) { $err }; if ($out -match "USERNAME|ПОЛЬЗОВАТЕЛЬ|администратор|Администратор") { exit 0 } else { exit $p.ExitCode }' check_ansible_win_shell_warn "Windows collector processes" aw_windows '$p = Get-Process aw-watcher-afk,aw-watcher-window -ErrorAction SilentlyContinue; if ($p) { $p | Select-Object Name,Id,SessionId,StartTime | Format-Table -AutoSize } else { "no aw-watcher-afk/window process visible to this WinRM session" }' - check_ansible_win_shell "Windows ActivityWatch tasks" aw_windows 'schtasks /Query /TN "ActivityWatch Recovery" /FO LIST /V; schtasks /Query /TN "ActivityWatch Launch [HOST-EXAMPLE_Администратор]" /FO LIST /V' + check_ansible_win_shell "Windows ActivityWatch tasks" aw_windows 'schtasks /Query /TN "ActivityWatch Recovery" /FO LIST /V; schtasks /Query /TN "ActivityWatch Launch [SHARKON2025_Администратор]" /FO LIST /V' else skip "Windows WinRM checks skipped" fi diff --git a/scripts/awatch-production-hardening-smoke.mjs b/scripts/awatch-production-hardening-smoke.mjs index 3326e2c..a40aede 100644 --- a/scripts/awatch-production-hardening-smoke.mjs +++ b/scripts/awatch-production-hardening-smoke.mjs @@ -73,10 +73,24 @@ async function main() { assert(Array.isArray(kpiExplain.json?.factors), "KPI explain must include factors"); assert(kpiExplain.json.factors.some((item) => item.name === "productive_activity"), "KPI explain factors must be deterministic"); + const ueba = await request("/api/ueba?role=security"); + assert(ueba.response.status === 200, "UEBA API must return 200"); + assert(["high", "medium", "low", "unknown"].includes(ueba.json?.confidence), "UEBA must include confidence level"); + assert( + ["confirmed_risk", "likely_risk", "needs_investigation", "insufficient_data"].includes(ueba.json?.classification), + "UEBA must include stable classification", + ); + assert(Array.isArray(ueba.json?.confidence_reasons), "UEBA must include confidence reasons"); + const riskNarrative = await request("/api/risk/narrative?role=executive"); assert(riskNarrative.response.status === 200, "Risk narrative must return 200"); assert(typeof riskNarrative.json?.risk_score === "number", "Risk narrative must include risk_score"); assert(["low", "guarded", "medium", "high", "critical"].includes(riskNarrative.json?.risk_level), "Risk narrative must include stable risk_level"); + assert(["high", "medium", "low", "unknown"].includes(riskNarrative.json?.confidence), "Risk narrative must include confidence"); + assert( + ["confirmed_risk", "likely_risk", "needs_investigation", "insufficient_data"].includes(riskNarrative.json?.classification), + "Risk narrative must include classification", + ); assert(Array.isArray(riskNarrative.json?.why), "Risk narrative must include why list"); assert(riskNarrative.json?.model?.type === "rule_based", "Risk narrative must be rule-based"); @@ -97,6 +111,7 @@ async function main() { "query limits", "role gates", "/api/workforce/kpi/explain", + "/api/ueba", "/api/risk/narrative", "/api/actions", ], diff --git a/scripts/browser-conformance-smoke.mjs b/scripts/browser-conformance-smoke.mjs index fe0338a..368cb1e 100644 --- a/scripts/browser-conformance-smoke.mjs +++ b/scripts/browser-conformance-smoke.mjs @@ -70,11 +70,24 @@ async function switchView(page, selector, timeout) { selector.match(/data-view-mode="([^"]+)"/)?.[1] || "", { timeout }, ); - await page.waitForTimeout(250); +} + +async function waitForViewContent(page, spec, timeout) { + await page.waitForFunction( + ({ markers, minLength }) => { + const content = document.querySelector("#content")?.innerText || ""; + if (content.trim().length < minLength) return false; + const normalized = content.toLocaleLowerCase("ru-RU"); + return markers.every((marker) => normalized.includes(String(marker).toLocaleLowerCase("ru-RU"))); + }, + { markers: spec.required, minLength: 120 }, + { timeout }, + ); } async function checkView(page, spec, artifactDir, timeout) { await switchView(page, `[data-view-mode="${spec.viewMode}"]`, timeout); + await waitForViewContent(page, spec, timeout); const content = await page.locator("#content").innerText({ timeout }); const body = await page.locator("body").innerText({ timeout }); const missing = missingMarkers(content, spec.required); diff --git a/scripts/build_release_candidate.sh b/scripts/build_release_candidate.sh new file mode 100755 index 0000000..0babc93 --- /dev/null +++ b/scripts/build_release_candidate.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +print_cargo_target_dir() { + if [[ -n "${CARGO_TARGET_DIR:-}" ]]; then + echo "CARGO_TARGET_DIR=$CARGO_TARGET_DIR" + else + echo "CARGO_TARGET_DIR is not set; cargo default target dir will be used" + fi +} + +preflight_ok() { + echo "[OK] $1" +} + +preflight_fail() { + echo "[FAIL] $1" >&2 + PREFLIGHT_FAILED=1 +} + +check_command() { + local command_name="$1" + if command -v "$command_name" >/dev/null 2>&1; then + preflight_ok "command available: $command_name" + else + preflight_fail "missing command: $command_name" + fi +} + +check_file() { + local path="$1" + if [[ -f "$path" ]]; then + preflight_ok "required file exists: $path" + else + preflight_fail "required file is missing: $path" + fi +} + +run_preflight() { + PREFLIGHT_FAILED=0 + + echo "release candidate preflight" + print_cargo_target_dir + + check_command git + check_command cargo + check_command bash + check_command node + check_command sha256sum + + check_file scripts/generate_release_sbom_v0_2.sh + check_file scripts/check_private_config_guard.sh + check_file scripts/check_portal_contract_sync.mjs + + if command -v git >/dev/null 2>&1; then + if git check-ignore -q dist/release-candidate/.preflight-probe; then + preflight_ok "dist/ is ignored by git" + else + preflight_fail "dist/ is not ignored by git" + fi + else + preflight_fail "cannot verify git ignore rules without git" + fi + + case "$ROOT_DIR" in + /mnt/*|/media/*) + cat <<'EOF' +[HINT] Project is under /mnt or /media. If cargo fails on the mount with Operation not permitted, run the full RC build with a writable target dir: + CARGO_TARGET_DIR=/home/igor/.cache/aw-rus-hardening-target bash scripts/build_release_candidate.sh v1.0.2-rc1 +EOF + ;; + esac + + if [[ "$PREFLIGHT_FAILED" -ne 0 ]]; then + echo "release candidate preflight: FAIL" >&2 + return 1 + fi + + echo "release candidate preflight: OK" +} + +if [[ "${1:-}" == "--preflight" ]]; then + run_preflight + exit $? +fi + +print_cargo_target_dir + +RC_NAME="${1:-}" +if [[ -z "$RC_NAME" ]]; then + cat >&2 <<'EOF' +usage: bash scripts/build_release_candidate.sh +example: bash scripts/build_release_candidate.sh v1.0.2-rc1 + +preflight: bash scripts/build_release_candidate.sh --preflight +EOF + exit 2 +fi + +if [[ ! "$RC_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + echo "invalid release candidate name: start with a letter or number; use only letters, numbers, dot, underscore, and hyphen" >&2 + exit 2 +fi + +if [[ -n "$(git status --porcelain --untracked-files=normal)" ]]; then + echo "git working tree is not clean; commit, stash, or remove changes before building release candidate" >&2 + git status --short >&2 + exit 1 +fi + +OUT_DIR="$ROOT_DIR/dist/release-candidate/$RC_NAME" +if [[ -e "$OUT_DIR" ]]; then + echo "release candidate output already exists: $OUT_DIR" >&2 + exit 1 +fi + +BUILD_SUCCESS=0 +cleanup_on_failure() { + status=$? + if [[ $status -ne 0 && $BUILD_SUCCESS -ne 1 && -d "$OUT_DIR" ]]; then + rm -rf "$OUT_DIR" + fi + exit "$status" +} +trap cleanup_on_failure EXIT + +mkdir -p "$OUT_DIR" + +git rev-parse HEAD > "$OUT_DIR/git-commit.txt" + +cargo fmt --manifest-path adk-rust/Cargo.toml --all -- --check +cargo test --manifest-path adk-rust/Cargo.toml --workspace +cargo clippy --manifest-path adk-rust/Cargo.toml --workspace --all-targets -- -D warnings +cargo build --manifest-path adk-rust/Cargo.toml --workspace --release +bash scripts/quality-gate.sh +bash scripts/check_private_config_guard.sh +node scripts/check_portal_contract_sync.mjs + +bash scripts/generate_release_sbom_v0_2.sh "$OUT_DIR" + +( + cd "$OUT_DIR" + { + printf '%s\n' "FILES.txt" + find . -type f ! -name 'FILES.txt' ! -name 'SHA256SUMS.txt' -print \ + | sort \ + | sed 's#^\./##' + } > FILES.txt + + find . -type f ! -name 'SHA256SUMS.txt' -print0 \ + | sort -z \ + | xargs -0 sha256sum > SHA256SUMS.txt +) + +BUILD_SUCCESS=1 +echo "release candidate built: $OUT_DIR" diff --git a/scripts/check_portal_contract_sync.mjs b/scripts/check_portal_contract_sync.mjs new file mode 100755 index 0000000..7035ca9 --- /dev/null +++ b/scripts/check_portal_contract_sync.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, ".."); +const contractPath = path.join( + root, + "adk-rust/crates/detmir-portal/src/contracts/openapi.json", +); + +const requiredPublicPaths = [ + "/api/contracts", + "/api/contracts/openapi.json", + "/api/contracts/typescript.d.ts", + "/api/reports", + "/api/executive", + "/api/workforce", + "/api/security", + "/api/forensics", + "/api/ueba", + "/api/pfsense", + "/api/incidents", + "/api/cases", + "/api/readiness/latest", + "/api/readiness/bundle", + "/api/readiness/verify", +]; + +function fail(message, details = []) { + console.error(message); + for (const detail of details) { + console.error(`- ${detail}`); + } + process.exit(1); +} + +let contract; +try { + contract = JSON.parse(fs.readFileSync(contractPath, "utf8")); +} catch (error) { + fail(`failed to read OpenAPI contract: ${contractPath}`, [error.message]); +} + +if (!contract || typeof contract !== "object" || !contract.paths || typeof contract.paths !== "object") { + fail("OpenAPI contract has no object 'paths' section."); +} + +const contractPaths = Object.keys(contract.paths); +const forbiddenPaths = contractPaths.filter((contractPathName) => + /dioxus|prototype-mirror|mirror/i.test(contractPathName), +); +if (forbiddenPaths.length > 0) { + fail("OpenAPI contract contains legacy/prototype paths.", forbiddenPaths); +} + +const effectivePublicPaths = new Set(); +for (const contractPathName of contractPaths) { + effectivePublicPaths.add(contractPathName); + if (contractPathName.startsWith("/") && !contractPathName.startsWith("/api/")) { + effectivePublicPaths.add(`/api${contractPathName}`); + } +} + +const missingPaths = requiredPublicPaths.filter((requiredPath) => !effectivePublicPaths.has(requiredPath)); +if (missingPaths.length > 0) { + fail("OpenAPI contract is missing required public API paths.", missingPaths); +} + +console.log("portal contract sync guard: OK"); diff --git a/scripts/check_private_config_guard.sh b/scripts/check_private_config_guard.sh new file mode 100755 index 0000000..149ceb2 --- /dev/null +++ b/scripts/check_private_config_guard.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +violations=() + +while IFS= read -r -d '' path; do + rest="${path#private-config/}" + case "$path" in + private-config/README.md|private-config/.gitkeep) + continue + ;; + esac + if [[ "$rest" != */* && ( "$rest" == *.example || "$rest" == *.template ) ]]; then + continue + fi + violations+=("$path") +done < <(git ls-files -z -- private-config) + +if (( ${#violations[@]} > 0 )); then + printf 'private-config guard failed: tracked private files are forbidden. Allowed files are README.md, .gitkeep, *.example, *.template.\\n' >&2 + printf '%s\\n' "${violations[@]}" >&2 + exit 1 +fi + +echo "private-config guard: OK" diff --git a/scripts/quality-gate.sh b/scripts/quality-gate.sh index 6a434a6..2445e54 100755 --- a/scripts/quality-gate.sh +++ b/scripts/quality-gate.sh @@ -7,6 +7,16 @@ cd "$ROOT_DIR" TARGET_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/adk-rust/target}" RUST_BIN="${QUALITY_GATE_RUST:-}" +echo "[preflight] Private-config guard" +bash scripts/check_private_config_guard.sh + +echo "[preflight] Portal contract sync guard" +if command -v node >/dev/null 2>&1; then + node scripts/check_portal_contract_sync.mjs +else + echo "node not found, skipping portal contract sync guard." +fi + rust_candidates=() if [[ -n "$RUST_BIN" ]]; then rust_candidates+=("$RUST_BIN") @@ -39,6 +49,7 @@ fi echo "[3/6] Node syntax check (if node available)" if command -v node >/dev/null 2>&1; then node --check scripts/aw-webui-browser-smoke.mjs >/dev/null + node --check scripts/check_portal_contract_sync.mjs >/dev/null else echo "node not found, skipping." fi @@ -53,7 +64,11 @@ if command -v pwsh >/dev/null 2>&1; then [void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path "windows/ActivityWatch.Windows.Common.psm1"),[ref]$null,[ref]$null) [void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path "windows/ActivityWatch.Windows.Common.psd1"),[ref]$null,[ref]$null) ' - pwsh -NoLogo -NoProfile -File windows/aw-collector-guard.ps1 -SelfTest >/dev/null + if [[ -f windows/aw-collector-guard.ps1 ]]; then + pwsh -NoLogo -NoProfile -File windows/aw-collector-guard.ps1 -SelfTest >/dev/null + else + echo "windows/aw-collector-guard.ps1 absent; Rust collector guard is the primary runtime." + fi else echo "pwsh not found, skipping." fi diff --git a/scripts/verify_innosetup_installer.sh b/scripts/verify_innosetup_installer.sh index 4d1a049..e73d7ac 100644 --- a/scripts/verify_innosetup_installer.sh +++ b/scripts/verify_innosetup_installer.sh @@ -48,7 +48,6 @@ wineserver -w >/dev/null 2>&1 required_files=( windows/AWatchRusCollectorGuardService.cs - windows/aw-collector-guard.ps1 windows/install-collector-guard-service.ps1 windows/aw-windows-telemetry.exe windows/dlp-policy.native-cross-os.example.json @@ -70,9 +69,4 @@ for rel in "${required_files[@]}"; do fi done -if ! grep -q 'collector guard self-test OK' "${INSTALL_DIR_UNIX}/windows/aw-collector-guard.ps1"; then - echo "Guard self-test marker missing in extracted installer payload" >&2 - exit 1 -fi - echo "verify_innosetup_installer: OK" diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1 index f449f4b..a12ce54 100755 --- a/windows/ActivityWatch.Windows.Common.psm1 +++ b/windows/ActivityWatch.Windows.Common.psm1 @@ -312,7 +312,7 @@ function Get-ActivityWatchBuiltInAdministratorName { catch { } - if ([string]$env:COMPUTERNAME -ieq 'HOST-EXAMPLE') { + if ([string]$env:COMPUTERNAME -ieq 'SHARKON2025') { $script:ActivityWatchBuiltInAdministratorName = 'Администратор' return $script:ActivityWatchBuiltInAdministratorName } @@ -803,12 +803,18 @@ function Copy-ActivityWatchCollectorAssets { $examplePolicyTarget = Join-Path $StateRoot 'dlp-policy.example.json' $policyTarget = Join-Path $StateRoot 'dlp-policy.json' - Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force - Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force + if ($CollectorScriptSource -and (Test-Path -LiteralPath $CollectorScriptSource)) { + Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force + } + if ($EndpointCollectorScriptSource -and (Test-Path -LiteralPath $EndpointCollectorScriptSource)) { + Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force + } if ($PolicyClientScriptSource -and (Test-Path -LiteralPath $PolicyClientScriptSource)) { Copy-Item -LiteralPath $PolicyClientScriptSource -Destination $policyClientTarget -Force } - Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force + if ($FileCollectorScriptSource -and (Test-Path -LiteralPath $FileCollectorScriptSource)) { + Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force + } Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force if ($EvtxExportScriptSource -and (Test-Path -LiteralPath $EvtxExportScriptSource)) { Copy-Item -LiteralPath $EvtxExportScriptSource -Destination $evtxExportTarget -Force @@ -841,11 +847,15 @@ function Copy-ActivityWatchCollectorAssets { Copy-Item -LiteralPath $examplePolicyTarget -Destination $policyTarget -Force } + $effectiveCollectorTarget = if (Test-Path -LiteralPath $collectorTarget) { $collectorTarget } else { '' } + $effectiveEndpointCollectorTarget = if (Test-Path -LiteralPath $endpointCollectorTarget) { $endpointCollectorTarget } else { '' } + $effectiveFileCollectorTarget = if (Test-Path -LiteralPath $fileCollectorTarget) { $fileCollectorTarget } else { '' } + return [pscustomobject]@{ - CollectorScript = $collectorTarget - EndpointCollectorScript = $endpointCollectorTarget + CollectorScript = $effectiveCollectorTarget + EndpointCollectorScript = $effectiveEndpointCollectorTarget PolicyClientScript = $policyClientTarget - FileCollectorScript = $fileCollectorTarget + FileCollectorScript = $effectiveFileCollectorTarget SessionCollectorScript = $sessionCollectorTarget EvtxExportScript = $evtxExportTarget HayabusaUploadScript = $hayabusaUploadTarget @@ -873,11 +883,14 @@ function New-ActivityWatchDeploymentConfig { [Parameter(Mandatory = $true)] [string]$LogsRoot, [Parameter(Mandatory = $true)] + [AllowEmptyString()] [string]$CollectorScript, [Parameter(Mandatory = $true)] + [AllowEmptyString()] [string]$EndpointCollectorScript, [string]$PolicyClientScript, [Parameter(Mandatory = $true)] + [AllowEmptyString()] [string]$FileCollectorScript, [Parameter(Mandatory = $true)] [string]$SessionCollectorScript, @@ -929,6 +942,7 @@ function New-ActivityWatchDeploymentConfig { [int]$HayabusaAutoUploadHoursBack = 6, [string]$HayabusaAutoUploadMode = 'incident', [string]$HayabusaAutoUploadTaskName = 'ActivityWatch Hayabusa Upload', + [string]$HayabusaAutoUploadRunAsUser, [bool]$File1CAutoUploadEnabled = $true, [int]$File1CAutoUploadIntervalHours = 6, [int]$File1CAutoUploadIntervalMinutes = 15, @@ -960,6 +974,7 @@ function New-ActivityWatchDeploymentConfig { if ($File1CAutoUploadEnabled -and [string]::IsNullOrWhiteSpace($File1CTargetHost)) { throw 'File1CTargetHost is required when File1CAutoUploadEnabled is true.' } + $toolkitRoot = Join-Path (Split-Path -Parent $InstallRoot) 'windows' return [pscustomobject]@{ version = 1 @@ -983,7 +998,7 @@ function New-ActivityWatchDeploymentConfig { evtxExportScript = $EvtxExportScript hayabusaUploadScript = $HayabusaUploadScript file1cTelemetryScript = $File1CTelemetryScript - file1cTelemetryExecutable = if ([string]::IsNullOrWhiteSpace($File1CTelemetryScript)) { '' } else { Join-Path (Split-Path -Parent $File1CTelemetryScript) 'aw-windows-telemetry.exe' } + file1cTelemetryExecutable = Join-Path $toolkitRoot 'aw-windows-telemetry.exe' rulesPath = $RulesPath policyPath = $PolicyPath launchScript = $LaunchScriptPath @@ -998,6 +1013,9 @@ function New-ActivityWatchDeploymentConfig { windowEnabled = $WindowEnabled fileOpsEnabled = $FileOpsEnabled emailEnabled = $false + browserCollectorMode = 'rust_primary' + dlpEndpointMode = 'rust_primary' + fileOpsMode = 'rust_primary' worktimeSessionEnabled = $true worktimeSessionMode = 'powershell_primary' worktimeLegacyFallbackEnabled = $true @@ -1020,6 +1038,7 @@ function New-ActivityWatchDeploymentConfig { hoursBack = $HayabusaAutoUploadHoursBack mode = $HayabusaAutoUploadMode taskName = $HayabusaAutoUploadTaskName + runAsUser = $HayabusaAutoUploadRunAsUser } } analytics = [pscustomobject]@{ @@ -1446,19 +1465,20 @@ function Start-RustCollectorIfNeeded { ) if ([string]::IsNullOrWhiteSpace(`$ExePath) -or [string]::IsNullOrWhiteSpace(`$Subcommand)) { - return + return `$false } if (-not (Test-Path -LiteralPath `$ExePath)) { - return + return `$false } if (Test-RustCollectorRunning -Subcommand `$Subcommand -SessionId `$SessionId) { - return + return `$true } `$argumentList = @(`$Subcommand, '--config-path', `$ConfigPath, '--mode', 'enforce') Start-Process -FilePath `$ExePath -ArgumentList `$argumentList -WindowStyle Hidden + return `$true } `$config = Get-DeploymentConfig -Path `$ConfigPath @@ -1466,6 +1486,9 @@ function Start-RustCollectorIfNeeded { `$installRoot = [string]`$config.paths.installRoot `$stateRoot = [string]`$config.paths.stateRoot `$deployRoot = if (`$config.paths.PSObject.Properties.Name -contains 'deployRoot' -and -not [string]::IsNullOrWhiteSpace([string]`$config.paths.deployRoot)) { [string]`$config.paths.deployRoot } elseif (`$config.paths.PSObject.Properties.Name -contains 'toolkitRoot' -and -not [string]::IsNullOrWhiteSpace([string]`$config.paths.toolkitRoot)) { [string]`$config.paths.toolkitRoot } else { `$installRoot } +if ((Split-Path -Path `$deployRoot -Leaf) -ieq 'bin') { + `$deployRoot = Split-Path -Path `$deployRoot -Parent +} `$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port `$script:Hostname = if (`$config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]`$config.awHostname)) { [string]`$config.awHostname } else { `$env:COMPUTERNAME } `$script:KnownBuckets = @{} @@ -1481,9 +1504,9 @@ function Start-RustCollectorIfNeeded { `$afkEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]`$config.collectors.afkEnabled } else { `$true } `$windowEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]`$config.collectors.windowEnabled } else { `$true } `$fileOpsEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]`$config.collectors.fileOpsEnabled } else { `$true } -`$browserCollectorMode = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'browserCollectorMode') { [string]`$config.collectors.browserCollectorMode } else { 'powershell_primary' } -`$dlpEndpointMode = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'dlpEndpointMode') { [string]`$config.collectors.dlpEndpointMode } else { 'powershell_primary' } -`$fileOpsMode = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'fileOpsMode') { [string]`$config.collectors.fileOpsMode } else { 'powershell_primary' } +`$browserCollectorMode = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'browserCollectorMode') { [string]`$config.collectors.browserCollectorMode } else { 'rust_primary' } +`$dlpEndpointMode = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'dlpEndpointMode') { [string]`$config.collectors.dlpEndpointMode } else { 'rust_primary' } +`$fileOpsMode = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'fileOpsMode') { [string]`$config.collectors.fileOpsMode } else { 'rust_primary' } `$emailEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'emailEnabled') { [bool]`$config.collectors.emailEnabled } else { `$false } `$emailCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'emailCollectorScript') { [string]`$config.paths.emailCollectorScript } else { Join-Path `$stateRoot 'email-outbound-collector.ps1' } `$launchLockPath = New-LaunchLock -StateRoot `$stateRoot -SessionId `$sessionId @@ -1514,18 +1537,24 @@ try { catch { } if (`$browserCollectorMode -ieq 'rust_primary') { - Start-RustCollectorIfNeeded -ExePath `$telemetryExe -Subcommand 'browser-domains-collector' -ConfigPath `$ConfigPath -SessionId `$sessionId + if (-not (Start-RustCollectorIfNeeded -ExePath `$telemetryExe -Subcommand 'browser-domains-collector' -ConfigPath `$ConfigPath -SessionId `$sessionId)) { + Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } } else { Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId } if (`$dlpEndpointMode -ieq 'rust_primary') { - Start-RustCollectorIfNeeded -ExePath `$telemetryExe -Subcommand 'dlp-endpoint-collector' -ConfigPath `$ConfigPath -SessionId `$sessionId + if (-not (Start-RustCollectorIfNeeded -ExePath `$telemetryExe -Subcommand 'dlp-endpoint-collector' -ConfigPath `$ConfigPath -SessionId `$sessionId)) { + Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } } else { Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId } if (`$fileOpsEnabled) { if (`$fileOpsMode -ieq 'rust_primary') { - Start-RustCollectorIfNeeded -ExePath `$telemetryExe -Subcommand 'file-operations-collector' -ConfigPath `$ConfigPath -SessionId `$sessionId + if (-not (Start-RustCollectorIfNeeded -ExePath `$telemetryExe -Subcommand 'file-operations-collector' -ConfigPath `$ConfigPath -SessionId `$sessionId)) { + Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } } else { Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId } @@ -2504,11 +2533,16 @@ function Register-ActivityWatchHayabusaAutoUploadTask { $intervalHours = [Math]::Max(1, [int]$automation.intervalHours) $hoursBack = [Math]::Max(1, [int]$automation.hoursBack) $mode = if ($automation.PSObject.Properties.Name -contains 'mode' -and -not [string]::IsNullOrWhiteSpace([string]$automation.mode)) { [string]$automation.mode } else { 'incident' } + $runAsUser = if ($automation.PSObject.Properties.Name -contains 'runAsUser' -and -not [string]::IsNullOrWhiteSpace([string]$automation.runAsUser)) { [string]$automation.runAsUser } else { '' } $powerShellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $taskCommand = "`"$powerShellExe`" -NoProfile -ExecutionPolicy Bypass -File `"$uploadScript`" -ConfigPath `"$ConfigPath`" -HoursBack $hoursBack -Mode `"$mode`"" Remove-ActivityWatchScheduledTask -TaskName $taskName - & schtasks.exe /Create /TN $taskName /TR $taskCommand /SC HOURLY /MO $intervalHours /ST 00:00 /RU SYSTEM /RL HIGHEST /F | Out-Null + if ($runAsUser) { + & schtasks.exe /Create /TN $taskName /TR $taskCommand /SC HOURLY /MO $intervalHours /ST 00:00 /RU $runAsUser /IT /RL HIGHEST /F | Out-Null + } else { + & schtasks.exe /Create /TN $taskName /TR $taskCommand /SC HOURLY /MO $intervalHours /ST 00:00 /RU SYSTEM /RL HIGHEST /F | Out-Null + } if ($LASTEXITCODE -ne 0) { throw "Не удалось создать scheduled task $taskName через schtasks.exe" } diff --git a/windows/aw-collector-guard.ps1 b/windows/aw-collector-guard.ps1 index b6bac2c..e1bc05c 100644 --- a/windows/aw-collector-guard.ps1 +++ b/windows/aw-collector-guard.ps1 @@ -322,18 +322,18 @@ function Invoke-GuardSelfTest { $oldComputerName = $env:COMPUTERNAME try { - $env:COMPUTERNAME = 'HOST-EXAMPLE' + $env:COMPUTERNAME = 'SHARKON2025' $sessionRecords = @( [pscustomobject]@{ SessionName = 'USER5'; UserName = 'USER5'; SessionId = 2; State = 'Disc'; IsLive = $false }, [pscustomobject]@{ SessionName = 'console'; UserName = ''; SessionId = 1; State = 'Conn'; IsLive = $true } ) $taskDefs = @( - [pscustomobject]@{ taskName = 'ActivityWatch Launch [HOST-EXAMPLE_user5]'; userId = 'HOST-EXAMPLE\user5' } + [pscustomobject]@{ taskName = 'ActivityWatch Launch [SHARKON2025_user5]'; userId = 'SHARKON2025\user5' } ) - if (-not (Test-ActivityWatchUserHasManagedSession -UserId 'HOST-EXAMPLE\user5' -SessionRecords $sessionRecords -IncludeDisconnected)) { + if (-not (Test-ActivityWatchUserHasManagedSession -UserId 'SHARKON2025\user5' -SessionRecords $sessionRecords -IncludeDisconnected)) { throw 'expected disconnected managed session to match task user' } - if (Test-ActivityWatchUserHasManagedSession -UserId 'HOST-EXAMPLE\user5' -SessionRecords $sessionRecords -IncludeLive) { + if (Test-ActivityWatchUserHasManagedSession -UserId 'SHARKON2025\user5' -SessionRecords $sessionRecords -IncludeLive) { throw 'disconnected managed session should not match live-only filter' } $managed = @(Get-ActivityWatchManagedInteractiveSessions -TaskDefinitions $taskDefs -SessionRecords $sessionRecords -IncludeDisconnected) diff --git a/windows/aw-standalone-service.ps1 b/windows/aw-standalone-service.ps1 index 613af91..dc034e7 100644 --- a/windows/aw-standalone-service.ps1 +++ b/windows/aw-standalone-service.ps1 @@ -55,6 +55,41 @@ function Start-CollectorIfNeeded { Write-ServiceLog ("started collector: {0}" -f $ScriptPath) } +function Test-RustCollectorRunning { + param([string]$Subcommand) + if ([string]::IsNullOrWhiteSpace($Subcommand)) { return $false } + return [bool]( + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + $_.Name -ieq 'aw-windows-telemetry.exe' -and + $_.CommandLine -and + $_.CommandLine -match [Regex]::Escape($Subcommand) + } | + Select-Object -First 1 + ) +} + +function Start-RustCollectorIfNeeded { + param( + [string]$ExePath, + [string]$Subcommand, + [string]$ConfigPath + ) + + if ([string]::IsNullOrWhiteSpace($ExePath) -or [string]::IsNullOrWhiteSpace($Subcommand)) { + return $false + } + if (-not (Test-Path -LiteralPath $ExePath)) { + return $false + } + if (Test-RustCollectorRunning -Subcommand $Subcommand) { + return $true + } + Start-Process -FilePath $ExePath -ArgumentList @($Subcommand, '--config-path', $ConfigPath, '--mode', 'enforce') -WindowStyle Hidden | Out-Null + Write-ServiceLog ("started rust collector: {0}" -f $Subcommand) + return $true +} + $cfg = Get-Config -Path $ConfigPath $stateRoot = if ($cfg.paths -and $cfg.paths.stateRoot) { [string]$cfg.paths.stateRoot } else { 'C:\ProgramData\AWatch-rus' } $logsRoot = Join-Path $stateRoot 'logs' @@ -70,6 +105,7 @@ while ($true) { $cfg = Get-Config -Path $ConfigPath $paths = $cfg.paths $collectors = $cfg.collectors + $telemetryExe = if ($paths.PSObject.Properties.Name -contains 'file1cTelemetryExecutable' -and -not [string]::IsNullOrWhiteSpace([string]$paths.file1cTelemetryExecutable)) { [string]$paths.file1cTelemetryExecutable } else { Join-Path $PSScriptRoot 'aw-windows-telemetry.exe' } $isSession0 = ([System.Diagnostics.Process]::GetCurrentProcess().SessionId -eq 0) # In Session 0 (SYSTEM) collectors that depend on interactive desktop/user profile @@ -78,10 +114,14 @@ while ($true) { $startFileOps = $true $startEmail = $true $startWorktime = $true + $dlpEndpointMode = 'rust_primary' + $fileOpsMode = 'rust_primary' if ($collectors) { if ($collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { $startFileOps = [bool]$collectors.fileOpsEnabled } if ($collectors.PSObject.Properties.Name -contains 'emailEnabled') { $startEmail = [bool]$collectors.emailEnabled } if ($collectors.PSObject.Properties.Name -contains 'worktimeSessionEnabled') { $startWorktime = [bool]$collectors.worktimeSessionEnabled } + $dlpEndpointMode = if ($collectors.PSObject.Properties.Name -contains 'dlpEndpointMode') { [string]$collectors.dlpEndpointMode } else { 'rust_primary' } + $fileOpsMode = if ($collectors.PSObject.Properties.Name -contains 'fileOpsMode') { [string]$collectors.fileOpsMode } else { 'rust_primary' } $worktimeSessionMode = if ($collectors.PSObject.Properties.Name -contains 'worktimeSessionMode') { [string]$collectors.worktimeSessionMode } else { 'powershell_primary' } $worktimeLegacyFallbackEnabled = if ($collectors.PSObject.Properties.Name -contains 'worktimeLegacyFallbackEnabled') { [bool]$collectors.worktimeLegacyFallbackEnabled } else { $true } if ($worktimeSessionMode -ieq 'rust_primary') { @@ -98,9 +138,23 @@ while ($true) { if ($startBrowser) { Start-CollectorIfNeeded -ScriptPath ([string]$paths.collectorScript) -ConfigPath $ConfigPath } - Start-CollectorIfNeeded -ScriptPath ([string]$paths.endpointCollectorScript) -ConfigPath $ConfigPath + if ($dlpEndpointMode -ieq 'rust_primary') { + if (-not (Start-RustCollectorIfNeeded -ExePath $telemetryExe -Subcommand 'dlp-endpoint-collector' -ConfigPath $ConfigPath)) { + Start-CollectorIfNeeded -ScriptPath ([string]$paths.endpointCollectorScript) -ConfigPath $ConfigPath + } + } + else { + Start-CollectorIfNeeded -ScriptPath ([string]$paths.endpointCollectorScript) -ConfigPath $ConfigPath + } if ($startFileOps) { - Start-CollectorIfNeeded -ScriptPath ([string]$paths.fileCollectorScript) -ConfigPath $ConfigPath + if ($fileOpsMode -ieq 'rust_primary') { + if (-not (Start-RustCollectorIfNeeded -ExePath $telemetryExe -Subcommand 'file-operations-collector' -ConfigPath $ConfigPath)) { + Start-CollectorIfNeeded -ScriptPath ([string]$paths.fileCollectorScript) -ConfigPath $ConfigPath + } + } + else { + Start-CollectorIfNeeded -ScriptPath ([string]$paths.fileCollectorScript) -ConfigPath $ConfigPath + } } if ($paths.PSObject.Properties.Name -contains 'emailCollectorScript') { if ($startEmail) { diff --git a/windows/deploy-domain-users.ps1 b/windows/deploy-domain-users.ps1 index 76f26a9..ba9cc3c 100755 --- a/windows/deploy-domain-users.ps1 +++ b/windows/deploy-domain-users.ps1 @@ -45,6 +45,7 @@ param( [int]$HayabusaAutoUploadHoursBack = 6, [string]$HayabusaAutoUploadMode = 'incident', [string]$HayabusaAutoUploadTaskName = 'ActivityWatch Hayabusa Upload', + [string]$HayabusaAutoUploadRunAsUser, [bool]$File1CAutoUploadEnabled = $true, [int]$File1CAutoUploadIntervalHours = 6, [int]$File1CAutoUploadIntervalMinutes = 15, @@ -157,6 +158,7 @@ $config = New-ActivityWatchDeploymentConfig ` -HayabusaAutoUploadHoursBack $HayabusaAutoUploadHoursBack ` -HayabusaAutoUploadMode $HayabusaAutoUploadMode ` -HayabusaAutoUploadTaskName $HayabusaAutoUploadTaskName ` + -HayabusaAutoUploadRunAsUser $HayabusaAutoUploadRunAsUser ` -File1CAutoUploadEnabled $File1CAutoUploadEnabled ` -File1CAutoUploadIntervalHours $File1CAutoUploadIntervalHours ` -File1CAutoUploadIntervalMinutes $File1CAutoUploadIntervalMinutes ` diff --git a/windows/deploy-ensemble.ps1 b/windows/deploy-ensemble.ps1 index 888ecbc..979464c 100644 --- a/windows/deploy-ensemble.ps1 +++ b/windows/deploy-ensemble.ps1 @@ -46,6 +46,7 @@ param( [int]$HayabusaAutoUploadHoursBack = 6, [string]$HayabusaAutoUploadMode = 'incident', [string]$HayabusaAutoUploadTaskName = 'ActivityWatch Hayabusa Upload', + [string]$HayabusaAutoUploadRunAsUser, [bool]$File1CAutoUploadEnabled = $true, [int]$File1CAutoUploadIntervalHours = 6, [int]$File1CAutoUploadIntervalMinutes = 15, @@ -118,6 +119,7 @@ if (-not (Test-Path -LiteralPath $deployScript)) { -HayabusaAutoUploadHoursBack $HayabusaAutoUploadHoursBack ` -HayabusaAutoUploadMode $HayabusaAutoUploadMode ` -HayabusaAutoUploadTaskName $HayabusaAutoUploadTaskName ` + -HayabusaAutoUploadRunAsUser $HayabusaAutoUploadRunAsUser ` -File1CAutoUploadEnabled $File1CAutoUploadEnabled ` -File1CAutoUploadIntervalHours $File1CAutoUploadIntervalHours ` -File1CAutoUploadIntervalMinutes $File1CAutoUploadIntervalMinutes ` diff --git a/windows/export-upload-hayabusa-to-aw-server.ps1 b/windows/export-upload-hayabusa-to-aw-server.ps1 index 905d386..758dcdc 100644 --- a/windows/export-upload-hayabusa-to-aw-server.ps1 +++ b/windows/export-upload-hayabusa-to-aw-server.ps1 @@ -16,6 +16,25 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +$LogDir = Join-Path (Split-Path -Parent $ConfigPath) 'logs' +$LogPath = Join-Path $LogDir 'hayabusa-upload.log' +New-Item -ItemType Directory -Path $LogDir -Force | Out-Null + +function Write-RunLog { + param( + [Parameter(Mandatory = $true)] + [string]$Message + ) + + $line = '{0} {1}' -f ([DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')), $Message + Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 +} + +trap { + Write-RunLog ("ERROR: " + ($_ | Out-String).Trim()) + exit 1 +} + function New-TemporarySshKeyCopy { param( [Parameter(Mandatory = $true)] @@ -27,9 +46,20 @@ function New-TemporarySshKeyCopy { $tempKeyPath = Join-Path $tempDir 'awops_ed25519' Copy-Item -LiteralPath $SourceKeyPath -Destination $tempKeyPath -Force + $currentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $grantPrincipals = @( + ('*' + $currentIdentity.User.Value), + '*S-1-5-18', + '*S-1-5-32-544' + ) | + Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | + Select-Object -Unique + & icacls.exe $tempKeyPath /inheritance:r | Out-Null - & icacls.exe $tempKeyPath /grant:r "$($env:USERNAME):(F)" | Out-Null - & icacls.exe $tempKeyPath /remove:g 'Users' 'Authenticated Users' 'Everyone' 'BUILTIN\Users' 'BUILTIN\Administrators' 'NT AUTHORITY\SYSTEM' 2>$null | Out-Null + foreach ($principal in $grantPrincipals) { + & icacls.exe $tempKeyPath /grant:r "$principal`:(F)" | Out-Null + } + & icacls.exe $tempKeyPath /remove:g 'Users' 'Authenticated Users' 'Everyone' 'BUILTIN\Users' 2>$null | Out-Null return $tempKeyPath } @@ -42,6 +72,8 @@ if (-not (Test-Path -LiteralPath $RemoteKeyPath)) { throw "SSH private key not found: $RemoteKeyPath" } +Write-RunLog ("start hoursBack={0} daysBack={1} mode={2} serverHost={3} runRemote={4}" -f $HoursBack, $DaysBack, $Mode, $ServerHost, [bool]$RunRemote) + $config = Get-Content -Raw -LiteralPath $ConfigPath | ConvertFrom-Json if ([string]::IsNullOrWhiteSpace($ServerHost)) { $ServerHost = [string]$config.server.host @@ -81,7 +113,8 @@ try { if ($null -ne $CaseId) { $meta.case_id = [int]$CaseId } - $meta | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $metaPath -Encoding UTF8 + $metaJson = $meta | ConvertTo-Json -Depth 6 + [System.IO.File]::WriteAllText($metaPath, $metaJson, [System.Text.UTF8Encoding]::new($false)) & scp.exe -i $effectiveKeyPath -o StrictHostKeyChecking=no -o UserKnownHostsFile=NUL $metaPath $remoteTarget if ($LASTEXITCODE -ne 0) { throw "scp meta upload failed with rc=$LASTEXITCODE" @@ -97,6 +130,7 @@ try { if ($LASTEXITCODE -ne 0) { throw "scp upload failed with rc=$LASTEXITCODE" } + Write-RunLog ("upload complete zip={0} remote={1}" -f $zipPath, $remoteTarget) } finally { Remove-Item -LiteralPath $effectiveKeyPath -Force -ErrorAction SilentlyContinue diff --git a/windows/hardening-recovery.ps1 b/windows/hardening-recovery.ps1 index b810d08..a3edad8 100755 --- a/windows/hardening-recovery.ps1 +++ b/windows/hardening-recovery.ps1 @@ -66,9 +66,6 @@ $effectiveLogsRoot = if ($existingConfig) { [string]$existingConfig.paths.logsRo $effectiveConfigPath = if ($ConfigPath) { $ConfigPath } else { Join-Path $effectiveStateRoot 'deployment-config.json' } $effectiveLaunchScript = Join-Path $effectiveStateRoot 'launch-watchers.ps1' $effectiveRecoveryScript = Join-Path $effectiveStateRoot 'recovery-loop.ps1' -$effectiveCollector = Join-Path $effectiveStateRoot 'browser-domains-native-collector.ps1' -$effectiveEndpointCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$existingConfig.paths.endpointCollectorScript } else { Join-Path $effectiveStateRoot 'dlp-endpoint-signals-collector.ps1' } -$effectiveFileCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]$existingConfig.paths.fileCollectorScript } else { Join-Path $effectiveStateRoot 'file-operations-collector.ps1' } $effectiveSessionCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$existingConfig.paths.sessionCollectorScript } else { Join-Path $effectiveStateRoot 'worktime-session-collector.ps1' } $effectiveEvtxExportScript = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'evtxExportScript') { [string]$existingConfig.paths.evtxExportScript } else { Join-Path $effectiveStateRoot 'export-evtx-for-hayabusa.ps1' } $effectiveHayabusaUploadScript = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'hayabusaUploadScript') { [string]$existingConfig.paths.hayabusaUploadScript } else { Join-Path $effectiveStateRoot 'export-upload-hayabusa-to-aw-server.ps1' } @@ -76,6 +73,19 @@ $effectiveFile1CTelemetryScript = if ($existingConfig -and $existingConfig.paths $effectiveRules = Join-Path $effectiveStateRoot 'web-category-rules.json' $effectivePolicy = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$existingConfig.paths.policyPath } else { Join-Path $effectiveStateRoot 'dlp-policy.json' } $effectivePolicyClientScript = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'policyClientScript') { [string]$existingConfig.paths.policyClientScript } else { Join-Path $effectiveStateRoot 'dlp-policy-client.ps1' } +$effectiveTelemetryExecutable = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'file1cTelemetryExecutable' -and -not [string]::IsNullOrWhiteSpace([string]$existingConfig.paths.file1cTelemetryExecutable)) { [string]$existingConfig.paths.file1cTelemetryExecutable } else { Join-Path $PSScriptRoot 'aw-windows-telemetry.exe' } + +function Resolve-OptionalExistingPath { + param([AllowEmptyString()][string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return '' + } + if (Test-Path -LiteralPath $Path) { + return $Path + } + return '' +} $effectiveServerHost = if ($ServerHost) { $ServerHost } elseif ($existingConfig) { [string]$existingConfig.server.host } else { $null } $effectiveServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($existingConfig) { [int]$existingConfig.server.port } else { 5600 } @@ -109,6 +119,7 @@ $effectiveHayabusaAutoUploadIntervalHours = if ($existingConfig -and $existingCo $effectiveHayabusaAutoUploadHoursBack = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'hayabusaAutomation' -and $existingConfig.forensics.hayabusaAutomation.PSObject.Properties.Name -contains 'hoursBack') { [int]$existingConfig.forensics.hayabusaAutomation.hoursBack } else { 6 } $effectiveHayabusaAutoUploadMode = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'hayabusaAutomation' -and $existingConfig.forensics.hayabusaAutomation.PSObject.Properties.Name -contains 'mode') { [string]$existingConfig.forensics.hayabusaAutomation.mode } else { 'incident' } $effectiveHayabusaAutoUploadTaskName = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'hayabusaAutomation' -and $existingConfig.forensics.hayabusaAutomation.PSObject.Properties.Name -contains 'taskName') { [string]$existingConfig.forensics.hayabusaAutomation.taskName } else { 'ActivityWatch Hayabusa Upload' } +$effectiveHayabusaAutoUploadRunAsUser = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'hayabusaAutomation' -and $existingConfig.forensics.hayabusaAutomation.PSObject.Properties.Name -contains 'runAsUser') { [string]$existingConfig.forensics.hayabusaAutomation.runAsUser } else { '' } $effectiveFile1CAutoUploadEnabled = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'analytics' -and $existingConfig.analytics.PSObject.Properties.Name -contains 'file1cAutomation' -and $existingConfig.analytics.file1cAutomation.PSObject.Properties.Name -contains 'enabled') { [bool]$existingConfig.analytics.file1cAutomation.enabled } else { $true } $effectiveFile1CAutoUploadIntervalHours = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'analytics' -and $existingConfig.analytics.PSObject.Properties.Name -contains 'file1cAutomation' -and $existingConfig.analytics.file1cAutomation.PSObject.Properties.Name -contains 'intervalHours') { [int]$existingConfig.analytics.file1cAutomation.intervalHours } else { 6 } $effectiveFile1CAutoUploadIntervalMinutes = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'analytics' -and $existingConfig.analytics.PSObject.Properties.Name -contains 'file1cAutomation' -and $existingConfig.analytics.file1cAutomation.PSObject.Properties.Name -contains 'intervalMinutes') { [int]$existingConfig.analytics.file1cAutomation.intervalMinutes } else { [Math]::Max(1, $effectiveFile1CAutoUploadIntervalHours) * 60 } @@ -116,6 +127,9 @@ $effectiveFile1CAutoUploadTaskName = if ($existingConfig -and $existingConfig.PS $effectiveFile1CAutoUploadRunAsUser = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'analytics' -and $existingConfig.analytics.PSObject.Properties.Name -contains 'file1cAutomation' -and $existingConfig.analytics.file1cAutomation.PSObject.Properties.Name -contains 'runAsUser') { [string]$existingConfig.analytics.file1cAutomation.runAsUser } else { '' } $effectiveFile1CTargetHost = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'analytics' -and $existingConfig.analytics.PSObject.Properties.Name -contains 'file1cAutomation' -and $existingConfig.analytics.file1cAutomation.PSObject.Properties.Name -contains 'targetHost') { [string]$existingConfig.analytics.file1cAutomation.targetHost } else { '' } $effectiveFile1CTargetUser = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'analytics' -and $existingConfig.analytics.PSObject.Properties.Name -contains 'file1cAutomation' -and $existingConfig.analytics.file1cAutomation.PSObject.Properties.Name -contains 'targetUser') { [string]$existingConfig.analytics.file1cAutomation.targetUser } else { 'igor' } +$effectiveBrowserCollectorMode = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'collectors' -and $existingConfig.collectors.PSObject.Properties.Name -contains 'browserCollectorMode') { [string]$existingConfig.collectors.browserCollectorMode } else { 'rust_primary' } +$effectiveDlpEndpointMode = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'collectors' -and $existingConfig.collectors.PSObject.Properties.Name -contains 'dlpEndpointMode') { [string]$existingConfig.collectors.dlpEndpointMode } else { 'rust_primary' } +$effectiveFileOpsMode = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'collectors' -and $existingConfig.collectors.PSObject.Properties.Name -contains 'fileOpsMode') { [string]$existingConfig.collectors.fileOpsMode } else { 'rust_primary' } if ([string]::IsNullOrWhiteSpace($effectiveFile1CTargetHost)) { $file1cLogPath = Join-Path $effectiveLogsRoot 'file1c-telemetry.log' @@ -170,6 +184,10 @@ $assetResult = Copy-ActivityWatchCollectorAssets ` -CustomRulesSource $CustomRulesPath ` -CustomPolicySource $CustomPolicyPath +$effectiveCollector = if (-not [string]::IsNullOrWhiteSpace([string]$assetResult.CollectorScript)) { [string]$assetResult.CollectorScript } elseif ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'collectorScript') { Resolve-OptionalExistingPath -Path ([string]$existingConfig.paths.collectorScript) } else { '' } +$effectiveEndpointCollector = if (-not [string]::IsNullOrWhiteSpace([string]$assetResult.EndpointCollectorScript)) { [string]$assetResult.EndpointCollectorScript } elseif ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { Resolve-OptionalExistingPath -Path ([string]$existingConfig.paths.endpointCollectorScript) } else { '' } +$effectiveFileCollector = if (-not [string]::IsNullOrWhiteSpace([string]$assetResult.FileCollectorScript)) { [string]$assetResult.FileCollectorScript } elseif ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { Resolve-OptionalExistingPath -Path ([string]$existingConfig.paths.fileCollectorScript) } else { '' } + $taskDefinitions = New-ActivityWatchUserTaskDefinitions -Users $effectiveUsers Write-ActivityWatchLaunchScript -Path $effectiveLaunchScript -ConfigPath $effectiveConfigPath Write-ActivityWatchRecoveryScript -Path $effectiveRecoveryScript -ConfigPath $effectiveConfigPath @@ -220,6 +238,7 @@ $config = New-ActivityWatchDeploymentConfig ` -HayabusaAutoUploadHoursBack $effectiveHayabusaAutoUploadHoursBack ` -HayabusaAutoUploadMode $effectiveHayabusaAutoUploadMode ` -HayabusaAutoUploadTaskName $effectiveHayabusaAutoUploadTaskName ` + -HayabusaAutoUploadRunAsUser $effectiveHayabusaAutoUploadRunAsUser ` -File1CAutoUploadEnabled $effectiveFile1CAutoUploadEnabled ` -File1CAutoUploadIntervalHours $effectiveFile1CAutoUploadIntervalHours ` -File1CAutoUploadIntervalMinutes $effectiveFile1CAutoUploadIntervalMinutes ` @@ -232,6 +251,11 @@ $config = New-ActivityWatchDeploymentConfig ` -UserTasks $taskDefinitions ` -PackageVersion $effectiveVersion +$config.paths.file1cTelemetryExecutable = $effectiveTelemetryExecutable +$config.collectors.browserCollectorMode = $effectiveBrowserCollectorMode +$config.collectors.dlpEndpointMode = $effectiveDlpEndpointMode +$config.collectors.fileOpsMode = $effectiveFileOpsMode + Write-ActivityWatchDeploymentConfig -Config $config -Path $effectiveConfigPath Remove-LegacyActivityWatchEntries Set-ActivityWatchAcl -InstallRoot $effectiveInstallRoot -StateRoot $effectiveStateRoot -LogsRoot $effectiveLogsRoot diff --git a/windows/install-collector-guard-service.ps1 b/windows/install-collector-guard-service.ps1 index b6eb8e7..1c6dcb7 100644 --- a/windows/install-collector-guard-service.ps1 +++ b/windows/install-collector-guard-service.ps1 @@ -25,8 +25,8 @@ $guardScriptPath = Join-Path $PSScriptRoot 'aw-collector-guard.ps1' $rustTelemetryPath = Join-Path $PSScriptRoot 'aw-windows-telemetry.exe' $serviceSourcePath = Join-Path $PSScriptRoot 'AWatchRusCollectorGuardService.cs' $serviceExePath = Join-Path $PSScriptRoot 'AWatchRusCollectorGuardService.exe' -if (-not (Test-Path -LiteralPath $guardScriptPath)) { - throw "Collector guard script not found: $guardScriptPath" +if (-not (Test-Path -LiteralPath $rustTelemetryPath) -and -not (Test-Path -LiteralPath $guardScriptPath)) { + throw "Neither Rust collector guard nor PowerShell fallback was found: $rustTelemetryPath ; $guardScriptPath" } if (-not (Test-Path -LiteralPath $serviceSourcePath)) { throw "Collector guard service source not found: $serviceSourcePath" @@ -70,6 +70,9 @@ if (Test-Path -LiteralPath $rustTelemetryPath) { $binPath = "`"$serviceExePath`" --service-name `"$ServiceName`" --exec `"$rustTelemetryPath`" --args `"$rustArgs`" --log `"$serviceLogPath`"" } else { + if (-not (Test-Path -LiteralPath $guardScriptPath)) { + throw "Collector guard script not found: $guardScriptPath" + } $binPath = "`"$serviceExePath`" --service-name `"$ServiceName`" --script `"$guardScriptPath`" --config `"$ConfigPath`" --mode $Mode --loop $LoopSeconds --log `"$serviceLogPath`"" } diff --git a/windows/install-standalone-service.ps1 b/windows/install-standalone-service.ps1 index 3ec6c50..073c83d 100644 --- a/windows/install-standalone-service.ps1 +++ b/windows/install-standalone-service.ps1 @@ -29,6 +29,20 @@ function Ensure-Dir { } } +function Copy-IfExists { + param( + [Parameter(Mandatory = $true)] + [string]$Source, + [Parameter(Mandatory = $true)] + [string]$Destination + ) + if (Test-Path -LiteralPath $Source) { + Copy-Item -LiteralPath $Source -Destination $Destination -Force + return $Destination + } + return '' +} + Assert-Admin $logsRoot = Join-Path $StateRoot 'logs' @@ -40,14 +54,15 @@ $endpointCollectorScript = Join-Path $StateRoot 'dlp-endpoint-signals-collector. $fileCollectorScript = Join-Path $StateRoot 'file-operations-collector.ps1' $emailCollectorScript = Join-Path $StateRoot 'email-outbound-collector.ps1' $sessionCollectorScript = Join-Path $StateRoot 'worktime-session-collector.ps1' +$telemetryExecutable = Join-Path $PSScriptRoot 'aw-windows-telemetry.exe' $rulesPath = Join-Path $StateRoot 'web-category-rules.json' $policyPath = Join-Path $StateRoot 'dlp-policy.json' $configPath = Join-Path $StateRoot 'deployment-config.json' $serviceScriptPath = Join-Path $PSScriptRoot 'aw-standalone-service.ps1' -Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1') -Destination $collectorScript -Force -Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1') -Destination $endpointCollectorScript -Force -Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'file-operations-collector.ps1') -Destination $fileCollectorScript -Force +$collectorScript = Copy-IfExists -Source (Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1') -Destination $collectorScript +$endpointCollectorScript = Copy-IfExists -Source (Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1') -Destination $endpointCollectorScript +$fileCollectorScript = Copy-IfExists -Source (Join-Path $PSScriptRoot 'file-operations-collector.ps1') -Destination $fileCollectorScript if (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'email-outbound-collector.ps1')) { Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'email-outbound-collector.ps1') -Destination $emailCollectorScript -Force } @@ -82,6 +97,7 @@ $config = [pscustomobject]@{ fileCollectorScript = $fileCollectorScript emailCollectorScript = $emailCollectorScript sessionCollectorScript = $sessionCollectorScript + file1cTelemetryExecutable = $telemetryExecutable rulesPath = $rulesPath policyPath = $policyPath } @@ -94,6 +110,9 @@ $config = [pscustomobject]@{ windowEnabled = $false fileOpsEnabled = $true emailEnabled = $true + browserCollectorMode = 'rust_primary' + dlpEndpointMode = 'rust_primary' + fileOpsMode = 'rust_primary' worktimeSessionEnabled = $true worktimeSessionMode = 'powershell_primary' worktimeLegacyFallbackEnabled = $true diff --git a/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss b/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss index 3d0bc50..720d69b 100644 --- a/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss +++ b/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss @@ -5,7 +5,7 @@ #define AwDefaultServerHost "aw-server" #define AwDefaultServerPort "5600" #define AwDefaultWorktimeReportBase "http://aw-server:5610" -#define AwDefaultWorktimeHost "HOST-EXAMPLE" +#define AwDefaultWorktimeHost "SHARKON2025" #define AwDefaultUsers "user1,user2,user3,user4,user5" #define AwDefaultInstallRoot "C:\\Program Files\\AWatch-rus\\bin" #define AwDefaultStateRoot "C:\\ProgramData\\AWatch-rus" @@ -13,7 +13,7 @@ ; This installer wraps the standalone-service path. ; It is suitable for standalone/headless deployment and must not be treated -; as the canonical multi-user RDP deployment path used on HOST-EXAMPLE. +; as the canonical multi-user RDP deployment path used on SHARKON2025. [Setup] AppId={{6D6A1F74-0F4F-4A57-B5E3-1C2C2F56C0E9} @@ -45,16 +45,12 @@ Source: "..\..\deploy-single-user.ps1"; DestDir: "{app}\windows"; Flags: ignorev Source: "..\..\deploy-domain-users.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\deploy-ensemble.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\AWatchRusCollectorGuardService.cs"; DestDir: "{app}\windows"; Flags: ignoreversion -Source: "..\..\aw-collector-guard.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\install-collector-guard-service.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\..\adk-rust\target\x86_64-pc-windows-gnu\release\aw-windows-telemetry.exe"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\hardening-recovery.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\validate-deployment.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\migrate-awatch-rus-paths.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\worktime-session-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion -Source: "..\..\browser-domains-native-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion -Source: "..\..\dlp-endpoint-signals-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion -Source: "..\..\file-operations-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\email-outbound-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\web-category-rules.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\dlp-policy.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion diff --git a/windows/migrate-awatch-rus-paths.ps1 b/windows/migrate-awatch-rus-paths.ps1 index c588050..877d83c 100644 --- a/windows/migrate-awatch-rus-paths.ps1 +++ b/windows/migrate-awatch-rus-paths.ps1 @@ -91,12 +91,48 @@ function Update-AWatchConfigPaths { [pscustomobject]$Config ) + function Resolve-OptionalCollectorRuntimePath { + param([Parameter(Mandatory = $true)][string]$FileName) + + $stateCandidate = Join-Path $NewStateRoot $FileName + if (Test-Path -LiteralPath $stateCandidate) { + return $stateCandidate + } + + if (Test-Path -LiteralPath (Join-Path $ToolkitRoot $FileName)) { + return $stateCandidate + } + + return '' + } + + function Set-ConfigPathValue { + param( + [Parameter(Mandatory = $true)][string]$Name, + [AllowEmptyString()][string]$Value + ) + + if ($Config.paths.PSObject.Properties.Name -contains $Name) { + $Config.paths.PSObject.Properties[$Name].Value = $Value + } + else { + $Config.paths | Add-Member -NotePropertyName $Name -NotePropertyValue $Value + } + } + $logsRoot = Join-Path $NewStateRoot 'logs' $Config.paths.installRoot = $NewInstallRoot $Config.paths.stateRoot = $NewStateRoot $Config.paths.logsRoot = $logsRoot - $Config.paths.collectorScript = Join-Path $NewStateRoot 'browser-domains-native-collector.ps1' - $Config.paths.endpointCollectorScript = Join-Path $NewStateRoot 'dlp-endpoint-signals-collector.ps1' + Set-ConfigPathValue -Name 'collectorScript' -Value (Resolve-OptionalCollectorRuntimePath -FileName 'browser-domains-native-collector.ps1') + Set-ConfigPathValue -Name 'endpointCollectorScript' -Value (Resolve-OptionalCollectorRuntimePath -FileName 'dlp-endpoint-signals-collector.ps1') + Set-ConfigPathValue -Name 'fileCollectorScript' -Value (Resolve-OptionalCollectorRuntimePath -FileName 'file-operations-collector.ps1') + if ($Config.paths.PSObject.Properties.Name -contains 'file1cTelemetryExecutable') { + $Config.paths.file1cTelemetryExecutable = Join-Path $ToolkitRoot 'aw-windows-telemetry.exe' + } + else { + $Config.paths | Add-Member -NotePropertyName 'file1cTelemetryExecutable' -NotePropertyValue (Join-Path $ToolkitRoot 'aw-windows-telemetry.exe') + } if ($Config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { $Config.paths.sessionCollectorScript = Join-Path $NewStateRoot 'worktime-session-collector.ps1' } @@ -104,6 +140,20 @@ function Update-AWatchConfigPaths { if ($Config.paths.PSObject.Properties.Name -contains 'policyPath') { $Config.paths.policyPath = Join-Path $NewStateRoot 'dlp-policy.json' } + if ($Config.PSObject.Properties.Name -contains 'collectors') { + foreach ($entry in @( + @{ Name = 'browserCollectorMode'; Value = 'rust_primary' }, + @{ Name = 'dlpEndpointMode'; Value = 'rust_primary' }, + @{ Name = 'fileOpsMode'; Value = 'rust_primary' } + )) { + if ($Config.collectors.PSObject.Properties.Name -contains $entry.Name) { + $Config.collectors.PSObject.Properties[$entry.Name].Value = $entry.Value + } + else { + $Config.collectors | Add-Member -NotePropertyName $entry.Name -NotePropertyValue $entry.Value + } + } + } $Config.paths.launchScript = Join-Path $NewStateRoot 'launch-watchers.ps1' $Config.paths.recoveryScript = Join-Path $NewStateRoot 'recovery-loop.ps1' @@ -194,7 +244,9 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Миграция ActivityWatch W foreach ($file in @( 'browser-domains-native-collector.ps1', 'dlp-endpoint-signals-collector.ps1', + 'file-operations-collector.ps1', 'worktime-session-collector.ps1', + 'aw-windows-telemetry.exe', 'web-category-rules.example.json', 'dlp-policy.example.json' )) { diff --git a/windows/run-user1-probe.ps1 b/windows/run-user1-probe.ps1 index 3ef5fbb..641250a 100644 --- a/windows/run-user1-probe.ps1 +++ b/windows/run-user1-probe.ps1 @@ -1,6 +1,6 @@ [CmdletBinding()] param( - [string]$UserId = 'HOST-EXAMPLE\user1' + [string]$UserId = 'SHARKON2025\user1' ) Set-StrictMode -Version Latest @@ -13,7 +13,7 @@ Start-Sleep -Seconds 10 Get-Process notepad -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue '@ | Set-Content -LiteralPath $probeScriptPath -Encoding UTF8 -schtasks /Run /TN 'ActivityWatch Launch [HOST-EXAMPLE_user1]' | Out-Null +schtasks /Run /TN 'ActivityWatch Launch [SHARKON2025_user1]' | Out-Null Start-Sleep -Seconds 3 $taskName = 'AW User1 Notepad Probe' diff --git a/windows/validate-deployment.ps1 b/windows/validate-deployment.ps1 index bdfcb61..9f5f96b 100644 --- a/windows/validate-deployment.ps1 +++ b/windows/validate-deployment.ps1 @@ -16,6 +16,7 @@ $collectorScript = [string]$config.paths.collectorScript $endpointCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$config.paths.endpointCollectorScript } else { Join-Path $stateRoot 'dlp-endpoint-signals-collector.ps1' } $fileCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]$config.paths.fileCollectorScript } else { Join-Path $stateRoot 'file-operations-collector.ps1' } $sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' } +$telemetryExecutable = if ($config.paths.PSObject.Properties.Name -contains 'file1cTelemetryExecutable' -and -not [string]::IsNullOrWhiteSpace([string]$config.paths.file1cTelemetryExecutable)) { [string]$config.paths.file1cTelemetryExecutable } else { Join-Path (Join-Path ([System.Environment]::GetFolderPath('ProgramFiles')) 'AWatch-rus\windows') 'aw-windows-telemetry.exe' } $evtxExportScript = if ($config.paths.PSObject.Properties.Name -contains 'evtxExportScript') { [string]$config.paths.evtxExportScript } else { Join-Path $stateRoot 'export-evtx-for-hayabusa.ps1' } $rulesPath = [string]$config.paths.rulesPath $policyPath = if ($config.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$config.paths.policyPath } else { Join-Path $stateRoot 'dlp-policy.json' } @@ -36,6 +37,9 @@ $queueMaxDepth = 1000 $afkExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true } $windowExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$config.collectors.windowEnabled } else { $true } $fileOpsExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]$config.collectors.fileOpsEnabled } else { $true } +$browserCollectorMode = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'browserCollectorMode') { [string]$config.collectors.browserCollectorMode } else { 'rust_primary' } +$dlpEndpointMode = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'dlpEndpointMode') { [string]$config.collectors.dlpEndpointMode } else { 'rust_primary' } +$fileOpsMode = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'fileOpsMode') { [string]$config.collectors.fileOpsMode } else { 'rust_primary' } $sessionEventsConfig = if ($config.PSObject.Properties.Name -contains 'sessionEvents') { $config.sessionEvents } else { $null } $sessionLogonEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$sessionEventsConfig.logonEnabled } else { $false } $sessionProcessEventsEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$sessionEventsConfig.processEventsEnabled } else { $false } @@ -119,9 +123,14 @@ function Test-UserHasSession { function Get-CollectorProcesses { param( [Parameter(Mandatory = $true)] + [AllowEmptyString()] [string]$ScriptPath ) + if ([string]::IsNullOrWhiteSpace($ScriptPath)) { + return @() + } + return @( Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { @@ -438,8 +447,6 @@ function Get-TaskSnapshot { } $requiredFiles = @( - $collectorScript, - $endpointCollectorScript, $sessionCollectorScript, $evtxExportScript, $rulesPath, @@ -449,7 +456,16 @@ $requiredFiles = @( $recoveryScript, $ConfigPath ) -if ($fileOpsExpected) { +if ($browserCollectorMode -ieq 'rust_primary' -or $dlpEndpointMode -ieq 'rust_primary' -or ($fileOpsExpected -and $fileOpsMode -ieq 'rust_primary')) { + $requiredFiles += $telemetryExecutable +} +if ($browserCollectorMode -ine 'rust_primary') { + $requiredFiles += $collectorScript +} +if ($dlpEndpointMode -ine 'rust_primary') { + $requiredFiles += $endpointCollectorScript +} +if ($fileOpsExpected -and $fileOpsMode -ine 'rust_primary') { $requiredFiles += $fileCollectorScript } if ($afkExpected) { @@ -477,6 +493,15 @@ $sessionCollectorProcesses = @(Get-CollectorProcesses -ScriptPath $sessionCollec $endpointCollectorProcesses = @(Get-CollectorProcesses -ScriptPath $endpointCollectorScript) $fileCollectorProcesses = if ($fileOpsExpected) { @(Get-CollectorProcesses -ScriptPath $fileCollectorScript) } else { @() } $browserCollectorProcesses = @(Get-CollectorProcesses -ScriptPath $collectorScript) +$rustCollectorProcesses = @( + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + $_.Name -ieq 'aw-windows-telemetry.exe' -and + $_.CommandLine -and + ($_.CommandLine -match 'browser-domains-collector' -or $_.CommandLine -match 'dlp-endpoint-collector' -or $_.CommandLine -match 'file-operations-collector') + } | + Select-Object @{ Name = 'Name'; Expression = { $_.Name } }, @{ Name = 'Id'; Expression = { [int]$_.ProcessId } }, @{ Name = 'SessionId'; Expression = { [int]$_.SessionId } }, @{ Name = 'CommandLine'; Expression = { [string]$_.CommandLine } } +) $liveLoggedOnUsers = Get-LoggedOnUsers $interactiveUsers = Get-LoggedOnUsers -IncludeDisconnected $true @@ -611,6 +636,12 @@ $result = [ordered]@{ endpointCollectorDuplicates = @($endpointCollectorDuplicates) fileCollectors = @($fileCollectorProcesses) fileCollectorDuplicates = @($fileCollectorDuplicates) + rustCollectors = @($rustCollectorProcesses) + collectorModes = [ordered]@{ + browser = $browserCollectorMode + endpoint = $dlpEndpointMode + fileOps = $fileOpsMode + } ok = [bool]( $watcherCountsOk -and $sessionCollectorOk -and