App core
Application entry point, the always-on foreground service, and settings storage.
app/src/main/java/org/wazuh/androidagent/AgentApplication.kt
package org.wazuh.androidagent
import android.app.Application
import org.wazuh.androidagent.net.LogQueue
import org.wazuh.androidagent.util.CrashCatcher
class AgentApplication : Application() {
override fun onCreate() {
super.onCreate()
CrashCatcher.install(LogQueue(this))
}
}
app/src/main/java/org/wazuh/androidagent/AgentForegroundService.kt
package org.wazuh.androidagent
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import org.wazuh.androidagent.collectors.BatteryReceiver
import org.wazuh.androidagent.collectors.DeviceInfoCollector
import org.wazuh.androidagent.collectors.InstalledAppsCollector
import org.wazuh.androidagent.collectors.NetworkStateReceiver
import org.wazuh.androidagent.collectors.SystemLogCollector
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.LogQueue
import org.wazuh.androidagent.net.Severity
import org.wazuh.androidagent.net.SyslogConfig
import org.wazuh.androidagent.net.SyslogSender
import org.wazuh.androidagent.wazuhagent.KeepAliveBuilder
import org.wazuh.androidagent.wazuhagent.WazuhAgentConnection
import org.wazuh.androidagent.yara.YaraScanner
import java.time.Instant
/**
* The agent's core: a visible, persistent foreground service (Android requires
* this to be shown to the user — there is no hidden/background-only mode) that
* runs the collectors and keeps a syslog connection to the configured Wazuh
* manager open, draining the local queue as connectivity allows.
*/
class AgentForegroundService : Service() {
private val binder = LocalBinder()
private lateinit var queue: LogQueue
private lateinit var settings: SettingsStore
private var syslogSender: SyslogSender? = null
private var agentConnection: WazuhAgentConnection? = null
private lateinit var installedAppsCollector: InstalledAppsCollector
private lateinit var yaraScanner: YaraScanner
private var handlerThread: HandlerThread? = null
private var handler: Handler? = null
private var networkReceiver: NetworkStateReceiver? = null
private var batteryReceiver: BatteryReceiver? = null
@Volatile var lastConnected: Boolean = false
private set
@Volatile var lastError: String? = null
private set
inner class LocalBinder : Binder() {
fun getService(): AgentForegroundService = this@AgentForegroundService
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onCreate() {
super.onCreate()
queue = LogQueue(applicationContext)
settings = SettingsStore(applicationContext)
installedAppsCollector = InstalledAppsCollector(applicationContext)
yaraScanner = YaraScanner(applicationContext)
createNotificationChannel()
startForeground(NOTIFICATION_ID, buildNotification("Starting…"))
startTransport()
registerReceivers()
startPeriodicCollectors()
queue.enqueue(
LogEvent(
timestamp = Instant.now(),
msgId = "agent_started",
severity = Severity.NOTICE,
fields = mapOf("version" to BuildConfigShim.VERSION_NAME)
)
)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
override fun onDestroy() {
queue.enqueue(
LogEvent(
timestamp = Instant.now(),
msgId = "agent_stopped",
severity = Severity.NOTICE,
fields = emptyMap()
)
)
syslogSender?.stop()
agentConnection?.stop()
unregisterReceivers()
handlerThread?.quitSafely()
super.onDestroy()
}
/** Call after Settings change so the active transport reconnects with the new destination. */
fun reloadConfig() {
syslogSender?.updateConfig(currentSyslogConfig())
// The agent-protocol connection's identity/host rarely changes without re-enrolling;
// a full service restart (stop/start) is the simple, reliable way to pick that up.
}
fun enqueueTestEvent() {
queue.enqueue(
LogEvent(
timestamp = Instant.now(),
msgId = "test_event",
severity = Severity.INFO,
fields = mapOf("message" to "Manual test event from Wazuh Android Agent")
)
)
}
fun queueSize(): Int = queue.size()
fun sentCount(): Long = syslogSender?.sentCount ?: agentConnection?.sentCount ?: 0
fun isConnected(): Boolean = syslogSender?.isConnected() ?: agentConnection?.isConnected() ?: false
private fun deviceLabel(): String = settings.deviceLabel.ifBlank {
android.provider.Settings.Secure.getString(contentResolver, android.provider.Settings.Secure.ANDROID_ID)
?: "android-device"
}
private fun startTransport() {
when (settings.transportMode) {
TransportMode.SYSLOG -> {
val s = SyslogSender(queue, currentSyslogConfig())
s.onStateChange = { connected, error ->
lastConnected = connected
lastError = error
updateNotification()
}
s.start()
syslogSender = s
}
TransportMode.WAZUH_AGENT -> {
val identity = settings.agentIdentity()
if (identity == null) {
lastConnected = false
lastError = "Not enrolled - enroll in the app first"
updateNotification()
return
}
val label = deviceLabel()
val c = WazuhAgentConnection(
queue = queue,
host = settings.managerHost,
port = settings.agentProtocolPort,
identity = identity,
keepAliveMessageProvider = { KeepAliveBuilder.build(label) }
)
c.onStateChange = { connected, error ->
lastConnected = connected
lastError = error
updateNotification()
}
c.start()
agentConnection = c
}
}
}
private fun currentSyslogConfig(): SyslogConfig {
return SyslogConfig(
host = settings.managerHost,
port = settings.managerPort,
useTls = settings.useTls,
trustAllCerts = settings.trustAllCerts,
customCaPem = settings.customCaPem,
hostnameLabel = deviceLabel(),
facility = settings.syslogFacility
)
}
private fun registerReceivers() {
// These are all system-only protected broadcasts, so NOT_EXPORTED is correct
// and required on API 33+ (registerReceiver() throws without an explicit flag).
networkReceiver = NetworkStateReceiver { if (settings.collectNetworkState) queue.enqueue(it) }
ContextCompat.registerReceiver(
this, networkReceiver, NetworkStateReceiver.intentFilter(),
ContextCompat.RECEIVER_NOT_EXPORTED
)
batteryReceiver = BatteryReceiver { if (settings.collectBattery) queue.enqueue(it) }
ContextCompat.registerReceiver(
this, batteryReceiver, BatteryReceiver.intentFilter(),
ContextCompat.RECEIVER_NOT_EXPORTED
)
}
private fun unregisterReceivers() {
try { networkReceiver?.let { unregisterReceiver(it) } } catch (ignored: Exception) {}
try { batteryReceiver?.let { unregisterReceiver(it) } } catch (ignored: Exception) {}
}
private fun startPeriodicCollectors() {
val thread = HandlerThread("wazuh-agent-collectors").apply { start() }
handlerThread = thread
val h = Handler(thread.looper)
handler = h
// Device info: once immediately, then every 30 minutes. Checked on every
// tick (not just when scheduled) so toggling it off in Settings takes
// effect immediately without restarting the agent.
val deviceInfoRunnable = object : Runnable {
override fun run() {
if (settings.collectDeviceInfo) queue.enqueue(DeviceInfoCollector.collect(applicationContext))
h.postDelayed(this, DEVICE_INFO_INTERVAL_MS)
}
}
h.post(deviceInfoRunnable)
// Installed-app deltas every 15 minutes; a full inventory snapshot once a day.
val appsRunnable = object : Runnable {
var lastSnapshot = 0L
override fun run() {
val now = System.currentTimeMillis()
if (settings.collectInstalledApps) {
val forceSnapshot = now - lastSnapshot > SNAPSHOT_INTERVAL_MS
if (forceSnapshot) lastSnapshot = now
val deltaEvents = installedAppsCollector.collectDeltasAndMaybeSnapshot(forceSnapshot)
deltaEvents.forEach { queue.enqueue(it) }
if (settings.collectYaraScan) scanNewOrUpdatedApks(deltaEvents)
}
h.postDelayed(this, APPS_CHECK_INTERVAL_MS)
}
}
h.post(appsRunnable)
// Full system log (logcat), only if both the in-app toggle is on AND
// READ_LOGS was separately granted via ADB — SystemLogCollector.collect()
// simply returns nothing if the permission isn't actually there, so this
// is safe to poll on a schedule even before that ADB step has been done.
val systemLogRunnable = object : Runnable {
override fun run() {
if (settings.collectFullSystemLogs) {
val (event, newestTs) = SystemLogCollector.collect(settings.lastSystemLogTimestamp)
if (event != null) queue.enqueue(event)
if (newestTs != null) settings.lastSystemLogTimestamp = newestTs
}
h.postDelayed(this, SYSTEM_LOG_CHECK_INTERVAL_MS)
}
}
h.post(systemLogRunnable)
}
/**
* Runs the YARA scan only against packages that just showed up as
* app_installed/app_updated in this cycle's deltas — not a continuous
* full-device sweep — so cost is bounded to "one APK, once, when it
* actually changes."
*/
private fun scanNewOrUpdatedApks(deltaEvents: List<LogEvent>) {
val customRules = settings.customYaraRules
deltaEvents
.filter { it.msgId == "app_installed" || it.msgId == "app_updated" }
.forEach { evt ->
val pkg = evt.fields["package_name"] as? String ?: return@forEach
val apkPath = try {
applicationContext.packageManager.getApplicationInfo(pkg, 0).sourceDir
} catch (e: Exception) {
null // app was uninstalled again between the delta check and this scan
}
yaraScanner.scanApk(pkg, apkPath, customRules).forEach { queue.enqueue(it) }
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID, "Wazuh Agent Status", NotificationManager.IMPORTANCE_LOW
).apply {
description = "Shows that the Wazuh Android Agent is actively monitoring this device."
}
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(channel)
}
}
private fun buildNotification(status: String): android.app.Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Wazuh Agent — monitoring active")
.setContentText(status)
.setSmallIcon(android.R.drawable.ic_lock_lock)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
private fun updateNotification() {
val port = if (settings.transportMode == TransportMode.WAZUH_AGENT) settings.agentProtocolPort else settings.managerPort
val status = if (lastConnected) "Connected to ${settings.managerHost}:$port"
else "Reconnecting… (${lastError ?: "waiting"})"
val nm = getSystemService(NotificationManager::class.java)
nm.notify(NOTIFICATION_ID, buildNotification(status))
}
companion object {
private const val CHANNEL_ID = "wazuh_agent_status"
private const val NOTIFICATION_ID = 1
private const val DEVICE_INFO_INTERVAL_MS = 30 * 60 * 1000L
private const val APPS_CHECK_INTERVAL_MS = 15 * 60 * 1000L
private const val SNAPSHOT_INTERVAL_MS = 24 * 60 * 60 * 1000L
private const val SYSTEM_LOG_CHECK_INTERVAL_MS = 5 * 60 * 1000L
}
}
/** Small shim so we don't need to wire up Gradle's generated BuildConfig for one field. */
private object BuildConfigShim {
const val VERSION_NAME = "0.1.0"
}
app/src/main/java/org/wazuh/androidagent/BootReceiver.kt
package org.wazuh.androidagent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
/** Restarts the agent after device boot, only if the user had it enabled. */
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val settings = SettingsStore(context)
if (!settings.agentEnabled || !settings.isConfigured()) return
val serviceIntent = Intent(context, AgentForegroundService::class.java)
ContextCompat.startForegroundService(context, serviceIntent)
}
}
app/src/main/java/org/wazuh/androidagent/SettingsStore.kt
package org.wazuh.androidagent
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.wazuh.androidagent.net.Facility
import org.wazuh.androidagent.wazuhagent.AgentIdentity
enum class TransportMode { SYSLOG, WAZUH_AGENT }
/**
* Persists the manager destination and collection toggles. Backed by
* EncryptedSharedPreferences when the Android Keystore is available, falling
* back to plain SharedPreferences (e.g. on emulators without StrongBox/TEE).
*/
class SettingsStore(context: Context) {
private val prefs: SharedPreferences = try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"wazuh_agent_settings",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
Log.w("SettingsStore", "Falling back to unencrypted prefs: ${e.message}")
context.getSharedPreferences("wazuh_agent_settings_plain", Context.MODE_PRIVATE)
}
var managerHost: String
get() = prefs.getString(KEY_HOST, "") ?: ""
set(value) = prefs.edit().putString(KEY_HOST, value).apply()
var managerPort: Int
get() = prefs.getInt(KEY_PORT, 6514)
set(value) = prefs.edit().putInt(KEY_PORT, value).apply()
var useTls: Boolean
get() = prefs.getBoolean(KEY_TLS, true)
set(value) = prefs.edit().putBoolean(KEY_TLS, value).apply()
/** INSECURE: accepts any TLS certificate. Off by default; only for lab/testing against self-signed setups. */
var trustAllCerts: Boolean
get() = prefs.getBoolean(KEY_TRUST_ALL, false)
set(value) = prefs.edit().putBoolean(KEY_TRUST_ALL, value).apply()
/** Optional PEM-encoded custom CA certificate to trust, for internal Wazuh deployments with private CAs. */
var customCaPem: String
get() = prefs.getString(KEY_CUSTOM_CA, "") ?: ""
set(value) = prefs.edit().putString(KEY_CUSTOM_CA, value).apply()
var deviceLabel: String
get() = prefs.getString(KEY_DEVICE_LABEL, "") ?: ""
set(value) = prefs.edit().putString(KEY_DEVICE_LABEL, value).apply()
var syslogFacility: Int
get() = prefs.getInt(KEY_FACILITY, Facility.LOCAL0)
set(value) = prefs.edit().putInt(KEY_FACILITY, value).apply()
var agentEnabled: Boolean
get() = prefs.getBoolean(KEY_ENABLED, false)
set(value) = prefs.edit().putBoolean(KEY_ENABLED, value).apply()
/** Off by default: keep only notification metadata (package/category/timestamp), not the text content. */
var includeNotificationText: Boolean
get() = prefs.getBoolean(KEY_NOTIF_TEXT, false)
set(value) = prefs.edit().putBoolean(KEY_NOTIF_TEXT, value).apply()
var collectNotifications: Boolean
get() = prefs.getBoolean(KEY_COLLECT_NOTIF, true)
set(value) = prefs.edit().putBoolean(KEY_COLLECT_NOTIF, value).apply()
// --- Per-category log toggles. All default on; flipping one off both stops
// that category from being queued for the manager AND from appearing in the
// live log view, since both read from the same enqueue() choke point. ---
var collectDeviceInfo: Boolean
get() = prefs.getBoolean(KEY_COLLECT_DEVICE_INFO, true)
set(value) = prefs.edit().putBoolean(KEY_COLLECT_DEVICE_INFO, value).apply()
var collectInstalledApps: Boolean
get() = prefs.getBoolean(KEY_COLLECT_APPS, true)
set(value) = prefs.edit().putBoolean(KEY_COLLECT_APPS, value).apply()
var collectNetworkState: Boolean
get() = prefs.getBoolean(KEY_COLLECT_NETWORK, true)
set(value) = prefs.edit().putBoolean(KEY_COLLECT_NETWORK, value).apply()
var collectBattery: Boolean
get() = prefs.getBoolean(KEY_COLLECT_BATTERY, true)
set(value) = prefs.edit().putBoolean(KEY_COLLECT_BATTERY, value).apply()
/** Scans newly installed/updated APKs against the bundled + custom YARA rulesets. */
var collectYaraScan: Boolean
get() = prefs.getBoolean(KEY_COLLECT_YARA, true)
set(value) = prefs.edit().putBoolean(KEY_COLLECT_YARA, value).apply()
/** Additional .yar rule text (same supported subset as the bundled ruleset), appended to it. */
var customYaraRules: String
get() = prefs.getString(KEY_CUSTOM_YARA, "") ?: ""
set(value) = prefs.edit().putString(KEY_CUSTOM_YARA, value).apply()
/**
* Reads the device's full Android system log (logcat) via SystemLogCollector -
* every app's output, not just this one. Off by default and deliberately a
* separate, more consequential opt-in than the other toggles: even with this
* on, nothing is actually read unless android.permission.READ_LOGS has also
* been granted out-of-band via ADB, since Android gives third-party apps no
* in-app way to request or hold that permission. See the app's website for
* the exact `adb shell pm grant` command.
*/
var collectFullSystemLogs: Boolean
get() = prefs.getBoolean(KEY_COLLECT_SYSLOG, false)
set(value) = prefs.edit().putBoolean(KEY_COLLECT_SYSLOG, value).apply()
/** Logcat's own "MM-DD HH:MM:SS.mmm" timestamp of the newest line already forwarded,
* so each collection cycle only reads what's new. Empty means "never collected yet". */
var lastSystemLogTimestamp: String
get() = prefs.getString(KEY_SYSLOG_LAST_TS, "") ?: ""
set(value) = prefs.edit().putString(KEY_SYSLOG_LAST_TS, value).apply()
fun isConfigured(): Boolean = managerHost.isNotBlank() && managerPort in 1..65535
// --- Wazuh agent protocol (real enrollment) ---
var transportMode: TransportMode
get() = try {
TransportMode.valueOf(prefs.getString(KEY_TRANSPORT_MODE, TransportMode.SYSLOG.name)!!)
} catch (e: Exception) {
TransportMode.SYSLOG
}
set(value) = prefs.edit().putString(KEY_TRANSPORT_MODE, value.name).apply()
var authdPort: Int
get() = prefs.getInt(KEY_AUTHD_PORT, 1515)
set(value) = prefs.edit().putInt(KEY_AUTHD_PORT, value).apply()
var agentProtocolPort: Int
get() = prefs.getInt(KEY_AGENT_PORT, 1514)
set(value) = prefs.edit().putInt(KEY_AGENT_PORT, value).apply()
/** Optional authd enrollment password (only needed if the manager requires one). */
var enrollmentPassword: String
get() = prefs.getString(KEY_ENROLL_PASSWORD, "") ?: ""
set(value) = prefs.edit().putString(KEY_ENROLL_PASSWORD, value).apply()
var agentId: String
get() = prefs.getString(KEY_AGENT_ID, "") ?: ""
set(value) = prefs.edit().putString(KEY_AGENT_ID, value).apply()
var agentName: String
get() = prefs.getString(KEY_AGENT_NAME, "") ?: ""
set(value) = prefs.edit().putString(KEY_AGENT_NAME, value).apply()
var agentKey: String
get() = prefs.getString(KEY_AGENT_KEY, "") ?: ""
set(value) = prefs.edit().putString(KEY_AGENT_KEY, value).apply()
fun isEnrolled(): Boolean = agentId.isNotBlank() && agentName.isNotBlank() && agentKey.isNotBlank()
fun agentIdentity(): AgentIdentity? =
if (isEnrolled()) AgentIdentity(agentId, agentName, agentKey) else null
fun clearEnrollment() {
prefs.edit().remove(KEY_AGENT_ID).remove(KEY_AGENT_NAME).remove(KEY_AGENT_KEY).apply()
}
// --- Wazuh REST API + Claude AI log analysis (separate from the always-on
// agent above: an on-demand screen that authenticates to the Wazuh
// manager's API, pulls recent manager logs, and asks Claude to analyze
// them). Shares the manager's TLS trust settings (trustAllCerts/customCaPem)
// above rather than duplicating them, since it's normally the same host. ---
var wazuhApiHost: String
get() = prefs.getString(KEY_API_HOST, "") ?: ""
set(value) = prefs.edit().putString(KEY_API_HOST, value).apply()
var wazuhApiPort: Int
get() = prefs.getInt(KEY_API_PORT, 55000)
set(value) = prefs.edit().putInt(KEY_API_PORT, value).apply()
var wazuhApiUsername: String
get() = prefs.getString(KEY_API_USER, "") ?: ""
set(value) = prefs.edit().putString(KEY_API_USER, value).apply()
var wazuhApiPassword: String
get() = prefs.getString(KEY_API_PASSWORD, "") ?: ""
set(value) = prefs.edit().putString(KEY_API_PASSWORD, value).apply()
/** How many recent manager log entries to fetch per analysis run. */
var logFetchLimit: Int
get() = prefs.getInt(KEY_LOG_FETCH_LIMIT, 200)
set(value) = prefs.edit().putInt(KEY_LOG_FETCH_LIMIT, value).apply()
/** Stored locally only (EncryptedSharedPreferences); never baked into the APK. */
var claudeApiKey: String
get() = prefs.getString(KEY_CLAUDE_API_KEY, "") ?: ""
set(value) = prefs.edit().putString(KEY_CLAUDE_API_KEY, value).apply()
var claudeModel: String
get() = prefs.getString(KEY_CLAUDE_MODEL, "claude-opus-5") ?: "claude-opus-5"
set(value) = prefs.edit().putString(KEY_CLAUDE_MODEL, value).apply()
fun isAiAnalysisConfigured(): Boolean =
wazuhApiHost.isNotBlank() && wazuhApiPort in 1..65535 &&
wazuhApiUsername.isNotBlank() && wazuhApiPassword.isNotBlank() &&
claudeApiKey.isNotBlank() && claudeModel.isNotBlank()
companion object {
private const val KEY_HOST = "manager_host"
private const val KEY_PORT = "manager_port"
private const val KEY_TLS = "use_tls"
private const val KEY_TRUST_ALL = "trust_all_certs"
private const val KEY_CUSTOM_CA = "custom_ca_pem"
private const val KEY_DEVICE_LABEL = "device_label"
private const val KEY_FACILITY = "syslog_facility"
private const val KEY_ENABLED = "agent_enabled"
private const val KEY_NOTIF_TEXT = "include_notification_text"
private const val KEY_COLLECT_NOTIF = "collect_notifications"
private const val KEY_COLLECT_DEVICE_INFO = "collect_device_info"
private const val KEY_COLLECT_APPS = "collect_installed_apps"
private const val KEY_COLLECT_NETWORK = "collect_network_state"
private const val KEY_COLLECT_BATTERY = "collect_battery"
private const val KEY_COLLECT_YARA = "collect_yara_scan"
private const val KEY_CUSTOM_YARA = "custom_yara_rules"
private const val KEY_COLLECT_SYSLOG = "collect_full_system_logs"
private const val KEY_SYSLOG_LAST_TS = "system_log_last_timestamp"
private const val KEY_TRANSPORT_MODE = "transport_mode"
private const val KEY_AUTHD_PORT = "authd_port"
private const val KEY_AGENT_PORT = "agent_protocol_port"
private const val KEY_ENROLL_PASSWORD = "enrollment_password"
private const val KEY_AGENT_ID = "agent_id"
private const val KEY_AGENT_NAME = "agent_name"
private const val KEY_AGENT_KEY = "agent_key"
private const val KEY_API_HOST = "wazuh_api_host"
private const val KEY_API_PORT = "wazuh_api_port"
private const val KEY_API_USER = "wazuh_api_username"
private const val KEY_API_PASSWORD = "wazuh_api_password"
private const val KEY_LOG_FETCH_LIMIT = "log_fetch_limit"
private const val KEY_CLAUDE_API_KEY = "claude_api_key"
private const val KEY_CLAUDE_MODEL = "claude_model"
}
}
Wazuh agent protocol
The real enrollment + encrypted transport handshake — authd (1515) and remoted (1514).
app/src/main/java/org/wazuh/androidagent/wazuhagent/AgentCrypto.kt
package org.wazuh.androidagent.wazuhagent
import java.io.ByteArrayOutputStream
import java.security.MessageDigest
import java.util.zip.Deflater
import java.util.zip.Inflater
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Implements Wazuh's agent<->manager "secure message" wire format (AES variant):
* https://documentation.wazuh.com/current/development/message-format.html
*
* Verified byte-for-byte against Wazuh's own QA agent simulator
* (wazuh/wazuh-qa: deps/wazuh_testing/wazuh_testing/tools/agent_simulator.py and
* remoted_sim.py) and validated live against a real Wazuh 4.14 manager.
*
* Message layout: MD5(random+global:local:msg) + random+global:local:msg
* -> zlib compress -> pad with '!' to a multiple of 8 bytes (prepended)
* -> PKCS7-pad to a multiple of 16 -> AES-256-CBC with a fixed IV
* -> prefixed with "!<agent_id>!#AES:"
*/
object AgentCrypto {
// Fixed IV used by the real protocol for both directions (not a secret; the
// per-agent key is what actually protects the traffic).
private val AES_IV = "FEDCBA0987654321".toByteArray(Charsets.US_ASCII)
/**
* Derives the 47-byte encryption key from the agent's enrollment identity.
* Only the first 32 bytes (an MD5 hex digest, so already valid AES-256 key
* material) are used for AES; the full 47 bytes exist for legacy Blowfish
* compatibility, which this implementation does not use.
*/
fun deriveKey(agentId: String, agentName: String, rawKey: String): ByteArray {
val md5NameHex = md5Hex(agentName.toByteArray(Charsets.UTF_8))
val md5IdHex = md5Hex(agentId.toByteArray(Charsets.UTF_8))
val sum1 = md5Hex((md5NameHex + md5IdHex).toByteArray(Charsets.US_ASCII)).substring(0, 15)
val sum2 = md5Hex(rawKey.toByteArray(Charsets.UTF_8))
return (sum2 + sum1).toByteArray(Charsets.US_ASCII)
}
/** Builds one ready-to-send, framed-less event. Caller adds the 4-byte TCP length prefix. */
fun createEvent(message: String, agentId: String, encryptionKey: ByteArray): ByteArray {
val msgBytes = message.toByteArray(Charsets.UTF_8)
val random = "55555".toByteArray(Charsets.US_ASCII)
val globalCounter = "1234567891".toByteArray(Charsets.US_ASCII)
val localCounter = "5555".toByteArray(Charsets.US_ASCII)
val colon = ":".toByteArray(Charsets.US_ASCII)
val body = random + globalCounter + colon + localCounter + colon + msgBytes
val checksum = md5Hex(body).toByteArray(Charsets.US_ASCII)
val composed = checksum + body
val compressed = zlibCompress(composed)
val padded = wazuhPad(compressed)
val aesKey = encryptionKey.copyOfRange(0, 32)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(aesKey, "AES"), IvParameterSpec(AES_IV))
val encrypted = cipher.doFinal(padded)
val header = "!$agentId!#AES:".toByteArray(Charsets.US_ASCII)
return header + encrypted
}
/**
* Best-effort decrypt of a manager->agent message. Returns null on any
* failure (unsupported cipher, corrupt frame, partial read) rather than
* throwing - this path is only used to detect commands worth ACKing, and
* must never be allowed to take down the connection.
*/
fun decryptEvent(data: ByteArray, encryptionKey: ByteArray): String? {
return try {
var offset = 0
if (data.isNotEmpty() && data[0] == '!'.code.toByte()) {
val secondBang = data.indexOfFrom(1, '!'.code.toByte())
if (secondBang > 0) offset = secondBang + 1
}
if (data.size < offset + 5 || String(data, offset, 5, Charsets.US_ASCII) != "#AES:") {
return null // unsupported (e.g. legacy blowfish ":") or malformed
}
val cipherText = data.copyOfRange(offset + 5, data.size)
val aesKey = encryptionKey.copyOfRange(0, 32)
val cipher = Cipher.getInstance("AES/CBC/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(aesKey, "AES"), IvParameterSpec(AES_IV))
val decrypted = cipher.doFinal(cipherText)
var padStart = 0
while (padStart < decrypted.size && decrypted[padStart] == '!'.code.toByte()) padStart++
val decompressed = zlibDecompressTolerant(decrypted, padStart) ?: return null
String(decompressed, Charsets.ISO_8859_1)
} catch (e: Exception) {
null
}
}
private fun md5Hex(data: ByteArray): String {
val digest = MessageDigest.getInstance("MD5").digest(data)
return digest.joinToString("") { "%02x".format(it) }
}
private fun wazuhPad(data: ByteArray): ByteArray {
val extra = data.size % 8
val padLen = if (extra > 0) 8 - extra else 8
val pad = ByteArray(padLen) { '!'.code.toByte() }
return pad + data
}
private fun zlibCompress(data: ByteArray): ByteArray {
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, false) // nowrap=false == zlib framing (matches Python's zlib.compress)
deflater.setInput(data)
deflater.finish()
val out = ByteArrayOutputStream()
val buf = ByteArray(4096)
while (!deflater.finished()) {
val n = deflater.deflate(buf)
out.write(buf, 0, n)
}
deflater.end()
return out.toByteArray()
}
/** Decompresses, tolerating trailing PKCS7 padding bytes after the deflate stream ends. */
private fun zlibDecompressTolerant(data: ByteArray, offset: Int): ByteArray? {
val inflater = Inflater(false)
inflater.setInput(data, offset, data.size - offset)
val out = ByteArrayOutputStream()
val buf = ByteArray(4096)
return try {
while (!inflater.finished()) {
val n = inflater.inflate(buf)
if (n == 0 && inflater.needsInput()) break
out.write(buf, 0, n)
}
inflater.end()
out.toByteArray()
} catch (e: Exception) {
inflater.end()
null
}
}
private fun ByteArray.indexOfFrom(start: Int, value: Byte): Int {
for (i in start until size) if (this[i] == value) return i
return -1
}
}
app/src/main/java/org/wazuh/androidagent/wazuhagent/AuthdClient.kt
package org.wazuh.androidagent.wazuhagent
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.Socket
import javax.net.ssl.SSLSocket
data class EnrollmentResult(val agentId: String, val agentName: String, val agentKey: String)
class EnrollmentException(message: String) : Exception(message)
/**
* Speaks Wazuh's agent enrollment protocol to authd (TLS, port 1515 by default):
* connect, send "OSSEC A:'<name>'" (optionally with a password), and parse the
* "OSSEC K:'<id> <name> <ip> <key>'" response.
*/
object AuthdClient {
fun enroll(
host: String,
port: Int,
agentName: String,
password: String?,
sslSocketFactory: javax.net.ssl.SSLSocketFactory,
timeoutMs: Int = 15_000
): EnrollmentResult {
val plain = Socket()
plain.connect(java.net.InetSocketAddress(host, port), timeoutMs)
val socket = sslSocketFactory.createSocket(plain, host, port, true) as SSLSocket
socket.soTimeout = timeoutMs
try {
socket.startHandshake()
val request = if (password.isNullOrBlank()) {
"OSSEC A:'$agentName'\n"
} else {
"OSSEC PASS: $password OSSEC A:'$agentName'\n"
}
socket.outputStream.write(request.toByteArray(Charsets.UTF_8))
socket.outputStream.flush()
val reader = BufferedReader(InputStreamReader(socket.inputStream, Charsets.UTF_8))
val response = reader.readLine() ?: throw EnrollmentException("No response from authd")
if (response.startsWith("ERROR")) {
throw EnrollmentException(response)
}
// Expected: OSSEC K:'<id> <name> <ip> <key>'
val parts = response.split("'")
if (parts.size < 2) throw EnrollmentException("Unrecognized authd response: $response")
val fields = parts[1].trim().split(" ")
if (fields.size < 4) throw EnrollmentException("Unrecognized authd key fields: ${parts[1]}")
return EnrollmentResult(agentId = fields[0], agentName = fields[1], agentKey = fields[3])
} finally {
try { socket.close() } catch (ignored: Exception) {}
try { plain.close() } catch (ignored: Exception) {}
}
}
}
app/src/main/java/org/wazuh/androidagent/wazuhagent/KeepAliveBuilder.kt
package org.wazuh.androidagent.wazuhagent
import android.os.Build
/**
* Builds the "hostinfo" style keepalive line real Wazuh agents send, which
* the manager parses to populate an agent's OS/version fields in the
* dashboard. Format confirmed against Wazuh's QA data
* (deps/wazuh_testing/wazuh_testing/data/keepalives.txt) and a live manager.
*/
object KeepAliveBuilder {
fun build(deviceLabel: String, agentVersion: String = "v4.7.0", mergedChecksum: String = STATIC_CHECKSUM): String {
val osLine = "#!-Linux |$deviceLabel |${Build.VERSION.RELEASE}-android-${Build.VERSION.SDK_INT} " +
"|${Build.FINGERPRINT} |${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"} " +
"[Android|android: ${Build.VERSION.RELEASE}] - Wazuh $agentVersion / $mergedChecksum"
return "$osLine\n$mergedChecksum merged.mg"
}
// We don't participate in Wazuh's shared-config group sync, so this is a
// static placeholder rather than a real merge checksum.
private const val STATIC_CHECKSUM = "abcdef0123456789abcdef0123456789"
}
app/src/main/java/org/wazuh/androidagent/wazuhagent/WazuhAgentConnection.kt
package org.wazuh.androidagent.wazuhagent
import android.util.Log
import org.wazuh.androidagent.net.LogQueue
import java.io.DataInputStream
import java.io.OutputStream
import java.net.InetSocketAddress
import java.net.Socket
import kotlin.math.min
data class AgentIdentity(val agentId: String, val agentName: String, val agentKey: String) {
val encryptionKey: ByteArray by lazy { AgentCrypto.deriveKey(agentId, agentName, agentKey) }
}
/**
* Maintains the persistent, encrypted TCP connection to the Wazuh manager's
* remoted (port 1514 by default): sends the startup event once per connection,
* a periodic keepalive (so the agent shows "active" with correct OS/version
* info in the dashboard), and drains [LogQueue] as regular log events tagged
* with [locationTag].
*
* Also runs a reader loop that ACKs manager control messages ("#!-..." frames,
* e.g. the shared-config/group-file push). Without this, the manager retries
* those pushes in a tight loop indefinitely - confirmed against a live 4.14
* manager during development.
*/
class WazuhAgentConnection(
private val queue: LogQueue,
private val host: String,
private val port: Int,
private val identity: AgentIdentity,
private val keepAliveMessageProvider: () -> String,
private val keepAliveIntervalMs: Long = 10_000,
private val locationTag: String = "wazuh-android-agent"
) {
@Volatile private var running = false
@Volatile private var connected = false
private var thread: Thread? = null
private var readerThread: Thread? = null
private var socket: Socket? = null
private var out: OutputStream? = null
private val writeLock = Any()
var onStateChange: ((connected: Boolean, lastError: String?) -> Unit)? = null
var sentCount: Long = 0
private set
fun isConnected(): Boolean = connected
fun start() {
if (running) return
running = true
thread = Thread(::loop, "wazuh-agent-connection").apply {
isDaemon = true
start()
}
}
fun stop() {
running = false
closeQuietly()
thread?.interrupt()
readerThread?.interrupt()
thread = null
readerThread = null
}
private fun loop() {
var backoffMs = 1000L
while (running) {
try {
connect()
sendFrame(AgentCrypto.createEvent("#!-agent startup ", identity.agentId, identity.encryptionKey))
var lastKeepAlive = 0L
while (running && connected) {
val now = System.currentTimeMillis()
if (now - lastKeepAlive >= keepAliveIntervalMs) {
sendFrame(AgentCrypto.createEvent(keepAliveMessageProvider(), identity.agentId, identity.encryptionKey))
lastKeepAlive = now
}
val batch = queue.peekBatch(20)
if (batch.isNotEmpty()) {
var sent = 0
for (event in batch) {
val msg = "x:$locationTag:${event.toJson()}"
sendFrame(AgentCrypto.createEvent(msg, identity.agentId, identity.encryptionKey))
sent++
}
queue.removeFront(sent)
sentCount += sent
} else {
Thread.sleep(500)
}
}
backoffMs = 1000L
} catch (e: InterruptedException) {
break
} catch (e: Exception) {
Log.w(TAG, "Connection loop error: ${e.message}")
closeQuietly()
onStateChange?.invoke(false, e.message)
try {
Thread.sleep(backoffMs)
} catch (ignored: InterruptedException) {
break
}
backoffMs = min(backoffMs * 2, 60_000L)
}
}
closeQuietly()
}
private fun connect() {
closeQuietly()
val s = Socket()
s.connect(InetSocketAddress(host, port), 10_000)
s.soTimeout = 0 // reader thread blocks indefinitely; writes use a separate lock
socket = s
out = s.getOutputStream()
connected = true
onStateChange?.invoke(true, null)
Log.i(TAG, "Connected to $host:$port as agent ${identity.agentId}")
readerThread = Thread({ readLoop(s) }, "wazuh-agent-reader").apply {
isDaemon = true
start()
}
}
private fun readLoop(s: Socket) {
try {
val input = DataInputStream(s.getInputStream())
val lenBuf = ByteArray(4)
while (running && !s.isClosed) {
input.readFully(lenBuf)
val len = (lenBuf[0].toInt() and 0xFF) or
((lenBuf[1].toInt() and 0xFF) shl 8) or
((lenBuf[2].toInt() and 0xFF) shl 16) or
((lenBuf[3].toInt() and 0xFF) shl 24)
if (len <= 0 || len > 10_000_000) break // sanity bound
val payload = ByteArray(len)
input.readFully(payload)
val decrypted = AgentCrypto.decryptEvent(payload, identity.encryptionKey)
if (decrypted != null && decrypted.startsWith("#!-")) {
// Acknowledge control messages (e.g. shared-config push) so the
// manager doesn't spin retrying them.
try {
sendFrame(AgentCrypto.createEvent("#!-agent ack ", identity.agentId, identity.encryptionKey))
} catch (ignored: Exception) {
}
}
}
} catch (e: Exception) {
// Connection dropped or closed - the main loop's reconnect logic handles this.
Log.d(TAG, "Reader loop ended: ${e.message}")
}
}
private fun sendFrame(event: ByteArray) {
synchronized(writeLock) {
val o = out ?: throw IllegalStateException("Not connected")
val length = byteArrayOf(
(event.size and 0xFF).toByte(),
((event.size shr 8) and 0xFF).toByte(),
((event.size shr 16) and 0xFF).toByte(),
((event.size shr 24) and 0xFF).toByte()
)
o.write(length)
o.write(event)
o.flush()
}
}
private fun closeQuietly() {
connected = false
try { out?.flush() } catch (ignored: Exception) {}
try { out?.close() } catch (ignored: Exception) {}
try { socket?.close() } catch (ignored: Exception) {}
out = null
socket = null
}
companion object {
private const val TAG = "WazuhAgentConnection"
}
}
Wazuh REST API + Claude
The on-demand AI Analysis screen's two clients — Wazuh's manager API and Claude's Messages API.
app/src/main/java/org/wazuh/androidagent/wazuhapi/WazuhApiClient.kt
package org.wazuh.androidagent.wazuhapi
import org.json.JSONObject
import org.wazuh.androidagent.net.TlsUtil
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import javax.net.ssl.HttpsURLConnection
/**
* Minimal client for the Wazuh manager's REST API (default port 55000) —
* distinct from `authd`/the agent wire protocol in [org.wazuh.androidagent.wazuhagent],
* which this app also speaks. Used only by the on-demand "API & AI Analysis"
* screen, not the always-on foreground agent.
*
* Deliberately raw [HttpsURLConnection], matching this project's existing
* choice (see AuthdClient/WazuhAgentConnection) to avoid a new networking
* dependency (OkHttp/Retrofit) — Wazuh's API here is two simple JSON calls,
* not worth a library, and this keeps the build free of extra transitive deps.
*/
object WazuhApiClient {
data class LogEntry(val timestamp: String, val level: String, val tag: String, val description: String) {
override fun toString(): String = "[$timestamp] [$level] $tag: $description"
}
class WazuhApiException(message: String) : IOException(message)
/**
* POST /security/user/authenticate with HTTP Basic auth -> short-lived JWT.
* The Wazuh API user needs at least the `manager:read` (or equivalent
* `GET /manager/logs`) RBAC permission — see the app README.
*/
fun authenticate(
host: String,
port: Int,
username: String,
password: String,
trustAllCerts: Boolean,
customCaPem: String?
): String {
val url = URL("https://$host:$port/security/user/authenticate")
val conn = openConnection(url, trustAllCerts, customCaPem)
try {
conn.requestMethod = "POST"
val basic = android.util.Base64.encodeToString(
"$username:$password".toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP
)
conn.setRequestProperty("Authorization", "Basic $basic")
conn.doOutput = false
val body = readResponse(conn, "authenticate")
return JSONObject(body).getJSONObject("data").getString("token")
} finally {
conn.disconnect()
}
}
/**
* GET /manager/logs — the manager's own ossec.log entries, newest first.
* This is literally "the Wazuh log file" surfaced over the API, as
* distinct from indexed alerts (which live in the separate Wazuh Indexer/
* OpenSearch component, not the manager API — not implemented here).
*/
fun fetchManagerLogs(
host: String,
port: Int,
token: String,
limit: Int,
trustAllCerts: Boolean,
customCaPem: String?
): List<LogEntry> {
val cappedLimit = limit.coerceIn(1, 5000)
val url = URL("https://$host:$port/manager/logs?limit=$cappedLimit&sort=-timestamp")
val conn = openConnection(url, trustAllCerts, customCaPem)
try {
conn.requestMethod = "GET"
conn.setRequestProperty("Authorization", "Bearer $token")
val body = readResponse(conn, "fetch manager logs")
val items = JSONObject(body).getJSONObject("data").getJSONArray("affected_items")
return (0 until items.length()).map { i ->
val o = items.getJSONObject(i)
LogEntry(
timestamp = o.optString("timestamp", ""),
level = o.optString("level", ""),
tag = o.optString("tag", ""),
description = o.optString("description", "")
)
}
} finally {
conn.disconnect()
}
}
private fun openConnection(url: URL, trustAllCerts: Boolean, customCaPem: String?): HttpURLConnection {
val conn = url.openConnection() as HttpURLConnection
if (conn is HttpsURLConnection) {
conn.sslSocketFactory = TlsUtil.buildSocketFactory(customCaPem, trustAllCerts)
TlsUtil.buildHostnameVerifier(trustAllCerts)?.let { conn.hostnameVerifier = it }
}
conn.connectTimeout = 15_000
conn.readTimeout = 30_000
conn.setRequestProperty("Content-Type", "application/json")
return conn
}
/** Reads the response body regardless of status, then raises with the Wazuh API's own error message on failure. */
private fun readResponse(conn: HttpURLConnection, action: String): String {
val code = conn.responseCode
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
val body = stream?.bufferedReader()?.use { it.readText() } ?: ""
if (code !in 200..299) {
val detail = try {
JSONObject(body).let { it.optJSONArray("detail")?.toString() ?: it.optString("detail", body) }
} catch (e: Exception) {
body
}
throw WazuhApiException("Wazuh API: failed to $action (HTTP $code): ${detail.ifBlank { "no details" }}")
}
return body
}
}
app/src/main/java/org/wazuh/androidagent/claude/ClaudeClient.kt
package org.wazuh.androidagent.claude
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
/**
* Minimal client for the Claude Messages API (`POST /v1/messages`), used to
* analyze Wazuh manager logs fetched by [org.wazuh.androidagent.wazuhapi.WazuhApiClient].
*
* There is no official Anthropic SDK targeting Android specifically, and the
* general Java SDK pulls in a substantial dependency tree (Jackson, OkHttp,
* ...) this project has deliberately avoided everywhere else (see
* AuthdClient/WazuhAgentConnection/WazuhApiClient — all raw sockets/
* HttpsURLConnection). This is a single non-streaming JSON call, so raw HTTP
* keeps that same footprint rather than adding a new dependency + risking
* Android/AndroidX version conflicts in an environment with no device to
* test the resulting build against.
*/
object ClaudeClient {
private const val API_URL = "https://api.anthropic.com/v1/messages"
private const val ANTHROPIC_VERSION = "2023-06-01"
class ClaudeApiException(message: String) : IOException(message)
/**
* Sends [userPrompt] as a single user turn and returns Claude's text reply.
* Non-streaming: max_tokens is kept modest (see [maxTokens] default) so a
* mobile foreground request completes well within [HttpURLConnection]'s
* read timeout without needing to parse a server-sent-events stream by hand.
*/
fun analyze(
apiKey: String,
model: String,
systemPrompt: String,
userPrompt: String,
maxTokens: Int = 4096
): String {
val conn = URL(API_URL).openConnection() as HttpURLConnection
try {
conn.requestMethod = "POST"
conn.doOutput = true
conn.connectTimeout = 20_000
conn.readTimeout = 180_000 // analysis can take a while; this is an interactive on-demand call, not the background agent
conn.setRequestProperty("x-api-key", apiKey)
conn.setRequestProperty("anthropic-version", ANTHROPIC_VERSION)
conn.setRequestProperty("content-type", "application/json")
val requestBody = JSONObject().apply {
put("model", model)
put("max_tokens", maxTokens)
put("system", systemPrompt)
put("messages", JSONArray().put(
JSONObject().put("role", "user").put("content", userPrompt)
))
}
conn.outputStream.use { it.write(requestBody.toString().toByteArray(Charsets.UTF_8)) }
val code = conn.responseCode
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
val body = stream?.bufferedReader()?.use { it.readText() } ?: ""
if (code !in 200..299) {
throw ClaudeApiException(describeError(code, body))
}
val response = JSONObject(body)
val stopReason = response.optString("stop_reason", "")
if (stopReason == "refusal") {
val category = response.optJSONObject("stop_details")?.optString("category", "unspecified") ?: "unspecified"
throw ClaudeApiException("Claude declined to analyze this content (category: $category).")
}
val content = response.optJSONArray("content") ?: JSONArray()
val text = StringBuilder()
for (i in 0 until content.length()) {
val block = content.getJSONObject(i)
if (block.optString("type") == "text") {
text.append(block.optString("text", ""))
}
}
if (text.isEmpty()) {
throw ClaudeApiException("Claude returned no text content (stop_reason=$stopReason)")
}
return text.toString()
} finally {
conn.disconnect()
}
}
private fun describeError(code: Int, body: String): String {
val apiMessage = try {
JSONObject(body).optJSONObject("error")?.optString("message")
} catch (e: Exception) {
null
} ?: body.ifBlank { "no details" }
val prefix = when (code) {
401 -> "Invalid Claude API key"
403 -> "Claude API key lacks permission for this request"
429 -> "Rate limited by the Claude API — try again shortly"
in 500..599 -> "Claude API server error"
else -> "Claude API request failed (HTTP $code)"
}
return "$prefix: $apiMessage"
}
}
Collectors
What actually generates telemetry — each independently toggleable, off by default.
app/src/main/java/org/wazuh/androidagent/collectors/DeviceInfoCollector.kt
package org.wazuh.androidagent.collectors
import android.app.KeyguardManager
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.os.Build
import android.provider.Settings
import java.io.File
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.Severity
/**
* Periodic snapshot of device identity and security posture: OS version/patch
* level, screen-lock state, storage encryption, VPN presence, and a best-effort
* root heuristic. Emitted at service start and on a slow interval thereafter.
*/
object DeviceInfoCollector {
fun collect(context: Context): LogEvent {
val androidId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
val keyguard = context.getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
val battery = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
val fields = mapOf(
"android_id" to androidId,
"manufacturer" to Build.MANUFACTURER,
"model" to Build.MODEL,
"brand" to Build.BRAND,
"device" to Build.DEVICE,
"android_release" to Build.VERSION.RELEASE,
"sdk_int" to Build.VERSION.SDK_INT,
"security_patch" to Build.VERSION.SECURITY_PATCH,
"build_fingerprint" to Build.FINGERPRINT,
"build_tags" to Build.TAGS,
"device_secure" to (keyguard?.isDeviceSecure ?: false),
"keyguard_locked" to (keyguard?.isKeyguardLocked ?: false),
"vpn_active" to isVpnActive(context),
"battery_pct" to (battery?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) ?: -1),
"root_indicators" to rootIndicators()
)
return LogEvent(
timestamp = java.time.Instant.now(),
msgId = "device_info",
severity = Severity.INFO,
fields = fields
)
}
private fun isVpnActive(context: Context): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
val network = cm.activeNetwork ?: return false
val caps = cm.getNetworkCapabilities(network) ?: return false
return caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
}
/** Heuristic only (no root access is required or used) — common indicators of a rooted device. */
private fun rootIndicators(): List<String> {
val hits = mutableListOf<String>()
val suPaths = listOf(
"/system/bin/su", "/system/xbin/su", "/sbin/su",
"/system/app/Superuser.apk", "/data/local/xbin/su", "/data/local/bin/su"
)
for (p in suPaths) if (File(p).exists()) hits.add("su_binary:$p")
if (Build.TAGS != null && Build.TAGS.contains("test-keys")) hits.add("test_keys_build")
if (File("/system/app/Magisk").exists() || File("/sbin/.magisk").exists()) hits.add("magisk")
return hits
}
}
app/src/main/java/org/wazuh/androidagent/collectors/InstalledAppsCollector.kt
package org.wazuh.androidagent.collectors
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import org.json.JSONObject
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.Severity
import java.io.File
import java.time.Instant
/**
* Enumerates installed packages and reports:
* - a full inventory snapshot (low cadence: on first run / once per day), and
* - install/update/removal deltas (checked on every collection cycle) so a
* newly sideloaded app shows up as its own security-relevant event.
*
* State (which packages were last seen, at which versionCode) is persisted to
* a small JSON file so deltas survive process restarts.
*/
class InstalledAppsCollector(private val context: Context) {
private val stateFile = File(context.filesDir, "known_apps.json")
fun collectDeltasAndMaybeSnapshot(forceSnapshot: Boolean = false): List<LogEvent> {
val pm = context.packageManager
val packages: List<PackageInfo> = try {
pm.getInstalledPackages(PackageManager.GET_PERMISSIONS)
} catch (e: Exception) {
Log.e(TAG, "getInstalledPackages failed", e)
return emptyList()
}
val current = mutableMapOf<String, Long>() // package -> versionCode
for (p in packages) current[p.packageName] = longVersionCode(p)
val previous = loadState()
val events = mutableListOf<LogEvent>()
for (p in packages) {
val prevVersion = previous[p.packageName]
if (prevVersion == null) {
events.add(appEvent("app_installed", p, pm))
} else if (prevVersion != longVersionCode(p)) {
events.add(appEvent("app_updated", p, pm))
}
}
for (pkg in previous.keys) {
if (!current.containsKey(pkg)) {
events.add(
LogEvent(
timestamp = Instant.now(),
msgId = "app_removed",
severity = Severity.NOTICE,
fields = mapOf("package_name" to pkg)
)
)
}
}
if (forceSnapshot) {
events.add(snapshotEvent(packages))
}
saveState(current)
return events
}
private fun appEvent(msgId: String, p: PackageInfo, pm: PackageManager): LogEvent {
val appInfo: ApplicationInfo? = p.applicationInfo
val isSystem = appInfo != null && (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0
val installer = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
pm.getInstallSourceInfo(p.packageName).installingPackageName
} else {
@Suppress("DEPRECATION")
pm.getInstallerPackageName(p.packageName)
}
} catch (e: Exception) {
null
}
val dangerousPerms = (p.requestedPermissions ?: arrayOf())
.filter { it.contains("DANGEROUS", ignoreCase = false) || isLikelyDangerous(it) }
return LogEvent(
timestamp = Instant.now(),
msgId = msgId,
severity = if (msgId == "app_installed") Severity.NOTICE else Severity.INFO,
fields = mapOf(
"package_name" to p.packageName,
"version_name" to p.versionName,
"version_code" to longVersionCode(p),
"is_system_app" to isSystem,
"installer_package" to installer,
"first_install_time" to Instant.ofEpochMilli(p.firstInstallTime).toString(),
"last_update_time" to Instant.ofEpochMilli(p.lastUpdateTime).toString(),
"requested_permissions" to (p.requestedPermissions?.toList() ?: emptyList<String>()),
"sensitive_permissions_requested" to dangerousPerms
)
)
}
private fun snapshotEvent(packages: List<PackageInfo>): LogEvent {
val summaries = packages.map { p ->
mapOf(
"package_name" to p.packageName,
"version_code" to longVersionCode(p),
"is_system_app" to ((p.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_SYSTEM != 0)
)
}
return LogEvent(
timestamp = Instant.now(),
msgId = "installed_apps_snapshot",
severity = Severity.INFO,
fields = mapOf("count" to packages.size, "apps" to summaries)
)
}
private fun isLikelyDangerous(perm: String): Boolean {
val sensitiveKeywords = listOf(
"SMS", "CALL_LOG", "CONTACTS", "LOCATION", "CAMERA", "RECORD_AUDIO",
"READ_PHONE_STATE", "READ_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE",
"ACCESSIBILITY", "BIND_DEVICE_ADMIN", "SYSTEM_ALERT_WINDOW"
)
return sensitiveKeywords.any { perm.contains(it) }
}
private fun longVersionCode(p: PackageInfo): Long =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) p.longVersionCode
else @Suppress("DEPRECATION") p.versionCode.toLong()
private fun loadState(): Map<String, Long> {
if (!stateFile.exists()) return emptyMap()
return try {
val obj = JSONObject(stateFile.readText())
val map = mutableMapOf<String, Long>()
val keys = obj.keys()
while (keys.hasNext()) {
val k = keys.next()
map[k] = obj.getLong(k)
}
map
} catch (e: Exception) {
emptyMap()
}
}
private fun saveState(state: Map<String, Long>) {
try {
val obj = JSONObject()
for ((k, v) in state) obj.put(k, v)
stateFile.writeText(obj.toString())
} catch (e: Exception) {
Log.e(TAG, "Failed to persist known_apps state", e)
}
}
companion object {
private const val TAG = "InstalledAppsCollector"
}
}
app/src/main/java/org/wazuh/androidagent/collectors/SystemLogCollector.kt
package org.wazuh.androidagent.collectors
import android.util.Log
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.Severity
import java.io.BufferedReader
import java.io.InputStreamReader
import java.time.Instant
/**
* Reads the Android system log (logcat) — every app's output on the device,
* not just this one — via `Runtime.exec("logcat")`. This only produces
* anything once android.permission.READ_LOGS has been granted out-of-band
* via ADB (`adb shell pm grant org.wazuh.androidagent android.permission.READ_LOGS`);
* there is no runtime prompt or in-app Settings toggle that can grant it, by
* design on modern Android, since a third-party app reading every other
* app's log output is a meaningfully bigger privacy surface than anything
* else this project collects. See the app's website for the exact command.
* Gated behind SettingsStore.collectFullSystemLogs, off by default, on top
* of the ADB grant — both have to be true.
*
* NOTE: this collector could not be exercised against a real device in this
* build environment (no adb/emulator available here — see the project
* README's "Build env quirks" section). The logcat invocation and the `-T`
* time-filter format below match long-stable, documented AOSP logcat
* behavior, but treat this specific path as unverified until it's actually
* run against real hardware with the permission granted.
*/
object SystemLogCollector {
private const val TAG = "SystemLogCollector"
/** Hard cap per collection cycle so a noisy device can't flood the local queue/manager. */
private const val MAX_LINES_PER_CYCLE = 300
/**
* Dumps logcat since [sinceTimestamp] (logcat's own "MM-DD HH:MM:SS.mmm" format,
* exactly as printed by `-v threadtime` — blank means "the current buffer, once").
* Returns a single batched event (or null if nothing new, or logcat isn't
* readable — most commonly because READ_LOGS isn't actually granted) plus the
* timestamp of the newest line seen, to persist for the next cycle.
*/
fun collect(sinceTimestamp: String): Pair<LogEvent?, String?> {
val cmd = if (sinceTimestamp.isBlank()) {
arrayOf("logcat", "-d", "-v", "threadtime")
} else {
arrayOf("logcat", "-d", "-v", "threadtime", "-T", sinceTimestamp)
}
val lines = try {
val process = Runtime.getRuntime().exec(cmd)
val out = BufferedReader(InputStreamReader(process.inputStream)).readLines()
process.waitFor()
out
} catch (e: Exception) {
// Almost always means READ_LOGS isn't actually granted (or this ROM's
// logcat isn't reachable via exec()). Fail silently and let the next
// cycle try again rather than spamming a warning every tick for what's
// usually just "the ADB step hasn't been done yet."
Log.d(TAG, "logcat unavailable: ${e.message}")
return null to null
}
if (lines.isEmpty()) return null to null
var newestTimestamp: String? = null
val kept = ArrayList<String>(minOf(lines.size, MAX_LINES_PER_CYCLE))
for (line in lines) {
extractTimestamp(line)?.let { newestTimestamp = it }
if (kept.size < MAX_LINES_PER_CYCLE) kept.add(line)
}
if (kept.isEmpty()) return null to newestTimestamp
val event = LogEvent(
timestamp = Instant.now(),
msgId = "system_log_batch",
severity = highestSeverity(kept),
fields = mapOf(
"line_count" to kept.size,
"truncated" to (lines.size > kept.size),
"lines" to kept
)
)
return event to (newestTimestamp ?: sinceTimestamp.ifBlank { null })
}
/** threadtime format starts every line "MM-DD HH:MM:SS.mmm ..."; logcat's own -T
* filter accepts exactly that substring back, so no reformatting is needed. */
private fun extractTimestamp(line: String): String? =
TIMESTAMP_REGEX.find(line)?.value
private fun highestSeverity(lines: List<String>): Int {
var worst = Severity.DEBUG // numerically highest = least severe; we want the lowest number seen
for (line in lines) {
val level = levelOf(line) ?: continue
if (level < worst) worst = level
}
return worst
}
/** The single level letter logcat prints right after the PID/TID columns in threadtime format. */
private fun levelOf(line: String): Int? {
val m = LEVEL_REGEX.find(line) ?: return null
return when (m.groupValues[1]) {
"F" -> Severity.CRITICAL
"E" -> Severity.ERROR
"W" -> Severity.WARNING
"I" -> Severity.INFO
"D", "V" -> Severity.DEBUG
else -> null
}
}
private val TIMESTAMP_REGEX = Regex("""^\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}""")
private val LEVEL_REGEX = Regex("""\d+\s+\d+\s+([VDIWEF])\s""")
}
app/src/main/java/org/wazuh/androidagent/collectors/NetworkStateReceiver.kt
package org.wazuh.androidagent.collectors
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.Severity
import java.time.Instant
/**
* Reports connectivity transitions: type (WiFi/cellular/none), VPN presence,
* and metered state. SSID/IP are intentionally omitted by default to avoid
* collecting fine-grained location-adjacent data without an explicit ask.
*/
class NetworkStateReceiver(private val onEvent: (LogEvent) -> Unit) : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
val network = cm.activeNetwork
val caps = network?.let { cm.getNetworkCapabilities(it) }
val transport = when {
caps == null -> "none"
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
else -> "other"
}
onEvent(
LogEvent(
timestamp = Instant.now(),
msgId = "network_state",
severity = Severity.INFO,
fields = mapOf(
"transport" to transport,
"connected" to (caps != null),
"vpn_active" to (caps?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) ?: false),
"metered" to (caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)?.not() ?: false)
)
)
)
}
companion object {
fun intentFilter(): android.content.IntentFilter =
android.content.IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
}
}
app/src/main/java/org/wazuh/androidagent/collectors/BatteryReceiver.kt
package org.wazuh.androidagent.collectors
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.Severity
import java.time.Instant
/** Reports charging-state transitions and periodic battery level snapshots. */
class BatteryReceiver(private val onEvent: (LogEvent) -> Unit) : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val msgId = when (intent.action) {
Intent.ACTION_POWER_CONNECTED -> "power_connected"
Intent.ACTION_POWER_DISCONNECTED -> "power_disconnected"
else -> "battery_status"
}
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
val pct = if (level >= 0 && scale > 0) (level * 100 / scale) else -1
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
onEvent(
LogEvent(
timestamp = Instant.now(),
msgId = msgId,
severity = Severity.INFO,
fields = mapOf("battery_pct" to pct, "charging" to charging)
)
)
}
companion object {
fun intentFilter(): IntentFilter = IntentFilter().apply {
addAction(Intent.ACTION_BATTERY_CHANGED)
addAction(Intent.ACTION_POWER_CONNECTED)
addAction(Intent.ACTION_POWER_DISCONNECTED)
}
}
}
app/src/main/java/org/wazuh/androidagent/collectors/NotificationAgentListenerService.kt
package org.wazuh.androidagent.collectors
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import org.wazuh.androidagent.SettingsStore
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.LogQueue
import org.wazuh.androidagent.net.Severity
import java.time.Instant
/**
* Requires the user to explicitly grant Notification Access in system Settings
* (this cannot be requested via a runtime permission dialog). By default only
* metadata is logged (package, category, posted time); full title/text is
* opt-in via Settings > "Include notification text", off by default.
*/
class NotificationAgentListenerService : NotificationListenerService() {
private lateinit var queue: LogQueue
private lateinit var settings: SettingsStore
override fun onCreate() {
super.onCreate()
queue = LogQueue(applicationContext)
settings = SettingsStore(applicationContext)
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
if (!settings.agentEnabled || !settings.collectNotifications) return
// Don't log our own foreground-service notification.
if (sbn.packageName == applicationContext.packageName) return
val extras = sbn.notification.extras
val fields = mutableMapOf<String, Any?>(
"package_name" to sbn.packageName,
"posted_at" to Instant.ofEpochMilli(sbn.postTime).toString(),
"category" to sbn.notification.category,
"is_ongoing" to sbn.isOngoing
)
if (settings.includeNotificationText) {
fields["title"] = extras?.getCharSequence("android.title")?.toString()
fields["text"] = extras?.getCharSequence("android.text")?.toString()
}
queue.enqueue(
LogEvent(
timestamp = Instant.now(),
msgId = "notification_posted",
severity = Severity.INFO,
fields = fields
)
)
}
override fun onNotificationRemoved(sbn: StatusBarNotification) {
if (!settings.agentEnabled || !settings.collectNotifications) return
if (sbn.packageName == applicationContext.packageName) return
queue.enqueue(
LogEvent(
timestamp = Instant.now(),
msgId = "notification_removed",
severity = Severity.DEBUG,
fields = mapOf("package_name" to sbn.packageName)
)
)
}
}
Transport & queue
Durable local queuing, the syslog fallback transport, and shared TLS handling.
app/src/main/java/org/wazuh/androidagent/net/LogEvent.kt
package org.wazuh.androidagent.net
import org.json.JSONArray
import org.json.JSONObject
import java.time.Instant
/**
* Syslog severities (RFC 5424 section 6.2.1).
*/
object Severity {
const val EMERGENCY = 0
const val ALERT = 1
const val CRITICAL = 2
const val ERROR = 3
const val WARNING = 4
const val NOTICE = 5
const val INFO = 6
const val DEBUG = 7
}
/** Syslog facility. We use local0 (16) by default; security/authorization (10) is offered as an alternative. */
object Facility {
const val SECURITY_AUTH = 10
const val LOCAL0 = 16
}
/**
* One unit of telemetry produced by a collector, queued locally, and eventually
* shipped to the Wazuh manager as a syslog message.
*/
data class LogEvent(
val timestamp: Instant,
val msgId: String,
val severity: Int,
val fields: Map<String, Any?>
) {
fun toJson(): String {
val obj = JSONObject()
obj.put("event_type", msgId)
obj.put("timestamp", timestamp.toString())
obj.put("severity", severity)
val f = JSONObject()
for ((k, v) in fields) f.put(k, toJsonValue(v))
obj.put("data", f)
return obj.toString()
}
private fun toJsonValue(v: Any?): Any? = when (v) {
null -> JSONObject.NULL
is List<*> -> JSONArray().apply { v.forEach { put(toJsonValue(it)) } }
is Map<*, *> -> JSONObject().apply { v.forEach { (k, mv) -> put(k.toString(), toJsonValue(mv)) } }
else -> v
}
companion object {
fun fromJsonLine(line: String): LogEvent? {
return try {
val obj = JSONObject(line)
val data = obj.getJSONObject("data")
val map = mutableMapOf<String, Any?>()
val keys = data.keys()
while (keys.hasNext()) {
val k = keys.next()
map[k] = data.get(k)
}
LogEvent(
timestamp = Instant.parse(obj.getString("timestamp")),
msgId = obj.getString("event_type"),
severity = obj.optInt("severity", Severity.INFO),
fields = map
)
} catch (e: Exception) {
null
}
}
}
}
app/src/main/java/org/wazuh/androidagent/net/LogQueue.kt
package org.wazuh.androidagent.net
import android.content.Context
import android.util.Log
import java.io.File
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
/**
* A simple, durable, file-backed FIFO queue for [LogEvent]s. Events are appended
* as one JSON object per line to a file under the app's private storage, so
* telemetry survives process death and network outages. Bounded so a device that
* stays offline for a long time doesn't fill the disk (oldest events are dropped).
*/
class LogQueue(context: Context) {
private val file = File(context.filesDir, "pending_events.jsonl")
private val lock = ReentrantLock()
var droppedCount: Long = 0
private set
fun enqueue(event: LogEvent) {
lock.withLock {
try {
file.appendText(event.toJson() + "\n")
trimIfNeeded()
} catch (e: Exception) {
Log.e(TAG, "Failed to enqueue event", e)
}
}
// Every event that's queued for delivery is also fanned out to the live
// log view. A category toggled off never reaches enqueue() in the first
// place (collectors check settings before calling this), so the live
// view and the manager see exactly the same filtered stream.
LiveLogBus.publish(event)
}
/** Returns up to [max] oldest events without removing them. Call [removeFront] after a successful send. */
fun peekBatch(max: Int): List<LogEvent> {
lock.withLock {
if (!file.exists()) return emptyList()
return file.bufferedReader().useLines { lines ->
lines.take(max).mapNotNull { LogEvent.fromJsonLine(it) }.toList()
}
}
}
fun size(): Int {
lock.withLock {
if (!file.exists()) return 0
return file.bufferedReader().useLines { it.count() }
}
}
/** Removes the oldest [count] entries after they were successfully delivered. */
fun removeFront(count: Int) {
lock.withLock {
if (!file.exists() || count <= 0) return
val remaining = file.bufferedReader().useLines { it.drop(count).toList() }
val tmp = File(file.parentFile, file.name + ".tmp")
tmp.bufferedWriter().use { w ->
for (line in remaining) {
w.write(line)
w.newLine()
}
}
tmp.renameTo(file)
}
}
private fun trimIfNeeded() {
if (file.length() <= MAX_FILE_BYTES) return
val allLines = file.bufferedReader().useLines { it.toList() }
if (allLines.size <= MAX_LINES) return
val overflow = allLines.size - MAX_LINES
droppedCount += overflow
val keep = allLines.drop(overflow)
val tmp = File(file.parentFile, file.name + ".tmp")
tmp.bufferedWriter().use { w ->
for (line in keep) {
w.write(line)
w.newLine()
}
}
tmp.renameTo(file)
Log.w(TAG, "Queue exceeded bounds, dropped $overflow oldest events")
}
companion object {
private const val TAG = "LogQueue"
private const val MAX_LINES = 20_000
private const val MAX_FILE_BYTES = 20L * 1024 * 1024 // 20MB
}
}
app/src/main/java/org/wazuh/androidagent/net/LiveLogBus.kt
package org.wazuh.androidagent.net
import android.os.Handler
import android.os.Looper
import java.util.Collections
/**
* In-process fan-out of every [LogEvent] the agent produces, for the "Live Log"
* screen. Deliberately not persisted and not the same thing as [LogQueue]:
* this is a bounded, memory-only ring buffer purely for on-device visibility
* while the app is open, so it doesn't inherit LogQueue's durability/retry
* concerns and can't affect delivery to the manager.
*/
object LiveLogBus {
private const val MAX_BUFFERED = 500
private val mainHandler = Handler(Looper.getMainLooper())
private val buffer = ArrayDeque<LogEvent>(MAX_BUFFERED)
private val listeners = Collections.synchronizedSet(mutableSetOf<(LogEvent) -> Unit>())
@Synchronized
fun publish(event: LogEvent) {
buffer.addLast(event)
while (buffer.size > MAX_BUFFERED) buffer.removeFirst()
val snapshot = listeners.toList()
if (snapshot.isEmpty()) return
mainHandler.post {
for (l in snapshot) l(event)
}
}
@Synchronized
fun recent(): List<LogEvent> = buffer.toList()
fun addListener(listener: (LogEvent) -> Unit) {
listeners.add(listener)
}
fun removeListener(listener: (LogEvent) -> Unit) {
listeners.remove(listener)
}
@Synchronized
fun clear() {
buffer.clear()
}
}
app/src/main/java/org/wazuh/androidagent/net/SyslogFormatter.kt
package org.wazuh.androidagent.net
import android.os.Process
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
/**
* Formats [LogEvent]s as RFC 5424 syslog messages, newline-delimited for
* transport over TCP (the convention understood by rsyslog's imtcp and by
* Wazuh's remote syslog listener).
*/
class SyslogFormatter(
private val hostname: String,
private val facility: Int,
private val appName: String = "WazuhAndroidAgent"
) {
private val procId = Process.myPid().toString()
private val timestampFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME
fun format(event: LogEvent): String {
val pri = facility * 8 + event.severity
val ts = event.timestamp.atOffset(ZoneOffset.UTC).format(timestampFormatter)
val host = sanitize(hostname.ifBlank { "android-device" })
val msgId = sanitize(event.msgId).ifBlank { "event" }
val msg = event.toJson()
// <PRI>VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG
return "<$pri>1 $ts $host $appName $procId $msgId - $msg"
}
/** RFC5424 header fields must be printable US-ASCII with no spaces. */
private fun sanitize(s: String): String =
s.filter { it.code in 33..126 }.take(48).ifBlank { "-" }
}
app/src/main/java/org/wazuh/androidagent/net/SyslogSender.kt
package org.wazuh.androidagent.net
import android.util.Log
import java.io.BufferedOutputStream
import java.io.OutputStream
import java.net.InetSocketAddress
import java.net.Socket
import javax.net.ssl.SSLSocket
import kotlin.math.min
data class SyslogConfig(
val host: String,
val port: Int,
val useTls: Boolean,
val trustAllCerts: Boolean,
val customCaPem: String,
val hostnameLabel: String,
val facility: Int
)
/**
* Owns a long-lived TCP (optionally TLS) connection to the Wazuh manager's
* remote-syslog listener and continuously drains [LogQueue], one line-delimited
* RFC5424 message per event. Reconnects with exponential backoff on failure so
* a temporarily unreachable manager doesn't lose telemetry.
*/
class SyslogSender(
private val queue: LogQueue,
private var config: SyslogConfig
) {
@Volatile private var running = false
@Volatile private var connected = false
private var thread: Thread? = null
var onStateChange: ((connected: Boolean, lastError: String?) -> Unit)? = null
var sentCount: Long = 0
private set
fun updateConfig(newConfig: SyslogConfig) {
config = newConfig
// Force a reconnect on next loop iteration with the new settings.
closeQuietly()
}
fun isConnected(): Boolean = connected
private var socket: Socket? = null
private var out: OutputStream? = null
fun start() {
if (running) return
running = true
thread = Thread(::loop, "wazuh-syslog-sender").apply {
isDaemon = true
start()
}
}
fun stop() {
running = false
closeQuietly()
thread?.interrupt()
thread = null
}
private fun loop() {
var backoffMs = 1000L
while (running) {
try {
if (config.host.isBlank()) {
Thread.sleep(2000)
continue
}
ensureConnected()
val batch = queue.peekBatch(50)
if (batch.isEmpty()) {
Thread.sleep(1000)
continue
}
val formatter = SyslogFormatter(config.hostnameLabel, config.facility)
var sentInBatch = 0
for (event in batch) {
val line = formatter.format(event) + "\n"
out?.write(line.toByteArray(Charsets.UTF_8))
sentInBatch++
}
out?.flush()
queue.removeFront(sentInBatch)
sentCount += sentInBatch
backoffMs = 1000L
} catch (e: InterruptedException) {
break
} catch (e: Exception) {
Log.w(TAG, "Send loop error: ${e.message}")
closeQuietly()
onStateChange?.invoke(false, e.message)
try {
Thread.sleep(backoffMs)
} catch (ignored: InterruptedException) {
break
}
backoffMs = min(backoffMs * 2, 60_000L)
}
}
closeQuietly()
}
private fun ensureConnected() {
if (connected && socket?.isConnected == true && socket?.isClosed == false) return
closeQuietly()
val plainSocket = Socket()
plainSocket.connect(InetSocketAddress(config.host, config.port), 10_000)
plainSocket.soTimeout = 15_000
val finalSocket: Socket = if (config.useTls) {
val factory = TlsUtil.buildSocketFactory(
config.customCaPem.ifBlank { null },
config.trustAllCerts
)
val tlsSocket = factory.createSocket(plainSocket, config.host, config.port, true) as SSLSocket
tlsSocket.startHandshake()
tlsSocket
} else {
plainSocket
}
socket = finalSocket
out = BufferedOutputStream(finalSocket.getOutputStream())
connected = true
onStateChange?.invoke(true, null)
Log.i(TAG, "Connected to ${config.host}:${config.port} (tls=${config.useTls})")
}
private fun closeQuietly() {
connected = false
try { out?.flush() } catch (ignored: Exception) {}
try { out?.close() } catch (ignored: Exception) {}
try { socket?.close() } catch (ignored: Exception) {}
out = null
socket = null
}
companion object {
private const val TAG = "SyslogSender"
}
}
app/src/main/java/org/wazuh/androidagent/net/TlsUtil.kt
package org.wazuh.androidagent.net
import java.io.ByteArrayInputStream
import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
object TlsUtil {
/**
* Hostname verifier for [javax.net.ssl.HttpsURLConnection]-based clients
* (the Wazuh REST API and Claude API clients). [SSLSocketFactory] alone
* only covers certificate trust; HttpsURLConnection separately checks the
* hostname against the cert unless this is overridden too. Only relevant
* when [trustAll] is set (same INSECURE lab/testing opt-in as above) —
* with a custom/default CA, the platform's normal hostname check applies.
*/
fun buildHostnameVerifier(trustAll: Boolean): HostnameVerifier? =
if (trustAll) HostnameVerifier { _, _ -> true } else null
/**
* Builds an [SSLSocketFactory] for talking to the Wazuh manager.
*
* - By default, trusts the system CA store plus, if provided, one extra
* PEM-encoded CA certificate (typical for an internal Wazuh deployment
* with a private CA).
* - If [trustAll] is true, certificate/hostname validation is disabled
* entirely. This is INSECURE and intended only for lab/testing against
* a self-signed manager; it must be explicitly opted into in Settings.
*/
fun buildSocketFactory(customCaPem: String?, trustAll: Boolean): SSLSocketFactory {
val context = SSLContext.getInstance("TLS")
if (trustAll) {
context.init(null, arrayOf<TrustManager>(InsecureTrustManager()), SecureRandom())
} else {
val trustManagers = buildTrustManagers(customCaPem)
context.init(null, trustManagers, SecureRandom())
}
return context.socketFactory
}
private fun buildTrustManagers(customCaPem: String?): Array<TrustManager> {
val defaultTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
defaultTmf.init(null as KeyStore?) // system default trust store
if (customCaPem.isNullOrBlank()) {
return defaultTmf.trustManagers
}
// Build a KeyStore containing the system CAs + the custom CA, so both are trusted.
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
keyStore.load(null, null)
val cf = CertificateFactory.getInstance("X.509")
val cert = cf.generateCertificate(ByteArrayInputStream(customCaPem.toByteArray())) as X509Certificate
keyStore.setCertificateEntry("wazuh_custom_ca", cert)
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(keyStore)
// Combine custom-CA trust manager with system defaults so both public and private CAs work.
return arrayOf(CombinedTrustManager(tmf.trustManagers.filterIsInstance<X509TrustManager>(),
defaultTmf.trustManagers.filterIsInstance<X509TrustManager>()))
}
private class CombinedTrustManager(
private val a: List<X509TrustManager>,
private val b: List<X509TrustManager>
) : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
throw java.security.cert.CertificateException("Client cert auth not supported")
}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
var lastError: java.security.cert.CertificateException? = null
for (tm in a + b) {
try {
tm.checkServerTrusted(chain, authType)
return
} catch (e: java.security.cert.CertificateException) {
lastError = e
}
}
throw lastError ?: java.security.cert.CertificateException("No trust manager could verify the chain")
}
override fun getAcceptedIssuers(): Array<X509Certificate> = (a + b).flatMap { it.acceptedIssuers.toList() }.toTypedArray()
}
private class InsecureTrustManager : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
}
}
YARA engine
A hand-written parser and matcher for a deliberately scoped subset of YARA syntax — see YaraParser.kt's header for exactly what's supported.
app/src/main/java/org/wazuh/androidagent/yara/YaraModel.kt
package org.wazuh.androidagent.yara
/**
* Data model for the YARA rule subset this engine understands.
*
* This is NOT a full YARA implementation (see YaraParser.kt header for exactly
* what's supported and what isn't). It exists so this app can scan newly
* installed APKs against .yar-format rule text without depending on a native
* libyara build, which isn't buildable/testable in this environment (no NDK
* toolchain set up, no device to validate a compiled .so against).
*/
sealed class YaraPattern {
/** One or more byte-encodings to search for (e.g. ascii + wide variants of the same text). */
data class Text(val variants: List<ByteArray>, val nocase: Boolean, val fullword: Boolean) : YaraPattern()
/** Raw byte sequence with a per-position wildcard mask (true = '??', matches any byte). */
data class Hex(val bytes: ByteArray, val mask: BooleanArray) : YaraPattern()
/** Regex matched against the content decoded as ISO-8859-1 (1 char == 1 byte, no charset surprises). */
data class Regex(val pattern: kotlin.text.Regex) : YaraPattern()
}
data class YaraString(val id: String, val pattern: YaraPattern)
sealed class OfCount {
object All : OfCount()
object Any : OfCount()
data class N(val n: Int) : OfCount()
}
sealed class CondNode {
data class StrRef(val id: String) : CondNode()
data class BoolLit(val v: Boolean) : CondNode()
data class Not(val n: CondNode) : CondNode()
data class And(val a: CondNode, val b: CondNode) : CondNode()
data class Or(val a: CondNode, val b: CondNode) : CondNode()
/** ids == null means "them" (every string defined in the rule). */
data class OfThem(val count: OfCount, val ids: List<String>?) : CondNode()
/** filesize <op> N(KB|MB) */
data class SizeCmp(val op: String, val bytes: Long) : CondNode()
}
data class YaraRule(
val name: String,
val tags: List<String>,
val meta: Map<String, String>,
val strings: List<YaraString>,
val condition: CondNode
)
class YaraParseException(message: String) : Exception(message)
/** Result of matching one rule against one blob of content. */
data class YaraMatch(val rule: YaraRule, val matchedStringIds: List<String>)
app/src/main/java/org/wazuh/androidagent/yara/YaraParser.kt
package org.wazuh.androidagent.yara
/**
* Hand-written recursive-descent parser for a deliberately scoped SUBSET of
* YARA's rule syntax. Written from scratch against the public YARA language
* documentation (no native libyara available to link against in this build
* environment — see the project README's "YARA scanning" section for why).
*
* Supported:
* - `rule NAME [: tag1 tag2] { meta: ... strings: ... condition: ... }`
* - meta values: quoted strings, numbers, true/false (parsed, carried through
* for reporting; not used in matching)
* - string patterns: `"text"` with `ascii` `wide` `nocase` `fullword`
* modifiers (any combination); `{ AA BB ?? CC }` hex byte patterns with
* full-byte `??` wildcards; `/regex/` with an optional `nocase` modifier
* - conditions: `and` `or` `not`, parentheses, bare `$id` (truthy if that
* string matched), `any of them` / `all of them` / `N of them` /
* `N of ($a,$b,...)`, and `filesize` comparisons (`filesize < 5MB`)
*
* Explicitly NOT supported (will throw YaraParseException, rule is skipped
* rather than silently mis-evaluated): YARA modules (pe/elf/cuckoo/...),
* external variables, `include`, hex jump ranges (`[0-4]`) or nibble-level
* wildcards (`4?`), string-count (`#a`) or offset (`@a`) operators, rule
* references (`OtherRule and ...`), private/global rule modifiers.
*/
object YaraParser {
fun parseRules(source: String): List<YaraRule> {
val p = Cursor(source)
val rules = mutableListOf<YaraRule>()
p.skipWsAndComments()
while (!p.eof()) {
rules.add(parseRule(p))
p.skipWsAndComments()
}
return rules
}
/** Parses rules one at a time; a rule that fails to parse is skipped (reported via onError) rather than aborting the whole file. */
fun parseRulesLenient(source: String, onError: (ruleNameOrIndex: String, error: Exception) -> Unit = { _, _ -> }): List<YaraRule> {
val p = Cursor(source)
val rules = mutableListOf<YaraRule>()
p.skipWsAndComments()
var index = 0
while (!p.eof()) {
val startPos = p.pos
try {
rules.add(parseRule(p))
} catch (e: Exception) {
onError("rule#$index near offset $startPos", e)
// Recovery: skip forward to the next "rule " keyword so one bad rule
// doesn't take the rest of the file down with it.
if (!p.skipToNextRuleKeyword()) break
}
index++
p.skipWsAndComments()
}
return rules
}
private fun parseRule(p: Cursor): YaraRule {
p.expectIdent("rule")
val name = p.parseIdentRaw()
val tags = mutableListOf<String>()
p.skipWsAndComments()
if (p.peek() == ':') {
p.advance()
p.skipWsAndComments()
while (p.peek() != '{') {
tags.add(p.parseIdentRaw())
p.skipWsAndComments()
}
}
p.expectChar('{')
var meta: Map<String, String> = emptyMap()
var strings: List<YaraString> = emptyList()
var condition: CondNode? = null
p.skipWsAndComments()
while (p.peek() != '}') {
val section = p.parseIdentRaw()
p.skipWsAndComments()
p.expectChar(':')
when (section) {
"meta" -> meta = parseMetaSection(p)
"strings" -> strings = parseStringsSection(p)
"condition" -> condition = parseCondition(p, strings.map { it.id })
else -> throw YaraParseException("Unknown section '$section' (expected meta/strings/condition)")
}
p.skipWsAndComments()
}
p.expectChar('}')
val cond = condition ?: throw YaraParseException("Rule '$name' has no condition: section")
return YaraRule(name, tags, meta, strings, cond)
}
private fun parseMetaSection(p: Cursor): Map<String, String> {
val map = LinkedHashMap<String, String>()
p.skipWsAndComments()
while (p.peek() != '}' && !p.atSectionKeyword()) {
val key = p.parseIdentRaw()
p.skipWsAndComments()
p.expectChar('=')
p.skipWsAndComments()
val value = when {
p.peek() == '"' -> p.parseQuotedString()
p.matchIdentAhead("true") -> { p.parseIdentRaw(); "true" }
p.matchIdentAhead("false") -> { p.parseIdentRaw(); "false" }
else -> p.parseNumberRaw()
}
map[key] = value
p.skipWsAndComments()
}
return map
}
private fun parseStringsSection(p: Cursor): List<YaraString> {
val list = mutableListOf<YaraString>()
p.skipWsAndComments()
while (p.peek() != '}' && !p.atSectionKeyword()) {
p.expectChar('$')
val id = "$" + p.parseIdentRaw()
p.skipWsAndComments()
p.expectChar('=')
p.skipWsAndComments()
val pattern: YaraPattern = when {
p.peek() == '"' -> parseTextPattern(p)
p.peek() == '{' -> parseHexPattern(p)
p.peek() == '/' -> parseRegexPattern(p)
else -> throw YaraParseException("Expected string, hex block, or regex after $id =")
}
list.add(YaraString(id, pattern))
p.skipWsAndComments()
}
return list
}
private fun parseTextPattern(p: Cursor): YaraPattern.Text {
val text = p.parseQuotedString()
var ascii = false
var wide = false
var nocase = false
var fullword = false
p.skipWsAndComments()
while (p.matchIdentAhead("ascii") || p.matchIdentAhead("wide") || p.matchIdentAhead("nocase") ||
p.matchIdentAhead("fullword") || p.matchIdentAhead("xor") || p.matchIdentAhead("base64") ||
p.matchIdentAhead("base64wide") || p.matchIdentAhead("private")
) {
when (val mod = p.parseIdentRaw()) {
"ascii" -> ascii = true
"wide" -> wide = true
"nocase" -> nocase = true
"fullword" -> fullword = true
else -> throw YaraParseException("Unsupported string modifier '$mod'")
}
p.skipWsAndComments()
}
if (!ascii && !wide) ascii = true // default per YARA spec
val variants = mutableListOf<ByteArray>()
if (ascii) variants.add(text.toByteArray(Charsets.ISO_8859_1))
if (wide) variants.add(textToUtf16LeBytes(text))
return YaraPattern.Text(variants, nocase, fullword)
}
private fun textToUtf16LeBytes(text: String): ByteArray {
val out = ByteArray(text.length * 2)
for (i in text.indices) {
val c = text[i].code
out[i * 2] = (c and 0xFF).toByte()
out[i * 2 + 1] = ((c shr 8) and 0xFF).toByte()
}
return out
}
private fun parseHexPattern(p: Cursor): YaraPattern.Hex {
p.expectChar('{')
val bytes = mutableListOf<Byte>()
val mask = mutableListOf<Boolean>()
p.skipWsAndComments()
while (p.peek() != '}') {
when {
p.peek() == '?' -> {
p.advance()
if (p.peek() == '?') p.advance()
else throw YaraParseException("Nibble-level hex wildcards ('4?') aren't supported, only full-byte '??'")
bytes.add(0)
mask.add(true)
}
p.peek()?.isHexDigit() == true -> {
val hi = p.advance() ?: throw YaraParseException("Truncated hex byte in hex string")
val lo = p.advance() ?: throw YaraParseException("Truncated hex byte in hex string")
if (!lo.isHexDigit()) throw YaraParseException("Truncated hex byte in hex string")
bytes.add(((hexVal(hi) shl 4) or hexVal(lo)).toByte())
mask.add(false)
}
p.peek() == '[' || p.peek() == '(' -> throw YaraParseException("Hex jump ranges / alternations aren't supported in this subset")
else -> throw YaraParseException("Unexpected character '${p.peek()}' in hex string")
}
p.skipWsAndComments()
}
p.expectChar('}')
if (bytes.isEmpty()) throw YaraParseException("Empty hex string")
return YaraPattern.Hex(bytes.toByteArray(), mask.toBooleanArray())
}
private fun parseRegexPattern(p: Cursor): YaraPattern.Regex {
p.expectChar('/')
val sb = StringBuilder()
while (true) {
val c = p.advance() ?: throw YaraParseException("Unterminated regex literal")
if (c == '\\') {
sb.append(c)
sb.append(p.advance() ?: throw YaraParseException("Unterminated regex escape"))
continue
}
if (c == '/') break
sb.append(c)
}
var nocase = false
p.skipWsAndComments()
while (p.matchIdentAhead("nocase") || p.matchIdentAhead("fullword")) {
when (val mod = p.parseIdentRaw()) {
"nocase" -> nocase = true
"fullword" -> throw YaraParseException("'fullword' on regex patterns isn't supported")
else -> throw YaraParseException("Unknown regex modifier '$mod'")
}
p.skipWsAndComments()
}
val opts = if (nocase) setOf(RegexOption.IGNORE_CASE) else emptySet()
val regex = try {
kotlin.text.Regex(sb.toString(), opts)
} catch (e: Exception) {
throw YaraParseException("Invalid regex /${sb}/: ${e.message}")
}
return YaraPattern.Regex(regex)
}
// --- condition expression parser (recursive descent, standard precedence: or < and < not) ---
private fun parseCondition(p: Cursor, knownIds: List<String>): CondNode {
val node = parseOr(p, knownIds)
return node
}
private fun parseOr(p: Cursor, ids: List<String>): CondNode {
var left = parseAnd(p, ids)
p.skipWsAndComments()
while (p.matchIdentAhead("or")) {
p.parseIdentRaw()
p.skipWsAndComments()
val right = parseAnd(p, ids)
left = CondNode.Or(left, right)
p.skipWsAndComments()
}
return left
}
private fun parseAnd(p: Cursor, ids: List<String>): CondNode {
var left = parseNot(p, ids)
p.skipWsAndComments()
while (p.matchIdentAhead("and")) {
p.parseIdentRaw()
p.skipWsAndComments()
val right = parseNot(p, ids)
left = CondNode.And(left, right)
p.skipWsAndComments()
}
return left
}
private fun parseNot(p: Cursor, ids: List<String>): CondNode {
p.skipWsAndComments()
if (p.matchIdentAhead("not")) {
p.parseIdentRaw()
p.skipWsAndComments()
return CondNode.Not(parseNot(p, ids))
}
return parsePrimary(p, ids)
}
private fun parsePrimary(p: Cursor, ids: List<String>): CondNode {
p.skipWsAndComments()
val c = p.peek() ?: throw YaraParseException("Unexpected end of condition")
if (c == '(') {
p.advance()
val inner = parseOr(p, ids)
p.skipWsAndComments()
p.expectChar(')')
return inner
}
if (c == '$') {
p.advance()
val id = "$" + p.parseIdentRaw()
return CondNode.StrRef(id)
}
if (c.isDigit()) {
// Only appears here as the N in "N of (...)"
val n = p.parseNumberRaw().toInt()
p.skipWsAndComments()
p.expectIdent("of")
p.skipWsAndComments()
return parseOfTarget(p, OfCount.N(n), ids)
}
val word = p.parseIdentRaw()
return when (word) {
"true" -> CondNode.BoolLit(true)
"false" -> CondNode.BoolLit(false)
"any" -> { p.skipWsAndComments(); p.expectIdent("of"); p.skipWsAndComments(); parseOfTarget(p, OfCount.Any, ids) }
"all" -> { p.skipWsAndComments(); p.expectIdent("of"); p.skipWsAndComments(); parseOfTarget(p, OfCount.All, ids) }
"filesize" -> parseSizeCmp(p)
else -> throw YaraParseException("Unsupported condition term '$word' (rule references and YARA-module fields aren't supported)")
}
}
private fun parseOfTarget(p: Cursor, count: OfCount, ids: List<String>): CondNode {
p.skipWsAndComments()
if (p.matchIdentAhead("them")) {
p.parseIdentRaw()
return CondNode.OfThem(count, null)
}
p.expectChar('(')
val list = mutableListOf<String>()
p.skipWsAndComments()
while (p.peek() != ')') {
p.expectChar('$')
val id = "$" + p.parseIdentRaw()
if (p.peek() == '*') throw YaraParseException("Wildcard string-set references (\$a*) aren't supported")
list.add(id)
p.skipWsAndComments()
if (p.peek() == ',') { p.advance(); p.skipWsAndComments() }
}
p.expectChar(')')
return CondNode.OfThem(count, list)
}
private fun parseSizeCmp(p: Cursor): CondNode.SizeCmp {
p.skipWsAndComments()
val op = when {
p.peekAhead("<=") -> { p.advanceN(2); "<=" }
p.peekAhead(">=") -> { p.advanceN(2); ">=" }
p.peekAhead("==") -> { p.advanceN(2); "==" }
p.peek() == '<' -> { p.advance(); "<" }
p.peek() == '>' -> { p.advance(); ">" }
else -> throw YaraParseException("Expected comparison operator after 'filesize'")
}
p.skipWsAndComments()
val numStr = p.parseNumberRaw()
var multiplier = 1L
if (p.peek()?.isLetter() == true) {
when (val unit = p.parseIdentRaw().uppercase()) {
"KB" -> multiplier = 1024L
"MB" -> multiplier = 1024L * 1024L
"GB" -> multiplier = 1024L * 1024L * 1024L
"B" -> multiplier = 1L
else -> throw YaraParseException("Unknown size unit '$unit'")
}
}
val bytes = numStr.toLong() * multiplier
return CondNode.SizeCmp(op, bytes)
}
// --- character-level cursor ---
private class Cursor(private val s: String) {
var pos = 0
fun eof() = pos >= s.length
fun peek(): Char? = if (pos < s.length) s[pos] else null
fun peekAhead(literal: String): Boolean = s.regionMatches(pos, literal, 0, literal.length)
fun advance(): Char? {
if (pos >= s.length) return null
return s[pos++]
}
fun advanceN(n: Int) { repeat(n) { advance() } }
fun skipWsAndComments() {
while (pos < s.length) {
val c = s[pos]
if (c.isWhitespace()) { pos++; continue }
if (c == '/' && pos + 1 < s.length && s[pos + 1] == '/') {
while (pos < s.length && s[pos] != '\n') pos++
continue
}
if (c == '/' && pos + 1 < s.length && s[pos + 1] == '*') {
pos += 2
while (pos + 1 < s.length && !(s[pos] == '*' && s[pos + 1] == '/')) pos++
pos = minOf(pos + 2, s.length)
continue
}
break
}
}
fun expectChar(c: Char) {
skipWsAndComments()
if (peek() != c) throw YaraParseException("Expected '$c' at offset $pos, found '${peek()}'")
pos++
}
fun expectIdent(word: String) {
skipWsAndComments()
val id = parseIdentRaw()
if (id != word) throw YaraParseException("Expected '$word' at offset $pos, found '$id'")
}
fun matchIdentAhead(word: String): Boolean {
skipWsAndComments()
val save = pos
val ok = try { parseIdentRaw() == word } catch (e: Exception) { false }
pos = save
return ok
}
fun atSectionKeyword(): Boolean = matchIdentAhead("meta") || matchIdentAhead("strings") || matchIdentAhead("condition")
fun parseIdentRaw(): String {
skipWsAndComments()
val start = pos
if (pos >= s.length || !(s[pos].isLetter() || s[pos] == '_')) {
throw YaraParseException("Expected identifier at offset $pos, found '${peek()}'")
}
while (pos < s.length && (s[pos].isLetterOrDigit() || s[pos] == '_')) pos++
return s.substring(start, pos)
}
fun parseNumberRaw(): String {
skipWsAndComments()
val start = pos
if (pos < s.length && s[pos] == '-') pos++
while (pos < s.length && (s[pos].isDigit() || s[pos] == '.')) pos++
if (pos == start) throw YaraParseException("Expected number at offset $pos")
return s.substring(start, pos)
}
fun parseQuotedString(): String {
expectChar('"')
val sb = StringBuilder()
while (true) {
val c = advance() ?: throw YaraParseException("Unterminated string literal")
if (c == '\\') {
val esc = advance() ?: throw YaraParseException("Unterminated escape in string literal")
sb.append(
when (esc) {
'n' -> '\n'; 't' -> '\t'; 'r' -> '\r'; '"' -> '"'; '\\' -> '\\'
'x' -> {
val hi = advance() ?: throw YaraParseException("Bad \\x escape")
val lo = advance() ?: throw YaraParseException("Bad \\x escape")
((hexVal(hi) shl 4) or hexVal(lo)).toChar()
}
else -> esc
}
)
continue
}
if (c == '"') break
sb.append(c)
}
return sb.toString()
}
fun skipToNextRuleKeyword(): Boolean {
while (pos < s.length) {
if (matchIdentAhead("rule")) return true
pos++
}
return false
}
}
}
private fun Char.isHexDigit(): Boolean = this.isDigit() || this in 'a'..'f' || this in 'A'..'F'
private fun hexVal(c: Char): Int = when (c) {
in '0'..'9' -> c - '0'
in 'a'..'f' -> c - 'a' + 10
in 'A'..'F' -> c - 'A' + 10
else -> throw YaraParseException("Invalid hex digit '$c'")
}
app/src/main/java/org/wazuh/androidagent/yara/YaraEngine.kt
package org.wazuh.androidagent.yara
/**
* Matches parsed [YaraRule]s against a byte buffer. Byte/hex/regex search
* uses an anchor-then-verify approach (find a candidate position on the
* pattern's first non-wildcard byte, then verify the rest) rather than
* naive O(n*m) scanning — fast in practice for the short signatures typical
* of real-world YARA rules, without needing a Boyer-Moore table per pattern.
* [YaraScanner] bounds the input size before calling this, since worst-case
* behavior on a pathological pattern/input pair is still O(n*m).
*/
object YaraEngine {
fun scan(rules: List<YaraRule>, content: ByteArray): List<YaraMatch> {
val results = mutableListOf<YaraMatch>()
// Decode once for regex patterns (ISO-8859-1 = 1 byte <-> 1 char, no lossy remapping).
val asLatin1: String by lazy { String(content, Charsets.ISO_8859_1) }
for (rule in rules) {
val matchedIds = mutableSetOf<String>()
for (s in rule.strings) {
val hit = when (val pat = s.pattern) {
is YaraPattern.Text -> pat.variants.any { variant ->
findText(content, variant, pat.nocase, pat.fullword) >= 0
}
is YaraPattern.Hex -> findHex(content, pat.bytes, pat.mask) >= 0
is YaraPattern.Regex -> pat.pattern.containsMatchIn(asLatin1)
}
if (hit) matchedIds.add(s.id)
}
val allIds = rule.strings.map { it.id }
if (evalCondition(rule.condition, matchedIds, allIds, content.size.toLong())) {
results.add(YaraMatch(rule, allIds.filter { it in matchedIds }))
}
}
return results
}
private fun evalCondition(node: CondNode, matched: Set<String>, allIds: List<String>, filesize: Long): Boolean =
when (node) {
is CondNode.StrRef -> node.id in matched
is CondNode.BoolLit -> node.v
is CondNode.Not -> !evalCondition(node.n, matched, allIds, filesize)
is CondNode.And -> evalCondition(node.a, matched, allIds, filesize) && evalCondition(node.b, matched, allIds, filesize)
is CondNode.Or -> evalCondition(node.a, matched, allIds, filesize) || evalCondition(node.b, matched, allIds, filesize)
is CondNode.OfThem -> {
val ids = node.ids ?: allIds
val count = ids.count { it in matched }
when (val c = node.count) {
OfCount.All -> count == ids.size && ids.isNotEmpty()
OfCount.Any -> count >= 1
is OfCount.N -> count >= c.n
}
}
is CondNode.SizeCmp -> when (node.op) {
"<" -> filesize < node.bytes
">" -> filesize > node.bytes
"<=" -> filesize <= node.bytes
">=" -> filesize >= node.bytes
"==" -> filesize == node.bytes
else -> false
}
}
private fun isWordByte(b: Byte): Boolean {
val c = b.toInt().toChar()
return c.isLetterOrDigit() || c == '_'
}
private fun foldByte(b: Byte): Byte {
val c = b.toInt().toChar()
return if (c in 'A'..'Z') (c + 32).code.toByte() else b
}
/** Returns the index of the first match, or -1. */
fun findText(haystack: ByteArray, needle: ByteArray, nocase: Boolean, fullword: Boolean): Int {
if (needle.isEmpty() || needle.size > haystack.size) return -1
val n = haystack.size
val m = needle.size
val anchor = if (nocase) foldByte(needle[0]) else needle[0]
var i = 0
outer@ while (i <= n - m) {
val hb = if (nocase) foldByte(haystack[i]) else haystack[i]
if (hb == anchor) {
var j = 1
while (j < m) {
val a = if (nocase) foldByte(haystack[i + j]) else haystack[i + j]
val b = if (nocase) foldByte(needle[j]) else needle[j]
if (a != b) { i++; continue@outer }
j++
}
if (!fullword || isWordBoundaryMatch(haystack, i, m)) return i
}
i++
}
return -1
}
private fun isWordBoundaryMatch(haystack: ByteArray, start: Int, len: Int): Boolean {
val beforeOk = start == 0 || !isWordByte(haystack[start - 1])
val afterOk = start + len >= haystack.size || !isWordByte(haystack[start + len])
return beforeOk && afterOk
}
/** Returns the index of the first match, or -1. mask[i]==true means that position is a '??' wildcard. */
fun findHex(haystack: ByteArray, needle: ByteArray, mask: BooleanArray): Int {
if (needle.isEmpty() || needle.size > haystack.size) return -1
val n = haystack.size
val m = needle.size
var anchor = 0
while (anchor < m && mask[anchor]) anchor++
if (anchor == m) return 0 // fully wildcard pattern: matches trivially at position 0
val anchorByte = needle[anchor]
var i = 0
outer@ while (i <= n - m) {
if (haystack[i + anchor] == anchorByte) {
var j = 0
while (j < m) {
if (!mask[j] && haystack[i + j] != needle[j]) { i++; continue@outer }
j++
}
return i
}
i++
}
return -1
}
}
app/src/main/java/org/wazuh/androidagent/yara/YaraScanner.kt
package org.wazuh.androidagent.yara
import android.content.Context
import android.util.Log
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.Severity
import java.io.File
import java.time.Instant
/**
* Loads the bundled starter ruleset (assets/yara_rules/android_starter.yar)
* plus any user-supplied rules from Settings, and scans a single APK's bytes
* against them. Designed to be called on `app_installed` / `app_updated`
* events (see AgentForegroundService) rather than as a continuous full-device
* sweep, so the cost is bounded to "one APK, once, when it actually changes."
*/
class YaraScanner(private val context: Context) {
private var cachedRules: List<YaraRule>? = null
private var cachedCustomSource: String? = null
/** (Re)loads rules if the custom-rules text has changed since last call; cheap to call every scan. */
private fun rules(customRulesSource: String): List<YaraRule> {
if (cachedRules != null && cachedCustomSource == customRulesSource) return cachedRules!!
val combined = mutableListOf<YaraRule>()
try {
val bundled = context.assets.open(BUNDLED_RULES_ASSET).bufferedReader().use { it.readText() }
combined.addAll(YaraParser.parseRulesLenient(bundled) { name, e ->
Log.w(TAG, "Skipping bundled rule $name: ${e.message}")
})
} catch (e: Exception) {
Log.e(TAG, "Failed to load bundled YARA rules", e)
}
if (customRulesSource.isNotBlank()) {
combined.addAll(YaraParser.parseRulesLenient(customRulesSource) { name, e ->
Log.w(TAG, "Skipping custom rule $name: ${e.message}")
})
}
cachedRules = combined
cachedCustomSource = customRulesSource
return combined
}
/**
* Scans [apkPath] and returns a "yara_match" LogEvent per matched rule (empty list if
* clean, unreadable, or over [MAX_SCAN_BYTES]). Never throws — scan failures are logged
* and treated as "no match" so one bad APK can't take the collector cycle down.
*/
fun scanApk(packageName: String, apkPath: String?, customRulesSource: String): List<LogEvent> {
if (apkPath.isNullOrBlank()) return emptyList()
return try {
val file = File(apkPath)
if (!file.exists() || !file.canRead()) return emptyList()
if (file.length() > MAX_SCAN_BYTES) {
Log.i(TAG, "Skipping YARA scan of $packageName: APK is ${file.length()} bytes, over the ${MAX_SCAN_BYTES} cap")
return emptyList()
}
val content = file.readBytes()
val ruleSet = rules(customRulesSource)
val matches = YaraEngine.scan(ruleSet, content)
matches.map { toEvent(packageName, it) }
} catch (e: Exception) {
Log.e(TAG, "YARA scan of $packageName failed", e)
emptyList()
}
}
private fun toEvent(packageName: String, match: YaraMatch): LogEvent {
return LogEvent(
timestamp = Instant.now(),
msgId = "yara_match",
severity = severityFor(match.rule.meta["severity"]),
fields = mapOf(
"package_name" to packageName,
"rule_name" to match.rule.name,
"rule_tags" to match.rule.tags,
"rule_description" to (match.rule.meta["description"] ?: ""),
"matched_strings" to match.matchedStringIds
)
)
}
/**
* Maps a rule's own `meta.severity` string (a convention this app's starter
* ruleset uses, not a real YARA feature) to our syslog-style Severity scale,
* so a rule that's explicitly informational (e.g. the EICAR self-test)
* doesn't get reported as critical just because it's a "YARA match."
* Unrecognized/missing values default to a cautious WARNING rather than
* the two extremes, since an unlabeled rule's real severity is unknown.
*/
private fun severityFor(metaSeverity: String?): Int = when (metaSeverity?.lowercase()) {
"critical" -> Severity.EMERGENCY
"high" -> Severity.CRITICAL
"medium" -> Severity.WARNING
"low" -> Severity.NOTICE
"info" -> Severity.INFO
else -> Severity.WARNING
}
companion object {
private const val TAG = "YaraScanner"
private const val BUNDLED_RULES_ASSET = "yara_rules/android_starter.yar"
/** Skip scanning APKs larger than this — bounds worst-case scan time/memory on-device. */
const val MAX_SCAN_BYTES = 150L * 1024 * 1024
}
}
UI
The three screens: main settings/enrollment, Live Log, and AI Analysis.
app/src/main/java/org/wazuh/androidagent/ui/MainActivity.kt
package org.wazuh.androidagent.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.provider.Settings
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.activity.result.contract.ActivityResultContracts
import org.wazuh.androidagent.AgentForegroundService
import org.wazuh.androidagent.R
import org.wazuh.androidagent.SettingsStore
import org.wazuh.androidagent.TransportMode
import org.wazuh.androidagent.net.TlsUtil
import org.wazuh.androidagent.wazuhagent.AuthdClient
class MainActivity : AppCompatActivity() {
private lateinit var settings: SettingsStore
private lateinit var hostInput: EditText
private lateinit var portInput: EditText
private lateinit var deviceLabelInput: EditText
private lateinit var tlsSwitch: com.google.android.material.switchmaterial.SwitchMaterial
private lateinit var trustAllSwitch: com.google.android.material.switchmaterial.SwitchMaterial
private lateinit var customCaInput: EditText
private lateinit var deviceInfoSwitch: com.google.android.material.switchmaterial.SwitchMaterial
private lateinit var installedAppsSwitch: com.google.android.material.switchmaterial.SwitchMaterial
private lateinit var networkStateSwitch: com.google.android.material.switchmaterial.SwitchMaterial
private lateinit var batterySwitch: com.google.android.material.switchmaterial.SwitchMaterial
private lateinit var notificationsSwitch: com.google.android.material.switchmaterial.SwitchMaterial
private lateinit var notificationTextSwitch: com.google.android.material.switchmaterial.SwitchMaterial
private lateinit var yaraScanSwitch: com.google.android.material.switchmaterial.SwitchMaterial
private lateinit var customYaraRulesInput: EditText
private lateinit var fullSystemLogsSwitch: com.google.android.material.switchmaterial.SwitchMaterial
private lateinit var systemLogPermissionStatusText: TextView
private lateinit var statusText: TextView
private lateinit var startStopButton: Button
private lateinit var agentProtocolSwitch: com.google.android.material.switchmaterial.SwitchMaterial
private lateinit var enrollmentStatusText: TextView
private lateinit var authdPortInput: EditText
private lateinit var agentPortInput: EditText
private lateinit var enrollPasswordInput: EditText
private lateinit var enrollButton: Button
private var boundService: AgentForegroundService? = null
private var bound = false
private val statusHandler = Handler(Looper.getMainLooper())
private val mainHandler = Handler(Looper.getMainLooper())
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
boundService = (service as AgentForegroundService.LocalBinder).getService()
bound = true
refreshStatus()
}
override fun onServiceDisconnected(name: ComponentName?) {
boundService = null
bound = false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
settings = SettingsStore(applicationContext)
hostInput = findViewById(R.id.hostInput)
portInput = findViewById(R.id.portInput)
deviceLabelInput = findViewById(R.id.deviceLabelInput)
tlsSwitch = findViewById(R.id.tlsSwitch)
trustAllSwitch = findViewById(R.id.trustAllSwitch)
customCaInput = findViewById(R.id.customCaInput)
deviceInfoSwitch = findViewById(R.id.deviceInfoSwitch)
installedAppsSwitch = findViewById(R.id.installedAppsSwitch)
networkStateSwitch = findViewById(R.id.networkStateSwitch)
batterySwitch = findViewById(R.id.batterySwitch)
notificationsSwitch = findViewById(R.id.notificationsSwitch)
notificationTextSwitch = findViewById(R.id.notificationTextSwitch)
yaraScanSwitch = findViewById(R.id.yaraScanSwitch)
customYaraRulesInput = findViewById(R.id.customYaraRulesInput)
fullSystemLogsSwitch = findViewById(R.id.fullSystemLogsSwitch)
systemLogPermissionStatusText = findViewById(R.id.systemLogPermissionStatusText)
statusText = findViewById(R.id.statusText)
startStopButton = findViewById(R.id.startStopButton)
agentProtocolSwitch = findViewById(R.id.agentProtocolSwitch)
enrollmentStatusText = findViewById(R.id.enrollmentStatusText)
authdPortInput = findViewById(R.id.authdPortInput)
agentPortInput = findViewById(R.id.agentPortInput)
enrollPasswordInput = findViewById(R.id.enrollPasswordInput)
enrollButton = findViewById(R.id.enrollButton)
loadSettingsIntoUi()
refreshSystemLogPermissionStatus()
startStopButton.setOnClickListener { onStartStopClicked() }
enrollButton.setOnClickListener { onEnrollClicked() }
findViewById<Button>(R.id.testEventButton).setOnClickListener {
saveUiIntoSettings()
boundService?.enqueueTestEvent()
Toast.makeText(this, "Test event queued", Toast.LENGTH_SHORT).show()
}
findViewById<Button>(R.id.grantNotificationAccessButton).setOnClickListener {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
}
findViewById<Button>(R.id.liveLogButton).setOnClickListener {
startActivity(Intent(this, LiveLogActivity::class.java))
}
findViewById<Button>(R.id.aiAnalysisButton).setOnClickListener {
startActivity(Intent(this, AiAnalysisActivity::class.java))
}
// Category toggles take effect immediately (the service re-reads Settings
// on every collection tick / receiver callback), so persist as soon as
// the user flips one rather than waiting for another save point.
val toggleListener = { _: android.widget.CompoundButton, _: Boolean -> saveUiIntoSettings() }
deviceInfoSwitch.setOnCheckedChangeListener(toggleListener)
installedAppsSwitch.setOnCheckedChangeListener(toggleListener)
networkStateSwitch.setOnCheckedChangeListener(toggleListener)
batterySwitch.setOnCheckedChangeListener(toggleListener)
notificationsSwitch.setOnCheckedChangeListener(toggleListener)
notificationTextSwitch.setOnCheckedChangeListener(toggleListener)
yaraScanSwitch.setOnCheckedChangeListener(toggleListener)
fullSystemLogsSwitch.setOnCheckedChangeListener(toggleListener)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
}
if (settings.agentEnabled) {
bindToService()
}
}
override fun onResume() {
super.onResume()
statusHandler.post(statusUpdater)
// Re-checked every time the screen becomes visible (not just onCreate) since
// the only way this permission changes is the user running an adb command
// while the app is backgrounded, then switching back to it.
refreshSystemLogPermissionStatus()
}
override fun onPause() {
super.onPause()
statusHandler.removeCallbacks(statusUpdater)
// trustAllSwitch/tlsSwitch/customCaInput (and host/port/label) have no
// per-change listener like the category toggles do — without this,
// navigating away (e.g. to AiAnalysisActivity) right after flipping one
// silently drops it, since it was never on a save path.
saveUiIntoSettings()
}
override fun onDestroy() {
super.onDestroy()
if (bound) {
unbindService(connection)
bound = false
}
}
private val statusUpdater = object : Runnable {
override fun run() {
refreshStatus()
statusHandler.postDelayed(this, 2000)
}
}
private fun loadSettingsIntoUi() {
hostInput.setText(settings.managerHost)
portInput.setText(settings.managerPort.toString())
deviceLabelInput.setText(settings.deviceLabel)
tlsSwitch.isChecked = settings.useTls
trustAllSwitch.isChecked = settings.trustAllCerts
customCaInput.setText(settings.customCaPem)
deviceInfoSwitch.isChecked = settings.collectDeviceInfo
installedAppsSwitch.isChecked = settings.collectInstalledApps
networkStateSwitch.isChecked = settings.collectNetworkState
batterySwitch.isChecked = settings.collectBattery
notificationsSwitch.isChecked = settings.collectNotifications
notificationTextSwitch.isChecked = settings.includeNotificationText
yaraScanSwitch.isChecked = settings.collectYaraScan
customYaraRulesInput.setText(settings.customYaraRules)
fullSystemLogsSwitch.isChecked = settings.collectFullSystemLogs
startStopButton.text = if (settings.agentEnabled) "Stop agent" else "Start agent"
agentProtocolSwitch.isChecked = settings.transportMode == TransportMode.WAZUH_AGENT
authdPortInput.setText(settings.authdPort.toString())
agentPortInput.setText(settings.agentProtocolPort.toString())
enrollPasswordInput.setText(settings.enrollmentPassword)
updateEnrollmentStatusText()
}
private fun saveUiIntoSettings() {
settings.managerHost = hostInput.text.toString().trim()
settings.managerPort = portInput.text.toString().trim().toIntOrNull() ?: 6514
settings.deviceLabel = deviceLabelInput.text.toString().trim()
settings.useTls = tlsSwitch.isChecked
settings.trustAllCerts = trustAllSwitch.isChecked
settings.customCaPem = customCaInput.text.toString().trim()
settings.collectDeviceInfo = deviceInfoSwitch.isChecked
settings.collectInstalledApps = installedAppsSwitch.isChecked
settings.collectNetworkState = networkStateSwitch.isChecked
settings.collectBattery = batterySwitch.isChecked
settings.collectNotifications = notificationsSwitch.isChecked
settings.includeNotificationText = notificationTextSwitch.isChecked
settings.collectYaraScan = yaraScanSwitch.isChecked
settings.customYaraRules = customYaraRulesInput.text.toString()
settings.collectFullSystemLogs = fullSystemLogsSwitch.isChecked
settings.transportMode = if (agentProtocolSwitch.isChecked) TransportMode.WAZUH_AGENT else TransportMode.SYSLOG
settings.authdPort = authdPortInput.text.toString().trim().toIntOrNull() ?: 1515
settings.agentProtocolPort = agentPortInput.text.toString().trim().toIntOrNull() ?: 1514
settings.enrollmentPassword = enrollPasswordInput.text.toString()
}
/**
* READ_LOGS can only ever be granted out-of-band via `adb shell pm grant`, so
* this is purely informational — there is no button here that could request
* it, unlike every other permission this app uses.
*/
private fun refreshSystemLogPermissionStatus() {
val granted = ContextCompat.checkSelfPermission(
this, android.Manifest.permission.READ_LOGS
) == android.content.pm.PackageManager.PERMISSION_GRANTED
systemLogPermissionStatusText.text = if (granted) {
"READ_LOGS permission: granted"
} else {
"READ_LOGS permission: not granted (see website for the adb command)"
}
}
private fun updateEnrollmentStatusText() {
enrollmentStatusText.text = if (settings.isEnrolled()) {
"Enrolled as '${settings.agentName}' (id ${settings.agentId})"
} else {
"Not enrolled"
}
}
private fun onEnrollClicked() {
saveUiIntoSettings()
val host = settings.managerHost
if (host.isBlank()) {
Toast.makeText(this, "Enter a manager host first", Toast.LENGTH_LONG).show()
return
}
val deviceName = settings.deviceLabel.ifBlank {
"android-${Build.MODEL}-${System.currentTimeMillis() % 100000}"
}.replace(Regex("[^A-Za-z0-9._-]"), "_")
enrollButton.isEnabled = false
enrollmentStatusText.text = "Enrolling…"
Thread {
try {
val factory = TlsUtil.buildSocketFactory(
settings.customCaPem.ifBlank { null },
settings.trustAllCerts
)
val result = AuthdClient.enroll(
host = host,
port = settings.authdPort,
agentName = deviceName,
password = settings.enrollmentPassword.ifBlank { null },
sslSocketFactory = factory
)
settings.agentId = result.agentId
settings.agentName = result.agentName
settings.agentKey = result.agentKey
mainHandler.post {
enrollButton.isEnabled = true
updateEnrollmentStatusText()
Toast.makeText(this, "Enrolled: ${result.agentName} (${result.agentId})", Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
mainHandler.post {
enrollButton.isEnabled = true
enrollmentStatusText.text = "Enrollment failed: ${e.message}"
Toast.makeText(this, "Enrollment failed: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}.start()
}
private fun onStartStopClicked() {
saveUiIntoSettings()
if (!settings.agentEnabled) {
val ready = if (settings.transportMode == TransportMode.WAZUH_AGENT) {
settings.managerHost.isNotBlank() && settings.isEnrolled()
} else {
settings.isConfigured()
}
if (!ready) {
val msg = if (settings.transportMode == TransportMode.WAZUH_AGENT)
"Enroll with the manager first" else "Enter a manager host and valid port first"
Toast.makeText(this, msg, Toast.LENGTH_LONG).show()
return
}
settings.agentEnabled = true
val intent = Intent(this, AgentForegroundService::class.java)
ContextCompat.startForegroundService(this, intent)
bindToService()
} else {
settings.agentEnabled = false
boundService?.let {
if (bound) {
unbindService(connection)
bound = false
}
}
stopService(Intent(this, AgentForegroundService::class.java))
}
startStopButton.text = if (settings.agentEnabled) "Stop agent" else "Start agent"
refreshStatus()
}
private fun bindToService() {
bindService(Intent(this, AgentForegroundService::class.java), connection, Context.BIND_AUTO_CREATE)
}
private fun refreshStatus() {
val svc = boundService
statusText.text = if (!settings.agentEnabled) {
"Agent stopped"
} else if (svc == null) {
"Agent starting…"
} else {
val connState = if (svc.isConnected()) "connected" else "reconnecting"
"Agent running — $connState, queued=${svc.queueSize()}, sent=${svc.sentCount()}"
}
}
}
app/src/main/java/org/wazuh/androidagent/ui/LiveLogActivity.kt
package org.wazuh.androidagent.ui
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.wazuh.androidagent.R
import org.wazuh.androidagent.net.LiveLogBus
import org.wazuh.androidagent.net.LogEvent
/**
* Live, in-app tail of every event the agent produces, newest at the bottom
* (like a terminal), auto-scrolling as new events arrive. Purely local — it
* reads from [LiveLogBus], not the network — so it works whether or not the
* manager connection is currently up.
*/
class LiveLogActivity : AppCompatActivity() {
private lateinit var recyclerView: RecyclerView
private lateinit var emptyText: TextView
private lateinit var adapter: LiveLogAdapter
private var autoScroll = true
private val listener: (LogEvent) -> Unit = { event ->
val pos = adapter.append(event)
emptyText.visibility = android.view.View.GONE
if (pos >= 0) {
adapter.notifyItemInserted(pos)
} else {
// Oldest row was dropped to stay under the cap; simplest correct
// refresh for that case is a full rebind.
adapter.notifyDataSetChanged()
}
if (autoScroll) recyclerView.scrollToPosition(adapter.itemCount - 1)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_live_log)
recyclerView = findViewById(R.id.liveLogRecyclerView)
emptyText = findViewById(R.id.liveLogEmptyText)
adapter = LiveLogAdapter()
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = adapter
val initial = LiveLogBus.recent()
adapter.replaceAll(initial)
emptyText.visibility = if (initial.isEmpty()) android.view.View.VISIBLE else android.view.View.GONE
if (initial.isNotEmpty()) recyclerView.scrollToPosition(adapter.itemCount - 1)
val autoScrollButton = findViewById<Button>(R.id.autoScrollToggleButton)
autoScrollButton.text = "Auto-scroll: ON"
autoScrollButton.setOnClickListener {
autoScroll = !autoScroll
autoScrollButton.text = if (autoScroll) "Auto-scroll: ON" else "Auto-scroll: OFF"
if (autoScroll) recyclerView.scrollToPosition(adapter.itemCount - 1)
}
findViewById<Button>(R.id.clearLiveLogButton).setOnClickListener {
adapter.clear()
emptyText.visibility = android.view.View.VISIBLE
}
}
override fun onStart() {
super.onStart()
LiveLogBus.addListener(listener)
}
override fun onStop() {
super.onStop()
LiveLogBus.removeListener(listener)
}
}
app/src/main/java/org/wazuh/androidagent/ui/LiveLogAdapter.kt
package org.wazuh.androidagent.ui
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.Severity
import java.time.format.DateTimeFormatter
import java.time.ZoneId
/** Renders one [LogEvent] per row: timestamp, event type, severity-colored, and a compact field dump. */
class LiveLogAdapter(private val events: MutableList<LogEvent> = mutableListOf()) :
RecyclerView.Adapter<LiveLogAdapter.ViewHolder>() {
class ViewHolder(view: TextView) : RecyclerView.ViewHolder(view) {
val text: TextView = view
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val tv = LayoutInflater.from(parent.context)
.inflate(android.R.layout.simple_list_item_1, parent, false) as TextView
tv.typeface = android.graphics.Typeface.MONOSPACE
tv.textSize = 12f
tv.setPadding(24, 12, 24, 12)
return ViewHolder(tv)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val event = events[position]
val time = TIME_FMT.format(event.timestamp.atZone(ZoneId.systemDefault()))
val fieldsPreview = event.fields.entries.joinToString(", ") { (k, v) -> "$k=$v" }
holder.text.text = "$time [${event.msgId}]\n$fieldsPreview"
holder.text.setTextColor(colorFor(event.severity))
}
override fun getItemCount(): Int = events.size
/** Appends and returns the new item's position, or null if it was dropped to stay under the cap. */
fun append(event: LogEvent): Int {
events.add(event)
val dropped = if (events.size > MAX_ROWS) {
events.removeAt(0)
true
} else false
return if (dropped) -1 else events.size - 1
}
fun replaceAll(newEvents: List<LogEvent>) {
events.clear()
events.addAll(newEvents.takeLast(MAX_ROWS))
notifyDataSetChanged()
}
fun clear() {
events.clear()
notifyDataSetChanged()
}
private fun colorFor(severity: Int): Int = when {
severity <= Severity.ERROR -> Color.parseColor("#D32F2F")
severity == Severity.WARNING -> Color.parseColor("#F57C00")
severity == Severity.NOTICE -> Color.parseColor("#1976D2")
severity == Severity.DEBUG -> Color.parseColor("#9E9E9E")
else -> Color.parseColor("#1976D2") // INFO and anything else - was near-black (#212121), now blue per request
}
companion object {
private const val MAX_ROWS = 500
private val TIME_FMT = DateTimeFormatter.ofPattern("HH:mm:ss")
}
}
app/src/main/java/org/wazuh/androidagent/ui/AiAnalysisActivity.kt
package org.wazuh.androidagent.ui
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import org.wazuh.androidagent.R
import org.wazuh.androidagent.SettingsStore
import org.wazuh.androidagent.claude.ClaudeClient
import org.wazuh.androidagent.wazuhapi.WazuhApiClient
/**
* On-demand screen: authenticates to the Wazuh manager's REST API, fetches
* recent `/manager/logs` entries, and sends them to Claude for analysis.
* Separate from [AgentForegroundService][org.wazuh.androidagent.AgentForegroundService] —
* nothing here runs unless the user taps the button.
*/
class AiAnalysisActivity : AppCompatActivity() {
private lateinit var settings: SettingsStore
private lateinit var apiHostInput: EditText
private lateinit var apiPortInput: EditText
private lateinit var apiUsernameInput: EditText
private lateinit var apiPasswordInput: EditText
private lateinit var logFetchLimitInput: EditText
private lateinit var claudeApiKeyInput: EditText
private lateinit var claudeModelInput: EditText
private lateinit var fetchAndAnalyzeButton: Button
private lateinit var analysisStatusText: TextView
private lateinit var analysisResultText: TextView
private lateinit var rawLogsText: TextView
private val mainHandler = Handler(Looper.getMainLooper())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_ai_analysis)
settings = SettingsStore(applicationContext)
apiHostInput = findViewById(R.id.apiHostInput)
apiPortInput = findViewById(R.id.apiPortInput)
apiUsernameInput = findViewById(R.id.apiUsernameInput)
apiPasswordInput = findViewById(R.id.apiPasswordInput)
logFetchLimitInput = findViewById(R.id.logFetchLimitInput)
claudeApiKeyInput = findViewById(R.id.claudeApiKeyInput)
claudeModelInput = findViewById(R.id.claudeModelInput)
fetchAndAnalyzeButton = findViewById(R.id.fetchAndAnalyzeButton)
analysisStatusText = findViewById(R.id.analysisStatusText)
analysisResultText = findViewById(R.id.analysisResultText)
rawLogsText = findViewById(R.id.rawLogsText)
loadSettingsIntoUi()
fetchAndAnalyzeButton.setOnClickListener { onFetchAndAnalyzeClicked() }
}
override fun onPause() {
super.onPause()
saveUiIntoSettings()
}
private fun loadSettingsIntoUi() {
apiHostInput.setText(settings.wazuhApiHost.ifBlank { settings.managerHost })
apiPortInput.setText(settings.wazuhApiPort.toString())
apiUsernameInput.setText(settings.wazuhApiUsername)
apiPasswordInput.setText(settings.wazuhApiPassword)
logFetchLimitInput.setText(settings.logFetchLimit.toString())
claudeApiKeyInput.setText(settings.claudeApiKey)
claudeModelInput.setText(settings.claudeModel)
}
private fun saveUiIntoSettings() {
settings.wazuhApiHost = apiHostInput.text.toString().trim()
settings.wazuhApiPort = apiPortInput.text.toString().trim().toIntOrNull() ?: 55000
settings.wazuhApiUsername = apiUsernameInput.text.toString().trim()
settings.wazuhApiPassword = apiPasswordInput.text.toString()
settings.logFetchLimit = logFetchLimitInput.text.toString().trim().toIntOrNull()?.coerceIn(1, 5000) ?: 200
settings.claudeApiKey = claudeApiKeyInput.text.toString().trim()
settings.claudeModel = claudeModelInput.text.toString().trim().ifBlank { "claude-opus-5" }
}
private fun onFetchAndAnalyzeClicked() {
saveUiIntoSettings()
if (!settings.isAiAnalysisConfigured()) {
Toast.makeText(this, "Fill in the Wazuh API and Claude fields first", Toast.LENGTH_LONG).show()
return
}
fetchAndAnalyzeButton.isEnabled = false
analysisStatusText.text = "Authenticating to Wazuh API…"
analysisResultText.text = ""
rawLogsText.text = ""
val host = settings.wazuhApiHost
val port = settings.wazuhApiPort
val username = settings.wazuhApiUsername
val password = settings.wazuhApiPassword
val limit = settings.logFetchLimit
val apiKey = settings.claudeApiKey
val model = settings.claudeModel
val trustAll = settings.trustAllCerts
val customCa = settings.customCaPem.ifBlank { null }
// This runs on a plain background Thread, not a foreground service —
// if the screen turns off mid-request, Doze/App Standby (or an OEM's
// more aggressive background killer) can freeze it with no exception
// ever thrown, which looks exactly like "fetch works, then Claude
// silently never responds even after minutes". A short partial wake
// lock keeps the CPU (and this thread) alive for the request's
// lifetime regardless of screen state; WAKE_LOCK is already granted
// in the manifest for the agent service. Timeout is a safety net
// slightly above ClaudeClient's own connect+read timeout ceiling, in
// case release() is ever skipped by an unexpected code path.
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WazuhMobileAgent:AiAnalysis")
wakeLock.acquire(4 * 60 * 1000L)
Thread {
try {
val token = WazuhApiClient.authenticate(host, port, username, password, trustAll, customCa)
setStatus("Fetching last $limit manager log entries…")
val entries = WazuhApiClient.fetchManagerLogs(host, port, token, limit, trustAll, customCa)
val rawText = if (entries.isEmpty()) {
"(no log entries returned)"
} else {
entries.joinToString("\n")
}
mainHandler.post { rawLogsText.text = rawText }
if (entries.isEmpty()) {
setStatus("No log entries to analyze.")
mainHandler.post {
analysisResultText.text = "Nothing to analyze — the manager returned no log entries."
fetchAndAnalyzeButton.isEnabled = true
}
return@Thread
}
setStatus("Sending ${entries.size} entries to Claude ($model)…")
val analysis = ClaudeClient.analyze(
apiKey = apiKey,
model = model,
systemPrompt = SYSTEM_PROMPT,
userPrompt = buildUserPrompt(entries)
)
mainHandler.post {
analysisResultText.text = analysis
analysisStatusText.text = "Done — analyzed ${entries.size} log entries."
fetchAndAnalyzeButton.isEnabled = true
}
} catch (e: Exception) {
mainHandler.post {
analysisStatusText.text = "Failed: ${e.message}"
fetchAndAnalyzeButton.isEnabled = true
Toast.makeText(this, "Failed: ${e.message}", Toast.LENGTH_LONG).show()
}
} finally {
if (wakeLock.isHeld) wakeLock.release()
}
}.start()
}
private fun setStatus(text: String) {
mainHandler.post { analysisStatusText.text = text }
}
private fun buildUserPrompt(entries: List<WazuhApiClient.LogEntry>): String {
val header = "Below are ${entries.size} recent log entries from a Wazuh manager's /manager/logs API " +
"(the manager's own ossec.log, not indexed agent alerts), newest first.\n\n"
return header + entries.joinToString("\n") { it.toString() }
}
companion object {
private const val SYSTEM_PROMPT = """
You are a security analyst reviewing Wazuh SIEM manager log entries for an
Android companion app's user. Analyze the provided log entries and produce a
concise report covering:
1. Overall health/status of the manager (any errors, connection issues,
rule/decoder problems, agent connectivity issues).
2. Any security-relevant findings: authentication failures, unusual agent
behavior, configuration problems, or anything indicating a misconfigured
or compromised environment.
3. Notable patterns or repeated entries worth the user's attention.
4. A short prioritized list of recommended next steps, if any are warranted.
If nothing concerning is present, say so plainly rather than manufacturing
findings. Base every claim strictly on the provided log text — do not invent
timestamps, hosts, or events not present in the input. Keep the report
readable on a phone screen: use short headings and bullet points, not long
paragraphs.
"""
}
}
Utilities & tests
Crash capture, and the pure-JVM test suite for the YARA engine (no device required).
app/src/main/java/org/wazuh/androidagent/util/CrashCatcher.kt
package org.wazuh.androidagent.util
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.LogQueue
import org.wazuh.androidagent.net.Severity
import java.io.PrintWriter
import java.io.StringWriter
import java.time.Instant
/**
* Captures this app's own uncaught exceptions as a security-relevant event
* before handing off to the platform's default handler (so the app still
* crashes/reports normally). Android does not allow a non-system app to
* observe crashes of *other* apps.
*/
object CrashCatcher {
fun install(queue: LogQueue) {
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
val sw = StringWriter()
throwable.printStackTrace(PrintWriter(sw))
queue.enqueue(
LogEvent(
timestamp = Instant.now(),
msgId = "agent_crash",
severity = Severity.CRITICAL,
fields = mapOf(
"thread" to thread.name,
"exception" to throwable.toString(),
"stack_trace" to sw.toString().take(4000)
)
)
)
} catch (ignored: Exception) {
// Never let telemetry capture itself crash the crash handler.
} finally {
previous?.uncaughtException(thread, throwable)
}
}
}
}
app/src/test/java/org/wazuh/androidagent/yara/YaraEngineTest.kt
package org.wazuh.androidagent.yara
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.assertFalse
import org.junit.Test
import java.io.File
/**
* Pure-JVM tests for the parser + matching engine (no Android framework
* dependency) — run via `gradle testDebugUnitTest`, no device/emulator
* needed. This is the actual verification for the YARA subset, since there's
* no device available in this environment to test the compiled app on.
*/
class YaraEngineTest {
@Test
fun `simple ascii string match fires condition`() {
val src = """
rule Simple {
strings:
${'$'}a = "malicious_marker"
condition:
${'$'}a
}
""".trimIndent()
val rules = YaraParser.parseRules(src)
assertEquals(1, rules.size)
val hit = YaraEngine.scan(rules, "xxx malicious_marker xxx".toByteArray())
assertEquals(1, hit.size)
assertEquals("Simple", hit[0].rule.name)
val miss = YaraEngine.scan(rules, "nothing interesting here".toByteArray())
assertTrue(miss.isEmpty())
}
@Test
fun `nocase modifier matches regardless of case`() {
val src = """
rule CI {
strings:
${'$'}a = "EVIL" nocase
condition:
${'$'}a
}
""".trimIndent()
val rules = YaraParser.parseRules(src)
assertTrue(YaraEngine.scan(rules, "this is evil stuff".toByteArray()).isNotEmpty())
assertTrue(YaraEngine.scan(rules, "this is EvIl stuff".toByteArray()).isNotEmpty())
assertTrue(YaraEngine.scan(rules, "this is fine".toByteArray()).isEmpty())
}
@Test
fun `fullword modifier rejects substring-only matches`() {
val src = """
rule FW {
strings:
${'$'}a = "cat" fullword
condition:
${'$'}a
}
""".trimIndent()
val rules = YaraParser.parseRules(src)
assertTrue(YaraEngine.scan(rules, "a cat sat".toByteArray()).isNotEmpty())
assertTrue(YaraEngine.scan(rules, "concatenate".toByteArray()).isEmpty())
}
@Test
fun `hex pattern with wildcard matches`() {
val src = """
rule Hex {
strings:
${'$'}a = { 4D 5A ?? ?? 90 00 }
condition:
${'$'}a
}
""".trimIndent()
val rules = YaraParser.parseRules(src)
val content = byteArrayOf(0x00, 0x4D, 0x5A, 0x11.toByte(), 0x22, 0x90.toByte(), 0x00, 0x00)
assertTrue(YaraEngine.scan(rules, content).isNotEmpty())
val noMatch = byteArrayOf(0x4D, 0x5A, 0x11.toByte(), 0x22, 0x91.toByte(), 0x00)
assertTrue(YaraEngine.scan(rules, noMatch).isEmpty())
}
@Test
fun `any of them requires at least one`() {
val src = """
rule AnyOf {
strings:
${'$'}a = "aaa"
${'$'}b = "bbb"
${'$'}c = "ccc"
condition:
any of them
}
""".trimIndent()
val rules = YaraParser.parseRules(src)
assertTrue(YaraEngine.scan(rules, "bbb only".toByteArray()).isNotEmpty())
assertTrue(YaraEngine.scan(rules, "none of it".toByteArray()).isEmpty())
}
@Test
fun `all of them requires every string`() {
val src = """
rule AllOf {
strings:
${'$'}a = "aaa"
${'$'}b = "bbb"
condition:
all of them
}
""".trimIndent()
val rules = YaraParser.parseRules(src)
assertTrue(YaraEngine.scan(rules, "aaa bbb".toByteArray()).isNotEmpty())
assertTrue(YaraEngine.scan(rules, "aaa only".toByteArray()).isEmpty())
}
@Test
fun `N of explicit set`() {
val src = """
rule NOf {
strings:
${'$'}a = "aaa"
${'$'}b = "bbb"
${'$'}c = "ccc"
condition:
2 of (${'$'}a, ${'$'}b, ${'$'}c)
}
""".trimIndent()
val rules = YaraParser.parseRules(src)
assertTrue(YaraEngine.scan(rules, "aaa bbb".toByteArray()).isNotEmpty())
assertTrue(YaraEngine.scan(rules, "aaa only".toByteArray()).isEmpty())
}
@Test
fun `boolean and or not composition`() {
val src = """
rule Bool {
strings:
${'$'}a = "aaa"
${'$'}b = "bbb"
condition:
${'$'}a and not ${'$'}b
}
""".trimIndent()
val rules = YaraParser.parseRules(src)
assertTrue(YaraEngine.scan(rules, "aaa only".toByteArray()).isNotEmpty())
assertTrue(YaraEngine.scan(rules, "aaa bbb".toByteArray()).isEmpty())
}
@Test
fun `filesize comparison`() {
val src = """
rule Small {
condition:
filesize < 10
}
""".trimIndent()
val rules = YaraParser.parseRules(src)
assertTrue(YaraEngine.scan(rules, ByteArray(5)).isNotEmpty())
assertTrue(YaraEngine.scan(rules, ByteArray(20)).isEmpty())
}
@Test
fun `regex pattern matches`() {
val src = """
rule Rx {
strings:
${'$'}a = /ev[i1]l[0-9]+/ nocase
condition:
${'$'}a
}
""".trimIndent()
val rules = YaraParser.parseRules(src)
assertTrue(YaraEngine.scan(rules, "prefix EV1L42 suffix".toByteArray()).isNotEmpty())
assertTrue(YaraEngine.scan(rules, "nothing here".toByteArray()).isEmpty())
}
@Test
fun `meta section is parsed but does not affect matching`() {
val src = """
rule WithMeta {
meta:
description = "test rule"
severity = "high"
strings:
${'$'}a = "trigger"
condition:
${'$'}a
}
""".trimIndent()
val rules = YaraParser.parseRules(src)
assertEquals("test rule", rules[0].meta["description"])
assertEquals("high", rules[0].meta["severity"])
assertTrue(YaraEngine.scan(rules, "trigger".toByteArray()).isNotEmpty())
}
@Test
fun `multiple rules in one file parse independently`() {
val src = """
rule First {
strings:
${'$'}a = "one"
condition:
${'$'}a
}
rule Second {
strings:
${'$'}a = "two"
condition:
${'$'}a
}
""".trimIndent()
val rules = YaraParser.parseRules(src)
assertEquals(2, rules.size)
val hits = YaraEngine.scan(rules, "one two".toByteArray())
assertEquals(2, hits.size)
}
@Test
fun `lenient parsing skips a malformed rule but keeps the rest`() {
val src = """
rule Good1 {
strings:
${'$'}a = "x"
condition:
${'$'}a
}
rule Broken {
condition:
pe.imports("nope")
}
rule Good2 {
strings:
${'$'}a = "y"
condition:
${'$'}a
}
""".trimIndent()
val errors = mutableListOf<String>()
val rules = YaraParser.parseRulesLenient(src) { name, e -> errors.add(name) }
assertEquals(2, rules.size)
assertEquals(listOf("Good1", "Good2"), rules.map { it.name })
assertTrue(errors.isNotEmpty())
}
@Test
fun `tags after rule name are parsed`() {
val src = """
rule Tagged : malware android {
strings:
${'$'}a = "x"
condition:
${'$'}a
}
""".trimIndent()
val rules = YaraParser.parseRules(src)
assertEquals(listOf("malware", "android"), rules[0].tags)
}
@Test(expected = YaraParseException::class)
fun `unsupported module reference throws instead of silently matching everything`() {
val src = """
rule Bad {
condition:
pe.number_of_sections > 2
}
""".trimIndent()
YaraParser.parseRules(src)
}
@Test
fun `wide string variant matches utf16le content`() {
val src = """
rule Wide {
strings:
${'$'}a = "hi" wide
condition:
${'$'}a
}
""".trimIndent()
val rules = YaraParser.parseRules(src)
val utf16le = byteArrayOf('h'.code.toByte(), 0, 'i'.code.toByte(), 0)
assertTrue(YaraEngine.scan(rules, utf16le).isNotEmpty())
assertFalse(YaraEngine.scan(rules, "hi".toByteArray()).isNotEmpty()) // ascii-only content, wide-only pattern
}
/**
* Loads the ACTUAL bundled asset (not a copy) to catch drift between this
* test and what ships in the app. Fails loudly (not silently-empty) if
* every rule in the file fails to parse, since that would mean the
* bundled ruleset is dead on arrival.
*/
@Test
fun `bundled starter ruleset parses cleanly and each rule matches its intended trigger`() {
val assetFile = File("src/main/assets/yara_rules/android_starter.yar")
assertTrue("expected to find ${assetFile.absolutePath}", assetFile.exists())
val source = assetFile.readText()
val errors = mutableListOf<String>()
val rules = YaraParser.parseRulesLenient(source) { name, e -> errors.add("$name: ${e.message}") }
assertTrue("bundled ruleset had parse errors: $errors", errors.isEmpty())
assertEquals(4, rules.size)
val eicarHit = YaraEngine.scan(rules, "xxx EICAR-STANDARD-ANTIVIRUS-TEST-FILE xxx".toByteArray())
assertTrue(eicarHit.any { it.rule.name == "EICAR_Test_String_Present" })
val fridaHit = YaraEngine.scan(rules, "...libfrida-gadget.so...".toByteArray())
assertTrue(fridaHit.any { it.rule.name == "Bundles_Frida_Instrumentation_Toolkit" })
val msfHit = YaraEngine.scan(rules, "Lcom/metasploit/stage/Payload;".toByteArray())
assertTrue(msfHit.any { it.rule.name == "Metasploit_Android_Meterpreter_Stager" })
val suHit = YaraEngine.scan(rules, "path=/system/bin/su end".toByteArray())
assertTrue(suHit.any { it.rule.name == "Bundles_Root_Su_Binary_Paths" })
// fullword: must NOT match as a substring of a longer path segment
val suNoMatch = YaraEngine.scan(rules, "path=/system/bin/sudoers end".toByteArray())
assertFalse(suNoMatch.any { it.rule.name == "Bundles_Root_Su_Binary_Paths" })
val clean = YaraEngine.scan(rules, "nothing interesting in this apk".toByteArray())
assertTrue(clean.isEmpty())
}
}
Build configuration
Gradle build files and the Android manifest — permissions, components, dependencies.
settings.gradle.kts
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "WazuhAndroidAgent"
include(":app")
build.gradle.kts
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}
app/build.gradle.kts
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "org.wazuh.androidagent"
compileSdk = 34
defaultConfig {
applicationId = "org.wazuh.androidagent"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
}
signingConfigs {
create("release") {
// Self-signed for internal/lab distribution. Replace with your own keystore for production.
storeFile = file("../keystore/release.keystore")
storePassword = "[redacted — throwaway lab keystore]"
keyAlias = "wazuhagent"
keyPassword = "[redacted — throwaway lab keystore]"
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
}
debug {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
viewBinding = true
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.activity:activity-ktx:1.9.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("androidx.recyclerview:recyclerview:1.3.2")
testImplementation("junit:junit:4.13.2")
}
app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Core networking -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Keep the agent alive as a visible foreground service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Needed to enumerate the full installed-app inventory (security telemetry) -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" />
<!-- Optional, opt-in "full system log" collector (SystemLogCollector). This is a
signature|privileged permission on modern Android: it cannot be requested via
a runtime dialog, cannot be granted from any in-app Settings screen, and the
Play Store would reject an app declaring it. It only works sideloaded, and only
after `adb shell pm grant org.wazuh.androidagent android.permission.READ_LOGS`
(see the app's website for the exact command). Declaring it here is required
for that ADB grant and the runtime checkSelfPermission() check to work at all,
even though the manifest declaration alone grants nothing by itself. -->
<uses-permission android:name="android.permission.READ_LOGS" tools:ignore="ProtectedPermissions" />
<application
android:name=".AgentApplication"
android:allowBackup="false"
android:usesCleartextTraffic="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.WazuhAgent"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.LiveLogActivity"
android:exported="false"
android:label="Live Log" />
<activity
android:name=".ui.AiAnalysisActivity"
android:exported="false"
android:label="Wazuh API + AI Analysis" />
<service
android:name=".AgentForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name=".collectors.NotificationAgentListenerService"
android:exported="false"
android:label="@string/app_name"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<receiver
android:name=".BootReceiver"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>