PNG  IHDRX cHRMz&u0`:pQ<bKGD pHYsodtIME MeqIDATxw]Wug^Qd˶ 6`!N:!@xI~)%7%@Bh&`lnjVF29gΨ4E$|>cɚ{gk= %,a KX%,a KX%,a KX%,a KX%,a KX%,a KX%, b` ǟzeאfp]<!SJmɤY޲ڿ,%c ~ع9VH.!Ͳz&QynֺTkRR.BLHi٪:l;@(!MԴ=žI,:o&N'Kù\vRmJ雵֫AWic H@" !: Cé||]k-Ha oݜ:y F())u]aG7*JV@J415p=sZH!=!DRʯvɱh~V\}v/GKY$n]"X"}t@ xS76^[bw4dsce)2dU0 CkMa-U5tvLƀ~mlMwfGE/-]7XAƟ`׮g ewxwC4\[~7@O-Q( a*XGƒ{ ՟}$_y3tĐƤatgvێi|K=uVyrŲlLӪuܿzwk$m87k( `múcE)"@rK( z4$D; 2kW=Xb$V[Ru819קR~qloѱDyįݎ*mxw]y5e4K@ЃI0A D@"BDk_)N\8͜9dz"fK0zɿvM /.:2O{ Nb=M=7>??Zuo32 DLD@D| &+֎C #B8ַ`bOb $D#ͮҪtx]%`ES`Ru[=¾!@Od37LJ0!OIR4m]GZRJu$‡c=%~s@6SKy?CeIh:[vR@Lh | (BhAMy=݃  G"'wzn޺~8ԽSh ~T*A:xR[ܹ?X[uKL_=fDȊ؂p0}7=D$Ekq!/t.*2ʼnDbŞ}DijYaȲ(""6HA;:LzxQ‘(SQQ}*PL*fc\s `/d'QXW, e`#kPGZuŞuO{{wm[&NBTiiI0bukcA9<4@SӊH*؎4U/'2U5.(9JuDfrޱtycU%j(:RUbArLֺN)udA':uGQN"-"Is.*+k@ `Ojs@yU/ H:l;@yyTn}_yw!VkRJ4P)~y#)r,D =ě"Q]ci'%HI4ZL0"MJy 8A{ aN<8D"1#IJi >XjX֔#@>-{vN!8tRݻ^)N_╗FJEk]CT՟ YP:_|H1@ CBk]yKYp|og?*dGvzنzӴzjֺNkC~AbZƷ`.H)=!QͷVTT(| u78y֮}|[8-Vjp%2JPk[}ԉaH8Wpqhwr:vWª<}l77_~{s۴V+RCģ%WRZ\AqHifɤL36: #F:p]Bq/z{0CU6ݳEv_^k7'>sq*+kH%a`0ԣisqにtү04gVgW΂iJiS'3w.w}l6MC2uԯ|>JF5`fV5m`Y**Db1FKNttu]4ccsQNnex/87+}xaUW9y>ͯ骵G{䩓Գ3+vU}~jJ.NFRD7<aJDB1#ҳgSb,+CS?/ VG J?|?,2#M9}B)MiE+G`-wo߫V`fio(}S^4e~V4bHOYb"b#E)dda:'?}׮4繏`{7Z"uny-?ǹ;0MKx{:_pÚmFמ:F " .LFQLG)Q8qN q¯¯3wOvxDb\. BKD9_NN &L:4D{mm o^tֽ:q!ƥ}K+<"m78N< ywsard5+вz~mnG)=}lYݧNj'QJS{S :UYS-952?&O-:W}(!6Mk4+>A>j+i|<<|;ر^߉=HE|V#F)Emm#}/"y GII웻Jі94+v뾧xu~5C95~ūH>c@덉pʃ1/4-A2G%7>m;–Y,cyyaln" ?ƻ!ʪ<{~h~i y.zZB̃/,雋SiC/JFMmBH&&FAbϓO^tubbb_hZ{_QZ-sύodFgO(6]TJA˯#`۶ɟ( %$&+V'~hiYy>922 Wp74Zkq+Ovn錄c>8~GqܲcWꂎz@"1A.}T)uiW4="jJ2W7mU/N0gcqܗOO}?9/wìXžΏ0 >֩(V^Rh32!Hj5`;O28؇2#ݕf3 ?sJd8NJ@7O0 b־?lldщ̡&|9C.8RTWwxWy46ah嘦mh٤&l zCy!PY?: CJyв]dm4ǜҐR޻RլhX{FƯanшQI@x' ao(kUUuxW_Ñ줮[w8 FRJ(8˼)_mQ _!RJhm=!cVmm ?sFOnll6Qk}alY}; "baӌ~M0w,Ggw2W:G/k2%R,_=u`WU R.9T"v,<\Ik޽/2110Ӿxc0gyC&Ny޽JҢrV6N ``یeA16"J³+Rj*;BϜkZPJaÍ<Jyw:NP8/D$ 011z֊Ⱳ3ι֘k1V_"h!JPIΣ'ɜ* aEAd:ݺ>y<}Lp&PlRfTb1]o .2EW\ͮ]38؋rTJsǏP@芎sF\> P^+dYJLbJ C-xϐn> ι$nj,;Ǖa FU *择|h ~izť3ᤓ`K'-f tL7JK+vf2)V'-sFuB4i+m+@My=O҈0"|Yxoj,3]:cо3 $#uŘ%Y"y죯LebqtҢVzq¼X)~>4L׶m~[1_k?kxֺQ`\ |ٛY4Ѯr!)N9{56(iNq}O()Em]=F&u?$HypWUeB\k]JɩSع9 Zqg4ZĊo oMcjZBU]B\TUd34ݝ~:7ڶSUsB0Z3srx 7`:5xcx !qZA!;%͚7&P H<WL!džOb5kF)xor^aujƍ7 Ǡ8/p^(L>ὴ-B,{ۇWzֺ^k]3\EE@7>lYBȝR.oHnXO/}sB|.i@ɥDB4tcm,@ӣgdtJ!lH$_vN166L__'Z)y&kH;:,Y7=J 9cG) V\hjiE;gya~%ks_nC~Er er)muuMg2;֫R)Md) ,¶ 2-wr#F7<-BBn~_(o=KO㭇[Xv eN_SMgSҐ BS헃D%g_N:/pe -wkG*9yYSZS.9cREL !k}<4_Xs#FmҶ:7R$i,fi!~' # !6/S6y@kZkZcX)%5V4P]VGYq%H1!;e1MV<!ϐHO021Dp= HMs~~a)ަu7G^];git!Frl]H/L$=AeUvZE4P\.,xi {-~p?2b#amXAHq)MWǾI_r`S Hz&|{ +ʖ_= (YS(_g0a03M`I&'9vl?MM+m~}*xT۲(fY*V4x@29s{DaY"toGNTO+xCAO~4Ϳ;p`Ѫ:>Ҵ7K 3}+0 387x\)a"/E>qpWB=1 ¨"MP(\xp߫́A3+J] n[ʼnӼaTbZUWb={~2ooKױӰp(CS\S筐R*JغV&&"FA}J>G֐p1ٸbk7 ŘH$JoN <8s^yk_[;gy-;߉DV{c B yce% aJhDȶ 2IdйIB/^n0tNtџdcKj4϶v~- CBcgqx9= PJ) dMsjpYB] GD4RDWX +h{y`,3ꊕ$`zj*N^TP4L:Iz9~6s) Ga:?y*J~?OrMwP\](21sZUD ?ܟQ5Q%ggW6QdO+\@ ̪X'GxN @'4=ˋ+*VwN ne_|(/BDfj5(Dq<*tNt1х!MV.C0 32b#?n0pzj#!38}޴o1KovCJ`8ŗ_"]] rDUy޲@ Ȗ-;xџ'^Y`zEd?0„ DAL18IS]VGq\4o !swV7ˣι%4FѮ~}6)OgS[~Q vcYbL!wG3 7띸*E Pql8=jT\꘿I(z<[6OrR8ºC~ډ]=rNl[g|v TMTղb-o}OrP^Q]<98S¤!k)G(Vkwyqyr޽Nv`N/e p/~NAOk \I:G6]4+K;j$R:Mi #*[AȚT,ʰ,;N{HZTGMoּy) ]%dHء9Պ䠬|<45,\=[bƟ8QXeB3- &dҩ^{>/86bXmZ]]yޚN[(WAHL$YAgDKp=5GHjU&99v簪C0vygln*P)9^͞}lMuiH!̍#DoRBn9l@ xA/_v=ȺT{7Yt2N"4!YN`ae >Q<XMydEB`VU}u]嫇.%e^ánE87Mu\t`cP=AD/G)sI"@MP;)]%fH9'FNsj1pVhY&9=0pfuJ&gޤx+k:!r˭wkl03׼Ku C &ѓYt{.O.zҏ z}/tf_wEp2gvX)GN#I ݭ߽v/ .& и(ZF{e"=V!{zW`, ]+LGz"(UJp|j( #V4, 8B 0 9OkRrlɱl94)'VH9=9W|>PS['G(*I1==C<5"Pg+x'K5EMd؞Af8lG ?D FtoB[je?{k3zQ vZ;%Ɠ,]E>KZ+T/ EJxOZ1i #T<@ I}q9/t'zi(EMqw`mYkU6;[t4DPeckeM;H}_g pMww}k6#H㶏+b8雡Sxp)&C $@'b,fPߑt$RbJ'vznuS ~8='72_`{q纶|Q)Xk}cPz9p7O:'|G~8wx(a 0QCko|0ASD>Ip=4Q, d|F8RcU"/KM opKle M3#i0c%<7׿p&pZq[TR"BpqauIp$ 8~Ĩ!8Սx\ւdT>>Z40ks7 z2IQ}ItԀ<-%S⍤};zIb$I 5K}Q͙D8UguWE$Jh )cu4N tZl+[]M4k8֦Zeq֮M7uIqG 1==tLtR,ƜSrHYt&QP윯Lg' I,3@P'}'R˪e/%-Auv·ñ\> vDJzlӾNv5:|K/Jb6KI9)Zh*ZAi`?S {aiVDԲuy5W7pWeQJk֤#5&V<̺@/GH?^τZL|IJNvI:'P=Ϛt"¨=cud S Q.Ki0 !cJy;LJR;G{BJy޺[^8fK6)=yʊ+(k|&xQ2`L?Ȓ2@Mf 0C`6-%pKpm')c$׻K5[J*U[/#hH!6acB JA _|uMvDyk y)6OPYjœ50VT K}cǻP[ $:]4MEA.y)|B)cf-A?(e|lɉ#P9V)[9t.EiQPDѠ3ϴ;E:+Օ t ȥ~|_N2,ZJLt4! %ա]u {+=p.GhNcŞQI?Nd'yeh n7zi1DB)1S | S#ًZs2|Ɛy$F SxeX{7Vl.Src3E℃Q>b6G ўYCmtկ~=K0f(=LrAS GN'ɹ9<\!a`)֕y[uՍ[09` 9 +57ts6}b4{oqd+J5fa/,97J#6yν99mRWxJyѡyu_TJc`~W>l^q#Ts#2"nD1%fS)FU w{ܯ R{ ˎ󅃏џDsZSQS;LV;7 Od1&1n$ N /.q3~eNɪ]E#oM~}v֯FڦwyZ=<<>Xo稯lfMFV6p02|*=tV!c~]fa5Y^Q_WN|Vs 0ҘދU97OI'N2'8N֭fgg-}V%y]U4 峧p*91#9U kCac_AFңĪy뚇Y_AiuYyTTYЗ-(!JFLt›17uTozc. S;7A&&<ԋ5y;Ro+:' *eYJkWR[@F %SHWP 72k4 qLd'J "zB6{AC0ƁA6U.'F3:Ȅ(9ΜL;D]m8ڥ9}dU "v!;*13Rg^fJyShyy5auA?ɩGHRjo^]׽S)Fm\toy 4WQS@mE#%5ʈfFYDX ~D5Ϡ9tE9So_aU4?Ѽm%&c{n>.KW1Tlb}:j uGi(JgcYj0qn+>) %\!4{LaJso d||u//P_y7iRJ߬nHOy) l+@$($VFIQ9%EeKʈU. ia&FY̒mZ=)+qqoQn >L!qCiDB;Y<%} OgBxB!ØuG)WG9y(Ą{_yesuZmZZey'Wg#C~1Cev@0D $a@˲(.._GimA:uyw֬%;@!JkQVM_Ow:P.s\)ot- ˹"`B,e CRtaEUP<0'}r3[>?G8xU~Nqu;Wm8\RIkբ^5@k+5(By'L&'gBJ3ݶ!/㮻w҅ yqPWUg<e"Qy*167΃sJ\oz]T*UQ<\FԎ`HaNmڜ6DysCask8wP8y9``GJ9lF\G g's Nn͵MLN֪u$| /|7=]O)6s !ĴAKh]q_ap $HH'\1jB^s\|- W1:=6lJBqjY^LsPk""`]w)󭃈,(HC ?䔨Y$Sʣ{4Z+0NvQkhol6C.婧/u]FwiVjZka&%6\F*Ny#8O,22+|Db~d ~Çwc N:FuuCe&oZ(l;@ee-+Wn`44AMK➝2BRՈt7g*1gph9N) *"TF*R(#'88pm=}X]u[i7bEc|\~EMn}P瘊J)K.0i1M6=7'_\kaZ(Th{K*GJyytw"IO-PWJk)..axӝ47"89Cc7ĐBiZx 7m!fy|ϿF9CbȩV 9V-՛^pV̌ɄS#Bv4-@]Vxt-Z, &ֺ*diؠ2^VXbs֔Ìl.jQ]Y[47gj=幽ex)A0ip׳ W2[ᎇhuE^~q흙L} #-b۸oFJ_QP3r6jr+"nfzRJTUqoaۍ /$d8Mx'ݓ= OՃ| )$2mcM*cЙj}f };n YG w0Ia!1Q.oYfr]DyISaP}"dIӗթO67jqR ҊƐƈaɤGG|h;t]䗖oSv|iZqX)oalv;۩meEJ\!8=$4QU4Xo&VEĊ YS^E#d,yX_> ۘ-e\ "Wa6uLĜZi`aD9.% w~mB(02G[6y.773a7 /=o7D)$Z 66 $bY^\CuP. (x'"J60׿Y:Oi;F{w佩b+\Yi`TDWa~|VH)8q/=9!g߆2Y)?ND)%?Ǐ`k/sn:;O299yB=a[Ng 3˲N}vLNy;*?x?~L&=xyӴ~}q{qE*IQ^^ͧvü{Huu=R|>JyUlZV, B~/YF!Y\u_ݼF{_C)LD]m {H 0ihhadd nUkf3oٺCvE\)QJi+֥@tDJkB$1!Đr0XQ|q?d2) Ӣ_}qv-< FŊ߫%roppVBwü~JidY4:}L6M7f٬F "?71<2#?Jyy4뷢<_a7_=Q E=S1И/9{+93֮E{ǂw{))?maÆm(uLE#lïZ  ~d];+]h j?!|$F}*"4(v'8s<ŏUkm7^7no1w2ؗ}TrͿEk>p'8OB7d7R(A 9.*Mi^ͳ; eeUwS+C)uO@ =Sy]` }l8^ZzRXj[^iUɺ$tj))<sbDJfg=Pk_{xaKo1:-uyG0M ԃ\0Lvuy'ȱc2Ji AdyVgVh!{]/&}}ċJ#%d !+87<;qN޼Nفl|1N:8ya  8}k¾+-$4FiZYÔXk*I&'@iI99)HSh4+2G:tGhS^繿 Kتm0 вDk}֚+QT4;sC}rՅE,8CX-e~>G&'9xpW,%Fh,Ry56Y–hW-(v_,? ; qrBk4-V7HQ;ˇ^Gv1JVV%,ik;D_W!))+BoS4QsTM;gt+ndS-~:11Sgv!0qRVh!"Ȋ(̦Yl.]PQWgٳE'`%W1{ndΗBk|Ž7ʒR~,lnoa&:ü$ 3<a[CBݮwt"o\ePJ=Hz"_c^Z.#ˆ*x z̝grY]tdkP*:97YľXyBkD4N.C_[;F9`8& !AMO c `@BA& Ost\-\NX+Xp < !bj3C&QL+*&kAQ=04}cC!9~820G'PC9xa!w&bo_1 Sw"ܱ V )Yl3+ס2KoXOx]"`^WOy :3GO0g;%Yv㐫(R/r (s } u B &FeYZh0y> =2<Ϟc/ -u= c&׭,.0"g"7 6T!vl#sc>{u/Oh Bᾈ)۴74]x7 gMӒ"d]U)}" v4co[ ɡs 5Gg=XR14?5A}D "b{0$L .\4y{_fe:kVS\\O]c^W52LSBDM! C3Dhr̦RtArx4&agaN3Cf<Ԉp4~ B'"1@.b_/xQ} _߃҉/gٓ2Qkqp0շpZ2fԫYz< 4L.Cyυι1t@鎫Fe sYfsF}^ V}N<_`p)alٶ "(XEAVZ<)2},:Ir*#m_YӼ R%a||EƼIJ,,+f"96r/}0jE/)s)cjW#w'Sʯ5<66lj$a~3Kʛy 2:cZ:Yh))+a߭K::N,Q F'qB]={.]h85C9cr=}*rk?vwV렵ٸW Rs%}rNAkDv|uFLBkWY YkX מ|)1!$#3%y?pF<@<Rr0}: }\J [5FRxY<9"SQdE(Q*Qʻ)q1E0B_O24[U'],lOb ]~WjHޏTQ5Syu wq)xnw8~)c 쫬gٲߠ H% k5dƝk> kEj,0% b"vi2Wس_CuK)K{n|>t{P1򨾜j>'kEkƗBg*H%'_aY6Bn!TL&ɌOb{c`'d^{t\i^[uɐ[}q0lM˕G:‚4kb祔c^:?bpg… +37stH:0}en6x˟%/<]BL&* 5&fK9Mq)/iyqtA%kUe[ڛKN]Ě^,"`/ s[EQQm?|XJ߅92m]G.E΃ח U*Cn.j_)Tѧj̿30ڇ!A0=͜ar I3$C^-9#|pk!)?7.x9 @OO;WƝZBFU keZ75F6Tc6"ZȚs2y/1 ʵ:u4xa`C>6Rb/Yм)^=+~uRd`/|_8xbB0?Ft||Z\##|K 0>>zxv8۴吅q 8ĥ)"6>~\8:qM}#͚'ĉ#p\׶ l#bA?)|g g9|8jP(cr,BwV (WliVxxᡁ@0Okn;ɥh$_ckCgriv}>=wGzβ KkBɛ[˪ !J)h&k2%07δt}!d<9;I&0wV/ v 0<H}L&8ob%Hi|޶o&h1L|u֦y~󛱢8fٲUsւ)0oiFx2}X[zVYr_;N(w]_4B@OanC?gĦx>мgx>ΛToZoOMp>40>V Oy V9iq!4 LN,ˢu{jsz]|"R޻&'ƚ{53ўFu(<٪9:΋]B;)B>1::8;~)Yt|0(pw2N%&X,URBK)3\zz&}ax4;ǟ(tLNg{N|Ǽ\G#C9g$^\}p?556]/RP.90 k,U8/u776s ʪ_01چ|\N 0VV*3H鴃J7iI!wG_^ypl}r*jɤSR 5QN@ iZ#1ٰy;_\3\BQQ x:WJv츟ٯ$"@6 S#qe딇(/P( Dy~TOϻ<4:-+F`0||;Xl-"uw$Цi󼕝mKʩorz"mϺ$F:~E'ҐvD\y?Rr8_He@ e~O,T.(ފR*cY^m|cVR[8 JҡSm!ΆԨb)RHG{?MpqrmN>߶Y)\p,d#xۆWY*,l6]v0h15M˙MS8+EdI='LBJIH7_9{Caз*Lq,dt >+~ّeʏ?xԕ4bBAŚjﵫ!'\Ը$WNvKO}ӽmSşذqsOy?\[,d@'73'j%kOe`1.g2"e =YIzS2|zŐƄa\U,dP;jhhhaxǶ?КZ՚.q SE+XrbOu%\GتX(H,N^~]JyEZQKceTQ]VGYqnah;y$cQahT&QPZ*iZ8UQQM.qo/T\7X"u?Mttl2Xq(IoW{R^ ux*SYJ! 4S.Jy~ BROS[V|žKNɛP(L6V^|cR7i7nZW1Fd@ Ara{詑|(T*dN]Ko?s=@ |_EvF]׍kR)eBJc" MUUbY6`~V޴dJKß&~'d3i WWWWWW
Current Directory: /opt/imunify360/venv/lib/python3.11/site-packages/imav/wordpress
Viewing File: /opt/imunify360/venv/lib/python3.11/site-packages/imav/wordpress/plugin.py
""" This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program.  If not, see <https://www.gnu.org/licenses/>. Copyright © 2019 Cloud Linux Software Inc. This software is also available under ImunifyAV commercial license, see <https://www.imunify360.com/legal/eula> """ import asyncio import logging import os import pwd import shutil import time import yaml import zipfile from collections import defaultdict from distutils.version import LooseVersion from pathlib import Path from defence360agent.api import inactivity from defence360agent.contracts.config import ( MalwareScanScheduleInterval as Interval, SystemConfig, ANTIVIRUS_MODE, ) from defence360agent.files import Index from defence360agent.sentry import log_message from defence360agent.contracts.config import Wordpress from imav import files from imav.model.wordpress import WordpressSite, WPSite from imav.wordpress import cli, telemetry from imav.wordpress.constants import PLUGIN_VERSION_FILE from imav.malwarelib.plugins.schedule_watcher import get_user_schedule_config from imav.wordpress.utils import ( calculate_next_scan_timestamp, clear_get_cagefs_enabled_users_cache, ensure_site_data_directory, format_php_with_embedded_json, get_last_scan, get_malware_history, prepare_scan_data, write_plugin_data_file_atomically, ) from imav.wordpress.site_repository import ( clear_manually_deleted_flag, delete_site, get_outdated_sites, get_sites_for_user, get_sites_to_adopt, get_sites_to_install, get_sites_to_mark_as_manually_deleted, get_installed_sites, insert_installed_sites, mark_site_as_manually_deleted, update_site_version, ) from imav.wordpress.proxy_auth import setup_site_authentication logger = logging.getLogger(__name__) COMPONENTS_DB_PATH = Path( "/var/lib/cloudlinux-app-version-detector/components_versions.sqlite3" ) # WordPress rules file names WP_RULES_ZIP_FILENAME = "wp-rules.zip" WP_RULES_VERSION_FILENAME = "VERSION" def clear_caches(): """Clear all WordPress-related caches.""" clear_get_cagefs_enabled_users_cache() cli.clear_get_content_dir_cache() def site_search(items: dict, user_info: pwd.struct_passwd, matcher) -> dict: # Get all WordPress sites for the user (the main site is always last) user_sites = get_sites_for_user(user_info) result = {path: [] for path in user_sites} for item in items: # Find all matching sites for this item matching_sites = [path for path in user_sites if matcher(item, path)] if matching_sites: # Find the most specific (longest) matching path most_specific_site = max(matching_sites, key=len) result[most_specific_site].append(item) return result async def _get_scan_data_for_user( sink, user_info: pwd.struct_passwd, admin_config: SystemConfig ): # Get the last scan data last_scan = await get_last_scan(sink, user_info.pw_name) # Extract the last scan date last_scan_time = last_scan.get("scan_date", None) # Get user-specific schedule configuration interval, hour, day_of_month, day_of_week = get_user_schedule_config( user_info.pw_name, admin_config ) next_scan_time = None if interval != Interval.NONE: next_scan_time = calculate_next_scan_timestamp( interval, hour, day_of_month, day_of_week ) # Get the malware history for the user malware_history = get_malware_history(user_info.pw_name) # Split malware history by site. This part relies on the main site being the last one in the list. # Without this all malware could be attributed to the main site. malware_by_site = site_search( malware_history, user_info, lambda item, path: item["resource_type"] == "file" and item["file"].startswith(path), ) return last_scan_time, next_scan_time, malware_by_site async def _send_telemetry_task(coro, semaphore: asyncio.Semaphore): async with semaphore: try: await coro except Exception as e: logger.error(f"Telemetry task failed: {e}") async def process_telemetry_tasks(coroutines: list, concurrency=10): """ Process a list of telemetry coroutines with a concurrency limit.s """ if not coroutines: return semaphore = asyncio.Semaphore(concurrency) tasks = [ asyncio.create_task(_send_telemetry_task(coro, semaphore)) for coro in coroutines ] try: await asyncio.gather(*tasks) except Exception as e: logger.error(f"Some telemetry tasks failed: {e}") async def load_wp_rules_php(): """ Load WordPress rules from the index and format them as PHP. Returns: str or None: PHP-formatted rules data, or None if rules could not be loaded. """ try: wp_rules_index = files.Index(files.WP_RULES, integrity_check=False) await wp_rules_index.update() wp_rules_data = get_updated_wp_rules_data(wp_rules_index) except Exception as e: logger.warning( "Failed to load wp-rules index: %s, skipping rules installation", e, ) return None if not wp_rules_data: logger.warning( "valid WordPress rules not found, skipping rules installation" ) return None # Get version and create ruleset dict with version and rules wp_rules_version = get_wp_ruleset_version(wp_rules_index) ruleset_dict = { "version": wp_rules_version, "rules": wp_rules_data, } return format_php_with_embedded_json(ruleset_dict) async def install_everywhere(sink): """Install the imunify-security plugin for all sites where it is not installed.""" sites = get_sites_to_install() installer = WordPressSiteInstaller(sink, sites) return await installer.run() async def adopt_found_sites(sink): """ Adopt WordPress sites where the plugin is installed but not tracked in our database or flagged as manually removed. This handles scenarios like: - Sites copied/migrated from another location - Sites migrated from another server - Sites where the manually_deleted flag was incorrectly set (past bugs) - Sites where the user installed the plugin from wordpress.org """ sites = get_sites_to_adopt() processor = WordPressSiteAdopter(sink, sites) return await processor.run() def get_latest_plugin_version() -> str: """Get the latest version of the imunify-security plugin from the version file.""" try: if not PLUGIN_VERSION_FILE.exists(): logger.error( "Plugin version file does not exist: %s", PLUGIN_VERSION_FILE ) return None return PLUGIN_VERSION_FILE.read_text().strip() except Exception as e: logger.error("Failed to read plugin version file: %s", e) return None async def update_everywhere(sink): """Update the imunify-security plugin on all sites where it is installed.""" latest_version = get_latest_plugin_version() if not latest_version: logger.error("Could not determine latest plugin version") return logger.info( "Updating imunify-security wp plugin to the latest version %s", latest_version, ) updated = set() telemetry_coros = [] with inactivity.track.task("wp-plugin-update"): try: # Get sites with outdated versions outdated_sites = get_outdated_sites(latest_version) logger.info(f"Found {len(outdated_sites)} outdated sites") if not outdated_sites: return # Create SystemConfig once for all users admin_config = SystemConfig() # Group sites by user id sites_by_user = defaultdict(list) for site in outdated_sites: sites_by_user[site.uid].append(site) # Process each user's sites for uid, sites in sites_by_user.items(): try: user_info = pwd.getpwuid(uid) username = user_info.pw_name except Exception as error: logger.error( "Failed to get username for uid=%d. error=%s", uid, error, ) continue # Get scan data once for all sites of this user ( last_scan_time, next_scan_time, malware_by_site, ) = await _get_scan_data_for_user( sink, user_info, admin_config ) for site in sites: if await remove_site_if_missing(sink, site): continue try: # Check if site still exists if not await cli.is_wordpress_installed(site): logger.info( "WordPress site no longer exists: %s", site ) continue # Prepare scan data scan_data = prepare_scan_data( last_scan_time, next_scan_time, username, site, malware_by_site, ) # Update the scan data file await update_scan_data_file(site, scan_data) # Now update the plugin await cli.plugin_update(site) updated.add(site) # Get the version after update version = await cli.get_plugin_version(site) if version: # Store original version for comparison original_version = site.version # Update the database with the new version update_site_version(site, version) # Create a new WPSite with updated version site = site.build_with_version(version) # Determine if this is a downgrade is_downgrade = LooseVersion( version ) < LooseVersion(original_version) # Prepare telemetry telemetry_coros.append( telemetry.send_event( sink=sink, event=( "downgraded_by_imunify" if is_downgrade else "updated_by_imunify" ), site=site, version=version, ) ) except Exception as error: logger.error( "Failed to update plugin on site=%s error=%s", site, error, ) logger.info( "Updated imunify-security wp plugin on %d sites", len(updated), ) except asyncio.CancelledError: logger.info( "Update of imunify-security wp plugin was cancelled. Plugin" " was updated on %d sites", len(updated), ) except Exception as error: logger.error( "Error occurred during plugin update. error=%s", error ) raise finally: # Send telemetry await process_telemetry_tasks(telemetry_coros) async def delete_plugin_files(site: WPSite): data_dir = await cli.get_data_dir(site) if data_dir.exists(): await asyncio.to_thread(shutil.rmtree, data_dir) async def remove_from_single_site(site: WPSite, sink, telemetry_coros) -> int: """ Remove the imunify-security plugin from a single site, including all cleanup and telemetry. Returns the number of affected sites (should be 1 if deletion was successful). This function is intended to be protected with asyncio.shield to ensure it completes even if the parent task is cancelled. """ try: # Check if site is still installed and accessible using WP CLI is_installed = await cli.is_plugin_installed(site) if not is_installed: # Plugin is no longer installed. It was removed manually by the user. await process_manually_deleted_plugin( site, time.time(), sink, telemetry_coros ) return 0 # Get the version of the plugin (for telemetry data) version = await cli.get_plugin_version(site) # Uninstall the plugin from WordPress site. await cli.plugin_uninstall(site) # Delete the data files from the site. await delete_plugin_files(site) # Delete the site from database. affected = delete_site(site) # Send telemetry for successful uninstall telemetry_coros.append( telemetry.send_event( sink=sink, event="uninstalled_by_imunify", site=site, version=version, ) ) return affected except Exception as error: # Log any error that occurs during the removal process logger.error("Failed to remove plugin from %s %s", site, error) return 0 async def remove_all_installed(sink): """Remove the imunify-security plugin from all sites where it is installed.""" logger.info("Deleting imunify-security wp plugin") telemetry_coros = [] affected = 0 with inactivity.track.task("wp-plugin-removal"): try: clear_caches() to_remove = get_installed_sites() for site in to_remove: try: affected += await asyncio.shield( remove_from_single_site(site, sink, telemetry_coros) ) except asyncio.CancelledError: logger.info( "Deleting imunify-security wp plugin was cancelled." " Plugin was deleted from %d sites (out of %d)", affected, len(to_remove), ) except Exception as error: logger.error("Error occurred during plugin deleting. %s", error) raise finally: logger.info( "Removed imunify-security wp plugin from %s sites", affected, ) # send telemetry await process_telemetry_tasks(telemetry_coros) async def process_manually_deleted_plugin(site, now, sink, telemetry_coros): """ Process the manually deleted plugin for a single site. Args: site: The site to process. now: The current time. sink: The telemetry/event sink. telemetry_coros: The list of telemetry coroutines to add the event to. The process includes: - marking the site as manually deleted in the database - removing plugin data files - sending telemetry for manual removal """ try: # Mark the site as manually deleted in the database mark_site_as_manually_deleted(site, now) # Remove plugin data files await delete_plugin_files(site) # Send telemetry for manual removal telemetry_coros.append( telemetry.send_event( sink=sink, event="removed_by_user", site=site, version=site.version, ) ) except Exception as error: logger.error( "Failed to process manually deleted plugin for site=%s error=%s", site, error, ) async def tidy_up_manually_deleted( sink, freshly_installed_sites: set[WPSite] = None ): """ Tidy up sites that have been manually deleted by the user. Args: sink: The telemetry/event sink. freshly_installed_sites: Optional set of sites that were just installed and should be excluded from being marked as manually deleted to avoid race conditions. """ telemetry_coros = [] try: to_mark_as_manually_removed = get_sites_to_mark_as_manually_deleted( freshly_installed_sites ) if to_mark_as_manually_removed: now = time.time() for site in to_mark_as_manually_removed: await process_manually_deleted_plugin( site, now, sink, telemetry_coros ) except Exception as error: logger.error("Error occurred during site tidy up. %s", error) finally: if telemetry_coros: await process_telemetry_tasks(telemetry_coros) async def update_data_on_sites(sink, sites: list[WPSite]): if not sites: return # Create SystemConfig once for all users admin_config = SystemConfig() # Group sites by user id sites_by_user = defaultdict(list) for site in sites: sites_by_user[site.uid].append(site) # Now iterate over the grouped sites for uid, sites in sites_by_user.items(): try: user_info = pwd.getpwuid(uid) username = user_info.pw_name except Exception as error: logger.error( "Failed to get username for uid=%d. error=%s", uid, error, ) continue ( last_scan_time, next_scan_time, malware_by_site, ) = await _get_scan_data_for_user(sink, user_info, admin_config) for site in sites: if await remove_site_if_missing(sink, site): continue try: # Prepare scan data scan_data = prepare_scan_data( last_scan_time, next_scan_time, username, site, malware_by_site, ) # Update the scan data file await update_scan_data_file(site, scan_data) except Exception as error: logger.error( "Failed to update scan data on site=%s error=%s", site, error, ) async def update_scan_data_file(site: WPSite, scan_data: dict): # Get the gid for the given user user_info = pwd.getpwuid(site.uid) gid = user_info.pw_gid # Ensure data directory exists with correct permissions data_dir = await ensure_site_data_directory(site, user_info) scan_data_path = data_dir / "scan_data.php" # Format and write the PHP file php_content = format_php_with_embedded_json(scan_data) write_plugin_data_file_atomically( scan_data_path, php_content, uid=site.uid, gid=gid ) def _find_file_in_index(index: Index, filename: str) -> Path | None: """ Find a file path from the index by filename. Args: index: files.Index object filename: Name of the file to find (e.g., WP_RULES_ZIP_FILENAME, WP_RULES_VERSION_FILENAME) Returns: Path to the file or None if not found """ for item in index.items(): if item["name"] == filename: file_path = Path(index.localfilepath(item["url"])) if file_path.exists(): return file_path logger.error("%s not found in %s", filename, index.files_path(index.type)) return None def _extract_wp_rules_yaml(zip_path: Path) -> dict | None: """ Extract and parse wp-rules.yaml from the zip file. Args: zip_path: Path to wp-rules.zip file Returns: Parsed YAML data as dict or None if extraction/parsing fails """ try: with zipfile.ZipFile(zip_path, "r") as zip_file: with zip_file.open("wp-rules.yaml") as yaml_file: rules_data = yaml.safe_load(yaml_file) except (zipfile.BadZipFile, KeyError, yaml.YAMLError) as e: logger.error("Failed to extract or parse wp-rules.yaml: %s", e) return None if not isinstance(rules_data, dict): logger.error("Invalid wp-rules.yaml format: %s", rules_data) return None return rules_data def get_updated_wp_rules_data(index: Index) -> dict | None: """ Retrieve the latest WordPress rules and return them as a dictionary. Args: index (Index): The files.Index object used to locate the wp-rules.zip file. Returns: dict: The parsed wp-rules data as a dictionary. If the wp-rules archive or data cannot be found or parsed, returns None. """ # Find wp-rules.zip file zip_path = _find_file_in_index(index, WP_RULES_ZIP_FILENAME) if not zip_path: return None # Extract and parse wp-rules.yaml rules_data = _extract_wp_rules_yaml(zip_path) if not rules_data: return None logger.info("Successfully parsed wp-rules.yaml") if ANTIVIRUS_MODE: # all rules will be in monitoring mode only for AV and AV+ for cve, params in rules_data.items(): params["mode"] = "pass" return rules_data def get_wp_ruleset_version(index: Index) -> str: """ Retrieve the WordPress ruleset version string from the VERSION file. Args: index (Index): The files.Index object used to locate the VERSION file. Returns: str: The version string from the VERSION file. If the VERSION file cannot be found or read, returns "NA". """ # Find VERSION file version_path = _find_file_in_index(index, WP_RULES_VERSION_FILENAME) if not version_path: return "NA" try: version_string = version_path.read_text().strip() logger.info("Successfully read wp-rules version: %s", version_string) return version_string except Exception as e: logger.error("Failed to read VERSION file: %s", e) return "NA" async def update_wp_rules_for_site( site: WPSite, user_info: pwd.struct_passwd, wp_rules_php: str, updated: set, failed: set, ) -> None: """ Deploy wp-rules to a single WordPress site and track the result. Args: site: WordPress site to deploy to user_info: User information from pwd wp_rules_php: Formatted PHP rules content updated: Set to add site to if successful failed: Set to add site to if failed """ gid = user_info.pw_gid try: data_dir = await ensure_site_data_directory(site, user_info) rules_path = data_dir / "rules.php" write_plugin_data_file_atomically( rules_path, wp_rules_php, uid=site.uid, gid=gid ) updated.add(site) logger.info("Updated wp-rules for site %s", site.docroot) except Exception as error: failed.add(site) logger.error( "Failed to update wp-rules for site %s: %s", site.docroot, error, ) async def update_wp_rules_on_sites(index: Index, is_updated: bool) -> None: """ Hook that runs when wp-rules files are updated. Extracts wp-rules.yaml from wp-rules.zip and deploys to all active WordPress sites. Args: index: files.Index object for wp-rules is_updated: Whether files were actually updated """ if not Wordpress.SECURITY_PLUGIN_ENABLED: logger.info( "wordpress security plugin not enabled, skipping wp-rules" " deployment" ) return if not is_updated: logger.info("wp-rules not updated, skipping deployment") return logger.info("Starting wp-rules deployment to WordPress sites") wp_rules_data = get_updated_wp_rules_data(index) if not wp_rules_data: logger.error("No valid wp-rules found, skipping deployment") return # Get version and create ruleset dict with version and rules wp_rules_version = get_wp_ruleset_version(index) ruleset_dict = { "version": wp_rules_version, "rules": wp_rules_data, } wp_rules_php = format_php_with_embedded_json(ruleset_dict) updated = set() failed = set() with inactivity.track.task("wp-rules-deployment"): try: clear_caches() # Get all active WordPress sites installed_sites = get_installed_sites() if not installed_sites: logger.info("No active WordPress sites found") return sites_by_user = defaultdict(list) for site in installed_sites: sites_by_user[site.uid].append(site) # Process users concurrently tasks = [] for uid, sites in sites_by_user.items(): try: user_info = pwd.getpwuid(uid) # Create tasks for all sites of this user for site in sites: task = update_wp_rules_for_site( site, user_info, wp_rules_php, updated, failed ) tasks.append(task) except Exception as error: log_message( "Skipping wp-rules update for {count} site(s)" " belonging to user {user} because username retrieval" " failed. Reason: {reason}", format_args={ "count": len(sites), "user": uid, "reason": error, }, level="warning", component="wordpress", fingerprint="wp-rules-update-skip-user", ) for site in sites: failed.add(site) continue # Run all site updates concurrently with a reasonable limit max_concurrent = 10 for i in range(0, len(tasks), max_concurrent): batch = tasks[i : i + max_concurrent] await asyncio.gather(*batch, return_exceptions=True) logger.info( "wp-rules deployment complete. Updated: %d, Failed: %d", len(updated), len(failed), ) except asyncio.CancelledError: logger.info( "wp-rules deployment was cancelled. Updated %d sites", len(updated), ) except Exception as error: logger.error( "Error occurred during wp-rules deployment. error=%s", error ) raise async def update_auth_everywhere(): """Update auth.php files for all existing WordPress sites.""" logger.info("Updating auth.php files for existing WordPress sites") updated = set() failed = set() with inactivity.track.task("wp-auth-update"): try: clear_caches() # Get all installed sites from db installed_sites = get_installed_sites() if not installed_sites: logger.info("No installed WordPress sites found") return sites_by_user = defaultdict(list) for site in installed_sites: sites_by_user[site.uid].append(site) # Process users concurrently tasks = [] for uid, sites in sites_by_user.items(): try: user_info = pwd.getpwuid(uid) # Create tasks for all sites of this user for site in sites: task = update_site_auth( site, user_info, updated, failed ) tasks.append(task) except Exception as error: log_message( "Skipping auth update for WordPress sites on" " {count} site(s) because they belong to user" " {user} and it is not possible to retrieve" " username for this user. Reason: {reason}", format_args={ "count": len(sites), "user": uid, "reason": error, }, level="warning", component="wordpress", fingerprint="wp-plugin-auth-update-skip-user", ) continue # Run all site updates concurrently with a reasonable limit # Adjust max_concurrent based on your system's I/O capacity max_concurrent = 10 for i in range(0, len(tasks), max_concurrent): batch = tasks[i : i + max_concurrent] await asyncio.gather(*batch, return_exceptions=True) logger.info( "Updated auth.php files for %d WordPress sites, %d failed", len(updated), len(failed), ) except asyncio.CancelledError: logger.info( "Auth update for WordPress sites was cancelled. Auth was" " updated for %d sites", len(updated), ) except Exception as error: logger.error("Error occurred during auth update. error=%s", error) raise async def update_site_auth(site, user_info, updated, failed): """Process authentication setup for a single site.""" try: await setup_site_authentication(site, user_info) updated.add(site) except Exception as error: failed.add(site) logger.error( "Failed to update auth for site=%s error=%s", site, error, ) async def remove_site_if_missing(sink, site: WPSite) -> bool: """ Checks if the site directory exists. If not, removes the site from the local database and sends a 'site_removed' telemetry event only if deletion is successful. Returns True if the site was removed (directory missing), False otherwise. Parameters: sink: The telemetry/event sink. site: The WPSite object to check and potentially remove. Side effect: If the site is missing and successfully deleted from database, a telemetry event will be sent. """ if os.path.isdir(site.docroot): return False # Attempt to delete the site from the database first rows_deleted = delete_site(site) # Only send telemetry if the deletion was successful (at least one row was deleted) if rows_deleted > 0: await telemetry.send_event( sink=sink, event="site_removed", site=site, version=site.version ) else: logger.warning( "Failed to delete missing site %s from database, no rows affected", site, ) log_message( "Failed to delete missing site {site} from database", format_args={"site": site}, level="warning", component="wordpress", fingerprint="wp-plugin-site-delete-failed", ) return True async def fix_site_data_file_permissions( site: WPSite, file_permissions: int ) -> bool: """ Fix data file permissions for a single WordPress site. Args: site: The WordPress site to fix permissions for file_permissions: The file permissions to set (e.g., 0o440 or 0o400) Returns: bool: True if permissions were fixed successfully, False otherwise """ try: # Get the data directory data_dir = await cli.get_data_dir(site) if not data_dir.exists(): return False # Fix directory permissions (0o750) only if not already correct current_dir_mode = data_dir.stat().st_mode & 0o777 if current_dir_mode != 0o750: data_dir.chmod(0o750) for file_name in ["scan_data.php", "auth.php"]: file_path = data_dir / file_name if file_path.exists(): # Set permissions based on hosting panel only if not already correct current_file_mode = file_path.stat().st_mode & 0o777 if current_file_mode != file_permissions: file_path.chmod(file_permissions) return True except Exception as error: logger.error( "Failed to fix permissions for site=%s error=%s", site, error, ) return False async def fix_data_file_permissions_everywhere(sink): """ Fix data file permissions for all WordPress sites with imunify-security plugin installed. Args: sink: The telemetry/event sink """ fixed = set() failed = set() with inactivity.track.task("wp-plugin-fix-permissions"): try: clear_caches() # Get all installed sites installed_sites = get_installed_sites() if not installed_sites: return # Determine file permissions based on hosting panel from defence360agent.subsys.panels.hosting_panel import ( HostingPanel, ) from defence360agent.subsys.panels.plesk import Plesk file_permissions = ( 0o440 if HostingPanel().NAME == Plesk.NAME else 0o400 ) # Process sites for site in installed_sites: if await remove_site_if_missing(sink, site): continue success = await fix_site_data_file_permissions( site, file_permissions ) if success: fixed.add(site) else: failed.add(site) logger.info( "Fixed data file permissions for %d WordPress sites, %d" " failed", len(fixed), len(failed), ) except asyncio.CancelledError: logger.info( "Fixing data file permissions was cancelled. Permissions were" " fixed for %d sites", len(fixed), ) except Exception as error: logger.error( "Error occurred during permission fixing. error=%s", error ) class WordPressSiteInstaller: """ Handles installation of imunify-security plugin on WordPress sites. This class processes WordPress sites and installs the imunify-security plugin, including setting up authentication, scan data files, and rules. """ install_plugin = True telemetry_event = "installed_by_imunify" task_name = "wp-plugin-installation" log_fingerprint_skip_user = "wp-plugin-install-skip-user" messages = { "start": "Installing imunify-security wp plugin", "complete": "Installed imunify-security wp plugin on {count} sites", "found": "Found {count} site(s) for installation", "error": "Failed to install plugin to site={site} error={error}", "cancelled": ( "Installation of imunify-security wp plugin was cancelled. " "Plugin was installed for {count} sites" ), "exception": ( "Error occurred during plugin installation. error={error}" ), "skip_user": ( "Skipping installation of WordPress plugin on " "{count} site(s) because they belong to user " "{user} and it is not possible to retrieve " "username for this user. Reason: {reason}" ), } def __init__(self, sink, sites): self.sink = sink self.sites = sites self.processed = set() self.authenticated = set() self.rules_installed = set() self.failed_rules_updates = set() self.failed_auth = set() self.telemetry_coros = [] async def is_site_ready(self, site): """ Check if site is ready for processing. Override in subclasses to implement different readiness checks. Args: site: The WordPress site to check. Returns: bool: True if the site is ready for processing, False otherwise. """ is_wordpress_installed = await cli.is_wordpress_installed(site) if not is_wordpress_installed: log_message( "WordPress site is not accessible using WP CLI. site={site}", format_args={"site": site}, level="warning", component="wordpress", fingerprint="wp-plugin-cli-not-accessible", ) return False return True def _record_processed_site(self, site, version): """ Record a successfully processed site. Override in subclasses to implement different recording logic. Args: site: The WordPress site that was processed. version: The plugin version installed on the site. """ self.processed.add(site) def run_post_processing(self): """ Execute post-processing after all sites have been processed. Override in subclasses to implement different database handling logic. By default, inserts all processed sites into the database. """ insert_installed_sites(self.processed) async def run(self): """ Process WordPress sites for imunify-security plugin operations. Returns: set: The set of successfully processed sites. """ logger.info(self.messages["start"]) with inactivity.track.task(self.task_name): try: clear_caches() if not self.sites: return self.processed logger.info( self.messages["found"].format(count=len(self.sites)) ) # Create SystemConfig once for all users admin_config = SystemConfig() # Create wp rules once for all users wp_rules_php = await load_wp_rules_php() # Group sites by user id sites_by_user = defaultdict(list) for site in self.sites: sites_by_user[site.uid].append(site) # Now iterate over the grouped sites for uid, sites in sites_by_user.items(): try: user_info = pwd.getpwuid(uid) username = user_info.pw_name except Exception as error: log_message( self.messages["skip_user"], format_args={ "count": len(sites), "user": uid, "reason": error, }, level="warning", component="wordpress", fingerprint=self.log_fingerprint_skip_user, ) continue ( last_scan_time, next_scan_time, malware_by_site, ) = await _get_scan_data_for_user( self.sink, user_info, admin_config ) for site in sites: if await remove_site_if_missing(self.sink, site): continue try: # Check if site is ready for processing (WP CLI accessible + other checks) if not await self.is_site_ready(site): continue # Prepare scan data scan_data = prepare_scan_data( last_scan_time, next_scan_time, username, site, malware_by_site, ) # Create data files (scan data and auth token) await update_scan_data_file(site, scan_data) await update_site_auth( site, user_info, self.authenticated, self.failed_auth, ) # Install rules if wp_rules_php: await update_wp_rules_for_site( site, user_info, wp_rules_php, self.rules_installed, self.failed_rules_updates, ) # Install the plugin if self.install_plugin: await cli.plugin_install(site) # Get the version of the plugin version = await cli.get_plugin_version(site) if version: site = WPSite.build_with_version(site, version) # Record the processed site self._record_processed_site(site, version) # Prepare telemetry self.telemetry_coros.append( telemetry.send_event( sink=self.sink, event=self.telemetry_event, site=site, version=version, ) ) except Exception as error: logger.error( self.messages["error"].format( site=site, error=repr(error) ) ) logger.info( self.messages["complete"].format(count=len(self.processed)) ) if self.failed_auth: logger.warning( "Failed to authenticate %d sites", len(self.failed_auth), ) if self.failed_rules_updates: logger.warning( "Failed to install wp-rules on %d sites", len(self.failed_rules_updates), ) except asyncio.CancelledError: logger.info( self.messages["cancelled"].format( count=len(self.processed) ) ) except Exception as error: logger.error( self.messages["exception"].format(error=repr(error)) ) raise finally: # Run post-processing (e.g., insert sites into database) self.run_post_processing() # Send telemetry await process_telemetry_tasks(self.telemetry_coros) return self.processed class WordPressSiteAdopter(WordPressSiteInstaller): """ Handles adoption of existing WordPress sites with imunify-security plugin. Adoption is a special case of installation where the site already has the plugin installed but is not tracked in our database. """ install_plugin = False telemetry_event = "site_found" task_name = "wp-plugin-adoption" log_fingerprint_skip_user = "wp-plugin-adopt-skip-user" messages = { "start": "Adopting imunify-security wp plugin", "complete": "Adopted imunify-security wp plugin on {count} sites", "found": "Found {count} site(s) for adoption", "error": "Failed to adopt plugin to site={site} error={error}", "cancelled": ( "Adoption of imunify-security wp plugin was cancelled. " "Plugin was adopted for {count} sites" ), "exception": "Error occurred during plugin adoption. error={error}", "skip_user": ( "Skipping adoption of WordPress plugin on " "{count} site(s) because they belong to user " "{user} and it is not possible to retrieve " "username for this user. Reason: {reason}" ), } def __init__(self, sink, sites): super().__init__(sink, sites) # Load existing docroots from database for adoption logic self.existing_docroots = { r.docroot for r in WordpressSite.select(WordpressSite.docroot) } # Track sites that need to be inserted (not already in DB) self.sites_to_insert = set() def _record_processed_site(self, site, version): """ Record a successfully adopted site. For adoption, sites that already exist in the database (flagged as manually deleted) have their flag cleared. New sites are tracked for batch insert. Args: site: The WordPress site that was processed. version: The plugin version installed on the site. """ if site.docroot in self.existing_docroots: # Site exists in DB but is flagged - clear flag clear_manually_deleted_flag(site) if version: update_site_version(site, version) else: # Site not in DB - track for batch insert self.sites_to_insert.add(site) self.processed.add(site) def run_post_processing(self): """ Execute post-processing after all sites have been processed. For adoption, only insert sites that are not already in the database. Sites that were already in the database had their flags cleared during _record_processed_site. """ insert_installed_sites(self.sites_to_insert) async def is_site_ready(self, site): """ Check if site is ready for adoption. Args: site: The WordPress site to check. Returns: bool: True if the site is ready for adoption, False otherwise. """ if not await super().is_site_ready(site): return False # Verify plugin is actually installed is_installed = await cli.is_plugin_installed(site) if not is_installed: logger.warning( "Plugin not installed on site %s, skipping adoption", site, ) return False return True