feat(portal): add optional ClickHouse security events summary

This commit is contained in:
igor04091968
2026-06-04 21:51:41 +03:00
parent 90378229ae
commit da17047b38
7 changed files with 1005 additions and 152 deletions
+141
View File
@@ -60,6 +60,21 @@ struct Cli {
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
disable_grafana_check: bool, disable_grafana_check: bool,
#[arg(long, default_value = "disabled")]
security_events_backend: String,
#[arg(long, default_value = "http://127.0.0.1:8123")]
clickhouse_url: String,
#[arg(long, default_value = "analytics_1c")]
clickhouse_database: String,
#[arg(long, default_value = "default")]
clickhouse_user: String,
#[arg(long, default_value = "")]
clickhouse_password: String,
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -318,9 +333,97 @@ fn service_checks(args: &Cli) -> Vec<ServiceCheck> {
if !args.disable_grafana_check { if !args.disable_grafana_check {
checks.push(grafana_data_check(args)); checks.push(grafana_data_check(args));
} }
if security_events_clickhouse_enabled(args) {
checks.push(clickhouse_security_events_check(args));
}
checks checks
} }
fn security_events_clickhouse_enabled(args: &Cli) -> bool {
args.security_events_backend
.trim()
.eq_ignore_ascii_case("clickhouse")
}
fn clickhouse_security_events_check(args: &Cli) -> ServiceCheck {
let name = "security-events-clickhouse".to_string();
let Some(database) = clickhouse_identifier(&args.clickhouse_database) else {
return ServiceCheck {
name,
required: false,
ok: false,
url: None,
payload: None,
error: Some("invalid ClickHouse database identifier".to_string()),
};
};
let timeout = Duration::from_secs(args.service_timeout_seconds.min(5));
let sql = format!(
"SELECT toUInt64(count()) AS events_24h FROM {database}.entity_timeline WHERE ts >= now() - INTERVAL 24 HOUR FORMAT JSONEachRow"
);
match clickhouse_query_first(args, &sql, timeout) {
Ok(payload) => ServiceCheck {
name,
required: false,
ok: true,
url: Some(args.clickhouse_url.trim_end_matches('/').to_string()),
payload: Some(payload.unwrap_or_else(|| serde_json::json!({"events_24h": 0}))),
error: None,
},
Err(err) => ServiceCheck {
name,
required: false,
ok: false,
url: Some(args.clickhouse_url.trim_end_matches('/').to_string()),
payload: None,
error: Some(err.to_string()),
},
}
}
fn clickhouse_identifier(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty()
|| !trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
{
return None;
}
Some(trimmed.to_string())
}
fn clickhouse_query_first(args: &Cli, sql: &str, timeout: Duration) -> Result<Option<Value>> {
let client = Client::builder()
.timeout(timeout)
.no_proxy()
.build()
.context("ClickHouse HTTP client")?;
let mut request = client
.post(args.clickhouse_url.trim_end_matches('/'))
.query(&[("database", args.clickhouse_database.trim())])
.body(sql.to_string());
if !args.clickhouse_user.trim().is_empty() {
request = request.basic_auth(
args.clickhouse_user.trim().to_string(),
Some(args.clickhouse_password.clone()),
);
}
let text = request
.send()
.context("ClickHouse request")?
.error_for_status()
.context("ClickHouse HTTP status")?
.text()
.context("ClickHouse response body")?;
let Some(line) = text.lines().map(str::trim).find(|line| !line.is_empty()) else {
return Ok(None);
};
Ok(Some(
serde_json::from_str::<Value>(line).context("ClickHouse JSONEachRow")?,
))
}
fn grafana_data_check(args: &Cli) -> ServiceCheck { fn grafana_data_check(args: &Cli) -> ServiceCheck {
let name = "grafana-data".to_string(); let name = "grafana-data".to_string();
let output = read_grafana_check_json_from_ct(args); let output = read_grafana_check_json_from_ct(args);
@@ -675,6 +778,12 @@ fn main() -> Result<()> {
args.rdp_host = env_or_default("DETMIR_RDP_HOST", &args.rdp_host); args.rdp_host = env_or_default("DETMIR_RDP_HOST", &args.rdp_host);
args.hostname = env_or_default("DETMIR_HOSTNAME", &args.hostname); args.hostname = env_or_default("DETMIR_HOSTNAME", &args.hostname);
args.grafana_check_json = env_or_default("DETMIR_GRAFANA_CHECK_JSON", &args.grafana_check_json); args.grafana_check_json = env_or_default("DETMIR_GRAFANA_CHECK_JSON", &args.grafana_check_json);
args.security_events_backend =
env_or_default("SECURITY_EVENTS_BACKEND", &args.security_events_backend);
args.clickhouse_url = env_or_default("CLICKHOUSE_URL", &args.clickhouse_url);
args.clickhouse_database = env_or_default("CLICKHOUSE_DATABASE", &args.clickhouse_database);
args.clickhouse_user = env_or_default("CLICKHOUSE_USER", &args.clickhouse_user);
args.clickhouse_password = env_or_default("CLICKHOUSE_PASSWORD", &args.clickhouse_password);
let report = build_report(&args)?; let report = build_report(&args)?;
if args.json { if args.json {
@@ -723,4 +832,36 @@ mod tests {
assert_eq!(status, "INACTIVE"); assert_eq!(status, "INACTIVE");
assert!(ok); assert!(ok);
} }
#[test]
fn security_events_backend_disabled_by_default() {
let args = Cli::parse_from(["detmir-check"]);
assert!(!security_events_clickhouse_enabled(&args));
}
#[test]
fn security_events_backend_clickhouse_is_optional() {
let args = Cli::parse_from([
"detmir-check",
"--security-events-backend",
"clickhouse",
"--clickhouse-database",
"analytics_1c",
]);
assert!(security_events_clickhouse_enabled(&args));
assert_eq!(
clickhouse_identifier(&args.clickhouse_database).as_deref(),
Some("analytics_1c")
);
}
#[test]
fn clickhouse_database_identifier_rejects_injection() {
assert_eq!(
clickhouse_identifier("analytics_1c").as_deref(),
Some("analytics_1c")
);
assert!(clickhouse_identifier("analytics_1c;DROP TABLE x").is_none());
assert!(clickhouse_identifier("").is_none());
}
} }
File diff suppressed because it is too large Load Diff
@@ -580,6 +580,7 @@ function renderExecutiveDashboard(report) {
<div ${tooltip("Сколько записей нужно срочно проверить вручную.")}><span class="muted">Срочно проверить</span><strong>${ui(candidates.length)}</strong></div> <div ${tooltip("Сколько записей нужно срочно проверить вручную.")}><span class="muted">Срочно проверить</span><strong>${ui(candidates.length)}</strong></div>
<div ${tooltip("Сколько расследований сейчас в работе.")}><span class="muted">Активные расследования</span><strong>${ui(dashboard.open_cases ?? 0)}</strong></div> <div ${tooltip("Сколько расследований сейчас в работе.")}><span class="muted">Активные расследования</span><strong>${ui(dashboard.open_cases ?? 0)}</strong></div>
<div><span class="muted">Закрыто за 30 дней</span><strong>${ui(dashboard.resolved_cases_30d ?? 0)}</strong></div> <div><span class="muted">Закрыто за 30 дней</span><strong>${ui(dashboard.resolved_cases_30d ?? 0)}</strong></div>
<div ${tooltip("Агрегированная сводка событий безопасности за последние 24 часа, без сырых логов.")}><span class="muted">События безопасности</span><strong>${ui(dashboard.security_events_24h ?? "откл.")}</strong></div>
</div> </div>
<div class="list compact-list executive-summary-list"> <div class="list compact-list executive-summary-list">
<div class="row compact-row"><strong>Главный риск</strong><span class="muted">${ui(summary.main_risk || "нет данных")}</span><span></span></div> <div class="row compact-row"><strong>Главный риск</strong><span class="muted">${ui(summary.main_risk || "нет данных")}</span><span></span></div>
@@ -1160,6 +1161,7 @@ function renderExecutiveView(report, incidents) {
return ` return `
${renderRiskNarrative(report)} ${renderRiskNarrative(report)}
${renderExecutiveDashboard(report)} ${renderExecutiveDashboard(report)}
${renderSecurityEventsSummary(report?.security_events_summary, { compact: true })}
${renderBusinessRisk(report?.business_risk)} ${renderBusinessRisk(report?.business_risk)}
${renderRiskHeatmap(report?.risk_heatmap)} ${renderRiskHeatmap(report?.risk_heatmap)}
${renderOverviewAnalytics(report)} ${renderOverviewAnalytics(report)}
@@ -1169,6 +1171,7 @@ function renderExecutiveView(report, incidents) {
function renderSecurityView(data, report, extras = {}) { function renderSecurityView(data, report, extras = {}) {
const cases = Array.isArray(extras.cases?.cases) ? extras.cases.cases : []; const cases = Array.isArray(extras.cases?.cases) ? extras.cases.cases : [];
return ` return `
${renderSecurityEventsSummary(report?.security_events_summary)}
${renderRiskIncidentCandidates(report?.risk_incident_candidates)} ${renderRiskIncidentCandidates(report?.risk_incident_candidates)}
${renderSecurityCorrelation(report?.security_correlation)} ${renderSecurityCorrelation(report?.security_correlation)}
${renderCases(cases)} ${renderCases(cases)}
@@ -1185,6 +1188,7 @@ function renderOperationsView(data, report) {
return ` return `
${renderAgentCoverageSla(report?.agent_coverage_sla)} ${renderAgentCoverageSla(report?.agent_coverage_sla)}
${renderAgentQuality(report?.agent_quality, report?.agent_quality_explain)} ${renderAgentQuality(report?.agent_quality, report?.agent_quality_explain)}
${renderSecurityEventsSummary(report?.security_events_summary, { operations: true })}
${renderOperationsErrors(data, report)} ${renderOperationsErrors(data, report)}
<section class="dashboard-band technical-band"> <section class="dashboard-band technical-band">
<div class="band-head"><h3>Телеметрия</h3><span class="muted">свежесть, стабильность и источники данных</span></div> <div class="band-head"><h3>Телеметрия</h3><span class="muted">свежесть, стабильность и источники данных</span></div>
@@ -1201,6 +1205,13 @@ function renderOperationsErrors(data, report) {
if (collectorError) { if (collectorError) {
rows.push(["Ошибка коллектора", collectorError, report?.agent_quality?.quality_status || "DEGRADED"]); rows.push(["Ошибка коллектора", collectorError, report?.agent_quality?.quality_status || "DEGRADED"]);
} }
if (report?.security_events_summary?.fallback_used) {
rows.push([
"События безопасности",
report.security_events_summary.error || "ClickHouse недоступен",
"WARN",
]);
}
const problemNodes = Array.isArray(report?.agent_coverage_sla?.problem_nodes) const problemNodes = Array.isArray(report?.agent_coverage_sla?.problem_nodes)
? report.agent_coverage_sla.problem_nodes ? report.agent_coverage_sla.problem_nodes
: []; : [];
@@ -1214,7 +1225,7 @@ function renderOperationsErrors(data, report) {
for (const [name, source] of Object.entries(data || {})) { for (const [name, source] of Object.entries(data || {})) {
if (!source || typeof source !== "object") continue; if (!source || typeof source !== "object") continue;
const status = source.status || (source.ok === false ? "FAIL" : ""); const status = source.status || (source.ok === false ? "FAIL" : "");
if (!status || ["OK", "READY", "INFO"].includes(String(status).toUpperCase())) continue; if (!status || ["OK", "READY", "INFO", "DISABLED"].includes(String(status).toUpperCase())) continue;
rows.push([label(name), source.summary || source.error || "требуется проверка", status]); rows.push([label(name), source.summary || source.error || "требуется проверка", status]);
} }
return ` return `
@@ -2139,6 +2150,74 @@ function renderAgentCoverageSla(sla) {
`; `;
} }
function renderSecurityEventsSummary(summary, options = {}) {
const s = summary || {};
const backend = s.backend || "disabled";
const disabled = backend === "disabled" || s.status === "disabled" || !summary;
const fallback = Boolean(s.fallback_used);
const status = disabled ? "UNKNOWN" : fallback ? "WARN" : Number(s.events_24h || 0) > 0 ? "WARN" : "OK";
const title = options.compact
? "События безопасности"
: "События безопасности за 24 часа";
const subtitle = disabled
? "Агрегированный источник событий безопасности отключён."
: fallback
? "ClickHouse недоступен, портал работает в резервном режиме без событий безопасности."
: "Агрегированная сводка без сырых журналов и без автоматического создания инцидентов.";
const top = Array.isArray(s.top_departments) ? s.top_departments.slice(0, 5) : [];
const warning = fallback
? `<div class="quality-warning">События безопасности временно недоступны. Проверьте ClickHouse и переменные SECURITY_EVENTS_BACKEND/CLICKHOUSE_*.</div>`
: "";
return `
<section class="card security-events-card">
<div class="section-head">
<div>
<h3 ${tooltip("Краткая агрегированная сводка событий безопасности за последние 24 часа. Это не SIEM-журнал.")}>${ui(title)}</h3>
<p class="muted">${ui(subtitle)}</p>
</div>
<span class="badge ${statusClass(status)}">${ui(status)}</span>
</div>
<div class="quality-grid">
<div><span class="muted">Источник</span><strong>${ui(backend)}</strong></div>
<div><span class="muted">Событий</span><strong>${ui(s.events_24h ?? 0)}</strong></div>
<div><span class="muted">Неуспешные входы</span><strong>${ui(s.failed_logins_24h ?? 0)}</strong></div>
<div><span class="muted">Подозрительные входы</span><strong>${ui(s.suspicious_logins_24h ?? 0)}</strong></div>
<div><span class="muted">RDP-сессии</span><strong>${ui(s.rdp_sessions_24h ?? 0)}</strong></div>
<div><span class="muted">Ошибки агентов</span><strong>${ui(s.agent_errors_24h ?? 0)}</strong></div>
</div>
${warning}
${s.error ? `<p class="muted small">Причина: ${ui(s.error)}</p>` : ""}
${disabled ? `<p class="muted small">Для включения задайте SECURITY_EVENTS_BACKEND=clickhouse и параметры CLICKHOUSE_*.</p>` : ""}
${options.compact ? "" : `
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Подразделение</th>
<th>События</th>
</tr>
</thead>
<tbody>
${top.length ? top.map(item => `
<tr>
<td>${ui(item.department || "Без подразделения")}</td>
<td>${ui(item.events ?? 0)}</td>
</tr>
`).join("") : `
<tr>
<td>${disabled ? "Источник отключён" : "Нет данных"}</td>
<td>0</td>
</tr>
`}
</tbody>
</table>
</div>
<p class="muted small">Последнее событие: ${ui(s.last_event_utc || "нет данных")} · запрос ${ui(s.query_ms ?? 0)} ms.</p>
`}
</section>
`;
}
function renderBusinessRisk(items) { function renderBusinessRisk(items) {
const rows = Array.isArray(items) ? items.slice(0, 10) : []; const rows = Array.isArray(items) ? items.slice(0, 10) : [];
const worst = rows[0]?.risk_level || "UNKNOWN"; const worst = rows[0]?.risk_level || "UNKNOWN";
@@ -2158,6 +2237,7 @@ function renderBusinessRisk(items) {
<th>Подразделение</th> <th>Подразделение</th>
<th>Риск</th> <th>Риск</th>
<th>Причины</th> <th>Причины</th>
<th>События</th>
<th>Рекомендация</th> <th>Рекомендация</th>
</tr> </tr>
</thead> </thead>
@@ -2167,6 +2247,7 @@ function renderBusinessRisk(items) {
<td><strong>${ui(item.department || "Без подразделения")}</strong></td> <td><strong>${ui(item.department || "Без подразделения")}</strong></td>
<td><span class="badge ${statusClass(item.risk_level)}">${ui(item.risk_level || "UNKNOWN")}</span></td> <td><span class="badge ${statusClass(item.risk_level)}">${ui(item.risk_level || "UNKNOWN")}</span></td>
<td>${ui(businessRiskReasons(item))}</td> <td>${ui(businessRiskReasons(item))}</td>
<td>${ui(item.security_events_24h ?? 0)}</td>
<td>${ui(item.recommendation || "Проверить первичные данные подразделения.")}</td> <td>${ui(item.recommendation || "Проверить первичные данные подразделения.")}</td>
</tr> </tr>
`).join("") : ` `).join("") : `
@@ -2174,6 +2255,7 @@ function renderBusinessRisk(items) {
<td>Нет данных</td> <td>Нет данных</td>
<td><span class="badge status-unknown">UNKNOWN</span></td> <td><span class="badge status-unknown">UNKNOWN</span></td>
<td>нет данных</td> <td>нет данных</td>
<td>0</td>
<td>Дождаться расчета подразделений.</td> <td>Дождаться расчета подразделений.</td>
</tr> </tr>
`} `}
@@ -2205,6 +2287,7 @@ function renderRiskHeatmap(items) {
<th>Активность</th> <th>Активность</th>
<th>Покрытие</th> <th>Покрытие</th>
<th>Риск</th> <th>Риск</th>
<th>События</th>
<th>Расследования</th> <th>Расследования</th>
<th>Связи</th> <th>Связи</th>
</tr> </tr>
@@ -2220,6 +2303,7 @@ function renderRiskHeatmap(items) {
<span class="badge ${statusClass(item.heat_level)}">${ui(item.heat_level || "UNKNOWN")}</span><br> <span class="badge ${statusClass(item.heat_level)}">${ui(item.heat_level || "UNKNOWN")}</span><br>
<span class="muted small">${ui(item.business_risk_level || "UNKNOWN")} · проверить ${ui(item.critical_candidates ?? 0)}</span> <span class="muted small">${ui(item.business_risk_level || "UNKNOWN")} · проверить ${ui(item.critical_candidates ?? 0)}</span>
</td> </td>
<td>${ui(item.security_events_24h ?? 0)}</td>
<td>${ui(item.open_cases ?? 0)}</td> <td>${ui(item.open_cases ?? 0)}</td>
<td>${renderRiskLayerLinks(item.links)}</td> <td>${renderRiskLayerLinks(item.links)}</td>
</tr> </tr>
@@ -2231,6 +2315,7 @@ function renderRiskHeatmap(items) {
<td>UNKNOWN</td> <td>UNKNOWN</td>
<td><span class="badge status-unknown">UNKNOWN</span></td> <td><span class="badge status-unknown">UNKNOWN</span></td>
<td>0</td> <td>0</td>
<td>0</td>
<td>-</td> <td>-</td>
</tr> </tr>
`} `}
@@ -2255,6 +2340,7 @@ function riskLayerTarget(target) {
if (value === "risk_heatmap") return { tab: "operator", selector: ".risk-heatmap-card" }; if (value === "risk_heatmap") return { tab: "operator", selector: ".risk-heatmap-card" };
if (value === "business_risk") return { tab: "operator", selector: "#business-risk-section" }; if (value === "business_risk") return { tab: "operator", selector: "#business-risk-section" };
if (value === "security_correlation") return { tab: "operator", selector: ".security-correlation-card" }; if (value === "security_correlation") return { tab: "operator", selector: ".security-correlation-card" };
if (value === "security_events") return { tab: "operator", selector: ".security-events-card" };
if (value === "incident_candidates") return { tab: "operator", selector: "#risk-candidates-section" }; if (value === "incident_candidates") return { tab: "operator", selector: "#risk-candidates-section" };
if (value === "cases") return { tab: "incidents", selector: "#cases-section" }; if (value === "cases") return { tab: "incidents", selector: "#cases-section" };
if (value === "agent_coverage") return { tab: "operator", selector: "#agent-coverage-section" }; if (value === "agent_coverage") return { tab: "operator", selector: "#agent-coverage-section" };
@@ -2288,6 +2374,7 @@ function renderSecurityCorrelation(items) {
<th>Активность</th> <th>Активность</th>
<th>Риск подразделения</th> <th>Риск подразделения</th>
<th>Проверки</th> <th>Проверки</th>
<th>События</th>
<th>Взаимосвязь</th> <th>Взаимосвязь</th>
<th>Причина</th> <th>Причина</th>
</tr> </tr>
@@ -2300,6 +2387,7 @@ function renderSecurityCorrelation(items) {
<td>${ui(riskPercentText(item.activity_score))}</td> <td>${ui(riskPercentText(item.activity_score))}</td>
<td><span class="badge ${statusClass(item.business_risk_level)}">${ui(item.business_risk_level || "UNKNOWN")}</span></td> <td><span class="badge ${statusClass(item.business_risk_level)}">${ui(item.business_risk_level || "UNKNOWN")}</span></td>
<td>проверить ${ui(item.critical_candidates ?? 0)} · расследования ${ui(item.open_cases ?? 0)}</td> <td>проверить ${ui(item.critical_candidates ?? 0)} · расследования ${ui(item.open_cases ?? 0)}</td>
<td>${ui(item.security_events_24h ?? 0)}</td>
<td><strong>${ui(Number(item.correlation_score || 0))}/100</strong></td> <td><strong>${ui(Number(item.correlation_score || 0))}/100</strong></td>
<td>${ui(item.explanation || item.correlation_reason || "связь не выражена")}</td> <td>${ui(item.explanation || item.correlation_reason || "связь не выражена")}</td>
</tr> </tr>
@@ -2310,6 +2398,7 @@ function renderSecurityCorrelation(items) {
<td>UNKNOWN</td> <td>UNKNOWN</td>
<td><span class="badge status-unknown">UNKNOWN</span></td> <td><span class="badge status-unknown">UNKNOWN</span></td>
<td>проверить 0 · расследования 0</td> <td>проверить 0 · расследования 0</td>
<td>0</td>
<td>0/100</td> <td>0/100</td>
<td>недостаточно данных по подразделениям</td> <td>недостаточно данных по подразделениям</td>
</tr> </tr>
@@ -2325,7 +2414,7 @@ function businessRiskReasons(item) {
const reasons = Array.isArray(item?.reasons) && item.reasons.length const reasons = Array.isArray(item?.reasons) && item.reasons.length
? item.reasons.join("; ") ? item.reasons.join("; ")
: "существенных причин не найдено"; : "существенных причин не найдено";
return `${reasons}. Доверие ${item?.trust_score ?? 0}%, активность ${item?.activity_score ?? 0}%, тренд ${businessTrendText(item?.trend)}, проблемных рабочих мест ${item?.problem_nodes_count ?? 0}`; return `${reasons}. Доверие ${item?.trust_score ?? 0}%, активность ${item?.activity_score ?? 0}%, тренд ${businessTrendText(item?.trend)}, проблемных рабочих мест ${item?.problem_nodes_count ?? 0}, событий безопасности за 24 часа ${item?.security_events_24h ?? 0}`;
} }
function renderBusinessRiskTimeline(history, summary) { function renderBusinessRiskTimeline(history, summary) {
@@ -2571,6 +2660,7 @@ function renderReports(data) {
${renderAgentQualityHistory(data.agent_quality_history, data.agent_quality_history_summary)} ${renderAgentQualityHistory(data.agent_quality_history, data.agent_quality_history_summary)}
${renderAgentQualityNodes(data.agent_quality_nodes, data.agent_quality_nodes_summary)} ${renderAgentQualityNodes(data.agent_quality_nodes, data.agent_quality_nodes_summary)}
${renderAgentCoverageSla(data.agent_coverage_sla)} ${renderAgentCoverageSla(data.agent_coverage_sla)}
${renderSecurityEventsSummary(data.security_events_summary)}
${renderRiskHeatmap(data.risk_heatmap)} ${renderRiskHeatmap(data.risk_heatmap)}
${renderSecurityCorrelation(data.security_correlation)} ${renderSecurityCorrelation(data.security_correlation)}
${renderBusinessRisk(data.business_risk)} ${renderBusinessRisk(data.business_risk)}
+5
View File
@@ -59,6 +59,11 @@
DETMIR_PORTAL_READINESS_BUNDLE_DIR={{ detmir_portal_readiness_bundle_dir }} DETMIR_PORTAL_READINESS_BUNDLE_DIR={{ detmir_portal_readiness_bundle_dir }}
DETMIR_PORTAL_EVIDENCE_LIMIT=30 DETMIR_PORTAL_EVIDENCE_LIMIT=30
DETMIR_PORTAL_EVIDENCE_MAX_BYTES=8388608 DETMIR_PORTAL_EVIDENCE_MAX_BYTES=8388608
SECURITY_EVENTS_BACKEND={{ detmir_security_events_backend | default('disabled') }}
CLICKHOUSE_URL={{ detmir_clickhouse_url | default('http://127.0.0.1:8123') }}
CLICKHOUSE_DATABASE={{ detmir_clickhouse_database | default('analytics_1c') }}
CLICKHOUSE_USER={{ detmir_clickhouse_user | default('default') }}
CLICKHOUSE_PASSWORD={{ detmir_clickhouse_password | default('') }}
- name: Install initial workforce policy when absent - name: Install initial workforce policy when absent
ansible.builtin.copy: ansible.builtin.copy:
+14 -1
View File
@@ -26,7 +26,8 @@
- `Руководитель` - главный вывод, сводка руководителя, риски подразделений и - `Руководитель` - главный вывод, сводка руководителя, риски подразделений и
карта рисков; карта рисков;
- `Безопасность` - записи, которые требуют проверки, связь рисков и - `Безопасность` - записи, которые требуют проверки, связь рисков и
активности, расследования, аудит решений и материалы расследования; активности, агрегированные события безопасности за 24 часа, расследования,
аудит решений и материалы расследования;
- `Эксплуатация` - полнота данных, качество сбора, ошибки и телеметрия рабочих - `Эксплуатация` - полнота данных, качество сбора, ошибки и телеметрия рабочих
мест. мест.
@@ -34,6 +35,18 @@
группирует существующие блоки портала под разные категории пользователей: группирует существующие блоки портала под разные категории пользователей:
директор, сотрудник безопасности и администратор эксплуатации. директор, сотрудник безопасности и администратор эксплуатации.
## События безопасности
Портал поддерживает необязательный источник агрегированных событий безопасности
из ClickHouse. По умолчанию он выключен и не влияет на работу портала.
Если источник включен, портал показывает только сводные числа за 24 часа:
общее количество событий, неуспешные и подозрительные входы, RDP-сессии,
изменения учетных записей, ошибки агентов и топ подразделений. Сырые журналы и
SIEM-представление не выводятся.
Подробности настройки: [SECURITY_EVENTS_CLICKHOUSE_RU.md](SECURITY_EVENTS_CLICKHOUSE_RU.md).
## Загрузка и актуальность данных ## Загрузка и актуальность данных
В верхней части портала есть глобальная полоса `Загрузка и актуальность В верхней части портала есть глобальная полоса `Загрузка и актуальность
+102
View File
@@ -0,0 +1,102 @@
# Агрегированные события безопасности через ClickHouse
## Назначение
`detmir-portal` может показывать краткую сводку событий безопасности за последние
24 часа из ClickHouse. Это дополнительный аналитический слой для управленческого
вывода, рисков подразделений, карты рисков и связи рисков с активностью.
Важно: этот режим не превращает AWatch-rus в SIEM. Портал не показывает сырые
журналы, не хранит SIEM-события как первичный источник и не создает инциденты
автоматически. В интерфейсе отображаются только агрегаты.
## Статус по умолчанию
Режим выключен:
```env
SECURITY_EVENTS_BACKEND=disabled
```
В выключенном режиме портал работает как раньше, `/api/reports` возвращает
валидный JSON, а `detmir-check` не проверяет ClickHouse.
## Переменные окружения
```env
SECURITY_EVENTS_BACKEND=clickhouse
CLICKHOUSE_URL=http://127.0.0.1:8123
CLICKHOUSE_DATABASE=analytics_1c
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=
```
`CLICKHOUSE_PASSWORD` используется только для HTTP Basic Auth и не выводится в
JSON, markdown-отчеты или health-ответы.
## Ожидаемые агрегаты
Портал формирует блок `security_events_summary`:
- `status`;
- `backend`;
- `events_24h`;
- `failed_logins_24h`;
- `suspicious_logins_24h`;
- `rdp_sessions_24h`;
- `account_changes_24h`;
- `agent_errors_24h`;
- `top_departments`;
- `last_event_utc`;
- `query_ms`;
- `fallback_used`.
Текущая реализация использует агрегированные запросы к таблицам
`entity_timeline` и `host_events` выбранной базы ClickHouse.
## Поведение при отказе
Если `SECURITY_EVENTS_BACKEND=clickhouse`, но ClickHouse недоступен:
- портал не падает;
- `/api/reports` остается валидным;
- `security_events_summary.fallback_used=true`;
- в ролевом представлении “Эксплуатация” показывается причина;
- `detmir-check` добавляет необязательное предупреждение, но не считает это
критическим отказом контура.
## Где видно в портале
- “Сводка руководителя”: короткий счетчик событий за 24 часа.
- “Главный вывод”: события учитываются как один из подтверждающих слоев риска.
- “Риски подразделений”: события повышают приоритет подразделения только как
агрегированный риск-фактор.
- “Карта рисков”: события отображаются отдельной колонкой.
- “Связь рисков и активности”: события участвуют в объяснении корреляции.
- “ИБ”: блок “События безопасности за 24 часа”.
- “Эксплуатация”: статус источника и причина fallback.
## Проверка
Выключенный режим:
```bash
SECURITY_EVENTS_BACKEND=disabled detmir-check --json
curl -s http://127.0.0.1:8720/portal/api/reports | jq '.security_events_summary'
```
Включенный режим:
```bash
SECURITY_EVENTS_BACKEND=clickhouse \
CLICKHOUSE_URL=http://127.0.0.1:8123 \
CLICKHOUSE_DATABASE=analytics_1c \
detmir-check --json
```
Ожидаемый результат при доступном ClickHouse: `security-events-clickhouse` имеет
`ok=true`, а `/api/reports.security_events_summary.backend="clickhouse"`.
Ожидаемый результат при недоступном ClickHouse: портал возвращает
`fallback_used=true`, а `detmir-check` показывает предупреждение
`security-events-clickhouse` с `required=false`.
+2
View File
@@ -199,6 +199,7 @@ async function main() {
"Расследования", "Расследования",
"Аудит", "Аудит",
"Материалы расследования", "Материалы расследования",
"События безопасности за 24 часа",
"Связь рисков и активности", "Связь рисков и активности",
].every((marker) => containsText(securityText, marker)), ].every((marker) => containsText(securityText, marker)),
}); });
@@ -218,6 +219,7 @@ async function main() {
"Представление эксплуатации", "Представление эксплуатации",
"Полнота данных", "Полнота данных",
"Качество данных", "Качество данных",
"События безопасности за 24 часа",
"Качество данных по рабочим местам", "Качество данных по рабочим местам",
"Ошибки", "Ошибки",
"Телеметрия", "Телеметрия",