feat(portal): add optional ClickHouse security events summary
This commit is contained in:
@@ -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)}
|
||||||
|
|||||||
@@ -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
@@ -26,7 +26,8 @@
|
|||||||
- `Руководитель` - главный вывод, сводка руководителя, риски подразделений и
|
- `Руководитель` - главный вывод, сводка руководителя, риски подразделений и
|
||||||
карта рисков;
|
карта рисков;
|
||||||
- `Безопасность` - записи, которые требуют проверки, связь рисков и
|
- `Безопасность` - записи, которые требуют проверки, связь рисков и
|
||||||
активности, расследования, аудит решений и материалы расследования;
|
активности, агрегированные события безопасности за 24 часа, расследования,
|
||||||
|
аудит решений и материалы расследования;
|
||||||
- `Эксплуатация` - полнота данных, качество сбора, ошибки и телеметрия рабочих
|
- `Эксплуатация` - полнота данных, качество сбора, ошибки и телеметрия рабочих
|
||||||
мест.
|
мест.
|
||||||
|
|
||||||
@@ -34,6 +35,18 @@
|
|||||||
группирует существующие блоки портала под разные категории пользователей:
|
группирует существующие блоки портала под разные категории пользователей:
|
||||||
директор, сотрудник безопасности и администратор эксплуатации.
|
директор, сотрудник безопасности и администратор эксплуатации.
|
||||||
|
|
||||||
|
## События безопасности
|
||||||
|
|
||||||
|
Портал поддерживает необязательный источник агрегированных событий безопасности
|
||||||
|
из ClickHouse. По умолчанию он выключен и не влияет на работу портала.
|
||||||
|
|
||||||
|
Если источник включен, портал показывает только сводные числа за 24 часа:
|
||||||
|
общее количество событий, неуспешные и подозрительные входы, RDP-сессии,
|
||||||
|
изменения учетных записей, ошибки агентов и топ подразделений. Сырые журналы и
|
||||||
|
SIEM-представление не выводятся.
|
||||||
|
|
||||||
|
Подробности настройки: [SECURITY_EVENTS_CLICKHOUSE_RU.md](SECURITY_EVENTS_CLICKHOUSE_RU.md).
|
||||||
|
|
||||||
## Загрузка и актуальность данных
|
## Загрузка и актуальность данных
|
||||||
|
|
||||||
В верхней части портала есть глобальная полоса `Загрузка и актуальность
|
В верхней части портала есть глобальная полоса `Загрузка и актуальность
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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 часа",
|
||||||
"Качество данных по рабочим местам",
|
"Качество данных по рабочим местам",
|
||||||
"Ошибки",
|
"Ошибки",
|
||||||
"Телеметрия",
|
"Телеметрия",
|
||||||
|
|||||||
Reference in New Issue
Block a user