diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 133372b..823f5a8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "ru.kalinamall.seaca" minSdk = 26 targetSdk = 35 - versionCode = 12 - versionName = "0.4.8" + versionCode = 13 + versionName = "0.4.9" } buildTypes { diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt index f7eb45d..9e2b56b 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt @@ -36,6 +36,7 @@ import ru.kalinamall.seaca.data.ProblemDetail import ru.kalinamall.seaca.data.SacRepository import ru.kalinamall.seaca.R import ru.kalinamall.seaca.ui.util.dailyReportTypeLabel +import ru.kalinamall.seaca.ui.util.formatEventDetails import ru.kalinamall.seaca.ui.util.inventoryFieldLines import ru.kalinamall.seaca.ui.util.isDailyReportType import ru.kalinamall.seaca.ui.util.reportTextFromDetails @@ -71,11 +72,30 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { reportTextFromDetails(d.details)?.let { MultilineField("Отчёт", it) } ?: Field("Отчёт", "Полный текст отчёта недоступен") } else { - d.details?.let { Field("Детали", it.toPrettyString()) } + EventDetailsSection(d.type, d.details, d.payload) } } } +@Composable +private fun EventDetailsSection(type: String, details: JsonElement?, payload: JsonElement?) { + val formatted = formatEventDetails(type, details, payload) + if (formatted?.hasContent == true) { + Text( + "Детали", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp), + ) + formatted.multilineText?.let { text -> + MultilineField(formatted.multilineLabel ?: "Сообщение", text) + } + formatted.fields.forEach { (label, value) -> Field(label, value) } + } else if (details != null) { + Field("Детали", details.toPrettyString()) + } +} + @Composable fun ProblemDetailScreen( repo: SacRepository, diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/util/DetailFormat.kt b/app/src/main/java/ru/kalinamall/seaca/ui/util/DetailFormat.kt index 1d33438..b6ef159 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/util/DetailFormat.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/util/DetailFormat.kt @@ -2,11 +2,21 @@ package ru.kalinamall.seaca.ui.util import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +data class FormattedEventDetails( + val fields: List> = emptyList(), + val multilineLabel: String? = null, + val multilineText: String? = null, +) { + val hasContent: Boolean + get() = fields.isNotEmpty() || !multilineText.isNullOrBlank() +} + fun isDailyReportType(type: String): Boolean = type == "report.daily.ssh" || type == "report.daily.rdp" @@ -16,6 +26,11 @@ fun dailyReportTypeLabel(type: String): String = when (type) { else -> type } +private fun isLoginEventType(type: String): Boolean = + type.startsWith("rdp.login.") || + type.startsWith("ssh.login.") || + type.startsWith("agent.login.") + /** Текст ежедневного отчёта из details (не сырой JSON). */ fun reportTextFromDetails(details: JsonElement?): String? { if (details == null) return null @@ -48,13 +63,247 @@ private fun stripHtmlTags(html: String): String = .replace("&", "&") .trim() -private fun jsonString(obj: JsonObject, key: String): String? = - obj[key]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotEmpty() } +fun formatEventDetails( + type: String, + details: JsonElement?, + payload: JsonElement? = null, +): FormattedEventDetails? { + val obj = details as? JsonObject ?: return null + return when (type) { + "agent.lifecycle" -> formatLifecycleDetails(obj, payload) + "agent.inventory" -> formatInventoryEventDetails(obj) + "rdp.bruteforce.burst" -> formatBruteforceBurstDetails(obj) + else -> when { + isLoginEventType(type) -> formatLoginDetails(obj) + else -> { + val fields = extractStructuredDetailFields(obj) + if (fields.isEmpty()) null else FormattedEventDetails(fields = fields) + } + } + } +} + +private fun formatLifecycleDetails(obj: JsonObject, payload: JsonElement?): FormattedEventDetails { + jsonString(obj, "notification_body")?.let { body -> + return FormattedEventDetails( + multilineLabel = "Сообщение", + multilineText = normalizeReportPlainText(body), + ) + } + val fields = mutableListOf>() + jsonString(obj, "lifecycle")?.let { fields += "Событие" to lifecycleLabel(it) } + jsonString(obj, "trigger")?.let { fields += "Причина" to lifecycleTriggerLabel(it) } + sourceProductLine(payload)?.let { fields += it } + return FormattedEventDetails(fields = fields) +} + +private fun formatInventoryEventDetails(obj: JsonObject): FormattedEventDetails { + val changes = obj["hardware_changes"]?.jsonArray + if (changes != null && changes.isNotEmpty()) { + val fields = changes.mapIndexedNotNull { index, element -> + val change = element.jsonObject + val field = jsonString(change, "field") ?: return@mapIndexedNotNull null + val old = primitiveDisplay(change["old"]) ?: "—" + val new = primitiveDisplay(change["new"]) ?: "—" + "Изменение ${index + 1}" to "${hardwareFieldLabel(field)}: $old → $new" + } + return FormattedEventDetails(fields = fields) + } + val inventory = obj["inventory"] + if (inventory != null) { + val lines = inventoryFieldLines(inventory) + if (lines.isNotEmpty()) return FormattedEventDetails(fields = lines) + } + val fallback = extractStructuredDetailFields(obj) + return if (fallback.isEmpty()) FormattedEventDetails() else FormattedEventDetails(fields = fallback) +} + +private fun formatBruteforceBurstDetails(obj: JsonObject): FormattedEventDetails { + val fields = mutableListOf>() + jsonString(obj, "tier")?.let { fields += "Уровень" to it } + primitiveDisplay(obj["count"])?.let { fields += "Количество" to it } + jsonString(obj, "bucket_key")?.let { fields += "Ключ" to it } + jsonString(obj, "ip_address", "source_ip", "ip")?.let { fields += "IP" to it } + jsonString(obj, "user", "username")?.let { fields += "Пользователь" to it } + if (fields.isEmpty()) { + return FormattedEventDetails(fields = extractStructuredDetailFields(obj)) + } + return FormattedEventDetails(fields = fields) +} + +private fun formatLoginDetails(obj: JsonObject): FormattedEventDetails { + jsonString(obj, "notification_body")?.let { body -> + return FormattedEventDetails( + multilineLabel = "Сообщение", + multilineText = normalizeReportPlainText(body), + ) + } + val fields = mutableListOf>() + jsonString(obj, "user", "username")?.let { fields += "Пользователь" to it } + jsonString(obj, "ip_address", "source_ip", "ip")?.let { fields += "IP" to it } + jsonString(obj, "workstation_name", "computer_name")?.let { fields += "Рабочая станция" to it } + jsonString(obj, "process_name", "process")?.let { fields += "Процесс" to it } + jsonPrimitiveString(obj, "logon_type")?.let { fields += "Тип входа" to logonTypeLabel(it) } + jsonPrimitiveString(obj, "port")?.let { fields += "Порт" to it } + val attempt = jsonPrimitiveString(obj, "attempt_number") + val maxAttempts = jsonPrimitiveString(obj, "max_attempts") + if (attempt != null && maxAttempts != null) { + fields += "Попытка" to "$attempt / $maxAttempts" + } + jsonPrimitiveString(obj, "event_id_windows")?.let { fields += "Event ID Windows" to it } + if (fields.isEmpty()) { + return FormattedEventDetails(fields = extractStructuredDetailFields(obj)) + } + return FormattedEventDetails(fields = fields) +} + +private fun extractStructuredDetailFields(obj: JsonObject): List> { + val fields = mutableListOf>() + val consumed = mutableSetOf() + + fun add(label: String, vararg keys: String) { + jsonString(obj, *keys)?.let { + fields += label to it + keys.forEach { key -> consumed += key } + } + jsonPrimitiveString(obj, *keys)?.let { + fields += label to it + keys.forEach { key -> consumed += key } + } + } + + add("Пользователь", "user", "username", "shadower_user", "target_user") + add("IP", "ip_address", "source_ip", "ip", "external_ip", "internal_ip") + add("Рабочая станция", "workstation_name", "computer_name") + add("Процесс", "process_name", "process") + add("Порт", "port") + add("Команда", "command") + add("Share", "share_name") + add("Путь", "share_path") + add("ResourceUri", "resource_uri") + add("Session ID", "session_id", "target_session_id") + add("Event ID Windows", "event_id_windows") + add("Уровень", "tier", "risk_level") + add("Количество", "count") + add("Ключ", "bucket_key") + + jsonPrimitiveString(obj, "logon_type")?.let { + fields += "Тип входа" to logonTypeLabel(it) + consumed += "logon_type" + } + + obj.forEach { (key, value) -> + if (key in consumed || key in SKIP_DETAIL_KEYS) return@forEach + if (value is JsonPrimitive) { + val text = primitiveDisplay(value) ?: return@forEach + fields += detailKeyLabel(key) to text + } + } + + return fields +} + +private val SKIP_DETAIL_KEYS = setOf( + "telegram_via", + "generated_by", + "report_body", + "report_html", + "inventory", + "hardware_changes", + "notification_body", + "lifecycle", + "trigger", +) + +private fun detailKeyLabel(key: String): String = when (key) { + "user" -> "Пользователь" + "username" -> "Пользователь" + "source_ip" -> "IP" + "ip_address" -> "IP" + "workstation_name" -> "Рабочая станция" + "event_id_windows" -> "Event ID Windows" + "logon_type" -> "Тип входа" + "attempt_number" -> "Попытка" + "max_attempts" -> "Макс. попыток" + else -> key.replace('_', ' ').replaceFirstChar { it.uppercase() } +} + +private fun lifecycleLabel(value: String): String = when (value.lowercase()) { + "started" -> "Запущен" + "stopped" -> "Остановлен" + "settings_reloaded" -> "Настройки перечитаны" + else -> value +} + +private fun lifecycleTriggerLabel(value: String): String = when (value.lowercase()) { + "boot" -> "Загрузка ОС / планировщик" + "deploy_recycle" -> "Обновление скрипта (recycle)" + "settings_reload" -> "Graceful restart (settings)" + "manual_recycle" -> "Ручной recycle" + "shutdown" -> "Остановка процесса" + else -> value +} + +private fun logonTypeLabel(raw: String): String { + val code = raw.trim().toIntOrNull() + val name = when (code) { + 2 -> "Interactive" + 3 -> "Network" + 4 -> "Batch" + 5 -> "Service" + 7 -> "Unlock" + 8 -> "NetworkCleartext" + 9 -> "NewCredentials" + 10 -> "RemoteInteractive" + 11 -> "CachedInteractive" + else -> null + } + return when { + name != null && code != null -> "$name ($code)" + else -> raw + } +} + +private fun hardwareFieldLabel(field: String): String = when (field) { + "memory_gb" -> "Память (GB)" + "processor" -> "Процессор" + else -> field +} + +private fun sourceProductLine(payload: JsonElement?): Pair? { + val source = payload?.jsonObject?.get("source")?.jsonObject ?: return null + val product = jsonString(source, "product") + val version = jsonString(source, "product_version") + val line = listOfNotNull(product, version).joinToString(" ").trim() + return if (line.isEmpty()) null else "Агент" to line +} + +private fun jsonString(obj: JsonObject, vararg keys: String): String? { + for (key in keys) { + val value = obj[key]?.jsonPrimitive?.contentOrNull?.trim() + if (!value.isNullOrEmpty()) return value + } + return null +} + +private fun jsonPrimitiveString(obj: JsonObject, vararg keys: String): String? { + for (key in keys) { + val el = obj[key] ?: continue + primitiveDisplay(el)?.let { return it } + } + return null +} + +private fun primitiveDisplay(el: JsonElement?): String? = + when (el) { + is JsonPrimitive -> el.contentOrNull?.trim()?.takeIf { it.isNotEmpty() } + else -> null + } private fun jsonInt(obj: JsonObject, key: String): Int? = obj[key]?.jsonPrimitive?.contentOrNull?.toIntOrNull() -/** Плоский список полей инвентаризации для карточки хоста. */ +/** Плоский список полей инвентаризации для карточки хоста / agent.inventory. */ fun inventoryFieldLines(inventory: JsonElement): List> { val obj = inventory as? JsonObject ?: return emptyList() val out = mutableListOf>()