From 05038c6ad7c86b4aa9561a589379bcb3e0b87d644915b008412680c2b5f22fe3 Mon Sep 17 00:00:00 2001 From: larssand Date: Thu, 18 Jun 2026 21:03:07 +0200 Subject: [PATCH] first add --- README.md | 71 +++++++++++ pyproject.toml | 9 ++ src/fgai/__init__.py | 4 + src/fgai/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 229 bytes src/fgai/__pycache__/cli.cpython-312.pyc | Bin 0 -> 6480 bytes src/fgai/__pycache__/llm.cpython-312.pyc | Bin 0 -> 2252 bytes src/fgai/__pycache__/logs.cpython-312.pyc | Bin 0 -> 4484 bytes .../__pycache__/mitigation.cpython-312.pyc | Bin 0 -> 6483 bytes src/fgai/__pycache__/models.cpython-312.pyc | Bin 0 -> 3344 bytes src/fgai/__pycache__/policies.cpython-312.pyc | Bin 0 -> 4638 bytes src/fgai/cli.py | 115 +++++++++++++++++ src/fgai/llm.py | 34 +++++ src/fgai/logs.py | 119 ++++++++++++++++++ src/fgai/mitigation.py | 109 ++++++++++++++++ src/fgai/models.py | 54 ++++++++ src/fgai/policies.py | 106 ++++++++++++++++ tests/__pycache__/test_logs.cpython-312.pyc | Bin 0 -> 1722 bytes .../test_mitigation.cpython-312.pyc | Bin 0 -> 1781 bytes .../__pycache__/test_policies.cpython-312.pyc | Bin 0 -> 1308 bytes tests/test_logs.py | 23 ++++ tests/test_mitigation.py | 27 ++++ tests/test_policies.py | 30 +++++ 22 files changed, 701 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/fgai/__init__.py create mode 100644 src/fgai/__pycache__/__init__.cpython-312.pyc create mode 100644 src/fgai/__pycache__/cli.cpython-312.pyc create mode 100644 src/fgai/__pycache__/llm.cpython-312.pyc create mode 100644 src/fgai/__pycache__/logs.cpython-312.pyc create mode 100644 src/fgai/__pycache__/mitigation.cpython-312.pyc create mode 100644 src/fgai/__pycache__/models.cpython-312.pyc create mode 100644 src/fgai/__pycache__/policies.cpython-312.pyc create mode 100644 src/fgai/cli.py create mode 100644 src/fgai/llm.py create mode 100644 src/fgai/logs.py create mode 100644 src/fgai/mitigation.py create mode 100644 src/fgai/models.py create mode 100644 src/fgai/policies.py create mode 100644 tests/__pycache__/test_logs.cpython-312.pyc create mode 100644 tests/__pycache__/test_mitigation.cpython-312.pyc create mode 100644 tests/__pycache__/test_policies.cpython-312.pyc create mode 100644 tests/test_logs.py create mode 100644 tests/test_mitigation.py create mode 100644 tests/test_policies.py diff --git a/README.md b/README.md index 473a0f4..e2bdcae 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,71 @@ +# fgAI + +Local FortiGate log and policy inspection agent. It parses FortiGate syslog/JSONL logs, audits FortiOS policy exports, highlights UTM events, and can quarantine malicious source IPs through the FortiGate API when explicitly enabled. + +Autoblocking is dry-run by default. The tool will not block RFC1918, loopback, multicast, link-local, reserved, or allowlisted addresses unless you change the code. + +## Quick Start + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +Analyze local logs: + +```bash +fgai analyze-logs --logs logs/fg_syslog.jsonl +``` + +Audit a FortiGate policy export: + +```bash +fgai audit-policies --config exports/fortigate.conf +``` + +Find block candidates without changing the firewall: + +```bash +fgai suggest-blocks --logs logs/fg_syslog.jsonl +``` + +Execute guarded quarantine actions: + +```bash +export FORTIGATE_HOST=192.0.2.10 +export FORTIGATE_API_TOKEN='...' +fgai suggest-blocks --logs logs/fg_syslog.jsonl --execute --expiry-minutes 60 +``` + +Optional local LLM summary through Ollama: + +```bash +ollama pull llama3.3 +fgai analyze-logs --logs logs/fg_syslog.jsonl --llm +``` + +## FortiGate Inputs + +For logs, configure FortiGate syslog to write into a local file such as `logs/fg_syslog.jsonl`. The parser supports common key/value syslog lines and JSONL. + +For policies, export a FortiOS config backup and pass it to `audit-policies`. + +## Environment + +- `FORTIGATE_HOST`: firewall hostname or IP. +- `FORTIGATE_API_TOKEN`: REST API token. +- `FORTIGATE_VERIFY_TLS`: `true` or `false`, defaults to `true`. +- `FGAI_ALLOWLIST`: comma-separated IPs/CIDRs never to block. +- `OLLAMA_HOST`: defaults to `http://127.0.0.1:11434`. +- `OLLAMA_MODEL`: defaults to `llama3.3`. + +## Safety Model + +The agent separates detection from enforcement: + +- UTM events are scored from FortiGate logs (`ips`, `virus`, `anomaly`, `ddos`, `webfilter`, `app-ctrl`, `waf`, `dns`). +- Source IPs must be globally routable and outside the allowlist. +- Blocking requires `--execute`. +- The FortiGate API call is limited to the quarantine/banned user monitor endpoint. + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8c03269 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "fgai" +version = "0.1.0" +description = "Local FortiGate log and policy inspection agent with guarded UTM autoblocking." +requires-python = ">=3.11" +dependencies = [] + +[project.scripts] +fgai = "fgai.cli:main" diff --git a/src/fgai/__init__.py b/src/fgai/__init__.py new file mode 100644 index 0000000..abd4e91 --- /dev/null +++ b/src/fgai/__init__.py @@ -0,0 +1,4 @@ +"""Local FortiGate AI/ML inspection helpers.""" + +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/src/fgai/__pycache__/__init__.cpython-312.pyc b/src/fgai/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..328dd209c20aa6562138f208d6d922f6e19c211fef4b8fe4613c07cf7c5adc67 GIT binary patch literal 229 zcmX@j%ge<81X&IyS&2aUF^B^Lj8MjBAs}NqLkdF_LkeR$V-#ZwQxsDrvnETGrcZuy zVvd4aeo;xLdtyndf}^Ltua81zUU5Nca!F=>ovED82`1mp)11J_BU&U&m zXQ*f3r^$SaJw84qKRG@g#7NA^iH|R00$R3`;WNnYUn0dt$@*#OiJAKG@tJvn1z~ literal 0 HcmV?d00001 diff --git a/src/fgai/__pycache__/cli.cpython-312.pyc b/src/fgai/__pycache__/cli.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..74b1f41a224e03e4a1e21f7115ced1fb454f519a43ef13f39461f3b8983481d1 GIT binary patch literal 6480 zcmd5ATWl29b!KOFz3+z~7=yuMV;*a;9R#7U;Smc$;>0+}cG9pY*{pZQ%b0yQcV@x6 zTDMkIsiZ*)s2{irQ5~sLjBpFBB89K2kt#J(`>~sKy6(hPkkWqe=K!i&Dpft_&d$!- zEK1a`UTN;*+%xxa?s@;w<8d-j&Of_<@_2w@zQ%!3c)i0u_$_prc!A&s>Ve4nJg3JhwoVcXADkalT#ud-=V;LBIl*70++K+ z5gB0oq>@f)Rxy*DQh-W4oIa}H{=y@lLSp?B@u=J%OUQjwS9#kkQ7t6Ghz zJPWusV`R1fwVNZOr@D+D%)5=g*%~`6vNtOfp0TJtV_pS$(Mz5+F!PoZ%zIqS5@%wT z*LN_?443C78x1_O1!%K5GI{_-%B?G+Ycim#_#=I<{f`*E`Y5w&h^2p;H8agFZ=0gOZKjMOcO-Z*(gBsmB@#)3Z$^+^G;1;~N{Jw^@lg_2G+Ra1ou-^>4zrK% zFF+wJ_^&&fiYBt44(L&O0EFu*zk~|7-LrA7`R8pvYb$xS&7NEd1efjJ<)+qa$F3e* zZrpIK?`q#lW9NMLM|~gml^XXxbXXg0voAh$GgklKT6ZqkO0E5?Hl}OOKdfA*_ipo+ zRgP)jUf#UBxOw+&`IGdo(}j_<<&ju%Bv$f^_c=iN#%gmoA98l9ZMBiHyMH|W_VkL~ zQ}AwGvUfn|a%yg5e$&F(t?-R-$$N0gerVa{{@48`_)|fGe%i77xXZal6017rKQLb_ ziKfm0o~g(Lpj3;&YeoyQiEGi4XF)bB71{8DY*_M^dQu6z?x(!Imd~l3GS<>t;B(Y6 zw1`z1WEm4`N^714PG=_l2EN(W;d3}8<#P-0IX6tY09j{22SJrVP*-TSSUNQ!$0@^U z_DDpOV`?Nqx)GtuW@I}IgB$@}z-aafP_}XkxUIqHp?0paRiMTyWFr%DLLyJWJXkkI zISUo=mT$}TlwDn-gcWCy772bDyEsZgAD% zgxBWD!v0&K8=;c-z>@uYloK3G;28yFVxeguWM9JwRh8Tg5?IR!K+69cm7KRIZu6nV z8n(%DsE}X(4Ju+21JrCCHh7;A`TEKlW~`!Z5+#3KsjsjN=6|30z-siLq^j4Pyurl2 zeAZOflc?9$6~oDy;YItCPW^#p#+v6RQTeSa^(g>vDy&0YB0exEyfSu5NXn`lr*56GgiA2cLP9V= zZ3o!~M|-mApi*148sPq<<&VoEZD-$Krlq#nrG;v;O zdP*kQ2v|ZuR)P&w?#WKv6{VO2@pl`zOz3ji^ebw1Ai@+_L`gwi3EBXQZfLw~d`N|a z0AVkD%6+K*M^p2Sn{jWT2IAO~{n=%o|87giea3Fxb;$wHD{Y`eih<68ziWQyLhx4q zjs8L~bSL<`6Mq=|bnr8|JRB(wM@lE&EF6y(yyHvu*s`zbz5Gw|EABv{v1iG>d)eK* z5)jIPU@;IZ1$JMuEjRTp$hXor(uLlmg}^Zcw3VB77Mpf1ILf;Y7Iz)I(_IdoDuzyd zcA?bt>LoiK!!`H9T&UpP@r8ZoY5)!grp@gk=ugJrs}@vS)Mxw?)U^t#o_Z45#VW(j zpf$k1*Ab=@YhS!`L{@U3{Qu-?XX>s9TrNdy&ayj~XDOvUyD zif$4&*H9F_=6aNB2a0Z4L(%$pkKd#|-s3mnChOg#YA(LRO}2_1H8}z;f?xuT{XP9Z4Gbobtzkojn+_>8D)aq!@2E4=~y%& zR3mL+V6gwxkRYd&DJg~#yr8Dj2@L8*Nr@483Sna~295ewMkZh&3{SlmJ@s7sfbO>o z5RtE07-uoUJtrlma<*O_yXSONJqKF|N>%{`;ladpr01B zkR!nBn5QFtQ4q24gW>lu}f`K?9r)qoZ7)@)I2isc2F{Dotckv7Da5=rwW{ zx$Xf>Y3Psj@+eS+NEhKk0!7FQy0e_K_x4tLTMX*b=Y>CXxG)}#ozG0=49nL`uc_pA zkLuYJq<_U23vn>MA`nMOXGlx}8tF7qa*p2K3Qn$PAf{&E9B=|W4SF9YKbh8B7&twc z^W$%5!14XifZscekLT3v5F*m_j^-U8@k|n|_i4=b5J=>RBBYJR=`2w+Cyw<2M2XOc zKPkS(VMfKRNNS|Q0}8>wk$U*lt_Q7}t@5YST#>pgn%y9?X4C0G{q-70!9AXl6UNSD z1Qs5IPr(Eix(O|1Pj}JNUD*5DlIP6q$>jiq3OkB{9k=;HU`HwN^z4gwT`gsoP;?3N zTMGxq3NE4KdS&*+GH);QEk(ZN%DIK+!u~f4e9ICaHKD1+eRo(244J^>;o>S2h`)8Wxln@x6auY_7@JorCOJGxRe3)E{@*mq`<=_(6e~_PJjZR zsR80q;ZX2D9Lo6LwJ=mTcAi2LRcK*j&&N%7^t}eE(Ccqdv^{rSf!R~KcZB&lqg1`4 z-vgx9JF12c=;5G-T37OnVX-_IBhPD2 zDnKZ73V99zj_0Oh61597Cf7L7<&cq5MTw9~?&ks-224Fd=cMVFf(x21IG>C{6oBFP zIrx-Os8FKX-hBf&!4)XG+KaCCxv`R~Yxcydg|`OKAI`p$oojyghXwoAf3CQi?=u!_ z0G(Rf73GR_DfNqeAB8>)ePQog@%jI~YK6&13Z?@;@b@vlatw5EpKKl2#C^KS3SG=B z;0Yp;AWslUv!%x;!Te$Ppk_!)8J2_`YckZUwFRo^fuYh@}LlgJw|L583LV}bo# zYZYl=jW!Fi!?4a7MDb$^`>djp*M+=*8`P}Qra2>#i41HmLB0dIhdhrJ+H&%Jth%v6 zmeB()WDS~Bnld%NJMjvW))RRJ~u6^ zHyA1bG;(@mKMXc2laP)Q2^L17P{L5HaxBaKmFfDL@qNj-zGR$#XF9%O`oCgEipjcDC{1(FQiK+RX6oOS#Xvw!b)=ua5on)snNlz_&w+ Jj^9N0{x3T&l>q<% literal 0 HcmV?d00001 diff --git a/src/fgai/__pycache__/llm.cpython-312.pyc b/src/fgai/__pycache__/llm.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..749320b40350d153c717b2b8e65f370fa197ac719936df8ba3cc9a1d18cb96b1 GIT binary patch literal 2252 zcmahKT}%{5cy{-8?`Pov;Sgx8>tEr5+<|B;6m1o_wuz!C7%L6AE;|G4v3Gl&-7DPX zNNL&!NldU$#fXuYCN}tBpV~)F+N4Qez%#{cH8Iho@eRe+$Wy;LSWkIzHaqjp_dDM= z-}lX*<>dhcWBkK|iEa_0hiu~xVFTdSVgUSrFv6UHlH4H2F_<^_LB6;Og95CA;Yo^v zBG5coG^C_=(3|uP`e5(HJ}iAF4*Iiv*#DR*4RemHrc$P@+Payt`e5&+Voiwars8HOL1)?76jPJUgMyWQ!vK`>g|Wm|xt)C&1UO z0}G3r@>4>dpX97s?oArOQ&;1ZCy#p2tf=v$NaKfs2u+E3F(GZWg3$N;g-Q?b0G$H~ zX;b1|kdX3Hg88&%0JeBbucHB5_Bw{`urx1#g{Gnf347=HDX(4O@+B%=sI=xia6kSx z@Oa(WTt-)T9GHI@t3-84lM7+l<(~4{As3sdb)nLl7cL`Qc7@OTa5)a*3Z1)Nx#-!T zPx)Vwtt-j6P~MNLu5WYWe+W$lUh$#96KD@=Ll!pylyiuK??Tt4 za1Kpy;ZUC=pE_~k*vVt@k5BavXdErojldk zdm`sE3^l2?N7}=JBUx07>uE={G?PN9QKDKmicr>QJf~S4%eu{!O;UEt zKsHTsm1;T-$1QfNXaZsbN?E^U;X2}t8CYc5)lG4Bn%8JAB ze!9^t9dCvjW|{z(VXD}2Jm4U9Bup5uBY*)%0CRA&%v89HvQFRxO5vqdMN4C*WjjHO z7(}xPj&H=vyLmZ||0O?V)$VwPO`LTE=-8Ci8D%AIvBN8`jP(l0s_4k5sz(hY8A)fI zpjphhxK&JrENuq~*4M1l(08ZNJ-K|gW2WQc(9+(nf_!8}?pc<5ZuS@CPo{hBh8k{% zy61X-Cu!CwP~cel<+&mUM50%g+k8sshiX5{?YZ_U*Z68+C=k-Tj- zIuo56n2Rm6EH$*=uy4ke&S*eKD$>@ zJ)4`!%{5(Zxzutwy3)|P46@q`4IPDw&hwt<0&qQ&gXitp$(hNyz{2rDMPos3T9G5m za-<-)E_qsCJPo7J2QQxT;L!8kz+Y6oZn2Je%TwLE*K=z}b8lGs4Yl`1gx@1#?_qD4 z>kIpexsWS2bj$vH^Kl)b%^;PlDdJv#fQO2ik_$j0DS4&OOc6+6VrOWIHi1ky-~{6F zkqjURWHNiCslukWVR9r$Q$ru#NM5O!T^40eBlWV0CBjc=4Q50Sz_jk`0-lUPj?zxp kvRB!vhiO&dIPN~${Se82BmaHW@DFPKSE}RMXa`XL2dOw6%K!iX literal 0 HcmV?d00001 diff --git a/src/fgai/__pycache__/logs.cpython-312.pyc b/src/fgai/__pycache__/logs.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..1c980266d9290cea29d484fb654feddefff9bca328b8d0b1d79f655e790f1e7e GIT binary patch literal 4484 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 KV|_qZp8o|``v2(w literal 0 HcmV?d00001 diff --git a/src/fgai/__pycache__/models.cpython-312.pyc b/src/fgai/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..cd5f73d4ab692788d34aa6acc4108139334c2e533f2fbe9ddc9fb3b8fc866589 GIT binary patch literal 3344 zcmc&$O>7fK6rR~Xuj9l^5(<=1)k&en{3H|-iV7-}wvhf=P}-_;6l} z2_jNxMS7tMf~NRm!RF&H9hTYL!z*+P81tyxE!W z{mxGv9c={0*qOnxUll@r!;jif1BO=0z|0YgSkxioG)XD)k|T}FNqJmJDwIgXmM!^# za-RlkByyfu>Mdev_oYB`KX{R(+EO>95`bYWE_a^O} z>&x6_zRb+4rKpunyI;cRp%{=6I5D1`r>hPTXT=l4Ln{w}nIlsKPrh-CRdF@TEn0Kz zHOf!Wt*n%qR`zUZrCeKiiNuwJuX2{oPWT$UQt%G*e1WBHVB->tz+ruP)OHuBuW;MR zaD*LO?*iq4%&3_i7|&a_!}}+SEDAFvqh`VW;CJ`~{w9!V^33SIbA9&uotv{a?_Twc z*M2sv`N4-L7fwDry>R-`3Gd*$Ao)>w5_ueXVmvm=U!=WZtE^iUSWTWYH{?@b?$3eQ znz=2h7PDj3EX2RpMlFcIFml2V@xo=dIAQx_K^B%^+d$D*o%~gsu}w}wtgm5Ar8NmOeZl_XyTPg+lS8$MTPo=&pm`-)Z zNTr&pFHGwbEI(m0H^@^FtqdK@5XS-$+XsQHqZEk-4^IL4nmh})&kV1$@Aj14&(w}T zC92xAA`>mTD%e#GSd<>G3y;@jLv`6uU3M{8y#`Az(qM1gF%3_BRLdo;oC8toHUOPT zvDF9lf|k@Q1sa1YT0|I|Kr#gFA!3DYsY$)4Ly2#@kax1_;wNA>|hQVlm+-Uz;?Yg3U{v4MhEcs28;Oi$JEy3KZQNvp1HF*rE}; z*M0A@XT-~TJV?sshJ-vkv~nC6arov22QnN}^>%ebo|2h5C0i8txkRQUIJlHxc)&Pi z{rFPE!P#Np{ZQ4gVuquoVlV#A$bJ}9)e-3 z@D&V&z%K}oy^iEnBv`tG+}V#Dl0EQ5=76~Ji%bO<r30UEx z86>y3llXELE_5nS=b6op!sdR6*(QfeX(=k=qgKO5{0?r8Pe3(=TAp)x0wTCoG;HuW zI*1{`fZ*6c!2|-hFhR!-Pua23-Mggp2KZ4m0c9MDccuWP9*Q?ZZ~Kw@1=;2dQ$)m_ zD8(YCgq_7s8@#Ztp9ZbRwK)=RCX*owv;L?%{`K5QzW~0 zlq}*HC=Z~LA;40LgiO++YEz&N?6&#oAN^4m1sb3~N{L3zUL7E4ezg9lT1JuRS7(np zT5{zCFuHH#Y4|syiFNL+d0J z!~ty?sU%Kl%co5~*F7fEhc#y)7KyQ*9QY@@FPQ{~A zaQbvID)w&Sm{GAYO7JCECEHX?Om#3#xJw5RqIep?h?$K*Yayr}g-j!87gCsfy*!0z z6$btnnPna+*Ehb?cofa|PNB=(D4N1Pe)t|Erc6(blSPGEpm&db`plX|jzq=F(148; z&GBj=niLOH8mAf;6=QHN=BN}CHA^Bu6jiV=Vq?OKH+az4eo?zF1no+h|QoW zT6O*d*tuQbdFzvFWSr6bEv4MhOyx_Gtg2}S&;LcWf@M}z>-WHNXSr z&&;~*e2(Fv)Q(_07Lvk3NTT8tz=@!{@bnpt6JsQ05s9P-`V9eqvP}do>lb4kw`%5? zcvQ&A3{cjRvOtOde%s*LeMw5skYfPHTMeqR9;Jg;JqA8DUlN zIu@n*i`%AMFvGNx_kyd>pn|J<=2)SzW#)LHqbq0j7Fu@A9R1u|n>o37GVk4eAJtp- z&Dsi;HCNx5dm~@jJZmmED|1!*R-F3^wGFe!3T58EIlWo$QuPOoh03~I+^M8pWx-XXYbVYEZ6nitjgEDID5R%+>$G6D6(64;Y#1va@s_G*WJ4GT;9EBmM=J~ zmz^!Y2o>suxf8Q5E$k>fwHH`sM^?&OR-cE>ulDGAuYt+X7A#=_NXL?wp_ADZ5R?)$ zKMVug-FE`uf8&4d`3QjWaLSTYLLEJtm5PaIASeP1+r(HfuE$4SWbiEeIQ=Xs3k{l` ziUHzh4#vW20>Y}ykW45#Lb*O`_!EUtgAT&K+yd1My6bHEiLx~KVd_rv{^jQVH_v@K znrq&lZ$6iIp3j-j({7;JvU7$!`p%C+{X=^O0d`^)4Wb1+K8Mg1`<#6{9A*18Pz?ee zLr6Sd{}@_x>2{ldqGVB!yip!v-5|Qk1x*nYG>v=U=5s2yowW@V|B-3lYX+=ObC2O$ z!j&iDYk4f+5_&!nUp~zd8)(eD?ve5#`l%@m7`TD6-%`zr%^*pE7tn6i^2kV=%0DvF zq~h&R{~_KI2XT@z)q11+I`cRv#tA`OGGssdnA5BvB|j&XlZbw^G#7Cb&-T(DBcsaD zrx<-NW4RV1g=GYKnYD*7@xEhIT*k;CGJL{VnYI}_kxdJDKBC$vYop(2n`*Ly)NHRU zte=PIgGazWG5n{wscn0{+F^}{$e|=nC>RtIie?K^NrALCn(};y#sfrvSQnq#h<2%$26QS}P-q|&l7d2r z#wP?wtOYrqq(Mf1T3ye%8?E)1^G6?j76q{sPhl^V40kSjY0HGa4`)&a<=FIz9W`77e@8T1X zW@RA19hVfb3x&{~pd@jOJ_1Xd3;RgIpWKvxas8Cm*MoI=g8HbRl5t4;q=F zm>|sPn7nT94U3RaBxql%C6I)qqnH)2RJu1BhhvlbI!h#eSShoZ{^h^WmosRkKb$)^ z_Iv(M<+sW|4c<9)dbvORmCSbX_Rc22|A-Whq;?lic$3v2l3bF7vuC~w7LpU9?oz44 z!*Su*sgZVJ;MH~k9zl>7v4>H+u<6y*E({I>1swmmfgxez!6m>U1QaC@jEJPuXV)x{ zP(e1tHm_O0(?qJ-H&PGCZyW=|qyAS1UOszxggyl%lmQuKS&L=^jr=DLkB;~pbgw=T zD!v3|%91zDqMsmrkuB(BRX-bbZ14s@)2xwz>}O7O0%o?RLWhbGuwtu;={X=|Y~=R< zAi<)ACSm4i!BfA6+AI|;D{x=!o$JlomiFYG`>su|IC~(;_}pESX;^H?M)K}9py_<> zw-X;v+&q@=9Qv#0$kN$`=Q9Tv55D_i&U0ke3f2FCpr$E1d8fH+xw$LX)Sat-X4dh! z)4gzh#o4&jcH{U;>kBsz{BdmM;K|&X*KWTaSveB{Ew7%OJ9*btQ)p}})OQwke!tMP z=Yieg_B`OMKy{d(wje>mS8p0$GQ7S7*xHhvV|%x_nrexR`Pf}zZRbY%uG zSbOy})-$ZH?jOUb>L~t7^BiISG6ZvU%Um;5&EM*-8LH?1&`>kf%=^s6ESLjd(|9T> zNg7KvD9J=rd<}-$1@^*cz438D6^7$65mr16tmt`GjY75V(rHeWer6a4k{4hi1*toQ zRqSU8osy2hC`>Z9)cg8qs$5^c@q5L%uR?S9{h=giBKrN5-Es=(fix@2!=loKVv5F3 z#EBS{i;mA83iK6Lon}=7t5{~P=VrQhehhJ&4X|6kZtI1`?Dr7Lkbq@&@-tAus~6+X tQ0o_{>@!sRPt^90@;x&S&8f`T)_5y!Uh}qM@7i%78pRK6`> None: + print(json.dumps(data, indent=2, sort_keys=True)) + + +def analyze_logs(args: argparse.Namespace) -> int: + events = read_events(args.logs) + candidates = suggest_block_candidates( + events, + min_events=args.min_events, + min_score=args.min_score, + allowlist=parse_allowlist(args.allowlist), + ) + _print_json( + { + "summary": summarize_events(events), + "block_candidates": [ + {"src_ip": candidate.src_ip, "score": candidate.score, "reasons": candidate.reasons} + for candidate in candidates + ], + } + ) + if args.llm: + print("\nLLM summary:") + print(ollama_summary([], candidates, args.model)) + return 0 + + +def audit_policy_file(args: argparse.Namespace) -> int: + findings = audit_policies(read_policies(args.config)) + _print_json([finding.__dict__ for finding in findings]) + if args.llm: + print("\nLLM summary:") + print(ollama_summary(findings, [], args.model)) + return 0 + + +def suggest_blocks(args: argparse.Namespace) -> int: + events = read_events(args.logs) + candidates = suggest_block_candidates( + events, + min_events=args.min_events, + min_score=args.min_score, + allowlist=parse_allowlist(args.allowlist), + ) + if not candidates: + print("No block candidates met the current thresholds.") + return 0 + + for candidate in candidates: + print(f"{candidate.src_ip} score={candidate.score} reasons={', '.join(candidate.reasons)}") + if not args.execute: + print(" dry-run: not blocked") + continue + client = FortiGateClient.from_env() + reason = f"fgAI UTM mitigation score={candidate.score}" + response = client.quarantine_ip(candidate.src_ip, args.expiry_minutes, reason) + print(f" blocked: {response}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Local FortiGate AI/ML inspection tool") + subparsers = parser.add_subparsers(required=True) + + logs = subparsers.add_parser("analyze-logs", help="Analyze local FortiGate logs") + logs.add_argument("--logs", required=True, help="Path to syslog JSONL or key/value log file") + logs.add_argument("--min-events", type=int, default=3) + logs.add_argument("--min-score", type=int, default=7) + logs.add_argument("--allowlist", default=None, help="Comma-separated IPs/CIDRs never to block") + logs.add_argument("--llm", action="store_true", help="Ask local Ollama to summarize results") + logs.add_argument("--model", default=None, help="Ollama model name") + logs.set_defaults(func=analyze_logs) + + policies = subparsers.add_parser("audit-policies", help="Audit FortiOS firewall policy config") + policies.add_argument("--config", required=True, help="Path to FortiOS config backup") + policies.add_argument("--llm", action="store_true", help="Ask local Ollama to summarize results") + policies.add_argument("--model", default=None, help="Ollama model name") + policies.set_defaults(func=audit_policy_file) + + blocks = subparsers.add_parser("suggest-blocks", help="Suggest or execute guarded source IP blocks") + blocks.add_argument("--logs", required=True, help="Path to syslog JSONL or key/value log file") + blocks.add_argument("--min-events", type=int, default=3) + blocks.add_argument("--min-score", type=int, default=7) + blocks.add_argument("--allowlist", default=None, help="Comma-separated IPs/CIDRs never to block") + blocks.add_argument("--execute", action="store_true", help="Actually call the FortiGate quarantine API") + blocks.add_argument("--expiry-minutes", type=int, default=60) + blocks.set_defaults(func=suggest_blocks) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.func(args) + except Exception as exc: + print(f"fgai: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/fgai/llm.py b/src/fgai/llm.py new file mode 100644 index 0000000..5530280 --- /dev/null +++ b/src/fgai/llm.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import json +import os +from urllib import request + +from .models import BlockCandidate, Finding + + +def ollama_summary(findings: list[Finding], candidates: list[BlockCandidate], model: str | None = None) -> str: + host = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip("/") + selected_model = model or os.getenv("OLLAMA_MODEL", "llama3.3") + prompt = { + "findings": [finding.__dict__ for finding in findings], + "block_candidates": [ + {"src_ip": candidate.src_ip, "score": candidate.score, "reasons": candidate.reasons} + for candidate in candidates + ], + } + body = json.dumps( + { + "model": selected_model, + "stream": False, + "prompt": ( + "You are a local FortiGate security analyst. Summarize these policy findings " + "and UTM block candidates. Be concise, include risk, likely cause, and next action. " + f"Data: {json.dumps(prompt)}" + ), + } + ).encode("utf-8") + req = request.Request(f"{host}/api/generate", data=body, method="POST", headers={"Content-Type": "application/json"}) + with request.urlopen(req, timeout=60) as response: + data = json.loads(response.read().decode("utf-8")) + return str(data.get("response", "")).strip() diff --git a/src/fgai/logs.py b/src/fgai/logs.py new file mode 100644 index 0000000..c0be0ef --- /dev/null +++ b/src/fgai/logs.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import json +import shlex +from collections.abc import Iterable +from pathlib import Path + +from .models import LogEvent + +UTM_SUBTYPES = { + "ips", + "virus", + "av", + "anomaly", + "ddos", + "dos", + "webfilter", + "app-ctrl", + "application", + "waf", + "dns", +} + +THREAT_ACTIONS = { + "blocked", + "block", + "dropped", + "drop", + "reset", + "reset-client", + "reset-server", + "detected", + "deny", + "quarantine", +} + +SEVERITY_SCORE = { + "emergency": 6, + "alert": 6, + "critical": 5, + "high": 4, + "warning": 3, + "medium": 3, + "notice": 2, + "low": 1, + "information": 1, + "info": 1, +} + + +def parse_log_line(line: str) -> LogEvent: + stripped = line.strip() + if not stripped: + return LogEvent(raw=line, fields={}) + + if stripped.startswith("{"): + try: + data = json.loads(stripped) + except json.JSONDecodeError: + data = {} + else: + return LogEvent(raw=line, fields={str(k).lower(): str(v) for k, v in data.items()}) + + fields: dict[str, str] = {} + try: + parts = shlex.split(stripped) + except ValueError: + parts = stripped.split() + + for part in parts: + if "=" not in part: + continue + key, value = part.split("=", 1) + fields[key.strip().lower()] = value.strip().strip('"') + + return LogEvent(raw=line, fields=fields) + + +def read_events(path: str | Path) -> list[LogEvent]: + with Path(path).open("r", encoding="utf-8", errors="replace") as handle: + return [event for line in handle if (event := parse_log_line(line)).fields] + + +def is_utm_event(event: LogEvent) -> bool: + subtype = event.subtype + event_type = event.fields.get("type", "").lower() + return event_type == "utm" or subtype in UTM_SUBTYPES + + +def event_score(event: LogEvent) -> int: + score = SEVERITY_SCORE.get(event.severity, 0) + if is_utm_event(event): + score += 2 + if event.action in THREAT_ACTIONS: + score += 2 + if event.fields.get("attack") or event.fields.get("attackid") or event.fields.get("signature"): + score += 1 + return score + + +def summarize_events(events: Iterable[LogEvent]) -> dict[str, int]: + summary = { + "total": 0, + "utm": 0, + "threat_actions": 0, + "with_src_ip": 0, + "critical_or_high": 0, + } + for event in events: + summary["total"] += 1 + if is_utm_event(event): + summary["utm"] += 1 + if event.action in THREAT_ACTIONS: + summary["threat_actions"] += 1 + if event.src_ip: + summary["with_src_ip"] += 1 + if event.severity in {"critical", "high", "alert", "emergency"}: + summary["critical_or_high"] += 1 + return summary diff --git a/src/fgai/mitigation.py b/src/fgai/mitigation.py new file mode 100644 index 0000000..7713a6c --- /dev/null +++ b/src/fgai/mitigation.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import ipaddress +import os +from collections import defaultdict +from dataclasses import dataclass +from typing import Iterable +from urllib import error, request +import json +import ssl + +from .logs import event_score, is_utm_event +from .models import BlockCandidate, LogEvent + + +def parse_allowlist(value: str | None = None) -> list[ipaddress._BaseNetwork]: + raw = value if value is not None else os.getenv("FGAI_ALLOWLIST", "") + networks: list[ipaddress._BaseNetwork] = [] + for item in raw.split(","): + item = item.strip() + if not item: + continue + networks.append(ipaddress.ip_network(item, strict=False)) + return networks + + +def is_blockable_public_ip(ip: str, allowlist: Iterable[ipaddress._BaseNetwork] = ()) -> bool: + try: + address = ipaddress.ip_address(ip) + except ValueError: + return False + if not address.is_global: + return False + return not any(address in network for network in allowlist) + + +def suggest_block_candidates( + events: Iterable[LogEvent], + *, + min_events: int = 3, + min_score: int = 7, + allowlist: Iterable[ipaddress._BaseNetwork] = (), +) -> list[BlockCandidate]: + grouped: dict[str, list[LogEvent]] = defaultdict(list) + for event in events: + if not event.src_ip or not is_utm_event(event): + continue + if not is_blockable_public_ip(event.src_ip, allowlist): + continue + if event_score(event) > 0: + grouped[event.src_ip].append(event) + + candidates: list[BlockCandidate] = [] + for src_ip, ip_events in grouped.items(): + score = sum(event_score(event) for event in ip_events) + if len(ip_events) < min_events and score < min_score: + continue + reasons = sorted( + { + f"{event.subtype or 'utm'}:{event.action or 'observed'}:{event.severity or 'unknown'}" + for event in ip_events + } + ) + candidates.append(BlockCandidate(src_ip, score, reasons, ip_events[-5:])) + + return sorted(candidates, key=lambda candidate: candidate.score, reverse=True) + + +@dataclass(frozen=True) +class FortiGateClient: + host: str + api_token: str + verify_tls: bool = True + + @classmethod + def from_env(cls) -> "FortiGateClient": + host = os.environ["FORTIGATE_HOST"] + token = os.environ["FORTIGATE_API_TOKEN"] + verify_tls = os.getenv("FORTIGATE_VERIFY_TLS", "true").lower() not in {"0", "false", "no"} + return cls(host=host, api_token=token, verify_tls=verify_tls) + + def quarantine_ip(self, src_ip: str, expiry_minutes: int, reason: str) -> str: + payload = { + "ip_address": src_ip, + "expiry": expiry_minutes, + "source": "fgai-local-agent", + "reason": reason, + } + url = f"https://{self.host}/api/v2/monitor/user/banned/create" + body = json.dumps(payload).encode("utf-8") + req = request.Request( + url, + data=body, + method="POST", + headers={ + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + context = None if self.verify_tls else ssl._create_unverified_context() + try: + with request.urlopen(req, timeout=15, context=context) as response: + return response.read().decode("utf-8", errors="replace") + except error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"FortiGate API returned HTTP {exc.code}: {detail}") from exc + except error.URLError as exc: + raise RuntimeError(f"Could not reach FortiGate API: {exc.reason}") from exc diff --git a/src/fgai/models.py b/src/fgai/models.py new file mode 100644 index 0000000..90fc248 --- /dev/null +++ b/src/fgai/models.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class LogEvent: + raw: str + fields: dict[str, str] + + @property + def src_ip(self) -> str | None: + return self.fields.get("srcip") or self.fields.get("src_ip") or self.fields.get("source_ip") + + @property + def subtype(self) -> str: + return self.fields.get("subtype", "").lower() + + @property + def action(self) -> str: + return self.fields.get("action", "").lower() + + @property + def severity(self) -> str: + return self.fields.get("severity", self.fields.get("level", "")).lower() + + +@dataclass(frozen=True) +class PolicyRule: + policy_id: str + settings: dict[str, list[str]] = field(default_factory=dict) + + def values(self, key: str) -> list[str]: + return self.settings.get(key, []) + + def first(self, key: str, default: str = "") -> str: + values = self.values(key) + return values[0] if values else default + + +@dataclass(frozen=True) +class Finding: + severity: str + title: str + detail: str + reference: str | None = None + + +@dataclass(frozen=True) +class BlockCandidate: + src_ip: str + score: int + reasons: list[str] + sample_events: list[LogEvent] diff --git a/src/fgai/policies.py b/src/fgai/policies.py new file mode 100644 index 0000000..3a48d5d --- /dev/null +++ b/src/fgai/policies.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import shlex +from pathlib import Path + +from .models import Finding, PolicyRule + +ANY_VALUES = {"all", "any", "0.0.0.0/0"} +UTM_KEYS = {"ips-sensor", "av-profile", "webfilter-profile", "application-list", "waf-profile", "dnsfilter-profile"} + + +def _parse_set_values(line: str) -> tuple[str, list[str]] | None: + try: + parts = shlex.split(line) + except ValueError: + parts = line.split() + if len(parts) < 3 or parts[0] != "set": + return None + return parts[1], parts[2:] + + +def parse_policy_config(text: str) -> list[PolicyRule]: + in_policy = False + current_id: str | None = None + current_settings: dict[str, list[str]] = {} + policies: list[PolicyRule] = [] + + for raw_line in text.splitlines(): + line = raw_line.strip() + if line == "config firewall policy": + in_policy = True + continue + if in_policy and line == "end": + if current_id is not None: + policies.append(PolicyRule(current_id, current_settings)) + break + if not in_policy: + continue + if line.startswith("edit "): + if current_id is not None: + policies.append(PolicyRule(current_id, current_settings)) + current_id = line.split(maxsplit=1)[1].strip('"') + current_settings = {} + continue + if line == "next": + if current_id is not None: + policies.append(PolicyRule(current_id, current_settings)) + current_id = None + current_settings = {} + continue + parsed = _parse_set_values(line) + if current_id is not None and parsed: + key, values = parsed + current_settings[key] = values + + return policies + + +def read_policies(path: str | Path) -> list[PolicyRule]: + return parse_policy_config(Path(path).read_text(encoding="utf-8", errors="replace")) + + +def audit_policies(policies: list[PolicyRule]) -> list[Finding]: + findings: list[Finding] = [] + for policy in policies: + if policy.first("status", "enable") == "disable": + continue + + action = policy.first("action") + srcaddr = {value.lower() for value in policy.values("srcaddr")} + dstaddr = {value.lower() for value in policy.values("dstaddr")} + service = {value.lower() for value in policy.values("service")} + policy_ref = f"policy {policy.policy_id}" + + if action == "accept" and srcaddr & ANY_VALUES and dstaddr & ANY_VALUES and service & ANY_VALUES: + findings.append( + Finding( + severity="critical", + title="Broad allow policy", + detail="Accepts traffic from any source to any destination for any service.", + reference=policy_ref, + ) + ) + + if action == "accept" and policy.first("logtraffic", "disable") in {"disable", "utm"}: + findings.append( + Finding( + severity="medium", + title="Insufficient traffic logging", + detail="Accepted traffic is not fully logged, reducing investigation value.", + reference=policy_ref, + ) + ) + + has_utm = policy.first("utm-status") == "enable" or any(policy.values(key) for key in UTM_KEYS) + if action == "accept" and not has_utm: + findings.append( + Finding( + severity="high", + title="Accepted traffic lacks UTM inspection", + detail="No IPS, AV, web filter, application, DNS, or WAF profile is attached.", + reference=policy_ref, + ) + ) + + return findings diff --git a/tests/__pycache__/test_logs.cpython-312.pyc b/tests/__pycache__/test_logs.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..32b4068f31e074cd7b9a354243ec6090f47d2476792c6fa0411b98597a9713e1 GIT binary patch literal 1722 zcmcgs&2Jk;6rcUFw~2$@h$6c*jagNIMQfUfhEm}GLRC4mL6w4p+{0?SJ9XFDwav^r zVvQpeDTr`_TZn{24;)hE(j)%^7lKo@8aNbT3EU7tYZ8&k72+uC z#8D|}=0-`z`wryCRnJo0(N;}FW>BMs#i*agt?S|>OEh-AU>-!dSdo*_7_R$JbA`FYbY{D_>l0)jUBdwFV z;>bYNP=SK_)plc<@_^f;2n@ul!FGm+KsHH`+8k*o;n$=-X(la$Q@Z+6Q%!}GapWC2 z79{eIahcRTWgO+HxMQgyWrE2O+fUCnI%8C8YGfbpL4L%KV;pr{DI^tbsdvM%0$Q6t zKfiG9{KC1pi{&8f(3$}gG0MDqGnU*A2Z`Bkr*^R#ePzoI+;*#G-1OS^3U$B?17vOx z)(jgq*S0*PmrcBd0vYzPuZqs_+Qp>BIc34+Pr8;TGLYJIJ0hDD6ce#^nRO|{dlu8N z;tHd1S{6ky9LqgfsGNGYgciYvy|}9vzdiB&sjX9Y!##cO(GlIu*37oH zr&mGub^fdTce%SC4T`Vr>1QLS*Ms;39or-DLX<*04c%r`@pL&s< z0RxVKUjlNVNRsp`$v-C72ju!;W=dMz`1E1Eyqhl{5)cl`M9ppVeo;z~)Z=$P`10E4 O*S`4hKq2bMDED7Mx`fdH literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_mitigation.cpython-312.pyc b/tests/__pycache__/test_mitigation.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..c7e3508275459d97827f89ee45ec8ccf5eacfd9b3fba74e3a9ddfcde25b242f7 GIT binary patch literal 1781 zcmbVMO>7%Q6n?Y2b~cL>mmrMoUQC22o5QxQ2Eme6RkJ9gGt|1h&I zvXw|d3L;2Ig;U_hF(?;~T)9<3LbOhsYC&ox;J_`yxsVfY*4~&zfg)z5nQz|T%)al< z`+jW9032Uld$C&60Dh61cq9g(w~k;7TyTjGCAlg-sVOCeNUZv5O)F`&L@7Z)fjMxs z4R8|-s;M~8aNSY+xHQ6aH@Wfx0H06*Z6}5{G1?gb0&UVJ=E(1IzhIJZylHd6sNbki z->WlI2{WFcOMb&yv6p;Co2@0^bEwx0v!Ye0FcI{X)UoTX=h^`iPJD=5$zapVBG|&) zl!yx@#U&e1Qe6dSt*x3`n4b3nuVM#YqrQkig%dkUf0-=#bG@%{+JaT6{#}bQIfB9O z1Ez9kppRGbcpoLUjn~L0rm26#$#c}$HeNBEU}_7URPpjftN79-t7sK3boAl{OMcUb zBbTh`Gu7}3TL{L3H*FsmtPTi`zGRWN7!sig=9h*1kHTRVvl@u=a;CMTJXy_lI2p;B z+`EaHM^*&2IivN)fE7tu=i-UfT95b9@mMi~(>grRQ+s;$j-K7ko&RRMn|ozfzjjP! zbaPjC^%<1iN`I2x&05>-Zuas${Ynr0;TVre6hu7!fM5%PBNm5@{SVm@a}{lk469)E zs0!8;te8`nTvc9&TdJc}z){{C2Ut^A)vCPXiSfDe=@|`e)>K!mkR7G3mRvjdOr`vT zi1qoP-DI<^pjHsAr3fP-1;HJ!IXfV}U^}wJXQKzj+=5^?8TW$r?6Ox`F4#d}J1eu3 z0w2?4=lZ{d8`jtKKZbNLu;xDF2hMJWEiV z*5TJw{>QV`ZffP{(*f_M#Qtbv!q`k6j6=rSE_a`v-ZUP?HT~_|_j7aG?Jvx|{8TqT zbvHM6+q`}Do__sLghH0WcZsK7ozxf1M5t3*w`+{j(4e%|a9h|7X_%r^=B=;cSVX`x zIDHK~EwQ{SABiUkgcNPSABvi|+B1yA4br!iI(bIT_Ue?HMyR*yULZ3Xj+QI7XJJbg z5o=*O+7BB+)IR!L>Wk>`nvE?bI`kPCRFffj9^vp#hx$za7hdMiqC!5z=)Lc&gpi*g z{Tn>@5Z*aRo*_5LXUlt;b9XZ54nXeqp9d|q*}1Fc9%xUlzxDC_hw~r3yRU+lkD~tt D+~Kop literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_policies.cpython-312.pyc b/tests/__pycache__/test_policies.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..7f647c8bc52dd212923eb8c518a8ff973597e5fa3d35c5d503bcb7da34cb08b6 GIT binary patch literal 1308 zcma)6&1(}u6n`_jiA!wMCb8hhVM$RNr4>=oq7|!R6>RlT5dyMocP4S`W)pWN)(t6C zs8~U$=pl%g9+h4N{RcdG@mpCv2p+^+F}>)?nN7B73xWgt=J)o!_nSBGz1>&IWCFx` zbmGwDOAPQ`D$$CyL6Zy600IO#ke54lPzmR8Ny#e+FpL0EW`U>_CbcNIvo9(woR85s ziCyah@KP9r@1~AVgcjQXfscHYi~p4RSPlo9%qp?DQ7JpNWm6WUE2hV2OZN?{>=x{z z6|u-2wUuT{fQC3Oj|k*3L9>uoe4JB)jTa@EKTa8ER^%?DRPo51o6m%30AH~YwAjYe zh_`PAQ186Ob$lA;6$@Vl3ttiUn^x+|q~u$-9^iXhRG3zU9VR!dGYXa9y1FR(As2#w z_$aG=d~qTY&$KcHId!b|JkctOUa&nnWjc-?*3M7pVo^~`{Xht%$|%>FXPJa}dRDk* z*H{SS|FAIX-LNgHXNS+9UmIgu+%CJiX<4+w*RVO|BKOQf!M1c_GjqbBq1mN3d4jqm zQA?fl$|li88s({W&b3{`ArU2d6ziDQHKt!0AJuJ_RcLrzPW5D|J=vKc#%=DkV_(p3u zB?&VoWco(ct$1ZF@}k5DuTnd@YTO1obpI7BCxl%VVg|mZcQ4^YQf(?-nZ%s7yc06n zg}!&`eRJBk{f9qxA6Xc=zo*gvu>W=Uk-4}KpS7;}EW&FSWXQz!*I