diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e4c3867..16d24ea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,12 +7,31 @@ on: branches: [ main ] workflow_dispatch: {} +env: + APK_KEYSTORE_PASSWORD: ${{ secrets.APK_KEYSTORE_PASSWORD }} + APK_KEY_ALIAS: ${{ secrets.APK_KEY_ALIAS }} + APK_KEY_PASSWORD: ${{ secrets.APK_KEY_PASSWORD }} + jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Stable env + run: | + echo "TZ=UTC" >> $GITHUB_ENV + echo "LANG=C.UTF-8" >> $GITHUB_ENV + echo "LC_ALL=C.UTF-8" >> $GITHUB_ENV + echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> $GITHUB_ENV + + - name: Restore testing keystore + env: + APK_KEYSTORE_B64: ${{ secrets.APK_KEYSTORE_B64 }} + run: | + echo "$APK_KEYSTORE_B64" | base64 -d > keystore.jks + echo "APK_KEYSTORE=$GITHUB_WORKSPACE/keystore.jks" >> $GITHUB_ENV + - name: Set up JDK uses: actions/setup-java@v4 with: @@ -35,12 +54,22 @@ jobs: gradle --version - name: Build with Gradle - run: gradle --no-daemon build + run: gradle --no-daemon assembleDebug assembleRelease + + - name: Upload debug APK + if: success() + uses: actions/upload-artifact@v4 + with: + name: app-debug + path: app/build/outputs/apk/debug/app-debug.apk + compression-level: 0 + retention-days: 7 - - name: Upload APKs + - name: Upload release APK if: success() uses: actions/upload-artifact@v4 with: - name: apk-${{ github.run_number }} - path: "**/build/outputs/apk/**/*.apk" + name: app-release + path: app/build/outputs/apk/release/app-release.apk + compression-level: 0 retention-days: 7 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5981070..4486afc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) @@ -10,6 +12,32 @@ android { namespace = "org.osservatorionessuno.bugbane" compileSdk = 36 + // For deterministic CI build and signatures + val ksPathStr: String? = System.getenv("APK_KEYSTORE") + val haveCiKeystore = ksPathStr != null + + if (haveCiKeystore) { + signingConfigs { + create("ciRelease") { + val ksPass = System.getenv("APK_KEYSTORE_PASSWORD") ?: error("APK_KEYSTORE_PASSWORD not set") + val alias = System.getenv("APK_KEY_ALIAS") ?: error("APK_KEY_ALIAS not set") + val keyPass = System.getenv("APK_KEY_PASSWORD") ?: error("APK_KEY_PASSWORD not set") + + // use the actual path string here + storeFile = file(ksPathStr!!) + storePassword = ksPass + keyAlias = alias + keyPassword = keyPass + + // Deterministic signing (avoid v1/JAR) + enableV1Signing = false + enableV2Signing = true + enableV3Signing = true + enableV4Signing = false + } + } + } + defaultConfig { applicationId = "org.osservatorionessuno.bugbane" minSdk = 30 @@ -26,7 +54,10 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) - signingConfig = signingConfigs.getByName("debug") + signingConfig = if (haveCiKeystore) + signingConfigs.getByName("ciRelease") + else + signingConfigs.getByName("debug") } } @@ -34,7 +65,11 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { jvmTarget = "11" } + kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_11 + } + } buildFeatures { compose = true @@ -61,6 +96,8 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.androidx.foundation) implementation(libs.compose.material.icons.extended) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.snakeyaml) // libadb-android and its dependency implementation(libs.libadb.android) @@ -82,7 +119,6 @@ dependencies { testRuntimeOnly(libs.junit.jupiter.engine) testImplementation(libs.org.json) - androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) @@ -94,7 +130,6 @@ dependencies { protobuf { protoc { - // was: artifact = "com.google.protobuf:protoc:3.25.3" artifact = libs.protoc.get().toString() } // Generate lite Java classes for Android diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/MainActivity.kt b/app/src/main/java/org/osservatorionessuno/bugbane/MainActivity.kt index 1b2ee0b..f6670e1 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/MainActivity.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/MainActivity.kt @@ -18,6 +18,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import kotlinx.coroutines.launch import io.github.muntashirakon.adb.PRNGFixes +import androidx.lifecycle.lifecycleScope +import androidx.work.* +import kotlinx.coroutines.Dispatchers +import org.osservatorionessuno.libmvt.common.IndicatorsUpdates +import org.osservatorionessuno.bugbane.workers.IndicatorsUpdateWorker +import java.util.concurrent.TimeUnit import org.osservatorionessuno.bugbane.components.AppTopBar import org.osservatorionessuno.bugbane.components.MergedTopBar import org.osservatorionessuno.bugbane.components.NavigationTabs @@ -36,7 +42,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) PRNGFixes.apply() - + // Observers viewModel.watchConnectAdb().observe(this) { isConnected -> if (!isConnected) { @@ -49,7 +55,7 @@ class MainActivity : ComponentActivity() { setLacksPermissionsCallback?.invoke(true) } } - + viewModel.watchCommandOutput().observe(this) { output -> // TODO: blibla Toast.makeText(this@MainActivity, output.toString(), Toast.LENGTH_SHORT).show() @@ -59,7 +65,13 @@ class MainActivity : ComponentActivity() { // Try auto-connecting viewModel.autoConnect() - if (!ConfigurationManager.isNotificationPermissionGranted(this) || !ConfigurationManager.isWirelessDebuggingEnabled(this)) { + // Fetch indicators on first launch and schedule daily background updates + setupIndicatorsUpdates() + + if (!ConfigurationManager.isNotificationPermissionGranted(this) || !ConfigurationManager.isWirelessDebuggingEnabled( + this + ) + ) { setLacksPermissionsCallback?.invoke(true) } @@ -77,6 +89,40 @@ class MainActivity : ComponentActivity() { } } } + + private fun setupIndicatorsUpdates() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + // Kick an immediate, one-time run if nothing has been downloaded yet + val updates = IndicatorsUpdates(filesDir.toPath(), null) + if (updates.latestUpdate == 0L) { + val now = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .addTag("IndicatorsUpdate") // common tag for querying later if you want + .build() + + WorkManager.getInstance(this).enqueueUniqueWork( + "IndicatorsUpdateInitial", + ExistingWorkPolicy.KEEP, + now + ) + } + + // Schedule daily periodic job (unique) + val periodic = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + .setConstraints(constraints) + .addTag("IndicatorsUpdate") + .build() + + WorkManager.getInstance(this).enqueueUniquePeriodicWork( + "IndicatorsUpdatePeriodic", + ExistingPeriodicWorkPolicy.KEEP, + periodic + ) + Log.i("MainActivity", "Scheduled daily indicator update worker") + } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/analysis/AcquisitionScanner.kt b/app/src/main/java/org/osservatorionessuno/bugbane/analysis/AcquisitionScanner.kt new file mode 100644 index 0000000..85c8534 --- /dev/null +++ b/app/src/main/java/org/osservatorionessuno/bugbane/analysis/AcquisitionScanner.kt @@ -0,0 +1,97 @@ +package org.osservatorionessuno.bugbane.analysis + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import org.osservatorionessuno.libmvt.common.Indicators +import org.osservatorionessuno.libmvt.common.IndicatorsUpdates +import org.osservatorionessuno.libmvt.matcher.IndicatorMatcher +import java.io.File +import java.nio.file.Files +import java.security.MessageDigest +import java.time.Instant +import java.util.UUID + +object AcquisitionScanner { + fun scan(context: Context, acquisitionDir: File): File { + val indicatorsDir = IndicatorsUpdates(context.filesDir.toPath(), null).getIndicatorsFolder().toFile() + return scanWithIndicators(acquisitionDir, indicatorsDir) + } + + private fun scanWithIndicators(acquisitionDir: File, indicatorsDir: File): File { + val started = Instant.now() + val indicators = Indicators.loadFromDirectory(indicatorsDir) + val matcher = IndicatorMatcher(indicators) + + val indicatorsArr = JSONArray() + val indicatorHashes = mutableListOf() + indicatorsDir.listFiles { file -> file.isFile }?.forEach { file -> + val hash = sha256(file) + val obj = JSONObject() + obj.put("file", file.name) + obj.put("sha256", hash) + indicatorsArr.put(obj) + indicatorHashes += hash + } + + val results = JSONArray() + val analysisPath = File(acquisitionDir, "analysis").toPath() + acquisitionDir.walkTopDown().forEach { file -> + if (file.isFile && !file.toPath().startsWith(analysisPath)) { + val lines: List = try { + Files.readAllLines(file.toPath()) + } catch (_: Exception) { + emptyList() + } + val detections = matcher.matchAllStrings(lines) + if (detections.isNotEmpty()) { + val rel = file.relativeTo(acquisitionDir).path + for (d in detections) { + val obj = JSONObject() + obj.put("file", rel) + obj.put("type", d.type.name) + obj.put("ioc", d.ioc) + obj.put("context", d.context) + results.put(obj) + } + } + } + } + + val completed = Instant.now() + val outDir = analysisPath.toFile() + if (!outDir.exists()) outDir.mkdirs() + val uuid = UUID.randomUUID().toString() + val outFile = File(outDir, "${started.toString().replace(':', '-')}.json") + indicatorHashes.sort() + val root = JSONObject() + root.put("uuid", uuid) + root.put("started", started.toString()) + root.put("completed", completed.toString()) + root.put("indicatorsHash", sha256(indicatorHashes.joinToString(""))) + root.put("indicators", indicatorsArr) + root.put("results", results) + outFile.writeText(root.toString(1)) + return outFile + } + + private fun sha256(file: File): String { + val md = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(8192) + while (true) { + val read = input.read(buffer) + if (read < 0) break + md.update(buffer, 0, read) + } + } + return md.digest().joinToString("") { "%02x".format(it) } + } + + private fun sha256(data: String): String { + val md = MessageDigest.getInstance("SHA-256") + md.update(data.toByteArray()) + return md.digest().joinToString("") { "%02x".format(it) } + } +} + diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/screens/AcquisitionDetailScreen.kt b/app/src/main/java/org/osservatorionessuno/bugbane/screens/AcquisitionDetailScreen.kt index 0b349b8..1c773e4 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/screens/AcquisitionDetailScreen.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/screens/AcquisitionDetailScreen.kt @@ -4,6 +4,7 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent +import android.content.res.Configuration import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.border @@ -13,6 +14,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -21,10 +23,12 @@ import androidx.core.content.FileProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.osservatorionessuno.bugbane.analysis.AcquisitionScanner import org.json.JSONObject import java.io.File import java.io.FileInputStream import java.io.FileOutputStream +import java.security.MessageDigest import java.text.DateFormat import java.time.Instant import java.util.Date @@ -38,15 +42,18 @@ import kage.crypto.scrypt.ScryptRecipient fun AcquisitionDetailScreen(acquisitionDir: File) { val context = LocalContext.current val scope = rememberCoroutineScope() + val configuration = LocalConfiguration.current var size by remember { mutableStateOf(0L) } var meta by remember { mutableStateOf(null) } var files by remember { mutableStateOf(listOf>()) } + var scans by remember { mutableStateOf(listOf()) } val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) } var processing by remember { mutableStateOf(false) } var passphrase by remember { mutableStateOf(null) } var showPassDialog by remember { mutableStateOf(false) } + var showFilesDialog by remember { mutableStateOf(false) } var generatedFile by remember { mutableStateOf(null) } LaunchedEffect(acquisitionDir) { @@ -62,6 +69,15 @@ fun AcquisitionDetailScreen(acquisitionDir: File) { } catch (_: Throwable) { } } + scans = loadScans(acquisitionDir) + if (scans.isEmpty()) { + processing = true + withContext(Dispatchers.IO) { + AcquisitionScanner.scan(context, acquisitionDir) + } + scans = loadScans(acquisitionDir) + processing = false + } } val exportLauncher = rememberLauncherForActivityResult( @@ -111,73 +127,178 @@ fun AcquisitionDetailScreen(acquisitionDir: File) { } } + fun startAnalysis() { + scope.launch { + processing = true + withContext(Dispatchers.IO) { + AcquisitionScanner.scan(context, acquisitionDir) + } + scans = loadScans(acquisitionDir) + processing = false + } + } + Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_size, formatBytes(size)), - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium) - ) - meta?.let { - val started = it.optString("started", null)?.let { s -> - try { - dateFormat.format(Date.from(Instant.parse(s))) - } catch (_: Exception) { - s + if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Column( + modifier = Modifier + .weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_size, formatBytes(size)), + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium) + ) + meta?.let { + val started = it.optString("started", "null").let { s -> + try { + dateFormat.format(Date.from(Instant.parse(s))) + } catch (_: Exception) { + s + } + } ?: "-" + val completed = it.optString("completed", "null").let { s -> + try { + dateFormat.format(Date.from(Instant.parse(s))) + } catch (_: Exception) { + s + } + } ?: "-" + + Text( + text = stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_uuid, it.optString("uuid")), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_started, started), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_completed, completed), + style = MaterialTheme.typography.bodyLarge + ) } - } ?: "-" - val completed = it.optString("completed", null)?.let { s -> - try { - dateFormat.format(Date.from(Instant.parse(s))) - } catch (_: Exception) { - s + + Spacer(modifier = Modifier.weight(1f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button(onClick = { showFilesDialog = true }, modifier = Modifier.weight(1f)) { + Text(stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_view_files)) + } + Button(onClick = { startAnalysis() }, modifier = Modifier.weight(1f)) { + Text(stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_rescan)) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button(onClick = { startExport() }, modifier = Modifier.weight(1f)) { + Text(stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_export)) + } + Button(onClick = { startShare() }, modifier = Modifier.weight(1f)) { + Text(stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_share)) + } } - } ?: "-" + } + Column( + modifier = Modifier + .weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_scans), + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium) + ) + ScanList( + scans = scans, + dateFormat = dateFormat, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) + } + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { Text( - text = stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_uuid, it.optString("uuid")), - style = MaterialTheme.typography.bodyLarge + text = stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_size, formatBytes(size)), + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium) ) + meta?.let { + val started = it.optString("started", "null").let { s -> + try { + dateFormat.format(Date.from(Instant.parse(s))) + } catch (_: Exception) { + s + } + } ?: "-" + val completed = it.optString("completed", "null").let { s -> + try { + dateFormat.format(Date.from(Instant.parse(s))) + } catch (_: Exception) { + s + } + } ?: "-" + + Text( + text = stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_uuid, it.optString("uuid")), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_started, started), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_completed, completed), + style = MaterialTheme.typography.bodyLarge + ) + } + + Button(onClick = { showFilesDialog = true }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_view_files)) + } + Button(onClick = { startAnalysis() }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_rescan)) + } Text( - text = stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_started, started), - style = MaterialTheme.typography.bodyLarge + text = stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_scans), + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium) ) - Text( - text = stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_completed, completed), - style = MaterialTheme.typography.bodyLarge + ScanList( + scans = scans, + dateFormat = dateFormat, + modifier = Modifier + .weight(1f) + .fillMaxWidth() ) - } - - Text( - text = stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_files), - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium) - ) - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .border(1.dp, MaterialTheme.colorScheme.outline) - ) { - LazyColumn(modifier = Modifier.fillMaxSize().padding(8.dp)) { - items(files) { (name, fsize) -> - Text("$name - ${formatBytes(fsize)}") + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button(onClick = { startExport() }, modifier = Modifier.weight(1f)) { + Text(stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_export)) + } + Button(onClick = { startShare() }, modifier = Modifier.weight(1f)) { + Text(stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_share)) } } } - - Button(onClick = { startExport() }, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_export)) - } - Button(onClick = { startShare() }, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_share)) - } - Button(onClick = { /* TODO scan again */ }, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_rescan)) - } } if (processing) { @@ -191,6 +312,27 @@ fun AcquisitionDetailScreen(acquisitionDir: File) { } } + if (showFilesDialog) { + AlertDialog( + onDismissRequest = { showFilesDialog = false }, + confirmButton = { + TextButton(onClick = { showFilesDialog = false }) { + Text(stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_passphrase_close)) + } + }, + title = { Text(stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_details_files)) }, + text = { + Box(modifier = Modifier.heightIn(max = 300.dp)) { + LazyColumn { + items(files) { (name, fsize) -> + Text("$name - ${formatBytes(fsize)}") + } + } + } + } + ) + } + if (showPassDialog && passphrase != null) { AlertDialog( onDismissRequest = { showPassDialog = false }, @@ -219,6 +361,94 @@ fun AcquisitionDetailScreen(acquisitionDir: File) { } } +@Composable +private fun ScanList( + scans: List, + dateFormat: DateFormat, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .border(1.dp, MaterialTheme.colorScheme.outline) + ) { + LazyColumn(modifier = Modifier.fillMaxSize().padding(8.dp)) { + item { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_scans_date), + modifier = Modifier.weight(1f), + fontWeight = FontWeight.SemiBold + ) + Text( + stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_scans_indicators), + modifier = Modifier.weight(1f), + fontWeight = FontWeight.SemiBold + ) + Text( + stringResource(org.osservatorionessuno.bugbane.R.string.acquisition_scans_matches), + modifier = Modifier.weight(1f), + fontWeight = FontWeight.SemiBold + ) + } + } + items(scans) { scan -> + Row(modifier = Modifier.fillMaxWidth()) { + Text( + dateFormat.format(Date.from(scan.started)), + modifier = Modifier.weight(1f) + ) + Text( + scan.indicatorsHash.take(8), + modifier = Modifier.weight(1f) + ) + Text( + scan.matchCount.toString(), + modifier = Modifier.weight(1f) + ) + } + } + } + } +} + +private data class ScanSummary( + val started: Instant, + val indicatorsHash: String, + val matchCount: Int, +) + +private fun loadScans(acquisitionDir: File): List { + val analysisDir = File(acquisitionDir, "analysis") + if (!analysisDir.exists()) return emptyList() + return analysisDir.listFiles { f -> f.isFile && f.extension == "json" }?.mapNotNull { file -> + try { + val obj = JSONObject(file.readText()) + val started = Instant.parse(obj.optString("started")) + val hash = obj.optString("indicatorsHash").ifEmpty { + val arr = obj.optJSONArray("indicators") + val hashes = mutableListOf() + if (arr != null) { + for (i in 0 until arr.length()) { + hashes += arr.getJSONObject(i).optString("sha256") + } + } + hashes.sort() + sha256(hashes.joinToString("")) + } + val results = obj.optJSONArray("results")?.length() ?: 0 + ScanSummary(started, hash, results) + } catch (_: Exception) { + null + } + }?.sortedByDescending { it.started } ?: emptyList() +} + +private fun sha256(data: String): String { + val md = MessageDigest.getInstance("SHA-256") + md.update(data.toByteArray()) + return md.digest().joinToString("") { "%02x".format(it) } +} + private fun calculateSize(file: File): Long { return if (file.isFile) file.length() else file.listFiles()?.sumOf { calculateSize(it) } ?: 0L } @@ -240,7 +470,7 @@ private suspend fun createEncryptedArchive(context: Context, sourceDir: File): P val dest = File.createTempFile("acquisition", ".zip.age", context.cacheDir) val plainZip = File.createTempFile("acquisition", ".zip", context.cacheDir) // 256MB goes OOM - val WORK_FACTOR = 15 + val workFactor = 15 ZipOutputStream(FileOutputStream(plainZip)).use { zipOut -> sourceDir.walkTopDown().filter { it.isFile }.forEach { file -> val entryName = file.relativeTo(sourceDir).path @@ -252,7 +482,7 @@ private suspend fun createEncryptedArchive(context: Context, sourceDir: File): P } FileOutputStream(dest).use { fileOut -> FileInputStream(plainZip).use { plainIn -> - Age.encryptStream(listOf(ScryptRecipient(pass.toByteArray(), workFactor = WORK_FACTOR)), plainIn, fileOut) + Age.encryptStream(listOf(ScryptRecipient(pass.toByteArray(), workFactor = workFactor)), plainIn, fileOut) } } plainZip.delete() diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/screens/AcquisitionsScreen.kt b/app/src/main/java/org/osservatorionessuno/bugbane/screens/AcquisitionsScreen.kt index a8f1565..48b5087 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/screens/AcquisitionsScreen.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/screens/AcquisitionsScreen.kt @@ -47,7 +47,7 @@ fun AcquisitionsScreen() { if (!metaFile.exists()) return@mapNotNull null try { val json = JSONObject(metaFile.readText()) - val completed = json.optString("completed", null)?.let { + val completed = json.optString("completed", "null").let { try { Date.from(Instant.parse(it)) } catch (e: Exception) { null } } AcquisitionItem(dir, dir.name, completed) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt b/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt index a2b03b9..bb6182c 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/screens/ScanScreen.kt @@ -21,6 +21,8 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalConfiguration +import android.content.res.Configuration import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -49,6 +51,8 @@ fun ScanScreen( val moduleLogIndex = remember { mutableStateMapOf() } val moduleBytes = remember { mutableStateMapOf() } + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + // Update lacksPermissions based on current permissions LaunchedEffect(Unit) { val hasPermissions = ConfigurationManager.isNotificationPermissionGranted(context) && @@ -63,30 +67,36 @@ fun ScanScreen( ) { if (isScanning) { Column(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - if (totalModules > 0) { - CircularProgressIndicator(progress = completedModules / totalModules.toFloat()) - Spacer(modifier = Modifier.height(8.dp)) - Text("$completedModules / $totalModules") - } else { - CircularProgressIndicator() + Row(modifier = Modifier.weight(1f)) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (totalModules > 0) { + CircularProgressIndicator( + progress = { + (completedModules / totalModules.toFloat()).coerceIn(0f, 1f) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Text("$completedModules / $totalModules") + } else { + CircularProgressIndicator() + } } } - } - LazyColumn( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .padding(16.dp) - ) { - items(progressLogs) { log -> - Text(log) + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(16.dp) + ) { + items(progressLogs) { log -> + Text(log) + } } } Button( @@ -113,33 +123,67 @@ fun ScanScreen( modifier = Modifier.fillMaxSize() ) { // Welcome content in the center - Column( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - painter = painterResource(id = R.drawable.ic_bugbane_zoom), - contentDescription = "Bugbane Logo", - modifier = Modifier.size(200.dp), - alpha = 0.4f - ) - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = stringResource(R.string.scan_welcome_title), - style = MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 16.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.scan_welcome_description), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - modifier = Modifier.padding(horizontal = 16.dp) - ) + if (isLandscape) { + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(id = R.drawable.ic_bugbane_zoom), + contentDescription = "Bugbane Logo", + modifier = Modifier.size(160.dp), + alpha = 0.4f + ) + Spacer(modifier = Modifier.width(24.dp)) + Column(modifier = Modifier.fillMaxWidth(0.5f)) { + Text( + text = stringResource(R.string.scan_welcome_title), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.scan_welcome_description), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.ic_bugbane_zoom), + contentDescription = "Bugbane Logo", + modifier = Modifier.size(160.dp), + alpha = 0.4f + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.scan_welcome_title), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.scan_welcome_description), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.padding(horizontal = 16.dp) + ) + } } // Disable Development Tools Dialog @@ -246,7 +290,6 @@ fun ScanScreen( override fun onFinished(cancelled: Boolean) { coroutineScope.launch { - isScanning = false if (!cancelled) { val latest = baseDir.listFiles()?.filter { it.isDirectory }?.maxByOrNull { it.lastModified() } if (latest != null) { @@ -257,6 +300,7 @@ fun ScanScreen( } showDisableDialog = true } + isScanning = false } } }) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/screens/SettingsScreen.kt b/app/src/main/java/org/osservatorionessuno/bugbane/screens/SettingsScreen.kt index 2efb8fb..9ccf922 100644 --- a/app/src/main/java/org/osservatorionessuno/bugbane/screens/SettingsScreen.kt +++ b/app/src/main/java/org/osservatorionessuno/bugbane/screens/SettingsScreen.kt @@ -13,16 +13,61 @@ import androidx.compose.ui.unit.dp import org.osservatorionessuno.bugbane.R import org.osservatorionessuno.bugbane.utils.ConfigurationManager import org.osservatorionessuno.bugbane.utils.SlideshowManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.osservatorionessuno.libmvt.common.IndicatorsUpdates +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter @Composable fun SettingsScreen() { val context = LocalContext.current + val updates = remember { IndicatorsUpdates(context.filesDir.toPath(), null) } + var lastUpdate by remember { mutableStateOf(null) } + var lastFetch by remember { mutableStateOf(null) } + var indicatorCount by remember { mutableStateOf(null) } + + val formatter = remember { + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + .withZone(ZoneId.systemDefault()) + } + + fun formatEpoch(epoch: Long?): String = + if (epoch == null || epoch == 0L) "N/A" else formatter.format(Instant.ofEpochSecond(epoch)) + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + lastUpdate = updates.latestUpdate + lastFetch = updates.latestCheck + indicatorCount = updates.countIndicators() + } + } + Column( modifier = Modifier .fillMaxSize() .padding(16.dp) ) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + text = stringResource(R.string.settings_indicators_title), + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = stringResource(R.string.settings_indicators_last_fetch, formatEpoch(lastFetch))) + Text(text = stringResource(R.string.settings_indicators_last_update, formatEpoch(lastUpdate))) + Text(text = stringResource(R.string.settings_indicators_count, (indicatorCount?.toInt() ?: 0))) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + Card( modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) diff --git a/app/src/main/java/org/osservatorionessuno/bugbane/workers/IndicatorsUpdateWorker.kt b/app/src/main/java/org/osservatorionessuno/bugbane/workers/IndicatorsUpdateWorker.kt new file mode 100644 index 0000000..6c83d41 --- /dev/null +++ b/app/src/main/java/org/osservatorionessuno/bugbane/workers/IndicatorsUpdateWorker.kt @@ -0,0 +1,87 @@ +package org.osservatorionessuno.bugbane.workers + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.osservatorionessuno.libmvt.common.IndicatorsUpdates +import org.osservatorionessuno.bugbane.R +import java.io.File +import java.io.RandomAccessFile + +/** + * Worker that periodically fetches and updates indicators. + */ +class IndicatorsUpdateWorker( + appContext: Context, + workerParams: WorkerParameters +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + // Cross-thread/process lock + val lockFile = File(applicationContext.filesDir, "indicators_update.lock") + RandomAccessFile(lockFile, "rw").channel.use { ch -> + val lock = try { ch.tryLock() } catch (_: Throwable) { null } + if (lock == null) { + Log.i(TAG, "Another update is running; skipping this execution") + return@withContext Result.success() + } + try { + val updates = IndicatorsUpdates(applicationContext.filesDir.toPath(), null) + val before = updates.countIndicators() + Log.i(TAG, "Starting indicator update with $before existing files") + + updates.update() + + val after = updates.countIndicators() + val diff = after - before + Log.i(TAG, "Indicator update finished, $after files total") + if (diff > 0) notify(applicationContext, diff) + Result.success() + } catch (e: Exception) { + Log.e(TAG, "Indicator update failed", e) + Result.retry() + } finally { + try { lock.release() } catch (_: Throwable) {} + } + } + } + + companion object { + private const val TAG = "IndicatorsUpdateWorker" + private const val CHANNEL_ID = "indicator_updates" + + fun notify(context: Context, newCount: Long) { + val manager = NotificationManagerCompat.from(context) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.notification_channel_indicators), + NotificationManager.IMPORTANCE_DEFAULT + ) + manager.createNotificationChannel(channel) + } + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentTitle(context.getString(R.string.notification_channel_indicators)) + .setContentText( + context.getString( + R.string.notification_indicators_updated, + newCount + ) + ) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + + manager.notify(1, notification) + } + } +} diff --git a/app/src/main/java/org/osservatorionessuno/libmvt/common/IndicatorsUpdates.java b/app/src/main/java/org/osservatorionessuno/libmvt/common/IndicatorsUpdates.java index b230fa8..93f020a 100644 --- a/app/src/main/java/org/osservatorionessuno/libmvt/common/IndicatorsUpdates.java +++ b/app/src/main/java/org/osservatorionessuno/libmvt/common/IndicatorsUpdates.java @@ -1,497 +1,648 @@ package org.osservatorionessuno.libmvt.common; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.constructor.SafeConstructor; + import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.BufferedReader; import java.io.BufferedWriter; -import java.io.ByteArrayOutputStream; import java.io.FileOutputStream; -import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.io.OutputStreamWriter; +import java.io.IOException; import java.net.HttpURLConnection; import java.net.URI; import java.net.URL; +import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.nio.file.Path; import java.time.Instant; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import org.json.JSONArray; +import org.json.JSONObject; +import org.json.JSONTokener; public class IndicatorsUpdates { private static final String DEFAULT_GITHUB_RAW = "https://raw.githubusercontent.com/%s/%s/%s/%s"; public static final Path MVT_DATA_FOLDER = Paths.get(System.getProperty("user.home"), ".mvt"); + private static final String TAG = "IndicatorsUpdates"; + private static final boolean HAS_ANDROID_LOG; + static { + boolean present; + try { Class.forName("android.util.Log"); present = true; } + catch (Throwable t) { present = false; } + HAS_ANDROID_LOG = present; + } + private static void logI(String msg) { if (HAS_ANDROID_LOG) android.util.Log.i(TAG, msg); else System.out.println(TAG + " I: " + msg); } + private static void logW(String msg) { if (HAS_ANDROID_LOG) android.util.Log.w(TAG, msg); else System.out.println(TAG + " W: " + msg); } + private static void logE(String msg, Throwable t) { + if (HAS_ANDROID_LOG) android.util.Log.e(TAG, msg, t); + else { System.err.println(TAG + " E: " + msg); if (t != null) t.printStackTrace(); } + } + private final Path latestUpdatePath; private final Path latestCheckPath; private final Path indicatorsFolder; - private final String indexUrl; - private final String githubRawUrl; + private final String indexUrl; // may be null -> default GitHub indicators.yaml + private final String githubRawUrl; // format string - public IndicatorsUpdates() { - this(null, null); - } + public IndicatorsUpdates() { this(null, null); } public IndicatorsUpdates(Path dataFolder, String indexUrl) { Path base = (dataFolder == null) ? MVT_DATA_FOLDER : dataFolder; this.indexUrl = indexUrl; this.githubRawUrl = DEFAULT_GITHUB_RAW; this.latestUpdatePath = base.resolve("latest_indicators_update"); - this.latestCheckPath = base.resolve("latest_indicators_check"); - this.indicatorsFolder = base.resolve("indicators"); + this.latestCheckPath = base.resolve("latest_indicators_check"); + this.indicatorsFolder = base.resolve("iocs"); try { Files.createDirectories(base); } catch (IOException ignored) {} try { Files.createDirectories(indicatorsFolder); } catch (IOException ignored) {} + logI("Init with base=" + base + ", indicatorsFolder=" + indicatorsFolder + ", indexUrl=" + (indexUrl == null ? "(default GitHub)" : indexUrl)); } /** Return the folder where indicators are stored. */ - public Path getIndicatorsFolder() { - return indicatorsFolder; - } + public Path getIndicatorsFolder() { return indicatorsFolder; } // --------------------- Networking & IO helpers --------------------- private static String httpGetString(String url, int timeoutMs) throws IOException { + return httpGetStringWithHeaders(url, timeoutMs, null); + } + + private static String httpGetStringWithHeaders(String url, int timeoutMs, Map headers) throws IOException { + logI("HTTP GET (string) url=" + url + " timeoutMs=" + timeoutMs); HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setConnectTimeout(timeoutMs); conn.setReadTimeout(timeoutMs); conn.setInstanceFollowRedirects(true); conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept", "*/*"); + // GitHub API requires a UA + conn.setRequestProperty("User-Agent", "bugbane/1.0 (+https://example.invalid)"); + if (headers != null) { + for (Map.Entry e : headers.entrySet()) { + conn.setRequestProperty(e.getKey(), e.getValue()); + } + } int code = conn.getResponseCode(); + long clen = -1; + try { clen = conn.getContentLengthLong(); } catch (Throwable ignored) {} + logI("Response code=" + code + " contentLength=" + clen); + if (code != HttpURLConnection.HTTP_OK) { - // allow some redirects that might already be followed - InputStream err = conn.getErrorStream(); - if (err != null) err.close(); - conn.disconnect(); + try (InputStream err = conn.getErrorStream()) { + if (err != null) { + try (BufferedReader br = new BufferedReader(new InputStreamReader(err, StandardCharsets.UTF_8))) { + StringBuilder esb = new StringBuilder(); + char[] buf = new char[2048]; + int n; while ((n = br.read(buf)) >= 0) esb.append(buf, 0, n); + logW("Error body: " + esb); + } + } + } finally { conn.disconnect(); } return null; } + + StringBuilder sb = new StringBuilder(); try (InputStream in = new BufferedInputStream(conn.getInputStream()); InputStreamReader isr = new InputStreamReader(in, StandardCharsets.UTF_8); BufferedReader br = new BufferedReader(isr)) { - StringBuilder sb = new StringBuilder(); char[] buf = new char[8192]; - int n; - while ((n = br.read(buf)) >= 0) { - sb.append(buf, 0, n); - } - return sb.toString(); - } finally { - conn.disconnect(); - } + int n, total = 0; + while ((n = br.read(buf)) >= 0) { sb.append(buf, 0, n); total += n; } + logI("Downloaded text chars=" + total); + } finally { conn.disconnect(); } + return sb.toString(); } private static boolean httpGetToFile(String url, Path dest, int timeoutMs) throws IOException { + logI("HTTP GET (file) url=" + url + " -> " + dest + " timeoutMs=" + timeoutMs); HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setConnectTimeout(timeoutMs); conn.setReadTimeout(timeoutMs); conn.setInstanceFollowRedirects(true); conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept", "*/*"); + conn.setRequestProperty("User-Agent", "bugbane/1.0 (+https://example.invalid)"); int code = conn.getResponseCode(); + long clen = -1; + try { clen = conn.getContentLengthLong(); } catch (Throwable ignored) {} + logI("Response code=" + code + " contentLength=" + clen); + if (code != HttpURLConnection.HTTP_OK) { - InputStream err = conn.getErrorStream(); - if (err != null) err.close(); - conn.disconnect(); + try (InputStream err = conn.getErrorStream()) { + if (err != null) { + try (BufferedReader br = new BufferedReader(new InputStreamReader(err, StandardCharsets.UTF_8))) { + StringBuilder esb = new StringBuilder(); + char[] buf = new char[2048]; + int n; while ((n = br.read(buf)) >= 0) esb.append(buf, 0, n); + logW("Error body: " + esb); + } + } + } finally { conn.disconnect(); } return false; } + Files.createDirectories(dest.getParent()); + long totalBytes = 0; try (InputStream in = new BufferedInputStream(conn.getInputStream()); BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(dest.toFile()))) { byte[] buf = new byte[32 * 1024]; - int n; - while ((n = in.read(buf)) >= 0) { - out.write(buf, 0, n); - } - } finally { - conn.disconnect(); - } + int n; while ((n = in.read(buf)) >= 0) { out.write(buf, 0, n); totalBytes += n; } + logI("Saved file bytes=" + totalBytes + " to " + dest); + } finally { conn.disconnect(); } return true; } private static String readSmallFile(Path p) throws IOException { - // Avoid Files.readString for Android; do a simple reader try (BufferedReader br = Files.newBufferedReader(p, StandardCharsets.UTF_8)) { StringBuilder sb = new StringBuilder(); char[] buf = new char[4096]; - int n; - while ((n = br.read(buf)) >= 0) sb.append(buf, 0, n); + int n; while ((n = br.read(buf)) >= 0) sb.append(buf, 0, n); return sb.toString(); } } private static void writeSmallFile(Path p, String s) throws IOException { Files.createDirectories(p.getParent()); - try (BufferedWriter bw = Files.newBufferedWriter(p, StandardCharsets.UTF_8)) { - bw.write(s); - } + try (BufferedWriter bw = Files.newBufferedWriter(p, StandardCharsets.UTF_8)) { bw.write(s); } } - // --------------------- Index loading --------------------- + // --------------------- YAML index loading --------------------- @SuppressWarnings("unchecked") - private Map getRemoteIndex() throws IOException, InterruptedException { + private Map getRemoteIndex() throws IOException { final String url = (indexUrl != null) ? indexUrl : String.format(githubRawUrl, "mvt-project", "mvt-indicators", "main", "indicators.yaml"); + logI("Fetching index url=" + url); String text; if (url.startsWith("file://")) { Path p = Paths.get(URI.create(url)); + logI("Reading local index from " + p); text = readSmallFile(p); } else { text = httpGetString(url, 15000); - if (text == null) return null; + if (text == null) { logW("Index fetch returned null"); return null; } } - // If the file is actually JSON, accept it directly - String trimmed = text.trim(); - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { - // extremely small JSON reader for the expected shape: - // { "indicators": [ { "type": "...", "github": { ... } }, { "download_url": "..." } ] } - return JsonMini.parseIndex(trimmed); + final String trimmed = text.trim(); + logI("Index length=" + trimmed.length()); + logI("Parsing YAML index"); + + try { + LoaderOptions opts = new LoaderOptions(); + opts.setMaxAliasesForCollections(50); + opts.setAllowDuplicateKeys(false); + Yaml yaml = new Yaml(new SafeConstructor(opts)); + Object loaded = yaml.load(text); + + if (!(loaded instanceof Map)) { + logW("YAML root is not a map; got=" + (loaded == null ? "null" : loaded.getClass().getName())); + return null; + } + Map root = (Map) loaded; + + try { + Object inds = root.get("indicators"); + int size = (inds instanceof List) ? ((List) inds).size() : -1; + logI("YAML parsed successfully; indicators.size=" + size); + } catch (Throwable ignored) {} + + return root; + } catch (Throwable t) { + logE("SnakeYAML parse failed", t); + return null; } + } + + // --------------------- “Same logic as mvt” freshness checks --------------------- + + private static class GhRef { + String owner, repo, branch, path; + GhRef(String o, String r, String b, String p) { owner=o; repo=r; branch=b; path=p; } + boolean isComplete() { return notBlank(owner) && notBlank(repo) && notBlank(path) && notBlank(branch); } + } + + private static boolean notBlank(String s) { return s != null && !s.trim().isEmpty(); } + + /** If indexUrl is the default or a raw.githubusercontent.com URL, resolve owner/repo/branch/path. */ + private GhRef resolveIndexGhRef() { + if (indexUrl == null) { + return new GhRef("mvt-project", "mvt-indicators", "main", "indicators.yaml"); + } + if (indexUrl.startsWith("https://raw.githubusercontent.com/")) { + try { + URI u = URI.create(indexUrl); + // path like: /{owner}/{repo}/{branch}/{path...} + String[] seg = u.getPath().split("/", 5); + if (seg.length >= 5) { + return new GhRef(seg[1], seg[2], seg[3], seg[4]); + } + } catch (Throwable t) { + logW("resolveIndexGhRef failed for " + indexUrl + ": " + t); + } + } + return null; // not a GH raw URL; we can't commit-check it + } + + /** Ask GitHub commits API for latest commit date (epoch seconds) for a file path. */ + private long githubLatestCommitEpoch(String owner, String repo, String branch, String path) { + long t = githubLatestCommitEpochOnce(owner, repo, branch, path); + if (t > 0) return t; + + // Fallbacks for repos that changed default branch or mis-specified branch in YAML + if (!"main".equals(branch)) { + logI("githubLatestCommitEpoch: retrying with branch=main"); + t = githubLatestCommitEpochOnce(owner, repo, "main", path); + if (t > 0) return t; + } + if (!"master".equals(branch)) { + logI("githubLatestCommitEpoch: retrying with branch=master"); + t = githubLatestCommitEpochOnce(owner, repo, "master", path); + if (t > 0) return t; + } + return 0L; + } + + private long githubLatestCommitEpochOnce(String owner, String repo, String branch, String path) { + try { + String url = "https://api.github.com/repos/" + + urlEnc(owner) + "/" + urlEnc(repo) + + "/commits?path=" + urlEnc(path) + + "&sha=" + urlEnc(branch) + + "&per_page=1"; + + Map headers = new java.util.HashMap<>(); + headers.put("Accept", "application/vnd.github+json"); + headers.put("User-Agent", "bugbane/1.0 (+https://example.invalid)"); + // If you have a token, uncomment the next line to avoid rate limits: + // headers.put("Authorization", "Bearer " + yourToken); + + String body = httpGetStringWithHeaders(url, 15000, headers); + if (body == null || body.isEmpty()) { + logW("githubLatestCommitEpochOnce: null/empty response for " + owner + "/" + repo + "@" + branch + " path=" + path); + return 0L; + } + + String iso = extractFirstIsoDateFromCommits(body); + if (iso == null || iso.isEmpty()) { + logW("githubLatestCommitEpochOnce: no date found in response (commits empty?)"); + return 0L; + } + long epoch = java.time.Instant.parse(iso).getEpochSecond(); + logI("githubLatestCommitEpochOnce: " + owner + "/" + repo + "@" + branch + " path=" + path + + " -> " + iso + " (" + epoch + ")"); + return epoch; + } catch (Exception e) { + logW("githubLatestCommitEpochOnce failed: " + e); + return 0L; + } + } + + /** Robustly extract commit.author.date from GitHub commits JSON (first item). */ + private static String extractFirstIsoDateFromCommits(String json) { + try { + Object parsed = new JSONTokener(json).nextValue(); + if (parsed instanceof JSONArray) { + JSONArray arr = (JSONArray) parsed; + if (arr.length() == 0) return null; + JSONObject first = arr.getJSONObject(0); + JSONObject commit = first.optJSONObject("commit"); + if (commit == null) return null; + JSONObject author = commit.optJSONObject("author"); + if (author == null) return null; + return author.optString("date", null); // ISO 8601, e.g., 2024-12-31T09:41:22Z + } else if (parsed instanceof JSONObject) { + // Often an error/rate-limit object: {"message": "...", "status": "...", ...} + JSONObject obj = (JSONObject) parsed; + String msg = obj.optString("message", ""); + if (!msg.isEmpty()) logW("GitHub API message: " + msg); + return null; + } else { + return null; + } + } catch (Exception e) { + // Keep logging terse to avoid spam; you already log callers. + return null; + } + } + + + private static String urlEnc(String s) { + try { return URLEncoder.encode(s, "UTF-8"); } catch (Exception e) { return s; } + } + + /** Returns true if commits indicate something newer than latestUpdate exists. */ + @SuppressWarnings("unchecked") + private boolean isUpdateAvailable(Map index, long latestUpdate) { + // 1) Check the index file itself (if we can resolve it on GitHub) + GhRef idx = resolveIndexGhRef(); + if (idx != null && idx.isComplete()) { + long t = githubLatestCommitEpoch(idx.owner, idx.repo, idx.branch, idx.path); + if (t > latestUpdate) { + logI("Index newer than latestUpdate (" + t + " > " + latestUpdate + ")"); + return true; + } + } else { + logI("Index is not a GitHub raw URL; skipping index commit check"); + } + + // 2) If index didn't change, check each indicator github entry + Object indicators = index.get("indicators"); + if (!(indicators instanceof Iterable)) { + logW("isUpdateAvailable: 'indicators' not iterable"); + return false; + } + for (Object obj : (Iterable) indicators) { + if (!(obj instanceof Map)) continue; + Map map = (Map) obj; - // Otherwise parse the known YAML shape (dependency-free) - return YamlMini.parseIndex(text); + String type = stringOrEmpty(map.get("type")); + if (!"github".equals(type)) continue; + + Object ghObj = map.get("github"); + if (!(ghObj instanceof Map)) continue; + Map gh = (Map) ghObj; + + String owner = stringOrEmpty(gh.get("owner")); + String repo = stringOrEmpty(gh.get("repo")); + String branch = stringOrEmptyDefault(gh.get("branch"), "main"); + String path = stringOrEmpty(gh.get("path")); + if (owner.isEmpty() || repo.isEmpty() || path.isEmpty()) continue; + + long t = githubLatestCommitEpoch(owner, repo, branch, path); + if (t > latestUpdate) { + logI("Indicator newer than latestUpdate (" + t + " > " + latestUpdate + ") for " + owner + "/" + repo + "@" + branch + " path=" + path); + return true; + } + } + logI("No remote updates detected vs latestUpdate=" + latestUpdate); + return false; } - private String downloadRemoteIoc(String url) throws IOException, InterruptedException { + // --------------------- Downloads --------------------- + + private String downloadRemoteIoc(String url) throws IOException { Files.createDirectories(indicatorsFolder); String fileName = url.replaceFirst("^https?://", "").replaceAll("[/\\\\]", "_"); Path dest = indicatorsFolder.resolve(fileName); + logI("Downloading IOC url=" + url + " -> " + dest); if (url.startsWith("file://")) { Path p = Paths.get(URI.create(url)); Files.copy(p, dest, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + logI("Copied local IOC " + p + " -> " + dest); return dest.toString(); } boolean ok = httpGetToFile(url, dest, 15000); - return ok ? dest.toString() : null; + if (!ok) { logW("Failed to download IOC: " + url); return null; } + return dest.toString(); } - private long getLatestCheck() { + // --------------------- Timestamps --------------------- + + public long getLatestCheck() { try { - return Long.parseLong(readSmallFile(latestCheckPath).trim()); + long v = Long.parseLong(readSmallFile(latestCheckPath).trim()); + logI("getLatestCheck=" + v + " (" + latestCheckPath + ")"); + return v; } catch (IOException | NumberFormatException e) { + logW("getLatestCheck missing or invalid (" + latestCheckPath + ")"); return 0; } } private void setLatestCheck() { - try { writeSmallFile(latestCheckPath, Long.toString(Instant.now().getEpochSecond())); } catch (IOException ignored) {} + try { long now = Instant.now().getEpochSecond(); writeSmallFile(latestCheckPath, Long.toString(now)); logI("setLatestCheck=" + now + " (" + latestCheckPath + ")"); } + catch (IOException ignored) { logW("setLatestCheck failed"); } } - private long getLatestUpdate() { + public long getLatestUpdate() { try { - return Long.parseLong(readSmallFile(latestUpdatePath).trim()); + long v = Long.parseLong(readSmallFile(latestUpdatePath).trim()); + logI("getLatestUpdate=" + v + " (" + latestUpdatePath + ")"); + return v; } catch (IOException | NumberFormatException e) { + logW("getLatestUpdate missing or invalid (" + latestUpdatePath + ")"); return 0; } } private void setLatestUpdate() { - try { writeSmallFile(latestUpdatePath, Long.toString(Instant.now().getEpochSecond())); } catch (IOException ignored) {} + try { long now = Instant.now().getEpochSecond(); writeSmallFile(latestUpdatePath, Long.toString(now)); logI("setLatestUpdate=" + now + " (" + latestUpdatePath + ")"); } + catch (IOException ignored) { logW("setLatestUpdate failed"); } } - /** Download a single IOC file from a URL into the indicators folder. */ - public Path download(String url) throws IOException, InterruptedException { + // --------------------- Public API --------------------- + + public Path download(String url) throws IOException { + logI("download() called with url=" + url); String dl = downloadRemoteIoc(url); - return dl != null ? Paths.get(dl) : null; + Path p = (dl != null) ? Paths.get(dl) : null; + logI("download() result=" + p); + return p; } - public void update() throws IOException, InterruptedException { + @SuppressWarnings("unchecked") + public void update() throws IOException { + logI("update() start"); setLatestCheck(); + Map index = getRemoteIndex(); - if (index == null) return; + if (index == null) { logW("update() no index"); return; } - Object indicators = index.get("indicators"); - if (!(indicators instanceof Iterable)) return; - Iterable inds = (Iterable) indicators; + long latestUpdate = getLatestUpdate(); - for (Object obj : inds) { - if (!(obj instanceof Map)) continue; - @SuppressWarnings("unchecked") - Map map = (Map) obj; + // Build per-item plan using commit times (index + each GH indicator) + java.util.List plan = buildDownloadPlan(index, latestUpdate); - String url; - String type = stringOrEmpty(map.get("type")); - if ("github".equals(type)) { - @SuppressWarnings("unchecked") - Map gh = (Map) map.get("github"); - if (gh == null) continue; - String owner = stringOrEmpty(gh.get("owner")); - String repo = stringOrEmpty(gh.get("repo")); - String branch = stringOrEmptyDefault(gh.get("branch"), "main"); - String path = stringOrEmpty(gh.get("path")); - if (owner.isEmpty() || repo.isEmpty() || path.isEmpty()) continue; - url = String.format(githubRawUrl, owner, repo, branch, path); - } else { - url = stringOrEmpty(map.get("download_url")); - } + // Decide if anything changed at all + boolean anythingChanged = false; + for (IndicatorPlan ip : plan) { + if (ip.changed) { anythingChanged = true; break; } + } + if (!anythingChanged) { + logI("No updates available; skipping downloads"); + return; + } - if (url.trim().isEmpty()) continue; - downloadRemoteIoc(url); + // Selective download: only changed items + int attempted = 0, succeeded = 0; + for (IndicatorPlan ip : plan) { + if (!ip.changed) continue; + attempted++; + String res = downloadRemoteIoc(ip.url); + if (res != null) { succeeded++; logI("update() downloaded -> " + res); } + else { logW("update() failed -> " + ip.url); } } - setLatestUpdate(); + + if (succeeded > 0) { + setLatestUpdate(); + } else { + logW("No files succeeded; not updating latest_indicators_update"); + } + logI("update() done: attempted=" + attempted + " succeeded=" + succeeded); } - private static String stringOrEmpty(Object o) { - return (o == null) ? "" : o.toString(); + + public long countIndicators() { + try (java.util.stream.Stream stream = Files.list(indicatorsFolder)) { + long c = stream.count(); + logI("countIndicators folder=" + indicatorsFolder + " count=" + c); + return c; + } catch (IOException e) { + logE("countIndicators failed for " + indicatorsFolder, e); + return 0; + } } + + // --------------------- utils --------------------- + + private static String stringOrEmpty(Object o) { return (o == null) ? "" : o.toString(); } private static String stringOrEmptyDefault(Object o, String dflt) { String s = stringOrEmpty(o); return s.isEmpty() ? dflt : s; } - - // --------------------- Minimal YAML/JSON readers --------------------- - - /** - * Very small YAML reader for the expected indicators.yaml shape. - * Not a general-purpose YAML parser. - */ - static class YamlMini { - static Map parseIndex(String text) { - Map root = new HashMap<>(); - List> indicators = new ArrayList<>(); - root.put("indicators", indicators); - - String[] lines = text.replace("\r\n", "\n").replace('\r', '\n').split("\n"); - - boolean inIndicators = false; - Map currentItem = null; - boolean inGithub = false; - Map currentGithub = null; - int githubIndent = -1; - int itemIndent = -1; - - for (String raw : lines) { - String line = stripComment(raw); - if (line.trim().isEmpty()) continue; - - int indent = leadingSpaces(line); - String t = line.trim(); - - if (!inIndicators) { - if (t.equals("indicators:")) { - inIndicators = true; - } - continue; - } - - // new list item - if (t.startsWith("- ")) { - currentItem = new HashMap<>(); - indicators.add(currentItem); - inGithub = false; - currentGithub = null; - itemIndent = indent; - t = t.substring(2).trim(); - if (!t.isEmpty()) { - // handle inline "key: value" after "- " - kvPut(currentItem, t); - } - continue; - } - - if (currentItem == null) continue; // ignore anything before first item - - // leaving nested github block if indentation decreases - if (inGithub && indent <= githubIndent) { - inGithub = false; - currentGithub = null; - } - - // within current item (deeper indent than itemIndent) - if (indent > itemIndent) { - if (t.equals("github:")) { - inGithub = true; - currentGithub = new HashMap<>(); - currentItem.put("github", currentGithub); - githubIndent = indent; - continue; - } - if (inGithub) { - kvPut(currentGithub, t); - } else { - kvPut(currentItem, t); - } - } else if (indent <= itemIndent) { - // a sibling or parent — we’ll wait for next "- " to start new item - // (no-op) - } - } - - return root; + private static class IndicatorPlan { + final String url; + final boolean changed; + final String reason; + IndicatorPlan(String url, boolean changed, String reason) { + this.url = url; + this.changed = changed; + this.reason = reason; } + } - private static void kvPut(Map target, String kvLine) { - int idx = kvLine.indexOf(':'); - if (idx < 0) return; - String k = kvLine.substring(0, idx).trim(); - String v = kvLine.substring(idx + 1).trim(); - // strip surrounding quotes if present - if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith("\"") && v.endsWith("\""))) { - v = v.substring(1, v.length() - 1); + // Build the download plan: which indicators should be fetched now? + @SuppressWarnings("unchecked") + private java.util.List buildDownloadPlan(Map index, long latestUpdate) { + java.util.List plan = new java.util.ArrayList<>(); + + // 1) Find index commit time (if resolvable on GitHub) + long indexEpoch = 0L; + GhRef idx = resolveIndexGhRef(); + if (idx != null && idx.isComplete()) { + indexEpoch = githubLatestCommitEpoch(idx.owner, idx.repo, idx.branch, idx.path); + if (indexEpoch > 0) { + logI("Index latest epoch=" + indexEpoch + " (latestUpdate=" + latestUpdate + ")"); + } else { + logW("Could not resolve latest commit epoch for index; treating non-GitHub items conservatively."); } - target.put(k, v); - } - - private static int leadingSpaces(String s) { - int i = 0; - while (i < s.length() && s.charAt(i) == ' ') i++; - return i; + } else { + logI("Index not a GitHub raw URL; skipping index commit check"); } - private static String stripComment(String s) { - int i = s.indexOf('#'); - if (i >= 0) return s.substring(0, i); - return s; + // 2) Iterate indicators and decide per-item freshness + Object indicators = index.get("indicators"); + if (!(indicators instanceof Iterable)) { + logW("buildDownloadPlan: 'indicators' not iterable"); + return plan; } - } - /** - * Minimal JSON reader for the very small expected index shape. - * If your index is true JSON, this avoids adding a JSON dependency. - * NOTE: This is deliberately tiny; if JSON grows, plug in a JSON lib. - */ - static class JsonMini { - static Map parseIndex(String json) { - // Extremely small and permissive reader: - // we will look for "indicators":[ ... ] and then for objects within it, - // extracting "type", "download_url", and "github" { owner, repo, branch, path }. - Map root = new HashMap<>(); - List> indicators = new ArrayList<>(); - root.put("indicators", indicators); - - String s = json.replace("\r", "").trim(); - - int indStart = s.indexOf("\"indicators\""); - if (indStart < 0) return root; - int arrStart = s.indexOf('[', indStart); - if (arrStart < 0) return root; - int arrEnd = findMatchingBracket(s, arrStart, '[', ']'); - if (arrEnd < 0) return root; - - String arr = s.substring(arrStart + 1, arrEnd); - int idx = 0; - while (idx < arr.length()) { - int objStart = arr.indexOf('{', idx); - if (objStart < 0) break; - int objEnd = findMatchingBracket(arr, objStart, '{', '}'); - if (objEnd < 0) break; - String obj = arr.substring(objStart + 1, objEnd); - - Map item = new HashMap<>(); - // crude key scanning - String type = extractJsonString(obj, "type"); - if (type != null) item.put("type", type); - String dl = extractJsonString(obj, "download_url"); - if (dl != null) item.put("download_url", dl); - - // nested github - int ghStart = obj.indexOf("\"github\""); - if (ghStart >= 0) { - int braceStart = obj.indexOf('{', ghStart); - int braceEnd = (braceStart >= 0) ? findMatchingBracket(obj, braceStart, '{', '}') : -1; - if (braceStart >= 0 && braceEnd > braceStart) { - String gh = obj.substring(braceStart + 1, braceEnd); - Map gm = new HashMap<>(); - putIfNotNull(gm, "owner", extractJsonString(gh, "owner")); - putIfNotNull(gm, "repo", extractJsonString(gh, "repo")); - putIfNotNull(gm, "branch", extractJsonString(gh, "branch")); - putIfNotNull(gm, "path", extractJsonString(gh, "path")); - item.put("github", gm); - } - } + for (Object obj : (Iterable) indicators) { + if (!(obj instanceof Map)) continue; + Map item = (Map) obj; - indicators.add(item); - idx = objEnd + 1; + String url = indicatorDownloadUrl(item); + if (url == null || url.trim().isEmpty()) { + logW("buildDownloadPlan: skipped item with empty url"); + continue; } - return root; - } - - private static void putIfNotNull(Map m, String k, String v) { - if (v != null) m.put(k, v); - } + String type = stringOrEmpty(item.get("type")); + boolean changed = false; + String reason = ""; - private static int findMatchingBracket(String s, int openPos, char open, char close) { - int depth = 0; - for (int i = openPos; i < s.length(); i++) { - char c = s.charAt(i); - if (c == open) depth++; - else if (c == close) { - depth--; - if (depth == 0) return i; + if ("github".equals(type)) { + Object ghObj = item.get("github"); + if (ghObj instanceof Map) { + Map gh = (Map) ghObj; + String owner = stringOrEmpty(gh.get("owner")); + String repo = stringOrEmpty(gh.get("repo")); + String branch = stringOrEmptyDefault(gh.get("branch"), "main"); + String path = stringOrEmpty(gh.get("path")); + + if (!owner.isEmpty() && !repo.isEmpty() && !path.isEmpty()) { + long t = githubLatestCommitEpoch(owner, repo, branch, path); + if (t > latestUpdate) { + changed = true; + reason = "github(" + owner + "/" + repo + "@" + branch + ":" + path + ") t=" + t + " > " + latestUpdate; + } else { + reason = "github unchanged (t <= latestUpdate)"; + } + } else { + // malformed GH entry: when in doubt, only refresh if the index changed + changed = (indexEpoch > latestUpdate && indexEpoch > 0); + reason = "github incomplete fields; fallback to index comparison"; + } + } else { + // no github block: fallback to index + changed = (indexEpoch > latestUpdate && indexEpoch > 0); + reason = "missing github block; fallback to index comparison"; + } + } else { + // Non-GitHub item. + boolean isFileUrl = url.startsWith("file://"); + boolean firstRun = (latestUpdate == 0); + + // If it's a local file, refresh at least once — or when the dest is missing. + boolean destMissing = false; + if (isFileUrl) { + String fileName = url.replaceFirst("^https?://", "").replaceAll("[/\\\\]", "_"); + Path dest = indicatorsFolder.resolve(fileName); + try { + destMissing = !java.nio.file.Files.exists(dest); + } catch (Throwable ignored) {} } - } - return -1; - } - private static String extractJsonString(String obj, String key) { - String needle = "\"" + key + "\""; - int k = obj.indexOf(needle); - if (k < 0) return null; - int colon = obj.indexOf(':', k + needle.length()); - if (colon < 0) return null; - int q1 = obj.indexOf('"', colon + 1); - if (q1 < 0) return null; - int q2 = findStringEnd(obj, q1 + 1); - if (q2 < 0) return null; - String raw = obj.substring(q1 + 1, q2); - return unescapeJsonString(raw); - } + if (isFileUrl && (firstRun || destMissing)) { + changed = true; + reason = "local file; " + (firstRun ? "first run" : "destination missing"); + } else if (firstRun) { + // For any other non-GitHub item, refresh on first run. + changed = true; + reason = "non-github; first run"; + } else { + // Otherwise follow index commits (which we can’t query here). + changed = (indexEpoch > latestUpdate && indexEpoch > 0); + reason = "non-github; follow index (indexEpoch=" + indexEpoch + ")"; + } } - private static int findStringEnd(String s, int start) { - boolean esc = false; - for (int i = start; i < s.length(); i++) { - char c = s.charAt(i); - if (esc) { esc = false; continue; } - if (c == '\\') { esc = true; continue; } - if (c == '"') return i; - } - return -1; + logI("plan: " + (changed ? "FETCH " : "SKIP ") + url + " -- " + reason); + plan.add(new IndicatorPlan(url, changed, reason)); } - private static String unescapeJsonString(String raw) { - // minimal unescape for common sequences - StringBuilder sb = new StringBuilder(raw.length()); - boolean esc = false; - for (int i = 0; i < raw.length(); i++) { - char c = raw.charAt(i); - if (!esc) { - if (c == '\\') { esc = true; continue; } - sb.append(c); - } else { - switch (c) { - case '"': sb.append('"'); break; - case '\\': sb.append('\\'); break; - case '/': sb.append('/'); break; - case 'b': sb.append('\b'); break; - case 'f': sb.append('\f'); break; - case 'n': sb.append('\n'); break; - case 'r': sb.append('\r'); break; - case 't': sb.append('\t'); break; - case 'u': - if (i + 4 < raw.length()) { - String hex = raw.substring(i + 1, i + 5); - try { sb.append((char) Integer.parseInt(hex, 16)); } - catch (Exception ignored) {} - i += 4; - } - break; - default: sb.append(c); break; - } - esc = false; - } - } - return sb.toString(); + return plan; + } + + // Build download URL for an item (github->raw, else download_url) + @SuppressWarnings("unchecked") + private String indicatorDownloadUrl(Map item) { + String type = stringOrEmpty(item.get("type")); + if ("github".equals(type)) { + Object ghObj = item.get("github"); + if (!(ghObj instanceof Map)) return ""; + Map gh = (Map) ghObj; + String owner = stringOrEmpty(gh.get("owner")); + String repo = stringOrEmpty(gh.get("repo")); + String branch = stringOrEmptyDefault(gh.get("branch"), "main"); + String path = stringOrEmpty(gh.get("path")); + if (owner.isEmpty() || repo.isEmpty() || path.isEmpty()) return ""; + return String.format(githubRawUrl, owner, repo, branch, path); } + return stringOrEmpty(item.get("download_url")); } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 104e335..db49733 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,87 +1,90 @@ Bugbane - + - Scan + Acquire Acquisitions Settings Welcome to Bugbane - This application is designed to facilitate the consensual forensic analysis of Android devices, for the purpose of identifying traces of compromise of sophisticated mobile spyware attacks. + This application helps with consensual forensic analysis of Android devices to identify signs of compromise or surveillance. \n\n - After the analysis, you can share an encrypted copy of the results with trusted third parties. - \n - Beware that the results could in some cases contain sensitive information, so be careful with the sharing. - Sorry, Bugbane is not supported on Android versions lower than 11 (API Level 30). Please use AndroidQF or MVT from a trusted computer. + The absence of detection from this app does not mean that your device is safe or secure. + \n + Sorry, Bugbane is not supported on Android versions lower than 11 (API level 30). Please use AndroidQF or MVT from a trusted computer. I understand - Connect to a Trusted Wi‑Fi Network - To continue, connect your device to a protected Wi‑Fi network, such as your home network or a personal hotspot. - Open Wi‑Fi Settings + Connect to a Trusted Wi-Fi Network + To continue, connect your device to a protected Wi-Fi network, such as your home network or a personal hotspot. + Open Wi-Fi Settings Enable Notification Permissions - To use this app, you need to enable Notification Permissions in your device settings. + To use this app, you need to enable notification permissions. \n\n - The application will show a notification to allow you to enter the pairing code to pair through ADB Wireless Debugging. + Bugbane shows a notification so you can enter the pairing code for ADB Wireless Debugging. Enable Enable Developer Options - To use this app, you need to enable Developer Options in your device settings. + To use this app, enable Developer Options in your device settings. \n\n - The application will scan you device using ADB Wireless Debugging, available only when the Developer Options are enabled. + The application acquires data using ADB Wireless Debugging, which is available only when Developer Options are enabled. Enable - Enable Wireless Debugging and Pair the Application - To use this app, you need to be connected to a Wi-Fi network and pair through the ADB Wireless Debugging feature. - \n\n - Enable ADB Wireless Debugging, click \"Pair device with pairing code\" and enter the code in the Bugbane notification. + Enable Wireless Debugging and Pair + Connect to Wi-Fi, enable ADB Wireless Debugging, tap “Pair device with pairing code,” then enter the code in the Bugbane notification. Pair Ready to Start - You\'re all set! The app is ready to use with all the latest Android development features. - \n\n - You can now start the scan and share the results with trusted third parties. + You\'re all set. You can now acquire data and run analyses, then share results with trusted third parties. Get Started - + Forensic Checklist - Start Analysis - Analizying... + Start Acquisition + Acquiring… Enable Permissions - After every scan, it is recommended to disable the Development Option until the next scan. + After each acquisition, it’s recommended to disable Developer Options until the next session. Disable Close - Stop Analysis - + Stop Acquisition + - Configure your app preferences and settings here. + Configure your preferences and app settings here. General Settings Disable Developer Options - It is recommended to disable the Development Options after every scan and re-enable it when needed to prevent unnecessary use. - + It’s recommended to disable Developer Options after each session and re-enable them only when needed. + Indicators + Last fetch: %1$s + Last update: %1$s + Indicator files: %1$d + + + Indicators + Downloaded %1$d new indicators + Acquisitions Rename Delete - You have no acquisition, perform one to see it here + No acquisitions yet. Perform one to see it here. Invalid name Unable to rename acquisition - - - Welcome to Bugbane - Press the \'Start Acquisition\' button to start - + + + Acquire Data + Press “Start Acquisition” to begin. + ADB Pairing ADB Pairing Successful ADB Pairing Failed - Enter Pairing Code - Searching for ADB Pairing Service - Stop Searching - ADB Pairing Service Found on Port %d - Pairing with ADB Service - + Enter pairing code + Searching for ADB pairing service + Stop searching + ADB pairing service found on port %d + Pairing with ADB service + Acquisition Details Size: %1$s @@ -89,13 +92,18 @@ Started: %1$s Completed: %1$s Files + View files + Analyses + Date + Indicators + Matches Export Share - Scan Again + Run Analysis The exported archive contains potentially private information and has been encrypted. The password will be shown only once:\n\n%1$s Acquisition archive. Passphrase: %1$s Copy to clipboard Close - \ No newline at end of file + diff --git a/app/src/test/java/org/osservatorionessuno/libmvt/common/IndicatorsUpdatesTest.java b/app/src/test/java/org/osservatorionessuno/libmvt/common/IndicatorsUpdatesTest.java index 5f13281..f2a249b 100644 --- a/app/src/test/java/org/osservatorionessuno/libmvt/common/IndicatorsUpdatesTest.java +++ b/app/src/test/java/org/osservatorionessuno/libmvt/common/IndicatorsUpdatesTest.java @@ -30,7 +30,7 @@ public void testUpdateLocal() throws Exception { IndicatorsUpdates updates = new IndicatorsUpdates(temp, indexFile.toUri().toString()); updates.update(); - Path indicatorsDir = temp.resolve("indicators"); + Path indicatorsDir = temp.resolve("iocs"); // Match IndicatorsUpdates' filename logic: replace only http(s) scheme and slash/backslash with '_' String fileName = stix.toUri().toString() .replaceFirst("^https?://", "") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d559ee8..f79c358 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,14 +9,16 @@ activityCompose = "1.10.1" composeBom = "2025.08.00" navigationCompose = "2.9.3" junitJupiter = "5.13.4" -json = "20250517" protobuf = "4.32.0" protobufPlugin = "0.9.5" conscrypt = "2.5.3" ahocorasick = "0.6.3" libadb = "3.0.0" sunSecurityAndroid = "1.1" -kage = "0.3.0" +work = "2.10.3" +snakeyaml = "2.4" +kage = "0.3.0" +json = "20250517" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -50,5 +52,7 @@ protobuf-javalite = { group = "com.google.protobuf", na protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junitJupiter" } junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junitJupiter" } -org-json = { group = "org.json", name = "json", version.ref = "json" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +snakeyaml = { group = "org.yaml", name = "snakeyaml", version.ref = "snakeyaml" } kage = { group = "com.github.android-password-store", name = "kage", version.ref = "kage" } +org-json = { group = "org.json", name = "json", version.ref = "json" } \ No newline at end of file