From 84448ad45e3ac8fc82de03c94e6c3a4ffe60aef3 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Thu, 5 Nov 2026 21:23:54 +0000 Subject: [PATCH] Upload via GUI (65 Dateien) --- theme/plugins/vertretung-1.1.0.zip | Bin 0 -> 9988 bytes .../migrations/001_abwesenheit.sqlite.sql | 22 + theme/plugins/vertretung/pages/vertretung.php | 93 ++++ theme/plugins/vertretung/plugin.json | 9 + theme/plugins/vertretung/plugin.php | 16 + theme/plugins/virenscan-1.2.1.zip | Bin 0 -> 20852 bytes theme/plugins/virustotal-1.0.0.zip | Bin 0 -> 19684 bytes theme/plugins/virustotal/README.md | 45 ++ theme/plugins/virustotal/lang/en.json | 100 +++++ theme/plugins/virustotal/lib.php | 420 ++++++++++++++++++ .../migrations/001_dateien.mysql.sql | 20 + .../migrations/001_dateien.sqlite.sql | 20 + theme/plugins/virustotal/pages/virustotal.php | 161 +++++++ theme/plugins/virustotal/plugin.json | 10 + theme/plugins/virustotal/plugin.php | 27 ++ 15 files changed, 943 insertions(+) create mode 100644 theme/plugins/vertretung-1.1.0.zip create mode 100644 theme/plugins/vertretung/migrations/001_abwesenheit.sqlite.sql create mode 100644 theme/plugins/vertretung/pages/vertretung.php create mode 100644 theme/plugins/vertretung/plugin.json create mode 100644 theme/plugins/vertretung/plugin.php create mode 100644 theme/plugins/virenscan-1.2.1.zip create mode 100644 theme/plugins/virustotal-1.0.0.zip create mode 100644 theme/plugins/virustotal/README.md create mode 100644 theme/plugins/virustotal/lang/en.json create mode 100644 theme/plugins/virustotal/lib.php create mode 100644 theme/plugins/virustotal/migrations/001_dateien.mysql.sql create mode 100644 theme/plugins/virustotal/migrations/001_dateien.sqlite.sql create mode 100644 theme/plugins/virustotal/pages/virustotal.php create mode 100644 theme/plugins/virustotal/plugin.json create mode 100644 theme/plugins/virustotal/plugin.php diff --git a/theme/plugins/vertretung-1.1.0.zip b/theme/plugins/vertretung-1.1.0.zip new file mode 100644 index 0000000000000000000000000000000000000000..6aec19f9d68b801197ee13e05187d7c56708e314 GIT binary patch literal 9988 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

3|3cj8_EY6hO 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 O7tWu3y-xkFxBmeQM^4EA literal 0 HcmV?d00001 diff --git a/theme/plugins/vertretung/migrations/001_abwesenheit.sqlite.sql b/theme/plugins/vertretung/migrations/001_abwesenheit.sqlite.sql new file mode 100644 index 00000000..d6a172a6 --- /dev/null +++ b/theme/plugins/vertretung/migrations/001_abwesenheit.sqlite.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS plugin_vertretung_absences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + staff_id INTEGER NOT NULL, + substitute_id INTEGER NOT NULL, + start_date TEXT NOT NULL, + end_date TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'planned', + created_by INTEGER NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_plugin_vertretung_status ON plugin_vertretung_absences (status, start_date); +CREATE TABLE IF NOT EXISTS plugin_vertretung_moves ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + absence_id INTEGER NOT NULL, + ticket_id INTEGER NOT NULL, + from_staff_id INTEGER NOT NULL, + to_staff_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + returned_at TEXT NULL +); +CREATE INDEX IF NOT EXISTS idx_plugin_vertretung_moves_absence ON plugin_vertretung_moves (absence_id, returned_at) diff --git a/theme/plugins/vertretung/pages/vertretung.php b/theme/plugins/vertretung/pages/vertretung.php new file mode 100644 index 00000000..24e29a15 --- /dev/null +++ b/theme/plugins/vertretung/pages/vertretung.php @@ -0,0 +1,93 @@ +query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll(); +$labels = vertretung_status_labels(); +$names = []; +foreach ($people as $p) $names[(int) $p['id']] = $p['name']; +$name = fn($id) => e($names[(int) $id] ?? vertretung_name($pdo, (int) $id)); +$current = $pdo->query("SELECT a.*, (SELECT COUNT(*) FROM plugin_vertretung_moves m WHERE m.absence_id = a.id) AS moved, (SELECT COUNT(*) FROM plugin_vertretung_moves m WHERE m.absence_id = a.id AND m.returned_at IS NULL) AS holding FROM plugin_vertretung_absences a WHERE a.status IN ('planned','active') ORDER BY a.start_date, a.id")->fetchAll(); +$past = $pdo->query("SELECT a.*, (SELECT COUNT(*) FROM plugin_vertretung_moves m WHERE m.absence_id = a.id) AS moved FROM plugin_vertretung_absences a WHERE a.status NOT IN ('planned','active') ORDER BY a.id DESC LIMIT 20")->fetchAll(); +?> +

Abwesenheit und Vertretung

+
+
+ +
+ + +

Abwesenheit eintragen

+

Zum Beginn gehen Ihre offenen Tickets an die Vertretung, neue Tickets ebenfalls (auch bei automatischer Zuweisung). Am Ende bekommen Sie die Tickets zurück, die dann noch bei der Vertretung liegen. Der Urlaubsstatus im Profil wird dafür automatisch gesetzt. Solange die Abwesenheit dauert, sieht und bearbeitet die Vertretung auch die Abteilungen der abwesenden Person, selbst wenn sie dort sonst keinen Zugang hat (Server ab 1.0.55).

+
Dieser Server ist älter als 1.0.55: Die Vertretung bekommt die Tickets, sieht sie aber nur, wenn sie auch Zugang zur Abteilung hat. Bitte den Server aktualisieren.
+
+ +
+ +
+
+
+
+ +
+
+ +
+

Laufend und geplant

+

Keine Abwesenheiten eingetragen.

+ + + + prepare('SELECT d.name FROM departments d WHERE d.id = (SELECT department_id FROM staff WHERE id = ?) OR d.id IN (SELECT department_id FROM staff_department WHERE staff_id = ?) ORDER BY d.name'); + $dq->execute([(int) $a['staff_id'], (int) $a['staff_id']]); + $deptNames = $dq->fetchAll(PDO::FETCH_COLUMN); + $absentAdmin = ($pdo->query('SELECT role FROM staff WHERE id = ' . (int) $a['staff_id'])->fetchColumn() === 'admin'); + ?> + + + + + + + + + + + +
WerVertretungZeitraumStandTicketsDie Vertretung sieht
– + +
+ +
+ +
+ + +
+

Frühere Abwesenheiten

+ + + + + + + +
WerVertretungZeitraumErgebnisWeitergegeben
– Tickets
+
+ diff --git a/theme/plugins/vertretung/plugin.json b/theme/plugins/vertretung/plugin.json new file mode 100644 index 00000000..ad6ad5a5 --- /dev/null +++ b/theme/plugins/vertretung/plugin.json @@ -0,0 +1,9 @@ +{ + "slug": "vertretung", + "name": "Urlaub und Vertretung", + "version": "1.1.0", + "description": "Mitarbeiter tragen Abwesenheiten mit Vertretung ein. Zum Beginn werden ihre offenen Tickets an die Vertretung verteilt, neue Tickets gehen automatisch dorthin, und am Ende bekommt der Mitarbeiter seine noch offenen Tickets zurück. Die Vertretung sieht während der Abwesenheit auch die Abteilungen der abwesenden Person, selbst ohne eigenen Zugang. Baut auf dem Urlaubsstatus im Profil und auf der automatischen Zuweisung der Abteilungen auf (Server ab 1.0.55).", + "author": "DeskHub", + "requires": "1.0.55", + "permissions": ["hooks.tickets", "hooks.cron", "ui.menu", "db.migrations"] +} diff --git a/theme/plugins/vertretung/plugin.php b/theme/plugins/vertretung/plugin.php new file mode 100644 index 00000000..0e5a4f87 --- /dev/null +++ b/theme/plugins/vertretung/plugin.php @@ -0,0 +1,16 @@ +I zdE>arj{I|7D)6f(LdG;)&hu_NpU04IfTI2$=-g1ZXY=@6F~N_G`(` zr+yPmAmaUCXf?Bal2j8}hZeo_V^ddvLI0%Ct9qM!I}Jfo0A_d$NR_rEi@Y@nIW6N{!$p8+aClrS`DFdg}zHNG_5rG{zcL zQaKSpAi)raJV?r(+_@&Fe2V=FyE0sxKLP%E!pAKuD;u*pSnE;ZVAi=DY@6X@Aps>+ zB$`8@TsV=O5RDR|VAQ{SUK5>0v=22s>83a@&;Q;weSBN@KJ@gia-F*R@=$ahHuRHM z&6C1mL6aJ$5RV@KCHPVQN)wZ)0kI6rxmC>gd76yI#bjP+JW3%dFDZ|L8;_4dT}CO_ zWRQ;&2`9f-gOPp33^x!aWm;{Bw#c}Uub*O-WE*{=Y%mFMmMA1r4&i}VRX{V}>XEAw z%8)8S9YWE7fGuHH?snsp!cphhI2{cX#BGrM?U^vLS55s*NPDVb;#lmz>+&ogOM@b= zSys(0SyXRZ!y3(OItn*^<(Z=_4c99}JSk4I%}tCuygC&W5cJBDFNgn)3)^=^nyU-U)hH)Rw@Pw%1`bhCWRM zB1P3K`9!>wU;G$&DV0-exV!F0lh|IYK?Nf2s7i_nhYTl|D#@+=G31^!-Y*z6tN4&j zQi|*cj?f#=jfA|<{X5UqZOu;oMsx-2XEJ!webgz${$o1Z7sN(c5ClEa@RkwiWguWf z{^CK-sw4s?Z2$Y(lcrb*P^@=sqXu7;%J@Pq!F^W(>>BOs+PmLVg_mNsDIG|qAKIB*cg#CQ}J36f)8Z?*@8fBf=YxOh#x2AaF>KyTUA z#lLX5@`UKE#k{E2N0NxdDgWS7-G7i7O$r$`XT#g|SIZog-@Nr1t?GqvuGQXs)Ao=x z7iy;Q-A8;CS|Dv5`~$@U_DdIR+!K-_MlMLDOE$uJYy_5>pTvA$qK*?05?i9euaI_D z6Ij24d$MILR!t9C`Y8cg(2n1IlN>)o`+5I+nS?0V5H@ZPiE&(1!orZfguOwC2(;GB zY=Y=ckh8-6kaZ*V`*6|e#{I-+k=OZ#&HHWuzUy`WyFwSh8Evl))73?->={77SLf*92myAJiYhDYOqb3Pvst%c1LzUFhn~w^Huns3*S}flrf9cyKUTt}*S3essK$Vi2rx zDS#fW*ocniO41&=#dZfLLWoK1D4gF8VxLG9Zi2`LBVmm{yf;tdoDzY>;e?RZYnEUe zXob4==TtSGr_GEgu`$~srv?$gz;dv5VO2#;U~@+7lg?^D3g>cn`o1pn{&T1N@z}M> z$939%3Z~l{2119`#wGceh$a%t%ZwMbm5~QHR0yndJP4Z^awoKRDLddMIEF_q5L6^B zA~iC!7aR!9$tdqUa%F>LZ%Ot5uhJl#txWQq&?ewH!diq@o#eKr6>>umt^tR+$M_bW zsaEHnL__uZhZg(_3P+f(gLi|DHCSG4wm(ri(yt$N-Iz z(Cf>k{hgj#Q*?H(+&)`+x(q71-T6BtWtND(rR!u@6PUg%S=~A0U?$<6EDvlHkzUtp zjW@K$^MMxM5gNA2!KGPY6JNT@jG#kF77z^*Jsdqgr86^v>2?yOQ2AUk)pk9NZSI}uvJNMI8~9EWjd`S8!g&o+^xQx zUD1DJtohJ*G0hP+8Mz`5CXKTu2nNQ1DFL5uDU9YH6biSFxu#xT;Ns7+96E?H1-W~;Nltcr3ax=v>G0q6>3yh~|Fnt~Xu9&Nd@c z@kLorsXs~!#F9dRq`M^`vR$}*POZ$^U(^iJQnPSY<4+rv{`kui!P%@s2N7frgwB|T zf}BARYmF=GSMv(z^E5{!56788@+QPjnK0!q6vV@8T89F>9^gNz27mnFQ|TVow!M@~ z)D3#A4JNd=TE<JnBc%sXFM1S)XNt3DXz5fbev#$ zNCEB#qf4tZ-zyJ@zof-N%a#e$N*2?P=0D;dJ&2_z0Tv0q)8-q?9nG)`MV0%E!Qf-;cT(UT;NJggy$sJZQ#8I>`2G@Q`rP=y+Twiyzai5f7@^I71&c^zX{^-=;$=svN**oj{Q2-Bq_^)!sYRX zFQ!3vFbhfV`Q>y`X?U%B$QLqGQ7CVL;lZ)IrCr<%TBZ_2>Rg}n7WKCs zZ;J7~I=VoM^7CqNH)p?sh~{x0o{$CK%7#9>7`5!wRcE^-{jTYgeRb`^Vtm68th`^9 zRP>K%5g(J3KDPb`(ArBzM(R~LfytI6Gq!9xVtQJLLhr~TD-G#nO?sbn=JqmvTCiLz zZG=nXLk5<4OKS=LFa2k1iqU>?b~Syug=qO6hNZn9V3*eelm5{#YHl#C!`q=-DEq`A zuTRXm2{+y5hox(;9hv4%2+tSjKO?YM;&FTxm2dP#BEtzo-*GO3$e(9ZTMFA~R9ut& z^?jvyf&2zj+YVU9-NgKYr2v);QbRz1F0mz2-{AR04&nHK1A&%ay2Iv(~1ylhk?tdHZ z?ew*+&AczQpe6GTWuEO%>PI_;|66b)i0bG%t&QDUKmq`>C;$NZ|0=kx4QBxjrMf^l+Iy$~DDr;Y^NCt8XeKplT3R$>4&ef`;;ff7( ztYsT^L@T`WmewRTTeLM>i|k%wzQyz%)-c8AUGq((KFPD^sx5g&G;7Tz?RFJqMpQK; z^Bb7pxz(E3fbQ*j-Bt3lLVvu>>9aS% zeIT%PGMt5nFJ14tx)68f*H%m8s^vSOjqf%)#Bg2_s-(vRYD?9{;?S>}Y^1zKM@x1w zQwjcr=tiJ&g4s=&*tK_mB-D_B%wdjm7lG`gJ9b0sJ(37)r140ZO^2DTuBDVJc&y6X zSsP$T9}K;|S%ll#oD!e)+mj_lca!1T8j>m|X~?Rh3_eF6wT$S-G`X3!mSW(i^QdKe zL?g|pDd+HZIoeO!5?UiEH@(P2dUh5Ax5f~x2?HBnR&#wOTKp*M^89f+vR`}?0ZZI% zI5_u@i4F#~1upoD9_(p^to^Ao<#K5)#Opk{AESj*RHfx}&$c{XM5CalmxNsnRI43a0a zH$9i^i{)kRkiQy8I+B}_Aoo$X$W|p84L7i!hx!Y=52Vn{bO=O?r`IVhK}|=GZ_H!n zrWyf2C)qz>*I06($%`D-&ep;Prjs51*a4cZ2igm5)|lVCDq=cJV|f+F`9!rsEBUxd z3vbuiQsA@EVBO5S6v?y(KM^g>Q~R#+vz{jxH=M6aJ(BVbJ2A%c?WuM@ebO6#{$Kb7&9hEUxyiYMj z7E_yyA1ZXpZ)XzpF(i(K&-m|q3XkCp`KH&w+Ml<(S_+ffLOy(ympm*fYX(pxFg1Iu zZ7e@<+`r;4{1>A$AyPaYM901~>v6j*-Mb2`o#>6qGZ@JubS-J`IbbdG@Nr;9r>2mFDf)pM?^zs4 z@KRrGIb75+&nKFl=5{+gI{vHU6c6U;knC?Q!vzJag)LKDqba`2*;^g@b<)9DKi8j+ zqT9%=f|hcoe2JPT6Y0=zzSoF+oc3~C2Vv^)Mt@YS?o4F}I9lIpqmCkRn3| zg?2jyniVuECgKNs`ku7znHG&9PtOLW@$CabjL`dz_IF6R{6rP&Qmv9`AU*MEq1aYB zIaM%Ml3yVg3!K6lufH8DBbe7A^c=`w&pp{X2ttCLnw@C^9V9Jz`a6(SxhO}QK8H)v zb{AC(NMm8+d9^~h$yf;>rpCOkV*p3GzmDQ`mB@zvB^okV-_Y(@q2 zR$-dyWVFZeduLl`-8!y2Aut{?45ghNoQm%`*QZ=v%+Q*zzwi%kY7ve z`9;3GLz~Z$nMW&%M5*ixYQtbRfj6M(uxv&5`59#`(4tg=(fquTW$yjW5gIv_g5Og7 zzlClU@Eymf`beI6iVb*4kDh|9flt}92vFS$$5`*oBr$TylRJIzue#v*dhd+%L+}sd zR?3VKO>Yk>8Wr23*Z9xoMI!xPoalvi10B@Btjyw~h;ozrrlbrm!c_;Jd>~|+&<#I% zDD*5rZJ&#NYxo2#46YPNyBp84;GsFDK!D5{E+pR4-By;);^+R@dqm48qw1K;@Eui! zmD3hrELA(CnSBwq_7HUAg8H<5WAd;+>fU9nMu<6nBk8;fxh>}j=~6Mz8d}3^-m2gC z1AxV9JqhlD&H(#Eg1c14l-ia{=d__q`Z<5o=_gykvjmU&d_wXe^khaF=V_NRXKD?) zbqZSo@5W2kkfX3KWpDOU4Q_e7=@{*$!g~gmmiV~25j`8H)mbJXCMaKWCZDM7>iYW8 zHS}UYba)yC4?(7TMw5EBs*boA4q$Bk-RT2@g3RVi+#DIflWu0yTId@HgAj5g>N*Wv zN>$kV7)nhES!uOHzPaCD2Eq9+c**YGb z?-6FyV3zBK8#=O>l{z>Z{mRu=Ke3i4{y=EMZ#7^Aj_dvW1dt@Z4mWSxKCSE%#>(8T z{(QT&QBQHNYqwKVq(T5vdiOa!9MOIs73S~DaQLlTlFTC>NkB$J08E|P8*O7lRvV#_@jNh_82+!U%ir)Z)ASTIu zz}IqJkKu)0MC&}tGV&ESyS^EGQj$f##`g&=ZtFJR@isemz|_^xo5R?j7_`p84?z{1 zPb5#TXIhBfvp0yC5IhU=xq3qTILead397!tmhdNoGl_=(xomvugelr^!dx+qT{EG> za)Osb@|IzpKvo995*XyfVQgzYiQ)i@79byP^npKvo?;dC+kg@8d)zO7E^SuV6IB%G zw@d-%TIV#mZJd26#NcDMC&3NVYoPJ%Xw`ndPzAd7<4`W6_SW}bm_Pb+_8L=*wD6)Z zPfFP4_t~sHW&!ly>G#g3(blES55^-0zc;ZT5k6++{YQYw`JFh}bL%iDMr+pt%=o4# znC?I{{00M8Axf>c9YOEptuIUJ0-n!kV`{dL#tcBjl3vs=@qQ!*>WT*2`J;6YRgEeg z*qr*4beH}#?k7<`aK`*KM3lYK$xAozh}9$)f=3rm)G{Hf1J5-?S~6 zO?!m|;+wu#!i-UlUp$g>dgsm66FFwe+;_vKNee+qE0l~Jpr*I!Ulos+)hI5Vz>V7u zHTJS1<&Y8`!Q4$t_ArJb6OgYc?K#X@&J-b&B8G19kp2qhI^xgsGB!E}^W>TI1~gYc zlWEe7+cJ5*1#~g4e+>PRj}xg15Y5_jofm(DKPxnzlltO{ZBSuYFVSk~tV;L5^5Hu> zz936G?;)5GH&tBgLeYJr|J+!kjYk{6_Ma5gcH@{|ss4S$9i`;!=#9g+ULo}`a{j-12md_q^cW`?#?&A(X3`P~zfaMz`f`mfg6m z3yv3bowbQP%ZlZOFvMEHWRFC(Vl@>D>;c4Hj{6(LHlV#a9rKBls*U$h&G!E0qW})s zUMBQ;`cr7zuHjleQ@0$iee@mt*S&pwaoLQY>_U*1viX(fdCchEv7h*F3g9^=4#>Xf zAikHK;1Av|vqa8}gKgLS4ZO2=z{n2Box8z%O@MEI%aG_$=!aiDDzSC;J?U2;P|}?} zGMMtr<#+!>H_9HbK$UV|u-_)6(l8&vT^$r8tbeN55Rn1f>>|+jr;m=yN7Nm;Nu`Yp7&WD=?L*$sL+Bw!qO0ItuMHz?klL{n^L z&$t!hxuSgsMaIT-MZlo!y zZ*K(#5m%(E4@bd)r(G^2jQmckjG$wdRB2!XVC>qe-sWS9Bp!Cz)9)KP9}wDtxxUW5 zF~kDH297Ee4ZhPOMa_dQzwcn|fB7e31mNYB1XR^pzE~hRHqeK_V7&~t$L}q@ zz`mrxb-%)GMg7?tdl!|^6G;hL9w!YamD<4+%)~1O)-WSGY`gc@F0c>SXwbASIMb&H zLzD>}9d{#G6LE~^b&F?a!3fofP54=w`t1>$axM>iGiogghWJ`S?NFISZ~WLCJxm%B z*gPBMIyn$gPNlWK(^v%lMB5tG>BUHTMmC;C-Zs=M8r709Phc-s@NP*1+Nsdsce-r~ zXcoiesP3zBU6P0+t_T!^4BYB+6hRP?7~f%thBW3~qvRC$&z#A{_Ncy{&(jd%{icMa zgO2R^wURhdlu@KIqJS3`FA{c1roI@j7zsizQ&lN!pE#LR?wX&z2@&*u2C9%~Xo_0= zKn;{?Ug{eNLSH#4v2ud~v78{^Ydq~&+;vJ6dVz##J?QhIStLGFv|6T&p(;3n5&67T z_8IUM9DS;Qi!pCH`kucK4fsLcc)CFA2{12}PL>mM=-+q4Mn6}&;x;bPw@2Ps8mHjQ z!L*bQGF~{sEJloo$O~aVc#MDlIRip#SR6Ib|sW}U(FYszR4h8rMO&FXZ9>{{y<^*0vF)-yz z>fyPA@8S1pZR{qbX)wQo)m z1QKi2vSQk^@@_OJ^2cP#zxedNpy*3Gt083XihvB7zYmWh8}2!rVmmpj5Jz#0z;7@j zrT!yFvftNlU|4r@SSWm{gulfSGt*AztTN2Vy1^~;e;yOluhmGdaaekbXq z42H)!Mf(G$$xhbH13{`hrOIFEIz*MNsuVMJuTrk@)III5QbH_mTlRgpfXIa64lspU zW+^y#GO!E-R@H|KIE*pAn}eqAKv}ZM1J^cX!D9m+>poBdkFX6WG>RRNVOg{4X(T?f z@WY4eyg5=PlwG-_{Yi|ZwmqTmxQLqK?l7k9ZYycn4|*!DLeSUXiQ9|xcFx-l1M(mnj`kYCQrq%P`^#fa`r%bv)i zUW^QTA77O#2mYk5)Nkd!$|Y$ht`87nHg^Z`dLll7>dOO!38iLmEkS8W6hbxRU`as_ z5o}eydNvoL05Ao@4Aek90#(h@l}d>DCA<@^`}34jXW2yRtuTIlP{P%g=3!VZwDEv| z_64JElltRA!-QzRUG^A#+G(>@Dl8_Q7T}jA;CO-k1M1*%T$uC+p7a^$vz7=#2sg*1 zVOXx#IM0hI_DlnI`rS%(@O%aV=+`e` z)iu4um`BLw^WnSA!Y?)hBO#neVpX-&$tgN?)6t-1rJRVU)D8ihmdiTOgmrqt^xX*u zpIIxdduKo_>;&eTX;euw>;rK|6k8hk$Z9_NkqJ|2bsB0 zl`IRtWFA8EqKIba=w@tv;2n(p5=4s3Kww|e>`jR!qXpy^!6F(d0nX`@>&0iTabmv(tJn0Re6~McXzU6Tb91(^epSzHcktHE^=f(I|o5ll(g?bqk1Avul$+ zE{rEMHOxj6z(tzw4HugLFPHgrUTtw9aDK3gKG0!hX~Ykv9%nxcI+?_Uhv86+EY|7e zMX|00?%6NI^xiV_Dqt|h8tFL1Yt&Nk z2f`+7%MsluufiZq?YDSQ0OY!1uN>15s*ME3b3k6t0O9cgq^HaJ0IPC$H;f5prqfoPyASp%e1qu_&R19;dTtFH{FuwH) z?!ZEi_VvVyag#E|aSUjXTgVN@T~5+KK-4ucoodx;mg4nssjqESg+`}BENnIL0wvsV z`_^Z=PV+vKfY*uRO3+53!v?FPF4Td4=8Sg6w=}&G4&Tl<0u8b`@9;3_$NAfCpOvLT zbe|?hD^VvJgo_9w-5i*N_*1K3YH;^|GeiOSWVP81f%*g^{CM;}jd+zB`Gl>8uQ79_ zl1^NN<3SDR1L_hglm4v2dN@^DHg1Mh+nVE$C2=~C0@vV%X@6gKo^!-jRpJWHf)J$| z+-`4V(dCLrV@%5p|pxYIb! z>9AE!o?>0-qgkmHXjezS%wmi2(BU&$C~g8FFxthsmWOJx8{*r4ra85AGU?)UUtIEm zkt+XAm;T#vK0i-??^yOq5rcmzqku9eK9Mzr2@#qKNepEehY7~SR5hl4^=E#MkDs;Q znza(PSOHN(cVr2`?F>tz8a{R)?+5`zl)NfWvHc4zb>aLfuKIDkPBouu~Xy$VsUqxP4bsiXi^j@dcg=U|b8AcF^D$^DHskVI{&CQJ2NJdNiT&y`)UZI!2$u)5 z5)x#eabgx&GYAoHx3iJgqH#T3+?;3m5W*t}i_(yCq78tarV*Ugd*a$A@d%ZUuO&>E~ zfXPSHhjT$(gtoo-2;7#NUS%;8gibaskb2sX{|gu7)BXgDTS=VqLfzri?|h1AVHiH0 z>Y=RLPg{$7r#b?}Zk)U>7Ln@WS1yYNqm1ORbGbPC7j{w&5!Fc)iwR9SE+VM9Q$wIi z&68yb`zxn-1N9>|q;g4zkv46zSG54G3ouPcLy|47esD1Hj4DbW;&49{rnmy-V6`bp z9GeHPI3*(+DT+5n(m&-!C(!CX5+&6m5`O!+JDP%0e}N_!e{R4? zxr;t9Dx2f#JAXe(lTEgsIVV$(PyGt`Ti4Wxb*!BkJS}6km=4%Nj#-b7Jn64<0>*Pw zwp6Z0p4Vp%DHEI;wz);rRH0tb+Yv@HS?LtpB8;eH+SMO3PIZO6=f%sxg(CR1L7klr z!`7>(sKFUcqykr$v~YZu92xq^xe2<3I*>q^SGz^H(z!3I24HY&tJ)PBN?NLirBG2O zx#gN#U;Fgr0^a^bt;Rs#kYq%{B!->4FC^3wi1X5FJqTf&U}Te*JUUt&J?#b7<7)1P ztca1T3;gfm?%YdDsL)C&1h>b%H(du-0qbC_Y!Yvg-#>ham?q}5l?V= zg`G)qu4P)f-2A+E9TNpdDFt_Ak0u|Z%* zjUU+0>9Oc^N>HmVUS6LTS5D4J{!6B;;xphZ-_8yL+}WZS;+yxB2RsRpR8MnYB96M9RM( zVhMiIhKmTDI}ukRaKwkiague*ot&Rnc!!hI6|?XiSnN1p_?+BN!fPr5UvH7 zZ@%in{W5wzQM#yE{NB3%V)*S5pZmk~o);Z|F05oydzYl#XKuLBavlYcK24UuMka|gvppWk$JTV)0Z7&wiTIiK8~oSp&1a}I_Tj9?Wy{ZJt=EvZzAA12&%Iy?8BQ>9m|!#) zNu0L*X3rs-lLV|0`t*00ZHRkySpu+S+6*eV*nsr?6=m~>xMGwni*ft%G)MLV#bP&N zgykPV;+uCTDT5`J4Yzb-^DY{5Ht+k|aCOyBEPti)NrVUtz8nAExu5cGTi&e;P(SKH zhK|e=CD7Es>p9~%!qIkhGsHDDr0%sW3Y?_fZx^e@x7zp4zLj#GkR8JOof~gI$->Ay zGPs08i_e2uSJ)aFidNoct2a;y<3>=+H4Lru(CI7988IRQgl={qRjfG#U#A{hPpRzH zOWdl~+f7=}F0QVe5?1rjI0+83teKmQFWh2>bLV99EG;u4GhEIeW=qtzubONSjfF{& z7yJ{Jt_6&i54#8oCzcwC;Djn}vZN?hoHpX}E!oB#D*Be%RH&2+R;Q_T+Hw3y#RJSX zIqO`$i-)S{ZkX&QIId{vUPODvJ!SBQLhV|rXYWqBmZnVH>_?C*sBq>@KO=Doe<_;! z9-AP&pL6uWX(>*{fiGrRM!%zz-JfivqI#jL0dydt(!I8;r>mJfs#n1?t-k#_K{489 zg$7aG>1e-txKlCB;e?kdL#1a>TE+pfL1aLdx){c>j6OgSpW8w=hrwpC4Be4_4Ga zgznw)FNHF8>SKP=NU@NMzE?hdSr31&4nuOv9|0wb#25{tPqkb63hv+sx3Vo9=+X68 zrrK+aD(JS{xzt~$dy-6*6?|;u4697N50X>FObzl$w{GNoL>!W5nd8hhzYl<>U^Zq0 zdD?!RX00-a6sY?@6SwO@dbKoxzH;%ZOqJ&NjzgB&)rOz7)_q~K_QJnDXPi((_H|#& z0)PAbKBxDH#kgs`EpR~_MEh=Li!ee=TcMO%wAj(s?=qJ}_1iPLlBp!iVJEm{c)Q^o zQgz}Okr#0VqNwuicI$01PTuBcg52i!MnG>X%ySF8)VVYG6IAXqk365A)@i6}S>UE} zRzBJHAk(rsv@iqW*I>L02e=&x@1|XpK#7w2Idy*hXdNy60^$OiP7A)+#}`5<>08MK zD3f9stv)mc*&g^ZPSZ^}HZ#tk62M;vm-(XH4$*-kbU)u1@$EK1ql1E=rmfR!E#D2Z zhPZqF>YruuNwH_O;Y*VtkGXtOE5S%9V~Yu(tRWRI>;P8GEbgY*zD}_?3uVFoI9nl> z^2!jLs(*xe%@TvT)b^qp&*PrG&6)_Li0$?Gd@3_a%4;wTSYZ-5iSgQ|Vll7`4?^XX z_9D*j!$XVsn*+SAK$pTSi67D+79sa$f*KRd5#7ey*Cli|Cst+%Y4#v$5elDLuxtAQg=kB$bZJmXV1n$u6sDBxeC|aV6&&~CYHWTj#7q@d)d#TeZ1iMnl&xkM@BA6l2g$V?U#)-f#VdcypU*zXV2JgyW47h#1cLwO(e>CK8Mpyw!d|fYS5?Yr z-*!1vgy_Ye)#ZofLzw>!7l~p?2IL=H{eN(2{=dNeht<;A^gnc?;|1jd88Jg{J|k{8 z6Y$Uzvw2ltOQIE;Xv}!x%~Y&|STWP%J*`3=Z29WK?ZD*LOO%dqWIx- zp#OHO?3Bvp>3JPe*PJVco1O#s4GE|JC;2Jpca?=MW94_#ajE$Bv|uP7Y!P@#R=T{22d36VyLv z$F*0N^gJp6;3NzH{4;p`k25d(e{`G}{_ok>e_S5jI@(T~VkthWwWs!NAdHil*5jE9 z$juR=!#S@7@vL&Ps0=Wm*q}m)c{qM1Tn81rGSd%BhiuIse>ZaDyzdszb6-<0xx9rz zK&hynV=9eM1oUUPyy3Irf%2htSDpeM zC(bN`t7n&dIylvtBg4>RZ+bB85=q;Ot5pM8@q%o9mm+1E*wfQ<$gwWExe@7o=v(H= zGM)g+B``>%v;SB@xqCCPRF|QP(DtzCOm8sM7gz{OTEo~)LbIO`YB6evi$nmA#7n_# zFpJh7{7{w;Y@`IKZe#nZ-%t2=C}YME?GBM0M}|{I$xIUPnFGEk4~{Hfu+QNT`)Fxl zB1U1N(}l_7D1Wrv?1;R$d29{=uy0oV!AYrk-(f5c=(mmn`n$R@hiT9D#(r%D>_h6S zxV)L}58E^4K>@iXrl+)*4ym;e2*rfOR`XDu9aOZE75qt?X4)Pz>!`$HJDFaY%i260 zmwE+TLmW5860_vN!88g28On%pDAUP=+TdSLHjB(YR`--X1eg`8>-~%}h$qj4P(|>_ zp9WdxltuJ)isX85k*Egc5(DHB_)cJKX7m{woI|9MMfCZkup6y;t)uHKS*{`Tu@X_A zA5S9&%dy-sdUU-E!FX4 z42_0S08d?@l=KZpsSqrc~AVBh0 z7_stVvvLIZyddRH^DV>7YNSq)J4#K#D%ZS1=rfiH=8QplZS4;mJGOxg5mz5yd|r?T zQyOy1fwd=re2~xAjRU9$Z$;c^C27u`W=iY+v+i~u9MfFk$gb>if0^{1(mq(Y^-Y5N zReE|wK3h(mEiEqt74a=92;G?(K6*&UN%&U zigo{0ox!ta4`Y1!d}VML zd%VHv9(WlPVd?NdE{0_4l^WlCH$_1P zNTMS4QyUU97oP<@Of`_Db}Rnbkp|)bS*L^8vhERZ0f`RJZqL>$KA@@#o;7AL`2+JN zxn03*kcQ3Rs8B@;?iE+e4!=Z>0Zkn3F9p$|{?679ZO~DG^&<`X3o~!wefNPA`apA= zLRf_JxE+j;aZ))rc3`cPo;!|AFmOA|pRkRb%g^sG5Em>J*Dyq=JKpE1p#D^#p zB6|B2mI3mF*`6a^>(B+!(%(YpNu+#9?a?32Uq; zFm49>KzbOGoVq3zCt_0MKfjt_@u5Hr!Z8Xn^+Czm<7g>mvG@O=ZA4Lg_ailBfG+USqr#h!}7#K zw=d$XVIm*S==A6n?PF~i#G{J}Qr=(K!cfKrGzFe8+7ipjvN6!veX&MhF0NGE7i5Q3 z-8nLk5GJBI^43-0xI=bXgjUru?;II%$B^!aD$#=!ysZ)b{%L%NE-N%lT_993*2Xdx zRCFkj_BC3$S2}`F-q%tj3NRv3gFA^-rRelNpazc=k>AO`D8Tzd7 z0V`a0D*^R)`1*bWH3?-T7DDm3(SW zqVRL#LuY>;FQ>k{L}TP~_*Iz-8PP$(%ON!q%+r;PyS|?1PfbLczOE(-X^f%dHnAe6 z(&=6KQ|%N$PZ(5lVegIy{R)x|EG1772*yhRh6Ln_e6gzW!IM#AXxO5krxDI^u_wQ3R5H$FS$nj8oFhPWqAb2%==qa@h7)) zaPi9e(O@}l)oowb34KUZaVHd^g(&ySx%oh_Zk*iS5;?p+@2^qz3Pq2Rt-Rdac;PQC zf3utglE+RajXYdV$ef?f4b6M3AQXe7pIZvbOJ3$Cv^b&YKGnHQ)qhaue`Dd)KxFQWkSh*=_ik=|7x?b}hUNdfZxkRcz#yBUg<<&PS&*0j;a9UPEZfp@ z^U`Y#J2`kr)rOe5YSVabuhk{-{qr3=?$3DPM3cT9@fjM=!tVxD4j~X^gl#mrtXdeZ zL_@Eh8741EsboWM-(P1EHLayOf+1IxVh3-Vm!?J778wM_`$F`mLBQZ#gpLrxV>{p< z*p4FNNUT=v4)MJC?7;BZx$5N9m^C0UDXv~obQN{7TU<@c{fs#4Bu?TD-kc#Dv;a|Lc? z205JPzW&09ooswJhm_%}yaF?=y^{?@ynEDR9;Gfc&{v>89+h;SUDKjiLYpWoZ)wj~ zYgEK<6o?7v-F)&iS~*4!o_)Hcx{NrJ52df4^5+bl(uBu3r)VNh=}j#Qn| zT}&3&5Ep9f$`?}~<*eRBXMsTg*jNR=xbu^4ySYyBR37p511L1K0Ph6BX}0qIm<*1N z`cwlMPoZ~>OiigBt^9ox?KqI;rppzt+~gr~NHD{A*W3~BlIR=~ePz9?h>uBs4>`@_ zbw}7aZ4S_ZQSQdTV-@4M_pv2`BsiPjX@y=#Mu#@=7-TTwo!D+k-aX9EX~8qs5OcXx zVe#*3@hG569UvBB-MQ_l=wrg>eFlzAo!`!hwb@)evTq)~A`*68(vSVqmLlx4|>mwn8oeG9}s=5yahNXE{o) zjneHs;Q2p|oOdu>{rAT=gdnSnUSkE>C{e<*dJD3mL|GO~g6I-uMK94?bRwc;i6vUF zqDMAbJW8|`k|kCPQM1B|5?{~nnfda4#&>?-d;hrenYrgRXXe~Ff1LL{??IQcox9hK znXKFUf)Tn>COPOdb0A2jCGuuIIY4e5UYX4Dtm6FiZI4&Qus+Sa{;Z-;E(#&MVe0_` zx7Dn^cJ{u{cHn*csCq#T_max#>i1aQyshTQFr(`#(}i@hsoE8Elp?q8wtGwD1-Hd6-^{0t$CF{Jn!$Ri4U1SF4PV+|&B+)j( zGHJukVPO={EMybM<6w;>H&7~#ufj+JX}2aT-_4tGq)+_kR5Izj=**(?Dp~6d2IN4> zd(u%=e48uWDq)xoGR6Ee&D^cshusr322k{>o1Y6tRiJN-BdL|9@Ug?y zCZ<8=@?&r|xNlvDV3%dm)&VB1l66^2i9K>}_zrW_qJe}AeRAjPSDpDf%`lpN6WjGv z;0E~t^g^+>o)apF=je6LDxvtKTmI+D20}{S%)(3Owh6GcWO(G?TH{ z;%*^B5mU{|w@}3cm=B^LUz<_p_etv&(&6>is?|R9FX0raDQg-xL~`S=@k{|!1tfWw zxFem}v_>xN*fjvrZD1Ekm(A{!2(x;iXyK*dcS}Zb1TT$5zD=xH_I(iQp1&eoztN&vh?|pM=oNdZM&X4RiwGg8`&sLHS1%76=-3Ww=dx3uC`iWIHz z%88q56qq!5Rx1Un+QD7@;1Cxd4tTWP-szNLJy#x$K~Ecs>ht}1*E%z7^?%P;$)gon8+wcC&;|i$iMNXbpu5-6 z#IPncKeb;mGI6`9Cf3GSATv=nK4zoy$G$a$uiLhqAGs@3l^HubD#Ti->cCCa6VrRG zpvd?=Z4L`qUm@k)CYSIK3uOo0%OoyEZr1ek$?O=RAcpu-^Dej;pFl(7#C-chyo#TDB@O3hD&lW&z)?=vMJlw5i2%R z0HO3Wu{L_z&zUL048os-0yzr?pF2bSl-Cc-Cb9qqc{BY2v55R(vCIW@O5Ef5aG$~% z2IcRjg^T&JInn8ffT>!Etb=b1a-P|gu%BeG3^wLw5233hw%E(caKd%8L6W>aAx+LZ=72$5+JJ98U~}6*UXj5it}T1 zQYOd}d;*i6B7A*FmaEej-OPzs``*-y*OcyGi&o)ywnBIHyM}t@V{gxj7o4FkfWlNM z_A-Ekuc;aI>r(0vMRJw_|3L&|I2L^QK1A=PuZNfOtYfy{H#*!9e}}zUu~h(1yaml- z(xWZO#dQ|IJYp`Sz3R%V@j@SRXLE8TImUn+(Js=YEA*nt1olA&o}5iy55S}b2P3O6 zk_P5X#X?WhYi+XdRL?q7OO%D?foPOKfcscRzM=Mjh~P$0yd~CerS7e!k71$8y_J@i z?&fBIKFui!(~los$9cjR_ROL{O8BeI@huhGoy!}YtnW9t)y)mC>*09z!h?aWuQG?U zbqT##+4dJkiGe7uvUgiy2KskzPXutzSK>`UrO=jb7TfF4%a-`^rcNF9+|itEdE=%| z_mP`PcOQ6V@)4pTr6TQD5<-lHX%}DA27JsJ{*?3DOg2GNg;-`)!xF1K&CiO1zB3lr zYAIHAmN2lOe@miqXsSu8X)?7>j7Q&ELrhYsI6G6_M1;5l+anSoG>nJ83;CmhZ#gz$ zK5-pnaNfja;{-|iUp^~T`Wkc#;o%=K4gn=_{m!J%-J7nyhb!BmMrOs4LiW&Aw9Qn1 zGPm9z9KTG6^_d2sh*zgUx>Kyehi$i4`@P$%Nf3iP$;9NKs7Ai*WQf&?4xJvw(XpZ#kCe}f^` zCFOVy?{++g|4+-Shi*tm=f4;5O^w<-@RGpk4N)osu^H*KCsT=tFZMc~T67libeP*; z9t&L+5F*zaZs?V^|8tQI9Q>Ab4fMT8Gekw@5g=+$k!9n>(>w;(1gi{iKf6$dVLIgFrtmr@Dzn@FE)!OTbCQ^AhjdS;=hex4=4I1qcOO}_Sz zDjsUOrym%zhlaQ*K3vB)MUJFEL4{9j{g(qvOt9LH#$5f&L|TqRdZAcBW_<0HpS&=s zu%?(NWcYM;Z$Ix0d8%ktA-nxZi{)Fn<>Q%mLhICh7mMODbu z{QZci{MxQ^%V#hgj*_c>#wW>tEePDrE?hanYHReK!l`TIjis+C|Dfxntm(u14X-`V{$=2L@3rNlya$LfY;}RZEwNH<;|1RP26x;m{)@4ePp>-eph9L!5^|h_q zkas|!DHAh)CqAXJUhHzUwR0x%4pHt`B7QmY-m{;Y4E-TVilyUPXcN^z3b z@NGtSg|Odcen{g6GD(OjHd^y}1lv5@#CgXKnW>D;27txZUSK!m_!rjdE}CXNvHdvJ zFYIfKgZBf0=}0bji?`&5=X!FI1Hcbh#kF2tBj9$Lm2f`EZdOHoG?-4q_o2P}ri+Nd z{n<8cTz8xcwLzO~vqj(~>(6baVtHAO_KjbMrN!;!8;3`)N!Ps~`;)PG)GkOpc-F}R zl{q*#C;jagqrC19ar7wpR|<(?Vc_jqFKNyiHpors%MO)@=@F@vFLG>us`+GE|IF~) zzhDSGN2Wsl-+ktei}ruloAAFL|CS$4yUv|PoaLPHHvj;56YhCzV0VHz?W1v;an^?G z34`|dJ37Vqd*AhR_*wesNjRO<>G1zgE&V6=pVHt7*H!v|;GPIbr@?3GWGCPTng0qt zQKtSa@BWkfPmSw@Yb^UeaL-QoETQTI{Ov!$XUJBkb3CihpP;sGozC%8*FTLotIwPu d-sqiyIPN-zPzuVw-l93acaOW%ef_^q{{rj$5z_zw literal 0 HcmV?d00001 diff --git a/theme/plugins/virustotal-1.0.0.zip b/theme/plugins/virustotal-1.0.0.zip new file mode 100644 index 0000000000000000000000000000000000000000..c2461da73d35dd95b020e1b1208c8da33ce14627 GIT binary patch literal 19684 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

l6woK&x#d$PxVWe9K1xNkIhY1=&c^ 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 literal 0 HcmV?d00001 diff --git a/theme/plugins/virustotal/README.md b/theme/plugins/virustotal/README.md new file mode 100644 index 00000000..b9a0b970 --- /dev/null +++ b/theme/plugins/virustotal/README.md @@ -0,0 +1,45 @@ +# VirusTotal-Anhangsprüfung für DeskHub + +Jeder Anhang eines Tickets wird **zurückgehalten, bis VirusTotal ihn geprüft hat**. Erst danach ist er für Mitarbeiter herunterladbar. + +Der Unterschied zum Plugin **Anhänge-Virenscan**: Der Virenscan prüft sofort lokal und fragt VirusTotal (optional) erst **nachträglich**; die Datei ist in der Zwischenzeit schon verfügbar. Dieses Plugin sperrt zuerst und gibt erst nach der Antwort von VirusTotal frei. Beide laufen nebeneinander (der Virenscan prüft eine Datei erst, wenn sie hier freigegeben ist). + +## Ablauf + +1. Neue Anhänge (Ticket anlegen, Antwort, E-Mail-Abruf) werden sofort **zurückgehalten**: Die Datei wandert in den Ordner `uploads/vt-pruefung/` (nicht von außen erreichbar), der Anhang heißt `[IN PRÜFUNG] …` und lässt sich nicht herunterladen. Im Ticket steht ein interner Hinweis. +2. Der Hintergrunddienst fragt bei VirusTotal den **SHA-256-Fingerabdruck** der Datei ab (nicht die Datei). +3. Ergebnis: + - **Bekannt und sauber** (weniger als *Schwelle* Scanner melden etwas, Standard 3): freigegeben, alles wie vorher. + - **Schadsoftware** (mindestens *Schwelle* Scanner): bleibt gesperrt (`[GESPERRT] …`), Tag „Virus-Verdacht“, Hinweis im Ticket, Mail an den Administrator. + - **Unbekannt:** Standard ist, die Datei **zurückzuhalten**, bis ein Administrator sie prüft und freigibt oder löscht. Einstellbar: freigeben, oder (nur wenn ausdrücklich eingeschaltet) **zur Analyse hochladen**. + - **VirusTotal nicht erreichbar:** mehrere Versuche mit wachsendem Abstand, danach zurückhalten (oder freigeben, einstellbar). + +## Einrichtung + +1. Plugin hochladen und aktivieren (Berechtigungen `hooks.tickets`, `hooks.cron`, `ui.menu`, `db.migrations`). +2. Auf virustotal.com ein (kostenloses) Konto anlegen und im Profil den **API Key** kopieren (64 Zeichen). +3. Im Adminportal unter **VirusTotal** den Schlüssel eintragen, speichern und **Schlüssel testen**. + +**Ohne Schlüssel werden keine Anhänge zurückgehalten.** Anhänge, die vor dem Einschalten da waren, bleiben unberührt. + +## Datenschutz (wichtig) + +- Standard: **Keine Datei verlässt den Server.** Es wird nur der Fingerabdruck abgefragt. +- „Unbekannte Dateien hochladen“ (aus, bis zu 32 MB) sendet die **Datei** an VirusTotal. Dort wird sie gespeichert und steht Sicherheitsfirmen und zahlenden Kunden zur Verfügung. Ticket-Anhänge können persönliche Inhalte enthalten (Ausweise, Bilder, Dokumente). Nur einschalten, wenn das für eure Nutzer in Ordnung ist und in der Datenschutzerklärung steht. + +## Limits und Verhalten + +- Kostenloser Schlüssel: **4 Abfragen pro Minute, 500 pro Tag.** Das Plugin hält sich daran (4 pro Lauf, höchstens 480 pro Tag). Bei vielen Anhängen entsteht eine Warteschlange; ist das Tageslimit erreicht, stehen die Dateien bis zum nächsten Tag an. Ein bezahlter Schlüssel hebt die Grenzen auf; die Zahl im Plugin bleibt konservativ. +- Die Abfragen laufen im Hintergrunddienst von DeskHub (jede Minute, wenn der echte Cronjob eingerichtet ist, sonst alle 5 Minuten bzw. bei Seitenaufrufen). **Mit Cronjob** (Einstellungen → Cron-Jobs → „Jetzt installieren“) sind Anhänge nach etwa einer Minute frei. +- Bis zum Ergebnis erscheint der Anhang im Ticket als `[IN PRÜFUNG]` und lässt sich nicht herunterladen (auch nicht vom Kunden, falls er seine Anhänge sieht). +- Der **Virenscan-Schutz bleibt wichtig**: VirusTotal kennt neue, gezielte Schadsoftware oft nicht. Unbekannt heißt nicht sauber; darum ist „zurückhalten“ der Standard. + +## Grenzen + +- Geprüft werden Dateien bis 32 MB per Upload; die Abfrage des Fingerabdrucks funktioniert für jede Größe. +- Es wird nur der Fingerabdruck bewertet (kein Entpacken von Archiven, kein Lesen des Inhalts); dafür gibt es den Virenscan. +- Bereits freigegebene Dateien werden nicht erneut geprüft. + +## Tests + +`tests/plugin_virustotal.php` im DeskHub-Projekt (VirusTotal wird dort durch eine Funktion ersetzt; es gibt keine Verbindung zu VirusTotal). diff --git a/theme/plugins/virustotal/lang/en.json b/theme/plugins/virustotal/lang/en.json new file mode 100644 index 00000000..a0bc0868 --- /dev/null +++ b/theme/plugins/virustotal/lang/en.json @@ -0,0 +1,100 @@ +{ + "_info": "English translations of the VirusTotal plugin. Key = German source text, value = English. {} stands for a changing part, {#} for a number. Further languages: lang/.json, for example lang/fr.json.", + "VirusTotal-Anhangsprüfung": "VirusTotal attachment check", + "Hält jeden Anhang eines Tickets zurück, bis VirusTotal ihn geprüft hat: Erst wird nur der Fingerabdruck (SHA-256) abgefragt, die Datei selbst verlässt den Server nicht. Ist die Datei sauber, wird sie freigegeben; melden mehrere Scanner Schadsoftware, bleibt sie gesperrt (Quarantäne, Hinweis im Ticket, Mail an den Administrator). Unbekannte Dateien werden zur manuellen Prüfung zurückgehalten, freigegeben oder (nur wenn ausdrücklich eingeschaltet) zur Analyse bei VirusTotal hochgeladen. Arbeitet auch neben dem Plugin „Anhänge-Virenscan“. Nur für Administratoren.": "Holds back every ticket attachment until VirusTotal has checked it: first only the fingerprint (SHA-256) is looked up; the file itself does not leave the server. If the file is clean it is released; if several scanners report malware it stays blocked (quarantine, note in the ticket, email to the administrator). Unknown files are held back for manual review, released, or (only if explicitly switched on) uploaded to VirusTotal for analysis. Also works alongside the “Attachment virus scan” plugin. For administrators only.", + "VirusTotal": "VirusTotal", + "Zustand": "Status", + "Noch kein Schlüssel.": "No key yet.", + "Ohne VirusTotal-Schlüssel werden Anhänge": "Without a VirusTotal key, attachments are", + "nicht": "not", + "zurückgehalten und nicht geprüft. Einen kostenlosen Schlüssel gibt es nach der Anmeldung bei virustotal.com im Profil unter „API Key“.": "held back and not checked. A free key is available after signing in at virustotal.com, in your profile under “API Key”.", + "✓ Aktiv.": "✓ Active.", + "Neue Anhänge sind gesperrt, bis VirusTotal sie geprüft hat.": "New attachments are blocked until VirusTotal has checked them.", + "warten ·": "waiting ·", + "gesperrt ·": "blocked ·", + "unbekannt ·": "unknown ·", + "mit Fehler ·": "with error ·", + "freigegeben": "released", + "Abfragen heute: {#} von {#} (der kostenlose Schlüssel erlaubt 4 pro Minute und 500 pro Tag). Letzter Lauf: {}.": "Queries today: {} of {} (the free key allows 4 per minute and 500 per day). Last run: {}.", + "Abfragen heute: {#} von {#} (der kostenlose Schlüssel erlaubt 4 pro Minute und 500 pro Tag). Letzter Lauf: noch nicht.": "Queries today: {} of {} (the free key allows 4 per minute and 500 per day). Last run: not yet.", + "Vorgehen: Jeder neue Anhang wird sofort zurückgehalten (nicht herunterladbar). Dann fragt das Plugin bei VirusTotal nur den": "Procedure: every new attachment is held back immediately (not downloadable). The plugin then asks VirusTotal only for the", + "Fingerabdruck (SHA-256)": "fingerprint (SHA-256)", + "der Datei ab. Ist die Datei bekannt und sauber, wird sie freigegeben; melden mindestens {#} Scanner Schadsoftware, bleibt sie gesperrt. Anhänge, die schon vor dem Einschalten da waren, bleiben unberührt.": "of the file. If the file is known and clean it is released; if at least {} scanners report malware it stays blocked. Attachments that existed before the plugin was switched on are left untouched.", + "Einstellungen": "Settings", + "VirusTotal-Schlüssel (API Key)": "VirusTotal key (API key)", + "64 Zeichen": "64 characters", + "Schlüssel entfernen (Anhänge werden dann nicht mehr geprüft)": "Remove key (attachments are then no longer checked)", + "Sperren ab so vielen Scannern, die Schadsoftware melden": "Block from this many scanners that report malware", + "Dateien, die VirusTotal nicht kennt": "Files that VirusTotal does not know", + "Zurückhalten, bis ein Administrator sie prüft und freigibt (sicher)": "Hold back until an administrator reviews and releases them (safe)", + "Freigeben (bequem, aber neue Schadsoftware kennt VirusTotal oft noch nicht)": "Release (convenient, but VirusTotal often does not know new malware yet)", + "Unbekannte Dateien zur Analyse bei VirusTotal hochladen (bis 32 MB).": "Upload unknown files to VirusTotal for analysis (up to 32 MB).", + "Achtung Datenschutz:": "Privacy warning:", + "Hochgeladene Dateien werden bei VirusTotal gespeichert und stehen Sicherheitsfirmen und zahlenden Kunden von VirusTotal zur Verfügung. Ticket-Anhänge können persönliche Inhalte enthalten. Ausgeschaltet verlässt": "Uploaded files are stored at VirusTotal and are available to security companies and paying customers of VirusTotal. Ticket attachments can contain personal content. When switched off,", + "keine Datei": "no file", + "den Server, nur der Fingerabdruck wird abgefragt.": "leaves the server; only the fingerprint is looked up.", + "Wenn VirusTotal nach mehreren Versuchen nicht erreichbar ist": "If VirusTotal cannot be reached after several attempts", + "Zurückhalten (sicher)": "Hold back (safe)", + "Mail an den Administrator, wenn ein Anhang gesperrt wird": "Email the administrator when an attachment is blocked", + "Speichern": "Save", + "Schlüssel testen": "Test key", + "Fragt bei VirusTotal den Fingerabdruck der Standard-Testdatei (EICAR) ab. Es wird nichts hochgeladen. Vorher speichern.": "Looks up the fingerprint of the standard test file (EICAR) at VirusTotal. Nothing is uploaded. Save first.", + "Zurückgehaltene und gesperrte Anhänge": "Held-back and blocked attachments", + "Nichts zurückgehalten.": "Nothing held back.", + "Zuletzt geprüft": "Recently checked", + "Noch nichts geprüft.": "Nothing checked yet.", + "Datei": "File", + "Ticket": "Ticket", + "Größe": "Size", + "Hinweis": "Note", + "Ergebnis": "Result", + "Wann": "When", + "Freigeben": "Release", + "Erneut prüfen": "Check again", + "Löschen": "Delete", + "Diese Datei freigeben? Sie wird für Mitarbeiter herunterladbar.": "Release this file? Staff will be able to download it.", + "Diese Datei endgültig löschen?": "Delete this file permanently?", + "Wartet auf VirusTotal": "Waiting for VirusTotal", + "Wird bei VirusTotal analysiert": "Being analysed at VirusTotal", + "Unbekannt (zurückgehalten)": "Unknown (held back)", + "Fehler (zurückgehalten)": "Error (held back)", + "Gesperrt (Schadsoftware)": "Blocked (malware)", + "Sauber, freigegeben": "Clean, released", + "Von Hand freigegeben": "Released by hand", + "Gelöscht": "Deleted", + "Gespeichert.": "Saved.", + "Die Datei ist freigegeben.": "The file has been released.", + "Die Datei ließ sich nicht freigeben.": "The file could not be released.", + "Die Datei ist gelöscht.": "The file has been deleted.", + "Die Datei ließ sich nicht löschen.": "The file could not be deleted.", + "Die Datei wird beim nächsten Lauf noch einmal geprüft.": "The file will be checked again on the next run.", + "Das ging nicht.": "That did not work.", + "Der VirusTotal-Schlüssel besteht aus 64 Zeichen (0–9, a–f).": "The VirusTotal key consists of 64 characters (0–9, a–f).", + "Bitte zuerst einen Schlüssel eintragen und speichern.": "Please enter a key and save it first.", + "Der Schlüssel funktioniert: Die Standard-Testdatei (EICAR) wurde von VirusTotal als gefährlich erkannt ({} von {} Scannern).": "The key works: VirusTotal recognised the standard test file (EICAR) as dangerous ({} of {} scanners).", + "Das Abfrage-Limit von VirusTotal ist gerade erreicht. Bitte in einer Minute noch einmal versuchen.": "The VirusTotal query limit has just been reached. Please try again in a minute.", + "Der Schlüssel wird angenommen, die Testdatei wurde aber unerwartet bewertet ({}).": "The key is accepted, but the test file was rated unexpectedly ({}).", + "Die Abfrage ist fehlgeschlagen.": "The query failed.", + "Keine Berechtigung.": "No permission.", + "VirusTotal: {} von {} Scannern melden Schadsoftware": "VirusTotal: {} of {} scanners report malware", + "VirusTotal: {} von {} Scannern melden etwas": "VirusTotal: {} of {} scanners report something", + "VirusTotal: keine Funde": "VirusTotal: no findings", + "Der VirusTotal-Schlüssel wird abgelehnt.": "The VirusTotal key is rejected.", + "VirusTotal nicht erreichbar (Status {}).": "VirusTotal not reachable (status {}).", + "Unerwartete Antwort von VirusTotal.": "Unexpected response from VirusTotal.", + "Upload zu VirusTotal fehlgeschlagen (Status {}).": "Upload to VirusTotal failed (status {}).", + "Zur Analyse bei VirusTotal hochgeladen": "Uploaded to VirusTotal for analysis", + "Die Analyse bei VirusTotal wurde nicht rechtzeitig fertig.": "The analysis at VirusTotal was not finished in time.", + "Bei VirusTotal unbekannt, freigegeben (Einstellung)": "Unknown at VirusTotal, released (setting)", + "Bei VirusTotal unbekannt und zu groß für den Upload (über 32 MB).": "Unknown at VirusTotal and too large for the upload (over 32 MB).", + "Bei VirusTotal unbekannt: bitte selbst prüfen und freigeben oder löschen.": "Unknown at VirusTotal: please check it yourself and release or delete it.", + "VirusTotal nicht erreichbar, freigegeben (Einstellung)": "VirusTotal not reachable, released (setting)", + "Von Hand freigegeben am {}": "Released by hand on {}", + "Gelöscht am {}": "Deleted on {}", + "Datei nicht gefunden.": "File not found.", + "Die Datei konnte nicht zurückgehalten werden (Schreibrechte im Ordner uploads prüfen).": "The file could not be held back (check write permissions in the uploads folder).", + "{}: Das Tageslimit des VirusTotal-Schlüssels ist erreicht, es geht morgen weiter.": "{}: The daily limit of the VirusTotal key has been reached; it continues tomorrow.", + "{}: Der VirusTotal-Schlüssel wird abgelehnt.": "{}: The VirusTotal key is rejected.", + "{}: VirusTotal nicht erreichbar (Status {}).": "{}: VirusTotal not reachable (status {}).", + "{}: Unerwartete Antwort von VirusTotal.": "{}: Unexpected response from VirusTotal." +} diff --git a/theme/plugins/virustotal/lib.php b/theme/plugins/virustotal/lib.php new file mode 100644 index 00000000..5fbddecd --- /dev/null +++ b/theme/plugins/virustotal/lib.php @@ -0,0 +1,420 @@ +query('SELECT sha256, analysis_id FROM plugin_virustotal_files LIMIT 1'); + } catch (Throwable $e) { + try { + plugin_run_migrations(get_db(), VT); + } catch (Throwable $e2) { + error_log('virustotal schema: ' . $e2->getMessage()); + } + } +} + +function vt_config(): array +{ + $key = ''; + $enc = (string) plugin_setting(VT, 'key_enc', ''); + if ($enc !== '') { try { $key = decrypt_secret($enc); } catch (Throwable $e) { $key = ''; } } + $unknown = (string) plugin_setting(VT, 'unknown', 'hold'); + return [ + 'key' => $key, + 'threshold' => max(1, min(30, (int) plugin_setting(VT, 'threshold', '3'))), + 'unknown' => in_array($unknown, ['hold', 'allow'], true) ? $unknown : 'hold', + 'upload' => plugin_setting(VT, 'upload', '0') === '1', + 'notify' => plugin_setting(VT, 'notify', '1') === '1', + 'on_error' => plugin_setting(VT, 'on_error', 'hold') === 'allow' ? 'allow' : 'hold', + ]; +} + +function vt_uploads_dir(): string +{ + return rtrim(str_replace('\\', '/', (string) (config()['uploads']['path'] ?? dirname(__DIR__, 2) . '/uploads')), '/'); +} + +/** Ordner für zurückgehaltene und gesperrte Dateien (nicht von außen erreichbar). */ +function vt_hold_dir(): string +{ + $dir = vt_uploads_dir() . '/vt-pruefung'; + if (!is_dir($dir)) { + @mkdir($dir, 0750, true); + @file_put_contents($dir . '/.htaccess', "Require all denied\nDeny from all\n"); + @file_put_contents($dir . '/index.html', ''); + } + return $dir; +} + +/** + * HTTP-Aufruf zu VirusTotal (für Tests austauschbar über $GLOBALS['virustotal_http']). Nur Adressen der VirusTotal-Schnittstelle. + * @param array{file?:string,name?:string} $opts bei POST: Datei für den Upload + * @return array{status:int,body:string} + */ +function vt_http(string $method, string $path, string $key, array $opts = []): array +{ + $url = VT_API . '/' . ltrim($path, '/'); + if (isset($GLOBALS['virustotal_http']) && is_callable($GLOBALS['virustotal_http'])) return ($GLOBALS['virustotal_http'])($method, $url, $key, $opts); + if (!function_exists('curl_init')) return ['status' => 0, 'body' => '']; + $body = ''; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => $method === 'POST' ? 120 : 12, CURLOPT_CONNECTTIMEOUT => 8, + CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_HTTPHEADER => ['x-apikey: ' . $key, 'Accept: application/json', 'User-Agent: DeskHub-VirusTotal'], + CURLOPT_WRITEFUNCTION => function ($c, string $data) use (&$body): int { + $body .= $data; + return strlen($body) > 1048576 ? 0 : strlen($data); + }, + ]); + if ($method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, ['file' => new CURLFile((string) ($opts['file'] ?? ''), 'application/octet-stream', (string) ($opts['name'] ?? 'datei.bin'))]); + } + curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + return ['status' => $status, 'body' => $body]; +} + +/** Wertet Scan-Zahlen aus. @return array{verdict:string,malicious:int,engines:int} verdict: bad, clean oder none (noch keine Analyse) */ +function vt_verdict(array $stats, int $threshold): array +{ + $malicious = (int) ($stats['malicious'] ?? 0); + $engines = 0; + foreach (['malicious', 'suspicious', 'undetected', 'harmless'] as $k) $engines += (int) ($stats[$k] ?? 0); + if ($engines === 0) return ['verdict' => 'none', 'malicious' => 0, 'engines' => 0]; + return ['verdict' => $malicious >= $threshold ? 'bad' : 'clean', 'malicious' => $malicious, 'engines' => $engines]; +} + +/** Fragt den Fingerabdruck einer Datei ab. @return array{result:string,detail:?string,malicious:int,engines:int} result: bad, clean, unknown, none, limit, key, error */ +function vt_lookup(string $sha256, string $key, int $threshold): array +{ + $r = vt_http('GET', 'files/' . $sha256, $key); + $base = ['detail' => null, 'malicious' => 0, 'engines' => 0]; + if ($r['status'] === 404) return ['result' => 'unknown'] + $base; + if ($r['status'] === 429) return ['result' => 'limit'] + $base; + if ($r['status'] === 401 || $r['status'] === 403) return ['result' => 'key', 'detail' => 'Der VirusTotal-Schlüssel wird abgelehnt.'] + $base; + if ($r['status'] !== 200) return ['result' => 'error', 'detail' => 'VirusTotal nicht erreichbar (Status ' . $r['status'] . ').'] + $base; + $j = json_decode($r['body'], true); + $stats = $j['data']['attributes']['last_analysis_stats'] ?? null; + if (!is_array($stats)) return ['result' => 'error', 'detail' => 'Unerwartete Antwort von VirusTotal.'] + $base; + $v = vt_verdict($stats, $threshold); + $label = (string) ($j['data']['attributes']['popular_threat_classification']['suggested_threat_label'] ?? ''); + $detail = $v['verdict'] === 'bad' ? 'VirusTotal: ' . $v['malicious'] . ' von ' . $v['engines'] . ' Scannern melden Schadsoftware' . ($label !== '' ? ' (' . mb_substr($label, 0, 60) . ')' : '') + : ($v['verdict'] === 'clean' ? 'VirusTotal: ' . $v['malicious'] . ' von ' . $v['engines'] . ' Scannern melden etwas' : null); + return ['result' => $v['verdict'], 'detail' => $detail, 'malicious' => $v['malicious'], 'engines' => $v['engines']]; +} + +/** Lädt eine Datei zur Analyse hoch. @return array{ok:bool,id:?string,detail:?string} */ +function vt_upload(string $path, string $name, string $key): array +{ + $r = vt_http('POST', 'files', $key, ['file' => $path, 'name' => $name]); + if ($r['status'] === 429) return ['ok' => false, 'id' => null, 'detail' => 'limit']; + if ($r['status'] === 401 || $r['status'] === 403) return ['ok' => false, 'id' => null, 'detail' => 'Der VirusTotal-Schlüssel wird abgelehnt.']; + $j = json_decode($r['body'], true); + $id = (string) ($j['data']['id'] ?? ''); + if ($r['status'] !== 200 || $id === '') return ['ok' => false, 'id' => null, 'detail' => 'Upload zu VirusTotal fehlgeschlagen (Status ' . $r['status'] . ').']; + return ['ok' => true, 'id' => mb_substr($id, 0, 250), 'detail' => null]; +} + +/** Fragt eine Analyse nach einem Upload ab. @return array{result:string,detail:?string,malicious:int,engines:int} result: bad, clean, wait, none, limit, key, error */ +function vt_analysis(string $id, string $key, int $threshold): array +{ + $r = vt_http('GET', 'analyses/' . rawurlencode($id), $key); + $base = ['detail' => null, 'malicious' => 0, 'engines' => 0]; + if ($r['status'] === 429) return ['result' => 'limit'] + $base; + if ($r['status'] === 401 || $r['status'] === 403) return ['result' => 'key', 'detail' => 'Der VirusTotal-Schlüssel wird abgelehnt.'] + $base; + if ($r['status'] !== 200) return ['result' => 'error', 'detail' => 'VirusTotal nicht erreichbar (Status ' . $r['status'] . ').'] + $base; + $j = json_decode($r['body'], true); + $attr = $j['data']['attributes'] ?? null; + if (!is_array($attr)) return ['result' => 'error', 'detail' => 'Unerwartete Antwort von VirusTotal.'] + $base; + if (($attr['status'] ?? '') !== 'completed') return ['result' => 'wait'] + $base; + $v = vt_verdict((array) ($attr['stats'] ?? []), $threshold); + return ['result' => $v['verdict'], 'detail' => $v['verdict'] === 'bad' ? 'VirusTotal: ' . $v['malicious'] . ' von ' . $v['engines'] . ' Scannern melden Schadsoftware' : null, 'malicious' => $v['malicious'], 'engines' => $v['engines']]; +} + +// ── Anhänge zurückhalten ─────────────────────────────────────────────────── + +/** Ab welchem Anhang wird geprüft? Bestand vor dem Einschalten bleibt unberührt. */ +function vt_start_id(PDO $pdo): int +{ + $v = plugin_setting(VT, 'start_id', null); + if ($v === null) { + $v = (string) (int) $pdo->query('SELECT COALESCE(MAX(id), 0) FROM ticket_attachments')->fetchColumn(); + plugin_set_setting(VT, 'start_id', $v); + } + return (int) $v; +} + +/** Anhänge ohne Eintrag (noch nicht zurückgehalten), optional nur eines Tickets. */ +function vt_unseen(PDO $pdo, int $limit = 25, ?int $ticketId = null): array +{ + $sql = "SELECT a.id, a.original_name, a.stored_name, a.filesize, t.ticket_id FROM ticket_attachments a JOIN ticket_threads t ON t.id = a.thread_id + WHERE a.id > ? AND a.stored_name <> 'gesperrt' AND a.id NOT IN (SELECT attachment_id FROM plugin_virustotal_files)"; + $params = [vt_start_id($pdo)]; + if ($ticketId !== null) { $sql .= ' AND t.ticket_id = ?'; $params[] = $ticketId; } + $stmt = $pdo->prepare($sql . ' ORDER BY a.id LIMIT ' . max(1, $limit)); + $stmt->execute($params); + return $stmt->fetchAll(); +} + +/** Hält einen Anhang zurück (Datei in den Prüfordner, Anhang nicht mehr herunterladbar) und legt den Eintrag an. @return string pending oder error */ +function vt_hold(PDO $pdo, array $att): string +{ + $now = db_now(); + $from = vt_uploads_dir() . '/' . basename((string) $att['stored_name']); + $insert = fn(string $status, ?string $sha, ?string $detail) => $pdo->prepare('INSERT INTO plugin_virustotal_files (attachment_id, ticket_id, original_name, stored_name, filesize, sha256, status, detail, tries, next_try, created_at) VALUES (?,?,?,?,?,?,?,?,0,?,?)') + ->execute([(int) $att['id'], $att['ticket_id'] ?? null, mb_substr((string) $att['original_name'], 0, 255), mb_substr((string) $att['stored_name'], 0, 255), (int) ($att['filesize'] ?? 0), $sha, $status, $detail, $now, $now]); + if (!is_file($from)) { $insert('error', null, 'Datei nicht gefunden.'); return 'error'; } + $sha = hash_file('sha256', $from); + $to = vt_hold_dir() . '/' . basename((string) $att['stored_name']); + if (!@rename($from, $to) && (!@copy($from, $to) || !@unlink($from))) { + // Ohne Zurückhalten gäbe es keine Sperre: ehrlich vermerken statt so zu tun + $insert('error', $sha, 'Die Datei konnte nicht zurückgehalten werden (Schreibrechte im Ordner uploads prüfen).'); + return 'error'; + } + $pdo->prepare("UPDATE ticket_attachments SET stored_name = 'gesperrt', original_name = ? WHERE id = ?")->execute([mb_substr('[IN PRÜFUNG] ' . $att['original_name'], 0, 255), (int) $att['id']]); + $insert('pending', $sha, null); + return 'pending'; +} + +/** Hält die neuen Anhänge eines Tickets zurück und vermerkt es einmal im Ticket. @return int Anzahl */ +function vt_hold_ticket(PDO $pdo, int $ticketId): int +{ + $n = 0; + $names = []; + foreach (vt_unseen($pdo, 25, $ticketId) as $att) { + if (vt_hold($pdo, $att) === 'pending') { $n++; $names[] = (string) $att['original_name']; } + } + if ($n > 0) { + $pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)") + ->execute([$ticketId, e('Anhang' . ($n > 1 ? 'e' : '') . ' wird bei VirusTotal geprüft und ist bis dahin gesperrt: ' . implode(', ', array_map(fn($x) => '„' . $x . '“', array_slice($names, 0, 5))) . ($n > 5 ? ' …' : '')), db_now()]); + log_activity($pdo, $ticketId, null, 'VirusTotal: ' . $n . ' Anhang/Anhänge zur Prüfung zurückgehalten'); + } + return $n; +} + +function vt_row(PDO $pdo, int $id): ?array +{ + $s = $pdo->prepare('SELECT * FROM plugin_virustotal_files WHERE id = ?'); + $s->execute([$id]); + return $s->fetch() ?: null; +} + +/** Gibt eine zurückgehaltene Datei frei (sauber, bewusst freigegeben oder zugelassen). */ +function vt_release(PDO $pdo, array $row, string $status, string $detail): bool +{ + $from = vt_hold_dir() . '/' . basename((string) $row['stored_name']); + $to = vt_uploads_dir() . '/' . basename((string) $row['stored_name']); + if (is_file($from) && !@rename($from, $to) && (!@copy($from, $to) || !@unlink($from))) return false; + $pdo->prepare('UPDATE ticket_attachments SET stored_name = ?, original_name = ? WHERE id = ?')->execute([$row['stored_name'], $row['original_name'], (int) $row['attachment_id']]); + $pdo->prepare('UPDATE plugin_virustotal_files SET status = ?, detail = ?, checked_at = ? WHERE id = ?')->execute([$status, mb_substr($detail, 0, 250), db_now(), (int) $row['id']]); + if ($row['ticket_id']) log_activity($pdo, (int) $row['ticket_id'], null, 'VirusTotal: Anhang freigegeben: ' . $row['original_name'] . ' (' . $detail . ')'); + return true; +} + +/** Sperrt eine Datei dauerhaft (bleibt im Prüfordner), vermerkt es im Ticket und benachrichtigt den Administrator. */ +function vt_block(PDO $pdo, array $row, string $detail, int $malicious = 0, int $engines = 0): void +{ + $pdo->prepare("UPDATE ticket_attachments SET stored_name = 'gesperrt', original_name = ? WHERE id = ?")->execute([mb_substr('[GESPERRT] ' . $row['original_name'], 0, 255), (int) $row['attachment_id']]); + $pdo->prepare("UPDATE plugin_virustotal_files SET status = 'bad', detail = ?, malicious = ?, engines = ?, checked_at = ? WHERE id = ?")->execute([mb_substr($detail, 0, 250), $malicious, $engines, db_now(), (int) $row['id']]); + $ticketId = (int) ($row['ticket_id'] ?? 0); + if ($ticketId > 0) { + $pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)") + ->execute([$ticketId, e('Der Anhang „' . $row['original_name'] . '“ wurde von VirusTotal als gefährlich eingestuft (' . $detail . ') und bleibt gesperrt. Er liegt in der Quarantäne und lässt sich unter VirusTotal prüfen oder löschen.'), db_now()]); + log_activity($pdo, $ticketId, null, 'VirusTotal: Anhang gesperrt: ' . $row['original_name'] . ' (' . $detail . ')'); + if (function_exists('apply_tag_to_ticket')) apply_tag_to_ticket($pdo, $ticketId, 'Virus-Verdacht'); + } + if (vt_config()['notify']) vt_notify($pdo, $ticketId, (string) $row['original_name'], $detail); +} + +function vt_notify(PDO $pdo, int $ticketId, string $name, string $detail): void +{ + $to = (string) (get_setting('admin_email', '') ?: (config()['site']['admin_email'] ?? '')); + if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) return; + $t = $pdo->prepare('SELECT ticket_number FROM tickets WHERE id = ?'); + $t->execute([$ticketId]); + $number = (string) $t->fetchColumn(); + try { + send_mail($to, 'VirusTotal: Anhang in Ticket #' . $number . ' gesperrt', '

Der Anhang ' . e($name) . ' in Ticket #' . e($number) . ' wurde gesperrt (' . e($detail) . ').

Er liegt in der Quarantäne. Prüfen oder löschen kannst du ihn unter VirusTotal im Mitarbeiterbereich.

'); + } catch (Throwable $e) { + // Mailfehler dürfen das Sperren nie verhindern + } +} + +/** Löscht eine zurückgehaltene oder gesperrte Datei endgültig (der Anhang bleibt als „gelöscht“ im Ticket vermerkt). */ +function vt_delete(PDO $pdo, int $rowId): bool +{ + $row = vt_row($pdo, $rowId); + if (!$row || !in_array($row['status'], ['bad', 'unknown', 'error', 'pending', 'analyzing'], true)) return false; + $file = vt_hold_dir() . '/' . basename((string) $row['stored_name']); + if (is_file($file)) @unlink($file); + $pdo->prepare('UPDATE ticket_attachments SET original_name = ? WHERE id = ?')->execute([mb_substr('[GELÖSCHT] ' . $row['original_name'], 0, 255), (int) $row['attachment_id']]); + $pdo->prepare("UPDATE plugin_virustotal_files SET status = 'deleted', detail = ?, checked_at = ? WHERE id = ?")->execute(['Gelöscht am ' . db_now(), db_now(), $rowId]); + return true; +} + +/** Von Hand freigeben (zum Beispiel bei Fehlalarm oder unbekannter Datei). */ +function vt_release_manual(PDO $pdo, int $rowId): bool +{ + $row = vt_row($pdo, $rowId); + if (!$row || !in_array($row['status'], ['bad', 'unknown', 'error', 'pending', 'analyzing'], true)) return false; + return vt_release($pdo, $row, 'released', 'Von Hand freigegeben am ' . db_now()); +} + +/** Noch einmal prüfen lassen (zum Beispiel nach einem Fehler). */ +function vt_recheck(PDO $pdo, int $rowId): bool +{ + $row = vt_row($pdo, $rowId); + if (!$row || !in_array($row['status'], ['unknown', 'error'], true)) return false; + $pdo->prepare("UPDATE plugin_virustotal_files SET status = 'pending', tries = 0, next_try = ?, detail = NULL, analysis_id = NULL WHERE id = ?")->execute([db_now(), $rowId]); + return true; +} + +// ── Hintergrundlauf ──────────────────────────────────────────────────────── + +function vt_day_used(): int +{ + $today = date('Y-m-d'); + if (plugin_setting(VT, 'day', '') !== $today) { plugin_set_setting(VT, 'day', $today); plugin_set_setting(VT, 'day_count', '0'); } + return (int) plugin_setting(VT, 'day_count', '0'); +} + +function vt_day_add(int $n = 1): void +{ + plugin_set_setting(VT, 'day_count', (string) (vt_day_used() + $n)); +} + +/** + * Entscheidung nach einer Abfrage. @return bool false = Lauf beenden (Tageslimit, Minutenlimit oder Schlüsselproblem) + */ +function vt_apply(PDO $pdo, array $row, array $res, array $cfg): bool +{ + $soon = fn(int $sec) => date('Y-m-d H:i:s', time() + $sec); + switch ($res['result']) { + case 'limit': + $pdo->prepare('UPDATE plugin_virustotal_files SET next_try = ? WHERE id = ?')->execute([$soon(60), $row['id']]); + return false; + case 'key': + plugin_set_setting(VT, 'last_error', db_now() . ': ' . $res['detail']); + return false; + case 'error': + plugin_set_setting(VT, 'last_error', db_now() . ': ' . $res['detail']); + $tries = (int) $row['tries'] + 1; + if ($tries >= 6) { + if ($cfg['on_error'] === 'allow') vt_release($pdo, $row, 'released', 'VirusTotal nicht erreichbar, freigegeben (Einstellung)'); + else $pdo->prepare("UPDATE plugin_virustotal_files SET status = 'error', detail = ?, tries = ?, checked_at = ? WHERE id = ?")->execute([mb_substr((string) $res['detail'], 0, 250), $tries, db_now(), $row['id']]); + } else { + $pdo->prepare('UPDATE plugin_virustotal_files SET tries = ?, next_try = ?, detail = ? WHERE id = ?')->execute([$tries, $soon(min(3600, 60 * (2 ** $tries))), mb_substr((string) $res['detail'], 0, 250), $row['id']]); + } + return true; + case 'bad': + plugin_set_setting(VT, 'last_error', ''); + vt_block($pdo, $row, (string) $res['detail'], (int) $res['malicious'], (int) $res['engines']); + return true; + case 'clean': + plugin_set_setting(VT, 'last_error', ''); + vt_release($pdo, $row, 'clean', (string) ($res['detail'] ?? 'VirusTotal: keine Funde')); + $pdo->prepare('UPDATE plugin_virustotal_files SET malicious = ?, engines = ? WHERE id = ?')->execute([(int) $res['malicious'], (int) $res['engines'], $row['id']]); + return true; + case 'wait': + if (time() - (int) strtotime((string) $row['created_at']) > 3600) { + $pdo->prepare("UPDATE plugin_virustotal_files SET status = 'error', detail = ?, checked_at = ? WHERE id = ?")->execute(['Die Analyse bei VirusTotal wurde nicht rechtzeitig fertig.', db_now(), $row['id']]); + } else { + $pdo->prepare('UPDATE plugin_virustotal_files SET next_try = ? WHERE id = ?')->execute([$soon(60), $row['id']]); + } + return true; + default: // unknown oder none: VirusTotal kennt die Datei (noch) nicht + $path = vt_hold_dir() . '/' . basename((string) $row['stored_name']); + $size = is_file($path) ? (int) filesize($path) : 0; + if ($cfg['upload'] && $size > 0 && $size <= VT_UPLOAD_MAX && $row['status'] === 'pending') { + $up = vt_upload($path, (string) $row['original_name'], $cfg['key']); + vt_day_add(); + if (!$up['ok']) { + if ($up['detail'] === 'limit') { $pdo->prepare('UPDATE plugin_virustotal_files SET next_try = ? WHERE id = ?')->execute([$soon(60), $row['id']]); return false; } + return vt_apply($pdo, $row, ['result' => 'error', 'detail' => $up['detail'], 'malicious' => 0, 'engines' => 0], $cfg); + } + $pdo->prepare("UPDATE plugin_virustotal_files SET status = 'analyzing', analysis_id = ?, next_try = ?, detail = 'Zur Analyse bei VirusTotal hochgeladen' WHERE id = ?")->execute([$up['id'], $soon(60), $row['id']]); + return true; + } + if ($cfg['unknown'] === 'allow') { + vt_release($pdo, $row, 'released', 'Bei VirusTotal unbekannt, freigegeben (Einstellung)'); + return true; + } + $why = $cfg['upload'] && $size > VT_UPLOAD_MAX ? 'Bei VirusTotal unbekannt und zu groß für den Upload (über 32 MB).' : 'Bei VirusTotal unbekannt: bitte selbst prüfen und freigeben oder löschen.'; + $pdo->prepare("UPDATE plugin_virustotal_files SET status = 'unknown', detail = ?, checked_at = ? WHERE id = ?")->execute([$why, db_now(), $row['id']]); + if ((int) $row['ticket_id'] > 0) log_activity($pdo, (int) $row['ticket_id'], null, 'VirusTotal: Anhang bleibt gesperrt (unbekannt): ' . $row['original_name']); + return true; + } +} + +/** + * Hintergrundlauf (jede Minute oder alle 5 Minuten): zurückgehaltene Anhänge abfragen, höchstens 4 pro Lauf (Grenze des kostenlosen + * Schlüssels) und 480 pro Tag. @return int Zahl der Abfragen + */ +function vt_tick(PDO $pdo): int +{ + $cfg = vt_config(); + if ($cfg['key'] === '') return 0; + // Zwei Auslöser (jede Minute und alle 5 Minuten) sollen sich nicht überlappen + if (time() - (int) plugin_setting(VT, 'tick_at', '0') < 25) return 0; + plugin_set_setting(VT, 'tick_at', (string) time()); + // Anhänge, die kein Ereignis gemeldet hat (zum Beispiel aus E-Mails), nachträglich zurückhalten + foreach (vt_unseen($pdo, 25) as $att) vt_hold($pdo, $att); + $rows = $pdo->prepare("SELECT * FROM plugin_virustotal_files WHERE status IN ('pending','analyzing') AND next_try <= ? ORDER BY id LIMIT 4"); + $rows->execute([db_now()]); + $done = 0; + foreach ($rows->fetchAll() as $row) { + if (vt_day_used() >= VT_DAY_LIMIT) { plugin_set_setting(VT, 'last_error', db_now() . ': Das Tageslimit des VirusTotal-Schlüssels ist erreicht, es geht morgen weiter.'); break; } + $res = $row['status'] === 'analyzing' && $row['analysis_id'] + ? vt_analysis((string) $row['analysis_id'], $cfg['key'], $cfg['threshold']) + : vt_lookup((string) $row['sha256'], $cfg['key'], $cfg['threshold']); + vt_day_add(); + $done++; + $pdo->prepare('UPDATE plugin_virustotal_files SET checked_at = ? WHERE id = ?')->execute([db_now(), $row['id']]); + if (!vt_apply($pdo, $row, $res, $cfg)) break; + } + if ($done > 0) plugin_set_setting(VT, 'last_run', db_now()); + return $done; +} + +/** Zahlen für die Seite. @return array */ +function vt_stats(PDO $pdo): array +{ + $out = ['pending' => 0, 'analyzing' => 0, 'clean' => 0, 'bad' => 0, 'unknown' => 0, 'error' => 0, 'released' => 0, 'deleted' => 0]; + foreach ($pdo->query('SELECT status, COUNT(*) AS n FROM plugin_virustotal_files GROUP BY status')->fetchAll() as $r) $out[$r['status']] = (int) $r['n']; + return $out; +} + +/** Prüft, ob der Schlüssel funktioniert (Abfrage des Fingerabdrucks der Standard-Testdatei EICAR). @return array{ok:bool,message:string} */ +function vt_test_key(string $key, int $threshold): array +{ + if ($key === '') return ['ok' => false, 'message' => 'Bitte zuerst einen Schlüssel eintragen und speichern.']; + $r = vt_lookup(VT_EICAR_SHA256, $key, $threshold); + if ($r['result'] === 'bad') return ['ok' => true, 'message' => 'Der Schlüssel funktioniert: Die Standard-Testdatei (EICAR) wurde von VirusTotal als gefährlich erkannt (' . $r['malicious'] . ' von ' . $r['engines'] . ' Scannern).']; + if ($r['result'] === 'limit') return ['ok' => false, 'message' => 'Das Abfrage-Limit von VirusTotal ist gerade erreicht. Bitte in einer Minute noch einmal versuchen.']; + if ($r['result'] === 'clean' || $r['result'] === 'unknown' || $r['result'] === 'none') return ['ok' => true, 'message' => 'Der Schlüssel wird angenommen, die Testdatei wurde aber unerwartet bewertet (' . $r['result'] . ').']; + return ['ok' => false, 'message' => (string) ($r['detail'] ?? 'Die Abfrage ist fehlgeschlagen.')]; +} + +plugin_add_hook('plugin.uninstall.' . VT, function () { + try { + get_db()->exec('DROP TABLE IF EXISTS plugin_virustotal_files'); + } catch (Throwable $e) { + // Die Dateien im Ordner uploads/vt-pruefung bleiben liegen + } +}); diff --git a/theme/plugins/virustotal/migrations/001_dateien.mysql.sql b/theme/plugins/virustotal/migrations/001_dateien.mysql.sql new file mode 100644 index 00000000..6af4d6ed --- /dev/null +++ b/theme/plugins/virustotal/migrations/001_dateien.mysql.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS plugin_virustotal_files ( + id INT AUTO_INCREMENT PRIMARY KEY, + attachment_id INT NOT NULL, + ticket_id INT NULL, + original_name VARCHAR(255) NOT NULL, + stored_name VARCHAR(255) NOT NULL, + filesize BIGINT NOT NULL DEFAULT 0, + sha256 VARCHAR(64) NULL, + status VARCHAR(20) NOT NULL, + detail VARCHAR(255) NULL, + analysis_id VARCHAR(255) NULL, + malicious INT NULL, + engines INT NULL, + tries INT NOT NULL DEFAULT 0, + next_try DATETIME NOT NULL, + created_at DATETIME NOT NULL, + checked_at DATETIME NULL, + UNIQUE KEY idx_plugin_virustotal_attachment (attachment_id), + KEY idx_plugin_virustotal_status (status, next_try) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 diff --git a/theme/plugins/virustotal/migrations/001_dateien.sqlite.sql b/theme/plugins/virustotal/migrations/001_dateien.sqlite.sql new file mode 100644 index 00000000..e13ae659 --- /dev/null +++ b/theme/plugins/virustotal/migrations/001_dateien.sqlite.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS plugin_virustotal_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + attachment_id INTEGER NOT NULL, + ticket_id INTEGER NULL, + original_name VARCHAR(255) NOT NULL, + stored_name VARCHAR(255) NOT NULL, + filesize INTEGER NOT NULL DEFAULT 0, + sha256 VARCHAR(64) NULL, + status VARCHAR(20) NOT NULL, + detail VARCHAR(255) NULL, + analysis_id VARCHAR(255) NULL, + malicious INTEGER NULL, + engines INTEGER NULL, + tries INTEGER NOT NULL DEFAULT 0, + next_try DATETIME NOT NULL, + created_at DATETIME NOT NULL, + checked_at DATETIME NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_plugin_virustotal_attachment ON plugin_virustotal_files (attachment_id); +CREATE INDEX IF NOT EXISTS idx_plugin_virustotal_status ON plugin_virustotal_files (status, next_try) diff --git a/theme/plugins/virustotal/pages/virustotal.php b/theme/plugins/virustotal/pages/virustotal.php new file mode 100644 index 00000000..98ad0a0e --- /dev/null +++ b/theme/plugins/virustotal/pages/virustotal.php @@ -0,0 +1,161 @@ +Keine Berechtigung.

'; + return; +} +$pdo = get_db(); +vt_ensure_schema(); +$message = ''; +$messageType = 'success'; + +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { + verify_csrf(); + $action = (string) ($_POST['action'] ?? ''); + $id = (int) ($_POST['id'] ?? 0); + if ($action === 'save') { + $keyIn = trim((string) ($_POST['key'] ?? '')); + if ($keyIn !== '' && !preg_match('/^[a-f0-9]{64}$/i', $keyIn)) { + $message = 'Der VirusTotal-Schlüssel besteht aus 64 Zeichen (0–9, a–f).'; + $messageType = 'error'; + } else { + if ($keyIn !== '') plugin_set_setting(VT, 'key_enc', encrypt_secret($keyIn)); + if (!empty($_POST['clear_key'])) plugin_set_setting(VT, 'key_enc', ''); + plugin_set_setting(VT, 'threshold', (string) max(1, min(30, (int) ($_POST['threshold'] ?? 3)))); + plugin_set_setting(VT, 'unknown', ($_POST['unknown'] ?? 'hold') === 'allow' ? 'allow' : 'hold'); + plugin_set_setting(VT, 'on_error', ($_POST['on_error'] ?? 'hold') === 'allow' ? 'allow' : 'hold'); + plugin_set_setting(VT, 'upload', !empty($_POST['upload']) ? '1' : '0'); + plugin_set_setting(VT, 'notify', !empty($_POST['notify']) ? '1' : '0'); + audit_log((int) $staff['id'], 'VirusTotal: Einstellungen geändert' . (!empty($_POST['upload']) ? ' (Upload unbekannter Dateien an)' : '')); + $message = 'Gespeichert.'; + } + } elseif ($action === 'test') { + $t = vt_test_key(vt_config()['key'], vt_config()['threshold']); + $message = $t['message']; + $messageType = $t['ok'] ? 'success' : 'error'; + } elseif ($action === 'release' && $id > 0) { + $ok = vt_release_manual($pdo, $id); + if ($ok) audit_log((int) $staff['id'], 'VirusTotal: Anhang #' . $id . ' von Hand freigegeben'); + $message = $ok ? 'Die Datei ist freigegeben.' : 'Die Datei ließ sich nicht freigeben.'; + $messageType = $ok ? 'success' : 'error'; + } elseif ($action === 'delete' && $id > 0) { + $ok = vt_delete($pdo, $id); + if ($ok) audit_log((int) $staff['id'], 'VirusTotal: Anhang #' . $id . ' gelöscht'); + $message = $ok ? 'Die Datei ist gelöscht.' : 'Die Datei ließ sich nicht löschen.'; + $messageType = $ok ? 'success' : 'error'; + } elseif ($action === 'recheck' && $id > 0) { + $ok = vt_recheck($pdo, $id); + $message = $ok ? 'Die Datei wird beim nächsten Lauf noch einmal geprüft.' : 'Das ging nicht.'; + $messageType = $ok ? 'success' : 'error'; + } +} + +$cfg = vt_config(); +$stats = vt_stats($pdo); +$used = vt_day_used(); +$lastError = (string) plugin_setting(VT, 'last_error', ''); +$lastRun = (string) plugin_setting(VT, 'last_run', ''); +$held = $pdo->query("SELECT f.*, t.ticket_number FROM plugin_virustotal_files f LEFT JOIN tickets t ON t.id = f.ticket_id WHERE f.status IN ('pending','analyzing','unknown','error','bad') ORDER BY (CASE f.status WHEN 'bad' THEN 0 WHEN 'unknown' THEN 1 WHEN 'error' THEN 2 ELSE 3 END), f.id DESC LIMIT 100")->fetchAll(); +$recent = $pdo->query("SELECT * FROM plugin_virustotal_files WHERE status IN ('clean','released','deleted') ORDER BY id DESC LIMIT 30")->fetchAll(); +$labels = ['pending' => ['Wartet auf VirusTotal', '#b45309'], 'analyzing' => ['Wird bei VirusTotal analysiert', '#b45309'], 'unknown' => ['Unbekannt (zurückgehalten)', '#b45309'], 'error' => ['Fehler (zurückgehalten)', '#b91c1c'], + 'bad' => ['Gesperrt (Schadsoftware)', '#b91c1c'], 'clean' => ['Sauber, freigegeben', '#15803d'], 'released' => ['Von Hand freigegeben', '#15803d'], 'deleted' => ['Gelöscht', '#64748b']]; +$mb = fn($b) => $b >= 1048576 ? round($b / 1048576, 1) . ' MB' : max(1, (int) round($b / 1024)) . ' KB'; +?> +

VirusTotal

+
+ +
+

Zustand

+ +

Noch kein Schlüssel. Ohne VirusTotal-Schlüssel werden Anhänge nicht zurückgehalten und nicht geprüft. Einen kostenlosen Schlüssel gibt es nach der Anmeldung bei virustotal.com im Profil unter „API Key“.

+ +

✓ Aktiv. Neue Anhänge sind gesperrt, bis VirusTotal sie geprüft hat.

+ +

+ warten · + gesperrt · + unbekannt · + mit Fehler · + freigegeben +

+

Abfragen heute: von (der kostenlose Schlüssel erlaubt 4 pro Minute und 500 pro Tag). Letzter Lauf: .

+

+

Vorgehen: Jeder neue Anhang wird sofort zurückgehalten (nicht herunterladbar). Dann fragt das Plugin bei VirusTotal nur den Fingerabdruck (SHA-256) der Datei ab. Ist die Datei bekannt und sauber, wird sie freigegeben; melden mindestens Scanner Schadsoftware, bleibt sie gesperrt. Anhänge, die schon vor dem Einschalten da waren, bleiben unberührt.

+
+ +
+ + +

Einstellungen

+ + + + + + + + + + + +
+
+ +
+ +

Schlüssel testen

+

Fragt bei VirusTotal den Fingerabdruck der Standard-Testdatei (EICAR) ab. Es wird nichts hochgeladen. Vorher speichern.

+ +
+ +
+

Zurückgehaltene und gesperrte Anhänge

+ +

Nichts zurückgehalten.

+ +
+ + + + + + + + + + + + + +
DateiTicketGrößeZustandHinweis
# + +
>
+ +
+ +
+ +
+

Zuletzt geprüft

+ +

Noch nichts geprüft.

+ +
+ + + + + + +
DateiZustandErgebnisWann
+ +
diff --git a/theme/plugins/virustotal/plugin.json b/theme/plugins/virustotal/plugin.json new file mode 100644 index 00000000..accb6cee --- /dev/null +++ b/theme/plugins/virustotal/plugin.json @@ -0,0 +1,10 @@ +{ + "slug": "virustotal", + "name": "VirusTotal-Anhangsprüfung", + "version": "1.0.0", + "description": "Hält jeden Anhang eines Tickets zurück, bis VirusTotal ihn geprüft hat: Erst wird nur der Fingerabdruck (SHA-256) abgefragt, die Datei selbst verlässt den Server nicht. Ist die Datei sauber, wird sie freigegeben; melden mehrere Scanner Schadsoftware, bleibt sie gesperrt (Quarantäne, Hinweis im Ticket, Mail an den Administrator). Unbekannte Dateien werden zur manuellen Prüfung zurückgehalten, freigegeben oder (nur wenn ausdrücklich eingeschaltet) zur Analyse bei VirusTotal hochgeladen. Arbeitet auch neben dem Plugin „Anhänge-Virenscan“. Nur für Administratoren.", + "author": "DeskHub", + "entry": "plugin.php", + "requires": "1.0.0", + "permissions": ["hooks.tickets", "hooks.cron", "ui.menu", "db.migrations"] +} diff --git a/theme/plugins/virustotal/plugin.php b/theme/plugins/virustotal/plugin.php new file mode 100644 index 00000000..83b3ebd2 --- /dev/null +++ b/theme/plugins/virustotal/plugin.php @@ -0,0 +1,27 @@ +getMessage()); +} + +foreach (['ticket.created', 'ticket.replied'] as $hook) { + plugin_add_hook($hook, function (array $payload) { + // Nur zurückhalten, wenn ein Schlüssel eingetragen ist: ohne Schlüssel gäbe es nie ein Ergebnis und die Anhänge blieben für immer gesperrt + if (!empty($payload['ticketId']) && vt_config()['key'] !== '') vt_hold_ticket(get_db(), (int) $payload['ticketId']); + }); +} +// Abfragen: jede Minute (wenn der Hintergrunddienst es kann), sonst alle 5 Minuten +plugin_add_hook('cron.minute', function () { + vt_tick(get_db()); +}); +plugin_add_hook('cron.run', function () { + vt_tick(get_db()); +}); + +plugin_register_menu('VirusTotal', 'virustotal', null, 'admin');