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:
NOrtmann1
2026-10-15 17:40:35 +02:00
parent 742ef39f05
commit 58430a0903
18 changed files with 1207 additions and 11 deletions
@@ -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. */
+8 -1
View File
@@ -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) {
@@ -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}",
)
}
}
+4 -3
View File
@@ -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(),
)
}
}