From 7cfd0b5e3b137de49ff10ab1221c54cdfd51c438 Mon Sep 17 00:00:00 2001 From: Enrico Wilhelm Date: Thu, 9 Jul 2026 14:38:51 +0200 Subject: [PATCH 1/2] Add evidence storage restore drill phase two --- CHANGELOG.md | 6 + docs/ISCY_Handbuch.md | 65 +- docs/ISCY_Handbuch.pdf | Bin 81852 -> 86058 bytes docs/ISCY_STRATEGIC_ROADMAP.md | 14 +- .../src/evidence_artifact_storage.rs | 342 ++++++++ rust/iscy-backend/src/evidence_store.rs | 15 + rust/iscy-backend/src/lib.rs | 790 +++++++++++++++--- rust/iscy-backend/tests/http_tests.rs | 205 +++++ 8 files changed, 1296 insertions(+), 141 deletions(-) create mode 100644 rust/iscy-backend/src/evidence_artifact_storage.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c50528..4cb4e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The project uses release tags for immutable release points. Changes under **Unre ### Added +- add Evidence Object Storage & Restore Drill Phase 2 with an internal local-filesystem artifact storage abstraction +- add tenant-scoped Evidence storage overview/detail APIs, bounded storage drill APIs, and storage event filtering without introducing production S3 or cloud credentials +- extend `/evidence/integrity/` with local storage metadata and administrator/editor Storage-Drill actions - add Evidence Integrity & Disposition Phase 1 through additive migration `0033_rust_evidence_integrity_disposition` - add tenant-scoped Evidence integrity overview, manual and bounded batch SHA-256 re-check APIs, Legal Hold metadata, disposition decisions, and integrity/disposition audit events - add Web UI support under `/evidence/integrity/` for safe Evidence integrity metadata, re-hash actions, Legal Hold set/release, and metadata-only disposition decisions @@ -31,6 +34,9 @@ The project uses release tags for immutable release points. Changes under **Unre ### Security +- route Evidence artifact checks through a canonical media-root-contained filesystem backend that blocks traversal, absolute-path references, and symlink escapes +- audit Evidence storage drills, artifact presence, unreadable/missing artifacts, hash matches, hash mismatches, drill failures, and drill completion without exposing absolute paths, raw payloads, SQL details, or secrets +- reuse the existing Evidence Integrity metadata and audit table for storage drills, avoiding a new Evidence engine or destructive migration - keep Evidence re-hash, Legal Hold, and disposition writes tenant-scoped and restricted to administrator/editor roles while allowing read-only roles to inspect safe metadata - audit Evidence integrity checks, hash mismatches, missing artifacts, Legal Hold changes, and disposition decisions without storing raw file payloads, secret values, authorization material, SQL details, or absolute media paths - make `disposition_completed_metadata_only` an explicit governance decision state; this release does not physically delete Evidence files or implement data destruction diff --git a/docs/ISCY_Handbuch.md b/docs/ISCY_Handbuch.md index c055c22..69ad388 100644 --- a/docs/ISCY_Handbuch.md +++ b/docs/ISCY_Handbuch.md @@ -456,6 +456,9 @@ Typische Funktionen: - Legal Hold mit Begruendung setzen oder freigeben - Disposition-/Retention-Entscheidungen als Governance-Metadaten dokumentieren - Integritaets-, Legal-Hold- und Disposition-Ereignisse auditierbar nachvollziehen +- lokale Evidence-Artefakte ueber eine interne Storage-Abstraktion pruefen +- Storage-/Restore-Drills fuer Vorhandensein, Lesbarkeit und SHA-256-Konsistenz ausloesen +- sichere Storage-Fehlerklassen sehen, ohne absolute Dateipfade oder Rohpayloads offenzulegen Fachlicher Nutzen: @@ -466,6 +469,7 @@ Fachlicher Nutzen: - belastbarere Aussage, ob Nachweise nur vorhanden oder wirklich reviewt und verwertbar sind - nachvollziehbare Integritaet und Lifecycle-Steuerung ohne pauschal fest codierte gesetzliche Aufbewahrungsfrist - klare Trennung zwischen Nachweisqualitaet, Integritaetspruefung, Legal Hold und metadata-only Disposition +- belastbarer Restore-/Integrity-Nachweis fuer lokal referenzierte Artefakte, ohne produktives Cloud-Storage vorauszusetzen Fuer Nicht-Sicherheitsleute: Evidence ist der Ordner mit den Belegen, aber strukturiert und auswertbar. @@ -1232,6 +1236,63 @@ Objektspeicher/S3, kontrollierte physische Loeschung, technische Datenvernichtung, eine neue Evidence-, Risk-, Control- oder Notification-Engine sowie Plattform-, Docker-, Postgres-, nginx-, Nix- oder Dependency-Upgrades. +### 6.13 Evidence Object Storage & Restore Drill Phase 2 + +Phase 2 fuehrt eine interne Storage-Abstraktion fuer Evidence-Artefakte ein, +ohne produktives S3, Cloud-Credentials oder ein neues Speichersystem +einzufuehren. Das erste Backend ist bewusst nur `local_filesystem` und nutzt +weiter den bestehenden Media Root. Bestehende Uploads und gespeicherte +Evidence-Pfade bleiben kompatibel. + +Die Storage-Schicht kapselt: + +- sichere Artefaktreferenzen +- canonical path containment unterhalb des Media Root +- Blockade von absoluten Pfaden, Directory Traversal und Symlink-Flucht +- sichere Metadaten wie Backend, Referenz vorhanden, Artefakt vorhanden, + lesbar, leer und Groesse +- serverseitige SHA-256-Berechnung fuer Drills +- sichere Fehlerklassen ohne absolute Pfade + +Die API ergaenzt: + +- `GET /api/v1/evidence/storage` +- `GET /api/v1/evidence/{evidence_id}/storage` +- `POST /api/v1/evidence/{evidence_id}/storage-drill` +- `POST /api/v1/evidence/storage-drills` +- `GET /api/v1/evidence/{evidence_id}/storage-events` + +Der Storage-/Restore-Drill prueft, ob der Evidence-Datensatz tenantgebunden +existiert, ob eine Artefaktreferenz vorhanden ist, ob das lokale Artefakt sicher +unterhalb des Media Root erreichbar und lesbar ist und ob der serverseitig +berechnete SHA-256 mit dem gespeicherten Evidence-Hash uebereinstimmt. +Fehlende Artefakte, unsichere Referenzen, fehlende erwartete Hashes und +Hash-Mismatches werden mit sicheren Fehlerklassen klassifiziert. + +Die Webansicht `/evidence/integrity/` zeigt Storage-Backend, Artefaktstatus, +sichere Fehlerklasse, letzten Drill-/Integritaetszeitpunkt und den vorhandenen +Integrity-, Legal-Hold- und Disposition-Kontext. Admin- und Editor-Rollen +duerfen Storage-Drills ausloesen. Read-only-Rollen sehen nur sichere +Metadaten. + +Auditereignisse nutzen die bestehende Evidence-Integrity-Ereignistabelle und +werden als `storage_*` Events geschrieben, darunter Start, Artefakt gefunden, +Artefakt fehlt, Artefakt nicht lesbar, Hash gueltig, Hash-Mismatch, Drill +fehlgeschlagen und Drill abgeschlossen. Auditdetails enthalten keine Secrets, +Tokens, Authorization Header, Rohpayloads, vertraulichen Dateiinhalte, +SQL-Details, fremden Tenant-IDs oder absoluten Dateipfade. + +Fuer Phase 2 ist keine neue Datenbankmigration noetig: Die bestehenden +Integrity-Felder aus Migration `0033_rust_evidence_integrity_disposition` +speichern letzten Pruefzeitpunkt, Status, berechneten Hash und sichere +Fehlerklasse bereits ausreichend. Storage-Events werden in der bestehenden +Ereignistabelle auditierbar gefuehrt. + +Bewusst nicht umgesetzt sind produktives S3/Object Storage, Cloud-Credentials, +physische Loeschung, technische Datenvernichtung, Backup-Restore aus externem +Storage, komplexer Scheduler, neue Evidence-/Risk-/Control-/Notification- +Engines sowie Docker-, Postgres-, nginx-, Nix- oder Dependency-Upgrades. + ## 7. Was die wichtigsten Begriffe bedeuten - ISO 27001: internationaler Standard fuer Informationssicherheits-Managementsysteme @@ -1272,12 +1333,12 @@ ISCY strukturiert, dokumentiert, priorisiert und verbindet. Entscheidungen muess ## 10. Strategische Weiterentwicklung -Die Rust-Migration ist abgeschlossen. Mit V23.7.19 ist das regulatorische Organisationsprofil als erster strategischer Baustein umgesetzt; V23.7.20 ergaenzt Management-Review- und Audit-Pakete als steuerbaren Review-Workflow; V23.7.21 liefert Exporte, Snapshot-Ruecklinks und Evidence-Qualitaet; V23.7.22 setzt Third-Party-/Supplier-Risk als eigenes Rust-Web-/API-Modul um; V23.7.23 baut Product Security um VEX, SBOM-Diff und CRA-Readiness aus; V23.7.24 fuegt AI Governance hinzu; V23.7.25 schliesst Agent-Policy-Profile, erwartete Flottenabdeckung und aktive Policy-Webhooks an; V23.7.26 ergaenzt versionierte Product-Security-Evidence-Pakete. Migration `0027_rust_ai_governance_links` verbindet AI-Systeme tenantgebunden mit Risiken, Roadmap-Tasks, Incidents und Changes. Migration `0028_rust_guided_agent_onboarding` ergaenzt den gefuehrten, tenantgebundenen Agent-Rollout mit Token-Lifecycle, Policy-Zuordnung und Auditspur. Migration `0029_rust_cross_domain_notifications` fuehrt Evidence-, CVE-, Incident- und Roadmap-Signale in denselben sicheren Kanalbetrieb. Migration `0031_rust_supplier_review_workflow` ergaenzt Supplier-Reviews mit Freigabehistorie, Subprocessors, Vertragslaufzeiten, Exit-Test-Nachweisen und tenantgesicherten Evidence-/Control-/Risk-Links. Migration `0032_rust_management_regulatory_templates` ergaenzt Management-/Regulatory-Templates fuer ISO 27001, NIS2, DORA, KRITIS und generische Security-Governance-Reviews. Migration `0033_rust_evidence_integrity_disposition` ergaenzt Evidence Integrity & Disposition Phase 1 mit manueller und begrenzter Batch-Re-Hash-Pruefung, Legal-Hold-Metadaten, metadata-only Disposition und auditierbaren Integritaetsereignissen. Die weitere ISCY-Agenda konzentriert sich deshalb nicht mehr auf Abloesung alter Python-/Django-Pfade, sondern auf fachliche Produktreife. +Die Rust-Migration ist abgeschlossen. Mit V23.7.19 ist das regulatorische Organisationsprofil als erster strategischer Baustein umgesetzt; V23.7.20 ergaenzt Management-Review- und Audit-Pakete als steuerbaren Review-Workflow; V23.7.21 liefert Exporte, Snapshot-Ruecklinks und Evidence-Qualitaet; V23.7.22 setzt Third-Party-/Supplier-Risk als eigenes Rust-Web-/API-Modul um; V23.7.23 baut Product Security um VEX, SBOM-Diff und CRA-Readiness aus; V23.7.24 fuegt AI Governance hinzu; V23.7.25 schliesst Agent-Policy-Profile, erwartete Flottenabdeckung und aktive Policy-Webhooks an; V23.7.26 ergaenzt versionierte Product-Security-Evidence-Pakete. Migration `0027_rust_ai_governance_links` verbindet AI-Systeme tenantgebunden mit Risiken, Roadmap-Tasks, Incidents und Changes. Migration `0028_rust_guided_agent_onboarding` ergaenzt den gefuehrten, tenantgebundenen Agent-Rollout mit Token-Lifecycle, Policy-Zuordnung und Auditspur. Migration `0029_rust_cross_domain_notifications` fuehrt Evidence-, CVE-, Incident- und Roadmap-Signale in denselben sicheren Kanalbetrieb. Migration `0031_rust_supplier_review_workflow` ergaenzt Supplier-Reviews mit Freigabehistorie, Subprocessors, Vertragslaufzeiten, Exit-Test-Nachweisen und tenantgesicherten Evidence-/Control-/Risk-Links. Migration `0032_rust_management_regulatory_templates` ergaenzt Management-/Regulatory-Templates fuer ISO 27001, NIS2, DORA, KRITIS und generische Security-Governance-Reviews. Migration `0033_rust_evidence_integrity_disposition` ergaenzt Evidence Integrity & Disposition Phase 1 mit manueller und begrenzter Batch-Re-Hash-Pruefung, Legal-Hold-Metadaten, metadata-only Disposition und auditierbaren Integritaetsereignissen. Evidence Object Storage & Restore Drill Phase 2 nutzt diese bestehenden Metadaten fuer eine interne lokale Storage-Abstraktion, sichere Artefaktreferenzen und tenantgebundene Restore-/Integritaetsdrills ohne neues Speichersystem. Die weitere ISCY-Agenda konzentriert sich deshalb nicht mehr auf Abloesung alter Python-/Django-Pfade, sondern auf fachliche Produktreife. Die priorisierte Roadmap liegt in `docs/ISCY_STRATEGIC_ROADMAP.md` und umfasst: 1. Feinere Management-/Regulatory-Template-Varianten und kontextsensitive Pruefpakete -2. Evidence-Integrity-Worker, kontrollierte physische Disposition und optionales Objektspeicher-Backend +2. Evidence-Integrity-Worker, kontrollierte physische Disposition und spaeteres produktives Objektspeicher-Backend 3. Signierte Agent-Pakete sowie eine spaetere getrennte CA-/PKI-Stufe 4. Performance-, HA- und visuelle Regressionstests diff --git a/docs/ISCY_Handbuch.pdf b/docs/ISCY_Handbuch.pdf index b62a35d89f235f603d864b41077c1f35be42e307..8e8c516c0255dd99e010f553fb312bd433d1935c 100644 GIT binary patch delta 4863 zcma)ATZ|k>6;-Vtk=MI^?%IwYq^x*t$DZDu?&*2i#`v)|Hh#qM8Ui>*?wP5b=}d3+ zs=H_H-GmJJKsZ0hPTOu-L5lf7C=!8a5KAOJNJ2gk0txbOIUf-56)QnPAc8qn{g|0{ z5X{ev>T&DVz2}^JYku``=^J;KUH^C;)k`!>&tIYwN9`8(sFr){pGUv4Bv;J&+js8v z_m8jaxTnqzwH0dFO~YdJ*qm8oyvmfrj>o2rrpIWDRhUDW$r&}d#~gf5dA4KJv8dvD z4i=dAH2CH7w>dEyKlX>aqso8EQA+xKVL!tA$HV8Xhxer;Qq;b`9>Fw;Qv@yk#3Tm)g_Q z%s)`w;y*CPI`4g5A3%624Awk&DAq>TnK+4X)E(2CSI!#M#ua8pcayQC4#MHUag!mG zG>K>A$*g17T8P{nbCZxAwd__+iGoY#5aX7+&~n*;x8T23DErT@8t!NpwH5yS^-+Hl z^OvgSd|5=muh)LypWK`6ET4XRxtu-3Zw{1I|MCZWJMUU1>HM3&Gb|^@Ub?wN&&#-V z-hJ^bS&l>zpWY$?hKGO1~hra#cPDDC5zS)^5GgW zJ{o*yl-wF9s^XxeT&Ef?Rs)w8A(gt@)o?r9UvZhKyfZMVhwmEexG4A(*UDvZ=4=U#*fgi&W8LF{H`;1 zmPy)rEU}kd|C}Ud#bRa!_L}z0tl{CO|DieJAG)@5L##hj)dp;Mv}w$`%<^A;xS<_u zLCm+9g?-9YHMq@9T#v$VgH~2?FHKolL}84_sLA)Jxg9)Vz08@$Eq zp-6{<-Sss1cr6(nKY;xVEBC>Z{mmp96R z;QD4VygVEYZfqeF!_f+*CTc`rZzkJU^$ofGdxzplHiv?6WBZ7I?*7vDR8*JxD^6IN zJP5Bys*})J5k-KzhPOcRZE&y7Du`AOQrK0HLg=^LD@$M+SKS0qv+YKQfLl>LjhE9f z$4Myv8_WU8Qq{adx^;kYs>60a5_pTok2ORfxEqQJe)D6P|?-eTo*@cxQ{Y6lO5X%GlbCkDlka>jIL z3=j3%rB|3E?39bjuQMPMe~J28XwiO~JlRWam?Hnj_`vvcdHXujO|2GI#*I zw!s6a7EY3IR+6%9Fwb2817=&i8Fn4QorGjPIOrC7(Wul7OF3a%HAQA<(sXBS7d*1L z=vF*-6|#4@HiNE4&L0c@x`hnwNup3g_k%tuE+TPLlyo=a!JZMaWo;aiWM5#9kgdV! zlVnX>s4y(lAQD~O?Pj&>?J;1f&VgYA{R_Ig-fTqC7xg0I5-dOgT>+MA!!gnJA-6Td zkywSl$gB)?TQ-JRHZ9rG4R-=|0f=yIxD;9om5yaeqORUz7PKcLfs&vC;wku_Tv`~C z?pRO{9>&{Qj}_3}aNHI>-Z%|LcaX6a$-ZF!4zg{T?COGr9b`u_nVEX#v@!{uH7ysw zb=Zu^`2|UT<>aKSolaNOgL^y37hb)%>|1d;7ex|HDq2ZVT4fu_D+X`Qm~{s=3I&1N z4ENfHD$tvqZeQHZ&T(c5IYQ=x4I4?R9alu20QQ{dsS}81L=FVLk@cAAT*glZCgMl@iLi7ZXanx zMs^PtyNU~;TVS;oUIv*kc}cr5ao%*B3Z6*Zb8JhQIBRoth9qtgWWy+sn4Dooud}H1AOh|5D=ibtN+Rmr{z!tTzYun9Kl0`g;wfu2C?j50 zQKuO-bz#dmjy4l42-`$PPfi{EhH@A@t{KP*hlF`h8K>I`1)t{0UP6Oi1+sTExz@Sx zr3BdGS@!badeH#}a|QC*dUqCHfC?@ueDs=He4$9@3EAzx{)fkd`(^T(Z0t!XczKlY zU1jkYrUwrW!UwzZ{Gcud#E%09=)`Hp>u3hGqI^;<-rq;okb!(Q_+&ph+Qu_ld?+83 z-`Q*~_sEc5&{L~&Wlc*B=~?k4@3U1XscL$toGbPYwXvaGPz$|dVyLL6j#Sk`KHI-a zRg1;ck*Z$GrZ?*4T*_Cqtf~l*__X7Tt7T2^^DLXyv_dhpQ`1U_mE0L`)Cy|aF|DAb z9n(r$DLOLQSkl#EyWdz&%a!u!p*&t{eMjbsS?!S_HJ^&Lu4-z^eO=Az{qDE3x?U`$ z!msOPH5J8t7NPC;BA?ZYsf~GE%|0@u<S~?(${8v)_I}E94#-Di%^nDim_X zRKyBEN-8`>RV_U-lmj{i{<`Qd=JRQ1i}^w!6~$t?oJ~0fRP^IIn=Rqbh}4lK9eyO2 s#2-wi6R#N?~=yze<-R#-+V6xJf60wE1UX2K?@7EMIEHr5%P zML|}JC?bm!U4m$l1ud)=*0L6nSVTlGTSU~oGm6gfZVvbR|Nr~G|8Z+H|Ixp^p|3>; zU{h=Scq{=4F#xo-1EL$N>P@_(yI6OkH9EGwTwmCm3SviTR8_0v9YtoUn+5crLycx~ z|E5Ac)ptdI94*&1eL9a|!DiD6_3hPb&HcjK8%qVH^oi=t*wM#feKJ{U zb|tsWMo*`LoYG7*b>?@(9K3Kj8yOwaK`OCss%kKQFMbZ|GwoHnG+nIUUwz;a-wZM} zFFPTkI0FVH{cSxsr2@V?|hwGlU4?WDz8o%ymJybS(QsAY{29k`T*2k(A?PE*T|6%{rSi zMhUZBPzNEaCF)>m@iXi#GnbxWY;8A*7!!_VmN9AbbB{~pl5ym%#Db_2)q_i Hvn})=BqD?S diff --git a/docs/ISCY_STRATEGIC_ROADMAP.md b/docs/ISCY_STRATEGIC_ROADMAP.md index 9f6d6dd..25b773f 100644 --- a/docs/ISCY_STRATEGIC_ROADMAP.md +++ b/docs/ISCY_STRATEGIC_ROADMAP.md @@ -74,7 +74,7 @@ Erfolgskriterium: Ziel: Evidence soll nicht nur vorhanden sein, sondern belastbar bewertet werden. -Status: In V23.7.21 als Evidence-Quality-API und Webansicht umgesetzt; am 2026-06-27 um den persistierten Evidence-Lifecycle erweitert. Im Unreleased-Stand kommt Evidence Integrity & Disposition Phase 1 hinzu. +Status: In V23.7.21 als Evidence-Quality-API und Webansicht umgesetzt; am 2026-06-27 um den persistierten Evidence-Lifecycle erweitert. Im Unreleased-Stand kommen Evidence Integrity & Disposition Phase 1 sowie Evidence Object Storage & Restore Drill Phase 2 hinzu. Umgesetzt: @@ -95,12 +95,17 @@ Umgesetzt: - Serverseitige SHA-256-Neuberechnung vorhandener Evidence-Artefakte mit Status `valid`, `mismatch`, `missing_artifact` oder `check_failed`. - Legal Hold kann Disposition-Entscheidungen blockieren und wird mit Begruendung, Akteurreferenz und Zeitpunkt auditierbar gefuehrt. - Disposition bleibt in Phase 1 ein Governance-/Audit-Metadatenmodell; `disposition_completed_metadata_only` loescht keine Dateien. +- Interne Storage-Abstraktion fuer Evidence-Artefakte mit erstem Backend `local_filesystem`. +- Storage-API-Pfade fuer Uebersicht, Detail, einzelne Drills, begrenzte Batch-Drills und Storage-Events. +- Die bestehende Weboberflaeche `/evidence/integrity/` zeigt lokale Storage-Metadaten und bietet fuer Admin/Editor einen Storage-/Restore-Drill an. +- Filesystem-Backend prueft Artefaktreferenzen ueber canonical path containment, blockiert Traversal, absolute Pfade und Symlink-Flucht aus dem Media Root und gibt nur sichere Fehlerklassen zurueck. +- Restore-/Integrity-Drill belegt, dass referenzierte Artefakte am erwarteten lokalen Storage-Ort vorhanden, lesbar und SHA-256-konsistent sind; Ergebnisse werden in den bestehenden Evidence-Integrity-Feldern und `storage_*` Audit-Events dokumentiert. Naechste Vertiefung: - Periodischen Re-Hash-Worker mit Betriebssignalen und Queue-Steuerung ergaenzen. - Kontrollierte physische Loeschung/Datenvernichtung als getrennten, spaeteren Meilenstein entwerfen. -- Optionales S3-/Objektspeicher-Backend inklusive Restore- und Integritaetsdrill. +- Optionales produktives S3-/Objektspeicher-Backend inklusive Restore- und Integritaetsdrill als spaeteren, separaten Meilenstein pruefen. Erfolgskriterium: @@ -231,7 +236,7 @@ Erfolgskriterium: ## Empfohlene Umsetzungsreihenfolge 1. Feinere Template-Varianten und kontextsensitive NIS2-/DORA-/DSGVO-Pruefpakete auf Basis der neuen Management-/Regulatory-Template-Schicht. -2. Evidence-Integrity-Worker, kontrollierte physische Disposition und optionales Objektspeicher-Backend. +2. Evidence-Integrity-Worker, kontrollierte physische Disposition und optionales produktives Objektspeicher-Backend. 3. Signierte Agent-Pakete, Release-Provenance und eine spaetere getrennte CA-/PKI-Stufe. ## Verbleibende Roadmap @@ -247,7 +252,8 @@ Erfolgskriterium: | Unreleased | Supplier-Review-Workflow | Kritische Lieferanten erhalten Freigabehistorie, Unterauftragnehmer, Vertragsfristen, Exit-Test-Nachweise und tenantgesicherte Evidence-/Control-/Risk-Links. | | Umgesetzt / Vertiefung | Management-/Regulatory-Templates | Wiederholbare ISO-27001-, NIS2-, DORA-, KRITIS- und Governance-Pakete werden aus bestehenden Snapshots erzeugt; feinere Varianten koennen spaeter folgen. | | Unreleased | Evidence Integrity & Disposition Phase 1 | Manuelle und begrenzte Batch-Re-Hash-Pruefung, Legal Hold, metadata-only Disposition und auditierbare Integritaetsereignisse sind tenantgebunden verfuegbar. | -| Reifegrad | Evidence-Worker, physische Disposition und Objektspeicher | Periodische Integritaetspruefung, kontrollierte Datenvernichtung und Storage-Restore sind auditierbar. | +| Unreleased | Evidence Object Storage & Restore Drill Phase 2 | Eine interne Storage-Abstraktion mit lokalem Filesystem-Backend prueft referenzierte Artefakte sicher auf Vorhandensein, Lesbarkeit und Hash-Konsistenz. | +| Reifegrad | Evidence-Worker, physische Disposition und produktiver Objektspeicher | Periodische Integritaetspruefung, kontrollierte Datenvernichtung und Storage-Restore sind auditierbar. | | Reifegrad | Performance, HA und visuelle Regression | Lastgrenzen, Mehrinstanzbetrieb und UI-Regressionen sind messbar abgesichert. | ## Abgrenzung diff --git a/rust/iscy-backend/src/evidence_artifact_storage.rs b/rust/iscy-backend/src/evidence_artifact_storage.rs new file mode 100644 index 0000000..7897b31 --- /dev/null +++ b/rust/iscy-backend/src/evidence_artifact_storage.rs @@ -0,0 +1,342 @@ +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::{ + fs, + path::{Component, Path, PathBuf}, +}; + +#[derive(Debug, Clone)] +pub struct EvidenceArtifactRef { + stored_path: Option, +} + +impl EvidenceArtifactRef { + pub fn new(stored_path: Option) -> Self { + Self { stored_path } + } + + pub fn reference_present(&self) -> bool { + self.stored_path + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + } + + fn trimmed_path(&self) -> Option<&str> { + self.stored_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct EvidenceArtifactMetadata { + pub backend: &'static str, + pub artifact_reference_present: bool, + pub artifact_present: bool, + pub readable: bool, + pub empty: bool, + pub size_bytes: Option, + pub safe_error_class: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct EvidenceArtifactDrill { + pub backend: &'static str, + pub artifact_reference_present: bool, + pub artifact_present: bool, + pub readable: bool, + pub empty: bool, + pub size_bytes: Option, + pub expected_sha256_present: bool, + pub calculated_sha256: String, + pub hash_matches: bool, + pub status: String, + pub safe_error_class: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EvidenceArtifactStorageError { + MissingReference, + MissingArtifact, + UnsafeReference, + NotReadable, + StorageUnavailable, +} + +impl EvidenceArtifactStorageError { + pub fn safe_error_class(self) -> &'static str { + match self { + Self::MissingReference => "artifact_reference_missing", + Self::MissingArtifact => "artifact_missing", + Self::UnsafeReference => "artifact_reference_unsafe", + Self::NotReadable => "artifact_not_readable", + Self::StorageUnavailable => "artifact_storage_unavailable", + } + } +} + +pub struct FilesystemEvidenceArtifactStorage { + media_root: PathBuf, +} + +impl FilesystemEvidenceArtifactStorage { + pub const BACKEND: &'static str = "local_filesystem"; + + pub fn new(media_root: PathBuf) -> Self { + Self { media_root } + } + + pub fn inspect_metadata(&self, artifact: &EvidenceArtifactRef) -> EvidenceArtifactMetadata { + match self.safe_artifact_path(artifact) { + Ok(path) => match fs::metadata(&path) { + Ok(metadata) => { + let readable = fs::File::open(&path).is_ok(); + EvidenceArtifactMetadata { + backend: Self::BACKEND, + artifact_reference_present: true, + artifact_present: true, + readable, + empty: metadata.len() == 0, + size_bytes: Some(metadata.len()), + safe_error_class: if readable { + String::new() + } else { + EvidenceArtifactStorageError::NotReadable + .safe_error_class() + .to_string() + }, + } + } + Err(err) => { + let error = if err.kind() == std::io::ErrorKind::NotFound { + EvidenceArtifactStorageError::MissingArtifact + } else { + EvidenceArtifactStorageError::NotReadable + }; + self.error_metadata(artifact.reference_present(), error) + } + }, + Err(error) => self.error_metadata(artifact.reference_present(), error), + } + } + + pub fn drill( + &self, + artifact: &EvidenceArtifactRef, + expected_sha256: &str, + ) -> EvidenceArtifactDrill { + let expected = expected_sha256.trim().to_ascii_lowercase(); + let expected_present = !expected.is_empty(); + let path = match self.safe_artifact_path(artifact) { + Ok(path) => path, + Err(error) => { + return self.error_drill(artifact.reference_present(), expected_present, error); + } + }; + let metadata = match fs::metadata(&path) { + Ok(metadata) => metadata, + Err(err) => { + let error = if err.kind() == std::io::ErrorKind::NotFound { + EvidenceArtifactStorageError::MissingArtifact + } else { + EvidenceArtifactStorageError::NotReadable + }; + return self.error_drill(artifact.reference_present(), expected_present, error); + } + }; + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(_) => { + return EvidenceArtifactDrill { + backend: Self::BACKEND, + artifact_reference_present: true, + artifact_present: true, + readable: false, + empty: metadata.len() == 0, + size_bytes: Some(metadata.len()), + expected_sha256_present: expected_present, + calculated_sha256: String::new(), + hash_matches: false, + status: "check_failed".to_string(), + safe_error_class: EvidenceArtifactStorageError::NotReadable + .safe_error_class() + .to_string(), + }; + } + }; + let calculated = format!("{:x}", Sha256::digest(&bytes)); + if !expected_present { + return EvidenceArtifactDrill { + backend: Self::BACKEND, + artifact_reference_present: true, + artifact_present: true, + readable: true, + empty: bytes.is_empty(), + size_bytes: Some(metadata.len()), + expected_sha256_present: false, + calculated_sha256: calculated, + hash_matches: false, + status: "check_failed".to_string(), + safe_error_class: "expected_hash_missing".to_string(), + }; + } + let matches = calculated == expected; + EvidenceArtifactDrill { + backend: Self::BACKEND, + artifact_reference_present: true, + artifact_present: true, + readable: true, + empty: bytes.is_empty(), + size_bytes: Some(metadata.len()), + expected_sha256_present: true, + calculated_sha256: calculated, + hash_matches: matches, + status: if matches { "valid" } else { "mismatch" }.to_string(), + safe_error_class: if matches { "" } else { "hash_mismatch" }.to_string(), + } + } + + fn safe_artifact_path( + &self, + artifact: &EvidenceArtifactRef, + ) -> Result { + let Some(stored_path) = artifact.trimmed_path() else { + return Err(EvidenceArtifactStorageError::MissingReference); + }; + let relative = Path::new(stored_path); + if relative.is_absolute() + || relative + .components() + .any(|component| !matches!(component, Component::Normal(_) | Component::CurDir)) + { + return Err(EvidenceArtifactStorageError::UnsafeReference); + } + let canonical_root = self.media_root.canonicalize().map_err(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + EvidenceArtifactStorageError::MissingArtifact + } else { + EvidenceArtifactStorageError::StorageUnavailable + } + })?; + let relative = strip_repeated_root_prefix(&self.media_root, relative); + let candidate = canonical_root.join(relative); + let canonical_candidate = candidate.canonicalize().map_err(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + EvidenceArtifactStorageError::MissingArtifact + } else { + EvidenceArtifactStorageError::NotReadable + } + })?; + if !canonical_candidate.starts_with(&canonical_root) || !canonical_candidate.is_file() { + return Err(EvidenceArtifactStorageError::UnsafeReference); + } + Ok(canonical_candidate) + } + + fn error_metadata( + &self, + reference_present: bool, + error: EvidenceArtifactStorageError, + ) -> EvidenceArtifactMetadata { + EvidenceArtifactMetadata { + backend: Self::BACKEND, + artifact_reference_present: reference_present, + artifact_present: false, + readable: false, + empty: false, + size_bytes: None, + safe_error_class: error.safe_error_class().to_string(), + } + } + + fn error_drill( + &self, + reference_present: bool, + expected_present: bool, + error: EvidenceArtifactStorageError, + ) -> EvidenceArtifactDrill { + let status = match error { + EvidenceArtifactStorageError::MissingReference + | EvidenceArtifactStorageError::MissingArtifact + | EvidenceArtifactStorageError::UnsafeReference => "missing_artifact", + EvidenceArtifactStorageError::NotReadable + | EvidenceArtifactStorageError::StorageUnavailable => "check_failed", + }; + EvidenceArtifactDrill { + backend: Self::BACKEND, + artifact_reference_present: reference_present, + artifact_present: false, + readable: false, + empty: false, + size_bytes: None, + expected_sha256_present: expected_present, + calculated_sha256: String::new(), + hash_matches: false, + status: status.to_string(), + safe_error_class: error.safe_error_class().to_string(), + } + } +} + +fn strip_repeated_root_prefix<'a>(media_root: &Path, relative: &'a Path) -> &'a Path { + let Some(root_name) = media_root.file_name() else { + return relative; + }; + relative.strip_prefix(root_name).unwrap_or(relative) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_media_root(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("iscy-storage-{name}-{nanos}")); + fs::create_dir_all(&root).unwrap(); + root + } + + #[test] + fn filesystem_storage_blocks_traversal_and_hashes_empty_files() { + let root = temp_media_root("safe-path"); + fs::write(root.join("empty.txt"), b"").unwrap(); + let storage = FilesystemEvidenceArtifactStorage::new(root.clone()); + let empty = storage.drill( + &EvidenceArtifactRef::new(Some("empty.txt".to_string())), + &format!("{:x}", Sha256::digest(b"")), + ); + assert_eq!(empty.status, "valid"); + assert!(empty.empty); + assert_eq!(empty.size_bytes, Some(0)); + + let traversal = storage.inspect_metadata(&EvidenceArtifactRef::new(Some( + "../outside.txt".to_string(), + ))); + assert_eq!(traversal.safe_error_class, "artifact_reference_unsafe"); + assert!(!traversal.artifact_present); + let _ = fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn filesystem_storage_blocks_symlink_escape() { + use std::os::unix::fs::symlink; + + let root = temp_media_root("symlink"); + let outside = temp_media_root("outside"); + fs::write(outside.join("secret.txt"), b"secret").unwrap(); + symlink(outside.join("secret.txt"), root.join("link.txt")).unwrap(); + let storage = FilesystemEvidenceArtifactStorage::new(root.clone()); + let metadata = + storage.inspect_metadata(&EvidenceArtifactRef::new(Some("link.txt".to_string()))); + assert_eq!(metadata.safe_error_class, "artifact_reference_unsafe"); + assert!(!metadata.artifact_present); + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(outside); + } +} diff --git a/rust/iscy-backend/src/evidence_store.rs b/rust/iscy-backend/src/evidence_store.rs index 826ef12..6f57cfe 100644 --- a/rust/iscy-backend/src/evidence_store.rs +++ b/rust/iscy-backend/src/evidence_store.rs @@ -1679,6 +1679,21 @@ impl EvidenceStore { } } + pub async fn evidence_integrity_item( + &self, + tenant_id: i64, + evidence_id: i64, + ) -> anyhow::Result> { + match self { + Self::Postgres(pool) => { + evidence_integrity_item_by_id_postgres(pool, tenant_id, evidence_id).await + } + Self::Sqlite(pool) => { + evidence_integrity_item_by_id_sqlite(pool, tenant_id, evidence_id).await + } + } + } + pub async fn evidence_integrity_targets( &self, tenant_id: i64, diff --git a/rust/iscy-backend/src/lib.rs b/rust/iscy-backend/src/lib.rs index 0e67b9f..caa1df5 100644 --- a/rust/iscy-backend/src/lib.rs +++ b/rust/iscy-backend/src/lib.rs @@ -23,7 +23,7 @@ use sha2::{Digest, Sha256}; use std::{ collections::{BTreeMap, HashMap}, fs, - path::{Component, Path as FsPath, PathBuf}, + path::{Path as FsPath, PathBuf}, sync::{Arc, Mutex}, time::{Duration, Instant}, }; @@ -41,6 +41,7 @@ pub mod control_store; pub mod cve_store; pub mod dashboard_store; pub mod db_admin; +pub mod evidence_artifact_storage; pub mod evidence_store; pub mod hardening; pub mod import_preview; @@ -71,6 +72,10 @@ use change_store::ChangeStore; use control_store::ControlStore; use cve_store::{CveStore, NvdCveRecord}; use dashboard_store::DashboardStore; +use evidence_artifact_storage::{ + EvidenceArtifactDrill, EvidenceArtifactMetadata, EvidenceArtifactRef, + FilesystemEvidenceArtifactStorage, +}; use evidence_store::EvidenceStore; use hardening::CommunitySecurityConfig; use import_preview::{ImportPreview, ImportUploadFile}; @@ -1509,6 +1514,81 @@ pub struct EvidenceIntegrityEventsResponse { pub events: Vec, } +#[derive(Debug, Clone, Serialize)] +pub struct EvidenceStorageOverview { + pub summary: EvidenceStorageSummary, + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct EvidenceStorageSummary { + pub total_items: i64, + pub local_filesystem_items: i64, + pub artifact_references: i64, + pub artifacts_present: i64, + pub artifacts_missing: i64, + pub unreadable_artifacts: i64, + pub expected_hash_present: i64, + pub valid: i64, + pub mismatch: i64, + pub check_failed: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct EvidenceStorageItem { + pub id: i64, + pub title: String, + pub evidence_status: String, + pub storage_backend: &'static str, + pub artifact_reference_present: bool, + pub artifact_present: bool, + pub readable: bool, + pub empty: bool, + pub size_bytes: Option, + pub expected_sha256_present: bool, + pub last_storage_drill_at: Option, + pub drill_status: String, + pub safe_error_class: String, + pub integrity_status: String, + pub integrity_status_label: String, + pub legal_hold_status: String, + pub legal_hold_status_label: String, + pub disposition_status: String, + pub disposition_status_label: String, +} + +#[derive(Debug, Serialize)] +pub struct EvidenceStorageOverviewResponse { + pub api_version: &'static str, + pub tenant_id: i64, + pub storage: EvidenceStorageOverview, +} + +#[derive(Debug, Serialize)] +pub struct EvidenceStorageItemResponse { + pub api_version: &'static str, + pub tenant_id: i64, + pub item: EvidenceStorageItem, +} + +#[derive(Debug, Serialize)] +pub struct EvidenceStorageDrillResponse { + pub accepted: bool, + pub api_version: &'static str, + pub tenant_id: i64, + pub evidence_id: i64, + pub drill: EvidenceArtifactDrill, + pub item: EvidenceStorageItem, +} + +#[derive(Debug, Serialize)] +pub struct EvidenceStorageBatchResponse { + pub accepted: bool, + pub api_version: &'static str, + pub checked: usize, + pub results: Vec, +} + #[derive(Debug, Clone, Default, Deserialize)] pub struct WebContextQuery { pub tenant_id: Option, @@ -8903,7 +8983,7 @@ async fn evidence_overview( } }; - let Some(store) = state.evidence_store else { + let Some(store) = state.evidence_store.as_ref() else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiErrorResponse { @@ -8966,7 +9046,7 @@ async fn evidence_quality( } }; - let Some(store) = state.evidence_store else { + let Some(store) = state.evidence_store.as_ref() else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiErrorResponse { @@ -9011,7 +9091,7 @@ async fn evidence_integrity(State(state): State, headers: HeaderMap) - Ok(context) => context, Err(err) => return auth_error_response(err), }; - let Some(store) = state.evidence_store else { + let Some(store) = state.evidence_store.as_ref() else { return evidence_api_error( StatusCode::SERVICE_UNAVAILABLE, "database_not_configured", @@ -9142,7 +9222,7 @@ async fn evidence_integrity_events( Ok(context) => context, Err(err) => return auth_error_response(err), }; - let Some(store) = state.evidence_store else { + let Some(store) = state.evidence_store.as_ref() else { return evidence_api_error( StatusCode::SERVICE_UNAVAILABLE, "database_not_configured", @@ -9172,6 +9252,223 @@ async fn evidence_integrity_events( } } +async fn evidence_storage(State(state): State, headers: HeaderMap) -> Response { + let context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(err) => return auth_error_response(err), + }; + let Some(store) = state.evidence_store.as_ref() else { + return evidence_api_error( + StatusCode::SERVICE_UNAVAILABLE, + "database_not_configured", + "Rust-Evidence-Store ist nicht konfiguriert.", + ); + }; + match store + .evidence_integrity_overview(context.tenant_id, 500) + .await + { + Ok(integrity) => ( + StatusCode::OK, + Json(EvidenceStorageOverviewResponse { + api_version: "v1", + tenant_id: context.tenant_id, + storage: evidence_storage_overview(&state, integrity.items), + }), + ) + .into_response(), + Err(_) => evidence_api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "database_error", + "Evidence-Storage-Metadaten konnten nicht sicher gelesen werden.", + ), + } +} + +async fn evidence_storage_detail( + Path(evidence_id): Path, + State(state): State, + headers: HeaderMap, +) -> Response { + let context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(err) => return auth_error_response(err), + }; + let Some(store) = state.evidence_store.as_ref() else { + return evidence_api_error( + StatusCode::SERVICE_UNAVAILABLE, + "database_not_configured", + "Rust-Evidence-Store ist nicht konfiguriert.", + ); + }; + match store + .evidence_integrity_item(context.tenant_id, evidence_id) + .await + { + Ok(Some(item)) => ( + StatusCode::OK, + Json(EvidenceStorageItemResponse { + api_version: "v1", + tenant_id: context.tenant_id, + item: evidence_storage_item(&evidence_artifact_storage(&state), &item), + }), + ) + .into_response(), + Ok(None) => evidence_not_found_api_response(), + Err(_) => evidence_api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "database_error", + "Evidence-Storage-Metadaten konnten nicht sicher gelesen werden.", + ), + } +} + +async fn evidence_storage_drill( + Path(evidence_id): Path, + State(state): State, + headers: HeaderMap, +) -> Response { + let context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(err) => return auth_error_response(err), + }; + if let Some(response) = write_permission_error(&context) { + return response; + } + let Some(store) = state.evidence_store.clone() else { + return evidence_api_error( + StatusCode::SERVICE_UNAVAILABLE, + "database_not_configured", + "Rust-Evidence-Store ist nicht konfiguriert.", + ); + }; + match run_evidence_storage_drill(&state, &store, &context, evidence_id).await { + Ok(Some((drill, item))) => ( + StatusCode::OK, + Json(EvidenceStorageDrillResponse { + accepted: true, + api_version: "v1", + tenant_id: context.tenant_id, + evidence_id, + drill, + item, + }), + ) + .into_response(), + Ok(None) => evidence_not_found_api_response(), + Err(err) => err.into_response(), + } +} + +async fn evidence_storage_drills( + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Response { + let context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(err) => return auth_error_response(err), + }; + if let Some(response) = write_permission_error(&context) { + return response; + } + let Some(store) = state.evidence_store.clone() else { + return evidence_api_error( + StatusCode::SERVICE_UNAVAILABLE, + "database_not_configured", + "Rust-Evidence-Store ist nicht konfiguriert.", + ); + }; + let targets = if let Some(evidence_ids) = payload.evidence_ids { + evidence_ids + .into_iter() + .filter(|id| *id > 0) + .take(25) + .collect::>() + } else { + match store + .evidence_integrity_targets(context.tenant_id, payload.limit.unwrap_or(10)) + .await + { + Ok(targets) => targets.into_iter().map(|target| target.id).collect(), + Err(_) => { + return evidence_api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "database_error", + "Evidence-Storage-Targets konnten nicht sicher gelesen werden.", + ); + } + } + }; + let mut results = Vec::new(); + for evidence_id in targets { + match run_evidence_storage_drill(&state, &store, &context, evidence_id).await { + Ok(Some((drill, item))) => results.push(EvidenceStorageDrillResponse { + accepted: true, + api_version: "v1", + tenant_id: context.tenant_id, + evidence_id, + drill, + item, + }), + Ok(None) => {} + Err(err) => return err.into_response(), + } + } + ( + StatusCode::OK, + Json(EvidenceStorageBatchResponse { + accepted: true, + api_version: "v1", + checked: results.len(), + results, + }), + ) + .into_response() +} + +async fn evidence_storage_events( + Path(evidence_id): Path, + State(state): State, + headers: HeaderMap, +) -> Response { + let context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(err) => return auth_error_response(err), + }; + let Some(store) = state.evidence_store else { + return evidence_api_error( + StatusCode::SERVICE_UNAVAILABLE, + "database_not_configured", + "Rust-Evidence-Store ist nicht konfiguriert.", + ); + }; + match store + .evidence_integrity_events(context.tenant_id, evidence_id, 100) + .await + { + Ok(Some(events)) => ( + StatusCode::OK, + Json(EvidenceIntegrityEventsResponse { + api_version: "v1", + tenant_id: context.tenant_id, + evidence_id, + events: events + .into_iter() + .filter(|event| event.event_type.starts_with("storage_")) + .collect(), + }), + ) + .into_response(), + Ok(None) => evidence_not_found_api_response(), + Err(_) => evidence_api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "database_error", + "Evidence-Storage-Ereignisse konnten nicht sicher gelesen werden.", + ), + } +} + async fn evidence_legal_hold( Path(evidence_id): Path, State(state): State, @@ -9358,8 +9655,6 @@ fn evidence_payload_error(err: &anyhow::Error) -> bool { #[derive(Debug, Clone, Copy)] enum EvidenceIntegrityActionError { Database, - StorageUnavailable, - StorageRead, } impl EvidenceIntegrityActionError { @@ -9370,16 +9665,6 @@ impl EvidenceIntegrityActionError { "database_error", "Evidence-Integrity konnte nicht sicher gespeichert werden.", ), - Self::StorageUnavailable => evidence_api_error( - StatusCode::SERVICE_UNAVAILABLE, - "evidence_storage_unavailable", - "Evidence-Dateispeicher ist nicht sicher verfuegbar.", - ), - Self::StorageRead => evidence_api_error( - StatusCode::INTERNAL_SERVER_ERROR, - "evidence_integrity_check_failed", - "Evidence-Integritaetscheck konnte nicht sicher ausgefuehrt werden.", - ), } } } @@ -9423,71 +9708,10 @@ async fn evidence_integrity_check_update( state: &AppState, target: &evidence_store::EvidenceIntegrityTarget, ) -> Result { - let expected = target.expected_sha256.trim().to_ascii_lowercase(); - let Some(stored_path) = target - .file_name - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - else { - return Ok(evidence_integrity_update( - "", - "missing_artifact", - false, - "none", - "Keine Artefaktreferenz gespeichert.", - "artifact_reference_missing", - )); - }; - let media_root = evidence_media_root(state); - let file_path = match resolve_evidence_integrity_path(&media_root, stored_path) { - Ok(path) => path, - Err(EvidenceIntegrityPathError::Missing | EvidenceIntegrityPathError::Unsafe) => { - return Ok(evidence_integrity_update( - "", - "missing_artifact", - false, - "none", - "Artefakt fehlt oder wurde ausserhalb des sicheren Medienpfads referenziert.", - "artifact_missing_or_unsafe", - )); - } - Err(EvidenceIntegrityPathError::Storage) => { - return Err(EvidenceIntegrityActionError::StorageUnavailable); - } - }; - let calculated = calculate_file_sha256(file_path) - .await - .map_err(|_| EvidenceIntegrityActionError::StorageRead)?; - if expected.is_empty() { - return Ok(evidence_integrity_update( - &calculated, - "check_failed", - false, - "review_required", - "Kein erwarteter SHA-256-Hash gespeichert; manuelle Review erforderlich.", - "expected_hash_missing", - )); - } - if calculated == expected { - Ok(evidence_integrity_update( - &calculated, - "valid", - false, - "none", - "Serverseitiger SHA-256-Re-Hash stimmt mit dem gespeicherten Hash ueberein.", - "", - )) - } else { - Ok(evidence_integrity_update( - &calculated, - "mismatch", - true, - "review_required", - "Serverseitiger SHA-256-Re-Hash weicht vom gespeicherten Hash ab.", - "hash_mismatch", - )) - } + let storage = evidence_artifact_storage(state); + let artifact = EvidenceArtifactRef::new(target.file_name.clone()); + let drill = storage.drill(&artifact, &target.expected_sha256); + Ok(evidence_integrity_update_from_storage_drill(&drill)) } fn evidence_integrity_update( @@ -9509,64 +9733,252 @@ fn evidence_integrity_update( } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum EvidenceIntegrityPathError { - Missing, - Unsafe, - Storage, +fn evidence_integrity_update_from_storage_drill( + drill: &EvidenceArtifactDrill, +) -> evidence_store::EvidenceIntegrityCheckUpdate { + let quarantine_status = if drill.status == "valid" { + "none" + } else { + "review_required" + }; + let result = match drill.status.as_str() { + "valid" => "Storage-/Restore-Drill: Artefakt vorhanden, lesbar und hash-konsistent.", + "mismatch" => "Storage-/Restore-Drill: Artefakt vorhanden und lesbar, Hash weicht ab.", + "missing_artifact" => { + "Storage-/Restore-Drill: Artefakt fehlt oder Referenz ist nicht sicher pruefbar." + } + _ if drill.safe_error_class == "expected_hash_missing" => { + "Storage-/Restore-Drill: Artefakt ist lesbar, aber erwarteter SHA-256 fehlt." + } + _ => "Storage-/Restore-Drill konnte nicht sicher abgeschlossen werden.", + }; + evidence_integrity_update( + &drill.calculated_sha256, + &drill.status, + drill.status == "mismatch", + quarantine_status, + result, + &drill.safe_error_class, + ) } -fn resolve_evidence_integrity_path( - media_root: &FsPath, - stored_path: &str, -) -> Result { - let relative = FsPath::new(stored_path); - if relative.is_absolute() - || relative - .components() - .any(|component| !matches!(component, Component::Normal(_) | Component::CurDir)) - { - return Err(EvidenceIntegrityPathError::Unsafe); +fn evidence_artifact_storage(state: &AppState) -> FilesystemEvidenceArtifactStorage { + FilesystemEvidenceArtifactStorage::new(evidence_media_root(state)) +} + +fn evidence_artifact_ref_from_item( + item: &evidence_store::EvidenceIntegrityItem, +) -> EvidenceArtifactRef { + EvidenceArtifactRef::new(item.file_name.clone()) +} + +fn evidence_storage_overview( + state: &AppState, + items: Vec, +) -> EvidenceStorageOverview { + let storage = evidence_artifact_storage(state); + let items = items + .iter() + .map(|item| evidence_storage_item(&storage, item)) + .collect::>(); + EvidenceStorageOverview { + summary: evidence_storage_summary(&items), + items, } - let canonical_root = media_root.canonicalize().map_err(|err| { - if err.kind() == std::io::ErrorKind::NotFound { - EvidenceIntegrityPathError::Missing - } else { - EvidenceIntegrityPathError::Storage - } - })?; - let relative = strip_integrity_repeated_root_prefix(media_root, relative); - let candidate = canonical_root.join(relative); - let canonical_candidate = candidate.canonicalize().map_err(|err| { - if err.kind() == std::io::ErrorKind::NotFound { - EvidenceIntegrityPathError::Missing +} + +fn evidence_storage_summary(items: &[EvidenceStorageItem]) -> EvidenceStorageSummary { + EvidenceStorageSummary { + total_items: items.len() as i64, + local_filesystem_items: items + .iter() + .filter(|item| item.storage_backend == FilesystemEvidenceArtifactStorage::BACKEND) + .count() as i64, + artifact_references: items + .iter() + .filter(|item| item.artifact_reference_present) + .count() as i64, + artifacts_present: items.iter().filter(|item| item.artifact_present).count() as i64, + artifacts_missing: items.iter().filter(|item| !item.artifact_present).count() as i64, + unreadable_artifacts: items + .iter() + .filter(|item| item.artifact_present && !item.readable) + .count() as i64, + expected_hash_present: items + .iter() + .filter(|item| item.expected_sha256_present) + .count() as i64, + valid: items + .iter() + .filter(|item| item.drill_status == "valid") + .count() as i64, + mismatch: items + .iter() + .filter(|item| item.drill_status == "mismatch") + .count() as i64, + check_failed: items + .iter() + .filter(|item| { + matches!( + item.drill_status.as_str(), + "check_failed" | "missing_artifact" + ) + }) + .count() as i64, + } +} + +fn evidence_storage_item( + storage: &FilesystemEvidenceArtifactStorage, + item: &evidence_store::EvidenceIntegrityItem, +) -> EvidenceStorageItem { + let metadata = storage.inspect_metadata(&evidence_artifact_ref_from_item(item)); + evidence_storage_item_from_metadata(item, metadata) +} + +fn evidence_storage_item_from_metadata( + item: &evidence_store::EvidenceIntegrityItem, + metadata: EvidenceArtifactMetadata, +) -> EvidenceStorageItem { + EvidenceStorageItem { + id: item.id, + title: item.title.clone(), + evidence_status: item.evidence_status.clone(), + storage_backend: metadata.backend, + artifact_reference_present: metadata.artifact_reference_present, + artifact_present: metadata.artifact_present, + readable: metadata.readable, + empty: metadata.empty, + size_bytes: metadata.size_bytes, + expected_sha256_present: item.expected_sha256_present, + last_storage_drill_at: item.last_integrity_checked_at.clone(), + drill_status: item.integrity_status.clone(), + safe_error_class: if metadata.safe_error_class.is_empty() { + item.integrity_error_class.clone() } else { - EvidenceIntegrityPathError::Storage - } - })?; - if !canonical_candidate.starts_with(&canonical_root) || !canonical_candidate.is_file() { - return Err(EvidenceIntegrityPathError::Unsafe); + metadata.safe_error_class + }, + integrity_status: item.integrity_status.clone(), + integrity_status_label: item.integrity_status_label.clone(), + legal_hold_status: item.legal_hold_status.clone(), + legal_hold_status_label: item.legal_hold_status_label.clone(), + disposition_status: item.disposition_status.clone(), + disposition_status_label: item.disposition_status_label.clone(), } - Ok(canonical_candidate) } -fn strip_integrity_repeated_root_prefix<'a>( - media_root: &FsPath, - relative: &'a FsPath, -) -> &'a FsPath { - let Some(root_name) = media_root.file_name() else { - return relative; +async fn run_evidence_storage_drill( + state: &AppState, + store: &EvidenceStore, + context: &AuthenticatedTenantContext, + evidence_id: i64, +) -> Result, EvidenceIntegrityActionError> { + let target = store + .evidence_integrity_target(context.tenant_id, evidence_id) + .await + .map_err(|_| EvidenceIntegrityActionError::Database)?; + let Some(target) = target else { + return Ok(None); + }; + let storage = evidence_artifact_storage(state); + let artifact = EvidenceArtifactRef::new(target.file_name.clone()); + let drill = storage.drill(&artifact, &target.expected_sha256); + record_evidence_storage_event( + store, + context, + target.id, + "storage_drill_started", + &drill, + "", + ) + .await?; + if drill.artifact_present && drill.readable { + record_evidence_storage_event( + store, + context, + target.id, + "storage_artifact_found", + &drill, + "", + ) + .await?; + } + let outcome_event_type = storage_drill_outcome_event_type(&drill); + record_evidence_storage_event(store, context, target.id, outcome_event_type, &drill, "") + .await?; + if drill.status == "check_failed" && outcome_event_type != "storage_drill_failed" { + record_evidence_storage_event( + store, + context, + target.id, + "storage_drill_failed", + &drill, + "", + ) + .await?; + } + let update = evidence_integrity_update_from_storage_drill(&drill); + let item = store + .apply_integrity_check_result(context.tenant_id, target.id, context.user_id, update) + .await + .map_err(|_| EvidenceIntegrityActionError::Database)?; + let Some(item) = item else { + return Ok(None); }; - relative.strip_prefix(root_name).unwrap_or(relative) + record_evidence_storage_event( + store, + context, + target.id, + "storage_drill_completed", + &drill, + "", + ) + .await?; + let storage_item = evidence_storage_item(&storage, &item); + Ok(Some((drill, storage_item))) } -async fn calculate_file_sha256(path: PathBuf) -> Result { - tokio::task::spawn_blocking(move || { - let bytes = fs::read(path).map_err(|_| ())?; - Ok::<_, ()>(format!("{:x}", Sha256::digest(&bytes))) - }) - .await - .map_err(|_| ())? +async fn record_evidence_storage_event( + store: &EvidenceStore, + context: &AuthenticatedTenantContext, + evidence_id: i64, + event_type: &str, + drill: &EvidenceArtifactDrill, + note: &str, +) -> Result<(), EvidenceIntegrityActionError> { + store + .record_evidence_integrity_event( + context.tenant_id, + evidence_id, + Some(context.user_id), + event_type, + serde_json::json!({ + "storage_backend": drill.backend, + "artifact_reference_present": drill.artifact_reference_present, + "artifact_present": drill.artifact_present, + "readable": drill.readable, + "empty": drill.empty, + "size_bytes": drill.size_bytes, + "expected_sha256_present": drill.expected_sha256_present, + "hash_matches": drill.hash_matches, + "status": drill.status, + "safe_error_class": drill.safe_error_class, + }), + note, + ) + .await + .map_err(|_| EvidenceIntegrityActionError::Database)?; + Ok(()) +} + +fn storage_drill_outcome_event_type(drill: &EvidenceArtifactDrill) -> &'static str { + match drill.status.as_str() { + "valid" => "storage_hash_valid", + "mismatch" => "storage_hash_mismatch", + "missing_artifact" => "storage_artifact_missing", + _ if drill.safe_error_class == "artifact_not_readable" => "storage_artifact_not_readable", + _ => "storage_drill_failed", + } } async fn evidence_need_sync( @@ -15137,10 +15549,11 @@ async fn web_evidence_integrity( .await { Ok(overview) => { + let storage = evidence_artifact_storage(&state); let rows = overview .items .iter() - .map(|item| evidence_integrity_web_row(&context, item, can_write)) + .map(|item| evidence_integrity_web_row(&context, &storage, item, can_write)) .collect::>() .join(""); let body = format!( @@ -15157,7 +15570,7 @@ async fn web_evidence_integrity(

Integrity & Disposition

- + {}
EvidenceIntegrityLegal HoldDispositionRetentionLetzter CheckAktionen
EvidenceStorageIntegrityDrillLegal HoldDispositionRetentionAktionen
@@ -15172,7 +15585,7 @@ async fn web_evidence_integrity( metric_card("Disposition faellig", overview.summary.disposition_due), metric_card("Blockiert", overview.summary.disposition_blocked), if rows.is_empty() { - web_empty_row(7, "Keine Evidence vorhanden.") + web_empty_row(8, "Keine Evidence vorhanden.") } else { rows }, @@ -15193,6 +15606,65 @@ async fn web_evidence_integrity( } } +async fn web_evidence_storage_drill( + Path(evidence_id): Path, + State(state): State, + headers: HeaderMap, + Query(_query): Query, +) -> Response { + let auth_context = match authenticated_tenant_context(&state, &headers).await { + Ok(context) => context, + Err(_) => { + return web_missing_context("Evidence Integrity", "/evidence/integrity/") + .into_response() + } + }; + let context = WebContext { + tenant_id: auth_context.tenant_id, + user_id: auth_context.user_id, + user_email: auth_context.user_email.clone(), + }; + if !auth_context.can_write() { + return web_error_page( + "Evidence Integrity", + "/evidence/integrity/", + &context, + "Diese Aktion benoetigt eine schreibende ISCY-Rolle.", + ) + .into_response(); + } + let Some(store) = state.evidence_store.clone() else { + return web_store_missing( + "Evidence Integrity", + "/evidence/integrity/", + &context, + "Evidence", + ) + .into_response(); + }; + match run_evidence_storage_drill(&state, &store, &auth_context, evidence_id).await { + Ok(Some(_)) => Redirect::to(&web_path_with_context( + "/evidence/integrity/", + Some(&context), + )) + .into_response(), + Ok(None) => web_error_page( + "Evidence Integrity", + "/evidence/integrity/", + &context, + "Evidence wurde fuer diesen Tenant nicht gefunden.", + ) + .into_response(), + Err(_) => web_error_page( + "Evidence Integrity", + "/evidence/integrity/", + &context, + "Storage-/Restore-Drill konnte nicht sicher ausgefuehrt werden.", + ) + .into_response(), + } +} + async fn web_evidence_integrity_check( Path(evidence_id): Path, State(state): State, @@ -15475,6 +15947,7 @@ async fn web_evidence_disposition( fn evidence_integrity_web_row( context: &WebContext, + storage: &FilesystemEvidenceArtifactStorage, item: &evidence_store::EvidenceIntegrityItem, can_write: bool, ) -> String { @@ -15491,11 +15964,31 @@ fn evidence_integrity_web_row( } else { "-".to_string() }; + let storage_item = evidence_storage_item(storage, item); + let storage_state = if storage_item.artifact_present { + if storage_item.readable { + if storage_item.empty { + "vorhanden · leer" + } else { + "vorhanden" + } + } else { + "nicht lesbar" + } + } else if storage_item.artifact_reference_present { + "fehlt" + } else { + "keine Referenz" + }; let actions = if can_write { let check_action = web_path_with_context( &format!("/evidence/{}/integrity-check", item.id), Some(context), ); + let storage_action = web_path_with_context( + &format!("/evidence/{}/storage-drill", item.id), + Some(context), + ); let hold_action = web_path_with_context(&format!("/evidence/{}/legal-hold", item.id), Some(context)); let release_action = web_path_with_context( @@ -15507,6 +16000,7 @@ fn evidence_integrity_web_row( format!( r#"
+
Legal Hold
@@ -15533,6 +16027,7 @@ fn evidence_integrity_web_row(
"#, html_escape(&check_action), + html_escape(&storage_action), html_escape(&hold_action), html_escape(&release_action), html_escape(&disposition_action), @@ -15547,20 +16042,24 @@ fn evidence_integrity_web_row( "Nur Lesen".to_string() }; format!( - r#"{}
{} · {} · Hash {}{}
{}{}
{}{}
{}{}
{}{}{}"#, + r#"{}
{} · {} · Hash {}{}
{} · {}{}
{}{}
{}{}
{}{}
{}{}
{}{}"#, html_escape(&item.title), html_escape(&item.quality_status_label), artifact, html_escape(&hash), + html_escape(storage_item.storage_backend), + storage_state, + html_escape(&storage_item.safe_error_class), html_escape(&item.integrity_status_label), html_escape(&item.integrity_result), + html_escape(&storage_item.drill_status), + html_escape(item.last_integrity_checked_at.as_deref().unwrap_or("-")), html_escape(&item.legal_hold_status_label), html_escape(&item.legal_hold_reason), html_escape(&item.disposition_status_label), html_escape(&item.disposition_reason), html_escape(item.retention_until.as_deref().unwrap_or("-")), html_escape(item.disposition_due_at.as_deref().unwrap_or("-")), - html_escape(item.last_integrity_checked_at.as_deref().unwrap_or("-")), actions, ) } @@ -31989,19 +32488,36 @@ pub fn app_router_with_state(state: AppState) -> Router { .route("/api/v1/evidence", get(evidence_overview)) .route("/api/v1/evidence/quality", get(evidence_quality)) .route("/api/v1/evidence/integrity", get(evidence_integrity)) + .route("/api/v1/evidence/storage", get(evidence_storage)) .route( "/api/v1/evidence/integrity-checks", post(evidence_integrity_batch_checks), ) + .route( + "/api/v1/evidence/storage-drills", + post(evidence_storage_drills), + ) .route("/api/v1/evidence/uploads", post(evidence_upload)) + .route( + "/api/v1/evidence/{evidence_id}/storage", + get(evidence_storage_detail), + ) .route( "/api/v1/evidence/{evidence_id}/integrity-check", post(evidence_integrity_check), ) + .route( + "/api/v1/evidence/{evidence_id}/storage-drill", + post(evidence_storage_drill), + ) .route( "/api/v1/evidence/{evidence_id}/integrity-events", get(evidence_integrity_events), ) + .route( + "/api/v1/evidence/{evidence_id}/storage-events", + get(evidence_storage_events), + ) .route( "/api/v1/evidence/{evidence_id}/legal-hold", post(evidence_legal_hold), @@ -32260,6 +32776,10 @@ pub fn app_router_with_state(state: AppState) -> Router { "/evidence/{evidence_id}/integrity-check", post(web_evidence_integrity_check), ) + .route( + "/evidence/{evidence_id}/storage-drill", + post(web_evidence_storage_drill), + ) .route( "/evidence/{evidence_id}/legal-hold", post(web_evidence_legal_hold), diff --git a/rust/iscy-backend/tests/http_tests.rs b/rust/iscy-backend/tests/http_tests.rs index 6addc7c..2b3117e 100644 --- a/rust/iscy-backend/tests/http_tests.rs +++ b/rust/iscy-backend/tests/http_tests.rs @@ -9002,6 +9002,209 @@ async fn evidence_integrity_legal_hold_and_disposition_are_tenant_scoped_and_met let _ = fs::remove_dir_all(media_root); } +#[tokio::test] +async fn evidence_storage_drill_uses_local_backend_and_keeps_tenant_boundaries() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + db_admin::run_sqlite_migrations(&pool).await.unwrap(); + db_admin::seed_sqlite_demo(&pool).await.unwrap(); + let media_root = test_media_root("evidence-storage-drill"); + let evidence_dir = media_root.join("evidence/storage"); + fs::create_dir_all(&evidence_dir).unwrap(); + fs::write( + evidence_dir.join("valid.txt"), + b"trusted storage evidence\n", + ) + .unwrap(); + fs::write( + evidence_dir.join("changed.txt"), + b"changed storage evidence\n", + ) + .unwrap(); + fs::write(evidence_dir.join("empty.txt"), b"").unwrap(); + let valid_hash = format!("{:x}", Sha256::digest(b"trusted storage evidence\n")); + let stale_hash = "1".repeat(64); + let empty_hash = format!("{:x}", Sha256::digest(b"")); + let absolute_path = media_root.join("evidence/storage/valid.txt"); + sqlx::query( + r#" + INSERT INTO evidence_evidenceitem ( + id, tenant_id, title, description, linked_requirement, file, + file_sha256, status, owner_id, valid_until, retention_until, + retention_reason + ) VALUES + (920, 1, 'Storage valid evidence', 'Expected hash matches', 'NIS2 21', 'evidence/storage/valid.txt', ?1, 'APPROVED', 1, '2027-12-31', '2026-01-01', 'Storage drill test'), + (921, 1, 'Storage mismatch evidence', 'Expected hash is stale', 'NIS2 21', 'evidence/storage/changed.txt', ?2, 'APPROVED', 1, '2027-12-31', '2026-01-01', 'Storage drill test'), + (922, 1, 'Storage missing evidence', 'File is missing', 'NIS2 21', 'evidence/storage/missing.txt', ?1, 'APPROVED', 1, '2027-12-31', '2026-01-01', 'Storage drill test'), + (923, 1, 'Storage missing hash evidence', 'No expected hash', 'NIS2 21', 'evidence/storage/valid.txt', '', 'APPROVED', 1, '2027-12-31', '2026-01-01', 'Storage drill test'), + (924, 1, 'Storage empty evidence', 'Empty but valid file', 'NIS2 21', 'evidence/storage/empty.txt', ?3, 'APPROVED', 1, '2027-12-31', '2026-01-01', 'Storage drill test'), + (925, 2, 'Foreign storage evidence', 'Foreign tenant', 'NIS2 21', 'evidence/storage/valid.txt', ?1, 'APPROVED', 1, '2027-12-31', '2026-01-01', 'Storage drill test'), + (926, 1, 'Unsafe absolute storage evidence', 'Absolute path must not resolve', 'NIS2 21', ?4, ?1, 'APPROVED', 1, '2027-12-31', '2026-01-01', 'Storage drill test') + "#, + ) + .bind(&valid_hash) + .bind(&stale_hash) + .bind(&empty_hash) + .bind(absolute_path.display().to_string()) + .execute(&pool) + .await + .unwrap(); + let app = app_router_with_state( + AppState::default() + .with_evidence_store(Some(EvidenceStore::from_sqlite_pool(pool.clone()))) + .with_evidence_media_root(Some(media_root.clone())), + ); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/evidence/storage") + .header("x-iscy-tenant-id", "1") + .header("x-iscy-user-id", "1") + .header("x-iscy-roles", "AUDITOR") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body_text = String::from_utf8(body.to_vec()).unwrap(); + assert!(!body_text.contains(&media_root.display().to_string())); + let payload: serde_json::Value = serde_json::from_str(&body_text).unwrap(); + assert_eq!( + payload["storage"]["items"][0]["storage_backend"], + "local_filesystem" + ); + assert!( + payload["storage"]["summary"]["artifact_references"] + .as_i64() + .unwrap() + >= 6 + ); + + let readonly = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/evidence/920/storage-drill") + .header("x-iscy-tenant-id", "1") + .header("x-iscy-user-id", "1") + .header("x-iscy-roles", "AUDITOR") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(readonly.status(), StatusCode::FORBIDDEN); + + let foreign = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/evidence/925/storage") + .header("x-iscy-tenant-id", "1") + .header("x-iscy-user-id", "1") + .header("x-iscy-roles", "ADMIN") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(foreign.status(), StatusCode::NOT_FOUND); + + for (evidence_id, expected_status, expected_error, expected_match) in [ + (920, "valid", "", true), + (921, "mismatch", "hash_mismatch", false), + (922, "missing_artifact", "artifact_missing", false), + (923, "check_failed", "expected_hash_missing", false), + (924, "valid", "", true), + (926, "missing_artifact", "artifact_reference_unsafe", false), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/v1/evidence/{evidence_id}/storage-drill")) + .header("x-iscy-tenant-id", "1") + .header("x-iscy-user-id", "1") + .header("x-iscy-roles", "ADMIN") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body_text = String::from_utf8(body.to_vec()).unwrap(); + assert!(!body_text.contains(&media_root.display().to_string())); + let payload: serde_json::Value = serde_json::from_str(&body_text).unwrap(); + assert_eq!(payload["drill"]["status"], expected_status); + assert_eq!(payload["drill"]["safe_error_class"], expected_error); + assert_eq!(payload["drill"]["hash_matches"], expected_match); + } + + let batch = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/evidence/storage-drills") + .header("x-iscy-tenant-id", "1") + .header("x-iscy-user-id", "1") + .header("x-iscy-roles", "ADMIN") + .header("content-type", "application/json") + .body(Body::from(r#"{"evidence_ids":[920,921,925],"limit":50}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(batch.status(), StatusCode::OK); + let body = to_bytes(batch.into_body(), usize::MAX).await.unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(payload["checked"], 2); + + let events = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/evidence/920/storage-events") + .header("x-iscy-tenant-id", "1") + .header("x-iscy-user-id", "1") + .header("x-iscy-roles", "AUDITOR") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(events.status(), StatusCode::OK); + let body = to_bytes(events.into_body(), usize::MAX).await.unwrap(); + let body_text = String::from_utf8(body.to_vec()).unwrap(); + assert!(!body_text.contains(&media_root.display().to_string())); + assert!(!body_text.contains("valid.txt")); + let payload: serde_json::Value = serde_json::from_str(&body_text).unwrap(); + let event_types = payload["events"] + .as_array() + .unwrap() + .iter() + .map(|event| event["event_type"].as_str().unwrap()) + .collect::>(); + assert!(event_types.contains(&"storage_drill_started")); + assert!(event_types.contains(&"storage_artifact_found")); + assert!(event_types.contains(&"storage_hash_valid")); + assert!(event_types.contains(&"storage_drill_completed")); + assert!(media_root.join("evidence/storage/valid.txt").exists()); + assert!(media_root.join("evidence/storage/changed.txt").exists()); + assert!(media_root.join("evidence/storage/empty.txt").exists()); + let _ = fs::remove_dir_all(media_root); +} + #[tokio::test] async fn rust_web_evidence_accepts_file_upload_from_form() { let pool = SqlitePoolOptions::new() @@ -9155,6 +9358,7 @@ async fn rust_web_evidence_integrity_renders_safe_metadata_and_write_actions_by_ assert!(html.contains("MFA rollout evidence")); assert!(html.contains("Nur Lesen")); assert!(!html.contains("Re-Hash")); + assert!(!html.contains("Storage-Drill")); let admin = app .oneshot( @@ -9174,6 +9378,7 @@ async fn rust_web_evidence_integrity_renders_safe_metadata_and_write_actions_by_ assert!(html.contains("Evidence Integrity")); assert!(html.contains("MFA rollout evidence")); assert!(html.contains("Re-Hash")); + assert!(html.contains("Storage-Drill")); assert!(html.contains("Legal Hold")); assert!(html.contains("Disposition")); } From b76d75f9ae529eae79b4d510bda8bfac0b7f2011 Mon Sep 17 00:00:00 2001 From: Enrico Wilhelm Date: Thu, 9 Jul 2026 15:24:34 +0200 Subject: [PATCH 2/2] ci: rerun CodeQL for evidence storage drill