From 94fe87b4a25930dd1e25b347b935e6bbb445e313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 19 Nov 2025 18:57:58 +0100 Subject: [PATCH] feat(ens): support PDF reporting (#9158) Co-authored-by: alejandrobailo --- api/CHANGELOG.md | 1 + api/src/backend/api/specs/v1.yaml | 66 + api/src/backend/api/v1/views.py | 63 + api/src/backend/tasks/assets/img/ens_logo.png | Bin 0 -> 97549 bytes api/src/backend/tasks/jobs/export.py | 147 +- api/src/backend/tasks/jobs/report.py | 2347 +++++++++++++++-- api/src/backend/tasks/tasks.py | 31 +- api/src/backend/tasks/tests/test_export.py | 41 +- api/src/backend/tasks/tests/test_report.py | 1031 +++++--- api/src/backend/tasks/tests/test_tasks.py | 38 +- prowler/CHANGELOG.md | 1 + ui/CHANGELOG.md | 1 + ui/actions/scans/scans.ts | 22 +- .../compliance/[compliancetitle]/page.tsx | 48 +- .../compliance-download-button.tsx} | 22 +- ui/components/compliance/index.ts | 1 + .../compliance/threatscore-badge.tsx | 12 +- .../ui/breadcrumbs/breadcrumb-navigation.tsx | 3 +- ui/lib/compliance/compliance-report-types.ts | 71 + ui/lib/helper.ts | 20 +- 20 files changed, 3221 insertions(+), 745 deletions(-) create mode 100644 api/src/backend/tasks/assets/img/ens_logo.png rename ui/{app/(prowler)/compliance/[compliancetitle]/threatscore-download-button.tsx => components/compliance/compliance-download-button.tsx} (54%) create mode 100644 ui/lib/compliance/compliance-report-types.ts diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 12312c8bdc..3a98f095cf 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to the **Prowler API** are documented in this file. - Support muting findings based on simple rules with custom reason [(#9051)](https://github.com/prowler-cloud/prowler/pull/9051) - Support C5 compliance framework for the GCP provider [(#9097)](https://github.com/prowler-cloud/prowler/pull/9097) - Support for Amazon Bedrock and OpenAI compatible providers in Lighthouse AI [(#8957)](https://github.com/prowler-cloud/prowler/pull/8957) +- Support PDF reporting for ENS compliance framework [(#9158)](https://github.com/prowler-cloud/prowler/pull/9158) - Tenant-wide ThreatScore overview aggregation and snapshot persistence with backfill support [(#9148)](https://github.com/prowler-cloud/prowler/pull/9148) - Added `metadata`, `details`, and `partition` attributes to `/resources` endpoint & `details`, and `partition` to `/findings` endpoint [(#9098)](https://github.com/prowler-cloud/prowler/pull/9098) - Support for MongoDB Atlas provider [(#9167)](https://github.com/prowler-cloud/prowler/pull/9167) diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 6227d2500e..de54939f7f 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -8919,6 +8919,72 @@ paths: '404': description: The scan has no threatscore reports, or the threatscore report generation task has not started yet + /api/v1/scans/{id}/ens: + get: + operationId: scans_ens_retrieve + description: Download a specific ENS compliance report (e.g., 'prowler_ens_aws') + as a PDF file. + summary: Retrieve ENS compliance report + parameters: + - in: query + name: fields[scans] + schema: + type: array + items: + type: string + enum: + - name + - trigger + - state + - unique_resource_count + - progress + - duration + - provider + - task + - inserted_at + - started_at + - completed_at + - scheduled_at + - next_scan_at + - processor + - url + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this scan. + required: true + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - provider + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false + tags: + - Scan + security: + - JWT or API Key: [] + responses: + '200': + description: PDF file containing the ENS compliance report + '202': + description: The task is in progress + '401': + description: API key missing or user not Authenticated + '403': + description: There is a problem with credentials + '404': + description: The scan has no ENS reports, or the ENS report generation task + has not started yet /api/v1/schedules/daily: post: operationId: schedules_daily_create diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 700550e4e4..6d2787b41b 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -1661,6 +1661,25 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet): ), }, ), + ens=extend_schema( + tags=["Scan"], + summary="Retrieve ENS RD2022 compliance report", + description="Download ENS RD2022 compliance report (e.g., 'ens_rd2022_aws') as a PDF file.", + request=None, + responses={ + 200: OpenApiResponse( + description="PDF file containing the ENS compliance report" + ), + 202: OpenApiResponse(description="The task is in progress"), + 401: OpenApiResponse( + description="API key missing or user not Authenticated" + ), + 403: OpenApiResponse(description="There is a problem with credentials"), + 404: OpenApiResponse( + description="The scan has no ENS reports, or the ENS report generation task has not started yet" + ), + }, + ), ) @method_decorator(CACHE_DECORATOR, name="list") @method_decorator(CACHE_DECORATOR, name="retrieve") @@ -1720,6 +1739,9 @@ class ScanViewSet(BaseRLSViewSet): elif self.action == "threatscore": if hasattr(self, "response_serializer_class"): return self.response_serializer_class + elif self.action == "ens": + if hasattr(self, "response_serializer_class"): + return self.response_serializer_class return super().get_serializer_class() def partial_update(self, request, *args, **kwargs): @@ -1973,6 +1995,7 @@ class ScanViewSet(BaseRLSViewSet): if running_resp: return running_resp + # TODO: add detailed response if the compliance framework is not supported for the provider if not scan.output_location: return Response( { @@ -2001,6 +2024,46 @@ class ScanViewSet(BaseRLSViewSet): content, filename = loader return self._serve_file(content, filename, "application/pdf") + @action( + detail=True, + methods=["get"], + url_name="ens", + ) + def ens(self, request, pk=None): + scan = self.get_object() + running_resp = self._get_task_status(scan) + if running_resp: + return running_resp + + # TODO: add detailed response if the compliance framework is not supported for the provider + if not scan.output_location: + return Response( + { + "detail": "The scan has no reports, or the ENS report generation task has not started yet." + }, + status=status.HTTP_404_NOT_FOUND, + ) + + if scan.output_location.startswith("s3://"): + bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "") + key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/") + prefix = os.path.join( + os.path.dirname(key_prefix), + "ens", + "*_ens_report.pdf", + ) + loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True) + else: + base = os.path.dirname(scan.output_location) + pattern = os.path.join(base, "ens", "*_ens_report.pdf") + loader = self._load_file(pattern, s3=False) + + if isinstance(loader, Response): + return loader + + content, filename = loader + return self._serve_file(content, filename, "application/pdf") + def create(self, request, *args, **kwargs): input_serializer = self.get_serializer(data=request.data) input_serializer.is_valid(raise_exception=True) diff --git a/api/src/backend/tasks/assets/img/ens_logo.png b/api/src/backend/tasks/assets/img/ens_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c3e6433f31c087c14a09e9a4cbf06eb21578d332 GIT binary patch literal 97549 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¥YCEtJ=cjh+5x?Tti~To#Uev2Y5YMxK;d}hH4B>QoqP+xT#*efH<-8xk~Dgu z&5hnXXyk(ajW58>$~n#_#RtFMBgYF;2%@xEdF;ndutn)RF>&ZExZ>Rgb4jU$GvB!1 zi)>e?^^>=(dG1Mx-=>W>Iw> zY`Q0_EajjSqTB+cmY8+0)}u|;6<0T1z;3{C&Va3^fbZMRi@&u18^GBh{MN6Pc8O=+pg3-0_2IHW)Gt=SU`E9L;WGM(E!uSLcBhO zR8?C@o4j85xTIeoDo$K&b0*SObN7DIxM1Y07np9K{wC>9`~{^n;I4$QnW>jvL-2N4 z|NMc(_z%e|@^;!i!8mM2c~I(xt%TtjQC6&933($_YEJ$jdZzX9Xx4+~(h-p&`?lL*uTTl1Q3>FdyE3x8zgVSyO4>Q zk>A(VYZ^X$ELV6sa9dFJ4yd=r=n4$zEEE*pOsuFsx_t!j_w7S9#5Qov83Yy^t9e5x zbTv~yc-As*mhNaRKBH$7Jpj@WP=+8)HO0r}>jzrm`*dNWzu%FgCTFXxfx4E5_~j+B zkWA^HI}#7&z$$Pq<9FD#m8E+>N0T0-`yJF8TT4N;YW7@S&#Y4griCD?v8JC%`% zzvdqQ$g3K65(YBk9)(Tl%x7Jc`C>=TKI3QBE7e}#1BLBELk51>0)a<5rDX?y{AA`n5gebAC6XkIlFj;R_49w#!)w_4DN*wbWNNMNru1yNZz9?-Nqft z#yrAqK-M11k6TCH*|<`7MG9-;*GjFzjGyHC!C6b@6+qQs;;mdCJ^EJI-Lr10AX zQD$A#mTKytJA6+R1(bW6Su*8#8{VZSe|BE>aa|^k=M7Of=@Q$z{SLkGtFbXq-ak^m zIj;c4q7-|-(G*lP{7c4w7nyQfINIOYx21Q@;|P^S2DH7Txp?w@?1!&DMHvmG+O{iK zR3f@)5R^f_F`yLMxx?CtAnBUDFQ-`oQtg!mVzUwZ7mHNFoFy2x+9}9ux4&@#i0Kg$ zTB4b9R+j-}%gig#PAwrsbAs1z+4~0_=Yw67i~Us*%j6jcLGRDdCAp(!BZi#(NpY-A z4+rf62p?1GZOT0Vwxj8Jp6;=RO&3dN2AdmZQU#Gv(Z!-nlSOjG9bL3nO)BV65D1zT zmuiU7zWz2_>Z7|xa))n%(#phKV2Hh`rtq!?v-Pr4sw7dM1nFULH&^uVn@xG;klOt8#Dt**yc~A$r{fYXDmkTvZg6oVLpaVjvA7++j zL%2&vw%GWA~&Lz#W&>CEfyO>abMG!x!7rhD5SdhHI)pD;uDC&$Zn`s{Mu;7 z&8aw*s0EhCPHhsp)<7%jBVyM;I%a)NOkNt-p6`Yq`Vi#z>kDYWFR9SgbdQ$=)-Lw> zh(DuuUUkP{V&F)ZR5>MviO{Q-cN?<$-oeoUbfZbWlkuWYoKBbw+JLx??#^4_g^s$d5c^dq}K+bjeJ>j${8i!M{%MuM;r->jeMLod7QKyU)ZpC6?!g;z7V-X)EzyH|#9WxBbo0(d52b zzg6PyCYPM!ThfPnHx7d0Zf#uO-`~Igr_%fR8K74t_e$67?>f!iSfgobDR@(5b$9e| z1ZP>*qq_I|i{tjn)DY)|j^&qpd+$;dV97(C-M2b_)hxMAe@n3g8K=WRMrm(hRrcqO zwB(#e{WnrU-(Qk+maESVQx#K2_oi=5*YEy<;mm^P^q*X_GCcUTbWkL4$Ehm~nq!#i zm}7*sQ09?gJbGnR3P6%~OrC`UMdgt={L!0dIQycJET-eE(J7<77d4PbH64&)1O@0RYva%8+&0@8Q-T0Cc^ZGZr?zf^Dfn_#3 zh>BsyU)2jzGcIYtJ9CbWub5(IGUXnTs>FuMTKE|4wfUn)uc{L@9JLik1MQ<1jYD=! z@b+y9T0sW&$A=Wp>@v9Md66q?zZG{)cqYZ=AVG_nrk z)$YA=9C4D3E=KgyN7lpzP6|05>}@;9FsU4w!dDc~%)d^f7x;Oj$1{yN#;?M`>+}87 z)eA+;D}j#~9+@#3DbERa=_E-t_HYSPZ9>)yo}{o?ztRDupj%_HlH+h@pUs+xPPf1b zp6uE)U+WZyRG)8s3lpk3dVLts>I|Eb#)WF!qbF${%oh}&vv5Y{|3T0$G&k39pni-~ z*Df5jL`$vkeWf(G@#694_Ojb|G4^kuT3 z434Ywdk}h*T!qUB&6X%~6;b~@F^XcAX@Yby_@6e{*OF>Hkw@P0MX-e3ipkrh(0>s9Z$7LG08Ey(qO*?_z zm1%_D7dsNSKOQ~EGI)FHv-P=24)9K&$Mk9=v$XsvUavO??>{|k_p=&0=RaWObHdT@ z()7XJjQUK%`|FmWNx*%S7XW=v7b(ngHR_qcyj1n9pcyOMO@2bEfaV3P`u?18=k`oS z-0Un{2HMgiQfVg5^MyZMCe*bo)?z;NXWw_d^pj&LX}re3>q4i1S87P0)k{v&LzuqK zPcw<89g9z7uFwVzW48wcB z!_~i?Hh;0Tcw+h4PlKOZXRgeJ4z?XT4hB4z4rnKyTvRn!x84u``bPfqi^}8JRWwlS!jgAh6Dg$xsxdbpiK1g;vq9k64W~4!ra7tvm<4z-eQtv)9yc4y`SnH%2YDmp*eW zF-ZW|d=h|Y1A5;g$MW^_M~2LfCAr=Xt(M3bh~)5K|0-$p;@jTNssw|{~tVkbzGFu*0q$9Dgpw6 zBA|4~AdM0t4MRwyz)%Ayr8J__Narx53^~*g(jn4AcMD2~bi;SJ_rBj>_!Dt>_St)_ zwd3rElH`-Mz|nOq^{GK=6Ezh|mlDopW&B>I`v?Y|s9h zESck~Q8Ds^y?X4xkCmzsUGc#;C{7pZHCcY^Lk3=Xod)-nk*HT*oCV{JX^j>c8zxi3 ziYj9s;oi+<0dXpPp@t@5Y5LM&cB?gnH}oG@%*qp~i?(HTpIr)!Su<|qR}Q5ZM0teR z8k+-$ZpkiEt{3II6Kc9|%=P#BmXV?gxKTy(@Xm|Jv^XsLoj?AbXw!bN(wAf1NwWIx z+FKhY6613^l(uA8`0LB(d~K<$%oaH&z)?>?^gMl!57Q33v7FVlx}N#6Q@oyB&<1prY5eVY3c?cSdlb;zZv6duOwWH9wO{*P=3S^aq$_pZVK7*tpgyrNmxeUVhD z!ejKKWcaNL{#ew@M>cMA-X8?5dg%o{_E)s6=>Nv>@irW1x&ub<>5Ak0tpD4x0y7R# zUIkJ1pC(yI>|I59KN3-`-+PDeG}E528MpupM=q88yuH_!s!013@z3n_@Uqx^g zy$pDP!@{zdwEc#|kKX}pp*3Y!g+qIlA3Aj~R?nofGZ{&dF%|^1EEUymaR}3;QIk+HiglN78tU5pd6Bpx9|%9% z+}>8FVqhXR`S_avsH~$zVY|`T7xdPg+O78;OE;`H6w$ZZRkq_g*dqm!%4?WmDCp)=!B&dGgZ~Z zm_={Z3gx8}yRv4Bsfkk$Qhcl@RdNc^5sA)nV()PsGuBHiaawKnDmHng(!pIKf4+0# z&l+*sN%HNE@A1#C6|6jzT|W!{fMuf=uRs(&Ro(TM36&Y3@IWwIwD>Lrv$D)W?7HU; zj>?cx4;&f`ds$}Q7Fzx`bh%%O)?L_2H2=nbwpHu6rlZ0=rnV~QH~VohJUp%<^Gg-z zJ?V*u)-*qJX{J3XUD-QpFx47gNSM36KiCRDH24Jpw(?b;%p9F1D9BV!cAto-?=fwKKf=&_-(|)JvE%!-EK{ z-}PTEi;o29|w;!?Nhd{({>hVra=q7fYj)o--$kw9KLQzpR!G-sQ&Uog@+6 zyp44(yrEvi!Aw$d3mBhDAJ&4o&{O=VjVH%FR#GuK!X3A+l`jhX=Sd4sF!>a0Q_HLt zRKFKOd2b(XR>6?yA@nr>~m0%eLnmZwzfO*IeFEMBY{4FQyK)&lK<^ve-Dt+!?pbJi%&p< zr6BEWdf9)jlI4YoT#uFX*+fegZ{POslhl99I8YvAvDNP}O4Z?`*$riD?tnhym&!6E zOx@&+LVn0~j6F5xY3LKMC+BRJdizva;SW$409`XNVht#iC`DxOn&!)gIw4f|=BOk8 zgjGg)lXKqMT#Fmae!&>3HlcL(i`R!gadRs9Bll4EZ3aV)&}a7}^$Z7($6h|5rC?D_ z{*#r9w(MB`bX#oYaj~Tq&2?fx2Btp(?%6y}f2`4JpT5}>?P%7UCT;rW{l`}!b>EpY zch5rhgs&#@e+tE*Q5l%&Rc=&-6eNf^mP(s$gMsslzVPs@G2Ff(GgZi zSKT))f)RI01D}W$?D{i@{LEZb-RSU~vLIg`D0nRP>jT5Z%FFr#9rsl6s)O!paVwJ! zcDS?d;Czb34;hcwwQSbE&lq{YVak??vE_ZXu;bQB{*uxSCcdrw;VzQ>mOoF#8O)H1 zKP|1jgBJI0$7i7E@8ti{XU9#uI$tKK2Llhhi!1k4JenRh=KYwwcc@8T;{odrHpBhuP^V~q;n6Q^P4QUE>%@cnig8a}=X&8%WLyh5Ru{9GS;cr;P4L>RzZARX$p5>T z4i+lNoA5;+xz$F}o5q662ZOiMVg{cyprd6<&_&c0SV*5UQa-4cqh4swy@+BU>3iTJ zExOf`K+ z&9UR6Tp=l5L{|O%zm{EJZ{XK}oa7$ET}8$8ptw7|eOLcC3xLC~Qz<&@wTFc*v<4G# zZ_l)c{ClPQBE2vZ+VInTDaq>4q5nzYM9Wta?C#%g3Mu49sf2v;wsRo)r~Jb7vkgSq zT1>P&yZW_Qu?8EWUs=dJX8gp&f_!V6YPfMPlWoLYN{{mUg1)y@c35T%)j@BDacft4 z-8%yivpkzez|-)$Glhnq!&Qa$=k<^!8R*=*s#Qc;({LO3%h_4SSt;|>ys#dzF%h z%9eNT=z(36n90kWLn0!|ia5b;!7oGwCa3KdQ+pt0{~iq8*RLaFL#+H+NZ-of5n+vZ zBsuBn*|9E=!4yGq{y%v9*E?wSiD_-S@ydr+zBfs*y(d|?TUnn{>o%@qp?7QD!LHyE zdO1zVQQ}lt@OZ-(I(PgfFMM()(EvLvQY?&usm$fhsCv({SBp;$yG`~DoBF1+OSE5p z$}2V~rTQxI>%v#P?&gJw>p{_FAr+PjlRc)5(iQX`i zzYE;-`41xIZiV!qzV9VyT?T>%y^OMtv>K3s&ij&{byV-8UZ*CsQqq1-ku7hhV}JY@ zf7WAFe{TueDC~H7Nwl{_ag-?XsHT#g_~oYU8eX5!)$G-6Xu+M^7gxD6D&F&}sHT*6 z>3NNA>Vh-lACgZ`&yugCmTmxRJ1WB=dJ=Rt3TlD}+z}OB<5R=7-YBVl$^vGTPshf) z71P9rus)HBDDj2Igf3vQ>=T*C^NQ)YR5)^eh%B$!`|R=A#-BKxTBK9BrfgkHbz8p$ zO6U=IS0(+9-77@&W_|aUIU%e&IjdL-XJrt;JDRbb_1JnYczkY*a_k|6&i$K;zdQYF zxJsgS zzuImMF9M?b>g@E0|KL+#^2xU|bMx70YlX|5xul1;rMmVHBG?(^xHt%ac4wdVBWEQ4 zS0BOx|1&**)Oh7RG&jd^FaL%8fwuzprjlxsoK%_#0>)a?`?#dWMdjHW$4@J$j)^X?%nZ0H@HmFgNcwJ+itRoH~D;Wf^2KMiTs{lWf^1FAv3a;9tU zh!57iZBVj?g4ibK-6_`<`ykddZ0cAPeHf0kTcgv3q|tgN<`p;o99C$Y3X|By6|~(d zebdyYR1Eyb!LGQhG6@6ZG_iE8vH_n;R^g#_;``@&ZP z@kyuHd5}f&U1!J$K}E7pGDM{3OOG&rcT5r7AFibLMg<$;#MS(sNv8qvj53zXFMA|Sa*2r!pRTYB$ zKr_V3bQrAL;F);p7t~ji7w%h?*Eo7FvD7)$dvPi0^faw%!#g6+@mPLwoB8lm8M8=q zHMSp)xR?8qPy7x*ONrcQD}a`7?d)}{vJMNOHQGH#VAmser6oC1-}^&3$Ig;r?q7DX zG>u~~E<_~ii+Sv0~P;s0^PKcnHjuf^K>6((uB<3hz-*;%Io zvcxxZCEZ}*HtuM0zoR4T7SP?EO~KmD3^;QXDtWX8DXZt}&J}{;#a$pb|LYPVJjV-Z zZSknza>(5cmc1i6ypHXAfO*2BF8Y_ULACIceDdJENK6u9>r!3t-&|Fa2!r7OWsrFe zIIg9xOv0YirO^<}D<0J7Yb+cfz$=v0zZc9iLBVH;X6xF`AKoB{po(o@mm!>~%pV{J z+-PTHmYbaketC689K@%&KT#ib=~1rqpw`TKx*5ctYo3PD$1N8CAeRO4K{Hc{+-R$p zy$}>j0*&ymKhLLf z-@3s&A!mhaLBWLE)y8z!hDz#I=2L~5Od@#Q8At{lucqLa#p|l*ZF|_iik180!QcV3 zl;LU+)KfOZf^%7(qXThI0GsjLz3lLj{+9sD-kU;wg=;!kStP`b__WvOW)f?R@GYvr z?+*tqDz-ea3Y&_8s(LGTqoM?pIZN@FyaJ8+%7V@>%0 zS~TBIKYm9@Sz2)jDz`=Y<<^?dB9&}c|3{q8Rca>p3Jpf+EsU>@@ALfJRAx>zUZ1pE z_5P*T%H5ZMCqLb>{@{+&pZPGsUOwmLUdw|aszPh4=u%zY$!zqI^)}+%V>-CLZG$x7rlc*RAgmugArm0OxSHt+|R09#F$4nW17(O>O zJSHq}ZU2W7I(H8pvh`MF{RQKM*i@ZYZpEM|43+0-O*95vvtq|trGAD$R(!Mez%X@( zB)~QcRpo%O4_+C!(y0Wt>5+iW^-E-(9^zd!Z+vXI>Wq_4O1LK1>C5Jtd*s(amm1T3 zF9SP6nbu%f>t>%RXe}SA=1{2mC_t*}U1*&9Syxq%n%XSQNnPJ7R^*o(s(4XWP;weC z@A`>z#97bM)#cX-r#Wua<=Ubu<1>dlP}3s|=rf2eFXAm`XKLjYs)7Ap8ilI2v-1eX zv4s@si#|z;ZXh~KY}U&{V+2*bjrV%2=2|a$<~h+*@LFTO;l@yK_(%feGZ(}rWLbjl zEylgu9{qkoW1VZI!#LbM%awDh<3r+UJWTSz--wXwGp2|7*1NJtE%;|dz4U6|_np5| z{)Gtq3Al`O?8ED=BpKcrtyNm%-@oi9%R9q>S_pmks!{m$_mw|o`7Rm1J(X=|Go4ol zvzNY5s43pc_RD$tEkgS8qoCugMCJVbrbZ?C+YYnVkXhH-iQSF|m=oz@hT+@owzK1$YJNxf-~bzkZQw7fuXLr@zI~Q>N{1CYVEBNcb^n*9%Wjlw2i_48qv7Wl z?dPZ&xuo^LGL9pzCxK7Vqc(T@I7D1JPoqfthHGA-V^nXyNF9wqHw?Eza?@UfJ$+o0Zh0s{8`F!y*+8BNMNm+Y?WIqb3cf zE}bx-4foZu%&9l8n%IK%$T(YKG-2od8Je}pr}3ltR_7^JIV1sX7a4iQc0OX+ko*e* zS@X-&_kwQ|>OI8Ga%bECCE^p{Q{sD>C{QHDEbY;SaOm&^_hD>b@p!Onxs-*A8$18w z7W1xq^3OQO4dK@gdDE%2wH^yxcC)Nhr+4y)4XS~s z2I~g6!QhGqD;PUKAR9gfH=J@_q$d5<=icU6iR7CC8p0m=umZTZjR~7JU4`8qQ#w{% zCmcxfXE!D$uD_q^zO+%U-?%Zfu+>v;fhd z365RHrFK&a1#$2dTrwX^o>Y_;kK|zJc!~q6)WOVrS=0Dl>P>&hF07>XlAL6T`o&Ds zJ}Z{p(}`VCsS%({KXVqG_;B^^Cz*&)Wz^EiOI>kwsS^uc>UV$jBOKgA@5b#FfObb3 zC`_q=mI>(4$tTHHKV>Sply-y|J~AAB&tiiTxTSgQM!RD1Hn2=i2EW=Y=+rJU7P5PLc`;c)}o7j(smTzD~e+OilEEygav42f?*ydXdaM1p1tDMe!xKMhodS}K8GP`3g z`0mIzvH74c+2HCUbne_18uUw-mu`XM@QS%jUHCJayV9HD=z#$lQUa*KX#;FZNy34D z#-y8}Q()yf#G%RFzKW_;jK&xuRm^ zTH99F9wzmhZfR3+?(aZ1rOp#Tb5Z;3;xxDo1UOR<_lU?$4dF3`+GpQNgWe5Jx$W&} zBvv2huTVO2{VzX$BgTSPusG#pDIDwg1$LuCPM=ibu*ChM(LoUJLouJD#k8$dhj?HQP1s43r*xed6tNw)hX#&y-6rimRS+@X)N$UHJ$u_54LaoZ9cn6QKTY z1NSB^Zo}MRH{y`9xUS6VI4+N=!J$;ui4IPlDHx2j+_ln&uUZbUhe@9fL?jm!)#P*i zAgPi)UHN%OdYb-S6HW>l_cYzs&AOd)XV{`7C$=qZ#_Z-;z1dAu``OV+*0-3aJakrq zA5uyVuI#ef5vQo7{NhV?D}r7K~_oI|&#;H-1F8EEv@O=4?%=3ysA zGbkk7ToxcbRBbx?vim=+6SZxti;^%k%r@rr=Kw1{^`p=#>Cd!>KQR1WRI#c%-7Q-x z#Pq)S=oUoe?(Y02>SglJ+u}VNpjDhg;%Y4}71PjgN)7wj7oYU0))#*ImPxDCa!~*| zW|(l*+G;h|E7&t1Dw`>A55df$E&`C?tC6W!oHJrC4ZI5pmO5q3M_C(`>xdu;kTa{4 zs=o*PTD@|tfqn)?*Jx&~G;}y>U+Enl*E_1;AAAPPnYmf!={VytCE_D3cxg z%sHnC1>)N-PTQd{dvWgyyJ?NGo>r5Rp2}T8z$lU?%>>_zF5oUd%%Z61>IL zY~gppbwz+*kfC8}$$(QFz}FBo9D)zqn|rB}WOLILjtmS7?HHg41d=V~ivC`j6Z^n? z%@V8bwRBtP8Mm)2B?)+IXpbmJz&a;m9i4Aq_}UN4K^d%rmySoLfLfd{z#5vLy%m`{ zR86$M38>Vq!OWT(`~xJx!>e0BG&1|+-i|29eBz6oe9>o*ZOm(Vagl+V0S*y70qElM2fi8 z6SKeR4N9HqPtFX?yd963SQ)BB>^?1p|IO%S@y$K?#Y0~?kn zMBIv2rQJ#=ej@{|=H1KKz?%iE^d&zVXFvY8+s%u374yeFH%(gLIVfVsrL}MvgaEr- z50hY%tWxt(MqgG`b|p1kzjDNufVbxub%j{%Hhsg-oeuNtU<@oF6A|qyQg`^_neBd1 zYbxw0c@Vie^{Q?AIZ&|dm$u@!(Xhi!i5b#{{!<5-EFAJx%hc#&+hzUxBFs~!zbUAw zZWj|rA5LcwI{#(R5bth5z?PosypPl051m!&_}|n~JpCH z*9Fllavx#TB2K8&Am(o}ey}h5=!sR+pgKL_ zREf-}4a-@NogLIlFi{V=cpsl*eZN>Yt@xsTs2qH~CBtB&3J`;Rue;jyIUmEUoGr^( zn93@WC&r{Z6B~eA!-;+Z9`z3AuH=4BPmMtG&;TPr9WdcM07|z9&mmSWOPJE+365)- zah|daX>tBW8+I))ekF1bh)8{KOy!)Jri}Bxs6|}YGD>K|ZH|?Qk)PPkCoX{fEO$Z)kq; z_Uw|SZ_;iYk{onz=3}^skOC!#M0*R;CT6o<=M8PTlh&5btvsGdRjH1_O5_OMieBiJ z#1qIF3t4dgZ)#HedaT>{CeRs{$_{p+_CO&Ak_0HI%!<1qHv4J&CRaM!IxNM|B!Q<3 zZw0~Nz1(71228TDdIG}o;;&K8#Gu63#9hQgUhK!$)GjHdiROZTxQJcZl3<@HBs}(53@GCqRaHoU{1sGR(XqP zr|OU07uH~whzd2Y>pG!!wmWti!S86#e-K_Q{8eAY>`*mT>V?JI#OuNV-oYQWOkXFsfgTg!o%S;~Ha-S$$b`g5D8&j9`l191xBH#Arld$~Di z6g)F+#9b5)GR)y0*h=^NI8$8=_zOT9H7N76eoun^`@6LE_B@8;ClD2m z4oefc${Ua=HGqh!iI!_1-jjbc<~5$mC~Mp{zAmw#pT0FTwE26!EVH-mO)(1?>tcIp zu6@gDZ5>hYeTuN}KNZeBE>Ng&K+THn>C;ue+%sB*>A#Pgf(HV0nfhxjxCO4u9V$}H zpdS2puBa=_&yjJSNlT!>f(&fXS#xW?z;TsdPw2mYj`F!GUf% zyYxMr#aAoJSGn@tsvlV`nmwu?n$Z^{`Gikt)P#c*%+z)7{d0(=CgXF>H{-IDG*612 z{ABy0Nh5c`Oq%pO%8!(E{vV0;-Jj7iFFG1rK6uOxx^7?Xr8+x|PYuVDUFnW%09kR^Ak)?_C7W*rC=MIfP$Z`wq zEwhden@F>q=LZBbYIrHnZJ)~?q_wxK#%g3t^Ii~5yiDzQDTSpp8%l!Fb#(`);hMzQ z*(@!-Y7d)J5_Ei0d_!|FSg~|fWyX1>V&Xwx^{#=A#~c_76EF2TaVmC1jvi%|PZ>Ja z$4wRG8lx@|Q#|6gU~Z5Xo;uR1*pJDU74Nk7p{QOC4|^XQO6+J4GiHL3Eb}JK;JQRs zSdKzvc+u69WHdQyLIEcrgc+rMi&UTKsh+%oYm>%%N&1oQm>aU111dm0D>LWS^4|zh zk*tRh^Zgn1%_oEv!5|g?Gr>~RmX~ZY6rD{x;=1O?NJOdSE$=MUq;UywLT#*W`;sGiKMQ9bspGn#P3lko;qFrLbIKf~#=03AC@rx& z`?~pT8;}-x8xWf5XvIR4=?3oo5{_a@wrN_`yxmZ+K!bxh%w_BT0p~D{v&i>wN1yZ1Xv>!)SRG5$Lo_PA1&@21Q^to#ZKrS@@cyWr4%)GH5SBk$d7IN=|@g1OU#)3hKmi^zjT*;4>iLk3#Zb= z{7_B2wTr}EWNh`Y7i*UtJwTR7A?_l2OGUoO$Wk6y?9n#LB39X}X6&x~M=e9&j0iE7 z8*09mw?~D*qBR-w^VhI83IlM&Pob&O8?qj)d+D*$;198H3smxQuUyEMUi&eIz})ih zGkaWEnTXD7II6`RDz8=(%UJ|l=Nc5JPjEo>C2pFrP0P2;o28Dvv1|` zNcH6~o7%3)9?Dep39L9@11uWVZKQyaKNi|Pmv>aBr4ygylb=-M96QVRi9HX$gjY z@8>St`7vUi4K5E2SJ|VmPGdru&FL zj&u+7J@_}xQY=$QE)PM*jEU(pm#l^vyXT*HfpHg^q;@RjvmgKsMiGagV>x@2Ttos4ze+>F`G)& zciOE(9qgY3a}CPqarrC=q4}imx%@KxS)NHcskZ#!Xo@5+)Me3CP{F1UKl_}ICLZx} zD~RVZK<2P#rsVrzjupp|Yb2jHZ0G$1&=ams(zk{i*qb!As2N@!oEa%D910 zGlk=+w?q(^tnM!seVyrlcfZA`Mt=8MH0@u)Kp}T z^zTUgyTMcUucebL8n5KEkGunssnR^ut}7BBKl$!_69}qO&D- zf52Hb+~mT^35=WB@c=u@W_=r>^^ngZw&#-|F|cMD zQI6ihE-Btuwue)H5)TIq8hQ+zYW2x91>UgF$Qca?tJL0Y}O7s@>Kz@t^L_Fivf{<;LES#COk zS~1;INnEv~btlKrZwk%XcUtlj#ZIURBO+Z0#$8yvNj~EG=rk!)%HKKu>41O z|BMhWwpc4WRpr=@>5~ejHu;ECpo}n?-logutp(UbXuU7a1qocxd6_Abj0y^VBm~BJZ(*QQ16Rcu`vxoJ=VFgt> z!32Cy2Cb(ohBb?Kgq*r99M$A8ROC>dI}hTb3vdFso?~>(Z8X))@y!T7m(4w$C05(?9K5j48>;PbGBL;{a-$m!`RO_3T>xss=W=I8b$o~~+^Vre#mvPddZGJ7}~ zZ4Y0)Zu!jPWOU()&@S=s{a4JkHmjBX22-e>{Eu}yCRrE8axczi-Yb0%))@fHX-RER zLd1M>?A7)mVg+~sLdlyMfEtFO!(RE5Y6U9ACt;EH@ z5}?cJyDIJ4eyXetw{^`Z38DwGVnmoEsnU}x7D2P7By?|A`L`7Uwh{*p z(rymGb!v+8@B+XaMbjtpgKSpe<7ZDP*@*dKq3q11cpS;Jh5mw%hitKf%w`{QeNx5D z6cbgCwxi-X+5m(ImQ$T1NJg*^QMbAspK#1^G@*v741ZE6qptBj&&Oj%eDv@>TnzeQ zZo-Adar^Y_tkixAQfS)v$g%MP0tT782Q(@kT1ce%UY?XIR&1W9Dkp(lMqh=P}mp@PKW4Y7paX@(Pch%v_6F2l3>(gOXL1)I*b*B-I=}rFJ(4 zNG3UQFuTq9sEZ+MyU~|hH39~CdFil3{Vjds(E~mU9jGUjeENt@B{Pn!K~2$YAN2d5 z04$8+MF_0mB_#XWg}Djz0D9e!eR@zLKI~SS+ zK()cfksOiggJA_sSz=~vz5xmtG!A%M z#<;rXUbSrUR*LO2*|JLvcpKoGn$*4x2|5b+XSrkGufPx&9!|Z^JNfE&Xbop z5K}Lx;aB;Opd4$l(c`1TY7l2-$qPQk>Q-2=7vCB4GBCdpP}l|gGJVLQ$*r$&BVhkj z9i5hwH{x#amw8=U#{w!CrRm2gP->u5l$SFOP>&6a+6cLdndRn^t0mB3xdKUO1pIYw zyvV{0Amp&v_EioS##2k6AEOC{D|<@@PUl)z6mmX%M+Pz(jzprIm(i}qO&YT&1ySCA z+86fo=ifvFvhKCW?LFGB$2IW{+OK~s(M-e%@T+AoyN9CQr!nY2B|FUTRWh4pxrTuQ zOJYA2r>+^eKJZG*W4CI?t2ggmjZS%qOejou_-*@K&6VVO9ybT9rTp;6P$j z^QvaOjC1}xZa|n>xKJtaGmA*_hp|BGrI6(qCcn<&(^|H<-z%T~SBTQ#6?a&PBqN{^N2g-99BYN?69G&I=_dw4*TC$W ziaJajw3GGJ;jqogp(}$bpps(i92ICWFL}T zll9XvDKchO$A!S)v?T3ode-r2s5YhgcPjhc)OFrn)#TIbSEX<<)?(K(g@30P{;F0B zACd>(UmaF82J3R)kve{C;{G?Tbxx@HgQ8BR{r0J^*-=#k6Bec;`GTX2)>5LPu{)m4 zc<|4)mJGSiIT+i9OLK3a=c23C){jxpG`sguX)mWrhaU*A^a-VLjq;t8o3%P4vbGl@ zokqlWHHx3~C09RvcWP0G4Xq@edlh&X*G)c|y;73gOgb$5O$j4RtbC(AICDx&QeZGI2VhXD zuBGILE}$c(R2U`p@Gg5bAaLN@@xx!6pKG%JC<@+;xjEFqefx6|*F(PVLvUrf-(Rv) z1b?npfG~fK5uV@AWBaby85iKD(sVaTRUVVgmNS$+XbI$A=EMt}udQCLO?M@Ag{E@F z`uK0i!geIa^Q}N;W^^svsqJmczMbA9mhv@DF(H!(8@pED2Jah8mDqq|cf{WP$|}kT zoIp*nGS(w{Y?~S|n6CsOvMJ$Y1cs*Z#k zj^frDXAj&2MT)N}wV4yiOoPDr0aW%ZOq7UIeGJM>F}5|zzuM5rtCTAQ#Tpcd1w!X$ zl~|)A(y)#BXKE6rYu7||YRUTXQG?d?VK*2^sIwFSgM<~NG|=~~ABAcQk~K`1KT`|I zOz)FAaCKFF>3b(@k6Jn&1{I9me=WCFqpg|+lRCIo2y7aX zuJ~Bpq;a5;a@4=ElR3O^HPZsAbu?#YlKs^833^tLGGxUX-TFp{9X7~$9XIp_h}E*# z==D)|+qM|CDhCUwWAIxoC7uJ$@DLb>tg>sSvF$a4g`iqiqlHld9;*t9oFL}`V?!V_ z_l#J5Cl=>US;CkQO~*K;<$<2r382@zMLJ+U*@t7v2M~|6iL{EyCzc1)`wAbG--{fdqoQl!Q{^Tr4tNt(;L#gORa^ zBGd0a`+gmUjh=Qm4jlC7uUNz0yiWw&ED4C1!1e*Nkl`CPDl<0Iq;UrwkykhME)W2b zLi~v}W1-~6Ux{>t5gtM@1|@9H>|yZ^eDWb^TUsVtsq9V+2a{`R=H(urAEP-wb`)gO zf1_Nk9XXoi88Aoa$7E3pCLLSe>_5@Ln+fD9GhWHVZ=b4QkRVHc@D~&{U~dFd9_iHP z;{*i3?3w}uOT?Wbp1VqeK7K2$+|GDnRoW@s{mmizVZFW1fHtpH9N~ngw zYS2)ZWVFe$zBm<-r*$^IA|McPNvnlZTU1ubwQ-oD>qUW8G(F?`A0@@x(gW3fQ%_z4 z+XWlXPs_>13*bmLtIF3l-rUYCf@<16t=CO{kr8~G=H9R&6?WK8s-1{Nz?swHi5pnUr+bwY}$* zSvd!HnZnQ9&+&E5z5{yPMf8 z(O-v{?Vf?GVZ;r61X<*t$C15HDvN*Ml@FRxyyL$!Fs)9V{Psm&GYXz%;5VU=r$juaUHl@TiT zihPr}H8CBbA=3C}+DIA3H~669#I6O`+1D-JyACWu-A;(G12*R-P1ysrFZyCspKA2| z9=62NlE@iS7S!E_Z8gINR=1y@VTb-0Ionzqulh}M3ce<>iqt|qq&dO--dDmc(m#EJ zk3xPaL=g}Q^#$ta(iJauE{4g1jD7KLC@+>Czz3|?gw-Rv)Td-Io;`RrxHY^FCHQ_4 z)!ktsbv?a)J)5DYtn_uV_u&UF*{dOSLJaLBGgK3=f{Z*F4QjBS?jgCkOrzhvPdpe# zVDuCOfk3an6bylj-!}`AkUiSQ?R;Tm^OkNO0@6J@1De#61WA#ulV@!5TR+;~NbdAtV(XQZad{5v|wOViX zKf-4-#mbBN?^QVfNs#VuVk<{sJMJNMF2 zwbIr7chChdP6+b-eWCC`=9PYMS-iw{gu}GZ=~oEV>$;Dnyh{S3Pb%m3`)EF{>f79B za~-uuX39$()bz47MKXH2)-3JKTchxa!U?V|mYfNJ9xQON5zvdj`@;IuY zH&yvU24qZf2Wdtn`Vls3{e4yA;PR&f@&*Y z2L13ouL}dkgHF)XIC%KygjEHHS5>Cvels4cF$k?!bN*k{o9yt&*OQcvTTX7qX!$8t zC7AlS586qNp$!8GNbalHatC|uelv7@apkPu%*IAK=DHT3^&lb`FCbUM%DRfFe~l$R zGsyyfhSGR*4tJk^1jykv3D-p%+t{hknMwFVtC~>?AS|@#KuV1lko=~qOVNCIF4$)( zuP62<%{^{?wtEGBvH>u;>)@HB1ogAdO5F+4@l63R7m^4oq1XNCWzly=Qy5~Pk|ag_ zNh^PB|I+GeCgJh^B}Laq&~+Gbyw5;XySMGMrMv~vb^}7u*^LwHncw( z+=>nW^1EF1D2lyc30!=5mA|v^8M0Z4DEc`c>Ko6JSwa(F)3l{+Q=~c2lSjCzJ#2vP z1@(R-s=xF^G=nykfoh$SLS{kMm2QBpv;Qn{Qml`9fmVhNfFqM-T1&o)pqxiU!n?VU zA-^Hctfc+gsmxy--x3Ew(jOaL;Nf25m%+|(EK!ymA#=Fz-eH@9BPhNSnqav-boJ>| z!dBqWMZ8eziQw=3eC;WEYPKggs`!33ieHyOHPtcy6ras>rQd&umt)6`qQ8>;<9(`R zTF5`c0&=Grx7aZX5rST^buMt5Sj=JZmq6s=vr?nBvF{lmexM(q6E3K&*^@U*osd+k zRCgAzH{-puBL5n_+w`U6KH8m$BRTJ-q-#@uqQz7V%NMtNYoH__JjaX}=7bT@l;!6z zmA_0f%$%;wSIkoE3?GIm48Z|03o{MpFTEbU0a|;1f?(^FdILDJEC)-&T2AsxC5CkQ z*;5AD25Eh{Y*x%E7ohH6VUgrCoWpO~TiTIyhNEJWvO49=UuQ=m7VnF%pyrCt77NAu zZ2R`saO$7oCR%N84N!-ndPtVDUOZSr4S;k{50XkW=?Xy--HQ)Y^(H8)KQ!PnZI~YV z3Y56EgXv6|YEE&bxlzoUAyJ;{;{{Dh#mb$1!C&!ANIYJ)NDWJ>4e#1)+99BJaRPwy zaA3n`-b?aXL-pDU86wT>bQIPkvMzbFoGhiWpwKNH=j% zhfvK(kkw&L`dQ1IC6%W zp;v}hNxLr7U}{t;&)wBXL1+k3t5xOif#8s5@EbY^sgtUG)Z2d_Jm51T0C|r&?j$|B zrh*7>l{g*nH4Z8EG4=W^x6g(45==b68isc-v{2=5eg-6V-H7I|9)0N>QPl1e*zZPV zT$uN@-cX@fK|ypTo&)Cd=EqYdN~~`7N=*r4LD>Vh0s#*2C@_u}wKIxYFo|xEU3ahF zA)txQtBt;H*9MX1p-FB2g~JlLt`(QGGah;Q^TD??8n$P73n>t6*m^wf8tv| z@I+=mW-UAa+e@;&`blA3yXRT5Mt66TeVv_q=A-WD<8pP{=jgT}N+Cn>7nl(lIbGu{ z-79_Sj0xQl=0DN)fZSnBop65{nVEc%YkkvY_oiw)_NL}7QXS#u*@Da@P!Z)qe?^0w zC&;ABKqp^TXg7qJ+n~IX!+gYD;Q~pTmpMWFuW$Me<8sFxM$gfx5~raY;f{IfOaHC2 zzOVWo_#5AR6<3L^>+mto@ zRCA;c-!Ng!hD6Cnjz!JcOPu~HU8_Lor*oBZl%iF;Y~u^-T9psNA-R$vLU5QSe>P$G zBm%m9K``MC@m+VCw~r404;w%+8xeH z3P_(2`<<*lL8t8>#Ff7Zhqmby?1eE+fvQS@505{C7@6qHN1^j6SrU@soedM0X(l24 z@vI{g)}MwhD!o#SiL4YyT$hG#d=@BUzf`?NN>$#ps?!&ozZZ5RV3*hZk-p%!SBF~A z%S#ZkJ|ylq!CH@LQA2&M!oh<{?K)S?)-Dt5_Ue9ezc*wKD7a=Idg7L4wnBPTW=W_WwiZ)3IdWM z5(3g464D@{!q7c*qjV$PjdX`YN(^0sfOI1uLn9?1-5@P}H=c9u{eQTh@Pj&31?{s&Er*zI3pv)OUpbWxf9a=EbD*nf+|A?X#>$HB}E3-BPEyROTAw@N8;@?g>*L+PAOr-|*yO zo8lRfIy5GQPu|+fw<{AM>L{p_Rpm~z71JX!Flv|)@NeK!;wq+(l${5#7TsK_4bjT# z^*GI{&!)ucpdelc$K3_scFEJu+LUehHBY(R>`M_}?ua{}orf2uLD(D``J`)PV&{bbT*W$OeaBwe zU#$L``eOPCk1kinjryLd^9oGtp<-oosQ+7bFkAn8_;}Q2{&E;_BPkm8=Biiwk^oq#r+=vwreHp( zdKmH&@MPRNMbVTu%FeR3>V&&3EFr3;hddFH^mrObA9HhII*qmjHetCiyCkS}@w+F8 zI?aOPgm7}!wBmZLa14N-V@U-BAtR2T2%lvuJNIq?x}bNe*CKVsN5rDZE`}!oh&^bt z)TRXKc7&qEkwP)e0H39D&)5@?qtZ5^_bh6Qgu~3vKxb)oc6+>yo9918$uKN@EH8Zf zccqzwqzLdH6Z6iMi;My-d@jSXW!&cDB;^xzx^{2xxRtu7+LdCt0y}N2c$0(>b+VCW zKPNghT13hnkwdY!5i$&Qx(go%3&-2HpzWx|1XE=LO|sQ@;+cn+p1G74&*MHT_m6+3B@D{ zi5F6;QGSR`ND*+2wGa8JX6bNY?hV>4vTOrymr&-B#l_fPzx0$e_of<2l^}iX>GvWs z1@m!Oqu0h1tY(YMSjhex#m%q?>#cOTYSH=-d?pPNveZ{F`5EwlLc_21yNZD33BNSJ zuiMA@uQCK@SHn_KLU!LEnMOBn+=kXa$Nz7WV$EKA(tAaTj?Q74gHJ0)j+th^3_V4Z zM1pS%{jl$$2ARs_cm-n#MkHdc$c?To9md{%p)(x<@BPMa!(#YoCgU)Sra|kBBxDd` zL8MW6#-15CS0HcIq78JPSOWuns41&wtHyU3N9-ih`` zxE4HeclKSy8TK9m6-9NdwDR-oGStwzuDe7fi>Qczs}LW=AH@Br1(!9escDXJgJBXp8{ z*JM{IUgDZ@#ws;QVSZgW!I&m5q2Sh{D3}4H3wZn;8X~X2yf0YmZ`@82mv6$Y71>Ge z7p$&wFj_LU!3Ur|a}E!B41kC86#kdQ!_dLO-2vJx9yaW;APYF{hA5$w$t3vG zXb@@qcFWoGogr3w$~0JUnVhQcN}^G&v(`UxZTCbNk2>1E+ULrZhlj_#@ok$Q-ej94 zex1+7k1En_yx9s*r=OD$`68kl9snOLuo>3+ZK4UH^1`woWorV+Ek&+-e+?*{Ch970?bWsw_!uhKaYfny$ zCfa25L^`RaibQeP%#@SXpE`Gjv}y^P=^olum;CBLlwn6m!j?hqyPIGuXSaY}6DL*} zZ7x3J93#cn%J&}7h!58yIan_&6fP0zG1@$`ji*i%QG}T{{!i`yNb2O zyJ5QHROThaku$8uH^gE`G+_I(Xydl2?~;lv3b2UBbP6}OKz9d}Np{Z(*_Ml^T)>1t zQ=@&fR++oGQt0W%^jGmE>xk=BDj28Uh)F7H=T~rRVOa3!B0mm60)of68kx*;B7u4P zAK!bNmZH#z2khVM!xqJdMj5{gbF@A&8t*UkCk%UoR4EgPjTvqX9ou~hN^VPzfm9)d z`)?#Y_oa|xHqzl+DpUinJzg@OlhG{JL`*|xyW4F|yxCUT3>H>}H^!gLUfH!PKOD7z zhfi7yM}zS^p%lH2-G+&3a9vZ`nJZmk72=RJ<7!by|_f2#Sk3=jTI zE(i9c8$Z)QK^q^I4*FcH_=WU&C*DYrg_*c`{%uViQC-9bNeDMH%PgC2RTNLQnJF*8 zAMiejKJPIUrtB$XKk33-5+suhXsz{=Jg;O=z9K0@PgfAx2B2Y10^s5vqhZ77rP}we zKN5c*c_1!X^*HqXLv0VHmQ(tLSjT%66xq8&IIP@1HJ(W|ZG0oaYoS>&b$4e+oU_m6 zP5=C9_@W5OpJyDy*hrH<%d{WL-kdZE!v3-|oWt@DIz#$j^7-zMZtV|b8Qx-M;)-Dx z%AIv!hs`Qt_>$mr(g9IpT5;hj5n%_X{B(pF>!lydbllu9xAnRnfzOgKUwXffcGT7W z&})vf`9F>V53g2dm@-kl+S1vdBVlWs@DU>TO5hP$R&uX%sI})<5#~HbMZd=kX-a+9 zzioUfRsuX(@vGq-ih5*&DVGlsd*AArDwO_IS44#OILAi$4>u;?9fZn)P&K7b6wSW$h`I7UWP94%xn@#$$8To?Nvw z-#+MT(Z2Ded6%9&RUB`S7BZ?MHJVDVrs+8*!}!wnRUIwYw)%;$6YzrkPS!Mlbu;1n zZ9|nrLdF$e*)BhHKN-xLGU$r=(Z;^dfKfN#&Zig+6c<>%`7;1dE-8s9m?@c<>%?k} zh`?eG?AK8z|9LAiXIa!J8g5R7N>+~ClbI4iXb&Z(d zYA3T8m6xfh?eeE?DUUEXCMhb%8Mkq|-C8MXeuq)*V=yH&w37TK_55xR)p_16UaM_) zrg+dyk%W^;8%MGVnG;*!h9wY`?WjQM$mA}h(1HDFWZHDO_wNL4Y=qvcgjX7)CLtRX z=#8-n?r{l94e$7z4G7RlAqE?EN=eb;%L*p+dORS6vob5qNyy0EhRYP~m z>J75gzo_sI@@eOS4u+$r&CvE8bmH=;Jfnp@j`@D;m39JE`*Z$yH#teZV9op;Err4g zj^`tzO1gf2ViX>$czVw9QQdUM-%B~zmTP;@EA*mrYAXYCVWBJS8ZCn1W;>#fL2Jx~ zz4s|Yf#W&t;*Ey@7D@6sQYFZs9&f$a7y*6oDt;$slz4!NchRcFh5#Sa<5W+a zsAbu zs&t8|w$0V8E%D#QXGU()oN?;VkP$oByXWc~f<#6Z+iJu!Gez2s*d1HrJ|{yR(C~WX zc|ULTmeN#NT39Je0q9O20?h1xx=A1*7q?UUimCjXnpmQyJXrmlnthY!+5lPwy1gD7n%q zTQ31tuMfPM9I4d+BTgD`xGCnH_R5PgK;5>c0iuhnB!gGY>IaCK@UjLcmBT%ITE7Kh z!+lgGmY0~jt=?#Z%Ru~>zV?j3l-u|q)g=vR-d|XdsoXK=Z4e-MiuEHvHzT;_pw3-2jnDCBS-hOkf@jot({)P9!3TPE^nYiS^sgCOb!%43DvE9`_I|%FS|$@faN(JV{Mm^rjsEc^3WBJ;Msd zs6xK=Papc-7W2eIq8%Fb(ri75IVE^>sR)qnRl8QDxer4l%-gpTs;I?*RA4X!ukX#4 z!d-HwtOqwBPZl-@-DJ2?(OvvbMy0%3OVn;$XPt6-J;W=Q-tdSl{BX2W#njKj9;jti z${h^OmN&;FM>SOBom})9JJGuh^EXW!IhqUSn;3rGZf$ zsdDG;R}Vv@D{FBs!_sZ)#!gunJ;b<%q^MZp=OBs2w;R4s9iJar9kE@P`E!p%ipUYQ_mw{|>e|x8 z8#J;c<}^u$jM*ZVEvxjWl}DWg^bcffSSU)F%oPOz=vMWlg(wum-00bn=$(C^NLJqV zMM5Ph@Koxo$5QSbI{;8sGI` zzA$KZzfKhG=1w@(3E!b{=gb!;Z=jlHZy|4-`I=UMnPxj`YWM~*#lDeghT4g_t9ywW z?0TyJ`wsM;3(xL(WjMF7TQ|TjK9oXC^dDg>2wp}qU5|;?9DM{xIU^;mhEX73Rhg)!Cb{~qGL$6DwUXvs-J0})u>nZqUuabs={bo` z^Z>sAgXtJr?%n)15RjeVdgng>?U>{4qkd!?TWAfb73j#J!bG9RJ2nC!uqk1`Lak@g{U_tJTYSQ@DEFGfQ0O)8Q5GYGurvvmdGHuCOj;lgjl+v_bg67Xw zuA%T0peVDfjbSm9vqmNWvBL%GGFxzXCH|q4Wz<@|0e5Zmcg|Eq}Oq_7hZ3Ltixg(pDrnVtE+N zb5mjFmb!U35WwbOs^2kN))h!L^2(WaEZn7#vLX|+w61HL@D9rN35^*m^JQ*TMK*`# zGsg{DFtQ27e;vpVPy79xaok~SY>c{vtwT47uZuD0LwV%0;XWdOWQnG1rRX1?jZ$Jr z30faqFk^~c=S(b6W%+ax5W8EGpWSU94^SxrE1WASBwkIIqy_Yr)%P6J05eWd*yBgU z18u(-@r2LtF?8iha0d&xC*9&UvYk;ANcQr33gIP zKF~kR?73oQ^7?o_5$k+)PsC&5YFQm1z3(dm-wsxK{N#tH5$p!8(-$V&s5U~}E*l>6 zj!>$ZQANJ2FSe%DIE)8So2w){P5Q_W&P7ls{Pj&<0VF1B8O?-SeCBFO&x zvP~g+V?`WwcE17R(ZV%%29iuAFDl~as6`#U16n>_v}_AoPG3>UF@eY{dPK^Yb>@w| zTpr7|7#It*!XwryFO`w}Gjn9XOi)&iHCd5NNn))ftCKT_D_nsA@v8F=J~9LU`*QAHTmU}!~ACBTO0FB4e$(P zunB@Kg(H=jtpA$Y2mzwfRSXTY2QBGN7ks5X7?8d}WL0AW?CthbEf_Jl@B+Eg6&K0i z;)-+d$rH+_G*RTTgl*Y;V7?_ZHnba?NVlb#oDzAh=BKC1JPyEc^|0CqTO z4wb6~E@Rz0nc>l`doCYzhXRddKv;(1Nj;YvDu==rsl9@({tTzb^Zgd;5X2Z|;?gp3Yt4k`+W5j9S-2|iZ)#(og0U9y3ae`^DAE_^!^(FhutAU)}bXXAx zqDqqh&SP)~j0y?>uuB;Xp8ui}Wr~@$*Ti(OQ5esgeB}}&{2m;@J@xN93gEnlS2bYK z`{TfWnY|UE_Td!0yFO0LQrxl1uAqtyZVMYbLG$eoc32~iRRO}MMRC|ZjBUR1hJ23W zZzvB|bS=8=JTt{c#)%5gB7&3pE=%4IQFfy#*8SVsFL1n8fwVjRKSpG!!Wn=i(xjEA z8-S6p0jn+gdc1X$1Ie57IbWveG)mUnOJ;`|(^TWh67^Oc;>ioW5B>C(+B-6%2q_8= zlxO0iv*aFa7$O+hZeC{#LKiOQYfYX|yk zz?!Mun4@r-JVn$=6^td!(0Q`mm>_On$W|^l4#k@XE0~wGSh=?{=RClJ4uf=sMTuW} zqfFI~^Da|M(CkaKS#N1-t&#Hjg*0cOy}qFSud_++#h#)vCA9|CCgB_B(QBb5qFX-_ zMpbzL=A8T4ZE8dm{I0%bcE`~z+oIILdnHU`L;|t^`TUWiWe(?Kob&S6`%zm97{hL| z+V_=*NFVnXcA=q)g%fRyAgI@#ndXPY{y7<{0zvGEIx;kv7No{yN33)|UPA9S9Dp^6 zN)K7%s89hA6BL5=jKSihA1D3sdZH)_lxGI_cR_qphX|C>O?3QXh9Ft&up8g!JS3?? zutn@1Im>M;(v1ZSI%Odj`9jls_3EAEws%)Oa={&6&)sBdv<KC7;WCTje7nd;f#E4Fe9 zk+>`aRpuY8xYR6n)c?&$;ccTR2d^oOiaxH9m}dll@`w?$yf@BNeetIY_iD^0;i=yD ztvwIvo~8?Ui)z`d3X1dgL~(2MNN;0|T3d`P9oSLOoa9lN6}6B{xv|0i+TusdA5JrE zm1!F^wFukMDfZWU@3as4#md^32&O$oZ7$YOVTcwgU$68V+HwGA9%W)rT*?^-ncb4fMzU6K#BUrCJzST(Y@ z{(VR0H2AVru7#fE4+!)B2-ZoAxLJN}OvzuGcU#ua`4tLTI!cnp)0-wE0yw3FY}#S!N9Q6&zLQbF|Mexa=*QW z1TRFoQq$sthPa`$4_M=LbehUUo9sx&c+Kc(=yi+_8)fYRgwnOn zjqyM7P8#o*6BLz{yoEp@zm^D3km_n{3BfjpUXZ}QJ2x@&r7lyHO0_ftxHBEIuv>)_ z2|qh6j*P@{J>4vTX7iZRG@t&|b-%sXIQ+X3d(~(#dGHROY7=Dpwtp8r`x1rtt@52KleUtUyTx^HHt8CfndDJ_dAZodY9jY-WNE7#-%<-VyM#YY zx~`fDR5+!Vo0B`iK#oVZDew8DSQ9zC&|8>9_xrj0OH`q|E9w*B4^2Fg9&7&M#Wn09 zd{0jac(Y6DoL*G3LAd8nbi{WqWKML5(K2pZm;~<3`>v)*mw3x3>7tdIK2+TURQ zZO})ypXw%XKkRuXW&85wm(jo4u%g!`%^clULQ?zGJ`G<@e-PdGG@VfeUC$H}MG6T< z^Xqhy-h3#!ElO-rDiQHJuD5@EN12t12KZT=WOUn9L!&g)QJ%T$VRnfdw^o>y+}Z`h zn}B_>yJwBQE}8Ohb+d7nir9h~ipQL46Az?LkAO`9IPs#5m_OWZl``p^oYIv|WY?RF zZw>n_X~VxD7at*UN7qx?$-VC);b-Rd{rrQyY4E%@9-lXpmOpjOvEFW;RI!jzdKzTi zCHyq^&xPGHp`w2~a3k)N2z9=trjIoplG_Nbywh$g&do5heDkdW=p-gtP|;vY7{mjj zw+}!09$<154xG!XpnBe2J{9uygk2v67S-BvL4LB>Mvd6W0gj32kcT&o4Z>HfR5o6( z83X^PA5`}vW3lrPfJ{c*eFas-5K0kGC;$}8!nmyQ2OMDDyke6r#wbAE5;7=o`Wl^c zYCl(}AR*y)u)s$3uGmJh_;J?2;{v7ZnBLyrD5do1wVuDX)vrd6ckVPoKRLcD-fI>6G^)mdoWixZ<~mjvEsz9y}k;x9KMu;6VP$a_ihnk-o;`cI$x)q z^-^S@7$!Xv2^eP_NWFSnx@Uwffl=5joas;8J(hN^Vp2aiOb7d}#|m`4!e}|SzElLr zHoEzbHpL_->>ragN;b3PRYtZBh8kn(ha3M-3!t;DgqQDo!b{!mP)Q>0oZc(g?ejoj zC}%_b_?Z)5p5})Ua&n)c_ro8<%&O#xXFMh6#{iqrE4OsEm9Qka_1S zCPe9peYWuUB0ewoiEAlOc0dXmc3YmO6A+GYZ$Fu(v#f*Pd83wah>C^2I;4k{`OnV< zWg1?^#kH4wdfcR5FtyE_^$01H=h#w10wm;s6qPMfgez%=qNPQ+S-My6B$L+P9{CiJ zW>>yUk`LISUX7z5!#U?1Ce}f+`v~pINXulqCU zOO8u^Px-~mFzbo720!k%{)7+SK0In%60Hv448}+bRUsDeq_|<1FL;RU*7bz{%i3|l zYQ!B?99#AZ*%N7N&#X)FucL%^P=u858`~E!HLmV6e{UV>v+Jkti!Z2@neG@W)C=4` z-F|21GGCqOO!K5U8fp~XM>_$rqTP^Z`bpG`FI`gmZBW-GHA%7KPaSV?wXZ>7aD+PHmumdhyW(+W z`w;81&iy1qjm>gsx0E<&u+bvBM1)ZhXTbebV`Byc+JGsJ5yCQ+OQH0AN8*4AUZm;O zR-ETwthZDJMD!g(FX4;TeQkoBYH1A}VHnQQYs%RXL zv4sF*Q#t$1G}SGNwNK0B5Qt`oA6beol*GgX3HHn1Q{Vx5O>x$Sq`C`K2#)TNtsDLt z>g^Q>*L{`j?4_;mxdjLJmv8%@Sm!w{%F6GtykNrg@itO*x5~7>3)P=m3>~>jP_*7M zb21Zodr$(D0!iGQwU{}!K#8uc9-8ugIlx?iz4TRHV8@rM1v2kG#~({4^|_a#un*~5Vt^OAMme$n6_&}*7Q)TLghNRp zMc9(`csMkko{Wjp`!%s-L~w+>7G6(2lSM@Y=PYM1GinAk;sq&Rlud-ZER>Wlz=U{` z*lu-{zz_OHf~#Wr%X2n3dMQpK4ZJgZz@j)zQT98_tsOd)r4r7M(`Y~aqH$E!Shinj zK$>~?A>Ko|EG>Tc{`*%1f;e^(nieVW-mp(&&Bv7Dp9Nb5FOw)-L1$1X((`iC-~$Ru zP7yw^an26GZ@v57+4UOnF3`4nRzlqc&xE zkQ%RCa-N?+=9MkqpLsgPTXpmlin$^+@2-dsHTrH!88ps_QxLBUdi*TPQfDg^&ipBl zdr71>!!DmC6{>`VGKu;>Pw5_XMOCDT>njAR;<^`vt~RM)(`k9~0!Vqk!t&Tm$59PK z=0~Y>y=gKvBnZ{J<9u->DCD3eG^rIY>7k@l_?r)Ms4WFPUUiPXB|$~tNKrvZ+2-FD zX@Xo;^a9S5B(CSm1z?FH34L?0uE4R}P+9`elK(_8NK;(|P6-{Kpyaac?t>xK2K!&!*HpT;OE2+43SOK=_V z2LLdMHKHeV$hZ*z&Z_5>tjf;2FZ&Ij47X2V+T4u^e}cPIy;6%P=lqml_KLx%=$k>_ zj3nT&?F5acjeo}#2ZYH38@K!6eBY}jXzS0ONZ@HXdAmk8pXYLlZ=vf^$rJG~NrQFV za=tGYZ!jP}6F%_Vw6QFGn^X~oW8sp~iPE@L%=L~%o{9uQs{2Wm@ttgs<{LF>Hd;7Z zA48)*`0c~sMyT#8!ig>(g4iqJz zBZQdd&qGj2Y-2qX?E zZitAHD}X-2if1M~AM=++tx^74F2=Bn(gJ|0Z-8$VoaZyfj1}daviE!;u*hzPngCYl z6o54}%UJK#jJ~(c$KJTzG+qBTAff&Vr0B&pA9H55i|ZTAGs4y>=kZoDFRJC$jW!a7 zJMI1nDw(YW?*TH8wdLc_bE5P>q6wdqob*WH>DY2t%GyaVLm}PBv+37|v3R)8gRAT> zdb7`f;Mi`skRsbVV&&0Z<7D<%0#r1~cI1U5YVk5$*=pP}lJV_t-;o8BC#&=rJ&`0D zwWBqn#@KQkH?@?PO^u%TDBD?vcAWE*e&CzX+2<1%69I@ClvG%yi#duOX3U#yT(T9% zpp6_xhVhJ4rU~b{syeQ0=(xh73!?NW!FWVvI!SifHs2u0Ig$?^vUq7o+`z(;=3lc~ zpH_kLVpLOH6aabxV6&JvFJl%q>u1*22N$CUflo%O}(HF%|-u~O6X&S z5pS;SE0eQ$iqi=kGb1yRP$?iw3Y8u7T^CysflzQD2&T_^JgWL^>ga`YuPPNLYI6mbceWwK!_v*b1MeFDyg!XUdajpZ-!lKIT|zO z@OK=m5+TKN1DZ<`BrMctH^@+(7`eZ&-$mx{=nFO(kaV|qcJRGuDKZJe5|L3Tn!u~| z1x#52(e8hZr=2MbXj^GqQQx+-OgkJsGdfUd5oE>cG~?zhMCy93X0P}!48UceT(&mz zc9*uVxHQ`5skdn_GdOs}1I=UT%1p5qb&{XVtS#;hv-gU%e|Fj28SqZ+Pt!#2+(*?= z1-v!=k=2=WxgIsHYsd*AOXhvKGUqs29#gdyY*tD7#)Mg<333T-O;abkmSPimyp;nAwE-I5JBaNB9CN;BE)s}wnk#n?r@_35{c+YT3_+ChS zq9-k%qk+^~;xX`jK1EZ%5$`G0fk59_e}>4;mVf)W*&}nu?&TP9VXnfHu+Fg~@bXvj zy10_sR#R?FVpJ7)s#Ua+5@8un`7v46AFO7bLQOo3gtlD!qa5HpS!@Qqi?77&L%O^tADqVH^A(BaSTU8X@@*xnj zfvQ8wc^$)(FHwc_Fk}Er#rNi^n)mui)M(r@Az#!wztikavty%UZ35s+2=cJFf0(py zYY>x2;wAKvC3iedKa`tS7vs2DTlH~!+b&l{gA~j^oGMMvdo9d{owvCs^O&3PU7`0@^wyZSl0-e#G@h zTZD@)xAhZG%``-NnGm+UT8p5|O7XZ2>&fNtyF=8P`Pq=`W3ixK-HIOqY#tq7SgZE1 zs)rlnHJ|cjYhaApxP`SzA%#i+o46mC@uJPF*HvXJP%y(C^9Ic^GLU98=nn>WRGim) zz9{kFR04HyX!%Onb_Cw}a>BP{jQUc4lfJ72o)9a(+oIQ>Ei zfdQn6t#NHWKa)zfQn%KMFn!o@{{ajEVmWc)aQKQhgW(Y7U)=zSHb!9UJHIWQ-Q~MG z=E81m6t&?4MpR2YrJCjkb&@sYSez!zP|xn(R8x*Ruu!LBPKe3xron5mevZ@MI8)rHmEz~Ym%pL zLI(W%XQM;f{^{8wFNVt?v$1Eyg&0*cQHgQFQ3r(56JE%GSF!hzRdDFqVDA{2#X;Xx zQ7l>W)`bsYevU)IhN%Nt9|*W1+QJ^RYX2iSS@BK>hg9Pl>qy6}Ac;s=+n#{<*Ae@f zo|h|ubYGdp)E2V}$4o7^~=rP5<{M-N%EcOqIi)DDzBcE}AounBZ@rZ93ToiQ41Yzgk zX%4B)`rHklBA`Q%O>&s3oJ(d*$!P3#r7nT*Q*ap7duA8_yB9M;OqbT4d(iLB59)-6f6}_E9u)NE(b1BnKzT_o_Ah&Fsg`#9gwI@Qu zDW1VeGtRmtt1qsZ3M%!ot|3R*-i5C>#_vM*rTp;hQs0H2=xEmzPr0W}q~p5=+oe+$ z-|^Hs>w+PTm@ogilILy_Qz6iGro32NyrV7Pkw4C4H&6(>4^U&=63togZT9MS<2h(&N=$^6#gqTc>@tOdyVeKd(>QTcLp zRrQOV!{d2$UgDnCJ2aL(>)DQ9DB<9eh!JWofrXb5y=V5f zXJ#_CC}ed0Z>yCyq>lv+fJji3+%?_AXVafLNWN_4^KRoKPPn6F?>^=|h;pgC_<+x_ z1v|)Zk@#gAl+NgpxOiu<(9Of#S7A}gLYzn7O&YOF#vqHs8w7y{C8vXU_l%$*8>{=W_} zfnDxI|AIaDc12Q_8Ixt+-h|ywr&1cPZgo-WlWNT+$|{=peF)U((d?V&sXt^PXph1b8D_*i7k)O7)E8_I2lnVn)Ns zcQmw_UMBhDX$IjVUS2&bAUS8|lz8Fym<@1yE2yK}CKuP4@MUVCBB3bX*w^Rm;!`SF z<9Il(Xc|o+7TW{r=`A8?(veXnSo>={7Zsgev_F~j1HSTlc!VJGeHfL?F|2-5lECLI z@K8xuAqz}E3Vd8Crp~R6kIUN(qC5B(L&n9J0^WAI<9$h?D7}SEq<)A9#Kk*I*{po{ zurp(zQAVbO_c*W`O>Hv#sh;#FVXkrzTH7A-;Js{0R8m9=WBkkDH!eE@^P85_%`6{Y zKxF&>jwx9vlS%_8f4lfHh1z$S)3g%y@o0laGtIA(Io5sX(1Bf~2CB3~i;lPaR9*5| z>cu@L*{}HqxATBd1o?yLayIFZ)|q#iby3@5D9-agAj|6g&E+YxcsqTfAlMT|<}#u3 ziPxH_a7c0e$sZ?eB!vL2)}(ji{Zb4Dn7fy2T^21{6fZM)OVM(oOZXW@G(fryG6)!& z1D0G?te$5RX9lo@9puo63Srz+O+%8(ts?=;-1eW4V`G9R=u8ALcBBpQ3mN~P`x<1q zeUJk&hCI3#_G2TBH^Rry=EH!4@uRRp???6qsd#%r*8LV{is9Juwrz$`03k5S#G<6Whic>&os5EyDmNYn?LLxo~i{ zZWA`)0fYfw+oS*yQGEnh5jsGB+bdqSD2>{Jkd}GzycwOf@h28(u}7s`^|qK&LePEF zE_^f8W_ABT3qwQ7I-8lmtSkygr%S4myBCHO&%bu?Chj{GDrQE^&gvYplE_~gJ8kXV z9?fe7Xc2rq54gOXU}X(6we^lZ$Fym6CGn*6Bn@adCH9qr4_VkcQn_La@G!9SFO<{3Z&vEUK3=-c}D)=8#Od|>al2=VaN@Fz4v)k6BukAN!V^om9# zpSC;0LoXwPhoOtg46lKy(K^zU2Rdq7&Xi`GpIyi+UV1#yS4Xeuv_l~J0T0!6!5Q^& zGxLsM`2fT8l^QNcwFUYHE&!iM*6`NWu`tx?&Q#8phxM7Qevzx1?5}gdZB70%*IB^* zzCP>O5M~u}*#p-0`yc-2n9XiCwwqo@^wVCZvSp76NH4CY{c}K{t_+gq4zUOA+W++0 zqWd1@eY&muV!GsB`CxHW4mI?={{44NrO80)6(7f$+ms!xg=d^aUHi&6(iAxiNxV7!??V{V3Q$;V#PDhJdTbVrjI;*^1Zzy= zgA|u^PZXY#AtzUnx8TSFM<+1Z&y?$bIvv38V_}ndPw!#*g=ysQqts}__SLd#X$eVA z3y_}`*UAZ^bJE=^{IuM@5r&x5=u2{!$Dd}i4BTfM3yqU1hh;&}DeZf?*1k4}(q>vV zls7Z|-_LVh++JY67#MKp!E3{bC<%0!IHpS5V=|d56O2F60ka^`n<9C|Ubh=wtn4Uz zoS9!UBM%R7qq^$(Q11?DOkcT0c#(SafO%Y@1fk|n4ZJNWbI;owCG@XqG$$3i2uno8 z8;c;DOfDj$zO;%_n^MfBB2T57My;<7PCH_VBkwmgh^t@B76!L1JerBAZcM12x0hVA zt#>Y_m~!2zPZeXMK=yTyag7X~vR7s9Pb&~G2)PeReLg7!&u7PeJO*`uq`zj9wX!(P z*4R=@@-;`Ter|5ETJU9Su+Me_QuF`5I}cdn$WWQaX0CS3;T;+HFVh?E?X!VqO&qFs z%$`pBK(P-%h5Q*7{L*dVjpjphtp>q@#Jms0zTg3I7=WHq>8m5G zFPC)1mjZHU81~bF-KiIqS^xJye`Bt?NHg8^_-(0000xkPiL^;n2R#tpMey{l;D+6z zE?u1`(bKDyHanYFH*dzOm;0`BeDtK{`7)my1(OTG^lf=0XY+-N)}#XZUVL$l2IS$c z#$>LP-wXMo?U`bDw<^a_(Lx5`F!HAdI27N3AkUstrkkHi@!U>|+hN4tVIT6jW8NJp z|L=(Z=cIqsFdCl(uWwD=lQVY0mNM4RH?^<`qtbLlkVPFPC*8K#;sU!}k(4kpR8y8dQ1lKl2tMheLJdt6`yQJ)MOF_=l`27yI9P6X|7uu4y-7&PJ%o6zzx+dL4M zR9)`=LZPf^hisaIB6aavcXbeKp?9UJRs-Z+!(Ow+|2<_1dF|^vMM@inoo0zyi{wB*Pv!#J=JXZV+(Ed%DhJe2~DSn+UYk z!(|Qe=$nJ7ln_@1^$$dYZl@C915&<9^?ml}=UuiimMJfu!xNH$m$@(y@0S5AU{3HLN`vNG<=Z+q)HH@k;H#b_Eo5|qtlfAW7-`Tmj(b-uRwD74@J~V`ERV z-*6idh;6Tb7c^~G`Ay&ZCZOf+;?ZvFiK5nV37)6;>AG+UMb{1p&3FhfIFMhBM!;o# zpJ90mwJxQ}&AHz!;-PW-S4WNg(uT}zSp2VgK+E|;^ul*PcrNTn4n4vqVj*K5)CKL= zErRMznIPhnYk(P1kpGLNUD=X6EL9d zLAc!j&7@e9sS-ZI5N-AfM9jQk?k-zgJcv9!rduxcpP%|zwrLbC5 zZ$l7}Xk|n(+iSoCjjeKL`O(Wa&BKImGL?$dNV@WPBAO8?wAzd^GJ@59pwA~40zTh( z`IJ~xnbsGO-cg%qV5zQ7cpqXS6WjH$3i(jO=qrcze=WrFe>Ffb$P0ZP(a~76;aDfx zABYV|kN*F|)K^DE{XXGJ2q;L1w19MnEFlfj4bt76(p@Uu(#V3eba#jdQcHIWNT#HM{l|7bW`PNeR zArAZ1N9B*7qV}-AfBZ@4#6gREfwG59a)j;plO2Wbr>Ie^Wo6eJLJrR-gM@Eii4Aga zAefp^nrTo}$)Hhu{7H#yg)lxTxctq#;%aKQVL|t-xVd;&b2)`gV_H7|-oARb%nScA z^g7=s^Hbp43jEw~*$ipP?m_}1dY=)qz7FMDi)2*7okZvvQtqq;6(UAm)-@`}+TV(N zA#&oEFA)zNXPGN1u0l@jd~6kdpM0v0Y}Gq5Bdd`0<{?cZ*{r!o`mn@+b6#R`>?F|=#)*VMI!AM9 z*?OHNDVroO6;Pb?R^%&bxBFAdsYna{LmrP_|If7CNR&N3y4)GrT7yGMFA|u%8 z-+IL~)=N@@<`-g~pyYpZo)X~}{YcDyCt==F3o?*1JH#ka1C!s|!QwVUCgcE%N}T%a z2YT}y7e=bL-z9S8xP%Ffg$+?OSIRRbiX`%Talzw4i?=4cF0;NmE7g(3N>Dv*m3*lx zI29}uf)Z5NQx-RP>b~ulPmiHcVyPYYwTnN0(|S1F8kvx|U|U@PJ{Lt~Bn@6v^mSIq z#USYz3+l~f8pmbgGC47}t`q*D;}W%_!M)$*%BZ1ixl72p;Yn#Ess7k?Gn~?7$FQ$~xxuk|XZFZ7+^ z2Z@8JePeEGY&uTeSA|hqFLMTB!5`=Ywa+Csy*4xHz&Kv9Zwmk=nSPN6v_hJ`GV*1L zDt{Hp#(8W&6%)RXh@gv@(e9qVcX#bW)6gjAO$Pt@y=!A@M=g~n%`hhCWc&M_}N2e7F5tDB8n>QD0Y_>lj$cnl29-!-&ekCRK+ z=gR$sejij&4brZ)NDpAsfe4E772L;IzD~C#-xqyO4Vde zkbk7Nf-&ctS*v<1{(U4yCG1`-e=)c@7Brey_KGjV^>;!@^p-s3Pq z0qjoU#Mz~NC%-*)inR@T5d{`H=#J0`l0;CR2X@TXg z_OYO|bN1s_0h14IDG#_;WyyB5W1QG-ymFrnm|7?>h_peC-6~Bs`Y=TtCcc-GK!Caf!#>_Z9!? zFV%bm_qkKUXYTBe4pb< zX9BpnA}tt2xE)w8eA%ZI;fxjerZmb`zy=4y z|K@d5V;(I`^?HPpmM+uvojQ(rF0{V#)*(U?1irT8>aL6*S`M1!Y7J&|b_rbtUG*8m z5lI`S=IYDm0b}?)Q@?wsiQ@@MZ3NKHO0RD;mtZk(0%Yh7)v?#e`}E<$ON{Mh$L8$< zj%llawxqQ4<8US4AKT`i>%Z~pc0sZ1b>}CS!#*ns@xI@iL=(ukmz}wp+J;!)g*3-7 zSYvHNysx$k`R^6y2V*&~(Vly5R?(TC^rKUIT&VR4@8{Q!Sk=$BE_`_nCLb|qK;h0V z#0ViFq;m{{!=9AD1YG#3J4Rjl&JH}?eecAJl4d0oFH%Lr{Mx<1FZGF8?kYkk* zSg~HrwhCLzSv>*G7c4KOJFX@xl6%Idr+Ko|cST`#-(RY}Q!`3A-u(@~UcUEF^W?ss z>{@O0I(ZuKw|g9H2%>u!D7osc^4fX7ZW@24u{1@StsEDR$}-E(ef7-^V*S1j4`HLD zW%+=m@bxSQPHwWr$zusUa@HKxZrJNzFHHr_@N5`ETon?87$wV_cNljqnXTEH_5w1e zLB{pv6(xh+!o$Ytr)(;#8|Fe;%^J9Liis6|`?g0k)N2%K)TMHRo+UVM{Ob8XgCGq;v#6jM~8-i8WnjI*;7n#+ylDSgN3; z1#Zmw!ZkY*;Rm-(zDHXfa_+K)>mHtLSW)+xDz*hF&uO;!=BI=dua-_0%IK6B!pEW{ zz#1H`aoj2Z%u$jxEbAN0_4C}t%jq^?4TyM|-+&7e+l}M!euHUpl+ZQ?5gavuv-cxO zu8+8-X{p#wZc*sg#D_s3i&S&hQWO0;g$CPhvK%IxZS#RYLJyDLQbut9{fQvCam)B5 z7@ai^wAJ_K$$T~gqVt@$gSDks`{|UPmEx8&7nAQ?e&R?a_H+PAd9|3)LJ%K4Us45| zv#)J*_2_U}Mq*l~>_;e^3SCFXG26n}e(O*TQCfs6TW zZU_0sLhwSuN6VSRgpTPt{agKaN@4c%^+~rqTQ~k^C593@5%x_@cd42ux`n%k0YChA zPg~f@?p(ZQj6?B?cWcON&FIQ+wEq}r>D^&cv zv=5!xOQ*Qhj6?{Q7frJ!H+&Hc{mzV=0d%+-1ZutD$bx|d&ME7+Uou0&qR`nBGp)2B zEQlE9wDO;q8Vr+eF%211+3v|dh&LQJOSp0`rRQehbRv7*Var&V^=~4xY`T`fIO^G|Obn zMHPH@Ro9)DejcKN@&P9q%P)=Mm07_+RMXk7bEO29lTTuV`*TWyds}(Z*4v3+SlZyJ zQb2p?96C@pP3J43w=g1pP|F!QwXH!vWE&|ym*}h#-=`rtesPW=xo=FWsr;rp+(*^5 z_~KaKeeK+AB%KV-@$+S5jM#u=(-~^k=f-SR?gc%#^GxdxsHLGmdWYLpYo`00b$HJB z`hLfo%G-Sf@9lLy!+x<>(nh(Gm2C%0!TI9^SzXDlGQ*~loVsjaja``Hw$~UgHF{xU z&VofjO#BilcsD37ykXc?lAQB<3w;U8Tj4WasxZnaA6sf>j#L>zKMnINObT#CU7o7%g=IZj;D)qaHe!z zcZ^}a1JHGw;?HP0-AmrPWe9JX>CIL#|IiZF*bBb7O8)At%coBN(kXDmoY7xiv$5zC zMl)jZo13fY4g-ya7nv6GDxp#xQA-}rmbxNqHZINvKiEtVrwkz;&~* z!fuTM>2#AS8KO`S%l)ml*LBBT9{o#vg`^wMa{YR*cL+=uYU*C3fzJWQlWE{ERAvB` z5c5sH6aaAP&pAJ8nMGgI?nV^(P7Kjo$pxOyRegQGNY{4QM%7l~#fH^fDCciGidSd9 zr?u@Z<^0CPTO|as(&-&n#skSy%VDQ6s)jOpvsrE7<;kpW4m%Y{I(_JJEy+K zz=4+}GGV{s=qY2jRs92ZAH}8SSu;xeYuG`R_PbBh$|kJsce}-J>hA+*vCKoStxYdOrJk zOtK04-LxO{cBH{j;OX^~2Sa|MZ36pdMBI4T9;4X7k znYCx-4bjr)X{l?Nx`))qWz=@%*Qa?@by)?g31f_YLfCk-D7tBaIfsmEI@?pdgNPli;7-@zgRFvA zGQ^f~a$_zp=%B&72b6iY?^Ab4>X(BL>SR`zzZfRC35MHFkX6qPTSpAp@S^U90t0Uo zwnNgq?8q-TQtB4MaoSMcqIo5IN`R>q)TRIc@yt^ z9^8DG*aFxbnZUP16B=9aHxOkD?nab1n=`0m?#10i-j3RtYiVpO+`&0oYnlm8%i43u z?G_mUg-@y81WqG#_yyIiB|kq+N7)7CS9^J%J{oSwRE@>K-_kS?-MSV<+N&fcFP`ju zVZF4!ZQ_;d452+k#pLL=(EWX9X}Eo7DR~V?#7ROW1pa1!Nncsj6F+AbpK2r>QMDV) z4sBoLms_se=h5qddVP%&Jjl~nMu|U>a>X1iQ?VV@_IxxyCwpqwEv8p4a1yuf9R^2z z-m0siG-@4@F5BZsM*ZQf5IvcFx5!AajV@bbyJ(EXQ-WtUiR*T3--i6zxw)iIG_KOdm#&3Y`zvmJzAoqo>feOZ zQe)}j`KB_&9tq~c+g{Q(!WVvm{=0+AZ*^^}c@2$R@y4~ZG%m$CZ=d|>SPR=c^*+F;t9t~8IevpEC!Is`$^b6Dr-oNWw7sa|+DGcY z8qaZksDT5UdM|h2H|mn9-==frzMpVYZ!b~xCSU9?KV>NZIj$PNsMEI1Mc5vc;<5~S z0Xg)N1jG6y_EM`PC@u~}QuH9*vYi}P%^sGX4@m^Lcqx&Q)vDUIPsy9uoSD;{Y4F%L zqK*{G)_iqQE+Lux~A@* z6y#Rc~ZAa($`>evn=1J{8C{4%do>{L2~Upuxv|B)B{ZP zp+#{RYfnIq*@t2zqqn1>Jw23dL$mvZ+fZKcsl+Q~+o?2sOU()#bNd6(#)&fDCe4dR9~5oa_Rjr&vF$NoTG}nbr>FPaKP?@8oAp)j zO0)5qoy6Z z{w%e_$nr3dR%xc*c)5Y=(R#+)FUZ3%ang|AA^*0`7L zY5R4Y+AU~ZaPycppW3}fMDX%}K-Rbv6bxmI>Z5HB2{OhDocV>^^?UDBird5w6v>Sic7?WDw|ZDjsY+Wz+F~FSO94>MnSc?OSR6 zd9VEscb~h$=bIAf?%o9E_ToX)5b>1U<78mi-h#Fk7IrPCZM#iF_@!U{C*~ab9Jxx< z3oOW^zDsSOkvW8e?scl!UukM6mYo! zXgSMSvI+c((^2zIl2x0Qir^(}+Gwt>x1$=Lm$+sAI&rH(n|Ps#@<0%+6Bg}zEU`S) zW;xa-YEH|1FKm}4pDpJi)|(vkVOu)8X_>C-Qyj6)1?%d;c zYd`h#Ua)zmxTK%C-&pT7f97{6xk2xm7Z|VdB4R?!H67o4difQ}C{eNrgS8>GpCRnc z=eZ3ZG=Y<Lo$$Jd=jh> zPik6EUmdUXo&)C&Z^5=rL?k5pCALv7b{1g!IONwzM7LFd(*&W)Irv`U)g;dmGAQ7W zrakYKv@L+q`_utf#c4hEh-bI9R6xbnp~WK@^(1?y66Z~)kK0{pEsHt3_vzLTzn$z7ua`&} z{w0@f-$J)uGRs-AZygG z({updskj?=W4n|&b8MRLkwUr@w_U|U4NJ@9e!^dP~-Zjoj*pMGbsKItI(xrbhs7JM57!>L zPVKN~*1FkYcB|N5_)sXo=Zwa@>InUQqZ<;&@w~Q^w=Q+5T$F|Tu*ZJ8N>Y!;HJN3GbL=T5Znnn{WA>2y*NAC#xe67m&Hk2wSF?s&*zI*XYY{% zc5jZG^J-KU>VkFkWHzPU<9p(G&@SmTnJr*I= zo4@bluFK

w80jRaG@VNRu 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 z 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 Q0sP2HDN5!)F?{!b0IN+lq5uE@ literal 0 HcmV?d00001 diff --git a/api/src/backend/tasks/jobs/export.py b/api/src/backend/tasks/jobs/export.py index 103cac3a72..ca260f816d 100644 --- a/api/src/backend/tasks/jobs/export.py +++ b/api/src/backend/tasks/jobs/export.py @@ -241,36 +241,33 @@ def _upload_to_s3( logger.error(f"S3 upload failed: {str(e)}") -def _generate_output_directory( - output_directory, prowler_provider: object, tenant_id: str, scan_id: str -) -> tuple[str, str, str]: +def _build_output_path( + output_directory: str, + prowler_provider: str, + tenant_id: str, + scan_id: str, + subdirectory: str = None, +) -> str: """ - Generate a file system path for the output directory of a prowler scan. - - This function constructs the output directory path by combining a base - temporary output directory, the tenant ID, the scan ID, and details about - the prowler provider along with a timestamp. The resulting path is used to - store the output files of a prowler scan. - - Note: - This function depends on one external variable: - - `output_file_timestamp`: A timestamp (as a string) used to uniquely identify the output. + Build a file system path for the output directory of a prowler scan. Args: output_directory (str): The base output directory. - prowler_provider (object): An identifier or descriptor for the prowler provider. - Typically, this is a string indicating the provider (e.g., "aws"). + prowler_provider (str): An identifier or descriptor for the prowler provider. + Typically, this is a string indicating the provider (e.g., "aws"). tenant_id (str): The unique identifier for the tenant. scan_id (str): The unique identifier for the scan. + subdirectory (str, optional): Optional subdirectory to include in the path + (e.g., "compliance", "threatscore", "ens"). Returns: - str: The constructed file system path for the prowler scan output directory. + str: The constructed path with directory created. Example: - >>> _generate_output_directory("/tmp", "aws", "tenant-1234", "scan-5678") - '/tmp/tenant-1234/aws/scan-5678/prowler-output-2023-02-15T12:34:56', - '/tmp/tenant-1234/aws/scan-5678/compliance/prowler-output-2023-02-15T12:34:56' - '/tmp/tenant-1234/aws/scan-5678/threatscore/prowler-output-2023-02-15T12:34:56' + >>> _build_output_path("/tmp", "aws", "tenant-1234", "scan-5678") + '/tmp/tenant-1234/scan-5678/prowler-output-aws-20230215123456' + >>> _build_output_path("/tmp", "aws", "tenant-1234", "scan-5678", "threatscore") + '/tmp/tenant-1234/scan-5678/threatscore/prowler-output-aws-20230215123456' """ # Sanitize the prowler provider name to ensure it is a valid directory name prowler_provider_sanitized = re.sub(r"[^\w\-]", "-", prowler_provider) @@ -279,22 +276,102 @@ def _generate_output_directory( started_at = Scan.objects.get(id=scan_id).started_at timestamp = started_at.strftime("%Y%m%d%H%M%S") - path = ( - f"{output_directory}/{tenant_id}/{scan_id}/prowler-output-" - f"{prowler_provider_sanitized}-{timestamp}" - ) + + if subdirectory: + path = ( + f"{output_directory}/{tenant_id}/{scan_id}/{subdirectory}/prowler-output-" + f"{prowler_provider_sanitized}-{timestamp}" + ) + else: + path = ( + f"{output_directory}/{tenant_id}/{scan_id}/prowler-output-" + f"{prowler_provider_sanitized}-{timestamp}" + ) + + # Create directory for the path if it doesn't exist os.makedirs("/".join(path.split("/")[:-1]), exist_ok=True) - compliance_path = ( - f"{output_directory}/{tenant_id}/{scan_id}/compliance/prowler-output-" - f"{prowler_provider_sanitized}-{timestamp}" - ) - os.makedirs("/".join(compliance_path.split("/")[:-1]), exist_ok=True) + return path - threatscore_path = ( - f"{output_directory}/{tenant_id}/{scan_id}/threatscore/prowler-output-" - f"{prowler_provider_sanitized}-{timestamp}" - ) - os.makedirs("/".join(threatscore_path.split("/")[:-1]), exist_ok=True) - return path, compliance_path, threatscore_path +def _generate_compliance_output_directory( + output_directory: str, + prowler_provider: str, + tenant_id: str, + scan_id: str, + compliance_framework: str, +) -> str: + """ + Generate a file system path for a compliance framework output directory. + + This function constructs the output directory path specifically for a compliance + framework (e.g., "threatscore", "ens") by combining a base temporary output directory, + the tenant ID, the scan ID, the compliance framework name, and details about the + prowler provider along with a timestamp. + + Args: + output_directory (str): The base output directory. + prowler_provider (str): An identifier or descriptor for the prowler provider. + Typically, this is a string indicating the provider (e.g., "aws"). + tenant_id (str): The unique identifier for the tenant. + scan_id (str): The unique identifier for the scan. + compliance_framework (str): The compliance framework name (e.g., "threatscore", "ens"). + + Returns: + str: The path for the compliance framework output directory. + + Example: + >>> _generate_compliance_output_directory("/tmp", "aws", "tenant-1234", "scan-5678", "threatscore") + '/tmp/tenant-1234/scan-5678/threatscore/prowler-output-aws-20230215123456' + >>> _generate_compliance_output_directory("/tmp", "aws", "tenant-1234", "scan-5678", "ens") + '/tmp/tenant-1234/scan-5678/ens/prowler-output-aws-20230215123456' + """ + return _build_output_path( + output_directory, + prowler_provider, + tenant_id, + scan_id, + subdirectory=compliance_framework, + ) + + +def _generate_output_directory( + output_directory: str, + prowler_provider: str, + tenant_id: str, + scan_id: str, +) -> tuple[str, str]: + """ + Generate file system paths for the standard and compliance output directories of a prowler scan. + + This function constructs both the standard output directory path and the compliance + output directory path by combining a base temporary output directory, the tenant ID, + the scan ID, and details about the prowler provider along with a timestamp. + + Args: + output_directory (str): The base output directory. + prowler_provider (str): An identifier or descriptor for the prowler provider. + Typically, this is a string indicating the provider (e.g., "aws"). + tenant_id (str): The unique identifier for the tenant. + scan_id (str): The unique identifier for the scan. + + Returns: + tuple[str, str]: A tuple containing (standard_path, compliance_path). + + Example: + >>> _generate_output_directory("/tmp", "aws", "tenant-1234", "scan-5678") + ('/tmp/tenant-1234/scan-5678/prowler-output-aws-20230215123456', + '/tmp/tenant-1234/scan-5678/compliance/prowler-output-aws-20230215123456') + """ + standard_path = _build_output_path( + output_directory, prowler_provider, tenant_id, scan_id + ) + compliance_path = _build_output_path( + output_directory, + prowler_provider, + tenant_id, + scan_id, + subdirectory="compliance", + ) + + return standard_path, compliance_path diff --git a/api/src/backend/tasks/jobs/report.py b/api/src/backend/tasks/jobs/report.py index ac74b7a04b..1af6e50b34 100644 --- a/api/src/backend/tasks/jobs/report.py +++ b/api/src/backend/tasks/jobs/report.py @@ -7,6 +7,7 @@ from shutil import rmtree import matplotlib.pyplot as plt from celery.utils.log import get_task_logger from config.django.base import DJANGO_FINDINGS_BATCH_SIZE, DJANGO_TMP_OUTPUT_DIRECTORY +from django.db.models import Count, Q from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER from reportlab.lib.pagesizes import letter @@ -24,23 +25,12 @@ from reportlab.platypus import ( Table, TableStyle, ) -from tasks.jobs.export import _generate_output_directory, _upload_to_s3 -from tasks.jobs.threatscore import compute_threatscore_metrics -from tasks.jobs.threatscore_utils import ( - _aggregate_requirement_statistics_from_database, - _calculate_requirements_data_from_statistics, -) +from tasks.jobs.export import _generate_compliance_output_directory, _upload_to_s3 from tasks.utils import batched from api.db_router import READ_REPLICA_ALIAS from api.db_utils import rls_transaction -from api.models import ( - Finding, - Provider, - ScanSummary, - StatusChoices, - ThreatScoreSnapshot, -) +from api.models import Finding, Provider, ScanSummary, StatusChoices from api.utils import initialize_prowler_provider from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.finding import Finding as FindingOutput @@ -63,11 +53,241 @@ pdfmetrics.registerFont( logger = get_task_logger(__name__) +# Color constants +COLOR_PROWLER_DARK_GREEN = colors.Color(0.1, 0.5, 0.2) +COLOR_BLUE = colors.Color(0.2, 0.4, 0.6) +COLOR_LIGHT_BLUE = colors.Color(0.3, 0.5, 0.7) +COLOR_LIGHTER_BLUE = colors.Color(0.4, 0.6, 0.8) +COLOR_BG_BLUE = colors.Color(0.95, 0.97, 1.0) +COLOR_BG_LIGHT_BLUE = colors.Color(0.98, 0.99, 1.0) +COLOR_GRAY = colors.Color(0.2, 0.2, 0.2) +COLOR_LIGHT_GRAY = colors.Color(0.9, 0.9, 0.9) +COLOR_BORDER_GRAY = colors.Color(0.7, 0.8, 0.9) +COLOR_GRID_GRAY = colors.Color(0.7, 0.7, 0.7) +COLOR_DARK_GRAY = colors.Color(0.4, 0.4, 0.4) +COLOR_HEADER_DARK = colors.Color(0.1, 0.3, 0.5) +COLOR_HEADER_MEDIUM = colors.Color(0.15, 0.35, 0.55) +COLOR_WHITE = colors.white + +# Risk and status colors +COLOR_HIGH_RISK = colors.Color(0.8, 0.2, 0.2) +COLOR_MEDIUM_RISK = colors.Color(0.9, 0.6, 0.2) +COLOR_LOW_RISK = colors.Color(0.9, 0.9, 0.2) +COLOR_SAFE = colors.Color(0.2, 0.8, 0.2) + +# ENS specific colors +COLOR_ENS_ALTO = colors.Color(0.8, 0.2, 0.2) +COLOR_ENS_MEDIO = colors.Color(0.98, 0.75, 0.13) +COLOR_ENS_BAJO = colors.Color(0.06, 0.72, 0.51) +COLOR_ENS_OPCIONAL = colors.Color(0.42, 0.45, 0.50) +COLOR_ENS_TIPO = colors.Color(0.2, 0.4, 0.6) +COLOR_ENS_AUTO = colors.Color(0.30, 0.69, 0.31) +COLOR_ENS_MANUAL = colors.Color(0.96, 0.60, 0.0) + +# Chart colors +CHART_COLOR_GREEN_1 = "#4CAF50" +CHART_COLOR_GREEN_2 = "#8BC34A" +CHART_COLOR_YELLOW = "#FFEB3B" +CHART_COLOR_ORANGE = "#FF9800" +CHART_COLOR_RED = "#F44336" +CHART_COLOR_BLUE = "#2196F3" + +# ENS dimension mappings +DIMENSION_MAPPING = { + "trazabilidad": ("T", colors.Color(0.26, 0.52, 0.96)), + "autenticidad": ("A", colors.Color(0.30, 0.69, 0.31)), + "integridad": ("I", colors.Color(0.61, 0.15, 0.69)), + "confidencialidad": ("C", colors.Color(0.96, 0.26, 0.21)), + "disponibilidad": ("D", colors.Color(1.0, 0.60, 0.0)), +} + +# ENS tipo icons +TIPO_ICONS = { + "requisito": "⚠️", + "refuerzo": "🛡️", + "recomendacion": "💡", + "medida": "📋", +} + +# Dimension names for charts +DIMENSION_NAMES = [ + "Trazabilidad", + "Autenticidad", + "Integridad", + "Confidencialidad", + "Disponibilidad", +] + +DIMENSION_KEYS = [ + "trazabilidad", + "autenticidad", + "integridad", + "confidencialidad", + "disponibilidad", +] + +# ENS nivel order +ENS_NIVEL_ORDER = ["alto", "medio", "bajo", "opcional"] + +# ENS tipo order +ENS_TIPO_ORDER = ["requisito", "refuerzo", "recomendacion", "medida"] + +# ThreatScore expected sections +THREATSCORE_SECTIONS = [ + "1. IAM", + "2. Attack Surface", + "3. Logging and Monitoring", + "4. Encryption", +] + +# Table column widths (in inches) +COL_WIDTH_SMALL = 0.4 * inch +COL_WIDTH_MEDIUM = 0.9 * inch +COL_WIDTH_LARGE = 1.5 * inch +COL_WIDTH_XLARGE = 2 * inch +COL_WIDTH_XXLARGE = 3 * inch + +# Common padding values +PADDING_SMALL = 4 +PADDING_MEDIUM = 6 +PADDING_LARGE = 8 +PADDING_XLARGE = 10 + + +# Cache for PDF styles to avoid recreating them on every call +_PDF_STYLES_CACHE: dict[str, ParagraphStyle] | None = None + + +# Helper functions for performance optimization +def _get_color_for_risk_level(risk_level: int) -> colors.Color: + """Get color based on risk level using optimized lookup.""" + if risk_level >= 4: + return COLOR_HIGH_RISK + elif risk_level >= 3: + return COLOR_MEDIUM_RISK + elif risk_level >= 2: + return COLOR_LOW_RISK + return COLOR_SAFE + + +def _get_color_for_weight(weight: int) -> colors.Color: + """Get color based on weight using optimized lookup.""" + if weight > 100: + return COLOR_HIGH_RISK + elif weight > 50: + return COLOR_LOW_RISK + return COLOR_SAFE + + +def _get_color_for_compliance(percentage: float) -> colors.Color: + """Get color based on compliance percentage.""" + if percentage >= 80: + return COLOR_SAFE + elif percentage >= 60: + return COLOR_LOW_RISK + return COLOR_HIGH_RISK + + +def _get_chart_color_for_percentage(percentage: float) -> str: + """Get chart color string based on percentage.""" + if percentage >= 80: + return CHART_COLOR_GREEN_1 + elif percentage >= 60: + return CHART_COLOR_GREEN_2 + elif percentage >= 40: + return CHART_COLOR_YELLOW + elif percentage >= 20: + return CHART_COLOR_ORANGE + return CHART_COLOR_RED + + +def _get_ens_nivel_color(nivel: str) -> colors.Color: + """Get ENS nivel color using optimized lookup.""" + nivel_lower = nivel.lower() + if nivel_lower == "alto": + return COLOR_ENS_ALTO + elif nivel_lower == "medio": + return COLOR_ENS_MEDIO + elif nivel_lower == "bajo": + return COLOR_ENS_BAJO + return COLOR_ENS_OPCIONAL + + +def _safe_getattr(obj, attr: str, default: str = "N/A") -> str: + """Optimized getattr with default value.""" + return getattr(obj, attr, default) + + +def _create_info_table_style() -> TableStyle: + """Create a reusable table style for information/metadata tables.""" + return TableStyle( + [ + ("BACKGROUND", (0, 0), (0, -1), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, -1), "FiraCode"), + ("BACKGROUND", (1, 0), (1, -1), COLOR_BG_BLUE), + ("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY), + ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_XLARGE), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_XLARGE), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ] + ) + + +def _create_header_table_style(header_color: colors.Color = None) -> TableStyle: + """Create a reusable table style for tables with headers.""" + if header_color is None: + header_color = COLOR_BLUE + + return TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), header_color), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (1, 1), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 1, COLOR_GRID_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ] + ) + + +def _create_findings_table_style() -> TableStyle: + """Create a reusable table style for findings tables.""" + return TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("ALIGN", (0, 0), (0, 0), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.1, COLOR_BORDER_GRAY), + ("LEFTPADDING", (0, 0), (0, 0), 0), + ("RIGHTPADDING", (0, 0), (0, 0), 0), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_SMALL), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_SMALL), + ] + ) + def _create_pdf_styles() -> dict[str, ParagraphStyle]: """ Create and return PDF paragraph styles used throughout the report. + Styles are cached on first call to improve performance. + Returns: dict[str, ParagraphStyle]: A dictionary containing the following styles: - 'title': Title style with prowler green color @@ -77,14 +297,18 @@ def _create_pdf_styles() -> dict[str, ParagraphStyle]: - 'normal': Normal text style with left indent - 'normal_center': Normal text style without indent """ + global _PDF_STYLES_CACHE + + if _PDF_STYLES_CACHE is not None: + return _PDF_STYLES_CACHE + styles = getSampleStyleSheet() - prowler_dark_green = colors.Color(0.1, 0.5, 0.2) title_style = ParagraphStyle( "CustomTitle", parent=styles["Title"], fontSize=24, - textColor=prowler_dark_green, + textColor=COLOR_PROWLER_DARK_GREEN, spaceAfter=20, fontName="PlusJakartaSans", alignment=TA_CENTER, @@ -94,37 +318,37 @@ def _create_pdf_styles() -> dict[str, ParagraphStyle]: "CustomH1", parent=styles["Heading1"], fontSize=18, - textColor=colors.Color(0.2, 0.4, 0.6), + textColor=COLOR_BLUE, spaceBefore=20, spaceAfter=12, fontName="PlusJakartaSans", leftIndent=0, borderWidth=2, - borderColor=colors.Color(0.2, 0.4, 0.6), - borderPadding=8, - backColor=colors.Color(0.95, 0.97, 1.0), + borderColor=COLOR_BLUE, + borderPadding=PADDING_LARGE, + backColor=COLOR_BG_BLUE, ) h2 = ParagraphStyle( "CustomH2", parent=styles["Heading2"], fontSize=14, - textColor=colors.Color(0.3, 0.5, 0.7), + textColor=COLOR_LIGHT_BLUE, spaceBefore=15, spaceAfter=8, fontName="PlusJakartaSans", leftIndent=10, borderWidth=1, - borderColor=colors.Color(0.7, 0.8, 0.9), + borderColor=COLOR_BORDER_GRAY, borderPadding=5, - backColor=colors.Color(0.98, 0.99, 1.0), + backColor=COLOR_BG_LIGHT_BLUE, ) h3 = ParagraphStyle( "CustomH3", parent=styles["Heading3"], fontSize=12, - textColor=colors.Color(0.4, 0.6, 0.8), + textColor=COLOR_LIGHTER_BLUE, spaceBefore=10, spaceAfter=6, fontName="PlusJakartaSans", @@ -135,9 +359,9 @@ def _create_pdf_styles() -> dict[str, ParagraphStyle]: "CustomNormal", parent=styles["Normal"], fontSize=10, - textColor=colors.Color(0.2, 0.2, 0.2), - spaceBefore=4, - spaceAfter=4, + textColor=COLOR_GRAY, + spaceBefore=PADDING_SMALL, + spaceAfter=PADDING_SMALL, leftIndent=30, fontName="PlusJakartaSans", ) @@ -146,11 +370,11 @@ def _create_pdf_styles() -> dict[str, ParagraphStyle]: "CustomNormalCenter", parent=styles["Normal"], fontSize=10, - textColor=colors.Color(0.2, 0.2, 0.2), + textColor=COLOR_GRAY, fontName="PlusJakartaSans", ) - return { + _PDF_STYLES_CACHE = { "title": title_style, "h1": h1, "h2": h2, @@ -159,6 +383,8 @@ def _create_pdf_styles() -> dict[str, ParagraphStyle]: "normal_center": normal_center, } + return _PDF_STYLES_CACHE + def _create_risk_component(risk_level: int, weight: int, score: int = 0) -> Table: """ @@ -172,23 +398,8 @@ def _create_risk_component(risk_level: int, weight: int, score: int = 0) -> Tabl Returns: Table: A ReportLab Table object with colored cells representing risk, weight, and score. """ - if risk_level >= 4: - risk_color = colors.Color(0.8, 0.2, 0.2) - elif risk_level >= 3: - risk_color = colors.Color(0.9, 0.6, 0.2) - elif risk_level >= 2: - risk_color = colors.Color(0.9, 0.9, 0.2) - else: - risk_color = colors.Color(0.2, 0.8, 0.2) - - if weight <= 50: - weight_color = colors.Color(0.2, 0.8, 0.2) - elif weight <= 100: - weight_color = colors.Color(0.9, 0.9, 0.2) - else: - weight_color = colors.Color(0.8, 0.2, 0.2) - - score_color = colors.Color(0.4, 0.4, 0.4) + risk_color = _get_color_for_risk_level(risk_level) + weight_color = _get_color_for_weight(weight) data = [ [ @@ -205,37 +416,37 @@ def _create_risk_component(risk_level: int, weight: int, score: int = 0) -> Tabl data, colWidths=[ 0.8 * inch, - 0.4 * inch, + COL_WIDTH_SMALL, 0.6 * inch, - 0.4 * inch, + COL_WIDTH_SMALL, 0.5 * inch, - 0.4 * inch, + COL_WIDTH_SMALL, ], ) table.setStyle( TableStyle( [ - ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.9, 0.9, 0.9)), + ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY), ("BACKGROUND", (1, 0), (1, 0), risk_color), - ("TEXTCOLOR", (1, 0), (1, 0), colors.white), + ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE), ("FONTNAME", (1, 0), (1, 0), "FiraCode"), - ("BACKGROUND", (2, 0), (2, 0), colors.Color(0.9, 0.9, 0.9)), + ("BACKGROUND", (2, 0), (2, 0), COLOR_LIGHT_GRAY), ("BACKGROUND", (3, 0), (3, 0), weight_color), - ("TEXTCOLOR", (3, 0), (3, 0), colors.white), + ("TEXTCOLOR", (3, 0), (3, 0), COLOR_WHITE), ("FONTNAME", (3, 0), (3, 0), "FiraCode"), - ("BACKGROUND", (4, 0), (4, 0), colors.Color(0.9, 0.9, 0.9)), - ("BACKGROUND", (5, 0), (5, 0), score_color), - ("TEXTCOLOR", (5, 0), (5, 0), colors.white), + ("BACKGROUND", (4, 0), (4, 0), COLOR_LIGHT_GRAY), + ("BACKGROUND", (5, 0), (5, 0), COLOR_DARK_GRAY), + ("TEXTCOLOR", (5, 0), (5, 0), COLOR_WHITE), ("FONTNAME", (5, 0), (5, 0), "FiraCode"), ("ALIGN", (0, 0), (-1, -1), "CENTER"), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("FONTSIZE", (0, 0), (-1, -1), 10), ("GRID", (0, 0), (-1, -1), 0.5, colors.black), - ("LEFTPADDING", (0, 0), (-1, -1), 6), - ("RIGHTPADDING", (0, 0), (-1, -1), 6), - ("TOPPADDING", (0, 0), (-1, -1), 8), - ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE), ] ) ) @@ -253,33 +464,34 @@ def _create_status_component(status: str) -> Table: Returns: Table: A ReportLab Table object displaying the status with appropriate color coding. """ - if status.upper() == "PASS": - status_color = colors.Color(0.2, 0.8, 0.2) - elif status.upper() == "FAIL": - status_color = colors.Color(0.8, 0.2, 0.2) + status_upper = status.upper() + if status_upper == "PASS": + status_color = COLOR_SAFE + elif status_upper == "FAIL": + status_color = COLOR_HIGH_RISK else: - status_color = colors.Color(0.4, 0.4, 0.4) + status_color = COLOR_DARK_GRAY - data = [["State:", status.upper()]] + data = [["State:", status_upper]] table = Table(data, colWidths=[0.6 * inch, 0.8 * inch]) table.setStyle( TableStyle( [ - ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.9, 0.9, 0.9)), + ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY), ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), ("BACKGROUND", (1, 0), (1, 0), status_color), - ("TEXTCOLOR", (1, 0), (1, 0), colors.white), + ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE), ("FONTNAME", (1, 0), (1, 0), "FiraCode"), ("ALIGN", (0, 0), (-1, -1), "CENTER"), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("FONTSIZE", (0, 0), (-1, -1), 12), ("GRID", (0, 0), (-1, -1), 0.5, colors.black), - ("LEFTPADDING", (0, 0), (-1, -1), 8), - ("RIGHTPADDING", (0, 0), (-1, -1), 8), - ("TOPPADDING", (0, 0), (-1, -1), 10), - ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_XLARGE), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_XLARGE), ] ) ) @@ -287,6 +499,136 @@ def _create_status_component(status: str) -> Table: return table +def _create_ens_nivel_badge(nivel: str) -> Table: + """ + Create a visual badge for ENS requirement level (Nivel). + + Args: + nivel (str): The level value (e.g., "alto", "medio", "bajo", "opcional"). + + Returns: + Table: A ReportLab Table object displaying the level with appropriate color coding. + """ + nivel_color = _get_ens_nivel_color(nivel) + data = [[f"Nivel: {nivel.upper()}"]] + + table = Table(data, colWidths=[1.4 * inch]) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), nivel_color), + ("TEXTCOLOR", (0, 0), (0, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, 0), "FiraCode"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ] + ) + ) + + return table + + +def _create_ens_tipo_badge(tipo: str) -> Table: + """ + Create a visual badge for ENS requirement type (Tipo). + + Args: + tipo (str): The type value (e.g., "requisito", "refuerzo", "recomendacion", "medida"). + + Returns: + Table: A ReportLab Table object displaying the type with appropriate styling. + """ + tipo_lower = tipo.lower() + icon = TIPO_ICONS.get(tipo_lower, "") + + data = [[f"{icon} {tipo.capitalize()}"]] + + table = Table(data, colWidths=[1.8 * inch]) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), COLOR_ENS_TIPO), + ("TEXTCOLOR", (0, 0), (0, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ] + ) + ) + + return table + + +def _create_ens_dimension_badges(dimensiones: list[str]) -> Table: + """ + Create visual badges for ENS security dimensions. + + Args: + dimensiones (list[str]): List of dimension names (e.g., ["trazabilidad", "autenticidad"]). + + Returns: + Table: A ReportLab Table object with color-coded badges for each dimension. + """ + badges = [ + DIMENSION_MAPPING[dimension.lower()] + for dimension in dimensiones + if dimension.lower() in DIMENSION_MAPPING + ] + + if not badges: + data = [["N/A"]] + table = Table(data, colWidths=[1 * inch]) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ] + ) + ) + return table + + data = [[badge[0] for badge in badges]] + col_widths = [COL_WIDTH_SMALL] * len(badges) + + table = Table(data, colWidths=col_widths) + + styles = [ + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTNAME", (0, 0), (-1, -1), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("TEXTCOLOR", (0, 0), (-1, -1), COLOR_WHITE), + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_SMALL), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_SMALL), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ] + + for idx, (_, badge_color) in enumerate(badges): + styles.append(("BACKGROUND", (idx, 0), (idx, 0), badge_color)) + + table.setStyle(TableStyle(styles)) + + return table + + def _create_section_score_chart( requirements_list: list[dict], attributes_by_requirement_id: dict ) -> io.BytesIO: @@ -300,14 +642,6 @@ def _create_section_score_chart( Returns: io.BytesIO: A BytesIO buffer containing the chart image in PNG format. """ - # Define expected sections - expected_sections = [ - "1. IAM", - "2. Attack Surface", - "3. Logging and Monitoring", - "4. Encryption", - ] - # Initialize all expected sections with default values sections_data = { section: { @@ -315,7 +649,7 @@ def _create_section_score_chart( "denominator": 0, "has_findings": False, } - for section in expected_sections + for section in THREATSCORE_SECTIONS } # Collect data from requirements @@ -326,38 +660,39 @@ def _create_section_score_chart( metadata = requirement_attributes.get("attributes", {}).get( "req_attributes", [] ) - if metadata: - m = metadata[0] - section = getattr(m, "Section", "Unknown") + if not metadata: + continue - # Add section if not in expected list (for flexibility) - if section not in sections_data: - sections_data[section] = { - "numerator": 0, - "denominator": 0, - "has_findings": False, - } + m = metadata[0] + section = _safe_getattr(m, "Section", "Unknown") - # Get findings data - passed_findings = requirement["attributes"].get("passed_findings", 0) - total_findings = requirement["attributes"].get("total_findings", 0) + # Add section if not in expected list (for flexibility) + if section not in sections_data: + sections_data[section] = { + "numerator": 0, + "denominator": 0, + "has_findings": False, + } - if total_findings > 0: - sections_data[section]["has_findings"] = True - risk_level = getattr(m, "LevelOfRisk", 0) - weight = getattr(m, "Weight", 0) + # Get findings data + passed_findings = requirement["attributes"].get("passed_findings", 0) + total_findings = requirement["attributes"].get("total_findings", 0) - # Calculate using ThreatScore formula from UI - rate_i = passed_findings / total_findings - rfac_i = 1 + 0.25 * risk_level + if total_findings > 0: + sections_data[section]["has_findings"] = True + risk_level = _safe_getattr(m, "LevelOfRisk", 0) + weight = _safe_getattr(m, "Weight", 0) - sections_data[section]["numerator"] += ( - rate_i * total_findings * weight * rfac_i - ) - sections_data[section]["denominator"] += ( - total_findings * weight * rfac_i - ) + # Calculate using ThreatScore formula from UI + rate_i = passed_findings / total_findings + rfac_i = 1 + 0.25 * risk_level + sections_data[section]["numerator"] += ( + rate_i * total_findings * weight * rfac_i + ) + sections_data[section]["denominator"] += total_findings * weight * rfac_i + + # Calculate percentages section_names = [] compliance_percentages = [] @@ -371,29 +706,17 @@ def _create_section_score_chart( compliance_percentages.append(compliance_percentage) # Sort alphabetically by section name - sorted_data = sorted( - zip(section_names, compliance_percentages), - key=lambda x: x[0], - ) - section_names, compliance_percentages = ( - zip(*sorted_data) if sorted_data else ([], []) - ) + sorted_data = sorted(zip(section_names, compliance_percentages), key=lambda x: x[0]) + if not sorted_data: + section_names, compliance_percentages = [], [] + else: + section_names, compliance_percentages = zip(*sorted_data) + # Generate chart fig, ax = plt.subplots(figsize=(12, 8)) - colors_list = [] - for percentage in compliance_percentages: - if percentage >= 80: - color = "#4CAF50" - elif percentage >= 60: - color = "#8BC34A" - elif percentage >= 40: - color = "#FFEB3B" - elif percentage >= 20: - color = "#FF9800" - else: - color = "#F44336" - colors_list.append(color) + # Use helper function for color selection + colors_list = [_get_chart_color_for_percentage(p) for p in compliance_percentages] bars = ax.bar(section_names, compliance_percentages, color=colors_list) @@ -413,15 +736,15 @@ def _create_section_score_chart( ) plt.xticks(rotation=45, ha="right") - ax.grid(True, alpha=0.3, axis="y") - plt.tight_layout() buffer = io.BytesIO() - plt.savefig(buffer, format="png", dpi=300, bbox_inches="tight") - buffer.seek(0) - plt.close() + try: + plt.savefig(buffer, format="png", dpi=300, bbox_inches="tight") + buffer.seek(0) + finally: + plt.close(fig) return buffer @@ -434,31 +757,272 @@ def _add_pdf_footer(canvas_obj: canvas.Canvas, doc: SimpleDocTemplate) -> None: canvas_obj (canvas.Canvas): The ReportLab canvas object for drawing. doc (SimpleDocTemplate): The document template containing page information. """ + canvas_obj.saveState() width, height = doc.pagesize - page_num_text = f"Page {doc.page}" + page_num_text = f"Página {doc.page}" canvas_obj.setFont("PlusJakartaSans", 9) canvas_obj.setFillColorRGB(0.4, 0.4, 0.4) canvas_obj.drawString(30, 20, page_num_text) powered_text = "Powered by Prowler" text_width = canvas_obj.stringWidth(powered_text, "PlusJakartaSans", 9) canvas_obj.drawString(width - text_width - 30, 20, powered_text) + canvas_obj.restoreState() + + +def _create_marco_category_chart( + requirements_list: list[dict], attributes_by_requirement_id: dict +) -> io.BytesIO: + """ + Create a bar chart showing compliance percentage by Marco (Section) and Categoría. + + Args: + requirements_list (list[dict]): List of requirement dictionaries with status and findings data. + attributes_by_requirement_id (dict): Mapping of requirement IDs to their attributes. + + Returns: + io.BytesIO: A BytesIO buffer containing the chart image in PNG format. + """ + # Collect data by Marco and Categoría + marco_categoria_data = defaultdict(lambda: {"passed": 0, "total": 0}) + + for requirement in requirements_list: + requirement_id = requirement["id"] + requirement_attributes = attributes_by_requirement_id.get(requirement_id, {}) + requirement_status = requirement["attributes"].get( + "status", StatusChoices.MANUAL + ) + + metadata = requirement_attributes.get("attributes", {}).get( + "req_attributes", [] + ) + if not metadata: + continue + + m = metadata[0] + marco = _safe_getattr(m, "Marco") + categoria = _safe_getattr(m, "Categoria") + + key = f"{marco} - {categoria}" + marco_categoria_data[key]["total"] += 1 + if requirement_status == StatusChoices.PASS: + marco_categoria_data[key]["passed"] += 1 + + # Calculate percentages + categories = [] + percentages = [] + + for category, data in sorted(marco_categoria_data.items()): + percentage = (data["passed"] / data["total"] * 100) if data["total"] > 0 else 0 + categories.append(category) + percentages.append(percentage) + + if not categories: + # Return empty chart if no data + fig, ax = plt.subplots(figsize=(12, 6)) + ax.text(0.5, 0.5, "No data available", ha="center", va="center", fontsize=14) + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.axis("off") + buffer = io.BytesIO() + try: + plt.savefig(buffer, format="png", dpi=300, bbox_inches="tight") + buffer.seek(0) + finally: + plt.close(fig) + return buffer + + # Create horizontal bar chart + fig, ax = plt.subplots(figsize=(12, max(8, len(categories) * 0.4))) + + # Use helper function for color selection + colors_list = [_get_chart_color_for_percentage(p) for p in percentages] + + y_pos = range(len(categories)) + bars = ax.barh(y_pos, percentages, color=colors_list) + + ax.set_yticks(y_pos) + ax.set_yticklabels(categories, fontsize=16) + ax.set_xlabel("Porcentaje de Cumplimiento (%)", fontsize=14) + ax.set_xlim(0, 100) + + # Add percentage labels + for bar, percentage in zip(bars, percentages): + width = bar.get_width() + ax.text( + width + 1, + bar.get_y() + bar.get_height() / 2.0, + f"{percentage:.1f}%", + ha="left", + va="center", + fontweight="bold", + fontsize=10, + ) + + ax.grid(True, alpha=0.3, axis="x") + plt.tight_layout() + + buffer = io.BytesIO() + try: + plt.savefig(buffer, format="png", dpi=300, bbox_inches="tight") + buffer.seek(0) + finally: + plt.close(fig) + + return buffer + + +def _create_dimensions_radar_chart( + requirements_list: list[dict], attributes_by_requirement_id: dict +) -> io.BytesIO: + """ + Create a radar/spider chart showing compliance percentage by security dimension. + + Args: + requirements_list (list[dict]): List of requirement dictionaries with status and findings data. + attributes_by_requirement_id (dict): Mapping of requirement IDs to their attributes. + + Returns: + io.BytesIO: A BytesIO buffer containing the chart image in PNG format. + """ + dimension_data = {key: {"passed": 0, "total": 0} for key in DIMENSION_KEYS} + + # Collect data for each dimension + for requirement in requirements_list: + requirement_id = requirement["id"] + requirement_attributes = attributes_by_requirement_id.get(requirement_id, {}) + requirement_status = requirement["attributes"].get( + "status", StatusChoices.MANUAL + ) + + metadata = requirement_attributes.get("attributes", {}).get( + "req_attributes", [] + ) + if not metadata: + continue + + m = metadata[0] + dimensiones = _safe_getattr(m, "Dimensiones", []) + + for dimension in dimensiones: + dimension_lower = dimension.lower() + if dimension_lower in dimension_data: + dimension_data[dimension_lower]["total"] += 1 + if requirement_status == StatusChoices.PASS: + dimension_data[dimension_lower]["passed"] += 1 + + # Calculate percentages + percentages = [ + ( + (dimension_data[key]["passed"] / dimension_data[key]["total"] * 100) + if dimension_data[key]["total"] > 0 + else 100 + ) # No requirements = 100% (no failures) + for key in DIMENSION_KEYS + ] + + # Create radar chart + num_dims = len(DIMENSION_NAMES) + angles = [n / float(num_dims) * 2 * 3.14159 for n in range(num_dims)] + percentages += percentages[:1] + angles += angles[:1] + + fig, ax = plt.subplots(figsize=(10, 10), subplot_kw=dict(projection="polar")) + + ax.plot(angles, percentages, "o-", linewidth=2, color=CHART_COLOR_BLUE) + ax.fill(angles, percentages, alpha=0.25, color=CHART_COLOR_BLUE) + ax.set_xticks(angles[:-1]) + ax.set_xticklabels(DIMENSION_NAMES, fontsize=14) + ax.set_ylim(0, 100) + ax.set_yticks([20, 40, 60, 80, 100]) + ax.set_yticklabels(["20%", "40%", "60%", "80%", "100%"], fontsize=12) + ax.grid(True, alpha=0.3) + + plt.tight_layout() + + buffer = io.BytesIO() + try: + plt.savefig(buffer, format="png", dpi=300, bbox_inches="tight") + buffer.seek(0) + finally: + plt.close(fig) + + return buffer + + +def _aggregate_requirement_statistics_from_database( + tenant_id: str, scan_id: str +) -> dict[str, dict[str, int]]: + """ + Aggregate finding statistics by check_id using database aggregation. + + This function uses Django ORM aggregation to calculate pass/fail statistics + entirely in the database, avoiding the need to load findings into memory. + + Args: + tenant_id (str): The tenant ID for Row-Level Security context. + scan_id (str): The ID of the scan to retrieve findings for. + + Returns: + dict[str, dict[str, int]]: Dictionary mapping check_id to statistics: + - 'passed' (int): Number of passed findings for this check + - 'total' (int): Total number of findings for this check + + Example: + { + 'aws_iam_user_mfa_enabled': {'passed': 10, 'total': 15}, + 'aws_s3_bucket_public_access': {'passed': 0, 'total': 5} + } + """ + requirement_statistics_by_check_id = {} + + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + # Use database aggregation to calculate stats without loading findings into memory + aggregated_statistics_queryset = ( + Finding.all_objects.filter(tenant_id=tenant_id, scan_id=scan_id) + .values("check_id") + .annotate( + total_findings=Count("id"), + passed_findings=Count("id", filter=Q(status=StatusChoices.PASS)), + ) + ) + + for aggregated_stat in aggregated_statistics_queryset: + check_id = aggregated_stat["check_id"] + requirement_statistics_by_check_id[check_id] = { + "passed": aggregated_stat["passed_findings"], + "total": aggregated_stat["total_findings"], + } + + logger.info( + f"Aggregated statistics for {len(requirement_statistics_by_check_id)} unique checks" + ) + return requirement_statistics_by_check_id def _load_findings_for_requirement_checks( - tenant_id: str, scan_id: str, check_ids: list[str], prowler_provider + tenant_id: str, + scan_id: str, + check_ids: list[str], + prowler_provider, + findings_cache: dict[str, list[FindingOutput]] = None, ) -> dict[str, list[FindingOutput]]: """ - Load findings for specific check IDs on-demand. + Load findings for specific check IDs on-demand with optional caching. This function loads only the findings needed for a specific set of checks, minimizing memory usage by avoiding loading all findings at once. This is used when generating detailed findings tables for specific requirements in the PDF. + Supports optional caching to avoid duplicate queries when generating multiple + reports for the same scan. + Args: tenant_id (str): The tenant ID for Row-Level Security context. scan_id (str): The ID of the scan to retrieve findings for. check_ids (list[str]): List of check IDs to load findings for. prowler_provider: The initialized Prowler provider instance. + findings_cache (dict, optional): Cache of already loaded findings. + If provided, checks are first looked up in cache before querying database. Returns: dict[str, list[FindingOutput]]: Dictionary mapping check_id to list of FindingOutput objects. @@ -474,11 +1038,40 @@ def _load_findings_for_requirement_checks( if not check_ids: return dict(findings_by_check_id) - logger.info(f"Loading findings for {len(check_ids)} checks on-demand") + # Initialize cache if not provided + if findings_cache is None: + findings_cache = {} + + # Separate cached and non-cached check_ids + check_ids_to_load = [] + cache_hits = 0 + cache_misses = 0 + + for check_id in check_ids: + if check_id in findings_cache: + # Reuse from cache + findings_by_check_id[check_id] = findings_cache[check_id] + cache_hits += 1 + else: + # Need to load from database + check_ids_to_load.append(check_id) + cache_misses += 1 + + if cache_hits > 0: + logger.info( + f"Findings cache: {cache_hits} hits, {cache_misses} misses " + f"({cache_hits / (cache_hits + cache_misses) * 100:.1f}% hit rate)" + ) + + # If all check_ids were in cache, return early + if not check_ids_to_load: + return dict(findings_by_check_id) + + logger.info(f"Loading findings for {len(check_ids_to_load)} checks on-demand") findings_queryset = ( Finding.all_objects.filter( - tenant_id=tenant_id, scan_id=scan_id, check_id__in=check_ids + tenant_id=tenant_id, scan_id=scan_id, check_id__in=check_ids_to_load ) .order_by("uid") .iterator() @@ -493,6 +1086,10 @@ def _load_findings_for_requirement_checks( finding_model, prowler_provider ) findings_by_check_id[finding_output.check_id].append(finding_output) + # Update cache with newly loaded findings + if finding_output.check_id not in findings_cache: + findings_cache[finding_output.check_id] = [] + findings_cache[finding_output.check_id].append(finding_output) total_findings_loaded = sum( len(findings) for findings in findings_by_check_id.values() @@ -504,6 +1101,84 @@ def _load_findings_for_requirement_checks( return dict(findings_by_check_id) +def _calculate_requirements_data_from_statistics( + compliance_obj, requirement_statistics_by_check_id: dict[str, dict[str, int]] +) -> tuple[dict[str, dict], list[dict]]: + """ + Calculate requirement status and statistics using pre-aggregated database statistics. + + This function uses O(n) lookups with pre-aggregated statistics from the database, + avoiding the need to iterate over all findings for each requirement. + + Args: + compliance_obj: The compliance framework object containing requirements. + requirement_statistics_by_check_id (dict[str, dict[str, int]]): Pre-aggregated statistics + mapping check_id to {'passed': int, 'total': int} counts. + + Returns: + tuple[dict[str, dict], list[dict]]: A tuple containing: + - attributes_by_requirement_id: Dictionary mapping requirement IDs to their attributes. + - requirements_list: List of requirement dictionaries with status and statistics. + """ + attributes_by_requirement_id = {} + requirements_list = [] + + compliance_framework = getattr(compliance_obj, "Framework", "N/A") + compliance_version = getattr(compliance_obj, "Version", "N/A") + + for requirement in compliance_obj.Requirements: + requirement_id = requirement.Id + requirement_description = getattr(requirement, "Description", "") + requirement_checks = getattr(requirement, "Checks", []) + requirement_attributes = getattr(requirement, "Attributes", []) + + # Store requirement metadata for later use + attributes_by_requirement_id[requirement_id] = { + "attributes": { + "req_attributes": requirement_attributes, + "checks": requirement_checks, + }, + "description": requirement_description, + } + + # Calculate aggregated passed and total findings for this requirement + total_passed_findings = 0 + total_findings_count = 0 + + for check_id in requirement_checks: + if check_id in requirement_statistics_by_check_id: + check_statistics = requirement_statistics_by_check_id[check_id] + total_findings_count += check_statistics["total"] + total_passed_findings += check_statistics["passed"] + + # Determine overall requirement status based on findings + if total_findings_count > 0: + if total_passed_findings == total_findings_count: + requirement_status = StatusChoices.PASS + else: + # Partial pass or complete fail both count as FAIL + requirement_status = StatusChoices.FAIL + else: + # No findings means manual review required + requirement_status = StatusChoices.MANUAL + + requirements_list.append( + { + "id": requirement_id, + "attributes": { + "framework": compliance_framework, + "version": compliance_version, + "status": requirement_status, + "description": requirement_description, + "passed_findings": total_passed_findings, + "total_findings": total_findings_count, + }, + } + ) + + return attributes_by_requirement_id, requirements_list + + def generate_threatscore_report( tenant_id: str, scan_id: str, @@ -512,6 +1187,9 @@ def generate_threatscore_report( provider_id: str, only_failed: bool = True, min_risk_level: int = 4, + provider_obj=None, + requirement_statistics: dict[str, dict[str, int]] = None, + findings_cache: dict[str, list[FindingOutput]] = None, ) -> None: """ Generate a PDF compliance report based on Prowler ThreatScore framework. @@ -532,6 +1210,13 @@ def generate_threatscore_report( only_failed (bool): If True, only requirements with status "FAIL" will be included in the detailed requirements section. Defaults to True. min_risk_level (int): Minimum risk level for critical failed requirements. Defaults to 4. + provider_obj (Provider, optional): Pre-fetched Provider object to avoid duplicate queries. + If None, the provider will be fetched from the database. + requirement_statistics (dict, optional): Pre-aggregated requirement statistics to avoid + duplicate database aggregations. If None, statistics will be aggregated from the database. + findings_cache (dict, optional): Cache of already loaded findings to avoid duplicate queries. + If None, findings will be loaded from the database. When provided, reduces database + queries and transformation overhead when generating multiple reports. Raises: Exception: If any error occurs during PDF generation, it will be logged and re-raised. @@ -551,22 +1236,32 @@ def generate_threatscore_report( # Get compliance and provider information with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): - provider_obj = Provider.objects.get(id=provider_id) + # Use provided provider_obj or fetch from database + if provider_obj is None: + provider_obj = Provider.objects.get(id=provider_id) + prowler_provider = initialize_prowler_provider(provider_obj) provider_type = provider_obj.provider frameworks_bulk = Compliance.get_bulk(provider_type) compliance_obj = frameworks_bulk[compliance_id] - compliance_framework = getattr(compliance_obj, "Framework", "N/A") - compliance_version = getattr(compliance_obj, "Version", "N/A") - compliance_name = getattr(compliance_obj, "Name", "N/A") - compliance_description = getattr(compliance_obj, "Description", "") + compliance_framework = _safe_getattr(compliance_obj, "Framework") + compliance_version = _safe_getattr(compliance_obj, "Version") + compliance_name = _safe_getattr(compliance_obj, "Name") + compliance_description = _safe_getattr(compliance_obj, "Description", "") # Aggregate requirement statistics from database (memory-efficient) - logger.info(f"Aggregating requirement statistics for scan {scan_id}") - requirement_statistics_by_check_id = ( - _aggregate_requirement_statistics_from_database(tenant_id, scan_id) - ) + # Use provided requirement_statistics or fetch from database + if requirement_statistics is None: + logger.info(f"Aggregating requirement statistics for scan {scan_id}") + requirement_statistics_by_check_id = ( + _aggregate_requirement_statistics_from_database(tenant_id, scan_id) + ) + else: + logger.info( + f"Reusing pre-aggregated requirement statistics for scan {scan_id}" + ) + requirement_statistics_by_check_id = requirement_statistics # Calculate requirements data using aggregated statistics attributes_by_requirement_id, requirements_list = ( @@ -612,27 +1307,8 @@ def generate_threatscore_report( ["Scan ID:", scan_id], ["Description:", Paragraph(compliance_description, normal_center)], ] - info_table = Table(info_data, colWidths=[2 * inch, 4 * inch]) - info_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 5), colors.Color(0.2, 0.4, 0.6)), - ("TEXTCOLOR", (0, 0), (0, 5), colors.white), - ("FONTNAME", (0, 0), (0, 5), "FiraCode"), - ("BACKGROUND", (1, 0), (1, 5), colors.Color(0.95, 0.97, 1.0)), - ("TEXTCOLOR", (1, 0), (1, 5), colors.Color(0.2, 0.2, 0.2)), - ("FONTNAME", (1, 0), (1, 5), "PlusJakartaSans"), - ("ALIGN", (0, 0), (-1, -1), "LEFT"), - ("VALIGN", (0, 0), (-1, -1), "TOP"), - ("FONTSIZE", (0, 0), (-1, -1), 11), - ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.8, 0.9)), - ("LEFTPADDING", (0, 0), (-1, -1), 10), - ("RIGHTPADDING", (0, 0), (-1, -1), 10), - ("TOPPADDING", (0, 0), (-1, -1), 8), - ("BOTTOMPADDING", (0, 0), (-1, -1), 8), - ] - ) - ) + info_table = Table(info_data, colWidths=[COL_WIDTH_XLARGE, 4 * inch]) + info_table.setStyle(_create_info_table_style()) elements.append(info_table) elements.append(PageBreak()) @@ -697,12 +1373,7 @@ def generate_threatscore_report( ["ThreatScore:", f"{overall_compliance:.2f}%"], ] - if overall_compliance >= 80: - compliance_color = colors.Color(0.2, 0.8, 0.2) - elif overall_compliance >= 60: - compliance_color = colors.Color(0.8, 0.8, 0.2) - else: - compliance_color = colors.Color(0.8, 0.2, 0.2) + compliance_color = _get_color_for_compliance(overall_compliance) summary_table = Table(summary_data, colWidths=[2.5 * inch, 2 * inch]) summary_table.setStyle( @@ -978,7 +1649,7 @@ def generate_threatscore_report( f"Loading findings on-demand for {len(sorted_requirements)} requirements" ) findings_by_check_id = _load_findings_for_requirement_checks( - tenant_id, scan_id, check_ids_to_load, prowler_provider + tenant_id, scan_id, check_ids_to_load, prowler_provider, findings_cache ) for requirement in sorted_requirements: @@ -1133,148 +1804,1248 @@ def generate_threatscore_report( raise e -def generate_threatscore_report_job( - tenant_id: str, scan_id: str, provider_id: str -) -> dict[str, bool | str]: +def generate_ens_report( + tenant_id: str, + scan_id: str, + compliance_id: str, + output_path: str, + provider_id: str, + include_manual: bool = True, + provider_obj=None, + requirement_statistics: dict[str, dict[str, int]] = None, + findings_cache: dict[str, list[FindingOutput]] = None, +) -> None: """ - Job function to generate a threatscore report and upload it to S3. + Generate a PDF compliance report for ENS RD2022 framework. - This function orchestrates the complete report generation workflow: - 1. Validates that the scan has findings - 2. Checks provider type compatibility - 3. Generates the output directory - 4. Calls generate_threatscore_report to create the PDF - 5. Computes and stores ThreatScore metrics snapshot - 6. Uploads the PDF to S3 - 7. Cleans up temporary files + This function creates a comprehensive PDF report containing: + - Compliance overview and metadata + - Executive summary with overall compliance score + - Marco/Categoría analysis with charts + - Security dimensions radar chart + - Requirement type distribution + - Execution mode distribution + - Critical failed requirements (nivel alto) + - Requirements index + - Detailed findings for failed and manual requirements Args: tenant_id (str): The tenant ID for Row-Level Security context. - scan_id (str): The ID of the scan to generate a report for. + scan_id (str): ID of the scan executed by Prowler. + compliance_id (str): ID of the compliance framework (e.g., "ens_rd2022_aws"). + output_path (str): Output PDF file path (e.g., "/tmp/ens_report.pdf"). + provider_id (str): Provider ID for the scan. + include_manual (bool): If True, include requirements with manual execution mode + in the detailed requirements section. Defaults to True. + provider_obj (Provider, optional): Pre-fetched Provider object to avoid duplicate queries. + If None, the provider will be fetched from the database. + requirement_statistics (dict, optional): Pre-aggregated requirement statistics to avoid + duplicate database aggregations. If None, statistics will be aggregated from the database. + findings_cache (dict, optional): Cache of already loaded findings to avoid duplicate queries. + If None, findings will be loaded from the database. When provided, reduces database + queries and transformation overhead when generating multiple reports. + + Raises: + Exception: If any error occurs during PDF generation, it will be logged and re-raised. + """ + logger.info(f"Generating ENS report for scan {scan_id} with provider {provider_id}") + try: + # Get PDF styles + pdf_styles = _create_pdf_styles() + title_style = pdf_styles["title"] + h1 = pdf_styles["h1"] + h2 = pdf_styles["h2"] + h3 = pdf_styles["h3"] + normal = pdf_styles["normal"] + normal_center = pdf_styles["normal_center"] + + # Get compliance and provider information + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + # Use provided provider_obj or fetch from database + if provider_obj is None: + provider_obj = Provider.objects.get(id=provider_id) + + prowler_provider = initialize_prowler_provider(provider_obj) + provider_type = provider_obj.provider + + frameworks_bulk = Compliance.get_bulk(provider_type) + compliance_obj = frameworks_bulk[compliance_id] + compliance_framework = _safe_getattr(compliance_obj, "Framework") + compliance_version = _safe_getattr(compliance_obj, "Version") + compliance_name = _safe_getattr(compliance_obj, "Name") + compliance_description = _safe_getattr(compliance_obj, "Description", "") + + # Aggregate requirement statistics from database (memory-efficient) + # Use provided requirement_statistics or fetch from database + if requirement_statistics is None: + logger.info(f"Aggregating requirement statistics for scan {scan_id}") + requirement_statistics_by_check_id = ( + _aggregate_requirement_statistics_from_database(tenant_id, scan_id) + ) + else: + logger.info( + f"Reusing pre-aggregated requirement statistics for scan {scan_id}" + ) + requirement_statistics_by_check_id = requirement_statistics + + # Calculate requirements data using aggregated statistics + attributes_by_requirement_id, requirements_list = ( + _calculate_requirements_data_from_statistics( + compliance_obj, requirement_statistics_by_check_id + ) + ) + + # Count manual requirements before filtering + manual_requirements_count = sum( + 1 + for req in requirements_list + if req["attributes"]["status"] == StatusChoices.MANUAL + ) + total_requirements_count = len(requirements_list) + + # Filter out manual requirements for the report + requirements_list = [ + req + for req in requirements_list + if req["attributes"]["status"] != StatusChoices.MANUAL + ] + + logger.info( + f"Filtered {manual_requirements_count} manual requirements out of {total_requirements_count} total requirements" + ) + + # Initialize PDF document + doc = SimpleDocTemplate( + output_path, + pagesize=letter, + title="Informe de Cumplimiento ENS - Prowler", + author="Prowler", + subject=f"Informe de Cumplimiento para {compliance_framework}", + creator="Prowler Engineering Team", + keywords=f"compliance,{compliance_framework},security,ens,prowler", + ) + + elements = [] + + # SECTION 1: PORTADA (Cover Page) + # Create logos side by side + prowler_logo_path = os.path.join( + os.path.dirname(__file__), "../assets/img/prowler_logo.png" + ) + ens_logo_path = os.path.join( + os.path.dirname(__file__), "../assets/img/ens_logo.png" + ) + + prowler_logo = Image( + prowler_logo_path, + width=3.5 * inch, + height=0.7 * inch, + ) + ens_logo = Image( + ens_logo_path, + width=1.5 * inch, + height=2 * inch, + ) + + # Create table with both logos + logos_table = Table( + [[prowler_logo, ens_logo]], colWidths=[4 * inch, 2.5 * inch] + ) + logos_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("ALIGN", (1, 0), (1, 0), "RIGHT"), + ("VALIGN", (0, 0), (0, 0), "MIDDLE"), # Prowler logo middle + ("VALIGN", (1, 0), (1, 0), "TOP"), # ENS logo top + ] + ) + ) + elements.append(logos_table) + elements.append(Spacer(1, 0.3 * inch)) + elements.append( + Paragraph("Informe de Cumplimiento ENS RD 311/2022", title_style) + ) + elements.append(Spacer(1, 0.5 * inch)) + + # Add compliance information table + info_data = [ + ["Framework:", compliance_framework], + ["ID:", compliance_id], + ["Nombre:", Paragraph(compliance_name, normal_center)], + ["Versión:", compliance_version], + ["Proveedor:", provider_type.upper()], + ["Scan ID:", scan_id], + ["Descripción:", Paragraph(compliance_description, normal_center)], + ] + info_table = Table(info_data, colWidths=[2 * inch, 4 * inch]) + info_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 6), colors.Color(0.2, 0.4, 0.6)), + ("TEXTCOLOR", (0, 0), (0, 6), colors.white), + ("FONTNAME", (0, 0), (0, 6), "FiraCode"), + ("BACKGROUND", (1, 0), (1, 6), colors.Color(0.95, 0.97, 1.0)), + ("TEXTCOLOR", (1, 0), (1, 6), colors.Color(0.2, 0.2, 0.2)), + ("FONTNAME", (1, 0), (1, 6), "PlusJakartaSans"), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.8, 0.9)), + ("LEFTPADDING", (0, 0), (-1, -1), 10), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ] + ) + ) + elements.append(info_table) + elements.append(Spacer(1, 0.5 * inch)) + + # Add warning about excluded manual requirements + warning_text = ( + f"AVISO: Este informe no incluye los requisitos de ejecución manual. " + f"El compliance {compliance_id} contiene un total de " + f"{manual_requirements_count} requisitos manuales que no han sido evaluados " + f"automáticamente y por tanto no están reflejados en las estadísticas de este reporte. " + f"El análisis se basa únicamente en los {len(requirements_list)} requisitos automatizados." + ) + warning_paragraph = Paragraph(warning_text, normal) + warning_table = Table([[warning_paragraph]], colWidths=[6 * inch]) + warning_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(1.0, 0.95, 0.7)), + ("TEXTCOLOR", (0, 0), (0, 0), colors.Color(0.4, 0.3, 0.0)), + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("VALIGN", (0, 0), (0, 0), "MIDDLE"), + ("BOX", (0, 0), (-1, -1), 2, colors.Color(0.9, 0.7, 0.0)), + ("LEFTPADDING", (0, 0), (-1, -1), 15), + ("RIGHTPADDING", (0, 0), (-1, -1), 15), + ("TOPPADDING", (0, 0), (-1, -1), 12), + ("BOTTOMPADDING", (0, 0), (-1, -1), 12), + ] + ) + ) + elements.append(warning_table) + elements.append(Spacer(1, 0.5 * inch)) + + # Add legend explaining ENS values + elements.append(Paragraph("Leyenda de Valores ENS", h2)) + elements.append(Spacer(1, 0.2 * inch)) + + legend_text = """ + Nivel (Criticidad del requisito):
+ • Alto: Requisitos críticos que deben cumplirse prioritariamente
+ • Medio: Requisitos importantes con impacto moderado
+ • Bajo: Requisitos complementarios de menor criticidad
+ • Opcional: Recomendaciones adicionales no obligatorias
+
+ Tipo (Clasificación del requisito):
+ • Requisito: Obligación establecida por el ENS
+ • Refuerzo: Medida adicional que refuerza un requisito
+ • Recomendación: Buena práctica sugerida
+ • Medida: Acción concreta de implementación
+
+ Modo de Ejecución:
+ • Automático: El requisito puede verificarse automáticamente mediante escaneo
+ • Manual: Requiere verificación manual por parte de un auditor
+
+ Dimensiones de Seguridad:
+ • C (Confidencialidad): Protección contra accesos no autorizados a la información
+ • I (Integridad): Garantía de exactitud y completitud de la información
+ • T (Trazabilidad): Capacidad de rastrear acciones y eventos
+ • A (Autenticidad): Verificación de identidad de usuarios y sistemas
+ • D (Disponibilidad): Acceso a la información cuando se necesita
+
+ Estados de Cumplimiento:
+ • CUMPLE (PASS): El requisito se cumple satisfactoriamente
+ • NO CUMPLE (FAIL): El requisito no se cumple y requiere corrección
+ • MANUAL: Requiere revisión manual para determinar cumplimiento + """ + legend_paragraph = Paragraph(legend_text, normal) + legend_table = Table([[legend_paragraph]], colWidths=[6.5 * inch]) + legend_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.95, 0.97, 1.0)), + ("TEXTCOLOR", (0, 0), (0, 0), colors.Color(0.2, 0.2, 0.2)), + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("VALIGN", (0, 0), (0, 0), "TOP"), + ("BOX", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.8)), + ("LEFTPADDING", (0, 0), (-1, -1), 15), + ("RIGHTPADDING", (0, 0), (-1, -1), 15), + ("TOPPADDING", (0, 0), (-1, -1), 12), + ("BOTTOMPADDING", (0, 0), (-1, -1), 12), + ] + ) + ) + elements.append(legend_table) + elements.append(PageBreak()) + + # SECTION 2: RESUMEN EJECUTIVO (Executive Summary) + elements.append(Paragraph("Resumen Ejecutivo", h1)) + elements.append(Spacer(1, 0.2 * inch)) + + # Calculate overall compliance (simple PASS/TOTAL) + total_requirements = len(requirements_list) + passed_requirements = sum( + 1 + for req in requirements_list + if req["attributes"]["status"] == StatusChoices.PASS + ) + failed_requirements = sum( + 1 + for req in requirements_list + if req["attributes"]["status"] == StatusChoices.FAIL + ) + + overall_compliance = ( + (passed_requirements / total_requirements * 100) + if total_requirements > 0 + else 0 + ) + + if overall_compliance >= 80: + compliance_color = colors.Color(0.2, 0.8, 0.2) + elif overall_compliance >= 60: + compliance_color = colors.Color(0.8, 0.8, 0.2) + else: + compliance_color = colors.Color(0.8, 0.2, 0.2) + + summary_data = [ + ["Nivel de Cumplimiento Global:", f"{overall_compliance:.2f}%"], + ] + + summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch]) + summary_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)), + ("TEXTCOLOR", (0, 0), (0, 0), colors.white), + ("FONTNAME", (0, 0), (0, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (0, 0), 12), + ("BACKGROUND", (1, 0), (1, 0), compliance_color), + ("TEXTCOLOR", (1, 0), (1, 0), colors.white), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("FONTSIZE", (1, 0), (1, 0), 16), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)), + ("LEFTPADDING", (0, 0), (-1, -1), 12), + ("RIGHTPADDING", (0, 0), (-1, -1), 12), + ("TOPPADDING", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ] + ) + ) + elements.append(summary_table) + elements.append(Spacer(1, 0.3 * inch)) + + # Summary counts table + counts_data = [ + ["Estado", "Cantidad", "Porcentaje"], + [ + "CUMPLE", + str(passed_requirements), + f"{(passed_requirements / total_requirements * 100):.1f}%", + ], + [ + "NO CUMPLE", + str(failed_requirements), + f"{(failed_requirements / total_requirements * 100):.1f}%", + ], + ["TOTAL", str(total_requirements), "100%"], + ] + + counts_table = Table(counts_data, colWidths=[2 * inch, 1.5 * inch, 1.5 * inch]) + counts_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.2, 0.4, 0.6)), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("BACKGROUND", (0, 1), (0, 1), colors.Color(0.2, 0.8, 0.2)), + ("TEXTCOLOR", (0, 1), (0, 1), colors.white), + ("BACKGROUND", (0, 2), (0, 2), colors.Color(0.8, 0.2, 0.2)), + ("TEXTCOLOR", (0, 2), (0, 2), colors.white), + ("BACKGROUND", (0, 3), (0, 3), colors.Color(0.4, 0.4, 0.4)), + ("TEXTCOLOR", (0, 3), (0, 3), colors.white), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + elements.append(counts_table) + elements.append(Spacer(1, 0.3 * inch)) + + # Summary by Nivel + nivel_data = defaultdict(lambda: {"passed": 0, "total": 0}) + for requirement in requirements_list: + requirement_id = requirement["id"] + requirement_attributes = attributes_by_requirement_id.get( + requirement_id, {} + ) + requirement_status = requirement["attributes"]["status"] + + metadata = requirement_attributes.get("attributes", {}).get( + "req_attributes", [] + ) + if not metadata: + continue + + m = metadata[0] + nivel = _safe_getattr(m, "Nivel") + nivel_data[nivel]["total"] += 1 + if requirement_status == StatusChoices.PASS: + nivel_data[nivel]["passed"] += 1 + + elements.append(Paragraph("Cumplimiento por Nivel", h2)) + nivel_table_data = [["Nivel", "Cumplidos", "Total", "Porcentaje"]] + for nivel in ENS_NIVEL_ORDER: + if nivel in nivel_data: + data = nivel_data[nivel] + percentage = ( + (data["passed"] / data["total"] * 100) if data["total"] > 0 else 0 + ) + nivel_table_data.append( + [ + nivel.capitalize(), + str(data["passed"]), + str(data["total"]), + f"{percentage:.1f}%", + ] + ) + + nivel_table = Table( + nivel_table_data, colWidths=[1.5 * inch, 1.5 * inch, 1.5 * inch, 1.5 * inch] + ) + nivel_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.2, 0.4, 0.6)), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + elements.append(nivel_table) + elements.append(PageBreak()) + + # SECTION 3: ANÁLISIS POR MARCOS (Marco Analysis) + elements.append(Paragraph("Análisis por Marcos y Categorías", h1)) + elements.append(Spacer(1, 0.2 * inch)) + + chart_buffer = _create_marco_category_chart( + requirements_list, attributes_by_requirement_id + ) + chart_image = Image(chart_buffer, width=7 * inch, height=5 * inch) + elements.append(chart_image) + elements.append(PageBreak()) + + # SECTION 4: DIMENSIONES DE SEGURIDAD (Security Dimensions) + elements.append(Paragraph("Análisis por Dimensiones de Seguridad", h1)) + elements.append(Spacer(1, 0.2 * inch)) + + radar_buffer = _create_dimensions_radar_chart( + requirements_list, attributes_by_requirement_id + ) + radar_image = Image(radar_buffer, width=6 * inch, height=6 * inch) + elements.append(radar_image) + elements.append(PageBreak()) + + # SECTION 5: DISTRIBUCIÓN POR TIPO (Type Distribution) + elements.append(Paragraph("Distribución por Tipo de Requisito", h1)) + elements.append(Spacer(1, 0.2 * inch)) + + tipo_data = defaultdict(lambda: {"passed": 0, "total": 0}) + for requirement in requirements_list: + requirement_id = requirement["id"] + requirement_attributes = attributes_by_requirement_id.get( + requirement_id, {} + ) + requirement_status = requirement["attributes"]["status"] + + metadata = requirement_attributes.get("attributes", {}).get( + "req_attributes", [] + ) + if not metadata: + continue + + m = metadata[0] + tipo = _safe_getattr(m, "Tipo") + tipo_data[tipo]["total"] += 1 + if requirement_status == StatusChoices.PASS: + tipo_data[tipo]["passed"] += 1 + + tipo_table_data = [["Tipo", "Cumplidos", "Total", "Porcentaje"]] + for tipo in ENS_TIPO_ORDER: + if tipo in tipo_data: + data = tipo_data[tipo] + percentage = ( + (data["passed"] / data["total"] * 100) if data["total"] > 0 else 0 + ) + tipo_table_data.append( + [ + tipo.capitalize(), + str(data["passed"]), + str(data["total"]), + f"{percentage:.1f}%", + ] + ) + + tipo_table = Table( + tipo_table_data, colWidths=[2 * inch, 1.5 * inch, 1.5 * inch, 1.5 * inch] + ) + tipo_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.2, 0.4, 0.6)), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + elements.append(tipo_table) + elements.append(PageBreak()) + + # SECTION 6: REQUISITOS CRÍTICOS NO CUMPLIDOS (Critical Failed Requirements) + elements.append(Paragraph("Requisitos Críticos No Cumplidos", h1)) + elements.append(Spacer(1, 0.2 * inch)) + + critical_failed = [] + for requirement in requirements_list: + requirement_status = requirement["attributes"]["status"] + if requirement_status == StatusChoices.FAIL: + requirement_id = requirement["id"] + req_attributes = attributes_by_requirement_id.get( + requirement_id, {} + ).get("attributes", {}) + metadata_list = req_attributes.get("req_attributes", []) + if metadata_list: + metadata = metadata_list[0] + nivel = _safe_getattr(metadata, "Nivel", "") + if nivel.lower() == "alto": + critical_failed.append( + { + "requirement": requirement, + "metadata": metadata, + } + ) + + if not critical_failed: + elements.append( + Paragraph( + "✅ No se encontraron requisitos críticos no cumplidos.", normal + ) + ) + else: + elements.append( + Paragraph( + f"Se encontraron {len(critical_failed)} requisitos de nivel Alto que no cumplen:", + normal, + ) + ) + elements.append(Spacer(1, 0.3 * inch)) + + critical_table_data = [["ID", "Descripción", "Marco", "Categoría"]] + for item in critical_failed: + requirement_id = item["requirement"]["id"] + description = item["requirement"]["attributes"]["description"] + marco = _safe_getattr(item["metadata"], "Marco") + categoria = _safe_getattr(item["metadata"], "Categoria") + + if len(description) > 60: + description = description[:57] + "..." + + critical_table_data.append( + [requirement_id, description, marco, categoria] + ) + + critical_table = Table( + critical_table_data, + colWidths=[1.5 * inch, 3.3 * inch, 1.5 * inch, 2 * inch], + ) + critical_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.8, 0.2, 0.2)), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 9), + ("FONTNAME", (0, 1), (0, -1), "FiraCode"), + ("FONTSIZE", (0, 1), (-1, -1), 8), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ( + "BACKGROUND", + (1, 1), + (-1, -1), + colors.Color(0.98, 0.98, 0.98), + ), + ] + ) + ) + elements.append(critical_table) + + elements.append(PageBreak()) + + # SECTION 7: ÍNDICE DE REQUISITOS (Requirements Index) + elements.append(Paragraph("Índice de Requisitos", h1)) + elements.append(Spacer(1, 0.2 * inch)) + + # Group by Marco → Categoría + marco_categoria_index = defaultdict(lambda: defaultdict(list)) + for ( + requirement_id, + requirement_attributes, + ) in attributes_by_requirement_id.items(): + metadata = requirement_attributes["attributes"]["req_attributes"][0] + marco = getattr(metadata, "Marco", "N/A") + categoria = getattr(metadata, "Categoria", "N/A") + id_grupo = getattr(metadata, "IdGrupoControl", "N/A") + + marco_categoria_index[marco][categoria].append( + { + "id": requirement_id, + "id_grupo": id_grupo, + "description": requirement_attributes["description"], + } + ) + + for marco, categorias in sorted(marco_categoria_index.items()): + elements.append(Paragraph(f"Marco: {marco.capitalize()}", h2)) + for categoria, requirements in sorted(categorias.items()): + elements.append(Paragraph(f"Categoría: {categoria.capitalize()}", h3)) + for req in requirements: + desc = req["description"] + if len(desc) > 80: + desc = desc[:77] + "..." + elements.append(Paragraph(f"{req['id']} - {desc}", normal)) + elements.append(Spacer(1, 0.05 * inch)) + + elements.append(PageBreak()) + + # SECTION 8: DETALLE DE REQUISITOS (Detailed Requirements) + elements.append(Paragraph("Detalle de Requisitos", h1)) + elements.append(Spacer(1, 0.2 * inch)) + + # Filter: NO CUMPLE + MANUAL (if include_manual) + filtered_requirements = [ + req + for req in requirements_list + if req["attributes"]["status"] == StatusChoices.FAIL + or (include_manual and req["attributes"]["status"] == StatusChoices.MANUAL) + ] + + if not filtered_requirements: + elements.append( + Paragraph("✅ Todos los requisitos automáticos cumplen.", normal) + ) + else: + elements.append( + Paragraph( + f"Se muestran {len(filtered_requirements)} requisitos que requieren atención:", + normal, + ) + ) + elements.append(Spacer(1, 0.2 * inch)) + + # Collect check IDs to load + check_ids_to_load = [] + for requirement in filtered_requirements: + requirement_id = requirement["id"] + requirement_attributes = attributes_by_requirement_id.get( + requirement_id, {} + ) + check_ids = requirement_attributes.get("attributes", {}).get( + "checks", [] + ) + check_ids_to_load.extend(check_ids) + + # Load findings on-demand + logger.info( + f"Loading findings on-demand for {len(filtered_requirements)} requirements" + ) + findings_by_check_id = _load_findings_for_requirement_checks( + tenant_id, scan_id, check_ids_to_load, prowler_provider, findings_cache + ) + + for requirement in filtered_requirements: + requirement_id = requirement["id"] + requirement_attributes = attributes_by_requirement_id.get( + requirement_id, {} + ) + requirement_status = requirement["attributes"]["status"] + requirement_description = requirement_attributes.get("description", "") + + # Requirement ID header in a box + req_id_paragraph = Paragraph(requirement_id, h2) + req_id_table = Table([[req_id_paragraph]], colWidths=[6.5 * inch]) + req_id_table.setStyle( + TableStyle( + [ + ( + "BACKGROUND", + (0, 0), + (0, 0), + colors.Color(0.15, 0.35, 0.55), + ), + ("TEXTCOLOR", (0, 0), (0, 0), colors.white), + ("ALIGN", (0, 0), (0, 0), "CENTER"), + ("VALIGN", (0, 0), (0, 0), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 15), + ("RIGHTPADDING", (0, 0), (-1, -1), 15), + ("TOPPADDING", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ("BOX", (0, 0), (-1, -1), 2, colors.Color(0.2, 0.4, 0.6)), + ] + ) + ) + elements.append(req_id_table) + elements.append(Spacer(1, 0.15 * inch)) + + metadata = requirement_attributes.get("attributes", {}).get( + "req_attributes", [] + ) + if metadata and len(metadata) > 0: + m = metadata[0] + + # Create all badges + status_component = _create_status_component(requirement_status) + nivel = getattr(m, "Nivel", "N/A") + nivel_badge = _create_ens_nivel_badge(nivel) + tipo = getattr(m, "Tipo", "N/A") + tipo_badge = _create_ens_tipo_badge(tipo) + + # Organize badges in a horizontal table (2 rows x 2 cols) + badges_table = Table( + [[status_component, nivel_badge], [tipo_badge]], + colWidths=[3.25 * inch, 3.25 * inch], + ) + badges_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 5), + ("RIGHTPADDING", (0, 0), (-1, -1), 5), + ("TOPPADDING", (0, 0), (-1, -1), 5), + ("BOTTOMPADDING", (0, 0), (-1, -1), 5), + ] + ) + ) + elements.append(badges_table) + elements.append(Spacer(1, 0.15 * inch)) + + # Dimensiones badges (if present) + dimensiones = getattr(m, "Dimensiones", []) + if dimensiones: + dim_label = Paragraph("Dimensiones:", normal) + dim_badges = _create_ens_dimension_badges(dimensiones) + dim_table = Table( + [[dim_label, dim_badges]], colWidths=[1.5 * inch, 5 * inch] + ) + dim_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("ALIGN", (1, 0), (1, 0), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ] + ) + ) + elements.append(dim_table) + elements.append(Spacer(1, 0.15 * inch)) + + # Requirement details in a clean table + details_data = [ + ["Descripción:", Paragraph(requirement_description, normal)], + ["Marco:", Paragraph(getattr(m, "Marco", "N/A"), normal)], + [ + "Categoría:", + Paragraph(getattr(m, "Categoria", "N/A"), normal), + ], + [ + "ID Grupo Control:", + Paragraph(getattr(m, "IdGrupoControl", "N/A"), normal), + ], + [ + "Descripción del Control:", + Paragraph(getattr(m, "DescripcionControl", "N/A"), normal), + ], + ] + details_table = Table( + details_data, colWidths=[2.2 * inch, 4.5 * inch] + ) + details_table.setStyle( + TableStyle( + [ + ( + "BACKGROUND", + (0, 0), + (0, -1), + colors.Color(0.9, 0.93, 0.96), + ), + ( + "TEXTCOLOR", + (0, 0), + (0, -1), + colors.Color(0.2, 0.2, 0.2), + ), + ("FONTNAME", (0, 0), (0, -1), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("ALIGN", (0, 0), (0, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ( + "GRID", + (0, 0), + (-1, -1), + 0.5, + colors.Color(0.7, 0.8, 0.9), + ), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + elements.append(details_table) + elements.append(Spacer(1, 0.2 * inch)) + + # Findings for checks + requirement_check_ids = requirement_attributes.get( + "attributes", {} + ).get("checks", []) + for check_id in requirement_check_ids: + elements.append(Paragraph(f"Check: {check_id}", h2)) + elements.append(Spacer(1, 0.1 * inch)) + + check_findings = findings_by_check_id.get(check_id, []) + + if not check_findings: + elements.append( + Paragraph( + "- No hay información disponible para este check", + normal, + ) + ) + else: + findings_table_data = [ + ["Finding", "Resource name", "Severity", "Status", "Region"] + ] + for finding_output in check_findings: + check_metadata = getattr(finding_output, "metadata", {}) + finding_title = getattr( + check_metadata, + "CheckTitle", + getattr(finding_output, "check_id", ""), + ) + resource_name = getattr(finding_output, "resource_name", "") + if not resource_name: + resource_name = getattr( + finding_output, "resource_uid", "" + ) + severity = getattr( + check_metadata, "Severity", "" + ).capitalize() + finding_status = getattr( + finding_output, "status", "" + ).upper() + region = getattr(finding_output, "region", "global") + + findings_table_data.append( + [ + Paragraph(finding_title, normal_center), + Paragraph(resource_name, normal_center), + Paragraph(severity, normal_center), + Paragraph(finding_status, normal_center), + Paragraph(region, normal_center), + ] + ) + + findings_table = Table( + findings_table_data, + colWidths=[ + 2.5 * inch, + 3 * inch, + 0.9 * inch, + 0.9 * inch, + 0.9 * inch, + ], + ) + findings_table.setStyle( + TableStyle( + [ + ( + "BACKGROUND", + (0, 0), + (-1, 0), + colors.Color(0.2, 0.4, 0.6), + ), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("ALIGN", (0, 0), (0, 0), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ( + "GRID", + (0, 0), + (-1, -1), + 0.1, + colors.Color(0.7, 0.8, 0.9), + ), + ("LEFTPADDING", (0, 0), (0, 0), 0), + ("RIGHTPADDING", (0, 0), (0, 0), 0), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ] + ) + ) + elements.append(findings_table) + + elements.append(Spacer(1, 0.1 * inch)) + + elements.append(PageBreak()) + + # Build the PDF + logger.info("Building PDF...") + doc.build(elements, onFirstPage=_add_pdf_footer, onLaterPages=_add_pdf_footer) + except Exception as e: + logger.error( + f"Error building ENS report, line {e.__traceback__.tb_lineno} -- {e}" + ) + raise e + + +def generate_compliance_reports( + tenant_id: str, + scan_id: str, + provider_id: str, + generate_threatscore: bool = True, + generate_ens: bool = True, + only_failed_threatscore: bool = True, + min_risk_level_threatscore: int = 4, + include_manual_ens: bool = True, +) -> dict[str, dict[str, bool | str]]: + """ + Generate multiple compliance reports (ThreatScore and/or ENS) with shared database queries. + + This function optimizes the generation of multiple reports by: + - Fetching the provider object once + - Aggregating requirement statistics once (shared across both reports) + - Reusing compliance framework data when possible + + This can reduce database queries by up to 50% when generating both reports. + + Args: + tenant_id (str): The tenant ID for Row-Level Security context. + scan_id (str): The ID of the scan to generate reports for. provider_id (str): The ID of the provider used in the scan. + generate_threatscore (bool): Whether to generate ThreatScore report. Defaults to True. + generate_ens (bool): Whether to generate ENS report. Defaults to True. + only_failed_threatscore (bool): For ThreatScore, only include failed requirements. Defaults to True. + min_risk_level_threatscore (int): Minimum risk level for ThreatScore critical requirements. Defaults to 4. + include_manual_ens (bool): For ENS, include manual requirements. Defaults to True. Returns: - dict[str, bool | str]: A dictionary containing: - - 'upload' (bool): True if the report was successfully uploaded to S3, False otherwise. - - 'error' (str): Error message if an exception occurred (only present on error). + dict[str, dict[str, bool | str]]: Dictionary with results for each report: + { + 'threatscore': {'upload': bool, 'path': str, 'error': str (optional)}, + 'ens': {'upload': bool, 'path': str, 'error': str (optional)} + } + + Example: + >>> results = generate_compliance_reports( + ... tenant_id="tenant-123", + ... scan_id="scan-456", + ... provider_id="provider-789", + ... generate_threatscore=True, + ... generate_ens=True + ... ) + >>> print(results['threatscore']['upload']) + True """ - # Check if the scan has findings and get provider info + logger.info( + f"Generating compliance reports for scan {scan_id} with provider {provider_id}" + f" (ThreatScore: {generate_threatscore}, ENS: {generate_ens})" + ) + + results = {} + + # Validate that the scan has findings and get provider info (shared query) with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): if not ScanSummary.objects.filter(scan_id=scan_id).exists(): logger.info(f"No findings found for scan {scan_id}") - return {"upload": False} + if generate_threatscore: + results["threatscore"] = {"upload": False, "path": ""} + if generate_ens: + results["ens"] = {"upload": False, "path": ""} + return results + # Fetch provider once (optimization) provider_obj = Provider.objects.get(id=provider_id) provider_uid = provider_obj.uid provider_type = provider_obj.provider - if provider_type not in ["aws", "azure", "gcp", "m365", "kubernetes"]: - logger.info( - f"Provider {provider_id} is not supported for threatscore report" - ) - return {"upload": False} - - # This compliance is hardcoded because is the only one that is available for the threatscore report - compliance_id = f"prowler_threatscore_{provider_type}" - logger.info( - f"Generating threatscore report for scan {scan_id} with compliance {compliance_id} inside the job" - ) - try: - logger.info("Generating the output directory") - out_dir, _, threatscore_path = _generate_output_directory( - DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id + # Check provider compatibility + if generate_threatscore and provider_type not in [ + "aws", + "azure", + "gcp", + "m365", + "kubernetes", + ]: + logger.info( + f"Provider {provider_id} ({provider_type}) is not supported for ThreatScore report" ) + results["threatscore"] = {"upload": False, "path": ""} + generate_threatscore = False + + if generate_ens and provider_type not in ["aws", "azure", "gcp"]: + logger.info( + f"Provider {provider_id} ({provider_type}) is not supported for ENS report" + ) + results["ens"] = {"upload": False, "path": ""} + generate_ens = False + + # If no reports to generate, return early + if not generate_threatscore and not generate_ens: + return results + + # Aggregate requirement statistics once (major optimization) + logger.info( + f"Aggregating requirement statistics once for all reports (scan {scan_id})" + ) + requirement_statistics = _aggregate_requirement_statistics_from_database( + tenant_id, scan_id + ) + + # Create shared findings cache (major optimization for findings queries) + findings_cache = {} + logger.info("Created shared findings cache for both reports") + + # Generate output directories for each compliance framework + try: + logger.info("Generating output directories") + threatscore_path = _generate_compliance_output_directory( + DJANGO_TMP_OUTPUT_DIRECTORY, + provider_uid, + tenant_id, + scan_id, + compliance_framework="threatscore", + ) + ens_path = _generate_compliance_output_directory( + DJANGO_TMP_OUTPUT_DIRECTORY, + provider_uid, + tenant_id, + scan_id, + compliance_framework="ens", + ) + # Extract base scan directory for cleanup (parent of threatscore directory) + out_dir = str(Path(threatscore_path).parent.parent) except Exception as e: logger.error(f"Error generating output directory: {e}") - return {"error": str(e)} + error_dict = {"error": str(e), "upload": False, "path": ""} + if generate_threatscore: + results["threatscore"] = error_dict.copy() + if generate_ens: + results["ens"] = error_dict.copy() + return results - pdf_path = f"{threatscore_path}_threatscore_report.pdf" - logger.info(f"The path for the threatscore report is {pdf_path}") - generate_threatscore_report( - tenant_id=tenant_id, - scan_id=scan_id, - compliance_id=compliance_id, - output_path=pdf_path, - provider_id=provider_id, - only_failed=True, - min_risk_level=4, + # Generate ThreatScore report + if generate_threatscore: + compliance_id_threatscore = f"prowler_threatscore_{provider_type}" + pdf_path_threatscore = f"{threatscore_path}_threatscore_report.pdf" + logger.info( + f"Generating ThreatScore report with compliance {compliance_id_threatscore}" + ) + + try: + generate_threatscore_report( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id_threatscore, + output_path=pdf_path_threatscore, + provider_id=provider_id, + only_failed=only_failed_threatscore, + min_risk_level=min_risk_level_threatscore, + provider_obj=provider_obj, # Reuse provider object + requirement_statistics=requirement_statistics, # Reuse statistics + findings_cache=findings_cache, # Share findings cache + ) + + upload_uri_threatscore = _upload_to_s3( + tenant_id, + scan_id, + pdf_path_threatscore, + f"threatscore/{Path(pdf_path_threatscore).name}", + ) + + if upload_uri_threatscore: + results["threatscore"] = { + "upload": True, + "path": upload_uri_threatscore, + } + logger.info(f"ThreatScore report uploaded to {upload_uri_threatscore}") + else: + results["threatscore"] = {"upload": False, "path": out_dir} + logger.warning(f"ThreatScore report saved locally at {out_dir}") + + except Exception as e: + logger.error(f"Error generating ThreatScore report: {e}") + results["threatscore"] = {"upload": False, "path": "", "error": str(e)} + + # Generate ENS report + if generate_ens: + compliance_id_ens = f"ens_rd2022_{provider_type}" + pdf_path_ens = f"{ens_path}_ens_report.pdf" + logger.info(f"Generating ENS report with compliance {compliance_id_ens}") + + try: + generate_ens_report( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id_ens, + output_path=pdf_path_ens, + provider_id=provider_id, + include_manual=include_manual_ens, + provider_obj=provider_obj, # Reuse provider object + requirement_statistics=requirement_statistics, # Reuse statistics + findings_cache=findings_cache, # Share findings cache + ) + + upload_uri_ens = _upload_to_s3( + tenant_id, + scan_id, + pdf_path_ens, + f"ens/{Path(pdf_path_ens).name}", + ) + + if upload_uri_ens: + results["ens"] = {"upload": True, "path": upload_uri_ens} + logger.info(f"ENS report uploaded to {upload_uri_ens}") + else: + results["ens"] = {"upload": False, "path": out_dir} + logger.warning(f"ENS report saved locally at {out_dir}") + + except Exception as e: + logger.error(f"Error generating ENS report: {e}") + results["ens"] = {"upload": False, "path": "", "error": str(e)} + + # Clean up temporary files if all reports were uploaded successfully + all_uploaded = all( + result.get("upload", False) + for result in results.values() + if result.get("upload") is not None + ) + + if all_uploaded: + try: + rmtree(Path(out_dir), ignore_errors=True) + logger.info(f"Cleaned up temporary files at {out_dir}") + except Exception as e: + logger.error(f"Error deleting output files: {e}") + + logger.info(f"Compliance reports generation completed. Results: {results}") + return results + + +def generate_compliance_reports_job( + tenant_id: str, + scan_id: str, + provider_id: str, + generate_threatscore: bool = True, + generate_ens: bool = True, +) -> dict[str, dict[str, bool | str]]: + """ + Job function to generate ThreatScore and/or ENS compliance reports with optimized database queries. + + This function efficiently generates compliance reports by: + - Fetching the provider object once (shared between both reports) + - Aggregating requirement statistics once (shared between both reports) + - Sharing findings cache between reports to avoid duplicate queries + - Reducing total database queries by 50-70% compared to generating reports separately + + Use this job when you need to generate compliance reports for a scan. + + Args: + tenant_id (str): The tenant ID for Row-Level Security context. + scan_id (str): The ID of the scan to generate reports for. + provider_id (str): The ID of the provider used in the scan. + generate_threatscore (bool): Whether to generate ThreatScore report. Defaults to True. + generate_ens (bool): Whether to generate ENS report. Defaults to True. + + Returns: + dict[str, dict[str, bool | str]]: Dictionary with results for each report: + { + 'threatscore': {'upload': bool, 'path': str, 'error': str (optional)}, + 'ens': {'upload': bool, 'path': str, 'error': str (optional)} + } + + Example: + >>> results = generate_compliance_reports_job( + ... tenant_id="tenant-123", + ... scan_id="scan-456", + ... provider_id="provider-789" + ... ) + >>> if results['threatscore']['upload']: + ... print(f"ThreatScore uploaded to {results['threatscore']['path']}") + >>> if results['ens']['upload']: + ... print(f"ENS uploaded to {results['ens']['path']}") + """ + logger.info( + f"Starting optimized compliance reports job for scan {scan_id} " + f"(ThreatScore: {generate_threatscore}, ENS: {generate_ens})" ) - # Compute and store ThreatScore metrics snapshot - logger.info(f"Computing ThreatScore metrics for scan {scan_id}") try: - metrics = compute_threatscore_metrics( + results = generate_compliance_reports( tenant_id=tenant_id, scan_id=scan_id, provider_id=provider_id, - compliance_id=compliance_id, - min_risk_level=4, + generate_threatscore=generate_threatscore, + generate_ens=generate_ens, + only_failed_threatscore=True, + min_risk_level_threatscore=4, + include_manual_ens=True, ) + logger.info("Optimized compliance reports job completed successfully") + return results - # Create snapshot in database - with rls_transaction(tenant_id): - # Get previous snapshot for the same provider to calculate delta - previous_snapshot = ( - ThreatScoreSnapshot.objects.filter( - tenant_id=tenant_id, - provider_id=provider_id, - compliance_id=compliance_id, - ) - .order_by("-inserted_at") - .first() - ) - - # Calculate score delta (improvement) - score_delta = None - if previous_snapshot: - score_delta = metrics["overall_score"] - float( - previous_snapshot.overall_score - ) - - snapshot = ThreatScoreSnapshot.objects.create( - tenant_id=tenant_id, - scan_id=scan_id, - provider_id=provider_id, - compliance_id=compliance_id, - overall_score=metrics["overall_score"], - score_delta=score_delta, - section_scores=metrics["section_scores"], - critical_requirements=metrics["critical_requirements"], - total_requirements=metrics["total_requirements"], - passed_requirements=metrics["passed_requirements"], - failed_requirements=metrics["failed_requirements"], - manual_requirements=metrics["manual_requirements"], - total_findings=metrics["total_findings"], - passed_findings=metrics["passed_findings"], - failed_findings=metrics["failed_findings"], - ) - - delta_msg = ( - f" (delta: {score_delta:+.2f}%)" if score_delta is not None else "" - ) - logger.info( - f"ThreatScore snapshot created with ID {snapshot.id} " - f"(score: {snapshot.overall_score}%{delta_msg})" - ) except Exception as e: - # Log error but don't fail the job if snapshot creation fails - logger.error(f"Error creating ThreatScore snapshot: {e}") - - upload_uri = _upload_to_s3( - tenant_id, - scan_id, - pdf_path, - f"threatscore/{Path(pdf_path).name}", - ) - if upload_uri: - try: - rmtree(Path(pdf_path).parent, ignore_errors=True) - except Exception as e: - logger.error(f"Error deleting output files: {e}") - final_location, did_upload = upload_uri, True - else: - final_location, did_upload = out_dir, False - - logger.info(f"Threatscore report outputs at {final_location}") - - return {"upload": did_upload} + logger.error(f"Error in optimized compliance reports job: {e}") + error_result = {"upload": False, "path": "", "error": str(e)} + results = {} + if generate_threatscore: + results["threatscore"] = error_result.copy() + if generate_ens: + results["ens"] = error_result.copy() + return results diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index 9524a1b985..06a35676a5 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -35,7 +35,7 @@ from tasks.jobs.lighthouse_providers import ( refresh_lighthouse_provider_models, ) from tasks.jobs.muting import mute_historical_findings -from tasks.jobs.report import generate_threatscore_report_job +from tasks.jobs.report import generate_compliance_reports_job from tasks.jobs.scan import ( aggregate_findings, create_compliance_requirements, @@ -75,7 +75,8 @@ def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str) scan_id=scan_id, provider_id=provider_id, tenant_id=tenant_id ), group( - generate_threatscore_report_task.si( + # Use optimized task that generates both reports with shared queries + generate_compliance_reports_task.si( tenant_id=tenant_id, scan_id=scan_id, provider_id=provider_id ), check_integrations_task.si( @@ -319,7 +320,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): frameworks_bulk = Compliance.get_bulk(provider_type) frameworks_avail = get_compliance_frameworks(provider_type) - out_dir, comp_dir, _ = _generate_output_directory( + out_dir, comp_dir = _generate_output_directory( DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id ) @@ -686,19 +687,33 @@ def jira_integration_task( @shared_task( base=RLSTask, - name="scan-threatscore-report", + name="scan-compliance-reports", queue="scan-reports", ) -def generate_threatscore_report_task(tenant_id: str, scan_id: str, provider_id: str): +def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id: str): """ - Task to generate a threatscore report for a given scan. + Optimized task to generate both ThreatScore and ENS reports with shared queries. + + This task is more efficient than running generate_threatscore_report_task and + generate_ens_report_task separately because it reuses database queries: + - Provider object fetched once (instead of twice) + - Requirement statistics aggregated once (instead of twice) + - Can reduce database load by up to 50% + Args: tenant_id (str): The tenant identifier. scan_id (str): The scan identifier. provider_id (str): The provider identifier. + + Returns: + dict: Results for both reports containing upload status and paths. """ - return generate_threatscore_report_job( - tenant_id=tenant_id, scan_id=scan_id, provider_id=provider_id + return generate_compliance_reports_job( + tenant_id=tenant_id, + scan_id=scan_id, + provider_id=provider_id, + generate_threatscore=True, + generate_ens=True, ) diff --git a/api/src/backend/tasks/tests/test_export.py b/api/src/backend/tasks/tests/test_export.py index f1e7120989..e030180bc7 100644 --- a/api/src/backend/tasks/tests/test_export.py +++ b/api/src/backend/tasks/tests/test_export.py @@ -9,6 +9,7 @@ import pytest from botocore.exceptions import ClientError from tasks.jobs.export import ( _compress_output_files, + _generate_compliance_output_directory, _generate_output_directory, _upload_to_s3, get_s3_client, @@ -168,17 +169,34 @@ class TestOutputs: provider = "aws" expected_timestamp = "20230615103045" - path, compliance, threatscore = _generate_output_directory( + # Test _generate_output_directory (returns standard and compliance paths) + path, compliance = _generate_output_directory( base_dir, provider, tenant_id, scan_id ) assert os.path.isdir(os.path.dirname(path)) assert os.path.isdir(os.path.dirname(compliance)) - assert os.path.isdir(os.path.dirname(threatscore)) - assert path.endswith(f"{provider}-{expected_timestamp}") assert compliance.endswith(f"{provider}-{expected_timestamp}") + assert "/compliance/" in compliance + + # Test _generate_compliance_output_directory with "threatscore" + threatscore = _generate_compliance_output_directory( + base_dir, provider, tenant_id, scan_id, compliance_framework="threatscore" + ) + + assert os.path.isdir(os.path.dirname(threatscore)) assert threatscore.endswith(f"{provider}-{expected_timestamp}") + assert "/threatscore/" in threatscore + + # Test _generate_compliance_output_directory with "ens" + ens = _generate_compliance_output_directory( + base_dir, provider, tenant_id, scan_id, compliance_framework="ens" + ) + + assert os.path.isdir(os.path.dirname(ens)) + assert ens.endswith(f"{provider}-{expected_timestamp}") + assert "/ens/" in ens @patch("tasks.jobs.export.rls_transaction") @patch("tasks.jobs.export.Scan") @@ -201,14 +219,25 @@ class TestOutputs: provider = "aws/test@check" expected_timestamp = "20230615103045" - path, compliance, threatscore = _generate_output_directory( + # Test provider name sanitization with _generate_output_directory + path, compliance = _generate_output_directory( base_dir, provider, tenant_id, scan_id ) assert os.path.isdir(os.path.dirname(path)) assert os.path.isdir(os.path.dirname(compliance)) - assert os.path.isdir(os.path.dirname(threatscore)) - assert path.endswith(f"aws-test-check-{expected_timestamp}") assert compliance.endswith(f"aws-test-check-{expected_timestamp}") + + # Test provider name sanitization with _generate_compliance_output_directory + threatscore = _generate_compliance_output_directory( + base_dir, provider, tenant_id, scan_id, compliance_framework="threatscore" + ) + ens = _generate_compliance_output_directory( + base_dir, provider, tenant_id, scan_id, compliance_framework="ens" + ) + + assert os.path.isdir(os.path.dirname(threatscore)) + assert os.path.isdir(os.path.dirname(ens)) assert threatscore.endswith(f"aws-test-check-{expected_timestamp}") + assert ens.endswith(f"aws-test-check-{expected_timestamp}") diff --git a/api/src/backend/tasks/tests/test_report.py b/api/src/backend/tasks/tests/test_report.py index 91d3b1145d..25a8df121c 100644 --- a/api/src/backend/tasks/tests/test_report.py +++ b/api/src/backend/tasks/tests/test_report.py @@ -1,352 +1,59 @@ +import io import uuid -from datetime import timedelta -from decimal import Decimal -from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch import matplotlib import pytest -from django.utils import timezone -from freezegun import freeze_time +from reportlab.lib import colors +from reportlab.platypus import Table, TableStyle from tasks.jobs.report import ( + CHART_COLOR_GREEN_1, + CHART_COLOR_GREEN_2, + CHART_COLOR_ORANGE, + CHART_COLOR_RED, + CHART_COLOR_YELLOW, + COLOR_BLUE, + COLOR_ENS_ALTO, + COLOR_ENS_BAJO, + COLOR_ENS_MEDIO, + COLOR_ENS_OPCIONAL, + COLOR_HIGH_RISK, + COLOR_LOW_RISK, + COLOR_MEDIUM_RISK, + COLOR_SAFE, + _create_dimensions_radar_chart, + _create_ens_dimension_badges, + _create_ens_nivel_badge, + _create_ens_tipo_badge, + _create_findings_table_style, + _create_header_table_style, + _create_info_table_style, + _create_marco_category_chart, + _create_pdf_styles, + _create_risk_component, + _create_section_score_chart, + _create_status_component, + _get_chart_color_for_percentage, + _get_color_for_compliance, + _get_color_for_risk_level, + _get_color_for_weight, + _get_ens_nivel_color, _load_findings_for_requirement_checks, + _safe_getattr, + generate_compliance_reports_job, generate_threatscore_report, - generate_threatscore_report_job, ) from tasks.jobs.threatscore_utils import ( _aggregate_requirement_statistics_from_database, _calculate_requirements_data_from_statistics, ) -from tasks.tasks import generate_threatscore_report_task -from api.models import Finding, Scan, StateChoices, StatusChoices, ThreatScoreSnapshot +from api.models import Finding, StatusChoices from prowler.lib.check.models import Severity matplotlib.use("Agg") # Use non-interactive backend for tests -@pytest.mark.django_db -class TestGenerateThreatscoreReport: - def setup_method(self): - self.scan_id = str(uuid.uuid4()) - self.provider_id = str(uuid.uuid4()) - self.tenant_id = str(uuid.uuid4()) - - def test_no_findings_returns_early(self): - with patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter: - mock_filter.return_value.exists.return_value = False - - result = generate_threatscore_report_job( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) - - assert result == {"upload": False} - mock_filter.assert_called_once_with(scan_id=self.scan_id) - - @patch("tasks.jobs.report.ThreatScoreSnapshot.objects.create") - @patch("tasks.jobs.report.rmtree") - @patch("tasks.jobs.report._upload_to_s3") - @patch("tasks.jobs.report.generate_threatscore_report") - @patch("tasks.jobs.report._generate_output_directory") - @patch("tasks.jobs.report.Provider.objects.get") - @patch("tasks.jobs.report.ScanSummary.objects.filter") - def test_generate_threatscore_report_happy_path( - self, - mock_scan_summary_filter, - mock_provider_get, - mock_generate_output_directory, - mock_generate_report, - mock_upload, - mock_rmtree, - mock_snapshot_create, - ): - mock_scan_summary_filter.return_value.exists.return_value = True - - mock_provider = MagicMock() - mock_provider.uid = "provider-uid" - mock_provider.provider = "aws" - mock_provider_get.return_value = mock_provider - - mock_generate_output_directory.return_value = ( - "/tmp/output", - "/tmp/compressed", - "/tmp/threatscore_path", - ) - - mock_upload.return_value = "s3://bucket/threatscore_report.pdf" - - result = generate_threatscore_report_job( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) - - assert result == {"upload": True} - mock_generate_report.assert_called_once_with( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - compliance_id="prowler_threatscore_aws", - output_path="/tmp/threatscore_path_threatscore_report.pdf", - provider_id=self.provider_id, - only_failed=True, - min_risk_level=4, - ) - mock_upload.assert_called_once_with( - self.tenant_id, - self.scan_id, - "/tmp/threatscore_path_threatscore_report.pdf", - "threatscore/threatscore_path_threatscore_report.pdf", - ) - mock_rmtree.assert_called_once_with( - Path("/tmp/threatscore_path_threatscore_report.pdf").parent, - ignore_errors=True, - ) - mock_snapshot_create.assert_called_once() - - @patch("tasks.jobs.report.ThreatScoreSnapshot.objects.create") - def test_generate_threatscore_report_fails_upload(self, mock_snapshot_create): - with ( - patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter, - patch("tasks.jobs.report.Provider.objects.get") as mock_provider_get, - patch("tasks.jobs.report._generate_output_directory") as mock_gen_dir, - patch("tasks.jobs.report.generate_threatscore_report"), - patch("tasks.jobs.report._upload_to_s3", return_value=None), - ): - mock_filter.return_value.exists.return_value = True - - # Mock provider - mock_provider = MagicMock() - mock_provider.uid = "aws-provider-uid" - mock_provider.provider = "aws" - mock_provider_get.return_value = mock_provider - - mock_gen_dir.return_value = ( - "/tmp/output", - "/tmp/compressed", - "/tmp/threatscore_path", - ) - - result = generate_threatscore_report_job( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) - - assert result == {"upload": False} - mock_snapshot_create.assert_called_once() - - @patch("tasks.jobs.report.ThreatScoreSnapshot.objects.create") - def test_generate_threatscore_report_logs_rmtree_exception( - self, mock_snapshot_create, caplog - ): - with ( - patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter, - patch("tasks.jobs.report.Provider.objects.get") as mock_provider_get, - patch("tasks.jobs.report._generate_output_directory") as mock_gen_dir, - patch("tasks.jobs.report.generate_threatscore_report"), - patch( - "tasks.jobs.report._upload_to_s3", return_value="s3://bucket/report.pdf" - ), - patch( - "tasks.jobs.report.rmtree", side_effect=Exception("Test deletion error") - ), - ): - mock_filter.return_value.exists.return_value = True - - # Mock provider - mock_provider = MagicMock() - mock_provider.uid = "aws-provider-uid" - mock_provider.provider = "aws" - mock_provider_get.return_value = mock_provider - - mock_gen_dir.return_value = ( - "/tmp/output", - "/tmp/compressed", - "/tmp/threatscore_path", - ) - - with caplog.at_level("ERROR"): - generate_threatscore_report_job( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) - assert "Error deleting output files" in caplog.text - mock_snapshot_create.assert_called_once() - - @patch("tasks.jobs.report.ThreatScoreSnapshot.objects.create") - def test_generate_threatscore_report_azure_provider(self, mock_snapshot_create): - with ( - patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter, - patch("tasks.jobs.report.Provider.objects.get") as mock_provider_get, - patch("tasks.jobs.report._generate_output_directory") as mock_gen_dir, - patch("tasks.jobs.report.generate_threatscore_report") as mock_generate, - patch( - "tasks.jobs.report._upload_to_s3", return_value="s3://bucket/report.pdf" - ), - patch("tasks.jobs.report.rmtree"), - ): - mock_filter.return_value.exists.return_value = True - - mock_provider = MagicMock() - mock_provider.uid = "azure-provider-uid" - mock_provider.provider = "azure" - mock_provider_get.return_value = mock_provider - - mock_gen_dir.return_value = ( - "/tmp/output", - "/tmp/compressed", - "/tmp/threatscore_path", - ) - - generate_threatscore_report_job( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) - - mock_generate.assert_called_once_with( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - compliance_id="prowler_threatscore_azure", - output_path="/tmp/threatscore_path_threatscore_report.pdf", - provider_id=self.provider_id, - only_failed=True, - min_risk_level=4, - ) - mock_snapshot_create.assert_called_once() - - @patch("tasks.jobs.report.rmtree") - @patch( - "tasks.jobs.report._upload_to_s3", - return_value="s3://bucket/threatscore/threatscore_report.pdf", - ) - @patch("tasks.jobs.report.generate_threatscore_report") - @patch("tasks.jobs.report._generate_output_directory") - @patch("tasks.jobs.report.ScanSummary.objects.filter") - @patch("tasks.jobs.report.compute_threatscore_metrics") - @pytest.mark.django_db - @freeze_time("2025-01-10T12:00:00Z") - def test_generate_threatscore_report_persists_snapshot_and_delta( - self, - mock_compute_metrics, - mock_scan_summary_filter, - mock_generate_output_directory, - mock_generate_report, - mock_upload, - mock_rmtree, - tenants_fixture, - providers_fixture, - ): - tenant = tenants_fixture[0] - provider = providers_fixture[0] - - scan_previous = Scan.objects.create( - tenant=tenant, - provider=provider, - name="previous-threatscore-scan", - trigger=Scan.TriggerChoices.MANUAL, - state=StateChoices.COMPLETED, - started_at=timezone.now() - timedelta(hours=4), - completed_at=timezone.now() - timedelta(hours=3), - ) - ThreatScoreSnapshot.objects.create( - tenant=tenant, - scan=scan_previous, - provider=provider, - compliance_id="prowler_threatscore_aws", - overall_score=Decimal("70.00"), - score_delta=None, - section_scores={"1. IAM": "65.00"}, - critical_requirements=[], - total_requirements=50, - passed_requirements=35, - failed_requirements=15, - manual_requirements=0, - total_findings=40, - passed_findings=25, - failed_findings=15, - ) - - scan_current = Scan.objects.create( - tenant=tenant, - provider=provider, - name="current-threatscore-scan", - trigger=Scan.TriggerChoices.MANUAL, - state=StateChoices.COMPLETED, - started_at=timezone.now() - timedelta(hours=2), - completed_at=timezone.now() - timedelta(hours=1), - ) - - mock_scan_summary_filter.return_value.exists.return_value = True - mock_generate_output_directory.return_value = ( - "/tmp/output", - "/tmp/compressed", - "/tmp/threatscore_path", - ) - - metrics = { - "overall_score": 85.5, - "score_delta": 10.0, - "section_scores": {"1. IAM": 82.3, "2. Attack Surface": 60.0}, - "critical_requirements": [ - { - "requirement_id": "req_new", - "title": "New high-risk requirement", - "section": "1. IAM", - "subsection": "Root Account", - "risk_level": 5, - "weight": 150, - "passed_findings": 7, - "total_findings": 10, - "description": "Critical requirement description", - } - ], - "total_requirements": 140, - "passed_requirements": 100, - "failed_requirements": 40, - "manual_requirements": 0, - "total_findings": 200, - "passed_findings": 150, - "failed_findings": 50, - } - mock_compute_metrics.return_value = metrics - - result = generate_threatscore_report_job( - tenant_id=str(tenant.id), - scan_id=str(scan_current.id), - provider_id=str(provider.id), - ) - - assert result == {"upload": True} - mock_compute_metrics.assert_called_once_with( - tenant_id=str(tenant.id), - scan_id=str(scan_current.id), - provider_id=str(provider.id), - compliance_id="prowler_threatscore_aws", - min_risk_level=4, - ) - mock_generate_report.assert_called_once() - mock_upload.assert_called_once() - mock_rmtree.assert_called_once() - - snapshots = ThreatScoreSnapshot.objects.filter( - tenant=tenant, provider=provider - ).order_by("inserted_at") - assert snapshots.count() == 2 - - new_snapshot = ThreatScoreSnapshot.objects.get(scan=scan_current) - assert new_snapshot.compliance_id == "prowler_threatscore_aws" - assert Decimal(new_snapshot.overall_score) == Decimal("85.50") - assert Decimal(new_snapshot.score_delta) == Decimal("15.50") - assert new_snapshot.section_scores == metrics["section_scores"] - assert new_snapshot.critical_requirements == metrics["critical_requirements"] - assert new_snapshot.total_requirements == metrics["total_requirements"] - assert new_snapshot.total_findings == metrics["total_findings"] - - @pytest.mark.django_db class TestAggregateRequirementStatistics: """Test suite for _aggregate_requirement_statistics_from_database function.""" @@ -1038,7 +745,7 @@ class TestGenerateThreatscoreReportFunction: mock_compliance_obj, {"check_1": {"passed": 5, "total": 10}} ) mock_load_findings.assert_called_once_with( - self.tenant_id, self.scan_id, ["check_1"], prowler_provider + self.tenant_id, self.scan_id, ["check_1"], prowler_provider, None ) # Verify PDF was built @@ -1071,38 +778,648 @@ class TestGenerateThreatscoreReportFunction: @pytest.mark.django_db -class TestGenerateThreatscoreReportTask: +class TestColorHelperFunctions: + """Test suite for color selection helper functions.""" + + def test_get_color_for_risk_level_high(self): + """High risk level (>=4) returns red color.""" + assert _get_color_for_risk_level(4) == COLOR_HIGH_RISK + assert _get_color_for_risk_level(5) == COLOR_HIGH_RISK + + def test_get_color_for_risk_level_medium_high(self): + """Medium-high risk level (3) returns orange color.""" + assert _get_color_for_risk_level(3) == COLOR_MEDIUM_RISK + + def test_get_color_for_risk_level_medium(self): + """Medium risk level (2) returns yellow color.""" + assert _get_color_for_risk_level(2) == COLOR_LOW_RISK + + def test_get_color_for_risk_level_low(self): + """Low risk level (<2) returns green color.""" + assert _get_color_for_risk_level(0) == COLOR_SAFE + assert _get_color_for_risk_level(1) == COLOR_SAFE + + def test_get_color_for_weight_high(self): + """High weight (>100) returns red color.""" + assert _get_color_for_weight(101) == COLOR_HIGH_RISK + assert _get_color_for_weight(200) == COLOR_HIGH_RISK + + def test_get_color_for_weight_medium(self): + """Medium weight (51-100) returns yellow color.""" + assert _get_color_for_weight(51) == COLOR_LOW_RISK + assert _get_color_for_weight(100) == COLOR_LOW_RISK + + def test_get_color_for_weight_low(self): + """Low weight (<=50) returns green color.""" + assert _get_color_for_weight(0) == COLOR_SAFE + assert _get_color_for_weight(50) == COLOR_SAFE + + def test_get_color_for_compliance_high(self): + """High compliance (>=80%) returns green color.""" + assert _get_color_for_compliance(80.0) == COLOR_SAFE + assert _get_color_for_compliance(100.0) == COLOR_SAFE + + def test_get_color_for_compliance_medium(self): + """Medium compliance (60-79%) returns yellow color.""" + assert _get_color_for_compliance(60.0) == COLOR_LOW_RISK + assert _get_color_for_compliance(79.9) == COLOR_LOW_RISK + + def test_get_color_for_compliance_low(self): + """Low compliance (<60%) returns red color.""" + assert _get_color_for_compliance(0.0) == COLOR_HIGH_RISK + assert _get_color_for_compliance(59.9) == COLOR_HIGH_RISK + + def test_get_chart_color_for_percentage_excellent(self): + """Excellent percentage (>=80%) returns green.""" + assert _get_chart_color_for_percentage(80.0) == CHART_COLOR_GREEN_1 + assert _get_chart_color_for_percentage(100.0) == CHART_COLOR_GREEN_1 + + def test_get_chart_color_for_percentage_good(self): + """Good percentage (60-79%) returns light green.""" + assert _get_chart_color_for_percentage(60.0) == CHART_COLOR_GREEN_2 + assert _get_chart_color_for_percentage(79.9) == CHART_COLOR_GREEN_2 + + def test_get_chart_color_for_percentage_fair(self): + """Fair percentage (40-59%) returns yellow.""" + assert _get_chart_color_for_percentage(40.0) == CHART_COLOR_YELLOW + assert _get_chart_color_for_percentage(59.9) == CHART_COLOR_YELLOW + + def test_get_chart_color_for_percentage_poor(self): + """Poor percentage (20-39%) returns orange.""" + assert _get_chart_color_for_percentage(20.0) == CHART_COLOR_ORANGE + assert _get_chart_color_for_percentage(39.9) == CHART_COLOR_ORANGE + + def test_get_chart_color_for_percentage_critical(self): + """Critical percentage (<20%) returns red.""" + assert _get_chart_color_for_percentage(0.0) == CHART_COLOR_RED + assert _get_chart_color_for_percentage(19.9) == CHART_COLOR_RED + + def test_get_ens_nivel_color_alto(self): + """Alto nivel returns red color.""" + assert _get_ens_nivel_color("alto") == COLOR_ENS_ALTO + assert _get_ens_nivel_color("ALTO") == COLOR_ENS_ALTO + + def test_get_ens_nivel_color_medio(self): + """Medio nivel returns yellow/orange color.""" + assert _get_ens_nivel_color("medio") == COLOR_ENS_MEDIO + assert _get_ens_nivel_color("MEDIO") == COLOR_ENS_MEDIO + + def test_get_ens_nivel_color_bajo(self): + """Bajo nivel returns green color.""" + assert _get_ens_nivel_color("bajo") == COLOR_ENS_BAJO + assert _get_ens_nivel_color("BAJO") == COLOR_ENS_BAJO + + def test_get_ens_nivel_color_opcional(self): + """Opcional and unknown nivels return gray color.""" + assert _get_ens_nivel_color("opcional") == COLOR_ENS_OPCIONAL + assert _get_ens_nivel_color("unknown") == COLOR_ENS_OPCIONAL + + +class TestSafeGetattr: + """Test suite for _safe_getattr helper function.""" + + def test_safe_getattr_attribute_exists(self): + """Returns attribute value when it exists.""" + obj = Mock() + obj.test_attr = "value" + assert _safe_getattr(obj, "test_attr") == "value" + + def test_safe_getattr_attribute_missing_default(self): + """Returns default 'N/A' when attribute doesn't exist.""" + obj = Mock(spec=[]) + result = _safe_getattr(obj, "missing_attr") + assert result == "N/A" + + def test_safe_getattr_custom_default(self): + """Returns custom default when specified.""" + obj = Mock(spec=[]) + result = _safe_getattr(obj, "missing_attr", "custom") + assert result == "custom" + + def test_safe_getattr_none_value(self): + """Returns None if attribute value is None.""" + obj = Mock() + obj.test_attr = None + assert _safe_getattr(obj, "test_attr") is None + + +class TestPDFStylesCreation: + """Test suite for PDF styles creation and caching.""" + + def test_create_pdf_styles_returns_dict(self): + """Returns a dictionary with all required styles.""" + styles = _create_pdf_styles() + + assert isinstance(styles, dict) + assert "title" in styles + assert "h1" in styles + assert "h2" in styles + assert "h3" in styles + assert "normal" in styles + assert "normal_center" in styles + + def test_create_pdf_styles_caches_result(self): + """Subsequent calls return cached styles.""" + styles1 = _create_pdf_styles() + styles2 = _create_pdf_styles() + + # Should return the exact same object (not just equal) + assert styles1 is styles2 + + def test_pdf_styles_have_correct_fonts(self): + """Styles use the correct fonts.""" + styles = _create_pdf_styles() + + assert styles["title"].fontName == "PlusJakartaSans" + assert styles["h1"].fontName == "PlusJakartaSans" + assert styles["normal"].fontName == "PlusJakartaSans" + + +class TestTableStyleFactories: + """Test suite for table style factory functions.""" + + def test_create_info_table_style_returns_table_style(self): + """Returns a TableStyle object.""" + style = _create_info_table_style() + assert isinstance(style, TableStyle) + + def test_create_header_table_style_default_color(self): + """Uses default blue color when not specified.""" + style = _create_header_table_style() + assert isinstance(style, TableStyle) + # Verify it has styling commands + assert len(style.getCommands()) > 0 + + def test_create_header_table_style_custom_color(self): + """Uses custom color when specified.""" + custom_color = colors.red + style = _create_header_table_style(custom_color) + assert isinstance(style, TableStyle) + + def test_create_findings_table_style(self): + """Returns appropriate style for findings tables.""" + style = _create_findings_table_style() + assert isinstance(style, TableStyle) + assert len(style.getCommands()) > 0 + + +class TestRiskComponent: + """Test suite for _create_risk_component function.""" + + def test_create_risk_component_returns_table(self): + """Returns a Table object.""" + table = _create_risk_component(risk_level=3, weight=100, score=50) + assert isinstance(table, Table) + + def test_create_risk_component_high_risk(self): + """High risk level uses red color.""" + table = _create_risk_component(risk_level=4, weight=50, score=0) + assert isinstance(table, Table) + # Table is created successfully + + def test_create_risk_component_low_risk(self): + """Low risk level uses green color.""" + table = _create_risk_component(risk_level=1, weight=30, score=100) + assert isinstance(table, Table) + + def test_create_risk_component_default_score(self): + """Uses default score of 0 when not specified.""" + table = _create_risk_component(risk_level=2, weight=50) + assert isinstance(table, Table) + + +class TestStatusComponent: + """Test suite for _create_status_component function.""" + + def test_create_status_component_pass(self): + """PASS status uses green color.""" + table = _create_status_component("pass") + assert isinstance(table, Table) + + def test_create_status_component_fail(self): + """FAIL status uses red color.""" + table = _create_status_component("fail") + assert isinstance(table, Table) + + def test_create_status_component_manual(self): + """MANUAL status uses gray color.""" + table = _create_status_component("manual") + assert isinstance(table, Table) + + def test_create_status_component_uppercase(self): + """Handles uppercase status strings.""" + table = _create_status_component("PASS") + assert isinstance(table, Table) + + +class TestENSBadges: + """Test suite for ENS-specific badge creation functions.""" + + def test_create_ens_nivel_badge_alto(self): + """Creates badge for alto nivel.""" + table = _create_ens_nivel_badge("alto") + assert isinstance(table, Table) + + def test_create_ens_nivel_badge_medio(self): + """Creates badge for medio nivel.""" + table = _create_ens_nivel_badge("medio") + assert isinstance(table, Table) + + def test_create_ens_nivel_badge_bajo(self): + """Creates badge for bajo nivel.""" + table = _create_ens_nivel_badge("bajo") + assert isinstance(table, Table) + + def test_create_ens_nivel_badge_opcional(self): + """Creates badge for opcional nivel.""" + table = _create_ens_nivel_badge("opcional") + assert isinstance(table, Table) + + def test_create_ens_tipo_badge_requisito(self): + """Creates badge for requisito type.""" + table = _create_ens_tipo_badge("requisito") + assert isinstance(table, Table) + + def test_create_ens_tipo_badge_unknown(self): + """Handles unknown tipo gracefully.""" + table = _create_ens_tipo_badge("unknown") + assert isinstance(table, Table) + + def test_create_ens_dimension_badges_single(self): + """Creates badges for single dimension.""" + table = _create_ens_dimension_badges(["trazabilidad"]) + assert isinstance(table, Table) + + def test_create_ens_dimension_badges_multiple(self): + """Creates badges for multiple dimensions.""" + dimensiones = ["trazabilidad", "autenticidad", "integridad"] + table = _create_ens_dimension_badges(dimensiones) + assert isinstance(table, Table) + + def test_create_ens_dimension_badges_empty(self): + """Returns N/A table for empty dimensions list.""" + table = _create_ens_dimension_badges([]) + assert isinstance(table, Table) + + def test_create_ens_dimension_badges_invalid(self): + """Filters out invalid dimensions.""" + table = _create_ens_dimension_badges(["invalid", "trazabilidad"]) + assert isinstance(table, Table) + + +class TestChartCreation: + """Test suite for chart generation functions.""" + + @patch("tasks.jobs.report.plt.close") + @patch("tasks.jobs.report.plt.savefig") + @patch("tasks.jobs.report.plt.subplots") + def test_create_section_score_chart_with_data( + self, mock_subplots, mock_savefig, mock_close + ): + """Creates chart successfully with valid data.""" + mock_fig, mock_ax = MagicMock(), MagicMock() + mock_subplots.return_value = (mock_fig, mock_ax) + mock_ax.bar.return_value = [MagicMock(), MagicMock()] + + requirements_list = [ + { + "id": "req_1", + "attributes": { + "passed_findings": 10, + "total_findings": 10, + }, + } + ] + + mock_metadata = MagicMock() + mock_metadata.Section = "1. IAM" + mock_metadata.LevelOfRisk = 3 + mock_metadata.Weight = 100 + + attributes_by_id = { + "req_1": { + "attributes": { + "req_attributes": [mock_metadata], + } + } + } + + result = _create_section_score_chart(requirements_list, attributes_by_id) + + assert isinstance(result, io.BytesIO) + mock_subplots.assert_called_once() + mock_close.assert_called_once_with(mock_fig) + + @patch("tasks.jobs.report.plt.close") + @patch("tasks.jobs.report.plt.savefig") + @patch("tasks.jobs.report.plt.subplots") + def test_create_marco_category_chart_with_data( + self, mock_subplots, mock_savefig, mock_close + ): + """Creates marco/category chart successfully.""" + mock_fig, mock_ax = MagicMock(), MagicMock() + mock_subplots.return_value = (mock_fig, mock_ax) + mock_ax.barh.return_value = [MagicMock()] + + requirements_list = [ + { + "id": "req_1", + "attributes": { + "status": StatusChoices.PASS, + }, + } + ] + + mock_metadata = MagicMock() + mock_metadata.Marco = "Marco1" + mock_metadata.Categoria = "Cat1" + + attributes_by_id = { + "req_1": { + "attributes": { + "req_attributes": [mock_metadata], + } + } + } + + result = _create_marco_category_chart(requirements_list, attributes_by_id) + + assert isinstance(result, io.BytesIO) + mock_close.assert_called_once_with(mock_fig) + + @patch("tasks.jobs.report.plt.close") + @patch("tasks.jobs.report.plt.savefig") + @patch("tasks.jobs.report.plt.subplots") + def test_create_dimensions_radar_chart( + self, mock_subplots, mock_savefig, mock_close + ): + """Creates radar chart for dimensions.""" + mock_fig, mock_ax = MagicMock(), MagicMock() + mock_ax.plot = MagicMock() + mock_ax.fill = MagicMock() + mock_subplots.return_value = (mock_fig, mock_ax) + + requirements_list = [ + { + "id": "req_1", + "attributes": { + "status": StatusChoices.PASS, + }, + } + ] + + mock_metadata = MagicMock() + mock_metadata.Dimensiones = ["trazabilidad", "integridad"] + + attributes_by_id = { + "req_1": { + "attributes": { + "req_attributes": [mock_metadata], + } + } + } + + result = _create_dimensions_radar_chart(requirements_list, attributes_by_id) + + assert isinstance(result, io.BytesIO) + mock_close.assert_called_once_with(mock_fig) + + @patch("tasks.jobs.report.plt.close") + @patch("tasks.jobs.report.plt.savefig") + @patch("tasks.jobs.report.plt.subplots") + def test_create_chart_closes_figure_on_error( + self, mock_subplots, mock_savefig, mock_close + ): + """Ensures figure is closed even if savefig fails.""" + mock_fig, mock_ax = MagicMock(), MagicMock() + mock_subplots.return_value = (mock_fig, mock_ax) + mock_savefig.side_effect = Exception("Save failed") + + requirements_list = [] + attributes_by_id = {} + + with pytest.raises(Exception): + _create_section_score_chart(requirements_list, attributes_by_id) + + # Verify figure was still closed + mock_close.assert_called_with(mock_fig) + + +@pytest.mark.django_db +class TestOptimizationImprovements: + """Test suite to verify optimization improvements work correctly.""" + + def test_constants_are_color_objects(self): + """Verify color constants are properly instantiated Color objects.""" + assert isinstance(COLOR_BLUE, colors.Color) + assert isinstance(COLOR_HIGH_RISK, colors.Color) + assert isinstance(COLOR_SAFE, colors.Color) + + def test_chart_color_constants_are_strings(self): + """Verify chart color constants are hex strings.""" + assert isinstance(CHART_COLOR_GREEN_1, str) + assert CHART_COLOR_GREEN_1.startswith("#") + assert len(CHART_COLOR_GREEN_1) == 7 + + def test_style_cache_persists_across_calls(self): + """Verify style caching reduces object creation.""" + # Clear any existing cache by calling directly + styles1 = _create_pdf_styles() + styles2 = _create_pdf_styles() + + # Should be the exact same cached object + assert id(styles1) == id(styles2) + + def test_helper_functions_return_consistent_results(self): + """Verify helper functions return consistent results.""" + # Same input should always return same output + assert _get_color_for_risk_level(3) == _get_color_for_risk_level(3) + assert _get_color_for_weight(100) == _get_color_for_weight(100) + assert _get_chart_color_for_percentage(75.0) == _get_chart_color_for_percentage( + 75.0 + ) + + +@pytest.mark.django_db +class TestGenerateComplianceReportsOptimized: + """Test suite for the optimized generate_compliance_reports_job function.""" + def setup_method(self): self.scan_id = str(uuid.uuid4()) self.provider_id = str(uuid.uuid4()) self.tenant_id = str(uuid.uuid4()) - @patch("tasks.tasks.generate_threatscore_report_job") - def test_generate_threatscore_report_task_calls_job(self, mock_generate_job): - mock_generate_job.return_value = {"upload": True} + def test_no_findings_returns_early_for_both_reports(self): + """Test that function returns early when no findings exist.""" + with patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter: + mock_filter.return_value.exists.return_value = False - result = generate_threatscore_report_task( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) - - assert result == {"upload": True} - mock_generate_job.assert_called_once_with( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) - - @patch("tasks.tasks.generate_threatscore_report_job") - def test_generate_threatscore_report_task_handles_job_exception( - self, mock_generate_job - ): - mock_generate_job.side_effect = Exception("Job failed") - - with pytest.raises(Exception, match="Job failed"): - generate_threatscore_report_task( + result = generate_compliance_reports_job( tenant_id=self.tenant_id, scan_id=self.scan_id, provider_id=self.provider_id, ) + + assert result["threatscore"] == {"upload": False, "path": ""} + assert result["ens"] == {"upload": False, "path": ""} + mock_filter.assert_called_once_with(scan_id=self.scan_id) + + @patch("tasks.jobs.report.rmtree") + @patch("tasks.jobs.report._upload_to_s3") + @patch("tasks.jobs.report.generate_ens_report") + @patch("tasks.jobs.report.generate_threatscore_report") + @patch("tasks.jobs.report._generate_compliance_output_directory") + @patch("tasks.jobs.report._aggregate_requirement_statistics_from_database") + @patch("tasks.jobs.report.Provider") + @patch("tasks.jobs.report.ScanSummary") + def test_generates_both_reports_with_shared_queries( + self, + mock_scan_summary, + mock_provider, + mock_aggregate_stats, + mock_gen_dir, + mock_gen_threatscore, + mock_gen_ens, + mock_upload, + mock_rmtree, + ): + """Test that both reports are generated with shared database queries.""" + # Setup mocks + mock_scan_summary.objects.filter.return_value.exists.return_value = True + mock_provider_obj = Mock() + mock_provider_obj.uid = "test-uid" + mock_provider_obj.provider = "aws" + mock_provider.objects.get.return_value = mock_provider_obj + + mock_aggregate_stats.return_value = {"check-1": {"passed": 10, "total": 15}} + # Mock returns different paths for different compliance_framework calls + mock_gen_dir.side_effect = [ + "/tmp/threatscore_path", # First call with compliance_framework="threatscore" + "/tmp/ens_path", # Second call with compliance_framework="ens" + ] + mock_upload.side_effect = [ + "s3://bucket/threatscore.pdf", + "s3://bucket/ens.pdf", + ] + + result = generate_compliance_reports_job( + tenant_id=self.tenant_id, + scan_id=self.scan_id, + provider_id=self.provider_id, + generate_threatscore=True, + generate_ens=True, + ) + + # Verify Provider fetched only ONCE (optimization) + mock_provider.objects.get.assert_called_once_with(id=self.provider_id) + + # Verify aggregation called only ONCE (optimization) + mock_aggregate_stats.assert_called_once_with(self.tenant_id, self.scan_id) + + # Verify both report generation functions were called with shared data + assert mock_gen_threatscore.call_count == 1 + assert mock_gen_ens.call_count == 1 + + # Verify provider_obj and requirement_statistics were passed to both + threatscore_call_kwargs = mock_gen_threatscore.call_args[1] + assert threatscore_call_kwargs["provider_obj"] == mock_provider_obj + assert threatscore_call_kwargs["requirement_statistics"] == { + "check-1": {"passed": 10, "total": 15} + } + + ens_call_kwargs = mock_gen_ens.call_args[1] + assert ens_call_kwargs["provider_obj"] == mock_provider_obj + assert ens_call_kwargs["requirement_statistics"] == { + "check-1": {"passed": 10, "total": 15} + } + + # Verify both reports were uploaded successfully + assert result["threatscore"]["upload"] is True + assert result["threatscore"]["path"] == "s3://bucket/threatscore.pdf" + assert result["ens"]["upload"] is True + assert result["ens"]["path"] == "s3://bucket/ens.pdf" + + @patch("tasks.jobs.report._aggregate_requirement_statistics_from_database") + @patch("tasks.jobs.report.Provider") + @patch("tasks.jobs.report.ScanSummary") + def test_skips_ens_for_unsupported_provider( + self, mock_scan_summary, mock_provider, mock_aggregate_stats + ): + """Test that ENS report is skipped for M365 provider.""" + mock_scan_summary.objects.filter.return_value.exists.return_value = True + mock_provider_obj = Mock() + mock_provider_obj.uid = "test-uid" + mock_provider_obj.provider = "m365" # Not supported for ENS + mock_provider.objects.get.return_value = mock_provider_obj + + result = generate_compliance_reports_job( + tenant_id=self.tenant_id, + scan_id=self.scan_id, + provider_id=self.provider_id, + ) + + # ENS should be skipped, only ThreatScore key should have error/status + assert "ens" in result + assert result["ens"]["upload"] is False + + def test_findings_cache_reuses_loaded_findings(self): + """Test that findings cache properly reuses findings across calls.""" + # Create mock findings + mock_finding1 = Mock() + mock_finding1.check_id = "check-1" + mock_finding2 = Mock() + mock_finding2.check_id = "check-2" + mock_finding3 = Mock() + mock_finding3.check_id = "check-1" + + mock_output1 = Mock() + mock_output1.check_id = "check-1" + mock_output2 = Mock() + mock_output2.check_id = "check-2" + mock_output3 = Mock() + mock_output3.check_id = "check-1" + + # Pre-populate cache + findings_cache = { + "check-1": [mock_output1, mock_output3], + } + + with ( + patch("tasks.jobs.report.Finding") as mock_finding_class, + patch("tasks.jobs.report.FindingOutput") as mock_finding_output, + patch("tasks.jobs.report.rls_transaction"), + patch("tasks.jobs.report.batched") as mock_batched, + ): + # Setup mocks + mock_finding_class.all_objects.filter.return_value.order_by.return_value.iterator.return_value = [ + mock_finding2 + ] + mock_batched.return_value = [([mock_finding2], True)] + mock_finding_output.transform_api_finding.return_value = mock_output2 + + mock_provider = Mock() + + # Call with cache containing check-1, requesting check-1 and check-2 + result = _load_findings_for_requirement_checks( + tenant_id=self.tenant_id, + scan_id=self.scan_id, + check_ids=["check-1", "check-2"], + prowler_provider=mock_provider, + findings_cache=findings_cache, + ) + + # Verify check-1 was reused from cache (no DB query) + assert len(result["check-1"]) == 2 + assert result["check-1"] == [mock_output1, mock_output3] + + # Verify check-2 was loaded from DB + assert len(result["check-2"]) == 1 + assert result["check-2"][0] == mock_output2 + + # Verify cache was updated with check-2 + assert "check-2" in findings_cache + assert findings_cache["check-2"] == [mock_output2] + + # Verify DB was only queried for check-2 (not check-1) + filter_call = mock_finding_class.all_objects.filter.call_args + assert filter_call[1]["check_id__in"] == ["check-2"] diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index b2d8b7bc2b..938290d947 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -109,7 +109,6 @@ class TestGenerateOutputs: return_value=( "/tmp/test/out-dir", "/tmp/test/comp-dir", - "/tmp/test/threat-dir", ), ), patch("tasks.tasks.Scan.all_objects.filter") as mock_scan_update, @@ -139,7 +138,7 @@ class TestGenerateOutputs: patch("tasks.tasks.Finding.all_objects.filter") as mock_findings, patch( "tasks.tasks._generate_output_directory", - return_value=("/tmp/test/out", "/tmp/test/comp", "/tmp/test/threat"), + return_value=("/tmp/test/out", "/tmp/test/comp"), ), patch("tasks.tasks.FindingOutput._transform_findings_stats"), patch("tasks.tasks.FindingOutput.transform_api_finding"), @@ -209,7 +208,7 @@ class TestGenerateOutputs: patch("tasks.tasks.Finding.all_objects.filter") as mock_findings, patch( "tasks.tasks._generate_output_directory", - return_value=("/tmp/test/out", "/tmp/test/comp", "/tmp/test/threat"), + return_value=("/tmp/test/out", "/tmp/test/comp"), ), patch( "tasks.tasks.FindingOutput._transform_findings_stats", @@ -289,7 +288,6 @@ class TestGenerateOutputs: return_value=( "/tmp/test/outdir", "/tmp/test/compdir", - "/tmp/test/threatdir", ), ), patch("tasks.tasks._compress_output_files", return_value="outdir.zip"), @@ -368,7 +366,6 @@ class TestGenerateOutputs: return_value=( "/tmp/test/outdir", "/tmp/test/compdir", - "/tmp/test/threatdir", ), ), patch("tasks.tasks.FindingOutput._transform_findings_stats"), @@ -436,7 +433,7 @@ class TestGenerateOutputs: patch("tasks.tasks.Finding.all_objects.filter") as mock_findings, patch( "tasks.tasks._generate_output_directory", - return_value=("/tmp/test/out", "/tmp/test/comp", "/tmp/test/threat"), + return_value=("/tmp/test/out", "/tmp/test/comp"), ), patch( "tasks.tasks.FindingOutput._transform_findings_stats", @@ -494,7 +491,7 @@ class TestGenerateOutputs: patch("tasks.tasks.Finding.all_objects.filter") as mock_findings, patch( "tasks.tasks._generate_output_directory", - return_value=("/tmp/test/out", "/tmp/test/comp", "/tmp/test/threat"), + return_value=("/tmp/test/out", "/tmp/test/comp"), ), patch("tasks.tasks.FindingOutput._transform_findings_stats"), patch("tasks.tasks.FindingOutput.transform_api_finding"), @@ -535,34 +532,45 @@ class TestScanCompleteTasks: @patch("tasks.tasks.create_compliance_requirements_task.apply_async") @patch("tasks.tasks.perform_scan_summary_task.si") @patch("tasks.tasks.generate_outputs_task.si") - @patch("tasks.tasks.generate_threatscore_report_task.si") + @patch("tasks.tasks.generate_compliance_reports_task.si") @patch("tasks.tasks.check_integrations_task.si") def test_scan_complete_tasks( self, mock_check_integrations_task, - mock_threatscore_task, + mock_compliance_reports_task, mock_outputs_task, mock_scan_summary_task, - mock_compliance_tasks, + mock_compliance_requirements_task, ): + """Test that scan complete tasks are properly orchestrated with optimized reports.""" _perform_scan_complete_tasks("tenant-id", "scan-id", "provider-id") - mock_compliance_tasks.assert_called_once_with( + + # Verify compliance requirements task is called + mock_compliance_requirements_task.assert_called_once_with( kwargs={"tenant_id": "tenant-id", "scan_id": "scan-id"}, ) + + # Verify scan summary task is called mock_scan_summary_task.assert_called_once_with( scan_id="scan-id", tenant_id="tenant-id", ) + + # Verify outputs task is called mock_outputs_task.assert_called_once_with( scan_id="scan-id", provider_id="provider-id", tenant_id="tenant-id", ) - mock_threatscore_task.assert_called_once_with( + + # Verify optimized compliance reports task is called (replaces individual tasks) + mock_compliance_reports_task.assert_called_once_with( tenant_id="tenant-id", scan_id="scan-id", provider_id="provider-id", ) + + # Verify integrations task is called mock_check_integrations_task.assert_called_once_with( tenant_id="tenant-id", provider_id="provider-id", @@ -738,7 +746,7 @@ class TestCheckIntegrationsTask: mock_initialize_provider.return_value = MagicMock() mock_compliance_bulk.return_value = {} mock_get_frameworks.return_value = [] - mock_generate_dir.return_value = ("out-dir", "comp-dir", "threat-dir") + mock_generate_dir.return_value = ("out-dir", "comp-dir") mock_transform_stats.return_value = {"stats": "data"} # Mock findings @@ -863,7 +871,7 @@ class TestCheckIntegrationsTask: mock_initialize_provider.return_value = MagicMock() mock_compliance_bulk.return_value = {} mock_get_frameworks.return_value = [] - mock_generate_dir.return_value = ("out-dir", "comp-dir", "threat-dir") + mock_generate_dir.return_value = ("out-dir", "comp-dir") mock_transform_stats.return_value = {"stats": "data"} # Mock findings @@ -979,7 +987,7 @@ class TestCheckIntegrationsTask: mock_initialize_provider.return_value = MagicMock() mock_compliance_bulk.return_value = {} mock_get_frameworks.return_value = [] - mock_generate_dir.return_value = ("out-dir", "comp-dir", "threat-dir") + mock_generate_dir.return_value = ("out-dir", "comp-dir") mock_transform_stats.return_value = {"stats": "data"} # Mock findings diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 218e6142e0..5a9a0657f3 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - C5 compliance framework for the GCP provider [(#9097)](https://github.com/prowler-cloud/prowler/pull/9097) - `organization_repository_creation_limited` check for GitHub provider [(#8844)](https://github.com/prowler-cloud/prowler/pull/8844) - HIPAA compliance framework for the GCP provider [(#8955)](https://github.com/prowler-cloud/prowler/pull/8955) +- Support PDF reporting for ENS compliance framework [(#9158)](https://github.com/prowler-cloud/prowler/pull/9158) - Add organization ID parameter for MongoDB Atlas provider [(#9167)](https://github.com/prowler-cloud/prowler/pull/9167) - Add multiple compliance improvements [(#9145)](https://github.com/prowler-cloud/prowler/pull/9145) - Added validation for invalid checks, services, and categories in `load_checks_to_execute` function [(#8971)](https://github.com/prowler-cloud/prowler/pull/8971) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index bc87128158..9efcac3e10 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the **Prowler UI** are documented in this file. - RSS feeds support [(#9109)](https://github.com/prowler-cloud/prowler/pull/9109) - Multi LLM support to Lighthouse AI [(#8925)](https://github.com/prowler-cloud/prowler/pull/8925) - Customer Support menu item [(#9143)](https://github.com/prowler-cloud/prowler/pull/9143) +- PDF reporting for ENS compliance framework [(#9158)](https://github.com/prowler-cloud/prowler/pull/9158) - IaC (Infrastructure as Code) provider support for scanning remote repositories [(#8751)](https://github.com/prowler-cloud/prowler/pull/8751) - External resource link to IaC findings for direct navigation to source code in Git repositories [(#9151)](https://github.com/prowler-cloud/prowler/pull/9151) - New Overview page and new app styles [(#9234)](https://github.com/prowler-cloud/prowler/pull/9234) diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 1442c1a342..5f2caaeee9 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -3,6 +3,10 @@ import { redirect } from "next/navigation"; import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib"; +import { + COMPLIANCE_REPORT_DISPLAY_NAMES, + type ComplianceReportType, +} from "@/lib/compliance/compliance-report-types"; import { addScanOperation } from "@/lib/sentry-breadcrumbs"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; @@ -280,10 +284,19 @@ export const getComplianceCsv = async ( } }; -export const getThreatScorePdf = async (scanId: string) => { +/** + * Generic function to get a compliance PDF report (ThreatScore, ENS, etc.) + * @param scanId - The scan ID + * @param reportType - Type of report (from COMPLIANCE_REPORT_TYPES) + * @returns Promise with the PDF data or error + */ +export const getCompliancePdfReport = async ( + scanId: string, + reportType: ComplianceReportType, +) => { const headers = await getAuthHeaders({ contentType: false }); - const url = new URL(`${apiBaseUrl}/scans/${scanId}/threatscore`); + const url = new URL(`${apiBaseUrl}/scans/${scanId}/${reportType}`); try { const response = await fetch(url.toString(), { headers }); @@ -301,9 +314,10 @@ export const getThreatScorePdf = async (scanId: string) => { if (!response.ok) { const errorData = await response.json(); + const reportName = COMPLIANCE_REPORT_DISPLAY_NAMES[reportType]; throw new Error( errorData?.errors?.detail || - "Unable to retrieve ThreatScore PDF report. Contact support if the issue continues.", + `Unable to retrieve ${reportName} PDF report. Contact support if the issue continues.`, ); } @@ -313,7 +327,7 @@ export const getThreatScorePdf = async (scanId: string) => { return { success: true, data: base64, - filename: `scan-${scanId}-threatscore.pdf`, + filename: `scan-${scanId}-${reportType}.pdf`, }; } catch (error) { return { diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index d94b700b33..4061478f94 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -8,6 +8,7 @@ import { } from "@/actions/compliances"; import { ClientAccordionWrapper, + ComplianceDownloadButton, ComplianceHeader, RequirementsStatusCard, RequirementsStatusCardSkeleton, @@ -20,6 +21,7 @@ import { import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; import { ContentLayout } from "@/components/ui"; import { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; +import { getReportTypeForFramework } from "@/lib/compliance/compliance-report-types"; import { AttributesData, Framework, @@ -27,8 +29,6 @@ import { } from "@/types/compliance"; import { ScanEntity } from "@/types/scans"; -import { ThreatScoreDownloadButton } from "./threatscore-download-button"; - interface ComplianceDetailSearchParams { complianceId: string; version?: string; @@ -92,23 +92,33 @@ export default async function ComplianceDetail({ return ( - - {attributesData?.data?.[0]?.attributes?.framework === - "ProwlerThreatScore" && - selectedScanId && ( -

- -
- )} +
+
+ +
+ {(() => { + const framework = attributesData?.data?.[0]?.attributes?.framework; + const reportType = getReportTypeForFramework(framework); + + return selectedScanId && reportType ? ( +
+ +
+ ) : null; + })()} +
{ + reportType, + label, +}: ComplianceDownloadButtonProps) => { const [isDownloading, setIsDownloading] = useState(false); const handleDownload = async () => { setIsDownloading(true); try { - await downloadThreatScorePdf(scanId, toast); + await downloadComplianceReportPdf(scanId, reportType, toast); } finally { setIsDownloading(false); } }; + const defaultLabel = COMPLIANCE_REPORT_BUTTON_LABELS[reportType]; + return ( ); }; diff --git a/ui/components/compliance/index.ts b/ui/components/compliance/index.ts index ba17df70af..bc92cdd2b9 100644 --- a/ui/components/compliance/index.ts +++ b/ui/components/compliance/index.ts @@ -11,6 +11,7 @@ export * from "./compliance-charts/top-failed-sections-card"; export * from "./compliance-custom-details/cis-details"; export * from "./compliance-custom-details/ens-details"; export * from "./compliance-custom-details/iso-details"; +export * from "./compliance-download-button"; export * from "./compliance-header/compliance-header"; export * from "./compliance-header/compliance-scan-info"; export * from "./compliance-header/data-compliance"; diff --git a/ui/components/compliance/threatscore-badge.tsx b/ui/components/compliance/threatscore-badge.tsx index 542b68d369..96ef61dcca 100644 --- a/ui/components/compliance/threatscore-badge.tsx +++ b/ui/components/compliance/threatscore-badge.tsx @@ -9,7 +9,11 @@ import { useState } from "react"; import { ThreatScoreLogo } from "@/components/compliance/threatscore-logo"; import { Button } from "@/components/shadcn/button/button"; import { toast } from "@/components/ui"; -import { downloadComplianceCsv, downloadThreatScorePdf } from "@/lib/helper"; +import { COMPLIANCE_REPORT_TYPES } from "@/lib/compliance/compliance-report-types"; +import { + downloadComplianceCsv, + downloadComplianceReportPdf, +} from "@/lib/helper"; import type { ScanEntity } from "@/types/scans"; interface ThreatScoreBadgeProps { @@ -77,7 +81,11 @@ export const ThreatScoreBadge = ({ const handleDownloadPdf = async () => { setIsDownloadingPdf(true); try { - await downloadThreatScorePdf(scanId, toast); + await downloadComplianceReportPdf( + scanId, + COMPLIANCE_REPORT_TYPES.THREATSCORE, + toast, + ); } finally { setIsDownloadingPdf(false); } diff --git a/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx b/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx index 480e569887..cbc46c6d25 100644 --- a/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx +++ b/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx @@ -7,6 +7,7 @@ import { usePathname, useSearchParams } from "next/navigation"; import { ReactNode } from "react"; import { LighthouseIcon } from "@/components/icons/Icons"; +import { cn } from "@/lib/utils"; export interface CustomBreadcrumbItem { name: string; @@ -142,7 +143,7 @@ export function BreadcrumbNavigation({ } return ( -
+
{breadcrumbItems.map((breadcrumb, index) => ( diff --git a/ui/lib/compliance/compliance-report-types.ts b/ui/lib/compliance/compliance-report-types.ts new file mode 100644 index 0000000000..b6e614eb7d --- /dev/null +++ b/ui/lib/compliance/compliance-report-types.ts @@ -0,0 +1,71 @@ +/** + * Compliance Report Type Constants + * + * This file defines the available compliance report types and their metadata. + * When adding new compliance PDF reports, add entries here to maintain consistency. + */ + +/** + * Available compliance report types + * Add new report types here as they become available + */ +export const COMPLIANCE_REPORT_TYPES = { + THREATSCORE: "threatscore", + ENS: "ens", + // Future report types can be added here: + // CIS: "cis", + // NIST: "nist", +} as const; + +/** + * Type-safe report type extracted from COMPLIANCE_REPORT_TYPES + */ +export type ComplianceReportType = + (typeof COMPLIANCE_REPORT_TYPES)[keyof typeof COMPLIANCE_REPORT_TYPES]; + +/** + * Display names for each report type (user-facing) + */ +export const COMPLIANCE_REPORT_DISPLAY_NAMES: Record< + ComplianceReportType, + string +> = { + [COMPLIANCE_REPORT_TYPES.THREATSCORE]: "ThreatScore", + [COMPLIANCE_REPORT_TYPES.ENS]: "ENS RD2022", + // Add display names for future report types here +}; + +/** + * Default button labels for download buttons + */ +export const COMPLIANCE_REPORT_BUTTON_LABELS: Record< + ComplianceReportType, + string +> = { + [COMPLIANCE_REPORT_TYPES.THREATSCORE]: "PDF ThreatScore Report", + [COMPLIANCE_REPORT_TYPES.ENS]: "PDF ENS Report", + // Add button labels for future report types here +}; + +/** + * Maps compliance framework names (from API) to their report types + * This mapping determines which frameworks support PDF reporting + */ +const FRAMEWORK_TO_REPORT_TYPE: Record = { + ProwlerThreatScore: COMPLIANCE_REPORT_TYPES.THREATSCORE, + ENS: COMPLIANCE_REPORT_TYPES.ENS, + // Add new framework mappings here as PDF support is added: + // "CIS-1.5": COMPLIANCE_REPORT_TYPES.CIS, + // "NIST-800-53": COMPLIANCE_REPORT_TYPES.NIST, +}; + +/** + * Helper function to get report type from framework name + * Returns undefined if framework doesn't support PDF reporting + */ +export const getReportTypeForFramework = ( + framework: string | undefined, +): ComplianceReportType | undefined => { + if (!framework) return undefined; + return FRAMEWORK_TO_REPORT_TYPE[framework]; +}; diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index 89b352c90b..d06a7c5474 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -1,11 +1,15 @@ import { getComplianceCsv, + getCompliancePdfReport, getExportsZip, - getThreatScorePdf, } from "@/actions/scans"; import { getTask } from "@/actions/task"; import { auth } from "@/auth.config"; import { useToast } from "@/components/ui"; +import { + COMPLIANCE_REPORT_DISPLAY_NAMES, + type ComplianceReportType, +} from "@/lib/compliance/compliance-report-types"; import { AuthSocialProvider, MetaDataProps, PermissionInfo } from "@/types"; export const baseUrl = process.env.AUTH_URL || "http://localhost:3000"; @@ -221,15 +225,23 @@ export const downloadComplianceCsv = async ( ); }; -export const downloadThreatScorePdf = async ( +/** + * Generic function to download a compliance PDF report (ThreatScore, ENS, etc.) + * @param scanId - The scan ID + * @param reportType - Type of report (from COMPLIANCE_REPORT_TYPES) + * @param toast - Toast notification function + */ +export const downloadComplianceReportPdf = async ( scanId: string, + reportType: ComplianceReportType, toast: ReturnType["toast"], ): Promise => { - const result = await getThreatScorePdf(scanId); + const result = await getCompliancePdfReport(scanId, reportType); + const reportName = COMPLIANCE_REPORT_DISPLAY_NAMES[reportType]; await downloadFile( result, "application/pdf", - "The ThreatScore PDF report has been downloaded successfully.", + `The ${reportName} PDF report has been downloaded successfully.`, toast, ); };