From d796d39326e6290a9434d96dcb265ac6d89ef7c1 Mon Sep 17 00:00:00 2001 From: valherun Date: Tue, 24 Mar 2026 21:17:47 -0400 Subject: [PATCH] chore(git-walk): Git walk update for cis-benchmarks --- README.md | 72 + bin/cis-check | 11 + pyproject.toml | 15 + src/cis_benchmarks/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 220 bytes .../__pycache__/checker.cpython-312.pyc | Bin 0 -> 66022 bytes src/cis_benchmarks/checker.py | 1205 +++++++++++++++++ .../__pycache__/test_checker.cpython-312.pyc | Bin 0 -> 13405 bytes tests/test_checker.py | 309 +++++ 9 files changed, 1613 insertions(+) create mode 100755 bin/cis-check create mode 100644 pyproject.toml create mode 100644 src/cis_benchmarks/__init__.py create mode 100644 src/cis_benchmarks/__pycache__/__init__.cpython-312.pyc create mode 100644 src/cis_benchmarks/__pycache__/checker.cpython-312.pyc create mode 100644 src/cis_benchmarks/checker.py create mode 100644 tests/__pycache__/test_checker.cpython-312.pyc create mode 100644 tests/test_checker.py diff --git a/README.md b/README.md index 12b8813..1166faf 100644 --- a/README.md +++ b/README.md @@ -25,5 +25,77 @@ Local repository for CIS benchmark source PDFs and plain-text extracts. This keeps the source PDFs in `pdf/` and rewrites matching `.txt` files in `text/`. +## Benchmark Checker +The repo now includes a generic checker that parses a CIS text export and runs the +documented `Audit:` procedures for automated controls. + +List parsed controls from a benchmark: +```bash +./bin/cis-check text/CIS_AlmaLinux_OS_9_Benchmark_v2.0.0.txt --list-controls +``` + +Run a single control: +```bash +./bin/cis-check text/CIS_AlmaLinux_OS_9_Benchmark_v2.0.0.txt --control 1.3.1.5 +``` + +Run all automated Level 1 server checks and emit JSON: +```bash +./bin/cis-check text/CIS_AlmaLinux_OS_9_Benchmark_v2.0.0.txt \ + --profile "Level 1 - Server" \ + --format json +``` + +Generate both machine-readable and human-readable reports in one pass: +```bash +./bin/cis-check text/CIS_AlmaLinux_OS_9_Benchmark_v2.0.0.txt \ + --profile "Level 1 - Server" \ + --manual \ + --report-dir reports/almalinux9 +``` + +Generate only the remediation guide from an existing audit JSON: +```bash +./bin/cis-check \ + --report-json reports/almalinux9/audit-report.json \ + --guide-output reports/almalinux9/remediation-guide.md +``` + +Record an approved exception live while reviewing: +```bash +./bin/cis-check \ + --exceptions-file reports/almalinux9/exceptions.json \ + --host lynx \ + --approve-exception 3.3.1 \ + --exception-reason "Docker networking requires IP forwarding" \ + --exception-notes "Reviewed during CIS audit" +``` + +Then rerun the report with the same exceptions file: +```bash +./bin/cis-check text/CIS_AlmaLinux_OS_9_Benchmark_v2.0.0.txt \ + --host lynx \ + --exceptions-file reports/almalinux9/exceptions.json \ + --report-dir reports/almalinux9 +``` + +Notes: +- Automated controls with embedded shell audit scripts are executed directly with `bash`. +- Automated controls with simple command examples are executed command-by-command and + evaluated against expected output in the benchmark text. +- Manual controls are excluded by default and reported as `MANUAL` when `--manual` is used. +- `--report-dir` writes `audit-report.json`, `audit-report.md`, and `remediation-guide.md`. +- `--format remediation` prints the remediation guide directly. +- The markdown report includes benchmark-provided remediation text for failed, error, review, + and manual controls. +- The remediation guide groups items by subsystem and separates execution blockers from + direct compliance gaps. +- The remediation guide also flags likely accepted exceptions such as structural partition + requirements or container-driven forwarding settings for human approval. +- Approved host-specific exceptions can be stored in a local JSON file and are then shown in + the audit and remediation outputs on future runs. +- Run the checker on the target host, ideally as `root`, because many CIS audits depend on + host-specific binaries, privileged files, and live system state. + ## Host Assessments - `docs/lynx-almalinux9-remediation-checklist.md` diff --git a/bin/cis-check b/bin/cis-check new file mode 100755 index 0000000..0a74223 --- /dev/null +++ b/bin/cis-check @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_PATH="$(readlink -f "$0")" +SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +export PYTHONPATH="$REPO_DIR/src${PYTHONPATH:+:$PYTHONPATH}" + +exec python3 -m cis_benchmarks.checker "$@" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4b8518d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "cis-benchmarks" +version = "0.1.0" +description = "Parse CIS benchmark exports and run automated audit checks" +requires-python = ">=3.9" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/cis_benchmarks/__init__.py b/src/cis_benchmarks/__init__.py new file mode 100644 index 0000000..41a09ac --- /dev/null +++ b/src/cis_benchmarks/__init__.py @@ -0,0 +1 @@ +"""CIS benchmark parsing and checking helpers.""" diff --git a/src/cis_benchmarks/__pycache__/__init__.cpython-312.pyc b/src/cis_benchmarks/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..96983f5f63d6037cbd594e34f630646d051e14ea GIT binary patch literal 220 zcmX@j%ge<81bp1}%9sd>p6xrs&D z3I&Np#hH2O3W<3s3dtF%$=M)IMruw$YEiMCpC;oi_W1ae{N(ufl?{r{RWxu*E9sAXH>G7)_Gz=NLj6o>hlm6w*AF0KsuKX3# zSW;1DPqf(#a2kM<%q3V7F)@6 zaFy@sx;)$=&V%17?l5xkd_Zrvq zuA!@m>*AX6MRRsMJ#(*fEr@I3&U5SVyN&Mu^sq7AkRQ~o_<|@Q{%n*OPZHnb<*k^9v%t$g8d`IfljYB zY48QO{+?jc%=v=8o+|CQZ}CtQDt}|mwd|Djjr4s}gSRGhbj{A=+xxj4*-4P~@$JF>;Glm;Q@!{6 z8hS)O`U8O>|8Q_e`&e)U758)DqrTxW-=OzADo|h3BLNEb18_>0`ss0m_f&pWmzq;` zY0dz^`n8;9JKl0y08<^F^wN`_JsG4Y1A8(`Pe%4+qNkf$uQ{2!8(n&N$j4tf>JR#8 zsFT`2kWcDawL70%@Zbr1Ix)gY<264YK&L0|qhpr_`vaH#n2o4^(i#{U<9qzwqZt3B ztc^K+ zP)HCRd>d#p3DB8 zD}KId^d?_OSyJG^f1nw`KT&;ZwZC&HQBpB+V6rQ!t4LJUPIOKkit1_;6{{u=PnAS< zs}hyf6Gx`%W4dadwi=d$@>so(=oQ9_Mor7cg6?#5M;nS?|3EJ<%Ps?FK%6*YrY<8J zX*2r<8zg(ONKa<=WR;#Q?8zoQS=m#L^kidCc6z$$@H&%5X_4?nc$2jBbC{6*Sl&Ee z%wD9Y>3wz5!HH|JyB7;~gunURjL{Fij;V1W=^o`rdiyb^SX_hsJ-$o*gZ;sqNmbIy zrB{B^j0wS(t3PSzXX`#`@zJvH4*G8dlU6>x^piPqX&Buxf2TKxuSAu24+T{etfIhx zAgQBz@Qw6L!^pQ#u#SRO3f5Dwfr4rZ*r?UgU7Z|waTB-I4%yZ zu}6$ZI{oRW?%>T)KVOf6`Lzg=dA^<=|7g&62_rc&791T5ViPc~*z^2G`m&9JtghWm z?mhy%3P~CQ=(MpwQrkZqOj>w#l%Kh=bBIXPG zBL!Nx)D&UMKdV33I}xzZ7a~xhI}xDJod`_mjsX&B652B+p#yPFsxOx-fvPB+*L3Z*A2dn%%*oB7^i8Y8xYXO0mt85M&~Jgr$aY10coX-~ly22ey8 zkY4_;&?gKy(2Nh|Gw`)!B3wv1q-E00vzZa#o9H8gBb|H+!elWZ96xdm`h_0h#g#lI039W23Zd()O4vREWMj5(s*2iqngLp1Ab(%a!8~(A_}_Iw+t8 zlf5SQ(mM($S>Rm+%fMZtv?i*nN#wc+n7IlGxUo4YzQ`|#>I%dcj=Xm|KWS)r=!!M$ zpExGC*F<&uGoatv7C~1&9g6B&5)I9Qu55ZBs%uWPY!Gx+Gi_1bhD3dnpevp3is_of zsZP~Wp6UdbR+#EqOf0r{rYAUqfmVh}p7f87@IkgeCrv{VTJzja*evZ?Ydu)u7m~)n z7~mx~GkG>-OgY3#v!IjTjcR0f=n;BH0c{-t7Xoysy)vq+OyrbCb)|_MPfX_#8%W8N z8~BxgeP;&Ne1IPr8x?`Rjxa0!7|IrbJfV+CQ;&ohe=%r3Ltkb=vs3hr0uKLyG6ZO~ zCFh-v1i^P1vQHco>{ThqPRW&9|AOHqc$0-B-PC&x$-I5?!rOz5uAf$s2*6XoA!!g- zr1G%6ipua5T)}@J7r~NsU~{~CSR6A-qg-oRK`Ts3g<5lZpyp|0j<_B4B<@CfLciN3 zn38IlG<*aEk``&ay8Ai)914~IkzNEXmNXpk8Wh0=%})Le3bIDBi{7ySHvvM47@GMNmmgG)fpZVE(8FGGi$PuCQtVX|gYn z=~|g#Fd#9JGSkQ;>wq!yt#~iT$(zwBl~>pKT;W4QpT2edD^YE|3>i513KEtP3Sb^;4^E9|&6H z=z$#hF8v!$p{Bh0_66nq!rF}dMs%vM8iS_o95U-TRLnJH78@_sM-pJqYQZ%U*p_#|R2z&!FEo?B|ju-gjLb%U=52 z#IhRoa{(F+4_)HOTiFLJWq{`q_ZI#GHxXdifyf_7xQgx_yL&9^s{Kr5(w9$~5{|;S zqcY;CoH5L5qK<~Rqc!4ajXE|=>Js^d_uB5ZeP{cm=@SPw()>G}w>qa=W44Nf&Gp1u z9Jf|Rtd%pSnAM9H36tZF{g!Jk-Z5_)gk5KJw>up#w@F>DN|Z|8=MoQCm>G-}oG zv9NBUEvRFqB2bz?VPh%>Q`p2;hfRtzC^h1kXUZv3^JZLs`!wdNRW3FCn=T9Uuve$z z^hz_3GrX$`Yxk<;^^>ZfIp~n{3Y%}J15Pz&9&f`Ob;|G4zr$+I7}kaLNMQ};%kRT# zIXA?sf^PZ!K!JRh{tXgM&8~qG`7Zq{=QBXGQPw5b{DJ8s2^eC1m^*(-7)Ho;+RqOK zJR`lHGXeiEY3zSVlY_sAU& zQ6C=+P~D`}_1wIjm_j#3`5mD~(EB{t2x;C-w~V4SZD(Aaz>cO=@(++nfXM%Uuln_b zYQfbt&BeAJjc+{_*?LMieeUtr*S>o?;_CW5U_zR&m(-}gZ>&*=7#A?b*z)Q{xO3c+2wRKJG zdtxn1*z*_cNADk=w#JLrEEKJo?@l-i6NR;(tXcovJ=4~ht7hKy#9e;x{N3|ET=U`D z57tKA&2jhEhfBBzq zVaL+*zTrN9Qaj|k0m}DS=joHjj&yhIl@}G8oL(*eHaqHi8q)Fa8FL1lA_BOvt} z^3Fg8*8)P3Bye1tF$$?3LJwnEpU2pZ>#$GhmB5h<4Z~{Mm(Z6bVI4*hg^X*lN2!57 zXeS`U2uiS$kW9Jf#|>f4+Z%#3AJ`SvDy5R~C)9?(qYmS!qj7v;ZP+d;)@Ixw)`Qb3 z?S05i8#a`r>hz*qPpR%pbA#bQ&()Uu`lQ)EJO(O`$Q%x}N@@rF!~8dJ$Doqen6zF3 z#JnP6ElC?2>u%!Z`}w2=Sdx5i6=T?B;lD-SGh_(abKT+$qJCNeaU3$l34oahP6t1Y z6#o+cfq#JjI8k1~#Nj7}o|)2UVdG~itv>ITby7bWOjsRv+HSRdYx^|t;5q<|nX31^ zlZOFR7L7=>Xi}N-1!GCVk^97%KXo|j^h|UljJblb{E53{dfNl-?BVE|Em8MYb*9#`;>i@!%^f#EG*uGwNf?lJ=QXJNOmD7{|EsQ%qg#c{f@6i2s z5hShYX%^srhhC@V7k>|rfn_txoi($%#~^ta5oARJR$RH}vmaU6() z5|t97Etf6l0_<-Oz^n#nsJ`=g!EsU6;{n$qE$eCHt;l>#hv2U#e$T^S!wnb`1Tgr1 z3i=R$tfQSMaJ@fxnZH7B38Dctqp6g1$lIC(ZIUMGML>j9mK0sSjBqQLr<0ro2*h;V z_}@eJQTzukBf!pAP&;!)SbzA@8_|OE6UP!IWfKPz&dRv6KH{v8I@i+N00H;JT|Kim z>h?}r5>|KI>WNrAGX;;WYe22tId<#V^r4xnGyHUCysRZs))FmieQaA#;B5neBG<&R z<+Oi3O2<&EpAI8jIxXa`26CR73>o`a=I)lFLH4TdXz%EHw0)``%>@7kwvTFwl2@7( z@}P3*eN=l_bGuvz3UM4?VJaH%Zw%`(<-qVBH)UWp>aa=PCCANS^Mv|#Uf2lQ3DhF( zl$@5ru$fVgH06v+(<*EZn|d>O2^(~(jA@;sB=@SO)T#DZSgXwArm%(6(XJm%aUIh1 z;1ysHyfti;)1>P(Y{^KU>bY@iSdH3POH|{wG*t+ClDL6c7o#UBwTUQbxfkW}l7Gi@ zf_d_X11Y*teuKWrNu{)h?O2w4*r3!$K_W8P6}AshGqNr&)k`bHo8)p{GzV7rHl-&O zBr1tr;gmj8Fh!|`ZJdR(%0O(%dmERd_}e>mzPF?E zK#HiUqo{ETXoq0f2l5=I`?o1LO2M}%V5o9xg95tT-{TMPBp)G0vTr!Re^b1t{SDG% z?nY7zq)6P@3`B=PXgq`@0qQ3S$(DaalFgAd$oxM?%DYgFUXgc4FVB z#cMtT+1xmp^TfGoX5(yQ)VYQ5Am`*O*w{X?I;YM|x88ki`s&^DGiMgu^^7-}bH{ee zHsznbAh;VIo7OJoAcJR4)tYs4?r7cSr>Zi&C1R?c)JAfuC;d|w64k3`b3fSp;MAP^ zqppSO&3LzGy6WzMTf@^Q7VMS(`qV@BA9PJ>69uKy`(~h z);`id8WMJ&kF6PsdWNF8!;^+5&XTy(6LDe-jyl&&>WL9STvO*A$1TSbTYjR{^Mh^g zZJP;3OV=XbdspsWnW>8w)W-{2A_Xncf>t>}I9j^l$?8214@XxYe^Rz?u3`R7;TRt+ z3p^=lp0m!M7Y<*HmiV3&uAO~--uv+NXyJ(`r7d%%^OuE#uSH9{@JYf|nQ*O2xW4w( zs4LHVW>HmcV6SQtu713#%zIXzpJV@ft=nu_bf9Mz+kg*DofE9(kcxi(^psjvIIIrP zO8aM}`_~yhR_hQhVet|v3qYiP3Jp>q3LqiXv_xM7et=gJHunMmMVKa-9mt1-0ZM2F z+eRM|!AhkuMB+vHfnX2l%>-_>1a3jTr$}&oE?q&wh1n&8X@1E!L6aPRO|>*eDYrCc zo0;~-B8l_{A5LjkDc41@#1vzCNr?p9*fl_~G3(;cGcvlA`Bhl+Jr!pN>%XV^z80ea z&Am|07mSAb_9MA}y~a+4RkMxDYY3V4-S7>Kk`kr96Iups&r9%g8SEZU;PS}WAm_Q{ z_lOECe(t%U(bKWt(@4L^PI?-XhO>UYzxSqBGe#R8F!qpU!~?Cz*V`MrE>PfW40$dz zwAJtz5$`pNkd?vH-^Giheb2F@N83C1b{{{vZ-2+Rq~p}SJ*SBYOdMhGgZW!XCGs;F z;;-TTpavLFy-zV)C?Lq1H20*WxL5HUp!p&KMiDF)^^6P-`bM$+UFS*c!*xq48vHy` z+Au)_#}R;7wC7LlyEPcMdm?sE)V^wF-At=sUmdrvjo8;l?M>i-+H$AbZ*_nvYH@tG zddmNu`UOi_!c{z7d-uqsIgwv>Z|B{e(fk^T8JfFT0TIm9zR4X>|5_}^B9?h!F>4d@ zS}e(kE=Kfs--=jQQ$#^Vggf<}P&pd2UZr>v?MV4P%T?JR3X*tim!p-NW7aK{#-2{I zn3Kw5dOT)rq-aYz`k7f}V3lESt@PwbMY6AhC?Du9WP zkU3px>V(rMVX(m4Aq@-?i0 zyQe^boM!<1M^G)a$ThdOX8k6lAA0Ra&N{rg{qPnVYzlse(A*Zy|4D^~=?ghEW zDEAkRkoxulhpmP-955q7`PEfS6Zlg9aI=5-n&*-)aGAe@%BOLBuT>Q9q!GkR6rX}+ z{7>nTfC>NKQ*Z@AGWXJ0{~+mjrPL^)jK#1|2Ezz)i8!G_RBTV$GLi;FX%4}-qy-YM zpvYfM=4Pak*tM1bz@He+{|xB^G)gjFNWT(0U&IYFz7OyZk+PP+9}o%&J+wq^)exG1bqLH` zNV_XG&5h0bqZNB2rqW5{R7s-FE0~}|TM6EZz3Bb*(_`OjTd>!H?>0RUDe}gPnj=Nc z(V}&e9SKKK+)*8IRR6Fu=2)L_y7A)uV-ZIK-P+3Iwwj2oX6Ch+tvO*Un6A2YZ1T{e zL**zI^NiYS7o94zJ8mjP$+5D=**;ECB$lUIGAXz(k%Z6EQUr0s_{h zt`r3PQ}j5{GBr3bfG&*xf@KLw5#Eaz*0*#Iy3nEY9=w-Q@Lm9qpejR~aMqR9l7JLn z6V_pM=45b7z;k(^lUL6v(j(F1-$0V@3&*G~fIND}kEsnC_*Gbo#;gMH$Qcw%PH6?m zZBSGGhTDzGti&`=0s`ZmfQjp z#aS|Yi*W`t;~MYYVrRHF`-Hc6Z81e1}RLh-81a>!`6rYF=g@-1W6kybNTv*12A>C2|}CUL9C!Q ziiYCawSNC~kkEuRiDKu?bVopmC(H|cclOO6(u-aSK0uH(kd6~HT`Xx0?xeA2jEAjE za2fZV{~zf)3ie=<2L28=K%ds{{YCDD*$qFp3$MQZ_~7~PHqE#m=dBiUFH9Ul0PVgz z=Wd;w8i?jpP3%t?t7oo7jEzqmRkL*u55yct1k;gE%PJ-giE1*=5>Y=!2T9P>mIZ6+ zCyoNFx45$^;)KBYL+=OP*#ps48)D9l;J!kP3{nmQ3Z){Fv|J(PS9vOvBVo#m zn~Ec*;^~?nc;EBR9Eg^zjhPz%H)F0?l}@4WYRth4CjN=Fe5UUqv}-y9UB~C2HK_95 zP&2XmKXLA<(EUyhf*u`OE?>k>nJ29PWm{XN)P=L-yU^74)9o8*io3MfZb5tSeWZ7bSl5^$%XS@zjd(xYR9ba{)bKw^dd!7co0XN_$ zSlxGa+}Z)fBk1%(Lm-h~4HP<22))%@2;!gRnn}QJRuZsNIjpa~jqHd%9l(q1O9`}m zBqaiPQV(SIzVu#1|M*7aNWeDh;xyOPd@15mG-Eo!i%R3wH??66LfuU$^eC2VV4(8S z3gsHFU=jXfOAcD)TBd;@&|tL^{b(lXgz0cYaOVI8PR zO&ZC)X}M|j+B*3^Mxg*?Gl?XLbq7=oc7eK~@LIDW=2~~yF z=NGsORfySmn93e9meYAib+65Xx<965tEFU6^hLfwF}Vl8bLj`Djp5|JvmN_h4b`HG zL%tqR!v03+0dr){A|+<#M}XdA!yMq~E~*em0RzB0>8_mu273t}L)#b-nM_0=;!LB0 z>`Q>x5z&UpBv)Owtr2NdvTIyM&AR+#&jeY1CThtOm#?2z=Ff2hP@I#0=fbTE@f=Si z#}mz2MesH+Zmf;~>Rw+kHYKdNacfz`S~jykVy&OO_T%yU=g8=DmshgP&M) z6Zy>v$IkR;Z!Q=&rax0JSgU4wKOFjCD871YWcAkA>TUS!NggrR^45SpEUJzdZHg3a zBKiAa_2j`Pm5sARn+mqFq@z-(*)VThsM(n)EElSFJ~YjTgvvug;h_cRA(F)(Rzm{j z+@(%fw=c(`2-9JpgSe~=bfDeYR~k%Yu&GiQ6*NIgFiOC1z;psN7=~`666;3J1xy$9 z_laK6bcVD|O-ZO&OuE>>!TxVW@xVSLTG`fz zI+syypab{P{fhatDCEJnqBdT!EmE;9;VO;0>LRYXL}5j|a1FGyFrV(cdF#z| zL_vAHpdnJwkZ`yYd8LV>x`m>~+16B6&r0pIf!pN`T!-T2i@KIguz9>)X#>Y*OxvD4 zV-3QK1EV`=lHyv5V=HahG6j*+@zU`>GOIE;96E6_d?eKl6pTS>dSMNl5Th`~7&Fy~ zjnGI8WX>{f;;iH5;R+7YY4#=uZ|r#E#`g-7k#n%EU&A?})2m0?+;MYei^Ha{InA@+ z^6;#fk8-^S3dNqv3}v{a+@2SHVSbSRk%SoasUNuHUL)pvVCgtHIX5Vt)Hs2Dv!HLR z__Z+!e*_>bXURz93>0P7QoPR|oeasa0Wxg~7_recfq!KgAT`~rE8Tlx60D8+nHW0})(2k+xP`OB(i;O!D zQCuA1v>snCR4hG7b%4?PAxnL|hY(y(eLc@G){-UP!JG*8h4_>q!IYRVWlGHO))f*{ z8W9POQ&qMu$x_B~{lG#zJ_v7mM*4>PLkxkXQkq!_qH1TTAm!G;lbH#mER0NKZDuI# zE)*o-#TfZdz1kq?m%P%eNxu(q% z(roj1b4AsHzoC>5DWFZ2QKS4{;x1`FEfT%^ZjAQxAQW~{bUOtsL!!q;gpD=quke_3 z?g8#S=tuV-7fEE1L2?OkUOWHS^aat$JfNJ4k;eQV(A~eLAc264Acw#+MrliXBWZ+f zFYhCgJ!MQB;6K490YZ9+xK3XpZ`Mj=wu~SA2PAr*Hv9VkjX({J_PDV)Vl1A1eZjcq ziKBYvz}!LM;JKLNHNo`S-w2MhbQ+#B2-v@Z|LsORQJs8nQI@8e=t5XBIIv^5t6lVdJEJS6Yf&5Z;LkEf-Pq8 z^tnh*ZTiipRdw;I)<{+BoNu9O^WAgvuTs)elO3oOY?HwBO%zoAt+PB)wjohkm8h(Z zSGGhdTX6q_SKfOiUfL8XZHksIf3B!|mSfD#ThyA%?a$11rv(^?+43uo%2t$W?qJL@ zB$$RiZD@qDo!tdN9OD!fR)3}{(pOGe7c~a+pqd(dI9{_kQnPtJU)a$p9DgTK%zLQciZ=Goqb>J5p4a`D}bXinwKf!S-(nvIEqidzR3*P(IGHmGbK zVzGe#l~-`jd)GUCD4JIXWbK(*Pw1ImZ}|1Ic8v}_ zn=gJ?C6w%aX4JXtpQ&^?_C+ny{KK=;>Wr_n=)r`$41-$II=6YwJHJjSdIh8Fu8g}| zB5r6f#MkYOtlKM`I4xZ07A_CO+*iJ}pUAaA_47bArtR0$AvPlE+ zs0aNL!8^fVnxu6Gk1p5|W!M%_&^r4E0(G>Z|5{W#%j=peoZ2+~>JKiwcj0^8vHZ1> zJeXxsgWf5tRP+BkZm>aZ#E(dZJ;hy83vQ0+#lcJQMp3QGDw9$DTmZg?B>ysEm6CR! z>@}%-WO(pq(j>kIIR9(P`#&Pc;Ck;80ktEmJ^Vi)+5bT;n8VGl8ENKpO&$E!n-d2= zG3F&2*8d{^wHf2w_4wwF$mWhmuPDYmx1*#qzphj;_!2svOs%76~Rziz@v5%AztAEH5US zSqu!aCXRjnnOo)RQh)xL0bhOonT;M;w|^&pw?p@#4#CITy4^0_$E~j2#kxN>>JToO zQ$#ed#X5}}vd&KZY2Me92{Jg3O_*OR%q}cIh2_erWdeZH(xf6{K`9;W;I~_rDEp?& zVZcXcJ{*`4u)dSwM)&m$;bMpsPbeEt$P)i|6c7=e)So_fy8Q^;lWF_>L2(EFHr``y zpP)3yD4=bADHq2q`g(v}H?P8|YtW*=Ii$iiU05_zHS3#S_edSFAHx2WSNxeut>2@b zG$g9lFax)O zP-LcNY0@TgD?~h&*P=%()FY(&OTvMI8PiX_^9Q1>dwbj zGd^HbYZ2?5_5skC8ZdS6I{W%&hV3z6GO>OLHe_APVKUg|irOHMiz*8P6reM)$ulT4 z(dDOqgu4J+j7M4m<2IZ}Ufxro8GoPD9Rl-LF})hStk^PBKq zsaNyBwOVP;g@JU0Pw(3YV0m@a;+^-Ts!2_zZ8s!$6VJicE{~0D_B`;%_)vjOG3sU{nH0BzdnIw2f5fZzL)bpYGbs0c|fNW{}BAmoR7 zIg?E&;Apu7)oJ>QC|c3^>1*?w;@e)2ZhQUx+(^-R$sQoLRQ+9JsXAm$naqQHH$zR7 zoITR*DbZ6>o9DS^t!D{xLAW_XSUYi(fv^HG{35FftRWSlv=aQ%W;h#=I^wzdl}`RM zWGrE>q6I$fcM2%$7j#7-bp_BI30;MBi;y+oWGbiYPUu$XTj9y!>GtWq8ScHI>F$`V z@mE@;kPz5awQBzV!M7pPT2Bx@QU_n*|6h7^!w!yl1OpdE@(31a^DqqNx%%~x=7NX6 zNa-{lxEuA5)m&C;&zg|d+veFpAUFf(VdxIy5;;7c4F67To_?~b4(U9e8c)bZ>f1YDQB6f6zieV@Lb-L(>;1b<2P6(5Zw9q3>JkgOD##Y6D zN&zv9ME=q)x@$)OO$W$Gi5Hf%_Ye0Bj&bAy6#h*)(OBIMrCrfs8syF3$k7}YvtVOL zC=oTmecC2Bko$kwOX*${mQC0uX(05>J896(_5%ILjIcB3SrF_A$My`*OtgDy4Q5K z=_zD0Cke&0=H7Yb)+^Kd7p$x9@5r#XxH?_%gYx&vryVo=?CN;^wn+Up!MlCFeZKdP z27iBW{tdx>BxX7)H-n#E9WSYml++8w4T77rVwk|UVZqt}UA#L-ZXFRy*Uhbtw;qqQ z9v9Y~AWp!U*W+i#B4@^g)7OOJ>oMC6_DT5ybZ1t7*!V%Cu>M?R)j8?&>75T=nJb+W;+dbOT4f#LN?%fGnz!2)>Pp1h8g3Bjt?9{Ws6X}E@o?G zGOTaD3EjPWw!60J@PniCb$`_K`%RCkqiZ{bhGRnc@tE@jOZBZ6rh4o=@fo`Lq4xOgo2@|Z-5Oz6p%8C3V=jNC8k^UMVmTDNS^|#gs*hp z2TLEZL}_JRVr-w5=qx2B1oU0hN-DdYfio((xKYkUM*Fh1XXTtt$b%S~Sy$G#iZWgP z%nG$NBTr?7&Vm>PREK(0t~JmkML95-N6Ia*DyLu80tNJpKfgkrDPu>}M!D3K8Rv>A zzAz8eTcJ)$#-1`yXGtHGWDOY}zeIadPCUO{)Mvz$1_N~ z%uB5xl@hT8GVHH`DiiO`y1aH=J_sBI@&&t!3o**OksC3J@SQ98e#(R#HP#HQUS1-9 zV1=3#eov7icuM$O+vvF% zGFwKrtEc<~~s&h_sCdW5$ZFM10y6tNiPK~qN*1278Z5$3qXhh0|w*EoX(UeqmrV zKJc~3z}JNSun-;>&b=wv-U0z*%z-xNg0Wgg-NQ3`KkWFRW41TmbSTnvC|Yw^s6HaN zkH$=y^h~_6Ia1jyRI~`W>tecAmP_4&u};cGC~TU&7H>WpX+A16bv^>q_Ob;hTPXLSYgvG(F5#@&Pa2o&~!{FJ|453V7V4A7>nh)os5~_)s0a~f_rt$ zBo{Z85ixZ(?yift>*DUlh`SMP+G3^_mQfk$K}uv+MtZ^RjhX5dA}FK!h`U~tSw`Kh zG1Gcv^o{Yak1M4N9!B;t)7m8wX#&e^u1V8Z>!+_1l`B*< z&t9AB``PeMhJ}s$9-ews81LwcbaV*^Ul(3KFBD&h+1|(^ywOXOUtr^VMT#^wc+0wg^^JNw)Bg`Wr8xS1Dz5zZQZja^uU070 zlWD=v07=QSE}2`yRfG`pg-Sn_oxB_ALOGsxIsyqCes*33Nhd;|-{8{nz{+NnjHyZ}gldd4;SI3!5y-2#Lc-q7Vc0 z#&O29r#HW#IuHfo!;wxb&0~7wCOU%*4j~ZoK*BJ>gV4Ch6f~aaHhMatM-+h4DjBfS zdnUSYHGS7RktU z@Ft4BItB$&5A3c`!=_LH3Xlpv$!g^}G3FohhZ>O4QXSy{4g+D53l2B1@a?B|&~P+` z@{ozxGpQ5hLS@K66mg`hLCU&3Y<-)&rjQ-q9655-a}cD_NN=x5iyeo*?#!$qLxS=O z4leVMH$-MueCX7%PO?-(!HD&6JT#I$KF{Sb)D{&=or~weK?Z1_`y|R7!Xa`f)^mM? zztRg0ueQ*UMh`mz#M6u6gMn$lbLl3m`Yr`-27>+}vhTZ0c6ouoWp1sg?TcST_5=sl zdWJ|w6To{Ab;sL}Hibq&y!zpN0LD)qSV8&GVrbs>6IGZlpqC#Rl5~jEbHmf)8-o;x zO2;8nL;V2~B*Jq!?_u{CY&zbeF#z*GoH^Em7UQ(OrqEH$RvfzI@xz3Vm4GZHRj@i5 zmUJ%=L5cA~PoZI>jhAjV(l5)^({q{JDI>e>jh@4Pyv-hP^nlO9;ovZge9!}(U#f#> zAIR);>2RPXQG#aAmXfNIKEQw*i&GNQk;eObdw-wM0IOX52)v!jBF)tl@Eh zspw%WX(6}gl1Ffohl$$Bu;QjEOp4!0gIM#VL7Z^`Qnz99G72QcHi>p6&2UDvu0sB# zqZ8O85v{pXtM4`6ZI0*FMe^!|+%>Z~M6hZE+cr?6x%n{LUMd)qjjRtr&-kqm-ip`l zh}7*6R_~m@DwMRxOuJLbYZr{QX`AdLki}KruZ(*SM7#%tH3uK5g_1)t)8SNdk?56k zPbJ?M@$M7W?0@JJN)Eg`_Q2eM$lC2f!z=SA1l!Jpy?DBAX7zi` zLUH|Um0(+|@B+Hqvj-wI8-(hObNFU+!d^LJoHa!}>x9bIIefGs0jEQoXSYPEHwdew zG$_mlhoSbxLYvw0ObcVIMUQGt(}!<<@a8Xad#BIN7yMEA@0SaQ{0m$C--fY$FB#kS zeqwb?tq|P%W2OVCCOt-z3OsPmZH6YaM8(I7TjmO)#am*wt+LoH@3E~6?zzq7FvP5> z|8U0#JARRKY`S3X?9VRz=km3P3%V?^^A~LKf7DVZI0<$$b3{L_;<%l zdzO}7_VT6w3Vxmo5_OGYYfs3neY85d^*A;5#H0a%Tvh>9fUyoffkB(Zp(x%QvynR_ zCQYPfeOf}z U+NMvfP9O~iG^6fF_D>C7F^0Cu{=I2zQ`J(*wGY#~-pJ}b={-R7t zVcc33u~tQ`)iWF{X$4*V=bxQXtMdAwYMH09AIDFDD(|E^P=iJHr*!|PUzl_Vm#9V1CWe3YQ&PTIs_iG^MWK-+`)v%>0C zNlV)q0Av!+qLCE!Y7jd|YnwGWc%*@CR^a5S{1-m1!TekIiI8&eUsgMrHucJA$_=uxkFx5X1ZH%W~Kd5-GVyevoos1v*aA_&TkC zuO#t?)~^=1q)f~>J>krp;ek<}3~D+k!PI&Bz6Pr5K5TFp6d2m0!@5VVh~A}P@=t#2 zaZsZ<0Fwfv#}<@Q8n%@0abkNsT6{=f;Lc15mhX zr6-d-C`(jjivqrDWU()+_oRnz7e4x*Xy~?!L#H!+>|EJ3$TJYiar@=DL$2s4htxK^q`@ z3^uTuvDzpe_EJ0smxq|tDebA$?Bn+#uRo?a_Z`#$JAk8ZRu^?N!oMp#6xu2yHo{Bt zqPDfb?edG_`PGs9>Y2T>tD^ah@Z;qujXPFH9IIzSb2z7J6MV?#7T+toTlW6t9}K=X z7|m^n=WdMTZj9z`o;dVrUQr^qAW`7?l~Gq_m^e7u1tYa*7L~qy`t;0}m~Jgjaha)^ zYkSx$oPJH{CC@#A?&@-mZ)z%&mi`J;8Pd-Q$)$thRLnT&jM*6|OleM=o~}xHkgY*K z9Bs(cs2^JqZFgQnC)-un4wIfXJe>;xz(DhX|4*nn=23c-X;_!akcx~d)x1BXCNSdZ9KmvlHW4tn&YGSZPMyz$JJS8cQX&tiAK1QPE^+Y;LZ2mj8-;( zrZSQ%=h8{b)PW~3|IBqK3ag&v7ffBh_qDrUo7pmZGFsRY&0hx#nR^X)8>aiBd8^@} zf<JVXdmGlxS<&ED1MWJm{p1~cC6wW#TW--Rx8Fh z5wk`yCKoaFN-@8c!3l;TKs_wiAmtD*%sX2BSr^`tszTNk);-9}u-0;QW~e-*wH=r~ z!#;fa55xyT6;&$&q8`Q&K3ER%=ydJDn~HmAC-S1B4gDomto<1!oyEpjl!9_jp$?S6#tj;OU zNY5r!U1NOp?uFI67tQo+QI%K6%bFI-nij3}Y*TqwLoI!wa>HT{J=;}975B!!J-+Cm zCucf!EA56b zw^Dj8Q#G{wc!$E80iU96-hkE(e?ylKZm(~iX|dS0dSHvQQ0Bgjv|x;`b|r6BlDvpP=(CXF#N2{3VQ8Cf=t6yRn8+SO6x6 zbTL{JU(=O>)9N&J$(j1GcZdhj{80T{YDq5u@RBAwu#D5#4ak6v>XLPFaG5IaE*bLF z9NIrJU^$hbr@Ubn?toD@WTYZ;W16Luv+UVPq|IlRn7v= z7qs zboTCd$0LPnXMzYGn;I9*$Q7gx=DO*pHFMYJZ^8sT+Ik4|x@ZXd`DbQTDW_&0{yxsj z+r8GXg!z{uc}!^KH*}=tqJ+vSqcM1^L!V?_c&mWC;H`qP2;M3varC8-m>1FE--Oq6 zPV;+evfaW0!dA@Cbog#hQ{$65cvaBCs{;HD%P74(Jt0Oeg@ZRb>_E4~H6>1O7rpIz zbG@37dp{U+l2D4oO-GsN$!khlh+`*4Jl8b`hen_4n=g+H`SEbg$A?VKoc~&L;PMby zI`m0W-wTG^rKA~*9vrX=xd}42ANKRZVDKFs;l_{|gmO7J50Tysu{TK*eqon%$)7Y| z!m;32h;|EE#{!oiL>l3JIEvF0xH<-J;=N!7ay>nxI8(ZRv?r;9oNN>Y;J||;{ljB7 zn567z`yO%<7x3ep*RdPOy9azd914@PgNuMuWFg1FnG7R1%N=e6M}~XJ@eG)Smfd(q zYlGY}BrTUxPf1-LKX!>updGu^io%?yMA_XzA4H79;Bqp)7M-B$51Ao%^m~YjmCQkz z+{h3-22n4Ba(eiiqmYh`j^HS?o1p@6A8r)iHnO+<=o(GPwc9t0bIu_V!!R5f>4OaP z%sH<%WN!8cdzyzvf?UYLZXv@PgGA~QOO6loI{iTsJhBf_M~D!;;Gtsda2GNS`-4sW zqt`a1?ly&T`bXhXh`)}8pc#N2Jwu}*H@b*iFZnpg;e!1E$oPoeXXZz{MVAfeE7N|6 zcCW)R4?5c1E8XK{@8MqNRTs=b`DMsCI5IMN31bR@rLPxaJ$$Y`b?P9hrX#5*W)QUt zx5+}HuHZA?&q1<$8Kp`xz@%w#q>r(P;rg$C6jIti5)T*piw>_^Q^Br~SdMCm2O;FKGV;lmeW!~HjqZ*hAM zGvgEI$|-Vg>^aWnm)97|ClMv)19RruFZxeP>LI-jTnXu+O6+NQZe?B1(s3d2A#m9T zp=HuIJc2&&$Fya>jhUw@Obd*i5mOQoH0ezP&~+I04Wcu7%rbnCG$J5?ku>9mhn_>w zA2Lw~d+>~}4NSd)qZPyaWMR&*U(8VI1sqAu6NHdvIExCI*^ih($VqU@b384=#@Q=& zv=S$*5HFbtX))OS9EQ9PhiOd$;7hr&6Qo0C4t{GHOha`{r~LdiIy}m2Ng6nxe+Xg* zL(gS?WcVgLL*s`Fv7e@p$ro(Gd`H)y;=vKXA-(UK?{a_Aa0Bpz*5q~nB@dW{STrFQ zxiNzjf0$(@L4YM264Bo4Ng73DQihQ(HV*>yw=^lwF_tBsV?@eA@*1PVG95~qF8ctF zF#ja}*F=WXnA)B}OadG&L!PHGM8FLcY?3$AwqJmXNPc7NtuuQkgHtzRj>?F=_4B8+ z`@Lh&RfmqtvVSNnWSnXvDgi(E;1j?Z00{wM@g&y+iMW=8z zVGiet>NA7T&yc`rsO$7)Kh!fMJ%fM;vxl4w_cA|h!#)Ui;5sKmiQ>{&&lNv(Gf+ro zwMg{^l_JG`D8yq;4c>%4h$#Oj8dM~}7{Q03kQ|{srMtgJ0I%-}{NN~Pnvl%SIjN@( z5AeT>ITax6n1uejM0rx)@}A~@59x}jZ;8?RYf@LwyL02#jZbZPaa%duXC-WTBuT|j z+*T2>RV=-s6pL1a+459tavJ_Gr^;x1+y74AVy;S`^I2YAj$vXSj)*BLr^BHGYMd;g zoAf`i+N1W?*?lp4>tidNj}}hvo!%r=T@{LXA)jBc@pMddKn*I%HV!izRBOqsBpwdx ztA6?}ZdQ^)NnXKm$TQFyq|3fIE$R7@fA1CI#e)#kH&@WM<&4tzrkiH3rITqif$vLo z3l+9A?JUtj9?c|n*fO-ANF@r1NGKVy!wy1Lis|!>GSw%pxAVg78q znqShw4CMj-lhZ;`eab^9HKJ%4gv_M(#PmV#tC@{9#KbgD0vKyqOzg_gi|HQa`)QN9 z$$y9x+i5nmBPEf{P^5o*eA>8RtW4kl4e8N9rqGU6@}p_N=t(%%E`3~`uqw!xi9lh4 z_dg)~hI*yp!@v;sOYO|MFkIBctP5|yB%X^L6xeHctH4@Qt{JCc-f0w!R+r5ylWJh?diauo;;x))D&49e z(U|N~E(dQF#)UIJFr|r6`4yI# zn^`7$L4i@qj@D%&PBb5F<%Ch8LF@WF)1e|SPRUX z2b&>F1IcSg-V($CU}_hV-IsiqY{7k|P+jz0(#XMyXHPIK*XT|i2d98GoB|AaN{Zg1 zD-+kDS>x2J_ulyJHzKY>BGT~nf+F>Qp0*GS<$r`l^<^7z8jav-)$tDygbeGN)-|;@ zEvYntN;Uvr0#_4xH4Jf}>^z=KEbTYz_H)P}!^v1~f0d9-1Uo4*{}wEtJ^@pJwM zXbqhEou-ySiAu$vrRJH4W^V>69Y)2eRL+oO*+B?s-Q%{L2nXxmuGAks+AsW*ihI3Dc8t@ zx+djQ;v@|EfH*VUG(e{Tq*1eT=&6u?ebf4;bx`#I`6-gBU)|u`mk?=vaO4UokFR8S zE-o7$$2rZ+naQzfQ_SJP@ZeDz9usH|P}^e6y%k$OT;6A0V#Me zX(HVqlINj7zbWZT^H;j1 zdUglDy3yl*f+?*)^!#Sasc+eMwYF;>>E~+ax5QfaV7%}sjTd+wI4x?B_zM1{{<<$T zb~$8)emc)J(y{=_2-<%*80ujwNLmNfH#oy(l$=wiv_@E$XM8CSV?bO&62TL=4E~JF z*%J9i62mOe#L(g$gcOUK`J|NpUt8B+UDmbhuWBB-=QhqCjIG~`uEnDasX``jp@^B* z7P3;$jRNyEGnZtGmR3zppXmHZR zW(U~-lLwcj7X)R`7~^VrMxYGOW-CA&CN$@Vg+Raq4wD~R_#>i)0HOa3sNEsqHL}*15Kqf&;R^Gk&hgd2HX*m8D+z=^BM$gP#*#QCF%4BI-#8#F` zmfksf>uAQa0y`T0zYg$kc2hs96Zc zOz_NI6fqV_=jt4c85@6EjUK?(wy08_R==uw#vTx^Kc~UzV$8`AJBR~9#kF_WE^1V+ zlNxwsCZE^j2+EeqbNT>`yJy04HzIXAK_DYnfMDd>%reD2GPNPo{Mxj~Y7!~l9Ym%@ zZ&0Q#J2Hh74w+WWpq}0bJ;(--K{)XizQzs0=60ouCGu}%)hdJR%l}j)`-GzUh_U`b z0GVP!@2+NAl5&Gjm8c zsaKb>EmNl)O0RH7YLGFsEDfs6>mhJjnekL0X#~Ci z9z|gJp;iGfKY~z-CCerJ(1BS2o({?U8UcV=JZGk zcDLw0&e2g=<=VYb_wgnj!augScDL#NxKf93$rhNJ`bBK&Lk&)Jt-KpkT=8^JCRx*x zvK;Bj^Uk#9Jq56;+k~wBwX_G|=nD;`!oDzd8yH1h00>uBo zp@vfFq-P9CU?o~X=kqA>AX38WbOksa%L6?@7s{`KIj^4S=3+-Vjdx6KWJw27*7*cG z+HjMpF(A1MR)7)9iDML@S?ZRPvRzNv5*3=FXyL33Css@KqrR1SA)Jc(SfXlO8BRH= zcyh`~oHeu|gh%Fad^t_HoALhT#Xy7v?$cZIcHkFSN;^R0`i3rXKDv?L0HcEuAiv`_ zEgrIV5UJhr3lBHPD&X8dTCr`u{c**PNcjbc=q)ON$Izs9sDD`0cUJS`_$-4#_}nCy z6KehqE5>R;9Ht4?`_(A&k^e7;|9m)Bej!?NVM4{AzgL~qLiv8lK%(yAKyJcK=0K*; z2H=?2Pw+w%29=%ftP$OTqob+683u#C|LdhqwI)I z0;hi__ z1K33Qo3fgQM;x37vr@z2PfYmn>Qj5ml5F&|M4dAt011d;j?xsH^ zNS(IJ4|Q}5U-QF+x{q0hqoCBm*r!GGjnlb zJb??6?jaK40wj~a1o|k|DNCv+nUgn#*1_11M9N}fnW!g`FFOiWIzo&xV(s*|d4BnX z+Ob<{aK!V*GFwBH5w&z!WyE@mXmyjlDx!?A8w2YfWS=FSgz{r(aNr9Yy8xJluucd2i9jLSrlftUP(C^$ zh0t`feEENeLHG`qbAWW+utSl%<|l6N>}oi@nyZSqH>4ai3;Ff4JB0eS`6?lIdrbF= zEN^7ON!j{16f=E|@eRrs;3#YTR*~fYMd{U9sqT+5CyphS}Y-CxlX1HfVCrsBzSIv4?CZ7m7PYuUEL4e>MI}XXKU6luhyU z)tN$a2`zZm&AER%{_rf^9dAA%Y&t2FpL*;(O}52nNTf|XgbZJ4f_qoY)GpaGfA_-7 zrg-(n2&}~eQTMjTrtJx{BW^BPFqg;{&!ST{qQ_J2&4j{(KQ9+vy%2wui@eGSz5UUS z0pZ{k!95r=4auh3xo2X!vk9wn;>hQpm8;5lzW+|0Z(D{RS18>EON za5{JiBQL>9Uny*z@Ka?~Qj1*4tFzMfi;@jl=!sJ43S#ILOD7*)FPX=((yap+lkAJr zaVGqmGjxqpgpZg5wtdiG;*};Kr8sLwT*}Xp6lcqbO9`N*xSWi*l(<`pvuDIv*R8|JT9Meac+!jzMKZAj++0E7&oPM7SIgHObN8=uh<+_9_K>LT?O<3^(aD& z0wT5)4O(ILr1}AMRW7#}xhtR$S)QzoN>({Mhi{AIu}c3+2&4@MnU zqf&fVCZ|sSq9@9NU;>C2(s<`kBRV&RqZWUryrF&hz!w6L)Mgan2 zHlr0_%>q|DyKl+_31q2yWgZ0 z$YHJgUHX?1#& z$%qpwlzXF@YZ)L)BkMw4i5$tg8uo34_ zDA#j6@>GDOOY;nFl)EC``{VYoeZ^cigzYQ9hD?k`xkg{OcDRl5NWhWC=H)d5tV7R_ zJ634%rm$ma>u%$$(G}8W)CHEb{{jbVX>OOTxQ}nnf>*i~_kb{VP*a+%}@uQY<86 zN&!ej%}nLXoYnh9UyH8P8D^fdu(Kfs z6C6?!cPUgNddtL@%*~=`3IIMn5`?Vq+jtBr+N8!$PWRK^E@iPOrGiV#rRv_yrDYjq zUXk*4nW^Z_w7Dr!s7T3?Ka24+dX7Nz4ZN)K85 zm`qkvH^QB1Ew^doWID-RSbNt5k2OiA(=^j57RtlQOxxdg&V7LvNttB&NB57j=brcZ z&g0(mo$vd7o+Ep=9oW9@;1NCuOsb4T42^^y<_Me+q2aPQU=b?J%Cr5a8LLtSlWbmF zb{EhJ=#j_sB;0*)Rvr5Xrd}wC5^=Z15q?Rz@MJFxEBUngMk{8wWL){iO zAwmcc>1JAtkTXkDmOxU$WaTF5wrL1gQY~p6sNseg06mxih-yM^iA(V@W~#!Xl#X{Z zH5w9Mf=ClEvq(-4cbU*XgeDE?+9|keT7j5H95Ec!|q&)OSSw~P*<05c|PBo%aOGh6trBK%)o-XDAb&kt4k(eO| zn}tds>G4Rt#?I+2<QL{JXLi@sl6cKEpVX;$Q8PvRpQTStUPRq z>+5O{Vm(_g1ISm=q>}11T@d2OD|4E)`Z!c!74%vx4u%K}+HwrrIM^bAF_Kgw7PB?N zdB2zu7idPL(_}~>CxcSBqHR)0>!!%mrM5<3o+Q<{EipcDgB0WSucFHKsMdB~!6FVP6rC0J}YU4sgC|;apD=wV}`| zDRC=qOFU2mWhlLEB-wg|?EskFY^X>t{s?QQvaa;Wz^m5u-5BmI^_dksfH51UZHYZo zqp<3zy9`BF)5OEB$rOo5D)$^;j~e{0%Jj#b5s?bQyO5P;$isXa=s!~>jVfTekBXN9 zQ(9f-gpUYKJIMb+^!f}WDp4HP#W7k1E!+38os5pLHm&jbuSj= zhA!OzuCE|~`Z8u~c^(riVt3(Ox^WlHVLHf&BrCx&3kY&X%%ds&Rr1j(l;4lEk#8e8 z`b|kQJ?9VLf-@z7m|=)+^VgG*5TBL(~uIo~DcpUKIG zGaJvY^%dX~!bB18Alqz`woME(Q<=O;BpEQ9rbX%c5RfpSyBi1M`CW|ea_NDb$9!yw z|AuNwO=tLz!Au6&LdE90@iP^$RbB>Ix0B-ues{!~HQo_)7Wpp*omByM(7AqW$DD1| zM8k}&eylB0T>YWF8cN2S0RP@+VFYUHodf?7T!c zwwLbNmjVyVWeu{eacZw@-NNkPJYtPgc2`WaeS7ztyTg?&!O9l7qV;aG0)mT+5@x&8 zU^z#2!=mM#>f6=fHC@3qUGnPP?|)9tcqU}os{#H3fH#X_{uaM+Y@+P-v;Mw7vuv#g zR_`0N{!6cK^j8O*vb7dyzHh|(+h4biU&VEiwK|egIk_+3e(U*(E?iMrn<6PCZwybY ze|^+{PC@^znj8)^y!Db?MX1QuW~#$l%-n+!>xGE*bj0e5Sa(OP`w9QI6!<9*VqJ0Z zpVpWx4)q4eRU6nVXRHrdHbh)2?wc$ISYO!Aj1PNOtMI~swmY4-I|G~KtgRtyi-w3B zB4JsMj@#fr9xka5mek9|>tqfGY|jYWi>K|y5n#yVuMOs}#U&VoB5t%3WY^W3S0~(n z?22UOy=r~M>L12^*ycAk%c~lv)_ynd{fj@k`u(fZyG6PGWVl}p_5*hA;!N`;dCO(F zSo$FIMO=cRC|rWsm`dq5!yI;^2lMHw(NiO&aGKiX3mBVq+j!8*g&B!uF~edzA_se0jnZF7F7IcgSVi<-8pq zICjp%#Av4g8QjDaW+|3IN^=9Va_Z{98g5k9h}Mi0`?Nk7J12%L!&(HHRLWX;-y|d+ z7Orhq2e5YhayY*>m|rWes0(ZjT)y+t?U&^G_PgTy<>4JY!5uyF_Fg&b`46mzAZvr1 z3gH;7?y0S+3moM}W{oJ$J42REt?~~+@{F7XQ)XhpwQXwMb^f?-CcTz4enrlzgxzB` zIMpz}GRPKf$XdE6E<69#s#jpiHqC&@JbuA{AY5D@EUuS})&)KXl%U*qa;H+{y!Mcz zLnZd!ki(<;U&J_Wd~@SO^^{Z2YYaJ>)QBsg$}M+YGnF0hJA;)w<%&)@Z&%3C6-mnq zr&UjoPuU_+0*p;s<=K9qil$$ZyOyX%!jB9}Ih?d+alV~^a> zD;GZ>$~-hzLTxPRkR2;ON-NO{TPxRZ57+Mr*6)$mJ|h?G4W;i>+hXXuoVPmUSTmPf z8)%!!ZN!XlR^4b-yS2ve^B?f%e30(NfIF-1DOPj$e(3PrF9pTtlQD+#b=q<>jD4yy z+;YajkmXrz>=}MI^tXk?Ju%v<8m}FHA)H?mgpuAAs{^U(PJ_j=Rp_EMlf66Umw@Wo z6GQWEPfV0~cR$8L+glRwwOvoZ*M|CxV?ShWotGE@z+2AVI`iL~;k=iU(7VC>p34lE z!0WXiaqQUr^%y*a6gXxb!P|`gba6sL42HOlF{hFyIR(P(DG-5Om6;~5yv<`L~~+7~w& z{}zoEez;+Zj^>qMNN<@;R27GpO`l{)pZpZ*tv=giiauV;w8BpJ$vQUQvbE#B`6)`v z@VTBMmD`u;%bLu7VvlotxxPH#ipl&Z($eEAc#3)pQSZv9sJ95|ik~7~iLcaG<}06E z^~Cm77{;#hDaOugNMH37>8lOtYn~$gYV>f;Q}nRbSLa(hS^vb5TBk!^`_}t5Og21` zB8_^B4G4BkDlgl&ad8=tW8c&zt=kK!zJ7LeT6(@^?6`izv6soX;UorrUKK>8>COlE zR;otYLt(SDat~C-=zzGi#HtZkQ{hk%)GCTlJgA?lTDMDMHr(N<>IFi%r)lNIZUJ^l+Y8dm2yif!N zmIW#?Fpr|JrKEVTmr_Vo9dJF+(Ho!^a2D00>A?~hId&4of<~+jXkGDjGwu4khEzi@K}X`qNsW8C#Uh_)5KLH|L4s2; z+4~{J7^AO2*HtTh5!t-V>Z`PA_j#`V3?wa>lmd?lyeJk$2koaSl3>tu5X%;RJ@%VfKu5}N}XgFim@j+e+H&|y^KO?w3Nqa0>&*(HZ@f1HdVk_f+K*3 z9zq`(=pyjpR6{*XNRuKY+ivy+Kx+n*6Du&`*vWH*6E&Nl!qBssiW2#^f>e2sEEyPD z$5%C@yO9So;zI`T{nXV9Zo5>=^Ds8oz&{~G&W1m#5lAAy4lp}J(l2Wx+fYR9nub07s0h8|NsiA5> zww;uUIQ$q}=V&U2=uuhfDmx2X&2Nmeg>bS^=Ff>kB&`6d7bYT?R90eW#K9&55Z5rK z8{4Mz0^%@lFZvz|)lw1N1JLT!=)<-<&d=8>2VxoaQ8qnhQ<}vSOb2)m%@%RiibdLo zSQaLdUY1B-eNd($lNCr|(BGN0a*10WA^s0~R7;uHlFn&eGOyC`Rt2P)-lK9+dEkXWufDEFU`helbSZvr z52NZE=!Yig+C()WFP=iU_Gz!xXc{A(8mf26&!>ryLZkq#;lJo*AT)}0iu%!RL>bk! zEC!*gxAb=<))%AeD5BITVmWH5IJ%Kh*C+xd;t!*@a@n35F8!nTWOz9m8N|AfGm6;e zvN<)89Z;9J-0T;<-O<~G&?tS6-U@_95&i^jkOI#$^ z7%6x>71U4aA;AaXb@ZrEO|Sop&yN(VPtjATrNBT%sg@E#??@eO24gMK52?$!4?^_X zn2#ptRJ)RZ5~7H&DZ;Z)Gn1o;t}Q!b6V-m;pc@lS=xvY=3QK;zq)AQSWYjXUT8+M< ziIFnh<;2mX5lsM$toB4JUtNWxi3UNaB!lQt%phIIEct;Nz^evCx3cH)XCL(eqL@p6 z2qgvSB|oca(yAWIpbQW*NUzu>Kk=(qlA(ZLRTL1=(@_$f-m)Ya9uc}VYG`ZJ(6&cI zeYU?eL#Y8TZ>U;W(H^>TTJrOyka)J3`S`W9c=sE%4Q2Z5gFBZLg-4&`kD$t-t6Hek z9JLwdqYW*2((Y-bQ5Df>gl^v0?+W##OMTM+5xPfzQhf;BtNQhSb(BCIh*FKFpnN(N zu-}!$D;iVh53?50h68Hp`u`0OT<_?(_f^+6?pm`ECPP^ahPM{cB7EkWNMmh6gA z+U84>@Lr8t+abL?wEnRAr2nhAqW8_0zFf&{pNiI&jKw)nZj{|jbmL(=9 z+NyA+z^S19X^Cw-X^qB3B)KJ}QP5T$0#q@zjWb9XYmgGqq1e2erK>D~gL7{UU*do?jtH;JjjG6sX{fD}CWW2ijE&tOs@ zryQqm4ivnPlsqTkDA~%IYdAh`va8*Q)Js1(=B@KtFbe{!UT@<0!FVjK1 z2HCmLYW`_lK>O<^%&#^0D<_V^^wyc+>K3`C_3qZYBk#ArH+uJ6$a5f=e_D}d&C3_~ zg_7iagpn%Q7X_X5Nk*Vw&W))$>*KnnujotSMp`xOD&Y#k=k0Kmj!cIcUbJw9!JLFKV|v~G{3nGoLn_~*++QH) zw{T`-$pi#_$%tTam_!dX#K!uivI`B+Y-}lzoY8DNX9RM2qyM6u-zVn>)!BzsGX)^Go)Jap~PvH>P zD(lOK!K+GkhLdxH$+_d*Gs%Txt@jd=!wEUl2{}M82`A?Vlk??d$Q&>s6FI9rWbK%P zTuEKHVsj8OOzGdtdM|4xb?3E&IZOK2yKnW}?C~cAgiywsKopWiG5vwC?^#rXRn8&-7bu%mC z&I!A{LAMw1=i#)nU|Lxu)fG-H38t379$q@^;XOz(C1&vEy%F!fG~=n6a)vS*r@G(m zeW#Z!o}-P;+rrKJrVz zm7!H{VWKT^#`ch9$GwcKTZeBR4rh3S8Q#D}Im0`Xv3aa(E~{(;28Y+<;+cfS=IrGV zxhZK#bWY=;3+`-+IgF_-QhH@|9o#WtN;0qdjbmDA~U0W(NLW-TK*`L8y<(s<*g z2Z?bG7!rliD~pxu&cotWZqcvomb4@UIy2{!P3{$8S6R?iMw}YKW=5AO>jDrAAEV5inm^V?*R1!LX&E)Zt>15BGEBn^q%|X9A;0(EHueaaB^QoJs zu;t3f21EI$XI!VRwG(v?j{9Cc{mSW(WfhU+VBd!+N8WG$%8TRc{H@>Yc&%f?_wB(q z2hq{WcPc-y17Pe3p^P2Dyd5v|x6jxr$J*v>CH@yCE={GsoAplCjI9MBsosg>GpTFG zc4E-J_|g|%idd{K+ppWhmb{=P&+iL#%a**5r9qpnArF~v_pHB*3DI)5`+I%w^~nbZ zgWCo}meZ7|&wpT|JM7&O^lq8SXuezbz0L1!miHeIwj2*xPAFsi&Fa^x<+>fgLMB^9 zb3AuCId{&QIiBP9g-g~2OPCpTZdck!qfi0OmVym;3qr2e>+KPn^X1;_z2m#4ZRHVH zfgZ49+Ey~>0#eX~FI?3etZJTdZN1+9GgpDXWTGg%YGZKK#u?WpqAElc{v_qz|3yuU z#&5Yk|ICqw*Ua66p)?2%?Yg#FHh0VWV3oQ1v-<-AX3l5#JtliU2T=GyVf}%6^S{>B z9%wYrTKf8E)9UNPr^<2K=7?Q48!rw3lz50es8>1^A-b`*iNurP_T1z7Z4ZmeL+Ha~ zcI|ov62$<#Wr^|qLZ}>{r&!xQeB|MlcAyv6g@?p1Q8~n@U@4^}h?aPGRv44`8ii+3 zbzdhRtK!edM=x4lG=(jb^3N#|%gq|WQko5AYW;YmcT(xdh4W_ydbg^#$-96J0w7E(q56tZs* z4`3ssps0x1ME;Mc7^;R5{h_gIG>YZIsQ}({$TFnW_X+v5`uHqL;%`ynFLJ?TMhp6$ zRo77baS?;Dm#a;;`JU=5mKg}T$2S4vzM@PUZ8u=T1HW@1wQKPdlU%Hpz4{e) zJ5;;QQ$n4ZY;MrdV({4KE*s|mAaZ4@b3@E0!o;w7BDwLI&CW4Z&5JI5QK}Qkq;zdn z5V6!zVb##~P`+a}sju(&1-xqp`uapV$cewD+A7KU4n6(|4x|dP|1vW`uor~Q#w5m( zLpLVeXuX9}C7g%I&zVz4**u?(he!|Zt;Hlt^ta@^%@X0x`aDil;&lo-L(XM#zCjMX ztN6CU_B-OU^W{cUgaacUBp=Oh@dP;{IqVSh74nUf^B3g&6*=sPG)2C<DC9>TksWD(f2SOC*ny{x9=DLQjhuFJc9O$RYtN96EF*C>j=SXh z7ji=6d_oRsM=;?_b_Qi96_J2WW|Q_YGXaow8euE~b_vT4HGtIQrUW;MB^Xi^U>bfG z9>~ZF!pEkX&K{He>zFv9Xx;>8Ar_DG?9ss@l7!rO6C5Ri9dQJix<-7O>mSLdQ4>zPN90<1V@RW$1XD3e3W~LM4KH4nf$oU`ZCz9^~c-@e566WJ^gAnuUcz8@ZkYA$=jeQdqHYSa1p13u|)V zZE^`agoQOp$dK(6Ha;jb3zaaHTf6SlCz3D8l#w-`Og^3$zJ\d+(?:\.\d+)+)\s+(?P.*)\((?P<assessment>Automated|Manual)\)\s*$") + + +@dataclass +class BenchmarkMetadata: + title: str + version: str | None = None + published_date: str | None = None + source_path: str | None = None + generated_at: str | None = None + + +@dataclass +class Control: + identifier: str + title: str + assessment: str + profile_applicability: list[str] = field(default_factory=list) + description: str = "" + rationale: str = "" + impact: str = "" + audit_text: str = "" + remediation: str = "" + default_value: str = "" + + +@dataclass +class CommandExpectation: + command: str + expectation_type: str + acceptable_outputs: list[str] = field(default_factory=list) + + +@dataclass +class CommandResult: + command: str + status: str + returncode: int + stdout: str + stderr: str + rationale: str + + +@dataclass +class ControlResult: + identifier: str + title: str + assessment: str + profile_applicability: list[str] + description: str + rationale_text: str + impact: str + remediation: str + default_value: str + status: str + rationale: str + command_results: list[CommandResult] = field(default_factory=list) + audit_text: str = "" + approved_exception_reason: str | None = None + approved_exception_notes: str | None = None + approved_exception_updated_at: str | None = None + approved_exception_host: str | None = None + + +@dataclass +class AuditReport: + metadata: BenchmarkMetadata + summary: dict[str, int] + results: list[ControlResult] + + +@dataclass +class RemediationGroup: + name: str + summary: str + controls: list[ControlResult] = field(default_factory=list) + + +@dataclass +class ExceptionCandidate: + identifier: str + title: str + reason: str + + +@dataclass +class ApprovedException: + host: str + control_id: str + reason: str + notes: str | None = None + updated_at: str | None = None + + +def normalize_line(line: str) -> str: + return " ".join(line.replace("\x0c", "").strip().split()) + + +def clean_lines(text: str) -> list[str]: + cleaned: list[str] = [] + for raw_line in text.splitlines(): + line = raw_line.replace("\x0c", "").rstrip() + stripped = line.strip() + if not stripped: + cleaned.append("") + continue + if PAGE_FOOTER_RE.match(stripped): + continue + cleaned.append(line) + return cleaned + + +def extract_benchmark_metadata(text: str, source_path: Path | None = None) -> BenchmarkMetadata: + lines = clean_lines(text) + title_parts: list[str] = [] + version = None + published_date = None + + for line in lines[:20]: + stripped = normalize_line(line) + if not stripped: + continue + if stripped == "Terms of Use": + break + if version is None: + match = BENCHMARK_VERSION_RE.match(stripped) + if match: + version = match.group(1) + published_date = match.group(2) + continue + title_parts.append(stripped) + + title = " ".join(part for part in title_parts if not BENCHMARK_VERSION_RE.match(part)) + return BenchmarkMetadata( + title=title or (source_path.name if source_path else "CIS Benchmark"), + version=version, + published_date=published_date, + source_path=str(source_path) if source_path else None, + generated_at=datetime.now(timezone.utc).isoformat(), + ) + + +def find_heading_start(lines: Sequence[str], profile_index: int) -> int | None: + for index in range(profile_index - 1, max(profile_index - 5, -1), -1): + if CONTROL_ID_RE.match(normalize_line(lines[index])): + return index + return None + + +def extract_section(block_lines: Sequence[str], start_marker: str, end_marker: str | None) -> str: + start_index = None + for index, line in enumerate(block_lines): + if line.strip() == start_marker: + start_index = index + 1 + break + if start_index is None: + return "" + end_index = len(block_lines) + if end_marker is not None: + for index in range(start_index, len(block_lines)): + if block_lines[index].strip() == end_marker: + end_index = index + break + return "\n".join(block_lines[start_index:end_index]).strip() + + +def extract_section_until(block_lines: Sequence[str], start_marker: str, end_markers: Sequence[str]) -> str: + start_index = None + for index, line in enumerate(block_lines): + if line.strip() == start_marker: + start_index = index + 1 + break + if start_index is None: + return "" + end_index = len(block_lines) + for index in range(start_index, len(block_lines)): + if block_lines[index].strip() in end_markers: + end_index = index + break + return "\n".join(block_lines[start_index:end_index]).strip() + + +def extract_profile_applicability(block_lines: Sequence[str]) -> list[str]: + profile_section = extract_section(block_lines, "Profile Applicability:", "Description:") + profiles: list[str] = [] + for line in profile_section.splitlines(): + normalized = normalize_line(line) + if normalized.startswith("• "): + profiles.append(normalized[2:].strip()) + return profiles + + +def parse_benchmark(text: str) -> tuple[BenchmarkMetadata, list[Control]]: + lines = clean_lines(text) + metadata = extract_benchmark_metadata(text) + profile_indices = [index for index, line in enumerate(lines) if line.strip() == "Profile Applicability:"] + controls: list[Control] = [] + spans: list[tuple[int, int]] = [] + + for profile_index in profile_indices: + start = find_heading_start(lines, profile_index) + if start is None: + continue + if spans and spans[-1][0] == start: + continue + spans.append((start, profile_index)) + + for idx, (start, profile_index) in enumerate(spans): + end = spans[idx + 1][0] if idx + 1 < len(spans) else len(lines) + block_lines = lines[start:end] + header = normalize_line(" ".join(line.strip() for line in lines[start:profile_index] if line.strip())) + match = HEADING_RE.match(header) + if not match: + continue + controls.append( + Control( + identifier=match.group("id"), + title=match.group("title").strip(), + assessment=match.group("assessment"), + profile_applicability=extract_profile_applicability(block_lines), + description=extract_section_until(block_lines, "Description:", ("Rationale:", "Impact:", "Audit:")), + rationale=extract_section_until(block_lines, "Rationale:", ("Impact:", "Audit:")), + impact=extract_section_until(block_lines, "Impact:", ("Audit:",)), + audit_text=extract_section_until(block_lines, "Audit:", ("Remediation:",)), + remediation=extract_section_until( + block_lines, + "Remediation:", + ("Default Value:", "References:", "Additional Information:", "CIS Controls:", "MITRE ATT&CK Mappings:"), + ), + default_value=extract_section_until( + block_lines, + "Default Value:", + ("References:", "Additional Information:", "CIS Controls:", "MITRE ATT&CK Mappings:"), + ), + ) + ) + return metadata, controls + + +def collapse_wrapped_command(lines: Sequence[str], start: int) -> tuple[str, int]: + command = lines[start].strip()[2:].strip() + index = start + 1 + while index < len(lines): + candidate = lines[index] + stripped = candidate.strip() + if not stripped: + break + if stripped.startswith(COMMAND_PREFIX): + break + if stripped in SECTION_MARKERS: + break + if stripped.startswith(("Example:", "Note:", "Nothing should be returned", "- IF -", "- OR -", "Verify")): + break + if CONTROL_ID_RE.match(normalize_line(stripped)): + break + if stripped.startswith(("•", "o ")): + break + if re.match(r"^[A-Z][A-Za-z ]+:$", stripped): + break + command = f"{command} {stripped}" + index += 1 + return command, index + + +def parse_expectations(audit_text: str) -> tuple[str | None, list[CommandExpectation]]: + lines = clean_lines(audit_text) + for index, line in enumerate(lines): + if line.strip() == "#!/usr/bin/env bash": + script = "\n".join(lines[index:]).strip() + return script, [] + + expectations: list[CommandExpectation] = [] + index = 0 + while index < len(lines): + stripped = lines[index].strip() + if not stripped.startswith(COMMAND_PREFIX): + index += 1 + continue + command, next_index = collapse_wrapped_command(lines, index) + expectation_lines: list[str] = [] + scan_index = next_index + while scan_index < len(lines): + candidate = lines[scan_index].strip() + if candidate.startswith(COMMAND_PREFIX): + break + if candidate in SECTION_MARKERS: + break + expectation_lines.append(candidate) + scan_index += 1 + expectations.append(build_expectation(command, expectation_lines)) + index = scan_index + return None, expectations + + +def build_expectation(command: str, expectation_lines: Sequence[str]) -> CommandExpectation: + normalized = [normalize_line(line) for line in expectation_lines if normalize_line(line)] + joined = " ".join(normalized) + if "Nothing should be returned" in joined: + return CommandExpectation(command=command, expectation_type="empty") + + acceptable: list[str] = [] + current: list[str] = [] + for line in normalized: + if line == "- OR -": + if current: + acceptable.append("\n".join(current)) + current = [] + continue + if line.startswith(("Run the following", "Verify", "Example additional audit needed", "Note:", "- IF -")): + continue + current.append(line) + if current: + acceptable.append("\n".join(current)) + + if acceptable: + return CommandExpectation(command=command, expectation_type="contains_any", acceptable_outputs=acceptable) + return CommandExpectation(command=command, expectation_type="review") + + +def run_subprocess(command: str, cwd: Path, shell_executable: str, timeout: int) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=cwd, + shell=True, + executable=shell_executable, + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def evaluate_expectation(result: subprocess.CompletedProcess[str], expectation: CommandExpectation) -> tuple[str, str]: + stdout = result.stdout.strip() + if expectation.expectation_type == "empty": + if result.returncode not in (0, 1) and result.stderr.strip(): + return "ERROR", "command could not be executed successfully" + if stdout: + return "FAIL", "expected no output" + return "PASS", "command returned no output as expected" + if expectation.expectation_type == "contains_any": + if result.returncode != 0 and result.stderr.strip(): + return "ERROR", "command could not be executed successfully" + compact = normalize_line(stdout) + for expected in expectation.acceptable_outputs: + expected_compact = normalize_line(expected) + if expected_compact and expected_compact in compact: + return "PASS", f"output matched expected text: {expected}" + return "FAIL", "output did not match any expected text" + return "REVIEW", "no machine-readable expectation found" + + +def truncate_text(text: str, limit: int = 1200) -> str: + compact = text.strip() + if len(compact) <= limit: + return compact + return f"{compact[:limit].rstrip()}..." + + +def build_control_result( + control: Control, + *, + status: str, + rationale: str, + command_results: list[CommandResult] | None = None, +) -> ControlResult: + return ControlResult( + identifier=control.identifier, + title=control.title, + assessment=control.assessment, + profile_applicability=control.profile_applicability, + description=control.description, + rationale_text=control.rationale, + impact=control.impact, + remediation=control.remediation, + default_value=control.default_value, + status=status, + rationale=rationale, + command_results=command_results or [], + audit_text=control.audit_text, + ) + + +def run_control(control: Control, cwd: Path, shell_executable: str, timeout: int) -> ControlResult: + if control.assessment != "Automated": + return build_control_result(control, status="MANUAL", rationale="manual control") + + script, expectations = parse_expectations(control.audit_text) + if script: + try: + result = run_subprocess(script, cwd, shell_executable, timeout) + except subprocess.TimeoutExpired: + return build_control_result( + control, + status="ERROR", + rationale=f"audit script timed out after {timeout}s", + ) + combined = f"{result.stdout}\n{result.stderr}" + if "** PASS **" in combined: + status = "PASS" + rationale = "audit script reported PASS" + elif "** FAIL **" in combined: + status = "FAIL" + rationale = "audit script reported FAIL" + else: + status = "REVIEW" if result.returncode == 0 else "ERROR" + rationale = "audit script did not emit a recognizable PASS/FAIL marker" + return build_control_result( + control, + status=status, + rationale=rationale, + command_results=[ + CommandResult( + command="benchmark audit script", + status=status, + returncode=result.returncode, + stdout=result.stdout.strip(), + stderr=result.stderr.strip(), + rationale=rationale, + ) + ], + ) + + if not expectations: + return build_control_result(control, status="REVIEW", rationale="no executable audit commands found") + + command_results: list[CommandResult] = [] + overall = "PASS" + rationales: list[str] = [] + for expectation in expectations: + try: + result = run_subprocess(expectation.command, cwd, shell_executable, timeout) + status, rationale = evaluate_expectation(result, expectation) + except subprocess.TimeoutExpired: + status = "ERROR" + rationale = f"command timed out after {timeout}s" + result = subprocess.CompletedProcess(expectation.command, returncode=124, stdout="", stderr="") + command_results.append( + CommandResult( + command=expectation.command, + status=status, + returncode=result.returncode, + stdout=result.stdout.strip(), + stderr=result.stderr.strip(), + rationale=rationale, + ) + ) + rationales.append(f"{expectation.command}: {rationale}") + if status == "FAIL": + overall = "FAIL" + elif status == "ERROR" and overall != "FAIL": + overall = "ERROR" + elif status == "REVIEW" and overall == "PASS": + overall = "REVIEW" + + return build_control_result( + control, + status=overall, + rationale="; ".join(rationales), + command_results=command_results, + ) + + +def filter_controls( + controls: Sequence[Control], + *, + control_ids: set[str] | None, + profile_filter: str | None, + automated_only: bool, +) -> list[Control]: + filtered = list(controls) + if control_ids: + filtered = [control for control in filtered if control.identifier in control_ids] + if profile_filter: + needle = profile_filter.lower() + filtered = [ + control + for control in filtered + if any(needle in profile.lower() for profile in control.profile_applicability) + ] + if automated_only: + filtered = [control for control in filtered if control.assessment == "Automated"] + return filtered + + +def summarize(results: Sequence[ControlResult]) -> dict[str, int]: + summary = {"PASS": 0, "FAIL": 0, "ERROR": 0, "REVIEW": 0, "MANUAL": 0} + for result in results: + summary[result.status] = summary.get(result.status, 0) + 1 + summary["TOTAL"] = len(results) + return summary + + +def build_report(metadata: BenchmarkMetadata, results: Sequence[ControlResult]) -> AuditReport: + return AuditReport(metadata=metadata, summary=summarize(results), results=list(results)) + + +def render_text(report: AuditReport, include_passed: bool) -> str: + lines: list[str] = [] + summary = report.summary + approved_count = sum(1 for result in report.results if result.approved_exception_reason) + lines.append(f"Benchmark: {report.metadata.title}") + if report.metadata.version: + lines.append(f"Version: {report.metadata.version}") + lines.append( + "Summary: " + + ", ".join( + f"{name.lower()}={summary[name]}" + for name in ("PASS", "FAIL", "ERROR", "REVIEW", "MANUAL") + ) + + f", total={summary['TOTAL']}" + ) + lines.append(f"Approved exceptions: {approved_count}") + for result in report.results: + if result.status == "PASS" and not include_passed: + continue + lines.append(f"[{result.status}] {result.identifier} {result.title}") + lines.append(f" {result.rationale}") + if result.approved_exception_reason: + lines.append(f" Approved exception ({result.approved_exception_host}): {result.approved_exception_reason}") + if result.remediation and result.status in {"FAIL", "ERROR", "REVIEW", "MANUAL"}: + lines.append(f" Remediation: {truncate_text(result.remediation, 300)}") + for command in result.command_results: + lines.append(f" $ {command.command}") + if command.stdout: + lines.append(f" stdout: {truncate_text(command.stdout, 300)}") + if command.stderr: + lines.append(f" stderr: {truncate_text(command.stderr, 300)}") + return "\n".join(lines) + + +def _render_result_markdown(result: ControlResult) -> list[str]: + lines = [f"### {result.identifier} {result.title}", f"- Status: `{result.status}`", f"- Assessment: `{result.assessment}`"] + if result.profile_applicability: + lines.append(f"- Profiles: {', '.join(result.profile_applicability)}") + lines.append(f"- Finding: {result.rationale}") + if result.approved_exception_reason: + lines.append(f"- Approved exception (`{result.approved_exception_host}`): {result.approved_exception_reason}") + if result.approved_exception_notes: + lines.append(f"- Exception notes: {result.approved_exception_notes}") + if result.description: + lines.append("") + lines.append("**Description**") + lines.append("") + lines.append(result.description.strip()) + if result.rationale_text: + lines.append("") + lines.append("**Why It Matters**") + lines.append("") + lines.append(result.rationale_text.strip()) + if result.impact: + lines.append("") + lines.append("**Impact**") + lines.append("") + lines.append(result.impact.strip()) + if result.remediation: + lines.append("") + lines.append("**Remediation Guidance**") + lines.append("") + lines.append(result.remediation.strip()) + if result.default_value: + lines.append("") + lines.append("**Default Value**") + lines.append("") + lines.append(result.default_value.strip()) + if result.command_results: + lines.append("") + lines.append("**Command Evidence**") + lines.append("") + for command in result.command_results: + lines.append(f"- `{command.command}` -> `{command.status}` ({command.rationale})") + if command.stdout: + lines.append("```text") + lines.append(truncate_text(command.stdout, 1600)) + lines.append("```") + if command.stderr: + lines.append("```text") + lines.append(truncate_text(command.stderr, 1600)) + lines.append("```") + return lines + + +def render_markdown(report: AuditReport, include_passed: bool) -> str: + lines = [f"# {report.metadata.title} Audit Report", ""] + meta_parts = [] + if report.metadata.version: + meta_parts.append(f"Version: `{report.metadata.version}`") + if report.metadata.published_date: + meta_parts.append(f"Published: `{report.metadata.published_date}`") + if report.metadata.generated_at: + meta_parts.append(f"Generated: `{report.metadata.generated_at}`") + if report.metadata.source_path: + meta_parts.append(f"Source: `{report.metadata.source_path}`") + if meta_parts: + lines.extend(meta_parts) + lines.append("") + + summary = report.summary + lines.extend( + [ + "## Executive Summary", + "", + f"- Total controls evaluated: `{summary['TOTAL']}`", + f"- Passed: `{summary['PASS']}`", + f"- Failed: `{summary['FAIL']}`", + f"- Execution errors: `{summary['ERROR']}`", + f"- Needs review: `{summary['REVIEW']}`", + f"- Manual controls: `{summary['MANUAL']}`", + "", + "## Findings Needing Action", + "", + ] + ) + + actionable = [result for result in report.results if result.status in {"FAIL", "ERROR", "REVIEW"}] + if not actionable: + lines.append("No automated controls currently require action.") + lines.append("") + else: + for result in actionable: + lines.extend(_render_result_markdown(result)) + lines.append("") + + manual = [result for result in report.results if result.status == "MANUAL"] + lines.extend(["## Manual Review Queue", ""]) + if not manual: + lines.append("No manual controls included in this report.") + lines.append("") + else: + for result in manual: + lines.extend(_render_result_markdown(result)) + lines.append("") + + if include_passed: + passed = [result for result in report.results if result.status == "PASS"] + lines.extend(["## Passed Controls", ""]) + if not passed: + lines.append("No passed controls recorded.") + lines.append("") + else: + for result in passed: + lines.extend(_render_result_markdown(result)) + lines.append("") + + lines.extend( + [ + "## LLM Handoff", + "", + "Use this report together with the JSON export to draft a human remediation plan.", + "Recommended workflow:", + "- Group failures by subsystem such as sshd, auditd, sysctl, mounts, and PAM.", + "- Separate true compliance failures from execution errors caused by permissions or missing host-specific tools.", + "- Turn each failed control's remediation text into step-by-step host changes.", + "- Keep manual controls as a distinct verification checklist.", + ] + ) + return "\n".join(lines).strip() + "\n" + + +def serialize_report(report: AuditReport) -> dict[str, object]: + return asdict(report) + + +def load_exceptions_registry(path: Path) -> dict[str, object]: + if not path.exists(): + return {"version": 1, "hosts": {}} + payload = json.loads(path.read_text(encoding="utf-8")) + if "hosts" not in payload or not isinstance(payload["hosts"], dict): + raise ValueError("exceptions file is missing a top-level 'hosts' object") + return payload + + +def save_exceptions_registry(path: Path, registry: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(registry, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def upsert_approved_exception(path: Path, host: str, control_id: str, reason: str, notes: str | None = None) -> ApprovedException: + registry = load_exceptions_registry(path) + hosts = registry.setdefault("hosts", {}) + host_payload = hosts.setdefault(host, {}) + controls = host_payload.setdefault("controls", {}) + updated_at = datetime.now(timezone.utc).isoformat() + controls[control_id] = { + "reason": reason, + "notes": notes, + "updated_at": updated_at, + } + save_exceptions_registry(path, registry) + return ApprovedException(host=host, control_id=control_id, reason=reason, notes=notes, updated_at=updated_at) + + +def list_approved_exceptions(path: Path, host: str | None = None) -> dict[str, object]: + registry = load_exceptions_registry(path) + if host is None: + return registry + return {"version": registry.get("version", 1), "hosts": {host: registry.get("hosts", {}).get(host, {"controls": {}})}} + + +def get_host_approved_exceptions(registry: dict[str, object], host: str | None) -> dict[str, ApprovedException]: + if not host: + return {} + host_payload = registry.get("hosts", {}).get(host, {}) + controls = host_payload.get("controls", {}) + approved: dict[str, ApprovedException] = {} + for control_id, payload in controls.items(): + approved[control_id] = ApprovedException( + host=host, + control_id=control_id, + reason=str(payload.get("reason", "")), + notes=payload.get("notes"), + updated_at=payload.get("updated_at"), + ) + return approved + + +def apply_approved_exceptions(report: AuditReport, registry: dict[str, object] | None, host: str | None) -> AuditReport: + if registry is None or not host: + return report + approved = get_host_approved_exceptions(registry, host) + if not approved: + return report + updated_results: list[ControlResult] = [] + for result in report.results: + match = approved.get(result.identifier) + if match is None: + updated_results.append(result) + continue + updated_results.append( + ControlResult( + identifier=result.identifier, + title=result.title, + assessment=result.assessment, + profile_applicability=result.profile_applicability, + description=result.description, + rationale_text=result.rationale_text, + impact=result.impact, + remediation=result.remediation, + default_value=result.default_value, + status=result.status, + rationale=result.rationale, + command_results=result.command_results, + audit_text=result.audit_text, + approved_exception_reason=match.reason, + approved_exception_notes=match.notes, + approved_exception_updated_at=match.updated_at, + approved_exception_host=match.host, + ) + ) + return AuditReport(metadata=report.metadata, summary=report.summary, results=updated_results) + + +def _load_report_from_json(path: Path) -> AuditReport: + payload = json.loads(path.read_text(encoding="utf-8")) + metadata = BenchmarkMetadata(**payload["metadata"]) + results = [] + for item in payload["results"]: + command_results = [CommandResult(**command) for command in item.get("command_results", [])] + item = dict(item) + item["command_results"] = command_results + results.append(ControlResult(**item)) + return AuditReport(metadata=metadata, summary=dict(payload["summary"]), results=results) + + +def classify_subsystem(result: ControlResult) -> tuple[str, str]: + haystack = " ".join( + [ + result.identifier, + result.title, + result.description, + result.remediation, + result.audit_text, + ] + ).lower() + rules = [ + ("Filesystem and Mounts", ("filesystem", "/tmp", "/home", "/var", "/dev/shm", "mount", "fstab", "partition")), + ("Kernel Modules", ("kernel module", "modprobe", "blacklist", "usb-storage", "squashfs", "dccp", "tipc", "sctp")), + ("SELinux and MAC", ("selinux", "mcstrans", "setroubleshoot", "unconfined")), + ("Bootloader", ("bootloader", "grub", "grub2")), + ("System Hardening", ("ptrace", "core dump", "randomization", "crypto policy", "update-crypto-policies")), + ("Banners and Login UX", ("/etc/motd", "/etc/issue", "banner")), + ("Network and Sysctl", ("sysctl", "net.ipv4", "net.ipv6", "ip forwarding", "icmp", "router advertisements", "rp_filter")), + ("Firewall", ("firewalld", "nftables", "firewall", "loopback traffic")), + ("SSH", ("sshd", "/etc/ssh", "hostbasedauthentication", "loglevel", "ciphers", "kexalgorithms", "macs")), + ("Privilege Escalation", ("sudo", "su command", "use_pty", "sudoers")), + ("Package Management", ("gpgcheck", "repo_gpgcheck", "dnf", "yum", "package manager", "repository")), + ("PAM and Authentication", ("pam_", "authselect", "pwquality", "faillock", "pwhistory", "pam_unix")), + ("Accounts and Shell Policy", ("password expiration", "umask", "uid 0", "/etc/passwd", "/etc/shadow", "nologin", "shell timeout")), + ("Logging", ("journald", "rsyslog", "logfile", "logrotate", "sudo log")), + ("Auditd", ("auditd", "auditctl", "augenrules", "/etc/audit", "audit log")), + ("File Permissions", ("world writable", "suid", "sgid", "/etc/passwd", "/etc/group", "dot files")), + ("Services", ("daemon", "chrony", "crond", "cron.", "at.allow", "autofs", "avahi", "xinetd", "web server", "time synchronization")), + ] + for name, terms in rules: + if any(term in haystack for term in terms): + return name, f"{name} controls should be remediated together to reduce churn and validation overhead." + return "General Hardening", "These controls do not fit a narrower subsystem bucket and should be reviewed individually." + + +def remediation_priority(result: ControlResult) -> tuple[int, str]: + if result.status == "ERROR": + return 0, "Audit execution blocker" + if result.status == "FAIL": + return 1, "Compliance gap" + if result.status == "REVIEW": + return 2, "Needs human verification" + if result.status == "MANUAL": + return 3, "Manual review item" + return 4, "No action" + + +def detect_exception_candidate(result: ControlResult) -> ExceptionCandidate | None: + if result.approved_exception_reason: + return None + haystack = " ".join( + [ + result.identifier, + result.title, + result.rationale, + result.description, + result.impact, + result.remediation, + " ".join(command.stdout for command in result.command_results), + " ".join(command.stderr for command in result.command_results), + ] + ).lower() + + if result.identifier.startswith("1.1.2.") and any(path in haystack for path in ("/tmp", "/home", "/var", "/var/log", "/var/log/audit", "/var/tmp")): + return ExceptionCandidate( + identifier=result.identifier, + title=result.title, + reason="This looks like a structural partition-layout requirement that may be intentionally unmet on single-root or homelab systems.", + ) + + if result.identifier == "3.3.1" or "ip_forward" in haystack: + if any(token in haystack for token in ("docker", "podman", "container", "kubernetes", "forwarding", "router", "cloud service provider", "csp")): + return ExceptionCandidate( + identifier=result.identifier, + title=result.title, + reason="IP forwarding is often intentionally enabled for container or routing workloads and may need to be tracked as an approved deviation.", + ) + + if "unconfined_service_t" in haystack and any(token in haystack for token in ("tailscale", "wazuh", "third-party", "agent")): + return ExceptionCandidate( + identifier=result.identifier, + title=result.title, + reason="This may be an accepted exception for third-party services that do not ship SELinux policy modules.", + ) + + if result.status == "ERROR": + return None + + if result.status in {"FAIL", "REVIEW"} and any(token in haystack for token in ("warning:", "may prevent", "may block", "requires reboot", "dynamic routing", "site policy")): + return ExceptionCandidate( + identifier=result.identifier, + title=result.title, + reason="The benchmark text itself documents operational tradeoffs here, so this item may need a human exception decision rather than blind remediation.", + ) + + return None + + +def split_report_results(report: AuditReport) -> tuple[list[ControlResult], list[ControlResult], list[ControlResult], list[ControlResult]]: + blockers = [result for result in report.results if result.status == "ERROR"] + failures = [result for result in report.results if result.status == "FAIL"] + reviews = [result for result in report.results if result.status == "REVIEW"] + manuals = [result for result in report.results if result.status == "MANUAL"] + return blockers, failures, reviews, manuals + + +def group_results_for_remediation(results: Sequence[ControlResult]) -> list[RemediationGroup]: + grouped: dict[str, RemediationGroup] = {} + for result in results: + name, summary = classify_subsystem(result) + if name not in grouped: + grouped[name] = RemediationGroup(name=name, summary=summary) + grouped[name].controls.append(result) + ordered = sorted( + grouped.values(), + key=lambda group: min(remediation_priority(control)[0] for control in group.controls), + ) + for group in ordered: + group.controls.sort(key=lambda control: (remediation_priority(control)[0], control.identifier)) + return ordered + + +def build_remediation_steps(result: ControlResult) -> list[str]: + steps: list[str] = [] + priority_label = remediation_priority(result)[1] + steps.append(f"Classify this item as: {priority_label}.") + if result.status == "ERROR": + steps.append("Re-run this control on the target host with the required packages, files, and privileges available.") + if result.command_results: + failing_commands = [command for command in result.command_results if command.status in {"FAIL", "ERROR"}] + if failing_commands: + steps.append("Investigate the following command evidence before changing configuration:") + for command in failing_commands[:3]: + detail = command.stderr or command.stdout or command.rationale + steps.append(f"`{command.command}` -> {truncate_text(detail, 180)}") + if result.remediation: + steps.append("Apply the benchmark remediation guidance:") + steps.append(truncate_text(result.remediation, 500)) + elif result.audit_text: + steps.append("Review the benchmark audit text for this control and derive the required configuration change.") + if result.impact: + steps.append(f"Validate the documented impact after the change: {truncate_text(result.impact, 220)}") + steps.append("Re-run this control after remediation and update the audit report.") + return steps + + +def render_remediation_markdown(report: AuditReport) -> str: + blockers, failures, reviews, manuals = split_report_results(report) + actionable = blockers + failures + reviews + manuals + groups = group_results_for_remediation(actionable) + approved = [result for result in actionable if result.approved_exception_reason] + exceptions = [candidate for candidate in (detect_exception_candidate(result) for result in actionable) if candidate] + + lines = [f"# {report.metadata.title} Remediation Guide", ""] + if report.metadata.version: + lines.append(f"Benchmark Version: `{report.metadata.version}`") + if report.metadata.generated_at: + lines.append(f"Source Report Generated: `{report.metadata.generated_at}`") + lines.extend( + [ + "", + "## Overview", + "", + f"- Execution blockers: `{len(blockers)}`", + f"- Compliance gaps: `{len(failures)}`", + f"- Needs review: `{len(reviews)}`", + f"- Manual verification items: `{len(manuals)}`", + f"- Approved exceptions: `{len(approved)}`", + f"- Likely exception candidates: `{len(exceptions)}`", + "", + "## Recommended Workflow", + "", + "1. Clear audit execution blockers first so later results are trustworthy.", + "2. Separate approved exceptions from real remediation work so they are tracked but not blindly changed.", + "3. Remediate direct compliance failures by subsystem to minimize service reloads and reboots.", + "4. Review `REVIEW` items and likely exceptions with an operator before making changes.", + "5. Work through manual controls as a separate sign-off checklist.", + "6. Re-run the audit and compare the new JSON report to confirm closure.", + "", + ] + ) + + if blockers: + lines.extend(["## Audit Execution Blockers", ""]) + for result in blockers: + lines.append(f"### {result.identifier} {result.title}") + lines.append("") + lines.append(f"- Reason: {result.rationale}") + if result.command_results: + for command in result.command_results: + if command.status == "ERROR": + lines.append(f"- `{command.command}` -> {truncate_text(command.stderr or command.rationale, 220)}") + lines.append("") + + lines.extend(["## Approved Exceptions", ""]) + if not approved: + lines.append("No approved exceptions were loaded for this report.") + lines.append("") + else: + lines.append("These items have an explicit host-level exception recorded and should be tracked, not auto-remediated.") + lines.append("") + for result in approved: + lines.append( + f"- `{result.identifier}` {result.title} (`{result.approved_exception_host}`): {result.approved_exception_reason}" + ) + if result.approved_exception_notes: + lines.append(f" Notes: {result.approved_exception_notes}") + lines.append("") + + lines.extend(["## Likely Accepted Exceptions", ""]) + if not exceptions: + lines.append("No likely exception candidates were detected automatically.") + lines.append("") + else: + lines.append("These items should be reviewed for formal exception handling before remediation is planned.") + lines.append("") + for candidate in exceptions: + lines.append(f"- `{candidate.identifier}` {candidate.title}: {candidate.reason}") + lines.append("") + + lines.extend(["## Remediation Plan By Subsystem", ""]) + if not groups: + lines.append("No actionable items were found in the source report.") + lines.append("") + else: + for group in groups: + lines.append(f"### {group.name}") + lines.append("") + lines.append(group.summary) + lines.append("") + for result in group.controls: + lines.append(f"#### {result.identifier} {result.title}") + lines.append("") + lines.append(f"- Status: `{result.status}`") + lines.append(f"- Priority: {remediation_priority(result)[1]}") + if result.profile_applicability: + lines.append(f"- Profiles: {', '.join(result.profile_applicability)}") + lines.append(f"- Finding: {result.rationale}") + if result.approved_exception_reason: + lines.append(f"- Approved exception: {result.approved_exception_reason}") + if result.approved_exception_notes: + lines.append(f"- Exception notes: {result.approved_exception_notes}") + exception_candidate = detect_exception_candidate(result) + if exception_candidate: + lines.append(f"- Exception note: {exception_candidate.reason}") + lines.append("") + lines.append("Recommended steps:") + for step in build_remediation_steps(result): + lines.append(f"- {step}") + lines.append("") + + lines.extend(["## Manual Verification Checklist", ""]) + if not manuals: + lines.append("No manual verification items were included in the source report.") + lines.append("") + else: + for result in manuals: + lines.append(f"- `{result.identifier}` {result.title}: review the benchmark audit steps manually and record an operator decision.") + lines.append("") + + lines.extend( + [ + "## LLM Usage Notes", + "", + "This guide is intended to be consumed by both operators and an LLM.", + "Best use:", + "- Feed `audit-report.json` and this remediation guide together into the LLM.", + "- Ask the LLM to expand each subsystem section into host-specific commands only after human approval.", + "- Keep accepted exceptions explicit instead of silently forcing CIS conformance where the environment intentionally diverges.", + ] + ) + return "\n".join(lines).strip() + "\n" + + +def write_output(content: str, destination: Path | None) -> None: + if destination is None: + print(content) + return + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(content, encoding="utf-8") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run a CIS benchmark audit from a benchmark text export.") + parser.add_argument("benchmark", nargs="?", type=Path, help="Path to a CIS benchmark .txt export") + parser.add_argument("--report-json", type=Path, help="Existing audit-report.json to use for remediation synthesis") + parser.add_argument("--exceptions-file", type=Path, help="JSON file storing host-level approved exceptions") + parser.add_argument("--host", help="Host name used for approved exception matching") + parser.add_argument("--approve-exception", dest="approve_exception", help="Control ID to mark as an approved exception") + parser.add_argument("--exception-reason", help="Required justification when recording an approved exception") + parser.add_argument("--exception-notes", help="Optional additional notes when recording an approved exception") + parser.add_argument("--list-exceptions", action="store_true", help="List approved exceptions from the exceptions file and exit") + parser.add_argument("--control", action="append", dest="controls", help="Run only the specified control ID") + parser.add_argument("--profile", help="Filter by profile text such as 'Level 1 - Server'") + parser.add_argument("--manual", action="store_true", help="Include manual controls in the report") + parser.add_argument("--list-controls", action="store_true", help="List parsed controls and exit") + parser.add_argument("--format", choices=("text", "json", "markdown", "remediation"), default="text", help="Output format") + parser.add_argument("--output", type=Path, help="Write the report to a file instead of stdout") + parser.add_argument("--report-dir", type=Path, help="Write both markdown and json reports to a directory") + parser.add_argument("--guide-output", type=Path, help="Write a remediation guide markdown file") + parser.add_argument("--include-passed", action="store_true", help="Include passed controls in text/markdown output") + parser.add_argument("--shell", default="/bin/bash", help="Shell used to execute audit commands") + parser.add_argument("--timeout", type=int, default=30, help="Per-command timeout in seconds") + parser.add_argument("--cwd", type=Path, default=Path("/"), help="Working directory for audit commands") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.list_exceptions: + if args.exceptions_file is None: + parser.error("--list-exceptions requires --exceptions-file") + payload = list_approved_exceptions(args.exceptions_file, args.host) + write_output(json.dumps(payload, indent=2), args.output) + return 0 + + if args.approve_exception: + if args.exceptions_file is None: + parser.error("--approve-exception requires --exceptions-file") + if not args.host: + parser.error("--approve-exception requires --host") + if not args.exception_reason: + parser.error("--approve-exception requires --exception-reason") + approved = upsert_approved_exception( + args.exceptions_file, + host=args.host, + control_id=args.approve_exception, + reason=args.exception_reason, + notes=args.exception_notes, + ) + write_output(json.dumps(asdict(approved), indent=2), args.output) + return 0 + + registry = load_exceptions_registry(args.exceptions_file) if args.exceptions_file else None + + if args.report_json: + report = _load_report_from_json(args.report_json) + report = apply_approved_exceptions(report, registry, args.host) + remediation = render_remediation_markdown(report) + write_output(remediation, args.guide_output or args.output) + return 0 + + if args.benchmark is None: + parser.error("benchmark is required unless --report-json is used") + + benchmark_text = args.benchmark.read_text(encoding="utf-8") + metadata, controls = parse_benchmark(benchmark_text) + metadata = extract_benchmark_metadata(benchmark_text, args.benchmark) + selected = filter_controls( + controls, + control_ids=set(args.controls or []) or None, + profile_filter=args.profile, + automated_only=not args.manual, + ) + + if args.list_controls: + payload = [ + { + "id": control.identifier, + "title": control.title, + "assessment": control.assessment, + "profiles": control.profile_applicability, + } + for control in selected + ] + if args.format == "json": + content = json.dumps(payload, indent=2) + else: + content = "\n".join(f"{item['id']}\t{item['assessment']}\t{item['title']}" for item in payload) + write_output(content, args.output) + return 0 + + results = [run_control(control, args.cwd, args.shell, args.timeout) for control in selected] + report = build_report(metadata, results) + report = apply_approved_exceptions(report, registry, args.host) + remediation = render_remediation_markdown(report) + + if args.report_dir: + args.report_dir.mkdir(parents=True, exist_ok=True) + write_output(json.dumps(serialize_report(report), indent=2), args.report_dir / "audit-report.json") + write_output(render_markdown(report, args.include_passed), args.report_dir / "audit-report.md") + write_output(remediation, args.report_dir / "remediation-guide.md") + if args.guide_output: + write_output(remediation, args.guide_output) + if args.format == "json": + content = json.dumps(serialize_report(report), indent=2) + elif args.format == "remediation": + content = remediation + elif args.format == "markdown": + content = render_markdown(report, args.include_passed) + else: + content = render_text(report, args.include_passed) + print(content) + else: + if args.format == "json": + content = json.dumps(serialize_report(report), indent=2) + elif args.format == "remediation": + content = remediation + elif args.format == "markdown": + content = render_markdown(report, args.include_passed) + else: + content = render_text(report, args.include_passed) + write_output(content, args.output) + if args.guide_output: + write_output(remediation, args.guide_output) + + return 1 if any(result.status in {"FAIL", "ERROR"} for result in results) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/__pycache__/test_checker.cpython-312.pyc b/tests/__pycache__/test_checker.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..374dd09dd48ff4bae52a05166ad9971145622f43 GIT binary patch literal 13405 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 SKmF6wKO0&%P{vxK-hTtrX+gyR literal 0 HcmV?d00001 diff --git a/tests/test_checker.py b/tests/test_checker.py new file mode 100644 index 0000000..7165062 --- /dev/null +++ b/tests/test_checker.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from cis_benchmarks.checker import parse_benchmark, parse_expectations + + +REPO_DIR = Path(__file__).resolve().parents[1] +BENCHMARK = REPO_DIR / "text" / "CIS_AlmaLinux_OS_9_Benchmark_v2.0.0.txt" + + +class ParseBenchmarkTests(unittest.TestCase): + def test_parses_real_controls(self) -> None: + metadata, controls = parse_benchmark(BENCHMARK.read_text(encoding="utf-8")) + self.assertIn("CIS AlmaLinux OS 9 Benchmark", metadata.title) + self.assertEqual(metadata.version, "v2.0.0") + self.assertGreater(len(controls), 200) + control_map = {control.identifier: control for control in controls} + self.assertIn("1.3.1.5", control_map) + self.assertEqual(control_map["1.3.1.5"].assessment, "Automated") + self.assertIn("getenforce", control_map["1.3.1.5"].audit_text) + self.assertTrue(control_map["1.3.1.5"].remediation) + self.assertNotIn("References:", control_map["1.3.1.5"].remediation) + self.assertEqual(control_map["1.1.1.9"].assessment, "Manual") + + def test_extracts_script_and_simple_commands(self) -> None: + _, controls = parse_benchmark(BENCHMARK.read_text(encoding="utf-8")) + control_map = {control.identifier: control for control in controls} + + script, expectations = parse_expectations(control_map["1.1.1.1"].audit_text) + self.assertIsNotNone(script) + self.assertFalse(expectations) + + script, expectations = parse_expectations(control_map["5.2.2"].audit_text) + self.assertIsNone(script) + self.assertEqual(len(expectations), 2) + self.assertEqual(expectations[1].expectation_type, "empty") + + def test_cli_lists_controls_as_json(self) -> None: + env = {"PYTHONPATH": str(REPO_DIR / "src")} + result = subprocess.run( + [ + sys.executable, + "-m", + "cis_benchmarks.checker", + str(BENCHMARK), + "--control", + "1.3.1.5", + "--list-controls", + "--format", + "json", + ], + check=False, + capture_output=True, + text=True, + cwd=REPO_DIR, + env=env, + ) + self.assertEqual(result.returncode, 0, msg=result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload[0]["id"], "1.3.1.5") + + def test_report_dir_writes_json_and_markdown(self) -> None: + env = {"PYTHONPATH": str(REPO_DIR / "src")} + with tempfile.TemporaryDirectory() as tmpdir: + report_dir = Path(tmpdir) + result = subprocess.run( + [ + sys.executable, + "-m", + "cis_benchmarks.checker", + str(BENCHMARK), + "--control", + "1.3.1.5", + "--manual", + "--report-dir", + str(report_dir), + "--format", + "markdown", + ], + check=False, + capture_output=True, + text=True, + cwd=REPO_DIR, + env=env, + ) + self.assertTrue((report_dir / "audit-report.json").exists(), msg=result.stderr) + self.assertTrue((report_dir / "audit-report.md").exists(), msg=result.stderr) + self.assertTrue((report_dir / "remediation-guide.md").exists(), msg=result.stderr) + markdown = (report_dir / "audit-report.md").read_text(encoding="utf-8") + remediation = (report_dir / "remediation-guide.md").read_text(encoding="utf-8") + payload = json.loads((report_dir / "audit-report.json").read_text(encoding="utf-8")) + self.assertIn("LLM Handoff", markdown) + self.assertIn("Remediation Plan By Subsystem", remediation) + self.assertIn("Likely Accepted Exceptions", remediation) + self.assertIn("summary", payload) + + def test_report_json_can_render_remediation_guide(self) -> None: + env = {"PYTHONPATH": str(REPO_DIR / "src")} + with tempfile.TemporaryDirectory() as tmpdir: + report_dir = Path(tmpdir) + first = subprocess.run( + [ + sys.executable, + "-m", + "cis_benchmarks.checker", + str(BENCHMARK), + "--control", + "5.2.2", + "--report-dir", + str(report_dir), + "--format", + "json", + ], + check=False, + capture_output=True, + text=True, + cwd=REPO_DIR, + env=env, + ) + self.assertTrue((report_dir / "audit-report.json").exists(), msg=first.stderr) + guide = report_dir / "standalone-remediation.md" + second = subprocess.run( + [ + sys.executable, + "-m", + "cis_benchmarks.checker", + "--report-json", + str(report_dir / "audit-report.json"), + "--guide-output", + str(guide), + ], + check=False, + capture_output=True, + text=True, + cwd=REPO_DIR, + env=env, + ) + self.assertEqual(second.returncode, 0, msg=second.stderr) + self.assertTrue(guide.exists(), msg=second.stderr) + self.assertIn("LLM Usage Notes", guide.read_text(encoding="utf-8")) + + def test_can_record_and_list_approved_exception(self) -> None: + env = {"PYTHONPATH": str(REPO_DIR / "src")} + with tempfile.TemporaryDirectory() as tmpdir: + exceptions_file = Path(tmpdir) / "exceptions.json" + approve = subprocess.run( + [ + sys.executable, + "-m", + "cis_benchmarks.checker", + "--exceptions-file", + str(exceptions_file), + "--host", + "lynx", + "--approve-exception", + "3.3.1", + "--exception-reason", + "Docker networking requires forwarding", + "--exception-notes", + "Reviewed with operator during CIS audit", + ], + check=False, + capture_output=True, + text=True, + cwd=REPO_DIR, + env=env, + ) + self.assertEqual(approve.returncode, 0, msg=approve.stderr) + payload = json.loads(approve.stdout) + self.assertEqual(payload["control_id"], "3.3.1") + + listed = subprocess.run( + [ + sys.executable, + "-m", + "cis_benchmarks.checker", + "--exceptions-file", + str(exceptions_file), + "--host", + "lynx", + "--list-exceptions", + ], + check=False, + capture_output=True, + text=True, + cwd=REPO_DIR, + env=env, + ) + self.assertEqual(listed.returncode, 0, msg=listed.stderr) + listed_payload = json.loads(listed.stdout) + self.assertIn("3.3.1", listed_payload["hosts"]["lynx"]["controls"]) + + def test_report_uses_approved_exception_annotations(self) -> None: + env = {"PYTHONPATH": str(REPO_DIR / "src")} + with tempfile.TemporaryDirectory() as tmpdir: + report_dir = Path(tmpdir) / "report" + exceptions_file = Path(tmpdir) / "exceptions.json" + subprocess.run( + [ + sys.executable, + "-m", + "cis_benchmarks.checker", + "--exceptions-file", + str(exceptions_file), + "--host", + "lynx", + "--approve-exception", + "1.1.2.1.1", + "--exception-reason", + "Single-root homelab layout accepted", + ], + check=True, + capture_output=True, + text=True, + cwd=REPO_DIR, + env=env, + ) + result = subprocess.run( + [ + sys.executable, + "-m", + "cis_benchmarks.checker", + str(BENCHMARK), + "--control", + "1.1.2.1.1", + "--host", + "lynx", + "--exceptions-file", + str(exceptions_file), + "--report-dir", + str(report_dir), + "--format", + "remediation", + ], + check=False, + capture_output=True, + text=True, + cwd=REPO_DIR, + env=env, + ) + guide = (report_dir / "remediation-guide.md").read_text(encoding="utf-8") + report_json = json.loads((report_dir / "audit-report.json").read_text(encoding="utf-8")) + self.assertIn("Approved Exceptions", guide) + self.assertIn("Single-root homelab layout accepted", guide) + self.assertEqual(report_json["results"][0]["approved_exception_host"], "lynx") + + def test_exception_candidates_are_flagged_in_guide(self) -> None: + env = {"PYTHONPATH": str(REPO_DIR / "src")} + with tempfile.TemporaryDirectory() as tmpdir: + report_dir = Path(tmpdir) + result = subprocess.run( + [ + sys.executable, + "-m", + "cis_benchmarks.checker", + str(BENCHMARK), + "--control", + "1.1.2.1.1", + "--report-dir", + str(report_dir), + "--format", + "remediation", + ], + check=False, + capture_output=True, + text=True, + cwd=REPO_DIR, + env=env, + ) + guide = (report_dir / "remediation-guide.md").read_text(encoding="utf-8") + self.assertIn("Likely exception candidates", guide) + self.assertIn("single-root or homelab systems", guide) + + def test_ip_forwarding_exception_candidate_is_flagged(self) -> None: + env = {"PYTHONPATH": str(REPO_DIR / "src")} + with tempfile.TemporaryDirectory() as tmpdir: + report_dir = Path(tmpdir) + result = subprocess.run( + [ + sys.executable, + "-m", + "cis_benchmarks.checker", + str(BENCHMARK), + "--control", + "3.3.1", + "--report-dir", + str(report_dir), + "--format", + "remediation", + ], + check=False, + capture_output=True, + text=True, + cwd=REPO_DIR, + env=env, + ) + guide = (report_dir / "remediation-guide.md").read_text(encoding="utf-8") + self.assertIn("Likely exception candidates", guide) + self.assertIn("IP forwarding is often intentionally enabled", guide) + + +if __name__ == "__main__": + unittest.main()