fix(ops): sync verified production baseline for AW-Rus DLP

This commit is contained in:
igor04091968
2026-05-13 22:50:42 +03:00
parent 22aadd5c03
commit 91c3b46f16
20 changed files with 661 additions and 127 deletions
+12 -1
View File
@@ -171,13 +171,24 @@ Playbook:
- `telegram_allowed_chat_ids`
- `tsj_bot_source_local_path`
3. Убедитесь, что в inventory есть группа `[proxmox]`.
4. Запустите:
Для текущего контура AW-Rus bot ожидает Proxmox host `10.10.10.2`.
Рабочая модель для этого контура: `igor` + `sudo`, а не обязательный `root` login.
4. При необходимости задайте recovery-команды для AW-Rus:
- `tsj_bot_aw_rus_worktime_heal_cmd`
- `tsj_bot_aw_rus_dlp_heal_cmd`
5. Запустите:
```bash
cd ansible
ansible-playbook -i inventory.ini deploy_tsj_guardian_bot_proxmox.yml
```
После актуального production hardening:
- bot различает `worktime idle` и реальную деградацию;
- bot поддерживает отдельный `AW_RUS_DLP_HEAL_CMD`;
- redeploy не должен терять runtime env-ключи, связанные с proxy, FS checks и AI escalation.
## Результат
- Установлен ActivityWatch Server.
+9
View File
@@ -378,6 +378,15 @@
mode: "0644"
when: aw_dlp_content_analysis_enabled | default(true) | bool
- name: Установить wrapper запуска DLP content analysis через virtualenv
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/dlp-content-analysis/aw-dlp-content-analyzer.sh"
dest: /usr/local/bin/aw-dlp-content-analyzer
owner: root
group: root
mode: "0755"
when: aw_dlp_content_analysis_enabled | default(true) | bool
- name: Создать virtualenv DLP content analysis
ansible.builtin.command:
cmd: python3 -m venv /opt/activitywatch/dlp-content-analysis/.venv
+49 -7
View File
@@ -259,16 +259,58 @@
try {
Enable-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" -ErrorAction SilentlyContinue | Out-Null
} catch {}
Get-ScheduledTask |
Where-Object TaskName -like "{{ aw_windows_launch_task_pattern }}" |
ForEach-Object {
try { Enable-ScheduledTask -TaskName $_.TaskName -ErrorAction SilentlyContinue | Out-Null } catch {}
$config = Get-Content -Raw -LiteralPath "{{ aw_windows_state_root }}\deployment-config.json" | ConvertFrom-Json
$loggedOnUsers = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
try {
foreach ($line in @(& quser.exe 2>$null)) {
$normalized = [string]$line
if ([string]::IsNullOrWhiteSpace($normalized)) { continue }
$normalized = $normalized.TrimStart(' ', '>')
if ([string]::IsNullOrWhiteSpace($normalized)) { continue }
if ($normalized -match '^(USERNAME|ПОЛЬЗОВАТЕЛЬ)\s+') { continue }
$parts = $normalized -split '\s+'
if ($parts.Count -lt 1) { continue }
$user = [string]$parts[0]
if ([string]::IsNullOrWhiteSpace($user)) { continue }
[void]$loggedOnUsers.Add($user)
[void]$loggedOnUsers.Add(('{0}\{1}' -f $env:COMPUTERNAME, $user))
if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) {
[void]$loggedOnUsers.Add(('{0}\{1}' -f $env:USERDOMAIN, $user))
}
}
} catch {}
function Test-TaskUserHasSession {
param([string]$UserId)
if ([string]::IsNullOrWhiteSpace($UserId)) { return $false }
$candidates = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
[void]$candidates.Add($UserId)
$leafUser = $UserId
if ($leafUser -match '^[^\\]+\\(.+)$') {
$leafUser = $Matches[1]
[void]$candidates.Add($leafUser)
}
[void]$candidates.Add(('{0}\{1}' -f $env:COMPUTERNAME, $leafUser))
if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) {
[void]$candidates.Add(('{0}\{1}' -f $env:USERDOMAIN, $leafUser))
}
foreach ($candidate in @($candidates)) {
if ($loggedOnUsers.Contains($candidate)) { return $true }
}
return $false
}
foreach ($taskDef in @($config.userTasks)) {
try { Enable-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue | Out-Null } catch {}
}
Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}"
Get-ScheduledTask |
Where-Object TaskName -like "{{ aw_windows_launch_task_pattern }}" |
ForEach-Object { Start-ScheduledTask -TaskName $_.TaskName }
foreach ($taskDef in @($config.userTasks)) {
if (Test-TaskUserHasSession -UserId ([string]$taskDef.userId)) {
Start-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue
}
}
- name: Получить Windows hostname для AW smoke-check bucket
when:
@@ -80,8 +80,20 @@
TELEGRAM_BOT_TOKEN={{ telegram_bot_token }}
TELEGRAM_ALLOWED_CHAT_IDS={{ telegram_allowed_chat_ids }}
TELEGRAM_DEFAULT_CHAT_ID={{ tsj_bot_default_chat_id }}
HTTPS_PROXY={{ tsj_bot_https_proxy_url | default(tsj_bot_telegram_proxy_url | default('http://127.0.0.1:11090')) }}
HTTP_PROXY={{ tsj_bot_http_proxy_url | default(tsj_bot_telegram_proxy_url | default('http://127.0.0.1:11090')) }}
NO_PROXY={{ tsj_bot_no_proxy | default('localhost,127.0.0.1,10.10.10.0/24') }}
NODE_13_HOST={{ tsj_bot_node_13_host | default('10.10.10.13') }}
NODE_16_HOST={{ tsj_bot_node_16_host | default('10.10.10.16') }}
NODE_13_URL={{ tsj_bot_node_13_url | default('http://10.10.10.13:5600/') }}
NODE_16_URL={{ tsj_bot_node_16_url | default('http://10.10.10.16/') }}
NODE_16_ENABLED={{ tsj_bot_node_16_enabled | default('false') }}
CHECK_SCRIPT={{ tsj_bot_check_script | default('/home/codex/infra-admin/scripts/system_self_support.sh --check') }}
HEAL_SCRIPT={{ tsj_bot_heal_script | default('/home/codex/infra-admin/scripts/system_self_support.sh --heal') }}
FS_WARN_PCT={{ tsj_bot_fs_warn_pct | default(85) }}
FS_CRIT_PCT={{ tsj_bot_fs_crit_pct | default(92) }}
FS_TARGETS={{ tsj_bot_fs_targets | default('host,200,201,202,203') }}
FS_EXCLUDE_TYPES={{ tsj_bot_fs_exclude_types | default('tmpfs,devtmpfs,proc,sysfs,cgroup,cgroup2,overlay,squashfs,nsfs,tracefs,debugfs,securityfs,configfs,fusectl,mqueue,hugetlbfs,ramfs') }}
STATE_FILE={{ tsj_bot_state_file | default('/home/codex/infra-admin/.state/tsj_guardian_state.json') }}
LOG_FILE={{ tsj_bot_log_file | default('/home/codex/infra-admin/logs/tsj_guardian_bot.log') }}
HEARTBEAT_FILE={{ tsj_bot_heartbeat_file | default('/home/codex/infra-admin/.state/tsj_guardian_heartbeat') }}
@@ -90,6 +102,8 @@
RETRY_AUTORECOVERY_EVERY_SEC={{ tsj_bot_retry_autorecovery_every_sec | default(300) }}
EXIT_ON_AUTORECOVERY_SUCCESS={{ tsj_bot_exit_on_autorecovery_success | default('true') }}
ENABLE_AI_ESCALATION={{ tsj_bot_enable_ai_escalation | default('true') }}
FS_IMMEDIATE_AI_ON_CRITICAL={{ tsj_bot_fs_immediate_ai_on_critical | default('true') }}
AI_ESCALATION_MODE={{ tsj_bot_ai_escalation_mode | default('codex_exec') }}
ENABLE_SERVER_FALLBACK={{ tsj_bot_enable_server_fallback | default('true') }}
TELEGRAM_PROXY_URL={{ tsj_bot_telegram_proxy_url | default('http://127.0.0.1:11090') }}
AI_CHAT_ENABLED={{ tsj_bot_ai_chat_enabled | default('true') }}
@@ -103,9 +117,22 @@
TMUX_SESSION={{ tsj_bot_tmux_session | default('ai') }}
TMUX_CREATE_IF_MISSING={{ tsj_bot_tmux_create_if_missing | default('false') }}
TMUX_START_COMMAND={{ tsj_bot_tmux_start_command | default('codex') }}
PFSENSE_CHANGE_CONTROL_ENABLED={{ tsj_bot_pfsense_change_control_enabled | default('true') }}
PFSENSE_CHANGE_CONFIRM_TTL_SEC={{ tsj_bot_pfsense_change_confirm_ttl_sec | default(900) }}
OPENVPN_CONFIG_ENABLED={{ tsj_bot_openvpn_config_enabled | default('true') }}
OPENVPN_CONFIG_CONFIRM_TTL_SEC={{ tsj_bot_openvpn_config_confirm_ttl_sec | default(900) }}
OPENVPN_EXPIRY_WARN_ENABLED={{ tsj_bot_openvpn_expiry_warn_enabled | default('false') }}
OPENVPN_EXPIRY_WARN_DAYS={{ tsj_bot_openvpn_expiry_warn_days | default(30) }}
OPENVPN_EXPIRY_WARN_TIMEOUT_SEC={{ tsj_bot_openvpn_expiry_warn_timeout_sec | default(120) }}
OPENVPN_EXPIRY_WARN_INTERVAL_SEC={{ tsj_bot_openvpn_expiry_warn_interval_sec | default(21600) }}
PFSENSE_MCP_BEARER={{ tsj_bot_pfsense_mcp_bearer | default(pfsense_mcp_bearer | default('')) }}
SERVER_FALLBACK_COMMANDS={{ tsj_bot_server_fallback_commands | default('/home/codex/infra-admin/scripts/system_self_support.sh --heal') }}
UPDATES_SCRIPT={{ tsj_bot_updates_script | default('/usr/bin/python3 /home/codex/infra-admin/scripts/proxmox_lxc_critical_updates.py') }}
UPDATE_TARGETS={{ tsj_bot_update_targets | default('auto') }}
AW_RUS_API_BASE={{ tsj_bot_aw_rus_api_base | default('http://10.10.10.13:5600/api/0') }}
AW_RUS_WORKTIME_BASE={{ tsj_bot_aw_rus_worktime_base | default('http://10.10.10.13:5610') }}
AW_RUS_WORKTIME_HEAL_CMD={{ tsj_bot_aw_rus_worktime_heal_cmd | default("sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl start aw-worktime-ui-bridge.service'") }}
AW_RUS_DLP_HEAL_CMD={{ tsj_bot_aw_rus_dlp_heal_cmd | default("sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S systemctl restart activitywatch-server.service && sudo -S systemctl start activitywatch-dlp-aggregator.service || true && sudo -S /usr/local/bin/aw-health-check && sudo -S /usr/local/bin/dlp-health-check'") }}
AW_RUS_HOST={{ tsj_bot_aw_rus_host | default('SHARKON2025') }}
AW_RUS_PRIMARY_USER={{ tsj_bot_aw_rus_primary_user | default('USER1') }}
AW_RUS_STALE_SEC={{ tsj_bot_aw_rus_stale_sec | default(900) }}
@@ -10,11 +10,38 @@ tsj_bot_check_interval_sec: 60
tsj_bot_operator_timeout_sec: 900
tsj_bot_retry_autorecovery_every_sec: 300
tsj_bot_telegram_proxy_url: "http://127.0.0.1:11090"
tsj_bot_https_proxy_url: "http://127.0.0.1:11090"
tsj_bot_http_proxy_url: "http://127.0.0.1:11090"
tsj_bot_no_proxy: "localhost,127.0.0.1,10.10.10.0/24"
tsj_bot_node_13_host: "10.10.10.13"
tsj_bot_node_16_host: "10.10.10.16"
tsj_bot_node_13_url: "http://10.10.10.13:5600/"
tsj_bot_node_16_url: "http://10.10.10.16/"
tsj_bot_node_16_enabled: "false"
tsj_bot_fs_warn_pct: 85
tsj_bot_fs_crit_pct: 92
tsj_bot_fs_targets: "host,200,201,202,203"
tsj_bot_fs_exclude_types: "tmpfs,devtmpfs,proc,sysfs,cgroup,cgroup2,overlay,squashfs,nsfs,tracefs,debugfs,securityfs,configfs,fusectl,mqueue,hugetlbfs,ramfs"
tsj_bot_fs_immediate_ai_on_critical: "true"
tsj_bot_ai_escalation_mode: "codex_exec"
tsj_bot_pfsense_change_control_enabled: "true"
tsj_bot_pfsense_change_confirm_ttl_sec: 900
tsj_bot_openvpn_config_enabled: "true"
tsj_bot_openvpn_config_confirm_ttl_sec: 900
tsj_bot_openvpn_expiry_warn_enabled: "false"
tsj_bot_openvpn_expiry_warn_days: 30
tsj_bot_openvpn_expiry_warn_timeout_sec: 120
tsj_bot_openvpn_expiry_warn_interval_sec: 21600
tsj_bot_server_fallback_commands: "/home/codex/infra-admin/scripts/system_self_support.sh --heal"
tsj_bot_updates_script: "/usr/bin/python3 /home/codex/infra-admin/scripts/proxmox_lxc_critical_updates.py"
tsj_bot_update_targets: "auto"
tsj_bot_pfsense_mcp_bearer: "CHANGE_ME"
# AW-Rus + DLP check defaults
tsj_bot_aw_rus_api_base: "http://10.10.10.13:5600/api/0"
tsj_bot_aw_rus_worktime_base: "http://10.10.10.13:5610"
tsj_bot_aw_rus_worktime_heal_cmd: "sshpass -p 'CHANGE_ME' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl start aw-worktime-ui-bridge.service'"
tsj_bot_aw_rus_dlp_heal_cmd: "sshpass -p 'CHANGE_ME' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S systemctl restart activitywatch-server.service && sudo -S systemctl start activitywatch-dlp-aggregator.service || true && sudo -S /usr/local/bin/aw-health-check && sudo -S /usr/local/bin/dlp-health-check'"
tsj_bot_aw_rus_host: "SHARKON2025"
tsj_bot_aw_rus_primary_user: "USER1"
tsj_bot_aw_rus_stale_sec: 900
@@ -25,6 +25,14 @@
group: "{{ aw_server_group | default('activitywatch') }}"
mode: "0644"
- name: Install content analysis wrapper
ansible.builtin.copy:
src: "{{ playbook_dir }}/../aw-server/dlp-content-analysis/aw-dlp-content-analyzer.sh"
dest: /usr/local/bin/aw-dlp-content-analyzer
owner: root
group: root
mode: "0755"
- name: Create venv for content analysis
ansible.builtin.command:
cmd: python3 -m venv /opt/activitywatch/dlp-content-analysis/.venv
+2 -2
View File
@@ -2,6 +2,8 @@
Description=AW Worktime Report API
After=network.target activitywatch-server.service
Wants=activitywatch-server.service
StartLimitBurst=3
StartLimitIntervalSec=60
[Service]
Type=simple
@@ -9,8 +11,6 @@ EnvironmentFile=/etc/activitywatch/aw-server.env
ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-api.py
Restart=on-failure
RestartSec=5
StartLimitBurst=3
StartLimitIntervalSec=60
User=activitywatch
Group=activitywatch
StandardOutput=journal
+2 -2
View File
@@ -2,6 +2,8 @@
Description=AW Worktime UI bridge (sessions -> afk/window)
After=network-online.target activitywatch-server.service
Wants=network-online.target
StartLimitBurst=3
StartLimitIntervalSec=120
[Service]
Type=simple
@@ -10,8 +12,6 @@ Environment=AW_WORKTIME_HOST=SHARKON2025
ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-ui-bridge.py
Restart=on-failure
RestartSec=10
StartLimitBurst=3
StartLimitIntervalSec=120
User=activitywatch
Group=activitywatch
StandardOutput=journal
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_DIR="/opt/activitywatch/dlp-content-analysis"
VENV_PY="$BASE_DIR/.venv/bin/python"
ANALYZER="$BASE_DIR/content_analyzer.py"
if [ ! -x "$VENV_PY" ]; then
echo "ERROR: content-analysis virtualenv is missing: $VENV_PY" >&2
exit 1
fi
exec "$VENV_PY" "$ANALYZER" "$@"
@@ -2,6 +2,8 @@
Description=AW DLP Policy Engine
After=network.target activitywatch-server.service
Wants=activitywatch-server.service
StartLimitBurst=3
StartLimitIntervalSec=60
[Service]
Type=simple
@@ -10,8 +12,6 @@ WorkingDirectory=/opt/activitywatch/dlp-policy-engine
ExecStart=/opt/activitywatch/dlp-policy-engine/.venv/bin/uvicorn policy_service:app --host ${AW_DLP_POLICY_ENGINE_BIND_HOST} --port ${AW_DLP_POLICY_ENGINE_PORT}
Restart=on-failure
RestartSec=5
StartLimitBurst=3
StartLimitIntervalSec=60
User=activitywatch
Group=activitywatch
StandardOutput=journal
+12 -103
View File
@@ -40,115 +40,24 @@ check_api_endpoint() {
}
check_dlp_transport_freshness() {
local api_base="${1:-http://127.0.0.1:5600/api/0}"
local max_age_seconds="${2:-900}"
local strict_fileops="${3:-0}"
local dlp_health="${DLP_HEALTH_BIN:-/usr/local/bin/dlp-health-check}"
local result
if ! command -v python3 >/dev/null 2>&1; then
echo "⚠ python3 is not available, skipping DLP transport freshness checks"
WARNINGS+=("dlp-transport-check-skipped")
if [[ ! -x "$dlp_health" ]]; then
echo "⚠ dlp-health-check is not available, skipping DLP transport freshness checks"
WARNINGS+=("dlp-health-check-missing")
return
fi
result="$(python3 - "$api_base" "$max_age_seconds" "$strict_fileops" <<'PY'
import json
import sys
import time
from urllib.request import urlopen
api_base = sys.argv[1].rstrip("/")
max_age = int(sys.argv[2])
strict_fileops = str(sys.argv[3]).strip().lower() in ("1", "true", "yes", "on")
now = time.time()
def parse_ts(ts):
if not ts:
return None
ts = ts.replace("Z", "+00:00")
try:
from datetime import datetime
return datetime.fromisoformat(ts).timestamp()
except Exception:
return None
def get_json(url):
with urlopen(url, timeout=8) as resp:
return json.loads(resp.read().decode("utf-8"))
out = {
"ok": True,
"warnings": [],
"errors": []
}
try:
buckets = get_json(f"{api_base}/buckets/")
except Exception as ex:
out["ok"] = False
out["errors"].append(f"dlp-buckets-read-failed:{ex}")
print(json.dumps(out))
sys.exit(0)
endpoint = [k for k in buckets.keys() if k.startswith("aw-dlp-endpoint-signals_")]
fileops = [k for k in buckets.keys() if k.startswith("aw-file-operations_")]
if not endpoint:
out["ok"] = False
out["errors"].append("no-endpoint-signal-buckets")
if not fileops:
out["warnings"].append("no-file-operations-buckets")
def check_bucket_freshness(bucket_id, label):
b = buckets.get(bucket_id, {})
meta = b.get("metadata") or {}
end = parse_ts(meta.get("end"))
if end is None:
# Some aw-server deployments may not populate metadata.end; fallback to latest event.
try:
events = get_json(f"{api_base}/buckets/{bucket_id}/events?limit=1")
if events:
end = parse_ts(events[0].get("timestamp"))
except Exception:
end = None
if end is None:
out["warnings"].append(f"{label}:no-end-ts-or-events:{bucket_id}")
result="$("$dlp_health" --json 2>/dev/null || true)"
if [[ -z "$result" ]]; then
echo "⚠ dlp-health-check did not return JSON, skipping DLP transport freshness checks"
WARNINGS+=("dlp-health-check-empty")
return
age = int(now - end)
if age > max_age:
if label == "fileops" and not strict_fileops:
out["warnings"].append(f"{label}:stale:{bucket_id}:age={age}s")
else:
out["ok"] = False
out["errors"].append(f"{label}:stale:{bucket_id}:age={age}s")
for bid in endpoint:
check_bucket_freshness(bid, "endpoint")
for bid in fileops:
check_bucket_freshness(bid, "fileops")
# Validate that endpoint self_test contains transport metrics at least once recently.
for bid in endpoint:
try:
events = get_json(f"{api_base}/buckets/{bid}/events?limit=20")
found = False
for e in events:
d = e.get("data") or {}
if d.get("signalType") == "self_test":
if all(k in d for k in ("queueDepth", "eventsEnqueued", "eventsFlushed", "sendFailures")):
found = True
break
if not found:
out["warnings"].append(f"endpoint:self_test-metrics-missing:{bid}")
except Exception as ex:
out["warnings"].append(f"endpoint:self_test-read-failed:{bid}:{ex}")
print(json.dumps(out))
PY
)" || true
fi
local ok
ok="$(printf '%s' "$result" | python3 -c 'import json,sys; d=json.load(sys.stdin); print("1" if d.get("ok") else "0")' 2>/dev/null || echo "0")"
ok="$(printf '%s' "$result" | python3 -c 'import json,sys; data=json.load(sys.stdin); names={r["name"]:r for r in data.get("results", [])}; checks=["buckets:endpoint-signals","buckets:file-operations","endpoint-self-test-metrics"]; bad=[n for n in checks if names.get(n,{}).get("status")=="fail"]; print("1" if not bad else "0")' 2>/dev/null || echo "0")"
if [[ "$ok" == "1" ]]; then
echo "✓ DLP transport freshness check passed"
else
@@ -157,8 +66,8 @@ PY
fi
local errors warnings
errors="$(printf '%s' "$result" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(", ".join(d.get("errors", [])))' 2>/dev/null || true)"
warnings="$(printf '%s' "$result" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(", ".join(d.get("warnings", [])))' 2>/dev/null || true)"
errors="$(printf '%s' "$result" | python3 -c 'import json,sys; data=json.load(sys.stdin); out=[]; [out.append(f"{r.get(\"name\")}:{r.get(\"summary\")}") for r in data.get("results", []) if r.get("status")=="fail" and r.get("name") in ("buckets:endpoint-signals","buckets:file-operations","endpoint-self-test-metrics")]; print(", ".join(out))' 2>/dev/null || true)"
warnings="$(printf '%s' "$result" | python3 -c 'import json,sys; data=json.load(sys.stdin); out=[]; [out.append(f"{r.get(\"name\")}:{r.get(\"summary\")}") for r in data.get("results", []) if r.get("status")=="warn" and r.get("name") in ("buckets:endpoint-signals","buckets:file-operations","endpoint-self-test-metrics")]; print(", ".join(out))' 2>/dev/null || true)"
if [[ -n "$errors" ]]; then
echo " errors: $errors"
fi
@@ -0,0 +1,60 @@
# DLP Content Analysis Runtime Status 2026-05-13
This document records the production-verified state of advanced content analysis on `10.10.10.13`.
## What is live
- Endpoint-side dictionary and regex matching is active in `windows/dlp-endpoint-signals-collector.ps1`.
- Active policy supports:
- `contentAnalysis.dictionaryPack`
- `contentAnalysis.regexPack`
- `contentAnalysis.ocrEnabled`
- `ioc.*`
- Historical incidents in `aw-dlp-incidents_SHARKON2025` already contain enriched fields:
- `dictionaryMatches`
- `regexMatches`
- `ocrRequested`
- IOC refresh pipeline is deployed and active:
- `aw-dlp-ioc-refresh.timer`
- output artifacts:
- `/opt/activitywatch/dlp-ioc/output/ioc_blacklist.json`
- `/opt/activitywatch/dlp-ioc/output/ioc_blacklist.csv`
- `/opt/activitywatch/dlp-ioc/output/ioc_blacklist.sql`
## What was fixed in this phase
- Server-side analyzer dependencies were installed only inside a virtualenv, but there was no canonical wrapper to run the analyzer in production.
- Added `/usr/local/bin/aw-dlp-content-analyzer`, which executes:
- `/opt/activitywatch/dlp-content-analysis/.venv/bin/python`
- `/opt/activitywatch/dlp-content-analysis/content_analyzer.py`
## Supported production mode
### Fully supported now
- Endpoint-side enrichment:
- clipboard and print content are matched against dictionary and regex packs on the endpoint;
- enriched incidents are sent to AW with structured matches;
- `ocrRequested=true` is carried into incident metadata when policy requires screenshot/OCR follow-up.
- IOC enrichment:
- Hayabusa/Sigma-derived IOC artifacts are refreshed on the server and exposed over HTTP for policy consumption.
- Server-side manual/operational analysis:
- operators can run `aw-dlp-content-analyzer` for text or image artifacts using the deployed packs and OCR stack.
### Not a continuous background pipeline yet
- There is no standalone daemon that automatically scans screenshot artifacts after incident creation.
- OCR is production-usable as a server-side utility path, not as an always-on post-processing service.
## Live verification commands
```bash
sudo systemctl status aw-dlp-ioc-refresh.timer --no-pager
ls -1 /opt/activitywatch/dlp-ioc/output
aw-dlp-content-analyzer --text "СНИЛС 112-233-445 95 пароль qwerty" --dictionary-pack 152-fz-pdn --regex-pack secrets
```
Expected result:
- IOC artifacts exist and are non-empty.
- The analyzer returns dictionary and regex matches for the sample text.
@@ -0,0 +1,48 @@
# DLP Runtime Chain Status 2026-05-13
## Verified production chain
Verified on `10.10.10.13`:
- `policy engine`
- service: `aw-dlp-policy-engine.service`
- health: `GET http://127.0.0.1:5601/healthz`
- active policy: `policyId=1`, `default-policy`, `version=1`
- `case management`
- service: `aw-dlp-case-management.service`
- health: `GET http://127.0.0.1:5602/health`
- runtime data present: case `id=1`
- `compliance reporting`
- timer active: `aw-dlp-report-scheduler.timer`
- artifacts present:
- `152-fz-2026-05.html`
- `152-fz-2026-05.json`
- `pci-dss-2026-05.html`
- `pci-dss-2026-05.json`
- `integrations`
- timers active:
- `aw-dlp-cef-exporter.timer`
- `aw-dlp-webhook-sender.timer`
- `aw-dlp-syslog-forwarder.timer`
- recent journal runs are clean
- current runtime result is `sent=0` / `delivered=0` because no new incidents were generated since the last seen bucket event
- `endpoint -> incident ingest`
- `aw-dlp-endpoint-signals_SHARKON2025` fresh
- `aw-dlp-incidents_SHARKON2025` exists and contains valid historical incidents
- `health/admin`
- `/usr/local/bin/dlp-health-check --json` = `ok=true`
- `/usr/local/bin/dlp-admin-cli.py health check` = policy/cases/aw OK
## Operational conclusion
Core chain is working:
`policy -> endpoint collectors -> incident bucket -> case management -> compliance -> integrations`
There is no confirmed production break in the server-side DLP chain.
## Bounded residual backlog
- external webhook/syslog/CEF destinations are configured and runnable, but current production evidence only shows clean timer execution with zero fresh incidents to export
- stale incident buckets must not be treated as failure by health-check if endpoint transport and policy/case services are healthy
- remaining work belongs to content-analysis completion and broader productization, not to server-side chain break repair
+3
View File
@@ -9,6 +9,8 @@
- [Компоненты системы](Components) - описание всех компонентов
- [Интерактивная карта](Interactive-Map) - визуальная карта связей
- [ИБ-профиль DLP](../dlp-security-functional-spec-ru.md) - подробное описание реализованного DLP/monitoring-контура для службы ИБ
- [Runtime status: DLP chain](../dlp-runtime-chain-status-2026-05-13.md) - фактический live-статус policy/cases/integrations/compliance
- [Runtime status: Content analysis](../dlp-content-analysis-runtime-status-2026-05-13.md) - фактический live-статус dictionary/regex/OCR/IOC
### Компоненты
- [DLP Endpoint Monitoring](DLP-Endpoint-Monitoring) - мониторинг clipboard, печати, USB
@@ -22,6 +24,7 @@
- [Установка на Windows](Windows-Installation) - установка коллекторов
- [Настройка сервера](Server-Setup) - настройка Linux сервера
- [Grafana + Prometheus](Monitoring-Setup) - мониторинг стек
- [Windows startup model](../windows-deploy-startup-model.md) - canonical startup model для RDP/standalone deployment
### Конфигурация
- [DLP Правила](DLP-Rules) - настройка DLP политик
+59
View File
@@ -0,0 +1,59 @@
# Windows Deploy Startup Model
## Supported startup models
### 1. Multi-user RDP host
Use this model on `SHARKON2025`-style hosts with multiple user sessions.
- `ActivityWatch Launch [HOST_user]` tasks:
- `AtLogOn`
- `InteractiveToken`
- start only for users that currently have a real Windows session
- `ActivityWatch Recovery` task:
- `AtStartup`
- `SYSTEM`
- keeps only the global `worktime-session-collector` alive
- may re-trigger user launch tasks, but only for users whose sessions currently exist
- interactive collectors/watcher binaries belong to the user-session path, not to Session 0
Collector ownership in this model:
- `aw-watcher-afk` and `aw-watcher-window`: user-session only
- `browser-domains-native-collector.ps1`: user-session only
- `email-outbound-collector.ps1`: user-session only
- `file-operations-collector.ps1`: user-session path
- `dlp-endpoint-signals-collector.ps1`: user-session path
- `worktime-session-collector.ps1`: single global process under recovery path
### 2. Standalone service installer
Use this model on single-user or headless hosts where Task Scheduler per-user orchestration is not the primary control plane.
- `aw-standalone-service.ps1` runs as a loop/service wrapper
- Session 0 starts only collectors that are safe headless
- browser/email interactive collectors must not be assumed available from Session 0
Collector ownership in this model:
- `dlp-endpoint-signals-collector.ps1`: allowed
- `file-operations-collector.ps1`: allowed
- `worktime-session-collector.ps1`: allowed
- `browser-domains-native-collector.ps1`: not reliable in Session 0
- `email-outbound-collector.ps1`: not reliable in Session 0
- `aw-watcher-afk` / `aw-watcher-window`: not a standalone Session 0 primitive
## Non-supported mix
Do not mix the two startup models on the same RDP host:
- no permanent standalone-service loop together with per-user launch/recovery tasks
- no blind `Start-ScheduledTask` for all configured users
- no validation rule that treats users without sessions as failed collector startup
## Hardening rules
- start launch tasks only for users with real sessions
- keep only one global `worktime-session-collector`
- validate by session-aware expectations, not by “all configured users must currently run”
- keep `deploy_aw_windows.yml`, `deploy-ensemble.ps1`, `hardening-recovery.ps1`, and installer assumptions aligned
+19
View File
@@ -357,6 +357,10 @@ class TSJGuardianBot:
"sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 "
"'sudo -S systemctl restart aw-worktime-api.service'",
).strip()
self.aw_rus_dlp_heal_cmd = os.getenv(
"AW_RUS_DLP_HEAL_CMD",
"",
).strip()
self.aw_rus_host = os.getenv("AW_RUS_HOST", "SHARKON2025").strip()
self.aw_rus_primary_user = os.getenv("AW_RUS_PRIMARY_USER", "USER1").strip()
self.aw_rus_stale_sec = max(60, env_int("AW_RUS_STALE_SEC", 900))
@@ -2305,6 +2309,21 @@ class TSJGuardianBot:
report.append("- heal: skipped (no DLP targets)")
return True, report
cmd = (self.aw_rus_dlp_heal_cmd or "").strip()
if cmd:
try:
rc, out = self._run_shell(cmd, timeout_sec=120)
if rc != 0:
tail = (out or "").strip().splitlines()[-1:] or [f"rc={rc}"]
report.append(f"- dlp-heal: FAIL ({tail[0]})")
return False, report
report.append("- dlp-heal: configured recovery command OK")
return True, report
except Exception as exc:
report.append(f"- dlp-heal: FAIL ({exc})")
return False, report
report.append("- dlp-heal: no configured recovery command, using freshness reseed fallback")
ok = True
for bucket_id in selected:
btype, client, hostname = bucket_defs[bucket_id]
+63 -1
View File
@@ -206,6 +206,68 @@ def check_bucket_group(
)
def check_incident_buckets(
report: HealthReport,
api_base: str,
buckets: dict[str, Any],
max_age_seconds: int,
) -> None:
now = _now_utc()
prefix = "aw-dlp-incidents_"
matched = sorted(bucket_id for bucket_id in buckets if bucket_id.startswith(prefix))
if not matched:
report.add(
"buckets:incidents",
"ok",
"no incident buckets yet",
prefix=prefix,
bucket_count=0,
)
return
ages: dict[str, int] = {}
unknown: list[str] = []
stale: list[dict[str, Any]] = []
for bucket_id in matched:
ts = _latest_bucket_ts(api_base, bucket_id, buckets.get(bucket_id, {}))
age = _age_seconds(ts, now)
if age is None:
unknown.append(bucket_id)
continue
ages[bucket_id] = age
if age > max_age_seconds:
stale.append({"bucket": bucket_id, "age_seconds": age})
if stale and not unknown:
report.add(
"buckets:incidents",
"ok",
"no recent incidents",
prefix=prefix,
bucket_count=len(matched),
max_age_seconds=max_age_seconds,
max_observed_age_seconds=max(ages.values()) if ages else None,
stale=stale,
unknown=[],
)
return
status = "ok" if not unknown else "warn"
summary = "incident buckets healthy" if not unknown else f"{len(unknown)} incident buckets without timestamp"
report.add(
"buckets:incidents",
status,
summary,
prefix=prefix,
bucket_count=len(matched),
max_age_seconds=max_age_seconds,
max_observed_age_seconds=max(ages.values()) if ages else None,
stale=stale,
unknown=unknown,
)
def _worktime_activity_map(api_base: str, buckets: dict[str, Any], max_age_seconds: int) -> dict[str, dict[str, Any]]:
now = _now_utc()
activity: dict[str, dict[str, Any]] = {}
@@ -401,7 +463,7 @@ def main() -> int:
report.add("aw:buckets-index", "ok", "bucket index loaded", total=len(buckets))
check_bucket_group(report, aw_api_base, buckets, "endpoint-signals", "aw-dlp-endpoint-signals_", args.max_age_seconds)
check_file_operations_buckets(report, aw_api_base, buckets, args.max_age_seconds, args.strict_fileops)
check_bucket_group(report, aw_api_base, buckets, "incidents", "aw-dlp-incidents_", args.max_age_seconds * 24, severity_if_missing="warn", severity_if_stale="warn")
check_incident_buckets(report, aw_api_base, buckets, args.max_age_seconds * 24)
check_endpoint_self_test_metrics(report, aw_api_base, buckets)
except Exception as exc:
report.add("aw:buckets-index", "fail", f"failed to inspect bucket index: {exc}")
+183 -9
View File
@@ -311,6 +311,83 @@ function New-ActivityWatchUserTaskDefinitions {
return @($result)
}
function Get-ActivityWatchLoggedOnUsers {
$users = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
try {
$lines = & quser.exe 2>$null
foreach ($line in @($lines)) {
$normalized = [string]$line
if ([string]::IsNullOrWhiteSpace($normalized)) {
continue
}
$normalized = $normalized.TrimStart(' ', '>')
if ([string]::IsNullOrWhiteSpace($normalized)) {
continue
}
if ($normalized -match '^(USERNAME|ПОЛЬЗОВАТЕЛЬ)\s+') {
continue
}
$parts = $normalized -split '\s+'
if ($parts.Count -lt 1) {
continue
}
$user = [string]$parts[0]
if ([string]::IsNullOrWhiteSpace($user)) {
continue
}
[void]$users.Add($user)
[void]$users.Add(('{0}\{1}' -f $env:COMPUTERNAME, $user))
if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) {
[void]$users.Add(('{0}\{1}' -f $env:USERDOMAIN, $user))
}
}
}
catch {
}
return @($users)
}
function Test-ActivityWatchUserHasSession {
param(
[Parameter(Mandatory = $true)]
[string]$UserId,
[string[]]$LoggedOnUsers
)
if ([string]::IsNullOrWhiteSpace($UserId)) {
return $false
}
$candidateIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
[void]$candidateIds.Add($UserId)
$leafUser = $UserId
if ($leafUser -match '^[^\\]+\\(.+)$') {
$leafUser = $Matches[1]
[void]$candidateIds.Add($leafUser)
}
[void]$candidateIds.Add(('{0}\{1}' -f $env:COMPUTERNAME, $leafUser))
if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) {
[void]$candidateIds.Add(('{0}\{1}' -f $env:USERDOMAIN, $leafUser))
}
foreach ($candidate in @($candidateIds)) {
if ($LoggedOnUsers -contains $candidate) {
return $true
}
}
return $false
}
function Copy-ActivityWatchCollectorAssets {
param(
[Parameter(Mandatory = $true)]
@@ -939,17 +1016,21 @@ function Get-RecoveryConfigPaths {
return @(`$paths | Sort-Object -Unique)
}
function Get-RecoveryTaskNames {
function Get-RecoveryTaskDefinitions {
param([string[]]`$ConfigPaths)
`$taskNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
`$taskMap = [ordered]@{}
foreach (`$candidatePath in @(`$ConfigPaths)) {
try {
`$config = Get-DeploymentConfig -Path `$candidatePath
foreach (`$task in @(`$config.userTasks)) {
`$taskName = [string]`$task.launchTaskName
if (-not [string]::IsNullOrWhiteSpace(`$taskName)) {
[void]`$taskNames.Add(`$taskName)
`$userId = [string]`$task.userId
if (-not [string]::IsNullOrWhiteSpace(`$taskName) -and -not `$taskMap.Contains(`$taskName)) {
`$taskMap[`$taskName] = [pscustomobject]@{
taskName = `$taskName
userId = `$userId
}
}
}
}
@@ -957,7 +1038,7 @@ function Get-RecoveryTaskNames {
}
}
return @(`$taskNames)
return @(`$taskMap.Values)
}
function New-RecoveryLock {
@@ -990,11 +1071,23 @@ function New-RecoveryLock {
}
function Start-TaskIfNotRunning {
param([string]`$TaskName)
param(
[string]`$TaskName,
[string]`$UserId,
[string[]]`$LoggedOnUsers
)
if ([string]::IsNullOrWhiteSpace(`$TaskName)) {
return
}
if ([string]::IsNullOrWhiteSpace(`$UserId)) {
return
}
if (-not (Test-UserHasSession -UserId `$UserId -LoggedOnUsers `$LoggedOnUsers)) {
return
}
try {
`$task = Get-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue
if (-not `$task) {
@@ -1009,6 +1102,82 @@ function Start-TaskIfNotRunning {
}
}
function Get-LoggedOnUsers {
`$users = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
try {
`$lines = & quser.exe 2>`$null
foreach (`$line in @(`$lines)) {
`$normalized = [string]`$line
if ([string]::IsNullOrWhiteSpace(`$normalized)) {
continue
}
`$normalized = `$normalized.TrimStart(' ', '>')
if ([string]::IsNullOrWhiteSpace(`$normalized)) {
continue
}
if (`$normalized -match '^(USERNAME|ПОЛЬЗОВАТЕЛЬ)\s+') {
continue
}
`$parts = `$normalized -split '\s+'
if (`$parts.Count -lt 1) {
continue
}
`$user = [string]`$parts[0]
if ([string]::IsNullOrWhiteSpace(`$user)) {
continue
}
[void]`$users.Add(`$user)
[void]`$users.Add(('{0}\{1}' -f `$env:COMPUTERNAME, `$user))
if (-not [string]::IsNullOrWhiteSpace(`$env:USERDOMAIN)) {
[void]`$users.Add(('{0}\{1}' -f `$env:USERDOMAIN, `$user))
}
}
}
catch {
}
return @(`$users)
}
function Test-UserHasSession {
param(
[string]`$UserId,
[string[]]`$LoggedOnUsers
)
if ([string]::IsNullOrWhiteSpace(`$UserId)) {
return `$false
}
`$candidateIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
[void]`$candidateIds.Add(`$UserId)
`$leafUser = `$UserId
if (`$leafUser -match '^[^\\]+\\(.+)$') {
`$leafUser = `$Matches[1]
[void]`$candidateIds.Add(`$leafUser)
}
[void]`$candidateIds.Add(('{0}\{1}' -f `$env:COMPUTERNAME, `$leafUser))
if (-not [string]::IsNullOrWhiteSpace(`$env:USERDOMAIN)) {
[void]`$candidateIds.Add(('{0}\{1}' -f `$env:USERDOMAIN, `$leafUser))
}
foreach (`$candidate in @(`$candidateIds)) {
if (`$LoggedOnUsers -contains `$candidate) {
return `$true
}
}
return `$false
}
function Test-CollectorRunningGlobal {
param([string]`$ScriptPath)
if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) {
@@ -1058,11 +1227,12 @@ try {
try {
`$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath
`$config = Get-DeploymentConfig -Path `$ConfigPath
`$loggedOnUsers = Get-LoggedOnUsers
`$stateRoot = [string]`$config.paths.stateRoot
`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { Join-Path `$stateRoot 'worktime-session-collector.ps1' }
Start-CollectorScriptGlobalIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath
foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) {
Start-TaskIfNotRunning -TaskName `$taskName
foreach (`$taskDef in Get-RecoveryTaskDefinitions -ConfigPaths `$configPaths) {
Start-TaskIfNotRunning -TaskName `$taskDef.taskName -UserId `$taskDef.userId -LoggedOnUsers `$loggedOnUsers
}
if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) {
@@ -1343,8 +1513,12 @@ function Start-ActivityWatchTasks {
[string]$RecoveryTaskName = 'ActivityWatch Recovery'
)
$loggedOnUsers = Get-ActivityWatchLoggedOnUsers
foreach ($definition in $TaskDefinitions) {
Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue
if (Test-ActivityWatchUserHasSession -UserId $definition.UserId -LoggedOnUsers $loggedOnUsers) {
Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue
}
}
Start-ScheduledTask -TaskName $RecoveryTaskName -ErrorAction SilentlyContinue
@@ -11,6 +11,10 @@
#define AwDefaultStateRoot "C:\\ProgramData\\AWatch-rus"
#define AwDefaultZipName "activitywatch-v0.13.2-windows-x86_64.zip"
; 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 SHARKON2025.
[Setup]
AppId={{6D6A1F74-0F4F-4A57-B5E3-1C2C2F56C0E9}
AppName={#MyAppName}
+59
View File
@@ -89,6 +89,62 @@ if ($config.userTasks) {
$taskNames += [string]$config.recovery.taskName
$taskNames = $taskNames | Sort-Object -Unique
function Get-LoggedOnUsers {
$users = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
try {
$lines = & quser.exe 2>$null
foreach ($line in @($lines)) {
$normalized = [string]$line
if ([string]::IsNullOrWhiteSpace($normalized)) { continue }
$normalized = $normalized.TrimStart(' ', '>')
if ([string]::IsNullOrWhiteSpace($normalized)) { continue }
if ($normalized -match '^(USERNAME|ПОЛЬЗОВАТЕЛЬ)\s+') { continue }
$parts = $normalized -split '\s+'
if ($parts.Count -lt 1) { continue }
$user = [string]$parts[0]
if ([string]::IsNullOrWhiteSpace($user)) { continue }
[void]$users.Add($user)
[void]$users.Add(('{0}\{1}' -f $env:COMPUTERNAME, $user))
if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) {
[void]$users.Add(('{0}\{1}' -f $env:USERDOMAIN, $user))
}
}
}
catch {
}
return @($users)
}
function Test-UserHasSession {
param(
[string]$UserId,
[string[]]$LoggedOnUsers
)
if ([string]::IsNullOrWhiteSpace($UserId)) { return $false }
$candidateIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
[void]$candidateIds.Add($UserId)
$leafUser = $UserId
if ($leafUser -match '^[^\\]+\\(.+)$') {
$leafUser = $Matches[1]
[void]$candidateIds.Add($leafUser)
}
[void]$candidateIds.Add(('{0}\{1}' -f $env:COMPUTERNAME, $leafUser))
if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) {
[void]$candidateIds.Add(('{0}\{1}' -f $env:USERDOMAIN, $leafUser))
}
foreach ($candidate in @($candidateIds)) {
if ($LoggedOnUsers -contains $candidate) { return $true }
}
return $false
}
$loggedOnUsers = Get-LoggedOnUsers
$sessionBoundUsers = @(
@($config.userTasks) |
Where-Object { Test-UserHasSession -UserId ([string]$_.userId) -LoggedOnUsers $loggedOnUsers } |
ForEach-Object { [string]$_.userId }
)
$tasks = @(
foreach ($taskName in $taskNames) {
$task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1
@@ -111,6 +167,7 @@ $tasks = @(
$serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port
$uniqueRunningProcessNames = @($runningProcesses | Select-Object -ExpandProperty Name -Unique)
$sessionBoundCollectorsExpected = ($sessionBoundUsers.Count -gt 0)
$result = [ordered]@{
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
configPath = $ConfigPath
@@ -128,10 +185,12 @@ $result = [ordered]@{
}
processes = [ordered]@{
expected = $processNames
sessionBoundUsers = $sessionBoundUsers
list = @($runningProcesses)
sessionCollectors = @($sessionCollectorProcesses)
ok = [bool](
(
(-not $sessionBoundCollectorsExpected) -or
($processNames.Count -eq 0) -or
($uniqueRunningProcessNames.Count -ge $processNames.Count)
) -and