QA006
Datenschutz:
Überprüfung der Anwendung hinsichtlich Datenschutz und Datensicherheit.
Keine unnötigen Berechtigungen
Keine sensiblen Logs
Sichere Dateispeicherung
Backup-Verhalten geprüft
Datenschutz-Dokumentation erstellt
usw.
This commit is contained in:
+89
@@ -0,0 +1,89 @@
|
||||
package de.medapp.wunddoku.data.repository
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
|
||||
import de.medapp.wunddoku.testutil.TestEnvironment
|
||||
import de.medapp.wunddoku.testutil.buildRepository
|
||||
import de.medapp.wunddoku.testutil.runDbTest
|
||||
import de.medapp.wunddoku.testutil.signatureBitmap
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Datenschutz-Löschkonzept: Beim Löschen einer Dokumentation müssen ALLE personenbezogenen
|
||||
* Daten verschwinden - Datenbankzeilen, Fotos, Signaturen und abgeleitete Exporte (PDF/Bilder).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DocumentationDeletionPrivacyTest {
|
||||
|
||||
private lateinit var env: TestEnvironment
|
||||
|
||||
private class RecordingCleaner(private val failure: Throwable? = null) : ExportedFilesCleaner {
|
||||
val deleted = mutableListOf<String>()
|
||||
override suspend fun deleteExportsFor(documentationId: String) {
|
||||
deleted += documentationId
|
||||
failure?.let { throw it }
|
||||
}
|
||||
override suspend fun deleteStaleFiles() = Unit
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
env = TestEnvironment()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
env.close()
|
||||
}
|
||||
|
||||
private suspend fun createDocumentationWithData(repo: DocumentationRepository): String {
|
||||
env.insertPatient()
|
||||
val id = repo.createEmpty("patient-1").id
|
||||
repo.addPhoto(id, Uri.parse("content://test/1"))
|
||||
repo.saveSignature(id, "Pfleger Paul", signatureBitmap())
|
||||
return id
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loeschen_entfernt_auch_die_exportierten_dateien() = runDbTest {
|
||||
val cleaner = RecordingCleaner()
|
||||
val repo = env.buildRepository(exportedFilesCleaner = cleaner)
|
||||
val id = createDocumentationWithData(repo)
|
||||
|
||||
repo.delete(id)
|
||||
|
||||
assertEquals(listOf(id), cleaner.deleted)
|
||||
assertNull(repo.getFullDocumentation(id))
|
||||
assertTrue(env.photoStorage.deletedAll.contains(id))
|
||||
assertTrue(env.signatureStorage.deletedAll.contains(id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fehler_beim_loeschen_der_exporte_verhindert_das_loeschen_der_dokumentation_nicht() = runDbTest {
|
||||
val repo = env.buildRepository(exportedFilesCleaner = RecordingCleaner(IllegalStateException("Test")))
|
||||
val id = createDocumentationWithData(repo)
|
||||
|
||||
repo.delete(id)
|
||||
|
||||
assertNull(repo.getFullDocumentation(id))
|
||||
assertTrue(repo.observeAll().first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loeschen_einer_nicht_vorhandenen_dokumentation_loescht_keine_exporte() = runDbTest {
|
||||
val cleaner = RecordingCleaner()
|
||||
val repo = env.buildRepository(exportedFilesCleaner = cleaner)
|
||||
|
||||
repo.delete("gibt-es-nicht")
|
||||
|
||||
assertTrue(cleaner.deleted.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package de.medapp.wunddoku.privacy
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.view.WindowManager
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.medapp.wunddoku.MainActivity
|
||||
import de.medapp.wunddoku.R
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Datenschutz-Prüfung (QA-006) der ZUSAMMENGEFÜHRTEN, tatsächlich installierten App: Das fertige
|
||||
* Manifest enthält auch Einträge aus Bibliotheken, die im eigenen Manifest nicht sichtbar sind.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PrivacyConfigurationTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val packageName = context.packageName
|
||||
private val id = "test-${UUID.randomUUID()}"
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
File(context.filesDir, "photos/$id").mkdirs()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
listOf("photos", "signatures", "pdf", "pdf_images").forEach {
|
||||
File(File(context.filesDir, it), id).deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun packageInfo(flags: Int) = context.packageManager.getPackageInfo(packageName, flags)
|
||||
|
||||
// --- Berechtigungen ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun app_fordert_keine_gefaehrlichen_oder_unnoetigen_berechtigungen_an() {
|
||||
val requested = packageInfo(PackageManager.GET_PERMISSIONS).requestedPermissions?.toList().orEmpty()
|
||||
|
||||
val forbidden = listOf(
|
||||
"android.permission.INTERNET",
|
||||
"android.permission.ACCESS_NETWORK_STATE",
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.READ_EXTERNAL_STORAGE",
|
||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||
"android.permission.MANAGE_EXTERNAL_STORAGE",
|
||||
"android.permission.READ_MEDIA_IMAGES",
|
||||
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
||||
"android.permission.ACCESS_FINE_LOCATION",
|
||||
"android.permission.ACCESS_COARSE_LOCATION",
|
||||
"android.permission.READ_CONTACTS",
|
||||
"android.permission.READ_PHONE_STATE",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
)
|
||||
assertEquals("Verbotene Berechtigungen: ${requested.filter { it in forbidden }}", emptyList<String>(), requested.filter { it in forbidden })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun app_hat_ausser_dem_androidx_interna_keine_weiteren_berechtigungen() {
|
||||
val requested = packageInfo(PackageManager.GET_PERMISSIONS).requestedPermissions?.toList().orEmpty()
|
||||
|
||||
// Die Signatur-Berechtigung von androidx (Schutz dynamischer Receiver) ist unkritisch.
|
||||
val allowed = setOf("$packageName.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION")
|
||||
val unexpected = requested.filterNot { it in allowed }
|
||||
|
||||
assertTrue("Unerwartete Berechtigungen im zusammengeführten Manifest: $unexpected", unexpected.isEmpty())
|
||||
}
|
||||
|
||||
// --- Backup --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun backup_ist_deaktiviert() {
|
||||
val flags = context.applicationInfo.flags
|
||||
assertEquals("android:allowBackup muss false sein", 0, flags and ApplicationInfo.FLAG_ALLOW_BACKUP)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun data_extraction_rules_schliessen_alles_aus() {
|
||||
val parser = context.resources.getXml(R.xml.data_extraction_rules)
|
||||
var section: String? = null
|
||||
val excluded = mutableMapOf<String, MutableSet<String>>()
|
||||
var includeFound = false
|
||||
|
||||
while (parser.next() != XmlPullParser.END_DOCUMENT) {
|
||||
if (parser.eventType == XmlPullParser.START_TAG) {
|
||||
when (parser.name) {
|
||||
"cloud-backup", "device-transfer" -> {
|
||||
section = parser.name
|
||||
excluded[parser.name] = mutableSetOf()
|
||||
}
|
||||
"exclude" -> {
|
||||
val domain = parser.getAttributeValue(null, "domain")
|
||||
val current = section
|
||||
if (domain != null && current != null) excluded[current]?.add(domain)
|
||||
}
|
||||
"include" -> includeFound = true
|
||||
}
|
||||
} else if (parser.eventType == XmlPullParser.END_TAG && parser.name == section) {
|
||||
section = null
|
||||
}
|
||||
}
|
||||
|
||||
assertFalse("Kein <include> erlaubt", includeFound)
|
||||
assertEquals(setOf("cloud-backup", "device-transfer"), excluded.keys)
|
||||
excluded.forEach { (name, domains) ->
|
||||
assertTrue("$name muss database, file und sharedpref ausschließen, war $domains", domains.containsAll(setOf("database", "file", "sharedpref")))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Exportierte Komponenten ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun ausser_der_start_activity_sind_keine_eigenen_komponenten_exportiert() {
|
||||
val info = packageInfo(
|
||||
PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or
|
||||
PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
|
||||
)
|
||||
|
||||
// Debug-Builds enthalten zusätzlich Test-/Tooling-Activities von androidx (nicht im Release).
|
||||
val toolingPrefixes = listOf("androidx.compose.ui.tooling", "androidx.activity.ComponentActivity", "androidx.compose.ui.test")
|
||||
val exportedActivities = info.activities.orEmpty().filter { it.exported }.map { it.name }
|
||||
.filterNot { name -> toolingPrefixes.any { name.startsWith(it) } }
|
||||
assertEquals(listOf(MainActivity::class.java.name), exportedActivities)
|
||||
|
||||
val exportedServices = info.services.orEmpty().filter { it.exported }.map { it.name }
|
||||
assertTrue("Exportierte Services: $exportedServices", exportedServices.isEmpty())
|
||||
|
||||
val exportedProviders = info.providers.orEmpty().filter { it.exported }.map { it.name }
|
||||
assertTrue("Exportierte ContentProvider: $exportedProviders", exportedProviders.isEmpty())
|
||||
|
||||
// androidx.profileinstaller ist per Systemberechtigung (DUMP) geschützt und unkritisch.
|
||||
val exportedReceivers = info.receivers.orEmpty().filter { it.exported }.map { it.name }
|
||||
.filterNot { it == "androidx.profileinstaller.ProfileInstallReceiver" }
|
||||
assertTrue("Exportierte Receiver: $exportedReceivers", exportedReceivers.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fileprovider_ist_nicht_exportiert_und_vergibt_nur_gezielte_rechte() {
|
||||
val provider = context.packageManager.getProviderInfo(
|
||||
ComponentName(packageName, FileProvider::class.java.name),
|
||||
PackageManager.GET_META_DATA,
|
||||
)
|
||||
|
||||
assertFalse(provider.exported)
|
||||
assertTrue(provider.grantUriPermissions)
|
||||
assertEquals("$packageName.fileprovider", provider.authority)
|
||||
}
|
||||
|
||||
// --- FileProvider-Freigaben (minimale Freigabe) --------------------------------------
|
||||
|
||||
private fun uriFor(file: File) = FileProvider.getUriForFile(context, "$packageName.fileprovider", file)
|
||||
|
||||
@Test
|
||||
fun pdf_und_bildexporte_sind_freigegeben() {
|
||||
val pdf = File(context.filesDir, "pdf/$id/x.pdf").apply { parentFile!!.mkdirs(); writeText("x") }
|
||||
val image = File(context.filesDir, "pdf_images/$id/s.jpg").apply { parentFile!!.mkdirs(); writeText("x") }
|
||||
|
||||
assertTrue(uriFor(pdf).toString().startsWith("content://$packageName.fileprovider/"))
|
||||
assertTrue(uriFor(image).toString().startsWith("content://$packageName.fileprovider/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun originalfotos_und_signaturen_sind_nicht_ueber_den_fileprovider_freigabefaehig() {
|
||||
val photo = File(context.filesDir, "photos/$id/foto.jpg").apply { parentFile!!.mkdirs(); writeText("x") }
|
||||
val signature = File(context.filesDir, "signatures/$id/sig.png").apply { parentFile!!.mkdirs(); writeText("x") }
|
||||
|
||||
for (file in listOf(photo, signature)) {
|
||||
try {
|
||||
uriFor(file)
|
||||
fail("${file.parentFile!!.parentFile!!.name}/ darf nicht freigebbar sein")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// erwartet: nicht in file_paths.xml konfiguriert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun datenbank_und_sonstige_interne_dateien_sind_nicht_freigabefaehig() {
|
||||
val database = context.getDatabasePath("irgendeine.db")
|
||||
val other = File(context.filesDir, "geheim_$id.txt").apply { writeText("x") }
|
||||
|
||||
try {
|
||||
uriFor(other)
|
||||
fail("Dateien im Wurzelordner von filesDir dürfen nicht freigebbar sein")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
} finally {
|
||||
other.delete()
|
||||
}
|
||||
try {
|
||||
uriFor(database)
|
||||
fail("Die Datenbank darf nie freigebbar sein")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Speicherort ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun dokumentationsdaten_liegen_im_privaten_internen_speicher_und_sind_nicht_fuer_andere_lesbar() {
|
||||
val file = File(context.filesDir, "photos/$id/foto.jpg").apply { writeText("x") }
|
||||
|
||||
assertTrue(file.canonicalPath.startsWith(context.filesDir.canonicalPath))
|
||||
val external = context.getExternalFilesDir(null)?.canonicalPath
|
||||
if (external != null) assertFalse(file.canonicalPath.startsWith(external))
|
||||
|
||||
val permissions = java.nio.file.Files.getPosixFilePermissions(file.toPath())
|
||||
assertFalse("Datei darf nicht 'others'-lesbar sein: $permissions", permissions.contains(java.nio.file.attribute.PosixFilePermission.OTHERS_READ))
|
||||
assertFalse("Datei darf nicht 'others'-schreibbar sein: $permissions", permissions.contains(java.nio.file.attribute.PosixFilePermission.OTHERS_WRITE))
|
||||
}
|
||||
|
||||
// --- Bildschirmschutz ----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun hauptfenster_verwendet_flag_secure_gegen_screenshots_und_app_switcher_vorschau() {
|
||||
var flags = 0
|
||||
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||
scenario.onActivity { activity -> flags = activity.window.attributes.flags }
|
||||
}
|
||||
|
||||
assertNotEquals("FLAG_SECURE muss gesetzt sein", 0, flags and WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,7 @@ fun TestEnvironment.buildRepository(
|
||||
photoDao: PhotoDao = db.photoDao(),
|
||||
signatureDao: SignatureDao = db.signatureDao(),
|
||||
documentationDao: DocumentationDao = db.documentationDao(),
|
||||
exportedFilesCleaner: de.medapp.wunddoku.pdf.ExportedFilesCleaner = de.medapp.wunddoku.pdf.ExportedFilesCleaner.None,
|
||||
): DocumentationRepository = DocumentationRepository(
|
||||
documentationDao = documentationDao,
|
||||
blockStateDao = db.blockStateDao(),
|
||||
@@ -159,6 +160,7 @@ fun TestEnvironment.buildRepository(
|
||||
pdfExporter = pdfExporter,
|
||||
pdfToImagesConverter = pdfToImagesConverter,
|
||||
db = db,
|
||||
exportedFilesCleaner = exportedFilesCleaner,
|
||||
)
|
||||
|
||||
/** Erwartet, dass der Suspend-Block eine Exception vom Typ [T] wirft, und liefert sie zurück. */
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Keine Speicher-/Kamera-Berechtigungen nötig: Der Photo Picker (Abschnitt 11 der
|
||||
Architektur) benötigt keine READ_MEDIA_IMAGES-Berechtigung, und Fotos/Signaturen/
|
||||
PDFs werden ausschließlich im privaten internen App-Speicher abgelegt. -->
|
||||
|
||||
<!-- Die App arbeitet vollständig offline. Falls eine Bibliothek (jetzt oder nach einem
|
||||
Update) die INTERNET-Berechtigung mitbringt, wird sie beim Zusammenführen der Manifeste
|
||||
entfernt - Patientendaten können das Gerät so nicht über das Netzwerk verlassen. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
|
||||
|
||||
<application
|
||||
android:name=".WunddokuApplication"
|
||||
android:allowBackup="false"
|
||||
|
||||
@@ -2,14 +2,25 @@ package de.medapp.wunddoku
|
||||
|
||||
import android.app.Application
|
||||
import de.medapp.wunddoku.di.AppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class WunddokuApplication : Application() {
|
||||
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
|
||||
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this)
|
||||
|
||||
// Datenschutz: veraltete PDF-/Bildexporte und liegengebliebene Kameraaufnahmen entfernen
|
||||
// (Aufbewahrungsfristen siehe InternalExportedFilesCleaner). Läuft im Hintergrund,
|
||||
// wirft nie und verzögert den Start nicht.
|
||||
applicationScope.launch { container.exportedFilesCleaner.deleteStaleFiles() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import de.medapp.wunddoku.domain.model.Signature
|
||||
import de.medapp.wunddoku.media.ImageDownsampler
|
||||
import de.medapp.wunddoku.media.PhotoStorage
|
||||
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
|
||||
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
|
||||
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
|
||||
import de.medapp.wunddoku.pdf.PdfExportError
|
||||
import de.medapp.wunddoku.pdf.PdfExportResult
|
||||
@@ -69,6 +70,7 @@ class DocumentationRepository(
|
||||
private val pdfExporter: DocumentationPdfExporter,
|
||||
private val pdfToImagesConverter: PdfToImagesConverter,
|
||||
private val db: AppDatabase,
|
||||
private val exportedFilesCleaner: ExportedFilesCleaner = ExportedFilesCleaner.None,
|
||||
) {
|
||||
|
||||
fun observeAll(): Flow<List<DocumentationSummary>> =
|
||||
@@ -434,6 +436,9 @@ class DocumentationRepository(
|
||||
// geladen werden.
|
||||
runCatching { photoStorage.deleteAll(documentationId) }
|
||||
runCatching { signatureStorage.deleteAll(documentationId) }
|
||||
// Datenschutz (Löschkonzept): Auch abgeleitete Kopien der Patientendaten - exportierte
|
||||
// PDFs und deren Seitenbilder - müssen mit der Dokumentation verschwinden.
|
||||
runCatching { exportedFilesCleaner.deleteExportsFor(documentationId) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,8 @@ import de.medapp.wunddoku.media.PhotoStorage
|
||||
import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
|
||||
import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter
|
||||
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
|
||||
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
|
||||
import de.medapp.wunddoku.pdf.InternalExportedFilesCleaner
|
||||
import de.medapp.wunddoku.pdf.PdfToImagesConverter
|
||||
import de.medapp.wunddoku.signature.InternalSignatureStorage
|
||||
import de.medapp.wunddoku.signature.SignatureStorage
|
||||
@@ -42,6 +44,11 @@ class AppContainer(context: Context) {
|
||||
}
|
||||
private val pdfToImagesConverter: PdfToImagesConverter by lazy { AndroidPdfToImagesConverter(context) }
|
||||
|
||||
/** Löscht Exporte gelöschter Dokumentationen sowie veraltete Exporte/Kameraaufnahmen. */
|
||||
val exportedFilesCleaner: ExportedFilesCleaner by lazy {
|
||||
InternalExportedFilesCleaner(filesDir = context.filesDir, cacheDir = context.cacheDir)
|
||||
}
|
||||
|
||||
val patientRepository: PatientRepository by lazy {
|
||||
PatientRepository(patientDao = database.patientDao())
|
||||
}
|
||||
@@ -67,6 +74,7 @@ class AppContainer(context: Context) {
|
||||
pdfExporter = pdfExporter,
|
||||
pdfToImagesConverter = pdfToImagesConverter,
|
||||
db = database,
|
||||
exportedFilesCleaner = exportedFilesCleaner,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,13 +117,13 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
|
||||
}
|
||||
|
||||
private fun documentationDirPath(documentationId: String): File =
|
||||
File(photosRootDir, documentationId)
|
||||
SafePaths.resolveChild(photosRootDir, documentationId)
|
||||
|
||||
private fun ensureDocumentationDir(documentationId: String): File =
|
||||
documentationDirPath(documentationId).apply { mkdirs() }
|
||||
|
||||
override fun resolve(documentationId: String, fileName: String): File =
|
||||
File(documentationDirPath(documentationId), fileName)
|
||||
SafePaths.resolveChild(documentationDirPath(documentationId), fileName)
|
||||
|
||||
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.medapp.wunddoku.media
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Schutz vor Path-Traversal (`../`) beim Auflösen von Datei-/Ordnernamen im privaten App-Speicher.
|
||||
*
|
||||
* Datei- und Ordnernamen stammen normalerweise aus der eigenen Datenbank (UUIDs). Als
|
||||
* Defense-in-Depth wird trotzdem sichergestellt, dass ein aufgelöster Pfad nie aus seinem
|
||||
* Wurzelordner ausbrechen kann - etwa bei einer manipulierten Datenbank oder einem Programmfehler.
|
||||
*/
|
||||
object SafePaths {
|
||||
|
||||
/**
|
||||
* Liefert `File(parent, name)`, wenn [name] ein einzelner, harmloser Pfadbestandteil ist.
|
||||
*
|
||||
* @throws IllegalArgumentException bei leerem Namen, Pfadtrennzeichen, "." / ".." oder wenn
|
||||
* der kanonische Pfad nicht unterhalb von [parent] liegt.
|
||||
*/
|
||||
fun resolveChild(parent: File, name: String): File {
|
||||
require(name.isNotBlank()) { "Name darf nicht leer sein." }
|
||||
require(!name.contains('/') && !name.contains('\\') && !name.contains('\u0000')) {
|
||||
"Name darf kein Pfadtrennzeichen enthalten."
|
||||
}
|
||||
require(name != "." && name != "..") { "Ungültiger Name." }
|
||||
val child = File(parent, name)
|
||||
val parentPath = parent.canonicalFile.path
|
||||
val childPath = child.canonicalFile.path
|
||||
require(childPath.startsWith(parentPath + File.separator)) { "Pfad liegt außerhalb des erlaubten Ordners." }
|
||||
return child
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package de.medapp.wunddoku.pdf
|
||||
|
||||
import de.medapp.wunddoku.media.SafePaths
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Löscht abgeleitete, personenbezogene Dateien, die NICHT zur Dokumentation selbst gehören:
|
||||
* exportierte PDFs (`filesDir/pdf/<documentationId>/`), daraus erzeugte Seitenbilder
|
||||
* (`filesDir/pdf_images/<documentationId>/`) und liegengebliebene Kameraaufnahmen
|
||||
* (`cacheDir/camera/`).
|
||||
*
|
||||
* Datenschutz: PDFs enthalten Name, Geburtsdatum, Anschrift und Wundbefund. Sie dürfen nach dem
|
||||
* Löschen einer Dokumentation nicht im Speicher zurückbleiben (Löschkonzept) und werden darüber
|
||||
* hinaus nach einer Aufbewahrungsfrist automatisch entfernt - sie lassen sich jederzeit aus den
|
||||
* Daten der Dokumentation neu erzeugen.
|
||||
*/
|
||||
interface ExportedFilesCleaner {
|
||||
|
||||
/** Entfernt alle PDF- und Bildexporte der Dokumentation [documentationId]. Wirft nie. */
|
||||
suspend fun deleteExportsFor(documentationId: String)
|
||||
|
||||
/** Entfernt veraltete Exporte und liegengebliebene Kameraaufnahmen. Wirft nie. */
|
||||
suspend fun deleteStaleFiles()
|
||||
|
||||
/** Tut nichts (Standard für Tests und Umgebungen ohne Dateisystem). */
|
||||
object None : ExportedFilesCleaner {
|
||||
override suspend fun deleteExportsFor(documentationId: String) = Unit
|
||||
override suspend fun deleteStaleFiles() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
class InternalExportedFilesCleaner(
|
||||
filesDir: File,
|
||||
cacheDir: File,
|
||||
private val exportMaxAgeMillis: Long = DEFAULT_EXPORT_MAX_AGE_MILLIS,
|
||||
private val captureMaxAgeMillis: Long = DEFAULT_CAPTURE_MAX_AGE_MILLIS,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
) : ExportedFilesCleaner {
|
||||
|
||||
private val pdfRoot = File(filesDir, "pdf")
|
||||
private val imagesRoot = File(filesDir, "pdf_images")
|
||||
private val captureDir = File(cacheDir, "camera")
|
||||
|
||||
override suspend fun deleteExportsFor(documentationId: String) = withContext(Dispatchers.IO) {
|
||||
listOf(pdfRoot, imagesRoot).forEach { root ->
|
||||
runCatching { SafePaths.resolveChild(root, documentationId).deleteRecursively() }
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
override suspend fun deleteStaleFiles() = withContext(Dispatchers.IO) {
|
||||
val currentTime = now()
|
||||
runCatching { deleteOlderThan(pdfRoot, currentTime - exportMaxAgeMillis) }
|
||||
runCatching { deleteOlderThan(imagesRoot, currentTime - exportMaxAgeMillis) }
|
||||
// Kameraaufnahmen nur nach längerer Frist löschen: Wurde die App während der Aufnahme
|
||||
// beendet, wird die Datei nach dem Neustart noch für den Import gebraucht.
|
||||
runCatching { deleteOlderThan(captureDir, currentTime - captureMaxAgeMillis) }
|
||||
Unit
|
||||
}
|
||||
|
||||
/** Löscht Dateien (rekursiv) mit `lastModified < cutoff`; leere Unterordner werden entfernt. */
|
||||
private fun deleteOlderThan(dir: File, cutoff: Long) {
|
||||
val children = dir.listFiles() ?: return
|
||||
for (child in children) {
|
||||
if (child.isDirectory) {
|
||||
deleteOlderThan(child, cutoff)
|
||||
if (child.listFiles().isNullOrEmpty()) child.delete()
|
||||
} else if (child.lastModified() < cutoff) {
|
||||
child.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_EXPORT_MAX_AGE_MILLIS: Long = 7L * 24 * 60 * 60 * 1000
|
||||
const val DEFAULT_CAPTURE_MAX_AGE_MILLIS: Long = 24L * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.medapp.wunddoku.signature
|
||||
|
||||
import de.medapp.wunddoku.media.SafePaths
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
@@ -92,13 +93,13 @@ class InternalSignatureStorage(
|
||||
}
|
||||
|
||||
private fun documentationDirPath(documentationId: String): File =
|
||||
File(rootDir, documentationId)
|
||||
SafePaths.resolveChild(rootDir, documentationId)
|
||||
|
||||
private fun ensureDocumentationDir(documentationId: String): File =
|
||||
documentationDirPath(documentationId).apply { mkdirs() }
|
||||
|
||||
override fun resolve(documentationId: String, fileName: String): File =
|
||||
File(documentationDirPath(documentationId), fileName)
|
||||
SafePaths.resolveChild(documentationDirPath(documentationId), fileName)
|
||||
|
||||
override suspend fun save(documentationId: String, bitmap: Bitmap): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
+4
-3
@@ -331,11 +331,12 @@ class DocumentationEditViewModel(
|
||||
failureCount++
|
||||
if (FailureCategory.of(e) == FailureCategory.OUT_OF_MEMORY) outOfMemoryCount++
|
||||
if (FailureCategory.of(e) == FailureCategory.OUT_OF_STORAGE) outOfStorageCount++
|
||||
// Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/
|
||||
// Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose.
|
||||
// Datenschutz: Es wird bewusst NUR der Exception-Typ geloggt. Die
|
||||
// Exception-Nachricht (e.message) enthält bei Dateifehlern häufig den
|
||||
// Dateipfad bzw. die Bild-Uri und darf deshalb nie in Logs landen.
|
||||
android.util.Log.w(
|
||||
"DocumentationEdit",
|
||||
"Fotoimport fehlgeschlagen: ${e::class.simpleName}: ${e.message}",
|
||||
"Fotoimport fehlgeschlagen: ${e::class.simpleName}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir)
|
||||
für die Freigabe über den FileProvider frei. Es gibt bewusst keinen <external-path>-
|
||||
für die Freigabe über den FileProvider frei - und zwar NUR das, was die App tatsächlich
|
||||
nach außen gibt (PDF-Export, Seitenbilder, Kameraaufnahme). Die Ordner photos/ und
|
||||
signatures/ (Originalfotos, Unterschriften) sind bewusst NICHT freigegeben (Prinzip der
|
||||
minimalen Freigabe; Datenschutz-Review QA-006). Es gibt bewusst keinen <external-path>-
|
||||
oder <external-files-path>-Eintrag: Fotos, Signaturen und PDFs liegen grundsätzlich
|
||||
intern (nur die kurzlebigen Kameraaufnahmen zusätzlich im internen Cache) (Abschnitt "Storage-Strategie" der Architektur, v3).
|
||||
-->
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<files-path name="pdf_exports" path="pdf/" />
|
||||
<files-path name="pdf_images" path="pdf_images/" />
|
||||
<files-path name="photos" path="photos/" />
|
||||
<files-path name="signatures" path="signatures/" />
|
||||
<!-- Temporäre Kameraaufnahmen (Kamerabutton im Fotobereich): nur bis zum Import in die
|
||||
Dokumentation, danach sofort gelöscht. -->
|
||||
<cache-path name="camera_captures" path="camera/" />
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package de.medapp.wunddoku.media
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
/** Datenschutz/Sicherheit: kein Ausbrechen aus dem privaten Ordner per Path-Traversal. */
|
||||
class SafePathsTest {
|
||||
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
private fun assertRejected(name: String) {
|
||||
val root = temp.newFolder()
|
||||
try {
|
||||
SafePaths.resolveChild(root, name)
|
||||
fail("'$name' hätte abgelehnt werden müssen")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// erwartet
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normale_namen_werden_unterhalb_des_wurzelordners_aufgeloest() {
|
||||
val root = temp.newFolder()
|
||||
|
||||
val result = SafePaths.resolveChild(root, "3f2a-uuid.jpg")
|
||||
|
||||
assertEquals(File(root, "3f2a-uuid.jpg"), result)
|
||||
assertTrue(result.canonicalPath.startsWith(root.canonicalPath + File.separator))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uebliche_namen_mit_punkten_und_bindestrichen_sind_erlaubt() {
|
||||
val root = temp.newFolder()
|
||||
SafePaths.resolveChild(root, "2026-08-19_Max.Mustermann.v2.pdf")
|
||||
SafePaths.resolveChild(root, "..hidden") // beginnt mit Punkten, ist aber kein Traversal
|
||||
}
|
||||
|
||||
@Test
|
||||
fun path_traversal_wird_abgelehnt() {
|
||||
assertRejected("..")
|
||||
assertRejected("../geheim")
|
||||
assertRejected("../../etc/passwd")
|
||||
assertRejected("a/../../b")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pfadtrenner_und_leere_namen_werden_abgelehnt() {
|
||||
assertRejected("")
|
||||
assertRejected(" ")
|
||||
assertRejected(".")
|
||||
assertRejected("unter/ordner.jpg")
|
||||
assertRejected("unter\\ordner.jpg")
|
||||
assertRejected("/absolut.jpg")
|
||||
assertRejected("nullbyte\u0000.jpg")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun symbolischer_link_aus_dem_ordner_heraus_wird_abgelehnt() {
|
||||
val root = temp.newFolder()
|
||||
val outside = temp.newFolder()
|
||||
val link = File(root, "link")
|
||||
try {
|
||||
java.nio.file.Files.createSymbolicLink(link.toPath(), outside.toPath())
|
||||
} catch (e: Exception) {
|
||||
return // Dateisystem ohne Symlink-Unterstützung: Test nicht anwendbar
|
||||
}
|
||||
|
||||
try {
|
||||
SafePaths.resolveChild(root, "link")
|
||||
fail("Symlink aus dem Ordner heraus hätte abgelehnt werden müssen")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// erwartet
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package de.medapp.wunddoku.pdf
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Datenschutz-Löschkonzept: PDF-/Bildexporte (enthalten Patientendaten) und liegengebliebene
|
||||
* Kameraaufnahmen dürfen nicht dauerhaft im Speicher bleiben.
|
||||
*/
|
||||
class ExportedFilesCleanerTest {
|
||||
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
private lateinit var filesDir: File
|
||||
private lateinit var cacheDir: File
|
||||
private val day = 24L * 60 * 60 * 1000
|
||||
private val now = 1_800_000_000_000L // fester "Jetzt"-Zeitpunkt
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
filesDir = temp.newFolder("files")
|
||||
cacheDir = temp.newFolder("cache")
|
||||
}
|
||||
|
||||
private fun cleaner() = InternalExportedFilesCleaner(filesDir, cacheDir, now = { now })
|
||||
|
||||
private fun file(path: File, ageMillis: Long = 0): File {
|
||||
path.parentFile!!.mkdirs()
|
||||
path.writeText("x")
|
||||
path.setLastModified(now - ageMillis)
|
||||
return path
|
||||
}
|
||||
|
||||
private fun pdf(docId: String, ageMillis: Long = 0) = file(File(filesDir, "pdf/$docId/bericht.pdf"), ageMillis)
|
||||
private fun image(docId: String, ageMillis: Long = 0) = file(File(filesDir, "pdf_images/$docId/seite_01.jpg"), ageMillis)
|
||||
private fun capture(name: String, ageMillis: Long) = file(File(cacheDir, "camera/$name"), ageMillis)
|
||||
|
||||
// --- Löschen mit der Dokumentation ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun deleteExportsFor_entfernt_pdf_und_bildexporte_der_dokumentation() = runBlocking {
|
||||
val p = pdf("doc-1")
|
||||
val i = image("doc-1")
|
||||
|
||||
cleaner().deleteExportsFor("doc-1")
|
||||
|
||||
assertFalse(p.exists())
|
||||
assertFalse(i.exists())
|
||||
assertFalse(File(filesDir, "pdf/doc-1").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteExportsFor_laesst_andere_dokumentationen_unberuehrt() = runBlocking {
|
||||
val other = pdf("doc-2")
|
||||
|
||||
cleaner().deleteExportsFor("doc-1")
|
||||
|
||||
assertTrue(other.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteExportsFor_ohne_vorhandene_ordner_wirft_nicht() = runBlocking {
|
||||
cleaner().deleteExportsFor("gibt-es-nicht")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteExportsFor_loescht_nie_ausserhalb_der_exportordner() = runBlocking {
|
||||
val secret = file(File(filesDir, "secret/patientendaten.db"))
|
||||
val photos = file(File(filesDir, "photos/doc-1/foto.jpg"))
|
||||
File(filesDir, "pdf").mkdirs()
|
||||
|
||||
cleaner().deleteExportsFor("../secret")
|
||||
cleaner().deleteExportsFor("..")
|
||||
cleaner().deleteExportsFor("../photos/doc-1")
|
||||
|
||||
assertTrue(secret.exists())
|
||||
assertTrue(photos.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteExportsFor_beruehrt_fotos_und_signaturen_der_dokumentation_nicht() = runBlocking {
|
||||
val photo = file(File(filesDir, "photos/doc-1/foto.jpg"))
|
||||
val signature = file(File(filesDir, "signatures/doc-1/sig.png"))
|
||||
pdf("doc-1")
|
||||
|
||||
cleaner().deleteExportsFor("doc-1")
|
||||
|
||||
assertTrue("Löschen der Exporte ist nicht Löschen der Dokumentation", photo.exists() && signature.exists())
|
||||
}
|
||||
|
||||
// --- Aufbewahrungsfristen ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun deleteStaleFiles_entfernt_exporte_aelter_als_7_tage_und_behaelt_frische() = runBlocking {
|
||||
val old = pdf("doc-old", ageMillis = 8 * day)
|
||||
val oldImage = image("doc-old", ageMillis = 8 * day)
|
||||
val fresh = pdf("doc-fresh", ageMillis = 1 * day)
|
||||
|
||||
cleaner().deleteStaleFiles()
|
||||
|
||||
assertFalse(old.exists())
|
||||
assertFalse(oldImage.exists())
|
||||
assertTrue(fresh.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteStaleFiles_entfernt_leere_unterordner() = runBlocking {
|
||||
pdf("doc-old", ageMillis = 30 * day)
|
||||
|
||||
cleaner().deleteStaleFiles()
|
||||
|
||||
assertFalse(File(filesDir, "pdf/doc-old").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteStaleFiles_entfernt_liegengebliebene_kameraaufnahmen_nach_24_stunden() = runBlocking {
|
||||
val stale = capture("capture_old.jpg", ageMillis = day + 60_000)
|
||||
val recent = capture("capture_new.jpg", ageMillis = 2 * 60 * 60 * 1000)
|
||||
|
||||
cleaner().deleteStaleFiles()
|
||||
|
||||
assertFalse("Wundfoto darf nicht dauerhaft im Cache liegen bleiben", stale.exists())
|
||||
assertTrue("aktuelle Aufnahme wird eventuell noch importiert", recent.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteStaleFiles_beruehrt_dokumentationsdaten_nie() = runBlocking {
|
||||
val photo = file(File(filesDir, "photos/doc-1/foto.jpg"), ageMillis = 365 * day)
|
||||
val signature = file(File(filesDir, "signatures/doc-1/sig.png"), ageMillis = 365 * day)
|
||||
|
||||
cleaner().deleteStaleFiles()
|
||||
|
||||
assertTrue("Dokumentationsfotos/-signaturen sind keine Exporte", photo.exists() && signature.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteStaleFiles_ohne_vorhandene_ordner_wirft_nicht() = runBlocking {
|
||||
cleaner().deleteStaleFiles()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun none_cleaner_tut_nichts() = runBlocking {
|
||||
val p = pdf("doc-1", ageMillis = 100 * day)
|
||||
|
||||
ExportedFilesCleaner.None.deleteExportsFor("doc-1")
|
||||
ExportedFilesCleaner.None.deleteStaleFiles()
|
||||
|
||||
assertTrue(p.exists())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package de.medapp.wunddoku.privacy
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
/**
|
||||
* Datenschutz-Regressionstests (QA-006) auf Quelltext- und Konfigurationsebene.
|
||||
*
|
||||
* Läuft als reiner JVM-Test ohne Gerät und liest die Projektdateien direkt. Er soll verhindern,
|
||||
* dass später versehentlich etwas eingebaut wird, das die Datenschutz-Zusagen der App bricht
|
||||
* (Netzwerkzugriff, sensible Logs, öffentlicher Speicher, Backup, zu weite Freigaben).
|
||||
* Schlägt ein Test fehl, ist das ein Hinweis zum Prüfen - nicht zwingend ein Fehler: dann
|
||||
* bewusst entscheiden und die Datenschutz-Dokumentation anpassen.
|
||||
*/
|
||||
class PrivacySourceScanTest {
|
||||
|
||||
private lateinit var mainDir: File
|
||||
|
||||
@Before
|
||||
fun locateSources() {
|
||||
mainDir = listOf(File("src/main"), File("app/src/main"))
|
||||
.firstOrNull { File(it, "AndroidManifest.xml").exists() }
|
||||
?: File("")
|
||||
assumeTrue("Quellverzeichnis nicht gefunden (Arbeitsverzeichnis: ${File(".").absolutePath})", mainDir.path.isNotEmpty())
|
||||
}
|
||||
|
||||
// --- Hilfsfunktionen -----------------------------------------------------------------
|
||||
|
||||
private fun kotlinSources(): List<Pair<String, String>> =
|
||||
File(mainDir, "java").walkTopDown()
|
||||
.filter { it.isFile && it.extension == "kt" }
|
||||
.map { it.relativeTo(mainDir).path to stripComments(it.readText()) }
|
||||
.toList()
|
||||
|
||||
/** Entfernt Kommentare, damit Dokumentationstext keine Fehlalarme auslöst. */
|
||||
private fun stripComments(source: String): String =
|
||||
source
|
||||
.replace(Regex("/\\*[\\s\\S]*?\\*/"), "")
|
||||
.replace(Regex("//[^\\n]*"), "")
|
||||
|
||||
private fun findings(pattern: Regex, sources: List<Pair<String, String>> = kotlinSources()): List<String> =
|
||||
sources.flatMap { (path, text) ->
|
||||
pattern.findAll(text).map { "$path: ${it.value}" }.toList()
|
||||
}
|
||||
|
||||
private fun assertNone(description: String, pattern: Regex) {
|
||||
val found = findings(pattern)
|
||||
assertTrue("$description - gefunden: $found", found.isEmpty())
|
||||
}
|
||||
|
||||
private fun parseXml(relativePath: String) =
|
||||
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(File(mainDir, relativePath))
|
||||
|
||||
// --- Logging -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun keine_konsolenausgaben_oder_stacktrace_ausgaben() {
|
||||
assertNone(
|
||||
"println/printStackTrace/System.out/Timber können Patientendaten in Logs schreiben",
|
||||
Regex("""\bprintln\s*\(|printStackTrace\s*\(|System\.(out|err)\b|\bTimber\b"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun log_aufrufe_enthalten_keine_nachrichten_pfade_uris_oder_personendaten() {
|
||||
val callStart = Regex("""\bLog\s*\.\s*(v|d|i|w|e|wtf)\s*\(""")
|
||||
val forbidden = Regex(
|
||||
"""(?i)\.message\b|localizedMessage|stackTraceToString|getStackTraceString|\buri\b|\bpath\b|absolutePath|fileName|patient|firstName|lastName|birthDate|signer|street|phone"""
|
||||
)
|
||||
val violations = mutableListOf<String>()
|
||||
|
||||
for ((path, text) in kotlinSources()) {
|
||||
for (match in callStart.findAll(text)) {
|
||||
val openIndex = match.range.last
|
||||
var depth = 0
|
||||
var end = openIndex
|
||||
for (i in openIndex until text.length) {
|
||||
when (text[i]) {
|
||||
'(' -> depth++
|
||||
')' -> { depth--; if (depth == 0) { end = i; break } }
|
||||
}
|
||||
}
|
||||
val call = text.substring(match.range.first, end + 1)
|
||||
if (forbidden.containsMatchIn(call)) violations += "$path: ${call.replace(Regex("\\s+"), " ")}"
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue("Log-Aufrufe mit potenziell sensiblem Inhalt: $violations", violations.isEmpty())
|
||||
}
|
||||
|
||||
// --- Speicherorte --------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun kein_zugriff_auf_oeffentlichen_oder_externen_speicher() {
|
||||
assertNone(
|
||||
"Patientendaten dürfen nur im privaten internen Speicher liegen",
|
||||
Regex(
|
||||
"""getExternalFilesDir|getExternalFilesDirs|externalCacheDir|externalMediaDirs|getExternalStorage|Environment\.getExternal|MediaStore\.|WRITE_EXTERNAL_STORAGE|READ_EXTERNAL_STORAGE|MANAGE_EXTERNAL_STORAGE|MODE_WORLD_READABLE|MODE_WORLD_WRITEABLE|DIRECTORY_(PICTURES|DOWNLOADS|DOCUMENTS)"""
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keine_nutzung_der_zwischenablage() {
|
||||
assertNone(
|
||||
"Die Zwischenablage ist für andere Apps lesbar",
|
||||
Regex("""ClipboardManager|setPrimaryClip|ClipboardManager"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keine_personendaten_als_text_oder_betreff_in_share_intents() {
|
||||
assertNone(
|
||||
"Share-Intents dürfen nur die PDF/Bilder (EXTRA_STREAM), keinen Text mit Patientendaten tragen",
|
||||
Regex("""EXTRA_TEXT|EXTRA_SUBJECT|EXTRA_EMAIL|EXTRA_CC|EXTRA_BCC|EXTRA_HTML_TEXT"""),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Netzwerk ------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun kein_netzwerkcode_im_quelltext() {
|
||||
assertNone(
|
||||
"Die App arbeitet offline - kein Netzwerkzugriff",
|
||||
Regex("""HttpURLConnection|HttpsURLConnection|java\.net\.URL\b|URLConnection|\bSocket\s*\(|okhttp|retrofit|\bktor\b|WebView|firebase|crashlytics|analytics""", RegexOption.IGNORE_CASE),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keine_netzwerk_bibliotheken_in_den_abhaengigkeiten() {
|
||||
val gradle = listOf(File("app/build.gradle.kts"), File("build.gradle.kts")).firstOrNull { it.exists() }
|
||||
assumeTrue("build.gradle.kts nicht gefunden", gradle != null)
|
||||
val dependencies = gradle!!.readLines()
|
||||
.filter { it.trim().startsWith("implementation") || it.trim().startsWith("api(") }
|
||||
val forbidden = Regex("""okhttp|retrofit|\bktor\b|volley|firebase|crashlytics|analytics|sentry|coil|glide|picasso|play-services""", RegexOption.IGNORE_CASE)
|
||||
val found = dependencies.filter { forbidden.containsMatchIn(it) }
|
||||
assertTrue("Abhängigkeiten mit Netzwerk-/Tracking-Bezug: $found", found.isEmpty())
|
||||
}
|
||||
|
||||
// --- Manifest ------------------------------------------------------------------------
|
||||
|
||||
private fun manifest(): String = File(mainDir, "AndroidManifest.xml").readText()
|
||||
|
||||
@Test
|
||||
fun manifest_fordert_keine_berechtigungen_an() {
|
||||
val permissions = Regex("""<uses-permission[^>]*>""").findAll(manifest()).map { it.value }.toList()
|
||||
val requested = permissions.filterNot { it.contains("tools:node=\"remove\"") }
|
||||
assertTrue("Unnötige Berechtigungen im Manifest: $requested", requested.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manifest_entfernt_die_internet_berechtigung_explizit() {
|
||||
val internet = Regex("""<uses-permission[^>]*android\.permission\.INTERNET[^>]*>""").find(manifest())
|
||||
assertTrue("INTERNET muss per tools:node=\"remove\" ausgeschlossen sein", internet != null && internet.value.contains("tools:node=\"remove\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manifest_deaktiviert_backup_und_verlangt_keine_cleartext_oder_debug_freigaben() {
|
||||
val text = manifest()
|
||||
assertTrue("allowBackup muss false sein", text.contains("""android:allowBackup="false""""))
|
||||
assertTrue("dataExtractionRules müssen gesetzt sein", text.contains("android:dataExtractionRules="))
|
||||
assertTrue(!text.contains("""android:usesCleartextTraffic="true""""))
|
||||
assertTrue(!text.contains("android:debuggable"))
|
||||
assertTrue(!text.contains("""android:allowBackup="true""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nur_die_start_activity_ist_exportiert_und_der_fileprovider_nicht() {
|
||||
val text = manifest()
|
||||
assertEquals(
|
||||
"Genau eine Komponente (MainActivity) darf exportiert sein",
|
||||
1,
|
||||
Regex("""android:exported="true"""").findAll(text).count(),
|
||||
)
|
||||
val provider = Regex("""<provider[\s\S]*?</provider>""").find(text)!!.value
|
||||
assertTrue(provider.contains("""android:exported="false""""))
|
||||
assertTrue(provider.contains("""android:grantUriPermissions="true""""))
|
||||
}
|
||||
|
||||
// --- Backup-Regeln -------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun data_extraction_rules_schliessen_alle_daten_aus_cloud_backup_und_geraetetransfer_aus() {
|
||||
val doc = parseXml("res/xml/data_extraction_rules.xml")
|
||||
for (section in listOf("cloud-backup", "device-transfer")) {
|
||||
val nodes = doc.getElementsByTagName(section)
|
||||
assertEquals("<$section> muss vorhanden sein", 1, nodes.length)
|
||||
val element = nodes.item(0) as org.w3c.dom.Element
|
||||
assertEquals("<$section> darf kein <include> enthalten", 0, element.getElementsByTagName("include").length)
|
||||
val excluded = (0 until element.getElementsByTagName("exclude").length)
|
||||
.map { (element.getElementsByTagName("exclude").item(it) as org.w3c.dom.Element).getAttribute("domain") }
|
||||
.toSet()
|
||||
assertTrue("<$section> muss database, file, sharedpref ausschließen, war $excluded", excluded.containsAll(setOf("database", "file", "sharedpref")))
|
||||
}
|
||||
}
|
||||
|
||||
// --- FileProvider-Freigaben ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun fileprovider_gibt_nur_export_und_kameraordner_frei() {
|
||||
val doc = parseXml("res/xml/file_paths.xml")
|
||||
val paths = doc.getElementsByTagName("paths").item(0).childNodes
|
||||
val entries = (0 until paths.length).map { paths.item(it) }
|
||||
.filterIsInstance<org.w3c.dom.Element>()
|
||||
.map { it.tagName to it.getAttribute("path") }
|
||||
|
||||
val allowed = setOf("files-path" to "pdf/", "files-path" to "pdf_images/", "cache-path" to "camera/")
|
||||
val unexpected = entries.filterNot { it in allowed }
|
||||
assertTrue(
|
||||
"Zu weite FileProvider-Freigaben (nur $allowed erlaubt): $unexpected",
|
||||
unexpected.isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user