diff --git a/api/poetry.lock b/api/poetry.lock index 771a036594..5a298bfbe0 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "about-time" @@ -1142,6 +1142,83 @@ files = [ ] markers = {dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} +[[package]] +name = "contourpy" +version = "1.3.2" +description = "Python library for calculating contours of 2D quadrilateral grids" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934"}, + {file = "contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989"}, + {file = "contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d"}, + {file = "contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9"}, + {file = "contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512"}, + {file = "contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631"}, + {file = "contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f"}, + {file = "contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2"}, + {file = "contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0"}, + {file = "contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a"}, + {file = "contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445"}, + {file = "contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773"}, + {file = "contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1"}, + {file = "contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43"}, + {file = "contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab"}, + {file = "contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7"}, + {file = "contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83"}, + {file = "contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd"}, + {file = "contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f"}, + {file = "contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878"}, + {file = "contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2"}, + {file = "contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15"}, + {file = "contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92"}, + {file = "contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87"}, + {file = "contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415"}, + {file = "contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe"}, + {file = "contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441"}, + {file = "contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e"}, + {file = "contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912"}, + {file = "contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73"}, + {file = "contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb"}, + {file = "contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08"}, + {file = "contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c"}, + {file = "contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f"}, + {file = "contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85"}, + {file = "contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841"}, + {file = "contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422"}, + {file = "contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef"}, + {file = "contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f"}, + {file = "contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9"}, + {file = "contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f"}, + {file = "contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739"}, + {file = "contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823"}, + {file = "contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5"}, + {file = "contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532"}, + {file = "contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b"}, + {file = "contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52"}, + {file = "contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd"}, + {file = "contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1"}, + {file = "contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69"}, + {file = "contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c"}, + {file = "contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16"}, + {file = "contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad"}, + {file = "contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0"}, + {file = "contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5"}, + {file = "contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5"}, + {file = "contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54"}, +] + +[package.dependencies] +numpy = ">=1.23" + +[package.extras] +bokeh = ["bokeh", "selenium"] +docs = ["furo", "sphinx (>=7.2)", "sphinx-copybutton"] +mypy = ["bokeh", "contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.15.0)", "types-Pillow"] +test = ["Pillow", "contourpy[test-no-images]", "matplotlib"] +test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist", "wurlitzer"] + [[package]] name = "coverage" version = "7.5.4" @@ -1276,6 +1353,22 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi (>=2024)", "cryptography-vectors (==44.0.1)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "cycler" +version = "0.12.1" +description = "Composable style cycles" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, + {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"}, +] + +[package.extras] +docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] +tests = ["pytest", "pytest-cov", "pytest-xdist"] + [[package]] name = "dash" version = "2.18.2" @@ -1972,6 +2065,71 @@ Werkzeug = ">=3.0.0" async = ["asgiref (>=3.2)"] dotenv = ["python-dotenv"] +[[package]] +name = "fonttools" +version = "4.59.0" +description = "Tools to manipulate font files" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "fonttools-4.59.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:524133c1be38445c5c0575eacea42dbd44374b310b1ffc4b60ff01d881fabb96"}, + {file = "fonttools-4.59.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:21e606b2d38fed938dde871c5736822dd6bda7a4631b92e509a1f5cd1b90c5df"}, + {file = "fonttools-4.59.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e93df708c69a193fc7987192f94df250f83f3851fda49413f02ba5dded639482"}, + {file = "fonttools-4.59.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:62224a9bb85b4b66d1b46d45cbe43d71cbf8f527d332b177e3b96191ffbc1e64"}, + {file = "fonttools-4.59.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b8974b2a266b54c96709bd5e239979cddfd2dbceed331aa567ea1d7c4a2202db"}, + {file = "fonttools-4.59.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:209b75943d158f610b78320eacb5539aa9e920bee2c775445b2846c65d20e19d"}, + {file = "fonttools-4.59.0-cp310-cp310-win32.whl", hash = "sha256:4c908a7036f0f3677f8afa577bcd973e3e20ddd2f7c42a33208d18bee95cdb6f"}, + {file = "fonttools-4.59.0-cp310-cp310-win_amd64.whl", hash = "sha256:8b4309a2775e4feee7356e63b163969a215d663399cce1b3d3b65e7ec2d9680e"}, + {file = "fonttools-4.59.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:841b2186adce48903c0fef235421ae21549020eca942c1da773ac380b056ab3c"}, + {file = "fonttools-4.59.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9bcc1e77fbd1609198966ded6b2a9897bd6c6bcbd2287a2fc7d75f1a254179c5"}, + {file = "fonttools-4.59.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37c377f7cb2ab2eca8a0b319c68146d34a339792f9420fca6cd49cf28d370705"}, + {file = "fonttools-4.59.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa39475eaccb98f9199eccfda4298abaf35ae0caec676ffc25b3a5e224044464"}, + {file = "fonttools-4.59.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d3972b13148c1d1fbc092b27678a33b3080d1ac0ca305742b0119b75f9e87e38"}, + {file = "fonttools-4.59.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a408c3c51358c89b29cfa5317cf11518b7ce5de1717abb55c5ae2d2921027de6"}, + {file = "fonttools-4.59.0-cp311-cp311-win32.whl", hash = "sha256:6770d7da00f358183d8fd5c4615436189e4f683bdb6affb02cad3d221d7bb757"}, + {file = "fonttools-4.59.0-cp311-cp311-win_amd64.whl", hash = "sha256:84fc186980231a287b28560d3123bd255d3c6b6659828c642b4cf961e2b923d0"}, + {file = "fonttools-4.59.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f9b3a78f69dcbd803cf2fb3f972779875b244c1115481dfbdd567b2c22b31f6b"}, + {file = "fonttools-4.59.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:57bb7e26928573ee7c6504f54c05860d867fd35e675769f3ce01b52af38d48e2"}, + {file = "fonttools-4.59.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4536f2695fe5c1ffb528d84a35a7d3967e5558d2af58b4775e7ab1449d65767b"}, + {file = "fonttools-4.59.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:885bde7d26e5b40e15c47bd5def48b38cbd50830a65f98122a8fb90962af7cd1"}, + {file = "fonttools-4.59.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6801aeddb6acb2c42eafa45bc1cb98ba236871ae6f33f31e984670b749a8e58e"}, + {file = "fonttools-4.59.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:31003b6a10f70742a63126b80863ab48175fb8272a18ca0846c0482968f0588e"}, + {file = "fonttools-4.59.0-cp312-cp312-win32.whl", hash = "sha256:fbce6dae41b692a5973d0f2158f782b9ad05babc2c2019a970a1094a23909b1b"}, + {file = "fonttools-4.59.0-cp312-cp312-win_amd64.whl", hash = "sha256:332bfe685d1ac58ca8d62b8d6c71c2e52a6c64bc218dc8f7825c9ea51385aa01"}, + {file = "fonttools-4.59.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:78813b49d749e1bb4db1c57f2d4d7e6db22c253cb0a86ad819f5dc197710d4b2"}, + {file = "fonttools-4.59.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:401b1941ce37e78b8fd119b419b617277c65ae9417742a63282257434fd68ea2"}, + {file = "fonttools-4.59.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:efd7e6660674e234e29937bc1481dceb7e0336bfae75b856b4fb272b5093c5d4"}, + {file = "fonttools-4.59.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51ab1ff33c19e336c02dee1e9fd1abd974a4ca3d8f7eef2a104d0816a241ce97"}, + {file = "fonttools-4.59.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a9bf8adc9e1f3012edc8f09b08336272aec0c55bc677422273e21280db748f7c"}, + {file = "fonttools-4.59.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37e01c6ec0c98599778c2e688350d624fa4770fbd6144551bd5e032f1199171c"}, + {file = "fonttools-4.59.0-cp313-cp313-win32.whl", hash = "sha256:70d6b3ceaa9cc5a6ac52884f3b3d9544e8e231e95b23f138bdb78e6d4dc0eae3"}, + {file = "fonttools-4.59.0-cp313-cp313-win_amd64.whl", hash = "sha256:26731739daa23b872643f0e4072d5939960237d540c35c14e6a06d47d71ca8fe"}, + {file = "fonttools-4.59.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8d77f92438daeaddc05682f0f3dac90c5b9829bcac75b57e8ce09cb67786073c"}, + {file = "fonttools-4.59.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:60f6665579e909b618282f3c14fa0b80570fbf1ee0e67678b9a9d43aa5d67a37"}, + {file = "fonttools-4.59.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:169b99a2553a227f7b5fea8d9ecd673aa258617f466b2abc6091fe4512a0dcd0"}, + {file = "fonttools-4.59.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:052444a5d0151878e87e3e512a1aa1a0ab35ee4c28afde0a778e23b0ace4a7de"}, + {file = "fonttools-4.59.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d40dcf533ca481355aa7b682e9e079f766f35715defa4929aeb5597f9604272e"}, + {file = "fonttools-4.59.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b818db35879d2edf7f46c7e729c700a0bce03b61b9412f5a7118406687cb151d"}, + {file = "fonttools-4.59.0-cp39-cp39-win32.whl", hash = "sha256:2e7cf8044ce2598bb87e44ba1d2c6e45d7a8decf56055b92906dc53f67c76d64"}, + {file = "fonttools-4.59.0-cp39-cp39-win_amd64.whl", hash = "sha256:902425f5afe28572d65d2bf9c33edd5265c612ff82c69e6f83ea13eafc0dcbea"}, + {file = "fonttools-4.59.0-py3-none-any.whl", hash = "sha256:241313683afd3baacb32a6bd124d0bce7404bc5280e12e291bae1b9bba28711d"}, + {file = "fonttools-4.59.0.tar.gz", hash = "sha256:be392ec3529e2f57faa28709d60723a763904f71a2b63aabe14fee6648fe3b14"}, +] + +[package.extras] +all = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\"", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.1.0) ; python_version <= \"3.12\"", "xattr ; sys_platform == \"darwin\"", "zopfli (>=0.1.4)"] +graphite = ["lz4 (>=1.7.4.2)"] +interpolatable = ["munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\""] +lxml = ["lxml (>=4.0)"] +pathops = ["skia-pathops (>=0.5.0)"] +plot = ["matplotlib"] +repacker = ["uharfbuzz (>=0.23.0)"] +symfont = ["sympy"] +type1 = ["xattr ; sys_platform == \"darwin\""] +unicode = ["unicodedata2 (>=15.1.0) ; python_version <= \"3.12\""] +woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] + [[package]] name = "freezegun" version = "1.5.1" @@ -2625,6 +2783,96 @@ files = [ [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "kiwisolver" +version = "1.4.8" +description = "A fast implementation of the Cassowary constraint solver" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "kiwisolver-1.4.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88c6f252f6816a73b1f8c904f7bbe02fd67c09a69f7cb8a0eecdbf5ce78e63db"}, + {file = "kiwisolver-1.4.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c72941acb7b67138f35b879bbe85be0f6c6a70cab78fe3ef6db9c024d9223e5b"}, + {file = "kiwisolver-1.4.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce2cf1e5688edcb727fdf7cd1bbd0b6416758996826a8be1d958f91880d0809d"}, + {file = "kiwisolver-1.4.8-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c8bf637892dc6e6aad2bc6d4d69d08764166e5e3f69d469e55427b6ac001b19d"}, + {file = "kiwisolver-1.4.8-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:034d2c891f76bd3edbdb3ea11140d8510dca675443da7304205a2eaa45d8334c"}, + {file = "kiwisolver-1.4.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d47b28d1dfe0793d5e96bce90835e17edf9a499b53969b03c6c47ea5985844c3"}, + {file = "kiwisolver-1.4.8-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb158fe28ca0c29f2260cca8c43005329ad58452c36f0edf298204de32a9a3ed"}, + {file = "kiwisolver-1.4.8-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5536185fce131780ebd809f8e623bf4030ce1b161353166c49a3c74c287897f"}, + {file = "kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:369b75d40abedc1da2c1f4de13f3482cb99e3237b38726710f4a793432b1c5ff"}, + {file = "kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:641f2ddf9358c80faa22e22eb4c9f54bd3f0e442e038728f500e3b978d00aa7d"}, + {file = "kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d561d2d8883e0819445cfe58d7ddd673e4015c3c57261d7bdcd3710d0d14005c"}, + {file = "kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1732e065704b47c9afca7ffa272f845300a4eb959276bf6970dc07265e73b605"}, + {file = "kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bcb1ebc3547619c3b58a39e2448af089ea2ef44b37988caf432447374941574e"}, + {file = "kiwisolver-1.4.8-cp310-cp310-win_amd64.whl", hash = "sha256:89c107041f7b27844179ea9c85d6da275aa55ecf28413e87624d033cf1f6b751"}, + {file = "kiwisolver-1.4.8-cp310-cp310-win_arm64.whl", hash = "sha256:b5773efa2be9eb9fcf5415ea3ab70fc785d598729fd6057bea38d539ead28271"}, + {file = "kiwisolver-1.4.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a4d3601908c560bdf880f07d94f31d734afd1bb71e96585cace0e38ef44c6d84"}, + {file = "kiwisolver-1.4.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:856b269c4d28a5c0d5e6c1955ec36ebfd1651ac00e1ce0afa3e28da95293b561"}, + {file = "kiwisolver-1.4.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c2b9a96e0f326205af81a15718a9073328df1173a2619a68553decb7097fd5d7"}, + {file = "kiwisolver-1.4.8-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5020c83e8553f770cb3b5fc13faac40f17e0b205bd237aebd21d53d733adb03"}, + {file = "kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dace81d28c787956bfbfbbfd72fdcef014f37d9b48830829e488fdb32b49d954"}, + {file = "kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11e1022b524bd48ae56c9b4f9296bce77e15a2e42a502cceba602f804b32bb79"}, + {file = "kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b9b4d2892fefc886f30301cdd80debd8bb01ecdf165a449eb6e78f79f0fabd6"}, + {file = "kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a96c0e790ee875d65e340ab383700e2b4891677b7fcd30a699146f9384a2bb0"}, + {file = "kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:23454ff084b07ac54ca8be535f4174170c1094a4cff78fbae4f73a4bcc0d4dab"}, + {file = "kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:87b287251ad6488e95b4f0b4a79a6d04d3ea35fde6340eb38fbd1ca9cd35bbbc"}, + {file = "kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b21dbe165081142b1232a240fc6383fd32cdd877ca6cc89eab93e5f5883e1c25"}, + {file = "kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:768cade2c2df13db52475bd28d3a3fac8c9eff04b0e9e2fda0f3760f20b3f7fc"}, + {file = "kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d47cfb2650f0e103d4bf68b0b5804c68da97272c84bb12850d877a95c056bd67"}, + {file = "kiwisolver-1.4.8-cp311-cp311-win_amd64.whl", hash = "sha256:ed33ca2002a779a2e20eeb06aea7721b6e47f2d4b8a8ece979d8ba9e2a167e34"}, + {file = "kiwisolver-1.4.8-cp311-cp311-win_arm64.whl", hash = "sha256:16523b40aab60426ffdebe33ac374457cf62863e330a90a0383639ce14bf44b2"}, + {file = "kiwisolver-1.4.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d6af5e8815fd02997cb6ad9bbed0ee1e60014438ee1a5c2444c96f87b8843502"}, + {file = "kiwisolver-1.4.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bade438f86e21d91e0cf5dd7c0ed00cda0f77c8c1616bd83f9fc157fa6760d31"}, + {file = "kiwisolver-1.4.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b83dc6769ddbc57613280118fb4ce3cd08899cc3369f7d0e0fab518a7cf37fdb"}, + {file = "kiwisolver-1.4.8-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111793b232842991be367ed828076b03d96202c19221b5ebab421ce8bcad016f"}, + {file = "kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:257af1622860e51b1a9d0ce387bf5c2c4f36a90594cb9514f55b074bcc787cfc"}, + {file = "kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b5637c3f316cab1ec1c9a12b8c5f4750a4c4b71af9157645bf32830e39c03a"}, + {file = "kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:782bb86f245ec18009890e7cb8d13a5ef54dcf2ebe18ed65f795e635a96a1c6a"}, + {file = "kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc978a80a0db3a66d25767b03688f1147a69e6237175c0f4ffffaaedf744055a"}, + {file = "kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:36dbbfd34838500a31f52c9786990d00150860e46cd5041386f217101350f0d3"}, + {file = "kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eaa973f1e05131de5ff3569bbba7f5fd07ea0595d3870ed4a526d486fe57fa1b"}, + {file = "kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a66f60f8d0c87ab7f59b6fb80e642ebb29fec354a4dfad687ca4092ae69d04f4"}, + {file = "kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858416b7fb777a53f0c59ca08190ce24e9abbd3cffa18886a5781b8e3e26f65d"}, + {file = "kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:085940635c62697391baafaaeabdf3dd7a6c3643577dde337f4d66eba021b2b8"}, + {file = "kiwisolver-1.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:01c3d31902c7db5fb6182832713d3b4122ad9317c2c5877d0539227d96bb2e50"}, + {file = "kiwisolver-1.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:a3c44cb68861de93f0c4a8175fbaa691f0aa22550c331fefef02b618a9dcb476"}, + {file = "kiwisolver-1.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1c8ceb754339793c24aee1c9fb2485b5b1f5bb1c2c214ff13368431e51fc9a09"}, + {file = "kiwisolver-1.4.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a62808ac74b5e55a04a408cda6156f986cefbcf0ada13572696b507cc92fa1"}, + {file = "kiwisolver-1.4.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68269e60ee4929893aad82666821aaacbd455284124817af45c11e50a4b42e3c"}, + {file = "kiwisolver-1.4.8-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34d142fba9c464bc3bbfeff15c96eab0e7310343d6aefb62a79d51421fcc5f1b"}, + {file = "kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc373e0eef45b59197de815b1b28ef89ae3955e7722cc9710fb91cd77b7f47"}, + {file = "kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77e6f57a20b9bd4e1e2cedda4d0b986ebd0216236f0106e55c28aea3d3d69b16"}, + {file = "kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08e77738ed7538f036cd1170cbed942ef749137b1311fa2bbe2a7fda2f6bf3cc"}, + {file = "kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5ce1e481a74b44dd5e92ff03ea0cb371ae7a0268318e202be06c8f04f4f1246"}, + {file = "kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc2ace710ba7c1dfd1a3b42530b62b9ceed115f19a1656adefce7b1782a37794"}, + {file = "kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3452046c37c7692bd52b0e752b87954ef86ee2224e624ef7ce6cb21e8c41cc1b"}, + {file = "kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e9a60b50fe8b2ec6f448fe8d81b07e40141bfced7f896309df271a0b92f80f3"}, + {file = "kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:918139571133f366e8362fa4a297aeba86c7816b7ecf0bc79168080e2bd79957"}, + {file = "kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e063ef9f89885a1d68dd8b2e18f5ead48653176d10a0e324e3b0030e3a69adeb"}, + {file = "kiwisolver-1.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:a17b7c4f5b2c51bb68ed379defd608a03954a1845dfed7cc0117f1cc8a9b7fd2"}, + {file = "kiwisolver-1.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:3cd3bc628b25f74aedc6d374d5babf0166a92ff1317f46267f12d2ed54bc1d30"}, + {file = "kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:370fd2df41660ed4e26b8c9d6bbcad668fbe2560462cba151a721d49e5b6628c"}, + {file = "kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:84a2f830d42707de1d191b9490ac186bf7997a9495d4e9072210a1296345f7dc"}, + {file = "kiwisolver-1.4.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7a3ad337add5148cf51ce0b55642dc551c0b9d6248458a757f98796ca7348712"}, + {file = "kiwisolver-1.4.8-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7506488470f41169b86d8c9aeff587293f530a23a23a49d6bc64dab66bedc71e"}, + {file = "kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f0121b07b356a22fb0414cec4666bbe36fd6d0d759db3d37228f496ed67c880"}, + {file = "kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6d6bd87df62c27d4185de7c511c6248040afae67028a8a22012b010bc7ad062"}, + {file = "kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:291331973c64bb9cce50bbe871fb2e675c4331dab4f31abe89f175ad7679a4d7"}, + {file = "kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:893f5525bb92d3d735878ec00f781b2de998333659507d29ea4466208df37bed"}, + {file = "kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b47a465040146981dc9db8647981b8cb96366fbc8d452b031e4f8fdffec3f26d"}, + {file = "kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:99cea8b9dd34ff80c521aef46a1dddb0dcc0283cf18bde6d756f1e6f31772165"}, + {file = "kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:151dffc4865e5fe6dafce5480fab84f950d14566c480c08a53c663a0020504b6"}, + {file = "kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:577facaa411c10421314598b50413aa1ebcf5126f704f1e5d72d7e4e9f020d90"}, + {file = "kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:be4816dc51c8a471749d664161b434912eee82f2ea66bd7628bd14583a833e85"}, + {file = "kiwisolver-1.4.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e7a019419b7b510f0f7c9dceff8c5eae2392037eae483a7f9162625233802b0a"}, + {file = "kiwisolver-1.4.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:286b18e86682fd2217a48fc6be6b0f20c1d0ed10958d8dc53453ad58d7be0bf8"}, + {file = "kiwisolver-1.4.8-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4191ee8dfd0be1c3666ccbac178c5a05d5f8d689bbe3fc92f3c4abec817f8fe0"}, + {file = "kiwisolver-1.4.8-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cd2785b9391f2873ad46088ed7599a6a71e762e1ea33e87514b1a441ed1da1c"}, + {file = "kiwisolver-1.4.8-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c07b29089b7ba090b6f1a669f1411f27221c3662b3a1b7010e67b59bb5a6f10b"}, + {file = "kiwisolver-1.4.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:65ea09a5a3faadd59c2ce96dc7bf0f364986a315949dc6374f04396b0d60e09b"}, + {file = "kiwisolver-1.4.8.tar.gz", hash = "sha256:23d5f023bdc8c7e54eb65f03ca5d5bb25b601eac4d7f1a042888a1f45237987e"}, +] + [[package]] name = "kombu" version = "5.5.3" @@ -2959,6 +3207,64 @@ dev = ["marshmallow[tests]", "pre-commit (>=3.5,<5.0)", "tox"] docs = ["autodocsumm (==0.2.14)", "furo (==2024.8.6)", "sphinx (==8.1.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.0)", "sphinxext-opengraph (==0.9.1)"] tests = ["pytest", "simplejson"] +[[package]] +name = "matplotlib" +version = "3.10.3" +description = "Python plotting package" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "matplotlib-3.10.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:213fadd6348d106ca7db99e113f1bea1e65e383c3ba76e8556ba4a3054b65ae7"}, + {file = "matplotlib-3.10.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3bec61cb8221f0ca6313889308326e7bb303d0d302c5cc9e523b2f2e6c73deb"}, + {file = "matplotlib-3.10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c21ae75651c0231b3ba014b6d5e08fb969c40cdb5a011e33e99ed0c9ea86ecb"}, + {file = "matplotlib-3.10.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a49e39755580b08e30e3620efc659330eac5d6534ab7eae50fa5e31f53ee4e30"}, + {file = "matplotlib-3.10.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cf4636203e1190871d3a73664dea03d26fb019b66692cbfd642faafdad6208e8"}, + {file = "matplotlib-3.10.3-cp310-cp310-win_amd64.whl", hash = "sha256:fd5641a9bb9d55f4dd2afe897a53b537c834b9012684c8444cc105895c8c16fd"}, + {file = "matplotlib-3.10.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0ef061f74cd488586f552d0c336b2f078d43bc00dc473d2c3e7bfee2272f3fa8"}, + {file = "matplotlib-3.10.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d96985d14dc5f4a736bbea4b9de9afaa735f8a0fc2ca75be2fa9e96b2097369d"}, + {file = "matplotlib-3.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c5f0283da91e9522bdba4d6583ed9d5521566f63729ffb68334f86d0bb98049"}, + {file = "matplotlib-3.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdfa07c0ec58035242bc8b2c8aae37037c9a886370eef6850703d7583e19964b"}, + {file = "matplotlib-3.10.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c0b9849a17bce080a16ebcb80a7b714b5677d0ec32161a2cc0a8e5a6030ae220"}, + {file = "matplotlib-3.10.3-cp311-cp311-win_amd64.whl", hash = "sha256:eef6ed6c03717083bc6d69c2d7ee8624205c29a8e6ea5a31cd3492ecdbaee1e1"}, + {file = "matplotlib-3.10.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ab1affc11d1f495ab9e6362b8174a25afc19c081ba5b0775ef00533a4236eea"}, + {file = "matplotlib-3.10.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2a818d8bdcafa7ed2eed74487fdb071c09c1ae24152d403952adad11fa3c65b4"}, + {file = "matplotlib-3.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:748ebc3470c253e770b17d8b0557f0aa85cf8c63fd52f1a61af5b27ec0b7ffee"}, + {file = "matplotlib-3.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed70453fd99733293ace1aec568255bc51c6361cb0da94fa5ebf0649fdb2150a"}, + {file = "matplotlib-3.10.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbed9917b44070e55640bd13419de83b4c918e52d97561544814ba463811cbc7"}, + {file = "matplotlib-3.10.3-cp312-cp312-win_amd64.whl", hash = "sha256:cf37d8c6ef1a48829443e8ba5227b44236d7fcaf7647caa3178a4ff9f7a5be05"}, + {file = "matplotlib-3.10.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9f2efccc8dcf2b86fc4ee849eea5dcaecedd0773b30f47980dc0cbeabf26ec84"}, + {file = "matplotlib-3.10.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ddbba06a6c126e3301c3d272a99dcbe7f6c24c14024e80307ff03791a5f294e"}, + {file = "matplotlib-3.10.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:748302b33ae9326995b238f606e9ed840bf5886ebafcb233775d946aa8107a15"}, + {file = "matplotlib-3.10.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a80fcccbef63302c0efd78042ea3c2436104c5b1a4d3ae20f864593696364ac7"}, + {file = "matplotlib-3.10.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55e46cbfe1f8586adb34f7587c3e4f7dedc59d5226719faf6cb54fc24f2fd52d"}, + {file = "matplotlib-3.10.3-cp313-cp313-win_amd64.whl", hash = "sha256:151d89cb8d33cb23345cd12490c76fd5d18a56581a16d950b48c6ff19bb2ab93"}, + {file = "matplotlib-3.10.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c26dd9834e74d164d06433dc7be5d75a1e9890b926b3e57e74fa446e1a62c3e2"}, + {file = "matplotlib-3.10.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:24853dad5b8c84c8c2390fc31ce4858b6df504156893292ce8092d190ef8151d"}, + {file = "matplotlib-3.10.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68f7878214d369d7d4215e2a9075fef743be38fa401d32e6020bab2dfabaa566"}, + {file = "matplotlib-3.10.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6929fc618cb6db9cb75086f73b3219bbb25920cb24cee2ea7a12b04971a4158"}, + {file = "matplotlib-3.10.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7818292a5cc372a2dc4c795e5c356942eb8350b98ef913f7fda51fe175ac5d"}, + {file = "matplotlib-3.10.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4f23ffe95c5667ef8a2b56eea9b53db7f43910fa4a2d5472ae0f72b64deab4d5"}, + {file = "matplotlib-3.10.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:86ab63d66bbc83fdb6733471d3bff40897c1e9921cba112accd748eee4bce5e4"}, + {file = "matplotlib-3.10.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a48f9c08bf7444b5d2391a83e75edb464ccda3c380384b36532a0962593a1751"}, + {file = "matplotlib-3.10.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb73d8aa75a237457988f9765e4dfe1c0d2453c5ca4eabc897d4309672c8e014"}, + {file = "matplotlib-3.10.3.tar.gz", hash = "sha256:2f82d2c5bb7ae93aaaa4cd42aca65d76ce6376f83304fa3a630b569aca274df0"}, +] + +[package.dependencies] +contourpy = ">=1.0.1" +cycler = ">=0.10" +fonttools = ">=4.22.0" +kiwisolver = ">=1.3.1" +numpy = ">=1.23" +packaging = ">=20.0" +pillow = ">=8" +pyparsing = ">=2.3.1" +python-dateutil = ">=2.7" + +[package.extras] +dev = ["meson-python (>=0.13.1,<0.17.0)", "pybind11 (>=2.13.2,!=2.13.3)", "setuptools (>=64)", "setuptools_scm (>=7)"] + [[package]] name = "mccabe" version = "0.7.0" @@ -3671,6 +3977,131 @@ files = [ [package.dependencies] setuptools = "*" +[[package]] +name = "pillow" +version = "11.3.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"}, + {file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae"}, + {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9"}, + {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e"}, + {file = "pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6"}, + {file = "pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f"}, + {file = "pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f"}, + {file = "pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722"}, + {file = "pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f"}, + {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e"}, + {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94"}, + {file = "pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0"}, + {file = "pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac"}, + {file = "pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd"}, + {file = "pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4"}, + {file = "pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024"}, + {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809"}, + {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d"}, + {file = "pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149"}, + {file = "pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d"}, + {file = "pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f"}, + {file = "pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c"}, + {file = "pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8"}, + {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2"}, + {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b"}, + {file = "pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3"}, + {file = "pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51"}, + {file = "pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580"}, + {file = "pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e"}, + {file = "pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59"}, + {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe"}, + {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c"}, + {file = "pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788"}, + {file = "pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31"}, + {file = "pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e"}, + {file = "pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12"}, + {file = "pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77"}, + {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874"}, + {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a"}, + {file = "pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214"}, + {file = "pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635"}, + {file = "pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6"}, + {file = "pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae"}, + {file = "pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477"}, + {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50"}, + {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b"}, + {file = "pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12"}, + {file = "pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db"}, + {file = "pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa"}, + {file = "pillow-11.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f"}, + {file = "pillow-11.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a"}, + {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978"}, + {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d"}, + {file = "pillow-11.3.0-cp39-cp39-win32.whl", hash = "sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71"}, + {file = "pillow-11.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada"}, + {file = "pillow-11.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8"}, + {file = "pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +typing = ["typing-extensions ; python_version < \"3.10\""] +xmp = ["defusedxml"] + [[package]] name = "platformdirs" version = "4.3.7" @@ -4731,6 +5162,29 @@ attrs = ">=22.2.0" rpds-py = ">=0.7.0" typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} +[[package]] +name = "reportlab" +version = "4.4.3" +description = "The Reportlab Toolkit" +optional = false +python-versions = "<4,>=3.7" +groups = ["main"] +files = [ + {file = "reportlab-4.4.3-py3-none-any.whl", hash = "sha256:df905dc5ec5ddaae91fc9cb3371af863311271d555236410954961c5ee6ee1b5"}, + {file = "reportlab-4.4.3.tar.gz", hash = "sha256:073b0975dab69536acd3251858e6b0524ed3e087e71f1d0d1895acb50acf9c7b"}, +] + +[package.dependencies] +charset-normalizer = "*" +pillow = ">=9.0.0" + +[package.extras] +accel = ["rl_accel (>=0.9.0,<1.1)"] +bidi = ["rlbidi"] +pycairo = ["freetype-py (>=2.3.0,<2.4)", "rlPyCairo (>=0.2.0,<1)"] +renderpm = ["rl_renderPM (>=4.0.3,<4.1)"] +shaping = ["uharfbuzz"] + [[package]] name = "requests" version = "2.32.4" @@ -5916,4 +6370,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.13" -content-hash = "6802b33984c2f8438c9dc02dac0a0c14d5a78af60251bd0c80ca59bc2182c48e" +content-hash = "60b3d54713a358d4fa8d1b3d25982118ddd16ebae70301d7c6c8313c51a86e8d" diff --git a/api/pyproject.toml b/api/pyproject.toml index f7cd0d1808..167da24aaf 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -30,7 +30,9 @@ dependencies = [ "sentry-sdk[django] (>=2.20.0,<3.0.0)", "uuid6==2024.7.10", "openai (>=1.82.0,<2.0.0)", - "xmlsec==1.3.14" + "xmlsec==1.3.14", + "matplotlib>=3.8.0,<4.0.0", + "reportlab>=4.0.0,<5.0.0" ] description = "Prowler's API (Django/DRF)" license = "Apache-2.0" diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index eab8286736..cab994468d 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -983,6 +983,14 @@ class ScanComplianceReportSerializer(serializers.Serializer): fields = ["id", "name"] +class ScanThreatscoreReportSerializer(serializers.Serializer): + id = serializers.CharField(source="scan") + + class Meta: + resource_name = "scan-reports" + fields = ["id"] + + class ResourceTagSerializer(RLSSerializer): """ Serializer for the ResourceTag model @@ -1808,14 +1816,6 @@ class ComplianceOverviewMetadataSerializer(serializers.Serializer): resource_name = "compliance-overviews-metadata" -class ComplianceOverviewThreatscoreReportSerializer(serializers.Serializer): - id = serializers.CharField(source="scan") - - class Meta: - resource_name = "scan-threatscore-reports" - fields = ["id"] - - # Overviews diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index dc144ddb5d..1ffd606868 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -137,7 +137,6 @@ from api.v1.serializers import ( ComplianceOverviewDetailSerializer, ComplianceOverviewMetadataSerializer, ComplianceOverviewSerializer, - ComplianceOverviewThreatscoreReportSerializer, FindingDynamicFilterSerializer, FindingMetadataSerializer, FindingSerializer, @@ -181,6 +180,7 @@ from api.v1.serializers import ( ScanCreateSerializer, ScanReportSerializer, ScanSerializer, + ScanThreatscoreReportSerializer, ScanUpdateSerializer, ScheduleDailyCreateSerializer, TaskSerializer, @@ -293,7 +293,7 @@ class SchemaView(SpectacularAPIView): def get(self, request, *args, **kwargs): spectacular_settings.TITLE = "Prowler API" - spectacular_settings.VERSION = "1.10.1" + spectacular_settings.VERSION = "1.10.2" spectacular_settings.DESCRIPTION = ( "Prowler API specification.\n\nThis file is auto-generated." ) @@ -1497,6 +1497,22 @@ class ProviderViewSet(BaseRLSViewSet): }, request=None, ), + threatscore=extend_schema( + tags=["Scan"], + summary="Retrieve threatscore report", + description="Download a specific threatscore report (e.g., 'prowler_threatscore_aws') as a PDF file.", + request=ScanThreatscoreReportSerializer, + responses={ + 200: OpenApiResponse( + description="PDF file containing the threatscore report" + ), + 202: OpenApiResponse(description="The task is in progress"), + 403: OpenApiResponse(description="There is a problem with credentials"), + 404: OpenApiResponse( + description="The scan has no threatscore reports, or the threatscore report generation task has not started yet" + ), + }, + ), ) @method_decorator(CACHE_DECORATOR, name="list") @method_decorator(CACHE_DECORATOR, name="retrieve") @@ -1553,6 +1569,10 @@ class ScanViewSet(BaseRLSViewSet): if hasattr(self, "response_serializer_class"): return self.response_serializer_class return ScanComplianceReportSerializer + elif self.action == "threatscore": + if hasattr(self, "response_serializer_class"): + return self.response_serializer_class + return ScanThreatscoreReportSerializer return super().get_serializer_class() def partial_update(self, request, *args, **kwargs): @@ -1784,6 +1804,45 @@ class ScanViewSet(BaseRLSViewSet): content, filename = loader return self._serve_file(content, filename, "text/csv") + @action( + detail=True, + methods=["get"], + url_name="threatscore", + ) + def threatscore(self, request, pk=None): + scan = self.get_object() + running_resp = self._get_task_status(scan) + if running_resp: + return running_resp + + if not scan.output_location: + return Response( + { + "detail": "The scan has no reports, or the threatscore 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), + "threatscore", + "*_threatscore_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, "threatscore", "*_threatscore_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) @@ -3118,8 +3177,6 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): return ComplianceOverviewAttributesSerializer elif self.action == "requirements": return ComplianceOverviewDetailSerializer - elif self.action == "threatscore": - return ComplianceOverviewThreatscoreReportSerializer return super().get_serializer_class() @extend_schema(exclude=True) @@ -3427,75 +3484,6 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): serializer = self.get_serializer(attribute_data, many=True) return Response(serializer.data, status=status.HTTP_200_OK) - @action( - detail=True, - methods=["get"], - url_path="threatscore", - url_name="threatscore", - ) - def threatscore(self, request): - scan_id = request.query_params.get("filter[scan_id]") - if not scan_id: - raise ValidationError( - [ - { - "detail": "This query parameter is required.", - "status": 400, - "source": {"pointer": "filter[scan_id]"}, - "code": "required", - } - ] - ) - try: - if task := self.get_task_response_if_running( - task_name="scan-threatscore-report", - task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id}, - raise_on_not_found=False, - ): - return task - except TaskFailedException: - return Response( - {"detail": "Task failed to generate threatscore report."}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - try: - scan = Scan.objects.get(id=scan_id) - except Scan.DoesNotExist: - return Response( - {"detail": "Scan not found."}, - status=status.HTTP_404_NOT_FOUND, - ) - - if not scan.output_location: - return Response( - { - "detail": "The scan has no reports, or the 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), - "compliance", - f"{scan.provider.provider}_threatscore.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, "compliance", f"*_{scan.provider.provider}_threatscore.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") - @extend_schema(tags=["Overview"]) @extend_schema_view( diff --git a/util/compliance_report/assets/fonts/FiraCode-Regular.ttf b/api/src/backend/tasks/assets/fonts/FiraCode-Regular.ttf similarity index 100% rename from util/compliance_report/assets/fonts/FiraCode-Regular.ttf rename to api/src/backend/tasks/assets/fonts/FiraCode-Regular.ttf diff --git a/util/compliance_report/assets/fonts/PlusJakartaSans-Regular.ttf b/api/src/backend/tasks/assets/fonts/PlusJakartaSans-Regular.ttf similarity index 100% rename from util/compliance_report/assets/fonts/PlusJakartaSans-Regular.ttf rename to api/src/backend/tasks/assets/fonts/PlusJakartaSans-Regular.ttf diff --git a/util/compliance_report/assets/img/prowler_logo.png b/api/src/backend/tasks/assets/img/prowler_logo.png similarity index 100% rename from util/compliance_report/assets/img/prowler_logo.png rename to api/src/backend/tasks/assets/img/prowler_logo.png diff --git a/api/src/backend/tasks/jobs/export.py b/api/src/backend/tasks/jobs/export.py index f4b389d8f0..1d20298c12 100644 --- a/api/src/backend/tasks/jobs/export.py +++ b/api/src/backend/tasks/jobs/export.py @@ -240,6 +240,7 @@ def _generate_output_directory( >>> _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' """ # Sanitize the prowler provider name to ensure it is a valid directory name prowler_provider_sanitized = re.sub(r"[^\w\-]", "-", prowler_provider) @@ -256,4 +257,10 @@ def _generate_output_directory( ) os.makedirs("/".join(compliance_path.split("/")[:-1]), exist_ok=True) - return path, compliance_path + threatscore_path = ( + f"{output_directory}/{tenant_id}/{scan_id}/threatscore/prowler-output-" + f"{prowler_provider_sanitized}-{output_file_timestamp}" + ) + os.makedirs("/".join(threatscore_path.split("/")[:-1]), exist_ok=True) + + return path, compliance_path, threatscore_path diff --git a/api/src/backend/tasks/jobs/report.py b/api/src/backend/tasks/jobs/report.py index 1af4abb4b7..f0d8f4f9f9 100644 --- a/api/src/backend/tasks/jobs/report.py +++ b/api/src/backend/tasks/jobs/report.py @@ -1,9 +1,11 @@ import io import os +from pathlib import Path +from shutil import rmtree import matplotlib.pyplot as plt from celery.utils.log import get_task_logger -from config.django.base import DJANGO_TMP_OUTPUT_DIRECTORY +from config.django.base import DJANGO_FINDINGS_BATCH_SIZE, DJANGO_TMP_OUTPUT_DIRECTORY from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER from reportlab.lib.pagesizes import letter @@ -22,8 +24,9 @@ from reportlab.platypus import ( TableStyle, ) from tasks.jobs.export import _generate_output_directory, _upload_to_s3 +from tasks.utils import batched -from api.models import Finding, Provider, Task +from api.models import Finding, Provider, Scan, ScanSummary from api.utils import initialize_prowler_provider from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.finding import Finding as FindingOutput @@ -32,7 +35,7 @@ pdfmetrics.registerFont( TTFont( "PlusJakartaSans", os.path.join( - os.path.dirname(__file__), "assets/fonts/PlusJakartaSans-Regular.ttf" + os.path.dirname(__file__), "../assets/fonts/PlusJakartaSans-Regular.ttf" ), ) ) @@ -40,10 +43,12 @@ pdfmetrics.registerFont( pdfmetrics.registerFont( TTFont( "FiraCode", - os.path.join(os.path.dirname(__file__), "assets/fonts/FiraCode-Regular.ttf"), + os.path.join(os.path.dirname(__file__), "../assets/fonts/FiraCode-Regular.ttf"), ) ) +logger = get_task_logger(__name__) + def generate_threatscore_report( scan_id: str, @@ -64,828 +69,861 @@ def generate_threatscore_report( - only_failed: If True, only requirements with status "FAIL" will be included in the list of requirements. - min_risk_level: Minimum risk level for critical failed requirements. """ - 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, - spaceAfter=20, - fontName="PlusJakartaSans", - alignment=TA_CENTER, + logger.info( + f"Generating the report for the scan {scan_id} with provider {provider_id}" ) + try: + styles = getSampleStyleSheet() - h1 = ParagraphStyle( - "CustomH1", - parent=styles["Heading1"], - fontSize=18, - textColor=colors.Color(0.2, 0.4, 0.6), - 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), - ) + prowler_dark_green = colors.Color(0.1, 0.5, 0.2) - h2 = ParagraphStyle( - "CustomH2", - parent=styles["Heading2"], - fontSize=14, - textColor=colors.Color(0.3, 0.5, 0.7), - spaceBefore=15, - spaceAfter=8, - fontName="PlusJakartaSans", - leftIndent=10, - borderWidth=1, - borderColor=colors.Color(0.7, 0.8, 0.9), - borderPadding=5, - backColor=colors.Color(0.98, 0.99, 1.0), - ) - - h3 = ParagraphStyle( - "CustomH3", - parent=styles["Heading3"], - fontSize=12, - textColor=colors.Color(0.4, 0.6, 0.8), - spaceBefore=10, - spaceAfter=6, - fontName="PlusJakartaSans", - leftIndent=20, - ) - - normal = ParagraphStyle( - "CustomNormal", - parent=styles["Normal"], - fontSize=10, - textColor=colors.Color(0.2, 0.2, 0.2), - spaceBefore=4, - spaceAfter=4, - leftIndent=30, - fontName="PlusJakartaSans", - ) - - normal_center = ParagraphStyle( - "CustomNormalCenter", - parent=styles["Normal"], - fontSize=10, - textColor=colors.Color(0.2, 0.2, 0.2), - fontName="PlusJakartaSans", - ) - - 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_name = compliance_obj.name - compliance_version = getattr(compliance_obj, "Version", "N/A") - compliance_description = getattr(compliance_obj, "Description", "") - - findings_qs = Finding.all_objects.filter(scan_id=scan_id).order_by("uid") - findings = [ - FindingOutput.transform_api_finding(f, prowler_provider) for f in findings_qs - ] - - attrs_map = {} - resp_reqs = [] - resp_attrs = [] - for req_id, req in compliance_obj.requirements.items(): - attrs_map[req_id] = { - "id": req_id, - "attributes": { - "metadata": [req.metadata], - "check_ids": req.check_ids, - }, - "description": getattr(req, "description", ""), - } - resp_attrs.append( - { - "id": req_id, - "attributes": { - "framework_description": compliance_description, - "attributes": {"metadata": [req.metadata]}, - }, - } - ) - status = "UNKNOWN" - description = getattr(req, "description", "") - for f in findings: - if f["check_id"] in req.check_ids: - status = f["status"] - description = f.get("description", description) - break - resp_reqs.append( - { - "id": req_id, - "attributes": { - "framework": compliance_name, - "version": compliance_version, - "status": status, - "description": description, - }, - } + title_style = ParagraphStyle( + "CustomTitle", + parent=styles["Title"], + fontSize=24, + textColor=prowler_dark_green, + spaceAfter=20, + fontName="PlusJakartaSans", + alignment=TA_CENTER, ) - def create_risk_component(risk_level, weight, score=0): - """Create a visual risk component similar to the UI design""" - 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) + h1 = ParagraphStyle( + "CustomH1", + parent=styles["Heading1"], + fontSize=18, + textColor=colors.Color(0.2, 0.4, 0.6), + 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), + ) - 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) + h2 = ParagraphStyle( + "CustomH2", + parent=styles["Heading2"], + fontSize=14, + textColor=colors.Color(0.3, 0.5, 0.7), + spaceBefore=15, + spaceAfter=8, + fontName="PlusJakartaSans", + leftIndent=10, + borderWidth=1, + borderColor=colors.Color(0.7, 0.8, 0.9), + borderPadding=5, + backColor=colors.Color(0.98, 0.99, 1.0), + ) - score_color = colors.Color(0.4, 0.4, 0.4) + h3 = ParagraphStyle( + "CustomH3", + parent=styles["Heading3"], + fontSize=12, + textColor=colors.Color(0.4, 0.6, 0.8), + spaceBefore=10, + spaceAfter=6, + fontName="PlusJakartaSans", + leftIndent=20, + ) - data = [ - [ - "Risk Level:", - str(risk_level), - "Weight:", - str(weight), - "Score:", - str(score), + normal = ParagraphStyle( + "CustomNormal", + parent=styles["Normal"], + fontSize=10, + textColor=colors.Color(0.2, 0.2, 0.2), + spaceBefore=4, + spaceAfter=4, + leftIndent=30, + fontName="PlusJakartaSans", + ) + + normal_center = ParagraphStyle( + "CustomNormalCenter", + parent=styles["Normal"], + fontSize=10, + textColor=colors.Color(0.2, 0.2, 0.2), + fontName="PlusJakartaSans", + ) + + 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_name = getattr(compliance_obj, "Framework", "N/A") + compliance_version = getattr(compliance_obj, "Version", "N/A") + compliance_description = getattr(compliance_obj, "Description", "") + + logger.info(f"Getting findings for scan {scan_id}") + findings_qs = ( + Finding.all_objects.filter(scan_id=scan_id).order_by("uid").iterator() + ) + findings = [] + for batch, is_last in batched(findings_qs, DJANGO_FINDINGS_BATCH_SIZE): + fos = [ + FindingOutput.transform_api_finding(f, prowler_provider) for f in batch ] - ] + findings.extend(fos) - table = Table( - data, - colWidths=[ - 0.8 * inch, - 0.4 * inch, - 0.6 * inch, - 0.4 * inch, - 0.5 * inch, - 0.4 * inch, - ], + attrs_map = {} + resp_reqs = [] + for req in compliance_obj.Requirements: + req_id = req.Id + attrs_map[req_id] = { + "attributes": { + "req_attributes": getattr(req, "Attributes", []), + "checks": getattr(req, "Checks", []), + }, + "description": getattr(req, "Description", ""), + } + status = "UNKNOWN" + description = getattr(req, "Description", "") + for f in findings: + if f.check_id in getattr(req, "Checks", []): + status = getattr(f, "status", "UNKNOWN") + description = getattr(f, "description", description) + break + resp_reqs.append( + { + "id": req_id, + "attributes": { + "framework": compliance_name, + "version": compliance_version, + "status": status, + "description": description, + }, + } + ) + + def create_risk_component(risk_level, weight, score=0): + """Create a visual risk component similar to the UI design""" + 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) + + data = [ + [ + "Risk Level:", + str(risk_level), + "Weight:", + str(weight), + "Score:", + str(score), + ] + ] + + table = Table( + data, + colWidths=[ + 0.8 * inch, + 0.4 * inch, + 0.6 * inch, + 0.4 * inch, + 0.5 * inch, + 0.4 * inch, + ], + ) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.9, 0.9, 0.9)), + ("BACKGROUND", (1, 0), (1, 0), risk_color), + ("TEXTCOLOR", (1, 0), (1, 0), colors.white), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("BACKGROUND", (2, 0), (2, 0), colors.Color(0.9, 0.9, 0.9)), + ("BACKGROUND", (3, 0), (3, 0), weight_color), + ("TEXTCOLOR", (3, 0), (3, 0), colors.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), + ("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), + ] + ) + ) + + return table + + def create_status_component(status): + """Create a visual status component with colors""" + 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) + else: + status_color = colors.Color(0.4, 0.4, 0.4) + + data = [["State:", status.upper()]] + + elements.append(Spacer(1, 0.1 * inch)) + + 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)), + ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), + ("BACKGROUND", (1, 0), (1, 0), status_color), + ("TEXTCOLOR", (1, 0), (1, 0), colors.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), + ] + ) + ) + + return table + + def create_section_score_chart(resp_reqs, attrs_map): + """Create a bar chart showing compliance score by section""" + sections_data = {} + + for req in resp_reqs: + req_id = req["id"] + attr = attrs_map.get(req_id, {}) + status = req["attributes"]["status"] + + metadata = attr.get("attributes", {}).get("req_attributes", []) + if metadata: + m = metadata[0] + section = getattr(m, "Section", "Unknown") + risk_level = getattr(m, "LevelOfRisk", 0) + weight = getattr(m, "Weight", 0) + + if section not in sections_data: + sections_data[section] = { + "total_score": 0, + "max_possible_score": 0, + } + + max_score = risk_level * weight + sections_data[section]["max_possible_score"] += max_score + + if status == "PASS": + sections_data[section]["total_score"] += max_score + + section_names = [] + compliance_percentages = [] + + for section, data in sections_data.items(): + if data["max_possible_score"] > 0: + compliance_percentage = ( + data["total_score"] / data["max_possible_score"] + ) * 100 + else: + compliance_percentage = 0 + + section_names.append(section) + compliance_percentages.append(compliance_percentage) + + sorted_data = sorted( + zip(section_names, compliance_percentages), + key=lambda x: x[1], + reverse=True, + ) + section_names, compliance_percentages = ( + zip(*sorted_data) if sorted_data else ([], []) + ) + + 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) + + bars = ax.bar(section_names, compliance_percentages, color=colors_list) + + ax.set_ylabel("Compliance Score (%)", fontsize=12) + ax.set_xlabel("Section", fontsize=12) + ax.set_ylim(0, 100) + + for bar, percentage in zip(bars, compliance_percentages): + height = bar.get_height() + ax.text( + bar.get_x() + bar.get_width() / 2.0, + height + 1, + f"{percentage:.1f}%", + ha="center", + va="bottom", + fontweight="bold", + ) + + 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() + + return buffer + + def get_finding_info(check_id: str): + return [f for f in findings if f.check_id == check_id] + + doc = SimpleDocTemplate( + output_path, + pagesize=letter, + title=f"Prowler ThreatScore Report - {compliance_name}", + author="Prowler", + subject=f"Compliance Report for {compliance_name}", + creator="Prowler Compliance Generator", + keywords=f"compliance,{compliance_name},security,framework,prowler", ) - table.setStyle( + elements = [] + + img_path = os.path.join( + os.path.dirname(__file__), "../assets/img/prowler_logo.png" + ) + logo = Image( + img_path, + width=5 * inch, + height=0.8 * inch, + ) + elements.append(logo) + + elements.append(Spacer(1, 0.5 * inch)) + elements.append(Paragraph("Prowler ThreatScore Report", title_style)) + elements.append(Spacer(1, 0.5 * inch)) + + info_data = [ + ["Compliance Framework:", compliance_name], + ["Compliance ID:", compliance_id], + ["Version:", compliance_version], + ["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, 0), colors.Color(0.9, 0.9, 0.9)), - ("BACKGROUND", (1, 0), (1, 0), risk_color), - ("TEXTCOLOR", (1, 0), (1, 0), colors.white), - ("FONTNAME", (1, 0), (1, 0), "FiraCode"), - ("BACKGROUND", (2, 0), (2, 0), colors.Color(0.9, 0.9, 0.9)), - ("BACKGROUND", (3, 0), (3, 0), weight_color), - ("TEXTCOLOR", (3, 0), (3, 0), colors.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), - ("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), + ("BACKGROUND", (0, 0), (0, 4), colors.Color(0.2, 0.4, 0.6)), + ("TEXTCOLOR", (0, 0), (0, 4), colors.white), + ("FONTNAME", (0, 0), (0, 4), "FiraCode"), + ("BACKGROUND", (1, 0), (1, 4), colors.Color(0.95, 0.97, 1.0)), + ("TEXTCOLOR", (1, 0), (1, 4), colors.Color(0.2, 0.2, 0.2)), + ("FONTNAME", (1, 0), (1, 4), "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), ] ) ) - return table + elements.append(info_table) - def create_status_component(status): - """Create a visual status component with colors""" - 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) - else: - status_color = colors.Color(0.4, 0.4, 0.4) + elements.append(PageBreak()) - data = [["State:", status.upper()]] + elements.append(Paragraph("Compliance Score by Sections", h1)) + elements.append(Spacer(1, 0.2 * inch)) - elements.append(Spacer(1, 0.1 * inch)) + chart_buffer = create_section_score_chart(resp_reqs, attrs_map) + chart_image = Image(chart_buffer, width=7 * inch, height=5.5 * inch) + elements.append(chart_image) - 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)), - ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), - ("BACKGROUND", (1, 0), (1, 0), status_color), - ("TEXTCOLOR", (1, 0), (1, 0), colors.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), - ] - ) - ) - - return table - - def create_section_score_chart(resp_reqs, attrs_map): - """Create a bar chart showing compliance score by section""" - sections_data = {} + total_score = 0 + max_possible_score = 0 for req in resp_reqs: req_id = req["id"] attr = attrs_map.get(req_id, {}) status = req["attributes"]["status"] - metadata = attr.get("attributes", {}).get("metadata", []) - if metadata: + metadata = attr.get("attributes", {}).get("req_attributes", []) + if metadata and len(metadata) > 0: m = metadata[0] - section = m.get("Section", "Unknown") - risk_level = m.get("LevelOfRisk", 0) - weight = m.get("Weight", 0) - - if section not in sections_data: - sections_data[section] = {"total_score": 0, "max_possible_score": 0} - + risk_level = getattr(m, "LevelOfRisk", 0) + weight = getattr(m, "Weight", 0) max_score = risk_level * weight - sections_data[section]["max_possible_score"] += max_score + max_possible_score += max_score if status == "PASS": - sections_data[section]["total_score"] += max_score + total_score += max_score - section_names = [] - compliance_percentages = [] - - for section, data in sections_data.items(): - if data["max_possible_score"] > 0: - compliance_percentage = ( - data["total_score"] / data["max_possible_score"] - ) * 100 - else: - compliance_percentage = 0 - - section_names.append(section) - compliance_percentages.append(compliance_percentage) - - sorted_data = sorted( - zip(section_names, compliance_percentages), key=lambda x: x[1], reverse=True - ) - section_names, compliance_percentages = ( - zip(*sorted_data) if sorted_data else ([], []) + overall_compliance = ( + (total_score / max_possible_score * 100) if max_possible_score > 0 else 0 ) - fig, ax = plt.subplots(figsize=(12, 8)) + elements.append(Spacer(1, 0.3 * inch)) - 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) - - bars = ax.bar(section_names, compliance_percentages, color=colors_list) - - ax.set_ylabel("Compliance Score (%)", fontsize=12) - ax.set_xlabel("Section", fontsize=12) - ax.set_ylim(0, 100) - - for bar, percentage in zip(bars, compliance_percentages): - height = bar.get_height() - ax.text( - bar.get_x() + bar.get_width() / 2.0, - height + 1, - f"{percentage:.1f}%", - ha="center", - va="bottom", - fontweight="bold", - ) - - 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() - - return buffer - - def get_finding_info(check_id: str): - return [f for f in findings if f["check_id"] == check_id] - - doc = SimpleDocTemplate( - output_path, - pagesize=letter, - title=f"Prowler ThreatScore Report - {compliance_name}", - author="Prowler", - subject=f"Compliance Report for {compliance_name}", - creator="Prowler Compliance Generator", - keywords=f"compliance,{compliance_name},security,framework,prowler", - ) - elements = [] - - try: - logo = Image( - "util/compliance_report/assets/img/prowler_logo.png", - width=5 * inch, - height=0.8 * inch, - ) - elements.append(logo) - except Exception: - pass - elements.append(Spacer(1, 0.5 * inch)) - elements.append(Paragraph("Prowler ThreatScore Report", title_style)) - elements.append(Spacer(1, 0.5 * inch)) - - info_data = [ - ["Compliance Framework:", compliance_name], - ["Compliance ID:", compliance_id], - ["Version:", compliance_version], - ["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, 4), colors.Color(0.2, 0.4, 0.6)), - ("TEXTCOLOR", (0, 0), (0, 4), colors.white), - ("FONTNAME", (0, 0), (0, 4), "FiraCode"), - ("BACKGROUND", (1, 0), (1, 4), colors.Color(0.95, 0.97, 1.0)), - ("TEXTCOLOR", (1, 0), (1, 4), colors.Color(0.2, 0.2, 0.2)), - ("FONTNAME", (1, 0), (1, 4), "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(PageBreak()) - - elements.append(Paragraph("Compliance Score by Sections", h1)) - elements.append(Spacer(1, 0.2 * inch)) - - chart_buffer = create_section_score_chart(resp_reqs, attrs_map) - chart_image = Image(chart_buffer, width=7 * inch, height=5.5 * inch) - elements.append(chart_image) - - total_score = 0 - max_possible_score = 0 - - for req in resp_reqs: - req_id = req["id"] - attr = attrs_map.get(req_id, {}) - status = req["attributes"]["status"] - - metadata = attr.get("attributes", {}).get("metadata", []) - if metadata: - m = metadata[0] - risk_level = m.get("LevelOfRisk", 0) - weight = m.get("Weight", 0) - max_score = risk_level * weight - max_possible_score += max_score - - if status == "PASS": - total_score += max_score - - overall_compliance = ( - (total_score / max_possible_score * 100) if max_possible_score > 0 else 0 - ) - - elements.append(Spacer(1, 0.3 * inch)) - - summary_data = [ - ["Total Score:", f"{total_score:,}"], - ["Max Possible Score:", f"{max_possible_score:,}"], - ["Overall Compliance:", 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) - - summary_table = Table(summary_data, colWidths=[2.5 * inch, 2 * inch]) - summary_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 1), colors.Color(0.3, 0.5, 0.7)), - ("TEXTCOLOR", (0, 0), (0, 1), colors.white), - ("FONTNAME", (0, 0), (0, 1), "FiraCode"), - ("BACKGROUND", (0, 2), (0, 2), colors.Color(0.1, 0.3, 0.5)), - ("TEXTCOLOR", (0, 2), (0, 2), colors.white), - ("FONTNAME", (0, 2), (0, 2), "FiraCode"), - ("FONTSIZE", (0, 2), (0, 2), 12), - ("BACKGROUND", (1, 0), (1, 1), colors.Color(0.95, 0.97, 1.0)), - ("TEXTCOLOR", (1, 0), (1, 1), colors.Color(0.2, 0.2, 0.2)), - ("FONTNAME", (1, 0), (1, 1), "PlusJakartaSans"), - ("BACKGROUND", (1, 2), (1, 2), compliance_color), - ("TEXTCOLOR", (1, 2), (1, 2), colors.white), - ("FONTNAME", (1, 2), (1, 2), "FiraCode"), - ("FONTSIZE", (1, 2), (1, 2), 14), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTSIZE", (0, 0), (1, 1), 11), - ("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(PageBreak()) - - elements.append(Paragraph("Requirements Index", h1)) - - sections = {} - for req in resp_attrs: - meta = req["attributes"]["attributes"]["metadata"][0] - section = meta["Section"] - subsection = meta["SubSection"] - req_id = req["id"] - title = meta["Title"] - - if section not in sections: - sections[section] = {} - if subsection not in sections[section]: - sections[section][subsection] = [] - - sections[section][subsection].append({"id": req_id, "title": title}) - - section_num = 1 - for section_name, subsections in sections.items(): - elements.append(Paragraph(f"{section_num}. {section_name}", h2)) - - subsection_num = 1 - for subsection_name, requirements in subsections.items(): - elements.append(Paragraph(f"{subsection_name}", h3)) - - req_num = 1 - for req in requirements: - elements.append(Paragraph(f"{req['id']} - {req['title']}", normal)) - req_num += 1 - - subsection_num += 1 - - section_num += 1 - elements.append(Spacer(1, 0.1 * inch)) - - elements.append(PageBreak()) - - elements.append(Paragraph("Top Requirements by Level of Risk", h1)) - elements.append(Spacer(1, 0.1 * inch)) - elements.append( - Paragraph(f"Critical Failed Requirements (Risk Level ≥ {min_risk_level})", h2) - ) - elements.append(Spacer(1, 0.2 * inch)) - - critical_reqs = [] - for req in resp_reqs: - req_id = req["id"] - attr = attrs_map.get(req_id, {}) - status = req["attributes"]["status"] - - if status == "FAIL": - metadata = attr.get("attributes", {}).get("metadata", []) - if metadata: - m = metadata[0] - risk_level = m.get("LevelOfRisk", 0) - weight = m.get("Weight", 0) - - if risk_level >= min_risk_level: - critical_reqs.append( - { - "req": req, - "attr": attr, - "risk_level": risk_level, - "weight": weight, - "metadata": m, - } - ) - - critical_reqs.sort(key=lambda x: (x["risk_level"], x["weight"]), reverse=True) - - if not critical_reqs: - elements.append( - Paragraph("✅ No critical failed requirements found. Great job!", normal) - ) - else: - elements.append( - Paragraph( - f"Found {len(critical_reqs)} critical failed requirements that require immediate attention:", - normal, - ) - ) - elements.append(Spacer(1, 0.5 * inch)) - - table_data = [["Risk", "Weight", "Requirement ID", "Title", "Section"]] - - for idx, critical_req in enumerate(critical_reqs): - req_id = critical_req["req"]["id"] - risk_level = critical_req["risk_level"] - weight = critical_req["weight"] - title = critical_req["metadata"].get("Title", "N/A") - section = critical_req["metadata"].get("Section", "N/A") - - if len(title) > 50: - title = title[:47] + "..." - - table_data.append([str(risk_level), str(weight), req_id, title, section]) - - critical_table = Table( - table_data, - colWidths=[0.6 * inch, 0.8 * inch, 1.2 * inch, 3 * inch, 1.4 * 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), 10), - ("BACKGROUND", (0, 1), (0, -1), colors.Color(0.8, 0.2, 0.2)), - ("TEXTCOLOR", (0, 1), (0, -1), colors.white), - ("FONTNAME", (0, 1), (0, -1), "FiraCode"), - ("ALIGN", (0, 1), (0, -1), "CENTER"), - ("FONTSIZE", (0, 1), (0, -1), 12), - ("ALIGN", (1, 1), (1, -1), "CENTER"), - ("FONTNAME", (1, 1), (1, -1), "FiraCode"), - ("FONTNAME", (2, 1), (2, -1), "FiraCode"), - ("FONTSIZE", (2, 1), (2, -1), 9), - ("FONTNAME", (3, 1), (-1, -1), "PlusJakartaSans"), - ("FONTSIZE", (3, 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), 8), - ("BOTTOMPADDING", (0, 0), (-1, -1), 8), - ("BACKGROUND", (1, 1), (-1, -1), colors.Color(0.98, 0.98, 0.98)), - ] - ) - ) - - for idx, critical_req in enumerate(critical_reqs): - row_idx = idx + 1 - weight = critical_req["weight"] - - if weight >= 150: - weight_color = colors.Color(0.8, 0.2, 0.2) - elif weight >= 100: - weight_color = colors.Color(0.9, 0.6, 0.2) - else: - weight_color = colors.Color(0.9, 0.9, 0.2) - - critical_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (1, row_idx), (1, row_idx), weight_color), - ("TEXTCOLOR", (1, row_idx), (1, row_idx), colors.white), - ] - ) - ) - - elements.append(critical_table) - elements.append(Spacer(1, 0.2 * inch)) - - warning_text = """ - IMMEDIATE ACTION REQUIRED:
- These requirements have the highest risk levels and have failed compliance checks. - Please prioritize addressing these issues to improve your security posture. - """ - - warning_style = ParagraphStyle( - "Warning", - parent=styles["Normal"], - fontSize=11, - textColor=colors.Color(0.8, 0.2, 0.2), - spaceBefore=10, - spaceAfter=10, - leftIndent=20, - rightIndent=20, - fontName="PlusJakartaSans", - backColor=colors.Color(1.0, 0.95, 0.95), - borderWidth=2, - borderColor=colors.Color(0.8, 0.2, 0.2), - borderPadding=10, - ) - - elements.append(Paragraph(warning_text, warning_style)) - - elements.append(PageBreak()) - - def get_weight(req): - req_id = req["id"] - attr = attrs_map.get(req_id, {}) - metadata = attr.get("attributes", {}).get("metadata", []) - if metadata: - return metadata[0].get("Weight", 0) - return 0 - - sorted_reqs = sorted(resp_reqs, key=get_weight, reverse=True) - - if only_failed: - sorted_reqs = [ - req for req in sorted_reqs if req["attributes"]["status"] == "FAIL" + summary_data = [ + ["Total Score:", f"{total_score:,}"], + ["Max Possible Score:", f"{max_possible_score:,}"], + ["Overall Compliance:", f"{overall_compliance:.2f}%"], ] - for req in sorted_reqs: - req_id = req["id"] - attr = attrs_map.get(req_id, {}) - desc = req["attributes"]["description"] - status = req["attributes"]["status"] + 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) - elements.append(Paragraph(f"{req_id}: {attr.get('description', desc)}", h1)) - - status_component = create_status_component(status) - elements.append(status_component) - elements.append(Spacer(1, 0.1 * inch)) - - metadata = attr.get("attributes", {}).get("metadata", []) - if metadata: - m = metadata[0] - elements.append(Paragraph("Title: ", h3)) - elements.append(Paragraph(f"{m.get('Title')}", normal)) - elements.append(Paragraph("Section: ", h3)) - elements.append(Paragraph(f"{m.get('Section')}", normal)) - elements.append(Paragraph("SubSection: ", h3)) - elements.append(Paragraph(f"{m.get('SubSection')}", normal)) - elements.append(Paragraph("Description: ", h3)) - elements.append(Paragraph(f"{m.get('AttributeDescription')}", normal)) - elements.append(Paragraph("Additional Information: ", h3)) - elements.append(Paragraph(f"{m.get('AdditionalInformation')}", normal)) - elements.append(Spacer(1, 0.1 * inch)) - - risk_level = m.get("LevelOfRisk", 0) - weight = m.get("Weight", 0) - - if status == "PASS": - score = risk_level * weight - else: - score = 0 - - risk_component = create_risk_component(risk_level, weight, score) - elements.append(risk_component) - elements.append(Spacer(1, 0.1 * inch)) - - checks = attr.get("attributes", {}).get("check_ids", []) - for cid in checks: - elements.append(Paragraph(f"Check: {cid}", h2)) - elements.append(Spacer(1, 0.1 * inch)) - finds = get_finding_info(cid) - if not finds: - elements.append( - Paragraph("- No information for this finding currently", normal) - ) - else: - findings_table_data = [ - [ - "Finding", - "Resource name", - "Severity", - "Status", - "Region", - ] + summary_table = Table(summary_data, colWidths=[2.5 * inch, 2 * inch]) + summary_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 1), colors.Color(0.3, 0.5, 0.7)), + ("TEXTCOLOR", (0, 0), (0, 1), colors.white), + ("FONTNAME", (0, 0), (0, 1), "FiraCode"), + ("BACKGROUND", (0, 2), (0, 2), colors.Color(0.1, 0.3, 0.5)), + ("TEXTCOLOR", (0, 2), (0, 2), colors.white), + ("FONTNAME", (0, 2), (0, 2), "FiraCode"), + ("FONTSIZE", (0, 2), (0, 2), 12), + ("BACKGROUND", (1, 0), (1, 1), colors.Color(0.95, 0.97, 1.0)), + ("TEXTCOLOR", (1, 0), (1, 1), colors.Color(0.2, 0.2, 0.2)), + ("FONTNAME", (1, 0), (1, 1), "PlusJakartaSans"), + ("BACKGROUND", (1, 2), (1, 2), compliance_color), + ("TEXTCOLOR", (1, 2), (1, 2), colors.white), + ("FONTNAME", (1, 2), (1, 2), "FiraCode"), + ("FONTSIZE", (1, 2), (1, 2), 14), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (1, 1), 11), + ("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), ] - for f in finds: - attr = f["attributes"] - check_meta = attr.get("check_metadata", {}) - title = check_meta.get("checktitle", attr.get("check_id", "")) - resource_name = attr.get("resource_id") - if not resource_name: - rel_resources = ( - f.get("relationships", {}) - .get("resources", {}) - .get("data", []) - ) - if rel_resources and isinstance(rel_resources, list): - resource_name = rel_resources[0].get("id", "") - else: - resource_name = "" - severity = attr.get("severity", "").capitalize() - status = attr.get("status", "").upper() - region = attr.get("region", "global") + ) + ) - findings_table_data.append( - [ - Paragraph(title, normal_center), - Paragraph(resource_name, normal_center), - Paragraph(severity, normal_center), - Paragraph(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(summary_table) + + elements.append(PageBreak()) + + elements.append(Paragraph("Requirements Index", h1)) + + sections = {} + for req_id, req in attrs_map.items(): + meta = req["attributes"]["req_attributes"][0] + section = getattr(meta, "Section", "N/A") + subsection = getattr(meta, "SubSection", "N/A") + title = getattr(meta, "Title", "N/A") + + if section not in sections: + sections[section] = {} + if subsection not in sections[section]: + sections[section][subsection] = [] + + sections[section][subsection].append({"id": req_id, "title": title}) + + section_num = 1 + for section_name, subsections in sections.items(): + elements.append(Paragraph(f"{section_num}. {section_name}", h2)) + + subsection_num = 1 + for subsection_name, requirements in subsections.items(): + elements.append(Paragraph(f"{subsection_name}", h3)) + + req_num = 1 + for req in requirements: + elements.append(Paragraph(f"{req['id']} - {req['title']}", normal)) + req_num += 1 + + subsection_num += 1 + + section_num += 1 elements.append(Spacer(1, 0.1 * inch)) elements.append(PageBreak()) - def add_footer(canvas: canvas.Canvas, doc): - width, height = doc.pagesize - page_num_text = f"Page {doc.page}" - canvas.setFont("PlusJakartaSans", 9) - canvas.setFillColorRGB(0.4, 0.4, 0.4) - canvas.drawString(30, 20, page_num_text) - powered_text = "Powered by Prowler" - text_width = canvas.stringWidth(powered_text, "PlusJakartaSans", 9) - canvas.drawString(width - text_width - 30, 20, powered_text) + elements.append(Paragraph("Top Requirements by Level of Risk", h1)) + elements.append(Spacer(1, 0.1 * inch)) + elements.append( + Paragraph( + f"Critical Failed Requirements (Risk Level ≥ {min_risk_level})", h2 + ) + ) + elements.append(Spacer(1, 0.2 * inch)) - doc.build(elements, onFirstPage=add_footer, onLaterPages=add_footer) + critical_reqs = [] + for req in resp_reqs: + status = req["attributes"]["status"] + if status == "FAIL": + metadata = ( + attrs_map.get(req["id"], {}) + .get("attributes", {}) + .get("req_attributes", [{}])[0] + ) + if metadata: + risk_level = getattr(metadata, "LevelOfRisk", 0) + weight = getattr(metadata, "Weight", 0) + if risk_level >= min_risk_level: + critical_reqs.append( + { + "req": req, + "attr": attrs_map[req["id"]], + "risk_level": risk_level, + "weight": weight, + "metadata": metadata, + } + ) -logger = get_task_logger(__name__) + critical_reqs.sort(key=lambda x: (x["risk_level"], x["weight"]), reverse=True) + + if not critical_reqs: + elements.append( + Paragraph( + "✅ No critical failed requirements found. Great job!", normal + ) + ) + else: + elements.append( + Paragraph( + f"Found {len(critical_reqs)} critical failed requirements that require immediate attention:", + normal, + ) + ) + elements.append(Spacer(1, 0.5 * inch)) + + table_data = [["Risk", "Weight", "Requirement ID", "Title", "Section"]] + + for idx, critical_req in enumerate(critical_reqs): + req_id = critical_req["req"]["id"] + risk_level = critical_req["risk_level"] + weight = critical_req["weight"] + title = getattr(critical_req["metadata"], "Title", "N/A") + section = getattr(critical_req["metadata"], "Section", "N/A") + + if len(title) > 50: + title = title[:47] + "..." + + table_data.append( + [str(risk_level), str(weight), req_id, title, section] + ) + + critical_table = Table( + table_data, + colWidths=[0.6 * inch, 0.8 * inch, 1.2 * inch, 3 * inch, 1.4 * 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), 10), + ("BACKGROUND", (0, 1), (0, -1), colors.Color(0.8, 0.2, 0.2)), + ("TEXTCOLOR", (0, 1), (0, -1), colors.white), + ("FONTNAME", (0, 1), (0, -1), "FiraCode"), + ("ALIGN", (0, 1), (0, -1), "CENTER"), + ("FONTSIZE", (0, 1), (0, -1), 12), + ("ALIGN", (1, 1), (1, -1), "CENTER"), + ("FONTNAME", (1, 1), (1, -1), "FiraCode"), + ("FONTNAME", (2, 1), (2, -1), "FiraCode"), + ("FONTSIZE", (2, 1), (2, -1), 9), + ("FONTNAME", (3, 1), (-1, -1), "PlusJakartaSans"), + ("FONTSIZE", (3, 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), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ( + "BACKGROUND", + (1, 1), + (-1, -1), + colors.Color(0.98, 0.98, 0.98), + ), + ] + ) + ) + + for idx, critical_req in enumerate(critical_reqs): + row_idx = idx + 1 + weight = critical_req["weight"] + + if weight >= 150: + weight_color = colors.Color(0.8, 0.2, 0.2) + elif weight >= 100: + weight_color = colors.Color(0.9, 0.6, 0.2) + else: + weight_color = colors.Color(0.9, 0.9, 0.2) + + critical_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (1, row_idx), (1, row_idx), weight_color), + ("TEXTCOLOR", (1, row_idx), (1, row_idx), colors.white), + ] + ) + ) + + elements.append(critical_table) + elements.append(Spacer(1, 0.2 * inch)) + + warning_text = """ + IMMEDIATE ACTION REQUIRED:
+ These requirements have the highest risk levels and have failed compliance checks. + Please prioritize addressing these issues to improve your security posture. + """ + + warning_style = ParagraphStyle( + "Warning", + parent=styles["Normal"], + fontSize=11, + textColor=colors.Color(0.8, 0.2, 0.2), + spaceBefore=10, + spaceAfter=10, + leftIndent=20, + rightIndent=20, + fontName="PlusJakartaSans", + backColor=colors.Color(1.0, 0.95, 0.95), + borderWidth=2, + borderColor=colors.Color(0.8, 0.2, 0.2), + borderPadding=10, + ) + + elements.append(Paragraph(warning_text, warning_style)) + + elements.append(PageBreak()) + + def get_weight(req): + req_id = req["id"] + attr = attrs_map.get(req_id, {}) + metadata = attr.get("attributes", {}).get("metadata", []) + if metadata: + return metadata[0].get("Weight", 0) + return 0 + + sorted_reqs = sorted(resp_reqs, key=get_weight, reverse=True) + + if only_failed: + sorted_reqs = [ + req for req in sorted_reqs if req["attributes"]["status"] == "FAIL" + ] + + for req in sorted_reqs: + req_id = req["id"] + attr = attrs_map.get(req_id, {}) + desc = req["attributes"]["description"] + status = req["attributes"]["status"] + + elements.append(Paragraph(f"{req_id}: {attr.get('description', desc)}", h1)) + + status_component = create_status_component(status) + elements.append(status_component) + elements.append(Spacer(1, 0.1 * inch)) + + metadata = attr.get("attributes", {}).get("req_attributes", []) + if metadata and len(metadata) > 0: + m = metadata[0] + elements.append(Paragraph("Title: ", h3)) + elements.append(Paragraph(f"{getattr(m, 'Title', 'N/A')}", normal)) + elements.append(Paragraph("Section: ", h3)) + elements.append(Paragraph(f"{getattr(m, 'Section', 'N/A')}", normal)) + elements.append(Paragraph("SubSection: ", h3)) + elements.append(Paragraph(f"{getattr(m, 'SubSection', 'N/A')}", normal)) + elements.append(Paragraph("Description: ", h3)) + elements.append( + Paragraph(f"{getattr(m, 'AttributeDescription', 'N/A')}", normal) + ) + elements.append(Paragraph("Additional Information: ", h3)) + elements.append( + Paragraph(f"{getattr(m, 'AdditionalInformation', 'N/A')}", normal) + ) + elements.append(Spacer(1, 0.1 * inch)) + + risk_level = getattr(m, "LevelOfRisk", 0) + weight = getattr(m, "Weight", 0) + + if status == "PASS": + score = risk_level * weight + else: + score = 0 + + risk_component = create_risk_component(risk_level, weight, score) + elements.append(risk_component) + elements.append(Spacer(1, 0.1 * inch)) + + checks = attr.get("attributes", {}).get("checks", []) + for cid in checks: + elements.append(Paragraph(f"Check: {cid}", h2)) + elements.append(Spacer(1, 0.1 * inch)) + finds = get_finding_info(cid) + if not finds: + elements.append( + Paragraph("- No information for this finding currently", normal) + ) + else: + findings_table_data = [ + [ + "Finding", + "Resource name", + "Severity", + "Status", + "Region", + ] + ] + for f in finds: + check_meta = getattr(f, "metadata", {}) + title = getattr( + check_meta, "CheckTitle", getattr(attr, "CheckId", "") + ) + resource_name = getattr(f, "resource_name", "") + if not resource_name: + resource_name = getattr(f, "resource_uid", "") + severity = getattr(check_meta, "Severity", "").capitalize() + status = getattr(f, "status", "").upper() + region = getattr(f, "region", "global") + + findings_table_data.append( + [ + Paragraph(title, normal_center), + Paragraph(resource_name, normal_center), + Paragraph(severity, normal_center), + Paragraph(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()) + + def add_footer(canvas: canvas.Canvas, doc): + width, height = doc.pagesize + page_num_text = f"Page {doc.page}" + canvas.setFont("PlusJakartaSans", 9) + canvas.setFillColorRGB(0.4, 0.4, 0.4) + canvas.drawString(30, 20, page_num_text) + powered_text = "Powered by Prowler" + text_width = canvas.stringWidth(powered_text, "PlusJakartaSans", 9) + canvas.drawString(width - text_width - 30, 20, powered_text) + + doc.build(elements, onFirstPage=add_footer, onLaterPages=add_footer) + except Exception as e: + logger.info( + f"Error building the document, line {e.__traceback__.tb_lineno} -- {e}" + ) + raise e def generate_threatscore_report_job(tenant_id: str, scan_id: str, provider_id: str): + # Check if the scan has findings + if not ScanSummary.objects.filter(scan_id=scan_id).exists(): + logger.info(f"No findings found for scan {scan_id}") + return {"upload": False} + 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"]: + 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}" - - out_dir, _ = _generate_output_directory( - DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id + logger.info( + f"Generating threatscore report for scan {scan_id} with compliance {compliance_id} inside the job" ) - pdf_path = f"{out_dir}_threatscore_report.pdf" + try: + logger.info("Generating the output directory") + out_dir, _, threatscore_path = _generate_output_directory( + DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id + ) + except Exception as e: + logger.error(f"Error generating output directory: {e}") + return {"error": str(e)} + pdf_path = f"{threatscore_path}_threatscore_report.pdf" + logger.info(f"The path for the threatscore report is {pdf_path}") generate_threatscore_report( scan_id=scan_id, compliance_id=compliance_id, @@ -895,18 +933,17 @@ def generate_threatscore_report_job(tenant_id: str, scan_id: str, provider_id: s min_risk_level=4, ) - s3_uri = None - if os.path.exists(pdf_path): + upload_uri = _upload_to_s3(tenant_id, pdf_path, scan_id) + if upload_uri: try: - s3_uri = _upload_to_s3(tenant_id, pdf_path, scan_id) + rmtree(Path(pdf_path).parent, ignore_errors=True) except Exception as e: - logger.error(f"Error uploading PDF to S3: {e}") + logger.error(f"Error deleting output files: {e}") + final_location, did_upload = upload_uri, True + else: + final_location, did_upload = out_dir, False - try: - task = Task.objects.get(scan_id=scan_id) - result_path = s3_uri if s3_uri else pdf_path - task.result = result_path - task.save(update_fields=["result"]) - except Exception as e: - logger.error(f"Error saving PDF location to Task: {e}") - return {"pdf_location": s3_uri if s3_uri else pdf_path} + Scan.all_objects.filter(id=scan_id).update(output_location=final_location) + logger.info(f"Threatscore report outputs at {final_location}") + + return {"upload": did_upload} diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index ffc53f51bd..cb38c9c6e9 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -281,7 +281,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 ) diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index def57e11b1..a7738a2df1 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -420,8 +420,13 @@ 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") def test_scan_complete_tasks( - self, mock_outputs_task, mock_scan_summary_task, mock_compliance_tasks + self, + mock_threatscore_task, + mock_outputs_task, + mock_scan_summary_task, + mock_compliance_tasks, ): _perform_scan_complete_tasks("tenant-id", "scan-id", "provider-id") mock_compliance_tasks.assert_called_once_with( @@ -436,3 +441,8 @@ class TestScanCompleteTasks: provider_id="provider-id", tenant_id="tenant-id", ) + mock_threatscore_task.assert_called_once_with( + scan_id="scan-id", + provider_id="provider-id", + tenant_id="tenant-id", + ) diff --git a/api/src/backend/tasks/tests/test_threatscore.py b/api/src/backend/tasks/tests/test_threatscore.py new file mode 100644 index 0000000000..83ed171b2d --- /dev/null +++ b/api/src/backend/tasks/tests/test_threatscore.py @@ -0,0 +1,351 @@ +import uuid +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from tasks.jobs.report import ( + generate_threatscore_report, + generate_threatscore_report_job, +) +from tasks.tasks import generate_threatscore_report_task + + +@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.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") + @patch("tasks.jobs.report.Scan.all_objects.filter") + def test_generate_threatscore_report_happy_path( + self, + mock_scan_update, + mock_scan_summary_filter, + mock_provider_get, + mock_generate_output_directory, + mock_generate_report, + mock_upload, + mock_rmtree, + ): + 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( + 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_scan_update.return_value.update.assert_called_once_with( + output_location="s3://bucket/threatscore_report.pdf" + ) + mock_rmtree.assert_called_once_with( + Path("/tmp/threatscore_path_threatscore_report.pdf").parent, + ignore_errors=True, + ) + + def test_generate_threatscore_report_fails_upload(self): + with ( + patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter, + patch("tasks.jobs.report.Provider.objects.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), + patch("tasks.jobs.report.Scan.all_objects.filter") as mock_scan_update, + ): + mock_filter.return_value.exists.return_value = True + 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_scan_update.return_value.update.assert_called_once() + + def test_generate_threatscore_report_logs_rmtree_exception(self, caplog): + with ( + patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter, + patch("tasks.jobs.report.Provider.objects.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.Scan.all_objects.filter"), + patch( + "tasks.jobs.report.rmtree", side_effect=Exception("Test deletion error") + ), + ): + mock_filter.return_value.exists.return_value = True + 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 + + def test_generate_threatscore_report_azure_provider(self): + 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.Scan.all_objects.filter"), + 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( + 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, + ) + + +@pytest.mark.django_db +class TestGenerateThreatscoreReportFunction: + def setup_method(self): + self.scan_id = str(uuid.uuid4()) + self.provider_id = str(uuid.uuid4()) + self.compliance_id = "prowler_threatscore_aws" + self.output_path = "/tmp/test_threatscore_report.pdf" + + @patch("tasks.jobs.report.initialize_prowler_provider") + @patch("tasks.jobs.report.Provider.objects.get") + @patch("tasks.jobs.report.Compliance.get_bulk") + @patch("tasks.jobs.report.Finding.all_objects.filter") + @patch("tasks.jobs.report.batched") + @patch("tasks.jobs.report.FindingOutput.transform_api_finding") + @patch("tasks.jobs.report.SimpleDocTemplate") + @patch("tasks.jobs.report.Image") + @patch("tasks.jobs.report.Spacer") + @patch("tasks.jobs.report.Paragraph") + @patch("tasks.jobs.report.PageBreak") + @patch("tasks.jobs.report.Table") + @patch("tasks.jobs.report.TableStyle") + @patch("tasks.jobs.report.plt.subplots") + @patch("tasks.jobs.report.io.BytesIO") + def test_generate_threatscore_report_success( + self, + mock_bytesio, + mock_subplots, + mock_table_style, + mock_table, + mock_page_break, + mock_paragraph, + mock_spacer, + mock_image, + mock_doc_template, + mock_transform_finding, + mock_batched, + mock_finding_filter, + mock_compliance_get_bulk, + mock_provider_get, + mock_initialize_provider, + ): + mock_provider = MagicMock() + mock_provider.provider = "aws" + mock_provider_get.return_value = mock_provider + + prowler_provider = MagicMock() + mock_initialize_provider.return_value = prowler_provider + + mock_compliance_obj = MagicMock() + mock_compliance_obj.Framework = "ProwlerThreatScore" + mock_compliance_obj.Version = "1.0" + mock_compliance_obj.Description = "Test Description" + mock_compliance_obj.Requirements = [] + mock_compliance_get_bulk.return_value = { + self.compliance_id: mock_compliance_obj + } + + mock_finding = MagicMock() + mock_finding.uid = "finding-1" + mock_finding_filter.return_value.order_by.return_value.iterator.return_value = [ + mock_finding + ] + + mock_batched.return_value = [([mock_finding], True)] + + mock_transformed_finding = MagicMock() + mock_transformed_finding.check_id = "check-1" + mock_transformed_finding.status = "FAIL" + mock_transformed_finding.description = "Test finding" + mock_transformed_finding.metadata = MagicMock() + mock_transformed_finding.metadata.CheckTitle = "Test Check" + mock_transformed_finding.metadata.Severity = "HIGH" + mock_transformed_finding.resource_name = "test-resource" + mock_transformed_finding.region = "us-east-1" + mock_transform_finding.return_value = mock_transformed_finding + + mock_doc = MagicMock() + mock_doc_template.return_value = mock_doc + + mock_fig, mock_ax = MagicMock(), MagicMock() + mock_subplots.return_value = (mock_fig, mock_ax) + mock_buffer = MagicMock() + mock_bytesio.return_value = mock_buffer + + mock_image.return_value = MagicMock() + mock_spacer.return_value = MagicMock() + mock_paragraph.return_value = MagicMock() + mock_page_break.return_value = MagicMock() + mock_table.return_value = MagicMock() + mock_table_style.return_value = MagicMock() + + generate_threatscore_report( + scan_id=self.scan_id, + compliance_id=self.compliance_id, + output_path=self.output_path, + provider_id=self.provider_id, + only_failed=True, + min_risk_level=4, + ) + + mock_provider_get.assert_called_once_with(id=self.provider_id) + mock_initialize_provider.assert_called_once_with(mock_provider) + mock_compliance_get_bulk.assert_called_once_with("aws") + mock_finding_filter.assert_called_once_with(scan_id=self.scan_id) + mock_batched.assert_called_once() + mock_transform_finding.assert_called_once_with(mock_finding, prowler_provider) + mock_doc_template.assert_called_once() + mock_doc.build.assert_called_once() + + @patch("tasks.jobs.report.initialize_prowler_provider") + @patch("tasks.jobs.report.Provider.objects.get") + @patch("tasks.jobs.report.Compliance.get_bulk") + @patch("tasks.jobs.report.Finding.all_objects.filter") + def test_generate_threatscore_report_exception_handling( + self, + mock_finding_filter, + mock_compliance_get_bulk, + mock_provider_get, + mock_initialize_provider, + ): + mock_provider_get.side_effect = Exception("Provider not found") + + with pytest.raises(Exception, match="Provider not found"): + generate_threatscore_report( + scan_id=self.scan_id, + compliance_id=self.compliance_id, + output_path=self.output_path, + provider_id=self.provider_id, + only_failed=True, + min_risk_level=4, + ) + + +@pytest.mark.django_db +class TestGenerateThreatscoreReportTask: + 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} + + 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( + tenant_id=self.tenant_id, + scan_id=self.scan_id, + provider_id=self.provider_id, + ) diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 80505b3ca1..dc49d38af8 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -320,3 +320,49 @@ export const getComplianceCsv = async ( }; } }; + +export const getThreatscoreReport = async ( + scanId: string, +) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL( + `${apiBaseUrl}/scans/${scanId}/threatscore`, + ); + + try { + const response = await fetch(url.toString(), { headers }); + + if (response.status === 202) { + const json = await response.json(); + const taskId = json?.data?.id; + const state = json?.data?.attributes?.state; + return { + pending: true, + state, + taskId, + }; + } + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData?.errors?.detail || + "Unable to retrieve threatscore report. Contact support if the issue continues.", + ); + } + + const arrayBuffer = await response.arrayBuffer(); + const base64 = Buffer.from(arrayBuffer).toString("base64"); + + return { + success: true, + data: base64, + filename: `scan-${scanId}-threatscore.pdf`, + }; + } catch (error) { + return { + error: getErrorMessage(error), + }; + } +}; diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index f9e67ae89c..a5e0bad23c 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -134,6 +134,8 @@ export default async function ComplianceDetail({ showSearch={false} framework={compliancetitle} showProviders={false} + scanId={selectedScanId || undefined} + complianceId={complianceId} /> { const frameworkFilters = []; - + const providerType = complianceId?.split("_").at(-1); // Add CIS Profile Level filter if framework is CIS if (framework === "CIS") { frameworkFilters.push({ @@ -64,7 +69,19 @@ export const ComplianceHeader = ({ )} - {allFilters.length > 0 && } +
+ {allFilters.length > 0 && ( + + )} + {scanId && + framework === "ProwlerThreatScore" && + providerType && + ["aws", "azure", "gcp", "m365"].includes(providerType) && ( +
+ +
+ )} +
); diff --git a/ui/components/compliance/index.ts b/ui/components/compliance/index.ts index 096951a261..381d2321cf 100644 --- a/ui/components/compliance/index.ts +++ b/ui/components/compliance/index.ts @@ -19,3 +19,4 @@ export * from "./skeletons/compliance-accordion-skeleton"; export * from "./skeletons/compliance-grid-skeleton"; export * from "./skeletons/heatmap-chart-skeleton"; export * from "./skeletons/pie-chart-skeleton"; +export * from "./threatscore-download-button"; diff --git a/ui/components/compliance/threatscore-download-button.tsx b/ui/components/compliance/threatscore-download-button.tsx new file mode 100644 index 0000000000..79da164f66 --- /dev/null +++ b/ui/components/compliance/threatscore-download-button.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useState } from "react"; + +import { DownloadThreatscoreButton, useToast } from "@/components/ui"; +import { downloadThreatscoreReport } from "@/lib/helper"; + +interface ThreatscoreDownloadButtonProps { + scanId: string; + isDisabled?: boolean; +} + +export const ThreatscoreDownloadButton = ({ + scanId, + isDisabled = false, +}: ThreatscoreDownloadButtonProps) => { + const { toast } = useToast(); + const [isDownloading, setIsDownloading] = useState(false); + + const handleDownload = async () => { + setIsDownloading(true); + try { + await downloadThreatscoreReport(scanId, toast); + } finally { + setIsDownloading(false); + } + }; + + return ( + + ); +}; diff --git a/ui/components/ui/download-threatscore-button/download-threascore-button.tsx b/ui/components/ui/download-threatscore-button/download-threascore-button.tsx new file mode 100644 index 0000000000..7c753f7a5e --- /dev/null +++ b/ui/components/ui/download-threatscore-button/download-threascore-button.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Tooltip } from "@nextui-org/react"; +import { DownloadIcon } from "lucide-react"; + +import { CustomButton } from "../custom/custom-button"; + +interface DownloadThreatscoreButtonProps { + paramId: string; + onDownload: (paramId: string) => void; + ariaLabel?: string; + isDisabled?: boolean; + textTooltip?: string; + isDownloading?: boolean; +} + +export const DownloadThreatscoreButton = ({ + paramId, + onDownload, + ariaLabel = "Download ThreatScore report", + isDisabled, + textTooltip = "Download ThreatScore report", + isDownloading = false, +}: DownloadThreatscoreButtonProps) => { + return ( +
+ + onDownload(paramId)} + className="border-0 bg-gradient-to-r from-green-500 to-emerald-600 font-semibold text-white shadow-lg transition-all duration-200 hover:from-green-600 hover:to-emerald-700 hover:shadow-xl" + ariaLabel={ariaLabel} + size="md" + startContent={ + isDownloading ? ( +
+ ) : ( + + ) + } + > + {isDownloading ? "Downloading..." : "Download ThreatScore Report"} + + +
+ ); +}; diff --git a/ui/components/ui/index.ts b/ui/components/ui/index.ts index 205ec7fd50..71c8ad0989 100644 --- a/ui/components/ui/index.ts +++ b/ui/components/ui/index.ts @@ -7,6 +7,7 @@ export * from "./chart/Chart"; export * from "./content-layout/content-layout"; export * from "./dialog/dialog"; export * from "./download-icon-button/download-icon-button"; +export * from "./download-threatscore-button/download-threascore-button"; export * from "./dropdown/Dropdown"; export * from "./feedback-banner/feedback-banner"; export * from "./headers/navigation-header"; diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index 1ec700e4d3..6f51797bb0 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -1,4 +1,8 @@ -import { getComplianceCsv, getExportsZip } from "@/actions/scans"; +import { + getComplianceCsv, + getExportsZip, + getThreatscoreReport, +} from "@/actions/scans"; import { getTask } from "@/actions/task"; import { auth } from "@/auth.config"; import { useToast } from "@/components/ui"; @@ -201,6 +205,69 @@ export const downloadComplianceCsv = async ( }); }; +export const downloadThreatscoreReport = async ( + scanId: string, + toast: ReturnType["toast"], +): Promise => { + const result = await getThreatscoreReport(scanId); + + if (result?.pending) { + toast({ + title: "The report is still being generated", + description: "Please try again in a few minutes.", + }); + return; + } + + if (result?.success && result.data) { + try { + const binaryString = window.atob(result.data); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + const blob = new Blob([bytes], { type: "application/pdf" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = result.filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + toast({ + title: "Download Complete", + description: "The threatscore report has been downloaded successfully.", + }); + } catch (error) { + toast({ + variant: "destructive", + title: "Download Failed", + description: "An error occurred while processing the file.", + }); + } + return; + } + + if (result?.error) { + toast({ + variant: "destructive", + title: "Download Failed", + description: result.error, + }); + return; + } + + // Unexpected case + toast({ + variant: "destructive", + title: "Download Failed", + description: "Unexpected response. Please try again later.", + }); +}; + export const isGoogleOAuthEnabled = !!process.env.SOCIAL_GOOGLE_OAUTH_CLIENT_ID && !!process.env.SOCIAL_GOOGLE_OAUTH_CLIENT_SECRET; diff --git a/util/compliance_report/readme.md b/util/compliance_report/readme.md deleted file mode 100644 index 4c18204e59..0000000000 --- a/util/compliance_report/readme.md +++ /dev/null @@ -1,83 +0,0 @@ -# ThreatScore Compliance Report Generator - -This tool generates a compliance PDF report using Prowler's API endpoints, summarizing requirements, risk levels, and findings for a given scan and compliance framework. - -## Features -- Authenticate with the Prowler API using email+password or token -- Retrieve compliance requirements and attributes for a given scan and framework -- Generate a visually rich PDF report, including: - - Compliance summary and description - - Compliance score by section (with charts) - - Critical failed requirements (with risk and weight) - - Detailed requirements and findings - -## Requirements -- Python 3.7+ -- Dependencies: `matplotlib`, `requests`, `reportlab` - -Install dependencies with: -```bash -pip install matplotlib requests reportlab -``` - -## Usage - -```bash -python3 util/compliance_report/threatscore_report_generator.py \ - --scan-id \ - --compliance-id \ - --email \ - --password \ - [--token ] \ - [--output ] \ - [--base-url ] \ - [--only-failed] \ - [--min-risk-level ] -``` - -> **Note:** You must provide either both `--email` and `--password`, or a `--token`. One of these authentication methods is required. If you provide a token, email and password are ignored. - -### Arguments -- `--scan-id` (required): ID of the scan executed by Prowler. -- `--compliance-id` (required): Compliance framework ID (e.g., `prowler_threatscore_azure`, `nis2_azure`). -- `--email` (required*): Email for API authentication (*required if `--token` is not used). -- `--password` (required*): Password for API authentication (*required if `--token` is not used). -- `--token` (required*): JWT token for authentication (*required if `--email` and `--password` are not used). -- `--output` (optional): Output PDF file path (default: `threatscore_report.pdf`). -- `--base-url` (optional): Base URL for the API (default: `http://localhost:8080`). -- `--only-failed` (optional): Only include failed requirements in the report. -- `--min-risk-level` (optional): Minimum risk level to show critical failed requirements (default: 4). - -### Example -```bash -python3 util/compliance_report/threatscore_report_generator.py \ - --scan-id 12345678-1234-5678-1234-567812345678 \ - --compliance-id prowler_threatscore_azure \ - --email user@example.com \ - --password mypassword \ - --output my_report.pdf \ - --base-url http://localhost:8080 \ - --only-failed \ - --min-risk-level 4 -``` - -Or using a token: -```bash -python3 util/compliance_report/threatscore_report_generator.py \ - --scan-id 12345678-1234-5678-1234-567812345678 \ - --compliance-id prowler_threatscore_azure \ - --token eyJhbGciOi... \ - --output my_report.pdf -``` - -## Output -- The script will generate a PDF file with: - - Compliance framework summary - - Compliance score by section (with bar chart) - - Table of critical failed requirements (if any) - - Detailed breakdown of each requirement and its findings - -## Notes -- The script can authenticate with email/password or directly with a JWT token. **One of these authentication methods is mandatory.** -- If you encounter authentication errors, check your credentials, token, and API URL. -- For more details, see the source code: `util/compliance_report/threatscore_report_generator.py` diff --git a/util/compliance_report/threatscore_report_generator.py b/util/compliance_report/threatscore_report_generator.py deleted file mode 100644 index 826f0dbb02..0000000000 --- a/util/compliance_report/threatscore_report_generator.py +++ /dev/null @@ -1,916 +0,0 @@ -import io -import os - -import matplotlib.pyplot as plt -import requests -from reportlab.lib import colors -from reportlab.lib.enums import TA_CENTER -from reportlab.lib.pagesizes import letter -from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet -from reportlab.lib.units import inch -from reportlab.pdfbase import pdfmetrics -from reportlab.pdfbase.ttfonts import TTFont -from reportlab.pdfgen import canvas -from reportlab.platypus import ( - Image, - PageBreak, - Paragraph, - SimpleDocTemplate, - Spacer, - Table, - TableStyle, -) - -pdfmetrics.registerFont( - TTFont( - "PlusJakartaSans", - os.path.join( - os.path.dirname(__file__), "assets/fonts/PlusJakartaSans-Regular.ttf" - ), - ) -) - -pdfmetrics.registerFont( - TTFont( - "FiraCode", - os.path.join(os.path.dirname(__file__), "assets/fonts/FiraCode-Regular.ttf"), - ) -) - - -def generate_threatscore_report( - scan_id: str, - compliance_id: str, - output_path: str, - email: str, - password: str, - token: str, - base_url: str, - only_failed: bool = True, - min_risk_level: int = 4, -): - """ - Generate a PDF compliance report based on Prowler endpoints. - - Parameters: - - scan_id: ID of the scan executed by Prowler. - - compliance_id: ID of the compliance framework (e.g., "nis2_azure"). - - output_path: Output PDF file path (e.g., "threatscore_report.pdf"). - - email: Email for the API authentication. - - password: Password for the API. - - token: Token for the API. - - base_url: Base URL for the API. - - only_failed: If True, only requirements with status "FAIL" will be included in the list of requirements. - - min_risk_level: Minimum risk level for critical failed requirements. - """ - 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, - spaceAfter=20, - fontName="PlusJakartaSans", - alignment=TA_CENTER, - ) - - h1 = ParagraphStyle( - "CustomH1", - parent=styles["Heading1"], - fontSize=18, - textColor=colors.Color(0.2, 0.4, 0.6), - 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), - ) - - h2 = ParagraphStyle( - "CustomH2", - parent=styles["Heading2"], - fontSize=14, - textColor=colors.Color(0.3, 0.5, 0.7), - spaceBefore=15, - spaceAfter=8, - fontName="PlusJakartaSans", - leftIndent=10, - borderWidth=1, - borderColor=colors.Color(0.7, 0.8, 0.9), - borderPadding=5, - backColor=colors.Color(0.98, 0.99, 1.0), - ) - - h3 = ParagraphStyle( - "CustomH3", - parent=styles["Heading3"], - fontSize=12, - textColor=colors.Color(0.4, 0.6, 0.8), - spaceBefore=10, - spaceAfter=6, - fontName="PlusJakartaSans", - leftIndent=20, - ) - - normal = ParagraphStyle( - "CustomNormal", - parent=styles["Normal"], - fontSize=10, - textColor=colors.Color(0.2, 0.2, 0.2), - spaceBefore=4, - spaceAfter=4, - leftIndent=30, - fontName="PlusJakartaSans", - ) - - normal_center = ParagraphStyle( - "CustomNormalCenter", - parent=styles["Normal"], - fontSize=10, - textColor=colors.Color(0.2, 0.2, 0.2), - fontName="PlusJakartaSans", - ) - if not token: - if not email or not password: - raise Exception("Email and password are required to generate a token") - url_credentials = f"{base_url}/api/v1/tokens" - payload = { - "data": { - "type": "tokens", - "attributes": { - "email": email, - "password": password, - }, - } - } - resp_credentials = requests.post( - url_credentials, - json=payload, - headers={"Content-Type": "application/vnd.api+json"}, - ).json() - if resp_credentials.get("errors"): - print(resp_credentials.get("errors")) - raise Exception(resp_credentials.get("errors")) - token = resp_credentials.get("data", {}).get("attributes", {}).get("access") - - try: - url_reqs = f"{base_url}/api/v1/compliance-overviews/requirements?filter[compliance_id]={compliance_id}&filter[scan_id]={scan_id}" - resp_reqs = ( - requests.get(url_reqs, headers={"Authorization": f"Bearer {token}"}) - .json() - .get("data", []) - ) - - url_attrs = f"{base_url}/api/v1/compliance-overviews/attributes?filter[compliance_id]={compliance_id}" - resp_attrs = ( - requests.get(url_attrs, headers={"Authorization": f"Bearer {token}"}) - .json() - .get("data", []) - ) - except Exception as e: - print(e) - raise Exception(e) - - compliance_name = resp_reqs[0]["attributes"]["framework"] - compliance_version = resp_reqs[0]["attributes"]["version"] - compliance_description = resp_attrs[0]["attributes"]["framework_description"] - - attrs_map = {item["id"]: item["attributes"] for item in resp_attrs} - - def create_risk_component(risk_level, weight, score=0): - """Create a visual risk component similar to the UI design""" - 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) - - data = [ - [ - "Risk Level:", - str(risk_level), - "Weight:", - str(weight), - "Score:", - str(score), - ] - ] - - table = Table( - data, - colWidths=[ - 0.8 * inch, - 0.4 * inch, - 0.6 * inch, - 0.4 * inch, - 0.5 * inch, - 0.4 * inch, - ], - ) - - table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.9, 0.9, 0.9)), - ("BACKGROUND", (1, 0), (1, 0), risk_color), - ("TEXTCOLOR", (1, 0), (1, 0), colors.white), - ("FONTNAME", (1, 0), (1, 0), "FiraCode"), - ("BACKGROUND", (2, 0), (2, 0), colors.Color(0.9, 0.9, 0.9)), - ("BACKGROUND", (3, 0), (3, 0), weight_color), - ("TEXTCOLOR", (3, 0), (3, 0), colors.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), - ("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), - ] - ) - ) - - return table - - def create_status_component(status): - """Create a visual status component with colors""" - 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) - else: - status_color = colors.Color(0.4, 0.4, 0.4) - - data = [["State:", status.upper()]] - - elements.append(Spacer(1, 0.1 * inch)) - - 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)), - ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), - ("BACKGROUND", (1, 0), (1, 0), status_color), - ("TEXTCOLOR", (1, 0), (1, 0), colors.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), - ] - ) - ) - - return table - - def create_section_score_chart(resp_reqs, attrs_map): - """Create a bar chart showing compliance score by section""" - sections_data = {} - - for req in resp_reqs: - req_id = req["id"] - attr = attrs_map.get(req_id, {}) - status = req["attributes"]["status"] - - metadata = attr.get("attributes", {}).get("metadata", []) - if metadata: - m = metadata[0] - section = m.get("Section", "Unknown") - risk_level = m.get("LevelOfRisk", 0) - weight = m.get("Weight", 0) - - if section not in sections_data: - sections_data[section] = {"total_score": 0, "max_possible_score": 0} - - max_score = risk_level * weight - sections_data[section]["max_possible_score"] += max_score - - if status == "PASS": - sections_data[section]["total_score"] += max_score - - section_names = [] - compliance_percentages = [] - - for section, data in sections_data.items(): - if data["max_possible_score"] > 0: - compliance_percentage = ( - data["total_score"] / data["max_possible_score"] - ) * 100 - else: - compliance_percentage = 0 - - section_names.append(section) - compliance_percentages.append(compliance_percentage) - - sorted_data = sorted( - zip(section_names, compliance_percentages), key=lambda x: x[1], reverse=True - ) - section_names, compliance_percentages = ( - zip(*sorted_data) if sorted_data else ([], []) - ) - - 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) - - bars = ax.bar(section_names, compliance_percentages, color=colors_list) - - ax.set_ylabel("Compliance Score (%)", fontsize=12) - ax.set_xlabel("Section", fontsize=12) - ax.set_ylim(0, 100) - - for bar, percentage in zip(bars, compliance_percentages): - height = bar.get_height() - ax.text( - bar.get_x() + bar.get_width() / 2.0, - height + 1, - f"{percentage:.1f}%", - ha="center", - va="bottom", - fontweight="bold", - ) - - 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() - - return buffer - - def get_finding_info(check_id: str): - url_find = f"{base_url}/api/v1/findings?filter[check_id]={check_id}&filter[scan]={scan_id}" - value = ( - requests.get(url_find, headers={"Authorization": f"Bearer {token}"}) - .json() - .get("data", []) - ) - return value - - doc = SimpleDocTemplate( - output_path, - pagesize=letter, - title=f"Prowler ThreatScore Report - {compliance_name}", - author="Prowler", - subject=f"Compliance Report for {compliance_name}", - creator="Prowler Compliance Generator", - keywords=f"compliance,{compliance_name},security,framework,prowler", - ) - elements = [] - - try: - logo = Image( - "util/compliance_report/assets/img/prowler_logo.png", - width=5 * inch, - height=0.8 * inch, - ) - elements.append(logo) - except Exception: - pass - elements.append(Spacer(1, 0.5 * inch)) - elements.append(Paragraph("Prowler ThreatScore Report", title_style)) - elements.append(Spacer(1, 0.5 * inch)) - - info_data = [ - ["Compliance Framework:", compliance_name], - ["Compliance ID:", compliance_id], - ["Version:", compliance_version], - ["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, 4), colors.Color(0.2, 0.4, 0.6)), - ("TEXTCOLOR", (0, 0), (0, 4), colors.white), - ("FONTNAME", (0, 0), (0, 4), "FiraCode"), - ("BACKGROUND", (1, 0), (1, 4), colors.Color(0.95, 0.97, 1.0)), - ("TEXTCOLOR", (1, 0), (1, 4), colors.Color(0.2, 0.2, 0.2)), - ("FONTNAME", (1, 0), (1, 4), "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(PageBreak()) - - elements.append(Paragraph("Compliance Score by Sections", h1)) - elements.append(Spacer(1, 0.2 * inch)) - - chart_buffer = create_section_score_chart(resp_reqs, attrs_map) - chart_image = Image(chart_buffer, width=7 * inch, height=5.5 * inch) - elements.append(chart_image) - - total_score = 0 - max_possible_score = 0 - - for req in resp_reqs: - req_id = req["id"] - attr = attrs_map.get(req_id, {}) - status = req["attributes"]["status"] - - metadata = attr.get("attributes", {}).get("metadata", []) - if metadata: - m = metadata[0] - risk_level = m.get("LevelOfRisk", 0) - weight = m.get("Weight", 0) - max_score = risk_level * weight - max_possible_score += max_score - - if status == "PASS": - total_score += max_score - - overall_compliance = ( - (total_score / max_possible_score * 100) if max_possible_score > 0 else 0 - ) - - elements.append(Spacer(1, 0.3 * inch)) - - summary_data = [ - ["Total Score:", f"{total_score:,}"], - ["Max Possible Score:", f"{max_possible_score:,}"], - ["Overall Compliance:", 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) - - summary_table = Table(summary_data, colWidths=[2.5 * inch, 2 * inch]) - summary_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 1), colors.Color(0.3, 0.5, 0.7)), - ("TEXTCOLOR", (0, 0), (0, 1), colors.white), - ("FONTNAME", (0, 0), (0, 1), "FiraCode"), - ("BACKGROUND", (0, 2), (0, 2), colors.Color(0.1, 0.3, 0.5)), - ("TEXTCOLOR", (0, 2), (0, 2), colors.white), - ("FONTNAME", (0, 2), (0, 2), "FiraCode"), - ("FONTSIZE", (0, 2), (0, 2), 12), - ("BACKGROUND", (1, 0), (1, 1), colors.Color(0.95, 0.97, 1.0)), - ("TEXTCOLOR", (1, 0), (1, 1), colors.Color(0.2, 0.2, 0.2)), - ("FONTNAME", (1, 0), (1, 1), "PlusJakartaSans"), - ("BACKGROUND", (1, 2), (1, 2), compliance_color), - ("TEXTCOLOR", (1, 2), (1, 2), colors.white), - ("FONTNAME", (1, 2), (1, 2), "FiraCode"), - ("FONTSIZE", (1, 2), (1, 2), 14), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTSIZE", (0, 0), (1, 1), 11), - ("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(PageBreak()) - - elements.append(Paragraph("Requirements Index", h1)) - - sections = {} - for req in resp_attrs: - meta = req["attributes"]["attributes"]["metadata"][0] - section = meta["Section"] - subsection = meta["SubSection"] - req_id = req["id"] - title = meta["Title"] - - if section not in sections: - sections[section] = {} - if subsection not in sections[section]: - sections[section][subsection] = [] - - sections[section][subsection].append({"id": req_id, "title": title}) - - section_num = 1 - for section_name, subsections in sections.items(): - elements.append(Paragraph(f"{section_num}. {section_name}", h2)) - - subsection_num = 1 - for subsection_name, requirements in subsections.items(): - elements.append(Paragraph(f"{subsection_name}", h3)) - - req_num = 1 - for req in requirements: - elements.append(Paragraph(f"{req['id']} - {req['title']}", normal)) - req_num += 1 - - subsection_num += 1 - - section_num += 1 - elements.append(Spacer(1, 0.1 * inch)) - - elements.append(PageBreak()) - - elements.append(Paragraph("Top Requirements by Level of Risk", h1)) - elements.append(Spacer(1, 0.1 * inch)) - elements.append( - Paragraph(f"Critical Failed Requirements (Risk Level ≥ {min_risk_level})", h2) - ) - elements.append(Spacer(1, 0.2 * inch)) - - critical_reqs = [] - for req in resp_reqs: - req_id = req["id"] - attr = attrs_map.get(req_id, {}) - status = req["attributes"]["status"] - - if status == "FAIL": - metadata = attr.get("attributes", {}).get("metadata", []) - if metadata: - m = metadata[0] - risk_level = m.get("LevelOfRisk", 0) - weight = m.get("Weight", 0) - - if risk_level >= min_risk_level: - critical_reqs.append( - { - "req": req, - "attr": attr, - "risk_level": risk_level, - "weight": weight, - "metadata": m, - } - ) - - critical_reqs.sort(key=lambda x: (x["risk_level"], x["weight"]), reverse=True) - - if not critical_reqs: - elements.append( - Paragraph("✅ No critical failed requirements found. Great job!", normal) - ) - else: - elements.append( - Paragraph( - f"Found {len(critical_reqs)} critical failed requirements that require immediate attention:", - normal, - ) - ) - elements.append(Spacer(1, 0.5 * inch)) - - table_data = [["Risk", "Weight", "Requirement ID", "Title", "Section"]] - - for idx, critical_req in enumerate(critical_reqs): - req_id = critical_req["req"]["id"] - risk_level = critical_req["risk_level"] - weight = critical_req["weight"] - title = critical_req["metadata"].get("Title", "N/A") - section = critical_req["metadata"].get("Section", "N/A") - - if len(title) > 50: - title = title[:47] + "..." - - table_data.append([str(risk_level), str(weight), req_id, title, section]) - - critical_table = Table( - table_data, - colWidths=[0.6 * inch, 0.8 * inch, 1.2 * inch, 3 * inch, 1.4 * 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), 10), - ("BACKGROUND", (0, 1), (0, -1), colors.Color(0.8, 0.2, 0.2)), - ("TEXTCOLOR", (0, 1), (0, -1), colors.white), - ("FONTNAME", (0, 1), (0, -1), "FiraCode"), - ("ALIGN", (0, 1), (0, -1), "CENTER"), - ("FONTSIZE", (0, 1), (0, -1), 12), - ("ALIGN", (1, 1), (1, -1), "CENTER"), - ("FONTNAME", (1, 1), (1, -1), "FiraCode"), - ("FONTNAME", (2, 1), (2, -1), "FiraCode"), - ("FONTSIZE", (2, 1), (2, -1), 9), - ("FONTNAME", (3, 1), (-1, -1), "PlusJakartaSans"), - ("FONTSIZE", (3, 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), 8), - ("BOTTOMPADDING", (0, 0), (-1, -1), 8), - ("BACKGROUND", (1, 1), (-1, -1), colors.Color(0.98, 0.98, 0.98)), - ] - ) - ) - - for idx, critical_req in enumerate(critical_reqs): - row_idx = idx + 1 - weight = critical_req["weight"] - - if weight >= 150: - weight_color = colors.Color(0.8, 0.2, 0.2) - elif weight >= 100: - weight_color = colors.Color(0.9, 0.6, 0.2) - else: - weight_color = colors.Color(0.9, 0.9, 0.2) - - critical_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (1, row_idx), (1, row_idx), weight_color), - ("TEXTCOLOR", (1, row_idx), (1, row_idx), colors.white), - ] - ) - ) - - elements.append(critical_table) - elements.append(Spacer(1, 0.2 * inch)) - - warning_text = """ - IMMEDIATE ACTION REQUIRED:
- These requirements have the highest risk levels and have failed compliance checks. - Please prioritize addressing these issues to improve your security posture. - """ - - warning_style = ParagraphStyle( - "Warning", - parent=styles["Normal"], - fontSize=11, - textColor=colors.Color(0.8, 0.2, 0.2), - spaceBefore=10, - spaceAfter=10, - leftIndent=20, - rightIndent=20, - fontName="PlusJakartaSans", - backColor=colors.Color(1.0, 0.95, 0.95), - borderWidth=2, - borderColor=colors.Color(0.8, 0.2, 0.2), - borderPadding=10, - ) - - elements.append(Paragraph(warning_text, warning_style)) - - elements.append(PageBreak()) - - def get_weight(req): - req_id = req["id"] - attr = attrs_map.get(req_id, {}) - metadata = attr.get("attributes", {}).get("metadata", []) - if metadata: - return metadata[0].get("Weight", 0) - return 0 - - sorted_reqs = sorted(resp_reqs, key=get_weight, reverse=True) - - if only_failed: - sorted_reqs = [ - req for req in sorted_reqs if req["attributes"]["status"] == "FAIL" - ] - - for req in sorted_reqs: - req_id = req["id"] - attr = attrs_map.get(req_id, {}) - desc = req["attributes"]["description"] - status = req["attributes"]["status"] - - elements.append(Paragraph(f"{req_id}: {attr.get('description', desc)}", h1)) - - status_component = create_status_component(status) - elements.append(status_component) - elements.append(Spacer(1, 0.1 * inch)) - - metadata = attr.get("attributes", {}).get("metadata", []) - if metadata: - m = metadata[0] - elements.append(Paragraph("Title: ", h3)) - elements.append(Paragraph(f"{m.get('Title')}", normal)) - elements.append(Paragraph("Section: ", h3)) - elements.append(Paragraph(f"{m.get('Section')}", normal)) - elements.append(Paragraph("SubSection: ", h3)) - elements.append(Paragraph(f"{m.get('SubSection')}", normal)) - elements.append(Paragraph("Description: ", h3)) - elements.append(Paragraph(f"{m.get('AttributeDescription')}", normal)) - elements.append(Paragraph("Additional Information: ", h3)) - elements.append(Paragraph(f"{m.get('AdditionalInformation')}", normal)) - elements.append(Spacer(1, 0.1 * inch)) - - risk_level = m.get("LevelOfRisk", 0) - weight = m.get("Weight", 0) - - if status == "PASS": - score = risk_level * weight - else: - score = 0 - - risk_component = create_risk_component(risk_level, weight, score) - elements.append(risk_component) - elements.append(Spacer(1, 0.1 * inch)) - - checks = attr.get("attributes", {}).get("check_ids", []) - for cid in checks: - elements.append(Paragraph(f"Check: {cid}", h2)) - elements.append(Spacer(1, 0.1 * inch)) - finds = get_finding_info(cid) - if not finds: - elements.append( - Paragraph("- No information for this finding currently", normal) - ) - else: - findings_table_data = [ - [ - "Finding", - "Resource name", - "Severity", - "Status", - "Region", - ] - ] - for f in finds: - attr = f["attributes"] - check_meta = attr.get("check_metadata", {}) - title = check_meta.get("checktitle", attr.get("check_id", "")) - resource_name = attr.get("resource_id") - resource_name = "sensitive data" - if not resource_name: - rel_resources = ( - f.get("relationships", {}) - .get("resources", {}) - .get("data", []) - ) - if rel_resources and isinstance(rel_resources, list): - resource_name = rel_resources[0].get("id", "") - else: - resource_name = "" - severity = attr.get("severity", "").capitalize() - status = attr.get("status", "").upper() - region = attr.get("region", "global") - - findings_table_data.append( - [ - Paragraph(title, normal_center), - Paragraph(resource_name, normal_center), - Paragraph(severity, normal_center), - Paragraph(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()) - - def add_footer(canvas: canvas.Canvas, doc): - width, height = doc.pagesize - page_num_text = f"Page {doc.page}" - canvas.setFont("PlusJakartaSans", 9) - canvas.setFillColorRGB(0.4, 0.4, 0.4) - canvas.drawString(30, 20, page_num_text) - powered_text = "Powered by Prowler" - text_width = canvas.stringWidth(powered_text, "PlusJakartaSans", 9) - canvas.drawString(width - text_width - 30, 20, powered_text) - - doc.build(elements, onFirstPage=add_footer, onLaterPages=add_footer) - - -if __name__ == "__main__": - import argparse - - parser = argparse.ArgumentParser( - description="Generate a PDF compliance report with Prowler" - ) - parser.add_argument("--scan-id", required=True, help="Scan ID") - parser.add_argument("--compliance-id", required=True, help="Compliance ID") - parser.add_argument( - "--output", default="threatscore_report.pdf", help="Output PDF file path" - ) - parser.add_argument("--email", required=True, help="Email for the API") - parser.add_argument("--password", required=True, help="Password for the API") - parser.add_argument( - "--only-failed", - action="store_true", - help="Only include failed requirements in the list of requirements", - ) - parser.add_argument( - "--token", - default="", - help="Token for the API", - ) - parser.add_argument( - "--base-url", - default="http://localhost:8080", - help="Base URL for the API", - ) - parser.add_argument( - "--min-risk-level", - type=int, - default=4, - help="Minimum risk level for critical failed requirements", - ) - args = parser.parse_args() - - generate_threatscore_report( - args.scan_id, - args.compliance_id, - args.output, - args.email, - args.password, - args.token, - args.base_url, - args.only_failed, - args.min_risk_level, - )