From 9ce03b66b422ed50e2bc02d140cd20eedbe6bae8 Mon Sep 17 00:00:00 2001 From: pedrooot Date: Mon, 4 Nov 2024 18:38:50 +0100 Subject: [PATCH 01/18] feat(jira): add jira integration --- poetry.lock | 160 +++++++++++++++++- prowler/lib/outputs/jira/__init__.py | 0 .../lib/outputs/jira/exceptions/__init__.py | 0 .../lib/outputs/jira/exceptions/exceptions.py | 77 +++++++++ prowler/lib/outputs/jira/jira.py | 152 +++++++++++++++++ pyproject.toml | 1 + 6 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 prowler/lib/outputs/jira/__init__.py create mode 100644 prowler/lib/outputs/jira/exceptions/__init__.py create mode 100644 prowler/lib/outputs/jira/exceptions/exceptions.py create mode 100644 prowler/lib/outputs/jira/jira.py diff --git a/poetry.lock b/poetry.lock index 26fc7c43df..0887d45620 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "about-time" @@ -1413,6 +1413,17 @@ files = [ {file = "dash_table-5.0.0.tar.gz", hash = "sha256:18624d693d4c8ef2ddec99a6f167593437a7ea0bf153aa20f318c170c5bc7308"}, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] + [[package]] name = "deprecated" version = "1.2.14" @@ -2117,6 +2128,33 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "jira" +version = "3.8.0" +description = "Python library for interacting with JIRA via REST APIs." +optional = false +python-versions = ">=3.8" +files = [ + {file = "jira-3.8.0-py3-none-any.whl", hash = "sha256:12190dc84dad00b8a6c0341f7e8a254b0f38785afdec022bd5941e1184a5a3fb"}, + {file = "jira-3.8.0.tar.gz", hash = "sha256:63719c529a570aaa01c3373dbb5a104dab70381c5be447f6c27f997302fa335a"}, +] + +[package.dependencies] +defusedxml = "*" +packaging = "*" +Pillow = ">=2.1.0" +requests = ">=2.10.0" +requests-oauthlib = ">=1.1.0" +requests-toolbelt = "*" +typing-extensions = ">=3.7.4.2" + +[package.extras] +async = ["requests-futures (>=0.9.7)"] +cli = ["ipython (>=4.0.0)", "keyring"] +docs = ["furo", "sphinx (>=5.0.0)", "sphinx-copybutton"] +opt = ["PyJWT", "filemagic (>=1.6)", "requests-jwt", "requests-kerberos"] +test = ["MarkupSafe (>=0.23)", "PyYAML (>=5.1)", "docutils (>=0.12)", "flaky", "oauthlib", "parameterized (>=0.8.1)", "pytest (>=6.0.0)", "pytest-cache", "pytest-cov", "pytest-instafail", "pytest-sugar", "pytest-timeout (>=1.3.1)", "pytest-xdist (>=2.2)", "requests-mock", "requires.io", "tenacity", "wheel (>=0.24.0)", "yanc (>=0.3.3)"] + [[package]] name = "jmespath" version = "1.0.1" @@ -3632,6 +3670,98 @@ files = [ {file = "phonenumbers-8.13.47.tar.gz", hash = "sha256:53c5e7c6d431cafe4efdd44956078404ae9bc8b0eacc47be3105d3ccc88aaffa"}, ] +[[package]] +name = "pillow" +version = "11.0.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pillow-11.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6619654954dc4936fcff82db8eb6401d3159ec6be81e33c6000dfd76ae189947"}, + {file = "pillow-11.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b3c5ac4bed7519088103d9450a1107f76308ecf91d6dabc8a33a2fcfb18d0fba"}, + {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a65149d8ada1055029fcb665452b2814fe7d7082fcb0c5bed6db851cb69b2086"}, + {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88a58d8ac0cc0e7f3a014509f0455248a76629ca9b604eca7dc5927cc593c5e9"}, + {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c26845094b1af3c91852745ae78e3ea47abf3dbcd1cf962f16b9a5fbe3ee8488"}, + {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1a61b54f87ab5786b8479f81c4b11f4d61702830354520837f8cc791ebba0f5f"}, + {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:674629ff60030d144b7bca2b8330225a9b11c482ed408813924619c6f302fdbb"}, + {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:598b4e238f13276e0008299bd2482003f48158e2b11826862b1eb2ad7c768b97"}, + {file = "pillow-11.0.0-cp310-cp310-win32.whl", hash = "sha256:9a0f748eaa434a41fccf8e1ee7a3eed68af1b690e75328fd7a60af123c193b50"}, + {file = "pillow-11.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5629742881bcbc1f42e840af185fd4d83a5edeb96475a575f4da50d6ede337c"}, + {file = "pillow-11.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:ee217c198f2e41f184f3869f3e485557296d505b5195c513b2bfe0062dc537f1"}, + {file = "pillow-11.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1c1d72714f429a521d8d2d018badc42414c3077eb187a59579f28e4270b4b0fc"}, + {file = "pillow-11.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:499c3a1b0d6fc8213519e193796eb1a86a1be4b1877d678b30f83fd979811d1a"}, + {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8b2351c85d855293a299038e1f89db92a2f35e8d2f783489c6f0b2b5f3fe8a3"}, + {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4dba50cfa56f910241eb7f883c20f1e7b1d8f7d91c750cd0b318bad443f4d5"}, + {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5ddbfd761ee00c12ee1be86c9c0683ecf5bb14c9772ddbd782085779a63dd55b"}, + {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:45c566eb10b8967d71bf1ab8e4a525e5a93519e29ea071459ce517f6b903d7fa"}, + {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b4fd7bd29610a83a8c9b564d457cf5bd92b4e11e79a4ee4716a63c959699b306"}, + {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb929ca942d0ec4fac404cbf520ee6cac37bf35be479b970c4ffadf2b6a1cad9"}, + {file = "pillow-11.0.0-cp311-cp311-win32.whl", hash = "sha256:006bcdd307cc47ba43e924099a038cbf9591062e6c50e570819743f5607404f5"}, + {file = "pillow-11.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:52a2d8323a465f84faaba5236567d212c3668f2ab53e1c74c15583cf507a0291"}, + {file = "pillow-11.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:16095692a253047fe3ec028e951fa4221a1f3ed3d80c397e83541a3037ff67c9"}, + {file = "pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923"}, + {file = "pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903"}, + {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4"}, + {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f"}, + {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9"}, + {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7"}, + {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6"}, + {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc"}, + {file = "pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6"}, + {file = "pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47"}, + {file = "pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25"}, + {file = "pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699"}, + {file = "pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38"}, + {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2"}, + {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2"}, + {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527"}, + {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa"}, + {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f"}, + {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb"}, + {file = "pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798"}, + {file = "pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de"}, + {file = "pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84"}, + {file = "pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b"}, + {file = "pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003"}, + {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2"}, + {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a"}, + {file = "pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8"}, + {file = "pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8"}, + {file = "pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904"}, + {file = "pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3"}, + {file = "pillow-11.0.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2e46773dc9f35a1dd28bd6981332fd7f27bec001a918a72a79b4133cf5291dba"}, + {file = "pillow-11.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2679d2258b7f1192b378e2893a8a0a0ca472234d4c2c0e6bdd3380e8dfa21b6a"}, + {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda2616eb2313cbb3eebbe51f19362eb434b18e3bb599466a1ffa76a033fb916"}, + {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ec184af98a121fb2da42642dea8a29ec80fc3efbaefb86d8fdd2606619045d"}, + {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:8594f42df584e5b4bb9281799698403f7af489fba84c34d53d1c4bfb71b7c4e7"}, + {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:c12b5ae868897c7338519c03049a806af85b9b8c237b7d675b8c5e089e4a618e"}, + {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:70fbbdacd1d271b77b7721fe3cdd2d537bbbd75d29e6300c672ec6bb38d9672f"}, + {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5178952973e588b3f1360868847334e9e3bf49d19e169bbbdfaf8398002419ae"}, + {file = "pillow-11.0.0-cp39-cp39-win32.whl", hash = "sha256:8c676b587da5673d3c75bd67dd2a8cdfeb282ca38a30f37950511766b26858c4"}, + {file = "pillow-11.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:94f3e1780abb45062287b4614a5bc0874519c86a777d4a7ad34978e86428b8dd"}, + {file = "pillow-11.0.0-cp39-cp39-win_arm64.whl", hash = "sha256:290f2cc809f9da7d6d622550bbf4c1e57518212da51b6a30fe8e0a270a5b78bd"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1187739620f2b365de756ce086fdb3604573337cc28a0d3ac4a01ab6b2d2a6d2"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbbcb7b57dc9c794843e3d1258c0fbf0f48656d46ffe9e09b63bbd6e8cd5d0a2"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d203af30149ae339ad1b4f710d9844ed8796e97fda23ffbc4cc472968a47d0b"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a0d3b115009ebb8ac3d2ebec5c2982cc693da935f4ab7bb5c8ebe2f47d36f2"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:73853108f56df97baf2bb8b522f3578221e56f646ba345a372c78326710d3830"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e58876c91f97b0952eb766123bfef372792ab3f4e3e1f1a2267834c2ab131734"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:224aaa38177597bb179f3ec87eeefcce8e4f85e608025e9cfac60de237ba6316"}, + {file = "pillow-11.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5bd2d3bdb846d757055910f0a59792d33b555800813c3b39ada1829c372ccb06"}, + {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:375b8dd15a1f5d2feafff536d47e22f69625c1aa92f12b339ec0b2ca40263273"}, + {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:daffdf51ee5db69a82dd127eabecce20729e21f7a3680cf7cbb23f0829189790"}, + {file = "pillow-11.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7326a1787e3c7b0429659e0a944725e1b03eeaa10edd945a86dead1913383944"}, + {file = "pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.1)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] +typing = ["typing-extensions"] +xmp = ["defusedxml"] + [[package]] name = "platformdirs" version = "4.3.6" @@ -4474,6 +4604,20 @@ requests = ">=2.0.0" [package.extras] rsa = ["oauthlib[signedtoken] (>=3.0.0)"] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, + {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, +] + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + [[package]] name = "responses" version = "0.25.3" @@ -4692,24 +4836,24 @@ python-versions = ">=3.6" files = [ {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:d92f81886165cb14d7b067ef37e142256f1c6a90a65cd156b063a43da1708cfd"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412"}, + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win32.whl", hash = "sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win_amd64.whl", hash = "sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:b5edda50e5e9e15e54a6a8a0070302b00c518a9d32accc2346ad6c984aacd279"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win32.whl", hash = "sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win_amd64.whl", hash = "sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:7048c338b6c86627afb27faecf418768acb6331fc24cfa56c93e8c9780f815fa"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_24_aarch64.whl", hash = "sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win32.whl", hash = "sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa"}, @@ -4717,7 +4861,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_12_0_arm64.whl", hash = "sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:3fcc54cb0c8b811ff66082de1680b4b14cf8a81dce0d4fbf665c2265a81e07a1"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3"}, @@ -4725,7 +4869,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win_amd64.whl", hash = "sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:665f58bfd29b167039f714c6998178d27ccd83984084c286110ef26b230f259f"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d"}, @@ -4733,7 +4877,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win_amd64.whl", hash = "sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:9eb5dee2772b0f704ca2e45b1713e4e5198c18f515b52743576d196348f374d3"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880"}, @@ -5747,4 +5891,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.13" -content-hash = "3ec22e10b783a77283ec4ce26cf8761341f8684353e20628d509b2f86bd17ae0" +content-hash = "a3a3c55343607b3cde03200eaa362260ee50a5a7529b8904424a739b1ca9fd55" diff --git a/prowler/lib/outputs/jira/__init__.py b/prowler/lib/outputs/jira/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/lib/outputs/jira/exceptions/__init__.py b/prowler/lib/outputs/jira/exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/lib/outputs/jira/exceptions/exceptions.py b/prowler/lib/outputs/jira/exceptions/exceptions.py new file mode 100644 index 0000000000..0de0feb950 --- /dev/null +++ b/prowler/lib/outputs/jira/exceptions/exceptions.py @@ -0,0 +1,77 @@ +from prowler.exceptions.exceptions import ProwlerException + + +# Exceptions codes from 8000 to 8999 are reserved for Jira exceptions +class JiraBaseException(ProwlerException): + """Base class for Security Hub exceptions.""" + + JIRA_ERROR_CODES = { + (8000, "JiraNoProjectsError"): { + "message": "No projects were found in Jira.", + "remediation": "Please create a project in Jira.", + }, + (8001, "JiraAuthenticationError"): { + "message": "Failed to authenticate with Jira.", + "remediation": "Please check the connection settings and permissions and try again.", + }, + (8002, "JiraTestConnectionError"): { + "message": "Failed to connect to Jira.", + "remediation": "Please check the connection settings and permissions and try again.", + }, + (8003, "JiraCreateIssueError"): { + "message": "Failed to create an issue in Jira.", + "remediation": "Please check the connection settings and permissions and try again.", + }, + (8004, "JiraGetProjectsError"): { + "message": "Failed to get projects from Jira.", + "remediation": "Please check the connection settings and permissions and try again.", + }, + } + + def __init__(self, code, file=None, original_exception=None, message=None): + module = "Jira" + error_info = self.SECURITYHUB_ERROR_CODES.get((code, self.__class__.__name__)) + if message: + error_info["message"] = message + super().__init__( + code=code, + source=module, + file=file, + original_exception=original_exception, + error_info=error_info, + ) + + +class JiraNoProjectsError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8000, file=file, original_exception=original_exception, message=message + ) + + +class JiraAuthenticationError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8001, file=file, original_exception=original_exception, message=message + ) + + +class JiraTestConnectionError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8002, file=file, original_exception=original_exception, message=message + ) + + +class JiraCreateIssueError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8003, file=file, original_exception=original_exception, message=message + ) + + +class JiraGetProjectsError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8004, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py new file mode 100644 index 0000000000..6fd60419f2 --- /dev/null +++ b/prowler/lib/outputs/jira/jira.py @@ -0,0 +1,152 @@ +import logging +import os + +from jira import JIRA as JiraSDK + +from prowler.lib.logger import logger +from prowler.lib.outputs.finding import Finding +from prowler.lib.outputs.jira.exceptions.exceptions import ( + JiraAuthenticationError, + JiraGetProjectsError, + JiraNoProjectsError, + JiraTestConnectionError, +) +from prowler.providers.common.models import Connection + + +class Jira: + def __init__(self, server_url, username, api_token): + """ + Initialize a Jira object. + + Args: + server_url (str): The Jira server URL. + username (str): The Jira username. + api_token (str): The Jira API token. + """ + self.server_url = server_url + self.username = username + self.api_token = api_token + self.jira = self.authenticate() + + def authenticate(self) -> JiraSDK: + """ + Authenticate with Jira using the provided credentials. + + Returns: + - JiraSDK: an authenticated JiraSDK object + + Raises: + - JiraAuthenticationError: if the authentication fails + """ + + try: + jira = JiraSDK( + server=self.server_url, basic_auth=(self.username, self.api_token) + ) + logging.info("Successfully authenticated with Jira") + return jira + except Exception as error: + logger.critical( + f"JiraAuthenticationError[{error.__traceback__.tb_lineno}]: {error}" + ) + raise JiraAuthenticationError( + original_exception=error, file=os.path.basename(__file__) + ) + + @staticmethod + def test_connection( + server_url: str = None, + username: str = None, + api_token: str = None, + raise_on_exception: bool = True, + ) -> Connection: + """ + Test the connection to Jira using the provided credentials. + + Args: + server_url (str): The Jira server URL. + username (str): The Jira username. + api_token (str): The Jira API token. + raise_on_exception (bool): Whether to raise an exception if the connection test fails. + + Returns: + - Connection: A Connection object with the connection status. + + Raises: + - JiraTestConnectionError: if the connection test fails and raise_on_exception is True. + """ + try: + jira = JiraSDK(server=server_url, basic_auth=(username, api_token)) + user = jira.myself() + logging.info(f"Authenticated as {user['displayName']}") + return Connection( + is_connected=True, + ) + except Exception as error: + logger.error( + f"JiraConnectionError[{error.__traceback__.tb_lineno}]: {error}" + ) + if raise_on_exception: + raise JiraTestConnectionError( + file=os.path.basename(__file__), original_exception=error + ) from error + return Connection(error=error) + + def get_projects(self) -> list[dict]: + """ + Fetch all projects from Jira. + + Returns: + - list[dict]: A list of dictionaries containing the key and name of each project. + + Raises: + - JiraGetProjectsError: if the request to get projects fails. + """ + try: + projects = self.jira.projects() + project_objects = [ + {"key": project.key, "name": project.name} for project in projects + ] + + if not project_objects: + logging.error("No projects found in Jira") + raise JiraNoProjectsError( + message="No projects found in Jira", file=os.path.basename(__file__) + ) + return project_objects + except Exception as e: + logging.error(f"Failed to get projects: {e}") + raise JiraGetProjectsError( + original_exception=e, file=os.path.basename(__file__) + ) + + def send_findings( + self, + project_key: str = None, + findings: list[Finding] = None, + issue_type: str = "Bug", + ): + """ + Create Jira issues for the given findings. + + Args: + project_key (str): The Jira project key. + findings (list[Finding]): A list of Finding objects. + issue_type (str): The issue type to create (default: "Bug"). + + Raises: + - JiraCreateIssueError: if the request to create an issue fails. + """ + for finding in findings: + try: + issue_dict = { + "project": {"key": project_key}, + "summary": finding["summary"], + "description": finding["description"], + "issuetype": {"name": issue_type}, + } + new_issue = self.jira.create_issue(fields=issue_dict) + print(f"Successfully created issue: {new_issue.key}") + except Exception as e: + logging.error(f"Failed to create issue: {e}") diff --git a/pyproject.toml b/pyproject.toml index c0dcdb9bc9..abd0e1f01d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ dash-bootstrap-components = "1.6.0" detect-secrets = "1.5.0" google-api-python-client = "2.147.0" google-auth-httplib2 = ">=0.1,<0.3" +jira = "^3.8.0" jsonschema = "4.23.0" kubernetes = "31.0.0" microsoft-kiota-abstractions = "1.3.3" From 4a87f69fe20b1a3e72834a069ec4128d0ccc4847 Mon Sep 17 00:00:00 2001 From: pedrooot Date: Tue, 5 Nov 2024 11:48:16 +0100 Subject: [PATCH 02/18] feat(jira): use oauth for authentication --- .../lib/outputs/jira/exceptions/exceptions.py | 132 +++++ prowler/lib/outputs/jira/jira.py | 560 +++++++++++++++--- 2 files changed, 606 insertions(+), 86 deletions(-) diff --git a/prowler/lib/outputs/jira/exceptions/exceptions.py b/prowler/lib/outputs/jira/exceptions/exceptions.py index 0de0feb950..7e69d1bac5 100644 --- a/prowler/lib/outputs/jira/exceptions/exceptions.py +++ b/prowler/lib/outputs/jira/exceptions/exceptions.py @@ -26,6 +26,54 @@ class JiraBaseException(ProwlerException): "message": "Failed to get projects from Jira.", "remediation": "Please check the connection settings and permissions and try again.", }, + (8005, "JiraGetCloudIdError"): { + "message": "Failed to get the cloud ID from Jira.", + "remediation": "Please check the connection settings and permissions and try again.", + }, + (8006, "JiraGetCloudIdNoResourcesError"): { + "message": "No resources were found in Jira.", + "remediation": "Please check the connection settings and permissions and try again.", + }, + (8007, "JiraGetCloudIdResponseError"): { + "message": "Failed to get the cloud ID from Jira.", + "remediation": "Please check the connection settings and permissions and try again.", + }, + (8008, "JiraRefreshTokenResponseError"): { + "message": "Failed to refresh the access token, response code did not match 200.", + "remediation": "Please check the connection settings and permissions and try again.", + }, + (8009, "JiraRefreshTokenError"): { + "message": "Failed to refresh the access token.", + "remediation": "Please check the connection settings and permissions and try again.", + }, + (8010, "JiraGetAccessTokenError"): { + "message": "Failed to get the access token.", + "remediation": "Please check the connection settings and permissions and try again.", + }, + (8011, "JiraGetAuthResponseError"): { + "message": "Failed to authenticate with Jira.", + "remediation": "Please check the connection settings and permissions and try again.", + }, + (8012, "JiraGetProjectsResponseError"): { + "message": "Failed to get projects from Jira, response code did not match 200.", + "remediation": "Please check the connection settings and permissions and try again.", + }, + (8013, "JiraSendFindingsResponseError"): { + "message": "Failed to send findings to Jira, response code did not match 201.", + "remediation": "Please check the finding format and try again.", + }, + (8014, "JiraGetAvailableIssueTypesError"): { + "message": "Failed to get available issue types from Jira.", + "remediation": "Please check the connection settings and permissions and try again.", + }, + (8015, "JiraGetAvailableIssueTypesResponseError"): { + "message": "Failed to get available issue types from Jira, response code did not match 200.", + "remediation": "Please check the connection settings and permissions and try again.", + }, + (8016, "JiraInvalidIssueTypeError"): { + "message": "The issue type is invalid.", + "remediation": "Please check the issue type and try again.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -75,3 +123,87 @@ def __init__(self, file=None, original_exception=None, message=None): super().__init__( 8004, file=file, original_exception=original_exception, message=message ) + + +class JiraGetCloudIdError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8005, file=file, original_exception=original_exception, message=message + ) + + +class JiraGetCloudIdNoResourcesError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8006, file=file, original_exception=original_exception, message=message + ) + + +class JiraGetCloudIdResponseError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8007, file=file, original_exception=original_exception, message=message + ) + + +class JiraRefreshTokenResponseError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8008, file=file, original_exception=original_exception, message=message + ) + + +class JiraRefreshTokenError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8009, file=file, original_exception=original_exception, message=message + ) + + +class JiraGetAccessTokenError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8010, file=file, original_exception=original_exception, message=message + ) + + +class JiraGetAuthResponseError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8011, file=file, original_exception=original_exception, message=message + ) + + +class JiraGetProjectsResponseError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8012, file=file, original_exception=original_exception, message=message + ) + + +class JiraSendFindingsResponseError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8013, file=file, original_exception=original_exception, message=message + ) + + +class JiraGetAvailableIssueTypesError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8014, file=file, original_exception=original_exception, message=message + ) + + +class JiraGetAvailableIssueTypesResponseError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8015, file=file, original_exception=original_exception, message=message + ) + + +class JiraInvalidIssueTypeError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 8016, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 6fd60419f2..538ac3bf29 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -1,152 +1,540 @@ -import logging +import base64 import os -from jira import JIRA as JiraSDK +import requests +import requests.compat from prowler.lib.logger import logger from prowler.lib.outputs.finding import Finding +from prowler.lib.outputs.jira.exceptions import JiraNoProjectsError from prowler.lib.outputs.jira.exceptions.exceptions import ( JiraAuthenticationError, + JiraCreateIssueError, + JiraGetAccessTokenError, + JiraGetAuthResponseError, + JiraGetAvailableIssueTypesError, + JiraGetAvailableIssueTypesResponseError, + JiraGetCloudIdError, + JiraGetCloudIdNoResourcesError, + JiraGetCloudIdResponseError, JiraGetProjectsError, - JiraNoProjectsError, + JiraGetProjectsResponseError, + JiraInvalidIssueTypeError, + JiraRefreshTokenError, + JiraRefreshTokenResponseError, + JiraSendFindingsResponseError, JiraTestConnectionError, ) from prowler.providers.common.models import Connection class Jira: - def __init__(self, server_url, username, api_token): - """ - Initialize a Jira object. + _redirect_uri: str + _client_id: str + _client_secret: str + _state_param: str + _access_token: str + _refresh_token: str + _auth_expiration: int + _cloud_id: str + _scopes: list[str] + + def __init__(self, redirect_uri, client_id, client_secret, state_param): + self._redirect_uri = redirect_uri + self._client_id = client_id + self._client_secret = client_secret + self._state_param = state_param + self._access_token = None + self._refresh_token = None + self._auth_expiration = None + self._cloud_id = None + self._scopes = ["read:jira-user", "read:jira-work", "write:jira-work"] + auth_url = self.auth_code_url(state_param) + print(f"Authorize the application by visiting this URL: {auth_url}") + authorization_code = input("Enter the authorization code from Jira: ") + self.get_auth(authorization_code) + + @property + def redirect_uri(self): + return self._redirect_uri + + @property + def client_id(self): + return self._client_id + + @property + def client_secret(self): + return self._client_secret + + @property + def state_param(self): + return self._state_param + + @property + def access_token(self): + return self._access_token + + @property + def refresh_token(self): + return self._refresh_token + + @property + def auth_expiration(self): + return self._auth_expiration + + @property + def cloud_id(self): + return self.cloud_id + + @property + def scopes(self): + return self._scopes + + def auth_code_url(self) -> str: + """Generate the URL to authorize the application""" + # Generate the state parameter + random_bytes = os.urandom(24) + state_encoded = base64.urlsafe_b64encode(random_bytes).decode("utf-8") + + # Generate the URL + params = { + "audience": "api.atlassian.com", + "client_id": self.client_id, + "scope": " ".join(self.scopes), + "redirect_uri": self.redirect_uri, + "state": state_encoded, + "response_type": "code", + "prompt": "consent", + } + + return ( + f"https://auth.atlassian.com/authorize?{requests.compat.urlencode(params)}" + ) + + def get_auth(self, auth_code) -> None: + """Get the access token and refresh token Args: - server_url (str): The Jira server URL. - username (str): The Jira username. - api_token (str): The Jira API token. - """ - self.server_url = server_url - self.username = username - self.api_token = api_token - self.jira = self.authenticate() + - auth_code: The authorization code from Jira + + Returns: + - None - def authenticate(self) -> JiraSDK: + Raises: + - JiraGetAuthResponseError: Failed to get the access token and refresh token + - JiraGetCloudIdNoResourcesError: No resources were found in Jira when getting the cloud id + - JiraGetCloudIdResponseError: Failed to get the cloud ID, response code did not match 200 + - JiraGetCloudIdError: Failed to get the cloud ID from Jira + - JiraAuthenticationError: Failed to authenticate + - JiraRefreshTokenError: Failed to refresh the access token + - JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match 200 + - JiraGetAccessTokenError: Failed to get the access token """ - Authenticate with Jira using the provided credentials. + try: + url = "https://auth.atlassian.com/oauth/token" + body = { + "grant_type": "authorization_code", + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": auth_code, + "redirect_uri": self.redirect_uri, + } + + headers = {"Content-Type": "application/json"} + response = requests.post(url, json=body, headers=headers) + + if response.status_code == 200: + tokens = response.json() + self._access_token = tokens.get("access_token") + self._refresh_token = tokens.get("refresh_token") + self._auth_expiration = tokens.get("expires_in") + self._cloud_id = self.get_cloud_id(self.access_token) + else: + response_error = ( + f"Failed to get auth: {response.status_code} - {response.json()}" + ) + raise JiraGetAuthResponseError( + message=response_error, file=os.path.basename(__file__) + ) + except JiraGetCloudIdNoResourcesError as no_resources_error: + raise no_resources_error + except JiraGetCloudIdResponseError as response_error: + raise response_error + except JiraGetCloudIdError as cloud_id_error: + raise cloud_id_error + except Exception as e: + logger.error(f"Failed to get auth: {e}") + raise JiraAuthenticationError( + message="Failed to authenticate with Jira", + file=os.path.basename(__file__), + ) + + def get_cloud_id(self, access_token) -> str: + """Get the cloud ID from Jira + + Args: + - access_token: The access token from Jira Returns: - - JiraSDK: an authenticated JiraSDK object + - str: The cloud ID Raises: - - JiraAuthenticationError: if the authentication fails + - JiraGetCloudIdNoResourcesError: No resources were found in Jira when getting the cloud id + - JiraGetCloudIdResponseError: Failed to get the cloud ID, response code did not match 200 + - JiraGetCloudIdError: Failed to get the cloud ID from Jira """ - try: - jira = JiraSDK( - server=self.server_url, basic_auth=(self.username, self.api_token) + url = "https://api.atlassian.com/oauth/token/accessible-resources" + headers = {"Authorization": f"Bearer {access_token}"} + response = requests.get(url, headers=headers) + + if response.status_code == 200: + resources = response.json() + if resources: + return resources[0].get("id") + else: + logger.error("No resources found") + raise JiraGetCloudIdNoResourcesError( + message="No resources were found in Jira when getting the cloud id", + file=os.path.basename(__file__), + ) + else: + response_error = f"Failed to get cloud id: {response.status_code} - {response.json()}" + logger.warning(response_error) + raise JiraGetCloudIdResponseError( + message=response_error, file=os.path.basename(__file__) + ) + except Exception as e: + logger.error(f"Failed to get cloud id: {e}") + raise JiraGetCloudIdError( + message="Failed to get the cloud ID from Jira", + file=os.path.basename(__file__), ) - logging.info("Successfully authenticated with Jira") - return jira - except Exception as error: - logger.critical( - f"JiraAuthenticationError[{error.__traceback__.tb_lineno}]: {error}" + + def get_access_token(self) -> str: + """Get the access token + + Returns: + - str: The access token + + Raises: + - JiraRefreshTokenError: Failed to refresh the access token + - JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match 200 + - JiraGetAccessTokenError: Failed to get the access token + """ + try: + now = requests.datetime.datetime.utcnow() + + if self.auth_expiration and self.auth_expiration > now.timestamp(): + return self.access_token + else: + return self.refresh_access_token() + except JiraRefreshTokenError as refresh_error: + raise refresh_error + except JiraRefreshTokenResponseError as response_error: + raise response_error + except Exception as e: + logger.error(f"Failed to get access token: {e}") + raise JiraGetAccessTokenError( + message="Failed to get the access token", + file=os.path.basename(__file__), ) - raise JiraAuthenticationError( - original_exception=error, file=os.path.basename(__file__) + + def refresh_access_token(self) -> str: + """Refresh the access token + + Returns: + - str: The access token + + Raises: + - JiraRefreshTokenError: Failed to refresh the access token + - JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match 200 + """ + try: + url = "https://auth.atlassian.com/oauth/token" + body = { + "grant_type": "refresh_token", + "client_id": self.client_id, + "client_secret": self.client_secret, + "refresh_token": self.refresh_token, + } + + headers = {"Content-Type": "application/json"} + response = requests.post(url, json=body, headers=headers) + + if response.status_code == 200: + tokens = response.json() + self._access_token = tokens.get("access_token") + self._refresh_token = tokens.get("refresh_token") + self._auth_expiration = tokens.get("expires_in") + return self.access_token + else: + response_error = f"Failed to refresh access token: {response.status_code} - {response.json()}" + logger.warning(response_error) + raise JiraRefreshTokenResponseError( + message=response_error, file=os.path.basename(__file__) + ) + + except Exception as e: + logger.error(f"Failed to refresh access token: {e}") + raise JiraRefreshTokenError( + message="Failed to refresh the access token", + file=os.path.basename(__file__), ) @staticmethod def test_connection( - server_url: str = None, - username: str = None, - api_token: str = None, - raise_on_exception: bool = True, + redirect_uri, client_id, client_secret, state_param, raise_on_exception ) -> Connection: - """ - Test the connection to Jira using the provided credentials. + """Test the connection to Jira Args: - server_url (str): The Jira server URL. - username (str): The Jira username. - api_token (str): The Jira API token. - raise_on_exception (bool): Whether to raise an exception if the connection test fails. + - redirect_uri: The redirect URI + - client_id: The client ID + - client_secret: The client secret + - state_param: The state parameter + - raise_on_exception: Whether to raise an exception or not Returns: - - Connection: A Connection object with the connection status. + - Connection: The connection object Raises: - - JiraTestConnectionError: if the connection test fails and raise_on_exception is True. + - JiraGetCloudIdNoResourcesError: No resources were found in Jira when getting the cloud id + - JiraGetCloudIdResponseError: Failed to get the cloud ID, response code did not match 200 + - JiraGetCloudIdError: Failed to get the cloud ID from Jira + - JiraAuthenticationError: Failed to authenticate + - JiraTestConnectionError: Failed to test the connection """ try: - jira = JiraSDK(server=server_url, basic_auth=(username, api_token)) - user = jira.myself() - logging.info(f"Authenticated as {user['displayName']}") - return Connection( - is_connected=True, + jira = Jira(redirect_uri, client_id, client_secret, state_param) + access_token = jira.get_access_token() + + if not access_token: + return ValueError("Failed to get access token") + + headers = {"Authorization": f"Bearer {access_token}"} + response = requests.get( + f"https://api.atlassian.com/ex/jira/{jira.cloud_id}/rest/api/3/myself", + headers=headers, ) - except Exception as error: + + if response.status_code == 200: + return Connection(is_connected=True) + else: + return Connection(is_connected=False, error=response.json()) + except JiraGetCloudIdNoResourcesError as no_resources_error: + logger.error( + f"{no_resources_error.__class__.__name__}[{no_resources_error.__traceback__.tb_lineno}]: {no_resources_error}" + ) + if raise_on_exception: + raise no_resources_error + return Connection(error=no_resources_error) + except JiraGetCloudIdResponseError as response_error: + logger.error( + f"{response_error.__class__.__name__}[{response_error.__traceback__.tb_lineno}]: {response_error}" + ) + if raise_on_exception: + raise response_error + return Connection(error=response_error) + except JiraGetCloudIdError as cloud_id_error: logger.error( - f"JiraConnectionError[{error.__traceback__.tb_lineno}]: {error}" + f"{cloud_id_error.__class__.__name__}[{cloud_id_error.__traceback__.tb_lineno}]: {cloud_id_error}" ) + if raise_on_exception: + raise cloud_id_error + return Connection(error=cloud_id_error) + except JiraAuthenticationError as auth_error: + logger.error( + f"{auth_error.__class__.__name__}[{auth_error.__traceback__.tb_lineno}]: {auth_error}" + ) + if raise_on_exception: + raise auth_error + return Connection(error=auth_error) + except Exception as error: + logger.error(f"Failed to test connection: {error}") if raise_on_exception: raise JiraTestConnectionError( - file=os.path.basename(__file__), original_exception=error - ) from error - return Connection(error=error) + message="Failed to test connection on the Jira integration", + file=os.path.basename(__file__), + ) + return Connection(is_connected=False, error=error) def get_projects(self) -> list[dict]: - """ - Fetch all projects from Jira. + """Get the projects from Jira Returns: - - list[dict]: A list of dictionaries containing the key and name of each project. + - list[dict]: The projects following the format {"key": str, "name": str} Raises: - - JiraGetProjectsError: if the request to get projects fails. + - JiraNoProjectsError: No projects found in Jira + - JiraGetProjectsError: Failed to get projects from Jira + - JiraRefreshTokenError: Failed to refresh the access token + - JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match + - JiraGetProjectsResponseError: Failed to get projects from Jira, response code did not match 200 """ try: - projects = self.jira.projects() - project_objects = [ - {"key": project.key, "name": project.name} for project in projects - ] - - if not project_objects: - logging.error("No projects found in Jira") - raise JiraNoProjectsError( - message="No projects found in Jira", file=os.path.basename(__file__) + access_token = self.get_access_token() + + if not access_token: + return ValueError("Failed to get access token") + + headers = {"Authorization": f"Bearer {access_token}"} + response = requests.get( + f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/project", + headers=headers, + ) + + if response.status_code == 200: + # Return the Project Key and Name + projects = [ + {"key": project.get("key"), "name": project.get("name")} + for project in response.json() + ] + if len(projects) == 0: + logger.error("No projects found") + raise JiraNoProjectsError( + message="No projects found in Jira", + file=os.path.basename(__file__), + ) + return projects + else: + logger.error( + f"Failed to get projects: {response.status_code} - {response.json()}" + ) + raise JiraGetProjectsResponseError( + message="Failed to get projects from Jira", + file=os.path.basename(__file__), ) - return project_objects + except JiraNoProjectsError as no_projects_error: + raise no_projects_error + except JiraRefreshTokenError as refresh_error: + raise refresh_error + except JiraRefreshTokenResponseError as response_error: + raise response_error except Exception as e: - logging.error(f"Failed to get projects: {e}") + logger.error(f"Failed to get projects: {e}") raise JiraGetProjectsError( - original_exception=e, file=os.path.basename(__file__) + message="Failed to get projects from Jira", + file=os.path.basename(__file__), + ) + + def get_available_issue_types(self, project_key: str) -> list[str]: + """Get the available issue types for a project + + Args: + - project_key: The project key + + Returns: + - list[str]: The available issue types + + Raises: + - JiraRefreshTokenError: Failed to refresh the access token + - JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match 200 + - JiraGetAccessTokenError: Failed to get the access token + - JiraGetAuthResponseError: Failed to authenticate with Jira + - JiraGetProjectsError: Failed to get projects from Jira + - JiraGetProjectsResponseError: Failed to get projects from Jira, response code did not match 200 + """ + + try: + access_token = self.get_access_token() + + if not access_token: + return ValueError("Failed to get access token") + + headers = {"Authorization": f"Bearer {access_token}"} + response = requests.get( + f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue/createmeta?projectKeys={project_key}&expand=projects.issuetypes.fields", + headers=headers, + ) + + if response.status_code == 200: + issue_types = response.json()["projects"][0]["issuetypes"] + return [issue_type["name"] for issue_type in issue_types] + else: + # Must be replaced with proper error handling from custom exceptions + response_error = f"Failed to get available issue types: {response.status_code} - {response.json()}" + logger.warning(response_error) + raise JiraGetAvailableIssueTypesResponseError( + message=response_error, file=os.path.basename(__file__) + ) + except JiraRefreshTokenError as refresh_error: + raise refresh_error + except JiraRefreshTokenResponseError as response_error: + raise response_error + except Exception as e: + logger.error(f"Failed to get available issue types: {e}") + raise JiraGetAvailableIssueTypesError( + message="Failed to get available issue types", + file=os.path.basename(__file__), ) def send_findings( - self, - project_key: str = None, - findings: list[Finding] = None, - issue_type: str = "Bug", + self, findings: list[Finding], project_key: str, issue_type: str = "Bug" ): """ - Create Jira issues for the given findings. + Send the findings to Jira Args: - project_key (str): The Jira project key. - findings (list[Finding]): A list of Finding objects. - issue_type (str): The issue type to create (default: "Bug"). + - findings: The findings to send + - project_key: The project key + - issue_type: The issue type Raises: - - JiraCreateIssueError: if the request to create an issue fails. + - JiraRefreshTokenError: Failed to refresh the access token + - JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match 200 + - JiraCreateIssueError: Failed to create an issue in Jira + - JiraSendFindingsResponseError: Failed to send the findings to Jira """ - for finding in findings: - try: - issue_dict = { - "project": {"key": project_key}, - "summary": finding["summary"], - "description": finding["description"], - "issuetype": {"name": issue_type}, + try: + access_token = self.get_access_token() + + if not access_token: + return ValueError("Failed to get access token") + + available_issue_types = self.get_available_issue_types(project_key) + + if issue_type not in available_issue_types: + logger.error("The issue type is invalid") + raise JiraInvalidIssueTypeError( + message="The issue type is invalid", file=os.path.basename(__file__) + ) + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + for finding in findings: + payload = { + "fields": { + "project": {"key": project_key}, + "summary": finding.metadata.CheckTitle, + "description": finding.metadata.Description, + "issuetype": {"name": issue_type}, + } } - new_issue = self.jira.create_issue(fields=issue_dict) - print(f"Successfully created issue: {new_issue.key}") - except Exception as e: - logging.error(f"Failed to create issue: {e}") + + response = requests.post( + f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue", + json=payload, + headers=headers, + ) + + if response.status_code != 201: + response_error = f"Failed to send finding: {response.status_code} - {response.json()}" + logger.warning(response_error) + raise JiraSendFindingsResponseError( + message=response_error, file=os.path.basename(__file__) + ) + else: + logger.info(f"Finding sent successfully: {response.json()}") + except JiraRefreshTokenError as refresh_error: + raise refresh_error + except JiraRefreshTokenResponseError as response_error: + raise response_error + except Exception as e: + logger.error(f"Failed to send findings: {e}") + raise JiraCreateIssueError( + message="Failed to create an issue in Jira", + file=os.path.basename(__file__), + ) From b34fde57603e911c993e98bea4fafd4692411a93 Mon Sep 17 00:00:00 2001 From: pedrooot Date: Tue, 5 Nov 2024 11:52:25 +0100 Subject: [PATCH 03/18] feat(jira): remove jira sdk --- poetry.lock | 146 +------------------------------------------------ pyproject.toml | 1 - 2 files changed, 1 insertion(+), 146 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0887d45620..370d3c0e22 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1413,17 +1413,6 @@ files = [ {file = "dash_table-5.0.0.tar.gz", hash = "sha256:18624d693d4c8ef2ddec99a6f167593437a7ea0bf153aa20f318c170c5bc7308"}, ] -[[package]] -name = "defusedxml" -version = "0.7.1" -description = "XML bomb protection for Python stdlib modules" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, - {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, -] - [[package]] name = "deprecated" version = "1.2.14" @@ -2128,33 +2117,6 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] -[[package]] -name = "jira" -version = "3.8.0" -description = "Python library for interacting with JIRA via REST APIs." -optional = false -python-versions = ">=3.8" -files = [ - {file = "jira-3.8.0-py3-none-any.whl", hash = "sha256:12190dc84dad00b8a6c0341f7e8a254b0f38785afdec022bd5941e1184a5a3fb"}, - {file = "jira-3.8.0.tar.gz", hash = "sha256:63719c529a570aaa01c3373dbb5a104dab70381c5be447f6c27f997302fa335a"}, -] - -[package.dependencies] -defusedxml = "*" -packaging = "*" -Pillow = ">=2.1.0" -requests = ">=2.10.0" -requests-oauthlib = ">=1.1.0" -requests-toolbelt = "*" -typing-extensions = ">=3.7.4.2" - -[package.extras] -async = ["requests-futures (>=0.9.7)"] -cli = ["ipython (>=4.0.0)", "keyring"] -docs = ["furo", "sphinx (>=5.0.0)", "sphinx-copybutton"] -opt = ["PyJWT", "filemagic (>=1.6)", "requests-jwt", "requests-kerberos"] -test = ["MarkupSafe (>=0.23)", "PyYAML (>=5.1)", "docutils (>=0.12)", "flaky", "oauthlib", "parameterized (>=0.8.1)", "pytest (>=6.0.0)", "pytest-cache", "pytest-cov", "pytest-instafail", "pytest-sugar", "pytest-timeout (>=1.3.1)", "pytest-xdist (>=2.2)", "requests-mock", "requires.io", "tenacity", "wheel (>=0.24.0)", "yanc (>=0.3.3)"] - [[package]] name = "jmespath" version = "1.0.1" @@ -3670,98 +3632,6 @@ files = [ {file = "phonenumbers-8.13.47.tar.gz", hash = "sha256:53c5e7c6d431cafe4efdd44956078404ae9bc8b0eacc47be3105d3ccc88aaffa"}, ] -[[package]] -name = "pillow" -version = "11.0.0" -description = "Python Imaging Library (Fork)" -optional = false -python-versions = ">=3.9" -files = [ - {file = "pillow-11.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6619654954dc4936fcff82db8eb6401d3159ec6be81e33c6000dfd76ae189947"}, - {file = "pillow-11.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b3c5ac4bed7519088103d9450a1107f76308ecf91d6dabc8a33a2fcfb18d0fba"}, - {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a65149d8ada1055029fcb665452b2814fe7d7082fcb0c5bed6db851cb69b2086"}, - {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88a58d8ac0cc0e7f3a014509f0455248a76629ca9b604eca7dc5927cc593c5e9"}, - {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c26845094b1af3c91852745ae78e3ea47abf3dbcd1cf962f16b9a5fbe3ee8488"}, - {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1a61b54f87ab5786b8479f81c4b11f4d61702830354520837f8cc791ebba0f5f"}, - {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:674629ff60030d144b7bca2b8330225a9b11c482ed408813924619c6f302fdbb"}, - {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:598b4e238f13276e0008299bd2482003f48158e2b11826862b1eb2ad7c768b97"}, - {file = "pillow-11.0.0-cp310-cp310-win32.whl", hash = "sha256:9a0f748eaa434a41fccf8e1ee7a3eed68af1b690e75328fd7a60af123c193b50"}, - {file = "pillow-11.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5629742881bcbc1f42e840af185fd4d83a5edeb96475a575f4da50d6ede337c"}, - {file = "pillow-11.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:ee217c198f2e41f184f3869f3e485557296d505b5195c513b2bfe0062dc537f1"}, - {file = "pillow-11.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1c1d72714f429a521d8d2d018badc42414c3077eb187a59579f28e4270b4b0fc"}, - {file = "pillow-11.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:499c3a1b0d6fc8213519e193796eb1a86a1be4b1877d678b30f83fd979811d1a"}, - {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8b2351c85d855293a299038e1f89db92a2f35e8d2f783489c6f0b2b5f3fe8a3"}, - {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4dba50cfa56f910241eb7f883c20f1e7b1d8f7d91c750cd0b318bad443f4d5"}, - {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5ddbfd761ee00c12ee1be86c9c0683ecf5bb14c9772ddbd782085779a63dd55b"}, - {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:45c566eb10b8967d71bf1ab8e4a525e5a93519e29ea071459ce517f6b903d7fa"}, - {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b4fd7bd29610a83a8c9b564d457cf5bd92b4e11e79a4ee4716a63c959699b306"}, - {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb929ca942d0ec4fac404cbf520ee6cac37bf35be479b970c4ffadf2b6a1cad9"}, - {file = "pillow-11.0.0-cp311-cp311-win32.whl", hash = "sha256:006bcdd307cc47ba43e924099a038cbf9591062e6c50e570819743f5607404f5"}, - {file = "pillow-11.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:52a2d8323a465f84faaba5236567d212c3668f2ab53e1c74c15583cf507a0291"}, - {file = "pillow-11.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:16095692a253047fe3ec028e951fa4221a1f3ed3d80c397e83541a3037ff67c9"}, - {file = "pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923"}, - {file = "pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903"}, - {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4"}, - {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f"}, - {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9"}, - {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7"}, - {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6"}, - {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc"}, - {file = "pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6"}, - {file = "pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47"}, - {file = "pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25"}, - {file = "pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699"}, - {file = "pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38"}, - {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2"}, - {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2"}, - {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527"}, - {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa"}, - {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f"}, - {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb"}, - {file = "pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798"}, - {file = "pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de"}, - {file = "pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84"}, - {file = "pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b"}, - {file = "pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003"}, - {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2"}, - {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a"}, - {file = "pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8"}, - {file = "pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8"}, - {file = "pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904"}, - {file = "pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3"}, - {file = "pillow-11.0.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2e46773dc9f35a1dd28bd6981332fd7f27bec001a918a72a79b4133cf5291dba"}, - {file = "pillow-11.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2679d2258b7f1192b378e2893a8a0a0ca472234d4c2c0e6bdd3380e8dfa21b6a"}, - {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda2616eb2313cbb3eebbe51f19362eb434b18e3bb599466a1ffa76a033fb916"}, - {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ec184af98a121fb2da42642dea8a29ec80fc3efbaefb86d8fdd2606619045d"}, - {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:8594f42df584e5b4bb9281799698403f7af489fba84c34d53d1c4bfb71b7c4e7"}, - {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:c12b5ae868897c7338519c03049a806af85b9b8c237b7d675b8c5e089e4a618e"}, - {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:70fbbdacd1d271b77b7721fe3cdd2d537bbbd75d29e6300c672ec6bb38d9672f"}, - {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5178952973e588b3f1360868847334e9e3bf49d19e169bbbdfaf8398002419ae"}, - {file = "pillow-11.0.0-cp39-cp39-win32.whl", hash = "sha256:8c676b587da5673d3c75bd67dd2a8cdfeb282ca38a30f37950511766b26858c4"}, - {file = "pillow-11.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:94f3e1780abb45062287b4614a5bc0874519c86a777d4a7ad34978e86428b8dd"}, - {file = "pillow-11.0.0-cp39-cp39-win_arm64.whl", hash = "sha256:290f2cc809f9da7d6d622550bbf4c1e57518212da51b6a30fe8e0a270a5b78bd"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1187739620f2b365de756ce086fdb3604573337cc28a0d3ac4a01ab6b2d2a6d2"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbbcb7b57dc9c794843e3d1258c0fbf0f48656d46ffe9e09b63bbd6e8cd5d0a2"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d203af30149ae339ad1b4f710d9844ed8796e97fda23ffbc4cc472968a47d0b"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a0d3b115009ebb8ac3d2ebec5c2982cc693da935f4ab7bb5c8ebe2f47d36f2"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:73853108f56df97baf2bb8b522f3578221e56f646ba345a372c78326710d3830"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e58876c91f97b0952eb766123bfef372792ab3f4e3e1f1a2267834c2ab131734"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:224aaa38177597bb179f3ec87eeefcce8e4f85e608025e9cfac60de237ba6316"}, - {file = "pillow-11.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5bd2d3bdb846d757055910f0a59792d33b555800813c3b39ada1829c372ccb06"}, - {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:375b8dd15a1f5d2feafff536d47e22f69625c1aa92f12b339ec0b2ca40263273"}, - {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:daffdf51ee5db69a82dd127eabecce20729e21f7a3680cf7cbb23f0829189790"}, - {file = "pillow-11.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7326a1787e3c7b0429659e0a944725e1b03eeaa10edd945a86dead1913383944"}, - {file = "pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739"}, -] - -[package.extras] -docs = ["furo", "olefile", "sphinx (>=8.1)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] -fpx = ["olefile"] -mic = ["olefile"] -tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] -typing = ["typing-extensions"] -xmp = ["defusedxml"] - [[package]] name = "platformdirs" version = "4.3.6" @@ -4604,20 +4474,6 @@ requests = ">=2.0.0" [package.extras] rsa = ["oauthlib[signedtoken] (>=3.0.0)"] -[[package]] -name = "requests-toolbelt" -version = "1.0.0" -description = "A utility belt for advanced users of python-requests" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, - {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, -] - -[package.dependencies] -requests = ">=2.0.1,<3.0.0" - [[package]] name = "responses" version = "0.25.3" @@ -5891,4 +5747,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.13" -content-hash = "a3a3c55343607b3cde03200eaa362260ee50a5a7529b8904424a739b1ca9fd55" +content-hash = "3ec22e10b783a77283ec4ce26cf8761341f8684353e20628d509b2f86bd17ae0" diff --git a/pyproject.toml b/pyproject.toml index abd0e1f01d..c0dcdb9bc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,6 @@ dash-bootstrap-components = "1.6.0" detect-secrets = "1.5.0" google-api-python-client = "2.147.0" google-auth-httplib2 = ">=0.1,<0.3" -jira = "^3.8.0" jsonschema = "4.23.0" kubernetes = "31.0.0" microsoft-kiota-abstractions = "1.3.3" From b506e20089bd21cc4ab89a450558a4a73734d32b Mon Sep 17 00:00:00 2001 From: pedrooot Date: Tue, 5 Nov 2024 11:57:15 +0100 Subject: [PATCH 04/18] feat(jira): update poetry version --- poetry.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 370d3c0e22..d86838aaa9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "about-time" From 39d096b4a99d07d05d9d420ce2ec5b34b0b69bdb Mon Sep 17 00:00:00 2001 From: pedrooot Date: Tue, 5 Nov 2024 15:43:48 +0100 Subject: [PATCH 05/18] feat(jira): add default values for params --- prowler/lib/outputs/jira/jira.py | 57 ++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 538ac3bf29..c4608938c6 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -29,29 +29,30 @@ class Jira: - _redirect_uri: str - _client_id: str - _client_secret: str - _state_param: str - _access_token: str - _refresh_token: str - _auth_expiration: int - _cloud_id: str - _scopes: list[str] - - def __init__(self, redirect_uri, client_id, client_secret, state_param): + _redirect_uri: str = None + _client_id: str = None + _client_secret: str = None + _state_param: str = None + _access_token: str = None + _refresh_token: str = None + _auth_expiration: int = None + _cloud_id: str = None + _scopes: list[str] = None + + def __init__( + self, + redirect_uri: str = None, + client_id: str = None, + client_secret: str = None, + state_param: str = None, + ): self._redirect_uri = redirect_uri self._client_id = client_id self._client_secret = client_secret self._state_param = state_param - self._access_token = None - self._refresh_token = None - self._auth_expiration = None - self._cloud_id = None self._scopes = ["read:jira-user", "read:jira-work", "write:jira-work"] auth_url = self.auth_code_url(state_param) - print(f"Authorize the application by visiting this URL: {auth_url}") - authorization_code = input("Enter the authorization code from Jira: ") + authorization_code = self.input_authorization_code(auth_url) self.get_auth(authorization_code) @property @@ -90,6 +91,11 @@ def cloud_id(self): def scopes(self): return self._scopes + @staticmethod + def input_authorization_code(auth_url: str = None) -> str: + print(f"Authorize the application by visiting this URL: {auth_url}") + return input("Enter the authorization code from Jira: ") + def auth_code_url(self) -> str: """Generate the URL to authorize the application""" # Generate the state parameter @@ -111,7 +117,7 @@ def auth_code_url(self) -> str: f"https://auth.atlassian.com/authorize?{requests.compat.urlencode(params)}" ) - def get_auth(self, auth_code) -> None: + def get_auth(self, auth_code: str = None) -> None: """Get the access token and refresh token Args: @@ -169,7 +175,7 @@ def get_auth(self, auth_code) -> None: file=os.path.basename(__file__), ) - def get_cloud_id(self, access_token) -> str: + def get_cloud_id(self, access_token: str = None) -> str: """Get the cloud ID from Jira Args: @@ -284,7 +290,11 @@ def refresh_access_token(self) -> str: @staticmethod def test_connection( - redirect_uri, client_id, client_secret, state_param, raise_on_exception + redirect_uri: str = None, + client_id: str = None, + client_secret: str = None, + state_param: str = None, + raise_on_exception: bool = True, ) -> Connection: """Test the connection to Jira @@ -418,7 +428,7 @@ def get_projects(self) -> list[dict]: file=os.path.basename(__file__), ) - def get_available_issue_types(self, project_key: str) -> list[str]: + def get_available_issue_types(self, project_key: str = None) -> list[str]: """Get the available issue types for a project Args: @@ -470,7 +480,10 @@ def get_available_issue_types(self, project_key: str) -> list[str]: ) def send_findings( - self, findings: list[Finding], project_key: str, issue_type: str = "Bug" + self, + findings: list[Finding] = None, + project_key: str = None, + issue_type: str = "Bug", ): """ Send the findings to Jira From 20d7dbc6bb068d3e17a625a00eb7b5d6274dc140 Mon Sep 17 00:00:00 2001 From: pedrooot Date: Tue, 5 Nov 2024 16:51:28 +0100 Subject: [PATCH 06/18] feat(jira): add test_auth_code_url --- .../lib/outputs/jira/exceptions/exceptions.py | 2 +- prowler/lib/outputs/jira/jira.py | 10 ++-- tests/lib/outputs/jira/__init__.py | 0 tests/lib/outputs/jira/jira_test.py | 53 +++++++++++++++++++ 4 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 tests/lib/outputs/jira/__init__.py create mode 100644 tests/lib/outputs/jira/jira_test.py diff --git a/prowler/lib/outputs/jira/exceptions/exceptions.py b/prowler/lib/outputs/jira/exceptions/exceptions.py index 7e69d1bac5..4437e5ebf1 100644 --- a/prowler/lib/outputs/jira/exceptions/exceptions.py +++ b/prowler/lib/outputs/jira/exceptions/exceptions.py @@ -78,7 +78,7 @@ class JiraBaseException(ProwlerException): def __init__(self, code, file=None, original_exception=None, message=None): module = "Jira" - error_info = self.SECURITYHUB_ERROR_CODES.get((code, self.__class__.__name__)) + error_info = self.JIRA_ERROR_CODES.get((code, self.__class__.__name__)) if message: error_info["message"] = message super().__init__( diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index c4608938c6..f0a9508271 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -6,7 +6,6 @@ from prowler.lib.logger import logger from prowler.lib.outputs.finding import Finding -from prowler.lib.outputs.jira.exceptions import JiraNoProjectsError from prowler.lib.outputs.jira.exceptions.exceptions import ( JiraAuthenticationError, JiraCreateIssueError, @@ -20,6 +19,7 @@ JiraGetProjectsError, JiraGetProjectsResponseError, JiraInvalidIssueTypeError, + JiraNoProjectsError, JiraRefreshTokenError, JiraRefreshTokenResponseError, JiraSendFindingsResponseError, @@ -44,14 +44,12 @@ def __init__( redirect_uri: str = None, client_id: str = None, client_secret: str = None, - state_param: str = None, ): self._redirect_uri = redirect_uri self._client_id = client_id self._client_secret = client_secret - self._state_param = state_param self._scopes = ["read:jira-user", "read:jira-work", "write:jira-work"] - auth_url = self.auth_code_url(state_param) + auth_url = self.auth_code_url() authorization_code = self.input_authorization_code(auth_url) self.get_auth(authorization_code) @@ -101,7 +99,7 @@ def auth_code_url(self) -> str: # Generate the state parameter random_bytes = os.urandom(24) state_encoded = base64.urlsafe_b64encode(random_bytes).decode("utf-8") - + self._state_param = state_encoded # Generate the URL params = { "audience": "api.atlassian.com", @@ -518,6 +516,8 @@ def send_findings( } for finding in findings: + if finding.status != "FAIL": + continue payload = { "fields": { "project": {"key": project_key}, diff --git a/tests/lib/outputs/jira/__init__.py b/tests/lib/outputs/jira/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py new file mode 100644 index 0000000000..2e1e1df6bf --- /dev/null +++ b/tests/lib/outputs/jira/jira_test.py @@ -0,0 +1,53 @@ +from unittest.mock import patch +from urllib.parse import parse_qs, urlparse + +import pytest + +from prowler.lib.outputs.jira.jira import Jira + + +class TestJiraIntegration: + @pytest.fixture(autouse=True) + @patch.object(Jira, "get_auth", return_value=None) + def setup(self, mock_get_auth, monkeypatch): + # To disable vulture + mock_get_auth = mock_get_auth + monkeypatch.setattr("builtins.input", lambda _: "test_authorization_code") + + self.redirect_uri = "https://example.com/callback" + self.client_id = "test_client_id" + self.client_secret = "test_client_secret" + self.state_param = "unique_state_value" + + self.jira_integration = Jira( + redirect_uri=self.redirect_uri, + client_id=self.client_id, + client_secret=self.client_secret, + ) + + @patch.object(Jira, "get_auth", return_value=None) + def test_auth_code_url(self, mock_get_auth): + """Test to verify the authorization URL generation with correct query parameters""" + # To disable vulture + mock_get_auth = mock_get_auth + generated_url = self.jira_integration.auth_code_url() + + expected_url = "https://auth.atlassian.com/authorize" + + parsed_url = urlparse(generated_url) + query_params = parse_qs(parsed_url.query) + + assert ( + parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path + == expected_url + ) + + assert query_params["audience"][0] == "api.atlassian.com" + assert query_params["client_id"][0] == self.client_id + assert ( + query_params["scope"][0] == "read:jira-user read:jira-work write:jira-work" + ) + assert query_params["redirect_uri"][0] == self.redirect_uri + assert query_params["state"][0] is not None + assert query_params["response_type"][0] == "code" + assert query_params["prompt"][0] == "consent" From 01d264805fbf4ed30a8953a34996f73446b31888 Mon Sep 17 00:00:00 2001 From: pedrooot Date: Wed, 6 Nov 2024 16:13:20 +0100 Subject: [PATCH 07/18] feat(jira): add more tests and modify methods --- prowler/lib/outputs/jira/jira.py | 6 +- tests/lib/outputs/jira/jira_test.py | 88 ++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index f0a9508271..3ed7ffde18 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -194,7 +194,7 @@ def get_cloud_id(self, access_token: str = None) -> str: if response.status_code == 200: resources = response.json() - if resources: + if len(resources) > 0: return resources[0].get("id") else: logger.error("No resources found") @@ -291,7 +291,6 @@ def test_connection( redirect_uri: str = None, client_id: str = None, client_secret: str = None, - state_param: str = None, raise_on_exception: bool = True, ) -> Connection: """Test the connection to Jira @@ -300,7 +299,6 @@ def test_connection( - redirect_uri: The redirect URI - client_id: The client ID - client_secret: The client secret - - state_param: The state parameter - raise_on_exception: Whether to raise an exception or not Returns: @@ -314,7 +312,7 @@ def test_connection( - JiraTestConnectionError: Failed to test the connection """ try: - jira = Jira(redirect_uri, client_id, client_secret, state_param) + jira = Jira(redirect_uri, client_id, client_secret) access_token = jira.get_access_token() if not access_token: diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index 2e1e1df6bf..8a7c1a1319 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -1,8 +1,12 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from urllib.parse import parse_qs, urlparse import pytest +from prowler.lib.outputs.jira.exceptions.exceptions import ( + JiraAuthenticationError, + JiraGetCloudIdError, +) from prowler.lib.outputs.jira.jira import Jira @@ -12,6 +16,7 @@ class TestJiraIntegration: def setup(self, mock_get_auth, monkeypatch): # To disable vulture mock_get_auth = mock_get_auth + monkeypatch.setattr("builtins.input", lambda _: "test_authorization_code") self.redirect_uri = "https://example.com/callback" @@ -30,6 +35,7 @@ def test_auth_code_url(self, mock_get_auth): """Test to verify the authorization URL generation with correct query parameters""" # To disable vulture mock_get_auth = mock_get_auth + generated_url = self.jira_integration.auth_code_url() expected_url = "https://auth.atlassian.com/authorize" @@ -51,3 +57,83 @@ def test_auth_code_url(self, mock_get_auth): assert query_params["state"][0] is not None assert query_params["response_type"][0] == "code" assert query_params["prompt"][0] == "consent" + + @patch("prowler.lib.outputs.jira.jira.requests.post") + @patch.object(Jira, "get_cloud_id", return_value="test_cloud_id") + def test_get_auth_successful(self, mock_get_cloud_id, mock_post): + """Test successful token retrieval in get_auth.""" + # To disable vulture + mock_get_cloud_id = mock_get_cloud_id + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "test_access_token", + "refresh_token": "test_refresh_token", + "expires_in": 3600, + } + mock_post.return_value = mock_response + + self.jira_integration.get_auth("test_auth_code") + + assert self.jira_integration._access_token == "test_access_token" + assert self.jira_integration._refresh_token == "test_refresh_token" + assert self.jira_integration._auth_expiration == 3600 + assert self.jira_integration._cloud_id == "test_cloud_id" + + @patch( + "prowler.lib.outputs.jira.jira.requests.post", + side_effect=Exception("Connection error"), + ) + def test_get_auth_connection_error(self, mock_post): + """Test get_auth raises JiraAuthenticationError on connection failure.""" + + with pytest.raises(JiraAuthenticationError): + self.jira_integration.get_auth("test_auth_code") + + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_cloud_id_successful(self, mock_get): + """Test successful retrieval of cloud ID.""" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [{"id": "test_cloud_id"}] + mock_get.return_value = mock_response + + cloud_id = self.jira_integration.get_cloud_id("test_access_token") + + assert cloud_id == "test_cloud_id" + + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_cloud_id_no_resources(self, mock_get): + """Test get_cloud_id raises JiraGetCloudIdNoResourcesError when no resources are found, later JiraGetCloudIdError will be raised.""" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [] + mock_get.return_value = mock_response + + with pytest.raises(JiraGetCloudIdError): + self.jira_integration.get_cloud_id("test_access_token") + + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_cloud_id_response_error(self, mock_get): + """Test get_cloud_id raises JiraGetCloudIdResponseError when response code is not 200, later JiraGetCloudIdError will be raised.""" + + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.json.return_value = {"error": "Not Found"} + mock_get.return_value = mock_response + + with pytest.raises(JiraGetCloudIdError): + self.jira_integration.get_cloud_id("test_access_token") + + @patch( + "prowler.lib.outputs.jira.jira.requests.get", + side_effect=Exception("Connection error"), + ) + def test_get_cloud_id_unexpected_error(self, mock_get): + """Test get_cloud_id raises JiraGetCloudIdError on an unexpected exception.""" + + with pytest.raises(JiraGetCloudIdError): + self.jira_integration.get_cloud_id("test_access_token") From 121c5c81c0e0c0d3c0e0b00d4031acefa3e0a876 Mon Sep 17 00:00:00 2001 From: pedrooot Date: Thu, 7 Nov 2024 17:31:44 +0100 Subject: [PATCH 08/18] feat(jira): refactor methods and use table for tickets --- prowler/lib/outputs/jira/jira.py | 495 ++++++++++++++++++++++++++++++- 1 file changed, 486 insertions(+), 9 deletions(-) diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 3ed7ffde18..b61021e7e7 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -83,7 +83,7 @@ def auth_expiration(self): @property def cloud_id(self): - return self.cloud_id + return self._cloud_id @property def scopes(self): @@ -227,9 +227,7 @@ def get_access_token(self) -> str: - JiraGetAccessTokenError: Failed to get the access token """ try: - now = requests.datetime.datetime.utcnow() - - if self.auth_expiration and self.auth_expiration > now.timestamp(): + if self.auth_expiration and self.auth_expiration > 0: return self.access_token else: return self.refresh_access_token() @@ -312,7 +310,11 @@ def test_connection( - JiraTestConnectionError: Failed to test the connection """ try: - jira = Jira(redirect_uri, client_id, client_secret) + jira = Jira( + redirect_uri=redirect_uri, + client_id=client_id, + client_secret=client_secret, + ) access_token = jira.get_access_token() if not access_token: @@ -455,6 +457,12 @@ def get_available_issue_types(self, project_key: str = None) -> list[str]: ) if response.status_code == 200: + if len(response.json()["projects"]) == 0: + logger.error("No projects found") + raise JiraNoProjectsError( + message="No projects found in Jira", + file=os.path.basename(__file__), + ) issue_types = response.json()["projects"][0]["issuetypes"] return [issue_type["name"] for issue_type in issue_types] else: @@ -514,13 +522,482 @@ def send_findings( } for finding in findings: - if finding.status != "FAIL": - continue + if finding.status.value == "PASS": + status_color = "#008000" + else: + status_color = "#FF0000" + + adf_description = { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Prowler has discovered the following finding:", + } + ], + }, + { + "type": "table", + "attrs": {"layout": "full-width"}, + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Check Id", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": finding.metadata.CheckID, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Check Title", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": finding.metadata.CheckTitle, + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Severity", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": finding.metadata.Severity.upper(), + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Status", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": finding.status.value, + "marks": [ + {"type": "strong"}, + { + "type": "textColor", + "attrs": { + "color": status_color + }, + }, + ], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Status Extended", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": finding.status_extended, + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Provider", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": finding.metadata.Provider, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Region", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": finding.region, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Resource UID", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": finding.resource_uid, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Resource Name", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": finding.resource_name, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Risk", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": finding.metadata.Risk, + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Recommendation", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": finding.metadata.Remediation.Recommendation.Text + + " ", + }, + { + "type": "text", + "text": finding.metadata.Remediation.Recommendation.Url, + "marks": [ + { + "type": "link", + "attrs": { + "href": finding.metadata.Remediation.Recommendation.Url + }, + } + ], + }, + ], + } + ], + }, + ], + }, + ], + }, + ], + } payload = { "fields": { "project": {"key": project_key}, - "summary": finding.metadata.CheckTitle, - "description": finding.metadata.Description, + "summary": f"[Prowler] {finding.metadata.Severity.value.upper()} - {finding.metadata.CheckID} - {finding.resource_uid}", + "description": adf_description, "issuetype": {"name": issue_type}, } } From aa51a73b917f4a5408876e5270fad8ceaa59c90c Mon Sep 17 00:00:00 2001 From: pedrooot Date: Thu, 7 Nov 2024 17:42:46 +0100 Subject: [PATCH 09/18] fix: poetry --- poetry.lock | 93 ++++++++++++++++++++++++----------------------------- 1 file changed, 42 insertions(+), 51 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4e8d354fe6..5d00375a28 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "about-time" @@ -4821,56 +4821,47 @@ description = "C version of reader, parser and emitter for ruamel.yaml derived f optional = false python-versions = ">=3.9" files = [ - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win32.whl", hash = "sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win_amd64.whl", hash = "sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win32.whl", hash = "sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win_amd64.whl", hash = "sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_24_aarch64.whl", hash = "sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win32.whl", hash = "sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win_amd64.whl", hash = "sha256:1758ce7d8e1a29d23de54a16ae867abd370f01b5a69e1a3ba75223eaa3ca1a1b"}, - {file = "ruamel.yaml.clib-0.2.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_12_0_arm64.whl", hash = "sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win32.whl", hash = "sha256:75e1ed13e1f9de23c5607fe6bd1aeaae21e523b32d83bb33918245361e9cc51b"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win_amd64.whl", hash = "sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win32.whl", hash = "sha256:955eae71ac26c1ab35924203fda6220f84dce57d6d7884f189743e2abe3a9fbe"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win_amd64.whl", hash = "sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-win32.whl", hash = "sha256:84b554931e932c46f94ab306913ad7e11bba988104c5cff26d90d03f68258cd5"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-win_amd64.whl", hash = "sha256:25ac8c08322002b06fa1d49d1646181f0b2c72f5cbc15a85e80b4c30a544bb15"}, - {file = "ruamel.yaml.clib-0.2.8.tar.gz", hash = "sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bc5f1e1c28e966d61d2519f2a3d451ba989f9ea0f2307de7bc45baa526de9e45"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a0e060aace4c24dcaf71023bbd7d42674e3b230f7e7b97317baf1e953e5b519"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"}, + {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"}, ] [[package]] From b50db73aa14cb3222e2536dc9a623e02a4a4db02 Mon Sep 17 00:00:00 2001 From: pedrooot Date: Thu, 7 Nov 2024 18:40:44 +0100 Subject: [PATCH 10/18] feat(jira): add tests --- prowler/lib/outputs/jira/jira.py | 14 +- tests/lib/outputs/jira/jira_test.py | 870 +++++++++++++++++++++++++++- 2 files changed, 880 insertions(+), 4 deletions(-) diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index b61021e7e7..21206708d5 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -73,6 +73,10 @@ def state_param(self): def access_token(self): return self._access_token + @access_token.setter + def access_token(self, value): + self._access_token = value + @property def refresh_token(self): return self._refresh_token @@ -81,10 +85,18 @@ def refresh_token(self): def auth_expiration(self): return self._auth_expiration + @auth_expiration.setter + def auth_expiration(self, value): + self._auth_expiration = value + @property def cloud_id(self): return self._cloud_id + @cloud_id.setter + def cloud_id(self, value): + self._cloud_id = value + @property def scopes(self): return self._scopes @@ -651,7 +663,7 @@ def send_findings( "content": [ { "type": "text", - "text": finding.metadata.Severity.upper(), + "text": finding.metadata.Severity.value.upper(), } ], } diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index 8a7c1a1319..c8eb18a9ea 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -1,11 +1,16 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, PropertyMock, patch from urllib.parse import parse_qs, urlparse import pytest from prowler.lib.outputs.jira.exceptions.exceptions import ( JiraAuthenticationError, + JiraCreateIssueError, + JiraGetAvailableIssueTypesError, JiraGetCloudIdError, + JiraGetProjectsError, + JiraNoProjectsError, + JiraRefreshTokenError, ) from prowler.lib.outputs.jira.jira import Jira @@ -14,11 +19,11 @@ class TestJiraIntegration: @pytest.fixture(autouse=True) @patch.object(Jira, "get_auth", return_value=None) def setup(self, mock_get_auth, monkeypatch): + monkeypatch.setattr("builtins.input", lambda _: "test_authorization_code") + # To disable vulture mock_get_auth = mock_get_auth - monkeypatch.setattr("builtins.input", lambda _: "test_authorization_code") - self.redirect_uri = "https://example.com/callback" self.client_id = "test_client_id" self.client_secret = "test_client_secret" @@ -33,6 +38,7 @@ def setup(self, mock_get_auth, monkeypatch): @patch.object(Jira, "get_auth", return_value=None) def test_auth_code_url(self, mock_get_auth): """Test to verify the authorization URL generation with correct query parameters""" + # To disable vulture mock_get_auth = mock_get_auth @@ -87,6 +93,8 @@ def test_get_auth_successful(self, mock_get_cloud_id, mock_post): ) def test_get_auth_connection_error(self, mock_post): """Test get_auth raises JiraAuthenticationError on connection failure.""" + # To disable vulture + mock_post = mock_post with pytest.raises(JiraAuthenticationError): self.jira_integration.get_auth("test_auth_code") @@ -134,6 +142,862 @@ def test_get_cloud_id_response_error(self, mock_get): ) def test_get_cloud_id_unexpected_error(self, mock_get): """Test get_cloud_id raises JiraGetCloudIdError on an unexpected exception.""" + # To disable vulture + mock_get = mock_get with pytest.raises(JiraGetCloudIdError): self.jira_integration.get_cloud_id("test_access_token") + + @patch.object(Jira, "refresh_access_token", return_value="new_access_token") + def test_get_access_token_refresh(self, mock_refresh_access_token): + """Test get_access_token refreshes token when expired.""" + + self.jira_integration.auth_expiration = 0 + access_token = self.jira_integration.get_access_token() + + assert access_token == "new_access_token" + mock_refresh_access_token.assert_called_once() + + def test_get_access_token_valid_token(self): + """Test get_access_token returns existing token if not expired.""" + + self.jira_integration.auth_expiration = 100 + self.jira_integration.access_token = "valid_access_token" + access_token = self.jira_integration.get_access_token() + + assert access_token == "valid_access_token" + + @patch.object(Jira, "refresh_access_token", side_effect=JiraRefreshTokenError) + def test_get_access_token_refresh_error(self, mock_refresh_access_token): + """Test get_access_token raises JiraRefreshTokenError on token refresh failure.""" + + # To disable vulture + mock_refresh_access_token = mock_refresh_access_token + + self.jira_integration.auth_expiration = 0 + + with pytest.raises(JiraRefreshTokenError): + self.jira_integration.get_access_token() + + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_refresh_access_token_successful(self, mock_post): + """Test successful access token refresh in refresh_access_token.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "new_access_token", + "refresh_token": "new_refresh_token", + "expires_in": 3600, + } + mock_post.return_value = mock_response + + new_access_token = self.jira_integration.refresh_access_token() + + assert new_access_token == "new_access_token" + assert self.jira_integration._access_token == "new_access_token" + assert self.jira_integration._refresh_token == "new_refresh_token" + assert self.jira_integration._auth_expiration == 3600 + + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_refresh_access_token_response_error(self, mock_post): + """Test refresh_access_token raises JiraRefreshTokenResponseError when response code is not 200, later JiraRefreshTokenError will be raised.""" + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {"error": "invalid_request"} + mock_post.return_value = mock_response + + with pytest.raises(JiraRefreshTokenError): + self.jira_integration.refresh_access_token() + + @patch( + "prowler.lib.outputs.jira.jira.requests.post", + side_effect=Exception("Connection error"), + ) + def test_refresh_access_token_unexpected_error(self, mock_post): + """Test refresh_access_token raises JiraRefreshTokenError on unexpected exception.""" + # To disable vulture + mock_post = mock_post + + with pytest.raises(JiraRefreshTokenError): + self.jira_integration.refresh_access_token() + + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object(Jira, "get_cloud_id", return_value="test_cloud_id") + @patch.object(Jira, "get_auth", return_value=None) + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_test_connection_successful( + self, mock_get, mock_get_cloud_id, mock_get_auth, mock_get_access_token + ): + """Test that a successful connection returns an active Connection object.""" + # To disable vulture + mock_get_cloud_id = mock_get_cloud_id + mock_get_auth = mock_get_auth + mock_get_access_token = mock_get_access_token + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "test_user_id"} + mock_get.return_value = mock_response + + connection = self.jira_integration.test_connection( + redirect_uri=self.redirect_uri, + client_id=self.client_id, + client_secret=self.client_secret, + ) + + assert connection.is_connected + assert connection.error is None + + @patch.object( + Jira, + "get_access_token", + side_effect=JiraAuthenticationError("Failed to authenticate with Jira"), + ) + def test_test_connection_failed(self, mock_get_access_token): + """Test that a failed connection raises JiraAuthenticationError.""" + # To disable vulture + mock_get_access_token = mock_get_access_token + + with pytest.raises(JiraAuthenticationError): + self.jira_integration.test_connection( + redirect_uri=self.redirect_uri, + client_id=self.client_id, + client_secret=self.client_secret, + ) + + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" + ) + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_projects_successful( + self, mock_get, mock_cloud_id, mock_get_access_token + ): + """Test successful retrieval of projects from Jira.""" + # To disable vulture + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + {"key": "PROJ1", "name": "Project One"}, + {"key": "PROJ2", "name": "Project Two"}, + ] + mock_get.return_value = mock_response + + projects = self.jira_integration.get_projects() + + expected_projects = [ + {"key": "PROJ1", "name": "Project One"}, + {"key": "PROJ2", "name": "Project Two"}, + ] + assert projects == expected_projects + + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" + ) + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_projects_no_projects_found( + self, mock_get, mock_cloud_id, mock_get_access_token + ): + """Test that get_projects raises JiraNoProjectsError when no projects are found.""" + # To disable vulture + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [] + mock_get.return_value = mock_response + + with pytest.raises(JiraNoProjectsError): + self.jira_integration.get_projects() + + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" + ) + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_projects_response_error( + self, mock_get, mock_cloud_id, mock_get_access_token + ): + """Test that get_projects raises JiraGetProjectsResponseError on non-200 response.""" + # To disable vulture + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.json.return_value = {"error": "Not Found"} + mock_get.return_value = mock_response + + with pytest.raises(JiraGetProjectsError): + self.jira_integration.get_projects() + + @patch.object( + Jira, + "get_access_token", + side_effect=JiraRefreshTokenError("Failed to refresh the access token"), + ) + def test_get_projects_refresh_token_error(self, mock_get_access_token): + """Test that get_projects raises JiraRefreshTokenError when refreshing the token fails.""" + # To disable vulture + mock_get_access_token = mock_get_access_token + + with pytest.raises(JiraRefreshTokenError): + self.jira_integration.get_projects() + + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" + ) + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_available_issue_types_successful( + self, mock_get, mock_cloud_id, mock_get_access_token + ): + """Test successful retrieval of issue types for a project.""" + # To disable vulture + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "projects": [ + { + "issuetypes": [ + {"name": "Bug"}, + {"name": "Task"}, + {"name": "Story"}, + ] + } + ] + } + mock_get.return_value = mock_response + + issue_types = self.jira_integration.get_available_issue_types( + project_key="TEST" + ) + + expected_issue_types = ["Bug", "Task", "Story"] + assert issue_types == expected_issue_types + + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" + ) + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_available_issue_types_no_projects_found( + self, mock_get, mock_cloud_id, mock_get_access_token + ): + """Test that get_available_issue_types raises JiraNoProjectsError when no projects are found, later JiraGetAvailableIssueTypesError is raised.""" + # To disable vulture + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"projects": []} + mock_get.return_value = mock_response + + with pytest.raises(JiraGetAvailableIssueTypesError): + self.jira_integration.get_available_issue_types(project_key="TEST") + + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" + ) + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_available_issue_types_response_error( + self, mock_get, mock_cloud_id, mock_get_access_token + ): + """Test that get_available_issue_types raises JiraGetAvailableIssueTypesResponseError on non-200 response, JiraGetAvailableIssueTypesError will be raised later""" + # To disable vulture + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.json.return_value = {"error": "Not Found"} + mock_get.return_value = mock_response + + with pytest.raises(JiraGetAvailableIssueTypesError): + self.jira_integration.get_available_issue_types(project_key="TEST") + + @patch.object( + Jira, + "get_access_token", + side_effect=JiraRefreshTokenError("Failed to refresh the access token"), + ) + def test_get_available_issue_types_refresh_token_error(self, mock_get_access_token): + """Test that get_available_issue_types raises JiraRefreshTokenError when refreshing the token fails.""" + # To disable vulture + mock_get_access_token = mock_get_access_token + + with pytest.raises(JiraRefreshTokenError): + self.jira_integration.get_available_issue_types(project_key="TEST") + + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"] + ) + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_send_findings_successful( + self, mock_post, mock_get_available_issue_types, mock_get_access_token + ): + """Test successful sending of findings to Jira.""" + # To disable vulture + mock_get_available_issue_types = mock_get_available_issue_types + mock_get_access_token = mock_get_access_token + + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "12345", "key": "TEST-1"} + mock_post.return_value = mock_response + + finding = MagicMock() + finding.status.value = "FAIL" + finding.status_extended = "status_extended" + finding.metadata.Severity.value = "HIGH" + finding.metadata.CheckID = "CHECK-1" + finding.metadata.CheckTitle = "Check Title" + finding.resource_uid = "resource-1" + finding.resource_name = "resource_name" + finding.metadata.Provider = "aws" + finding.region = "region" + finding.metadata.Risk = "risk" + finding.metadata.Remediation.Recommendation.Text = "remediation_text" + finding.metadata.Remediation.Recommendation.Url = "remediation_url" + + self.jira_integration.cloud_id = "valid_cloud_id" + + self.jira_integration.send_findings( + findings=[finding], project_key="TEST", issue_type="Bug" + ) + + expected_url = ( + "https://api.atlassian.com/ex/jira/valid_cloud_id/rest/api/3/issue" + ) + expected_json = { + "fields": { + "project": {"key": "TEST"}, + "summary": "[Prowler] HIGH - CHECK-1 - resource-1", + "description": { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Prowler has discovered the following finding:", + } + ], + }, + { + "type": "table", + "attrs": {"layout": "full-width"}, + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Check Id", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "CHECK-1", + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Check Title", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Check Title", + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Severity", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "HIGH"} + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Status", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "FAIL", + "marks": [ + {"type": "strong"}, + { + "type": "textColor", + "attrs": { + "color": "#FF0000" + }, + }, + ], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Status Extended", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "status_extended", + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Provider", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "aws", + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Region", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "region", + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Resource UID", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "resource-1", + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Resource Name", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "resource_name", + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Risk", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "risk"} + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Recommendation", + "marks": [ + {"type": "strong"} + ], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "remediation_text ", + }, + { + "type": "text", + "text": "remediation_url", + "marks": [ + { + "type": "link", + "attrs": { + "href": "remediation_url" + }, + } + ], + }, + ], + } + ], + }, + ], + }, + ], + }, + ], + }, + "issuetype": {"name": "Bug"}, + } + } + expected_headers = { + "Authorization": "Bearer valid_access_token", + "Content-Type": "application/json", + } + + mock_post.assert_called_once_with( + expected_url, json=expected_json, headers=expected_headers + ) + + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"] + ) + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_send_findings_invalid_issue_type( + self, mock_post, mock_get_available_issue_types, mock_get_access_token + ): + """Test that send_findings raises JiraInvalidIssueTypeError if the issue type is invalid that will raise JiraCreateIssueError later.""" + # To disable vulture + mock_get_available_issue_types = mock_get_available_issue_types + mock_get_access_token = mock_get_access_token + + with pytest.raises(JiraCreateIssueError): + self.jira_integration.send_findings( + findings=[MagicMock()], project_key="TEST", issue_type="InvalidType" + ) + + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"] + ) + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_send_findings_response_error( + self, mock_post, mock_get_available_issue_types, mock_get_access_token + ): + """Test that send_findings raises JiraSendFindingsResponseError on non-201 response that will raise JiraCreateIssueError later.""" + # To disable vulture + mock_get_available_issue_types = mock_get_available_issue_types + mock_get_access_token = mock_get_access_token + + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {"error": "Bad Request"} + mock_post.return_value = mock_response + + finding = MagicMock() + finding.status.value = "FAIL" + finding.metadata.Severity.value = "HIGH" + finding.metadata.CheckID = "CHECK-1" + finding.resource_uid = "resource-1" + + with pytest.raises(JiraCreateIssueError): + self.jira_integration.send_findings( + findings=[finding], project_key="TEST", issue_type="Bug" + ) From fd99c1c61b2644ccbacafd21b04f754083165e9c Mon Sep 17 00:00:00 2001 From: pedrooot Date: Fri, 8 Nov 2024 09:35:01 +0100 Subject: [PATCH 11/18] feat(jira): change exception codes --- .../lib/outputs/jira/exceptions/exceptions.py | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/prowler/lib/outputs/jira/exceptions/exceptions.py b/prowler/lib/outputs/jira/exceptions/exceptions.py index 4437e5ebf1..f4d5b5ab80 100644 --- a/prowler/lib/outputs/jira/exceptions/exceptions.py +++ b/prowler/lib/outputs/jira/exceptions/exceptions.py @@ -1,76 +1,76 @@ from prowler.exceptions.exceptions import ProwlerException -# Exceptions codes from 8000 to 8999 are reserved for Jira exceptions +# Exceptions codes from 9000 to 9999 are reserved for Jira exceptions class JiraBaseException(ProwlerException): - """Base class for Security Hub exceptions.""" + """Base class for Jira exceptions.""" JIRA_ERROR_CODES = { - (8000, "JiraNoProjectsError"): { + (9000, "JiraNoProjectsError"): { "message": "No projects were found in Jira.", "remediation": "Please create a project in Jira.", }, - (8001, "JiraAuthenticationError"): { + (9001, "JiraAuthenticationError"): { "message": "Failed to authenticate with Jira.", "remediation": "Please check the connection settings and permissions and try again.", }, - (8002, "JiraTestConnectionError"): { + (9002, "JiraTestConnectionError"): { "message": "Failed to connect to Jira.", "remediation": "Please check the connection settings and permissions and try again.", }, - (8003, "JiraCreateIssueError"): { + (9003, "JiraCreateIssueError"): { "message": "Failed to create an issue in Jira.", "remediation": "Please check the connection settings and permissions and try again.", }, - (8004, "JiraGetProjectsError"): { + (9004, "JiraGetProjectsError"): { "message": "Failed to get projects from Jira.", "remediation": "Please check the connection settings and permissions and try again.", }, - (8005, "JiraGetCloudIdError"): { + (9005, "JiraGetCloudIdError"): { "message": "Failed to get the cloud ID from Jira.", "remediation": "Please check the connection settings and permissions and try again.", }, - (8006, "JiraGetCloudIdNoResourcesError"): { + (9006, "JiraGetCloudIdNoResourcesError"): { "message": "No resources were found in Jira.", "remediation": "Please check the connection settings and permissions and try again.", }, - (8007, "JiraGetCloudIdResponseError"): { + (9007, "JiraGetCloudIdResponseError"): { "message": "Failed to get the cloud ID from Jira.", "remediation": "Please check the connection settings and permissions and try again.", }, - (8008, "JiraRefreshTokenResponseError"): { + (9008, "JiraRefreshTokenResponseError"): { "message": "Failed to refresh the access token, response code did not match 200.", "remediation": "Please check the connection settings and permissions and try again.", }, - (8009, "JiraRefreshTokenError"): { + (9009, "JiraRefreshTokenError"): { "message": "Failed to refresh the access token.", "remediation": "Please check the connection settings and permissions and try again.", }, - (8010, "JiraGetAccessTokenError"): { + (9010, "JiraGetAccessTokenError"): { "message": "Failed to get the access token.", "remediation": "Please check the connection settings and permissions and try again.", }, - (8011, "JiraGetAuthResponseError"): { + (9011, "JiraGetAuthResponseError"): { "message": "Failed to authenticate with Jira.", "remediation": "Please check the connection settings and permissions and try again.", }, - (8012, "JiraGetProjectsResponseError"): { + (9012, "JiraGetProjectsResponseError"): { "message": "Failed to get projects from Jira, response code did not match 200.", "remediation": "Please check the connection settings and permissions and try again.", }, - (8013, "JiraSendFindingsResponseError"): { + (9013, "JiraSendFindingsResponseError"): { "message": "Failed to send findings to Jira, response code did not match 201.", "remediation": "Please check the finding format and try again.", }, - (8014, "JiraGetAvailableIssueTypesError"): { + (9014, "JiraGetAvailableIssueTypesError"): { "message": "Failed to get available issue types from Jira.", "remediation": "Please check the connection settings and permissions and try again.", }, - (8015, "JiraGetAvailableIssueTypesResponseError"): { + (9015, "JiraGetAvailableIssueTypesResponseError"): { "message": "Failed to get available issue types from Jira, response code did not match 200.", "remediation": "Please check the connection settings and permissions and try again.", }, - (8016, "JiraInvalidIssueTypeError"): { + (9016, "JiraInvalidIssueTypeError"): { "message": "The issue type is invalid.", "remediation": "Please check the issue type and try again.", }, @@ -93,117 +93,117 @@ def __init__(self, code, file=None, original_exception=None, message=None): class JiraNoProjectsError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8000, file=file, original_exception=original_exception, message=message + 9000, file=file, original_exception=original_exception, message=message ) class JiraAuthenticationError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8001, file=file, original_exception=original_exception, message=message + 9001, file=file, original_exception=original_exception, message=message ) class JiraTestConnectionError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8002, file=file, original_exception=original_exception, message=message + 9002, file=file, original_exception=original_exception, message=message ) class JiraCreateIssueError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8003, file=file, original_exception=original_exception, message=message + 9003, file=file, original_exception=original_exception, message=message ) class JiraGetProjectsError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8004, file=file, original_exception=original_exception, message=message + 9004, file=file, original_exception=original_exception, message=message ) class JiraGetCloudIdError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8005, file=file, original_exception=original_exception, message=message + 9005, file=file, original_exception=original_exception, message=message ) class JiraGetCloudIdNoResourcesError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8006, file=file, original_exception=original_exception, message=message + 9006, file=file, original_exception=original_exception, message=message ) class JiraGetCloudIdResponseError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8007, file=file, original_exception=original_exception, message=message + 9007, file=file, original_exception=original_exception, message=message ) class JiraRefreshTokenResponseError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8008, file=file, original_exception=original_exception, message=message + 9008, file=file, original_exception=original_exception, message=message ) class JiraRefreshTokenError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8009, file=file, original_exception=original_exception, message=message + 9009, file=file, original_exception=original_exception, message=message ) class JiraGetAccessTokenError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8010, file=file, original_exception=original_exception, message=message + 9010, file=file, original_exception=original_exception, message=message ) class JiraGetAuthResponseError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8011, file=file, original_exception=original_exception, message=message + 9011, file=file, original_exception=original_exception, message=message ) class JiraGetProjectsResponseError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8012, file=file, original_exception=original_exception, message=message + 9012, file=file, original_exception=original_exception, message=message ) class JiraSendFindingsResponseError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8013, file=file, original_exception=original_exception, message=message + 9013, file=file, original_exception=original_exception, message=message ) class JiraGetAvailableIssueTypesError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8014, file=file, original_exception=original_exception, message=message + 9014, file=file, original_exception=original_exception, message=message ) class JiraGetAvailableIssueTypesResponseError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8015, file=file, original_exception=original_exception, message=message + 9015, file=file, original_exception=original_exception, message=message ) class JiraInvalidIssueTypeError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 8016, file=file, original_exception=original_exception, message=message + 9016, file=file, original_exception=original_exception, message=message ) From adf168fa25b0043d8c6c4ac9d505d522782cc93a Mon Sep 17 00:00:00 2001 From: pedrooot Date: Fri, 8 Nov 2024 11:52:58 +0100 Subject: [PATCH 12/18] feat(jira): resolve comments --- .../lib/outputs/jira/exceptions/exceptions.py | 25 +- prowler/lib/outputs/jira/jira.py | 954 ++++++++++-------- tests/lib/outputs/jira/jira_test.py | 60 +- 3 files changed, 596 insertions(+), 443 deletions(-) diff --git a/prowler/lib/outputs/jira/exceptions/exceptions.py b/prowler/lib/outputs/jira/exceptions/exceptions.py index f4d5b5ab80..4a14b5f8df 100644 --- a/prowler/lib/outputs/jira/exceptions/exceptions.py +++ b/prowler/lib/outputs/jira/exceptions/exceptions.py @@ -12,7 +12,7 @@ class JiraBaseException(ProwlerException): }, (9001, "JiraAuthenticationError"): { "message": "Failed to authenticate with Jira.", - "remediation": "Please check the connection settings and permissions and try again.", + "remediation": "Please check the connection settings and permissions and try again. Needed scopes are: read:jira-user read:jira-work write:jira-work", }, (9002, "JiraTestConnectionError"): { "message": "Failed to connect to Jira.", @@ -26,15 +26,15 @@ class JiraBaseException(ProwlerException): "message": "Failed to get projects from Jira.", "remediation": "Please check the connection settings and permissions and try again.", }, - (9005, "JiraGetCloudIdError"): { + (9005, "JiraGetCloudIDError"): { "message": "Failed to get the cloud ID from Jira.", "remediation": "Please check the connection settings and permissions and try again.", }, - (9006, "JiraGetCloudIdNoResourcesError"): { + (9006, "JiraGetCloudIDNoResourcesError"): { "message": "No resources were found in Jira.", "remediation": "Please check the connection settings and permissions and try again.", }, - (9007, "JiraGetCloudIdResponseError"): { + (9007, "JiraGetCloudIDResponseError"): { "message": "Failed to get the cloud ID from Jira.", "remediation": "Please check the connection settings and permissions and try again.", }, @@ -74,6 +74,10 @@ class JiraBaseException(ProwlerException): "message": "The issue type is invalid.", "remediation": "Please check the issue type and try again.", }, + (9017, "JiraNoTokenError"): { + "message": "No token was found.", + "remediation": "Make sure the token is set when using the Jira integration.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -125,21 +129,21 @@ def __init__(self, file=None, original_exception=None, message=None): ) -class JiraGetCloudIdError(JiraBaseException): +class JiraGetCloudIDError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( 9005, file=file, original_exception=original_exception, message=message ) -class JiraGetCloudIdNoResourcesError(JiraBaseException): +class JiraGetCloudIDNoResourcesError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( 9006, file=file, original_exception=original_exception, message=message ) -class JiraGetCloudIdResponseError(JiraBaseException): +class JiraGetCloudIDResponseError(JiraBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( 9007, file=file, original_exception=original_exception, message=message @@ -207,3 +211,10 @@ def __init__(self, file=None, original_exception=None, message=None): super().__init__( 9016, file=file, original_exception=original_exception, message=message ) + + +class JiraNoTokenError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 9017, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 21206708d5..9b7d232029 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -1,5 +1,7 @@ import base64 import os +from datetime import datetime, timedelta +from typing import Dict import requests import requests.compat @@ -13,13 +15,14 @@ JiraGetAuthResponseError, JiraGetAvailableIssueTypesError, JiraGetAvailableIssueTypesResponseError, - JiraGetCloudIdError, - JiraGetCloudIdNoResourcesError, - JiraGetCloudIdResponseError, + JiraGetCloudIDError, + JiraGetCloudIDNoResourcesError, + JiraGetCloudIDResponseError, JiraGetProjectsError, JiraGetProjectsResponseError, JiraInvalidIssueTypeError, JiraNoProjectsError, + JiraNoTokenError, JiraRefreshTokenError, JiraRefreshTokenResponseError, JiraSendFindingsResponseError, @@ -29,15 +32,82 @@ class Jira: + """ + Jira class to interact with the Jira API + + Attributes: + - _redirect_uri: The redirect URI + - _client_id: The client ID + - _client_secret: The client secret + - _access_token: The access token + - _refresh_token: The refresh token + - _auth_expiration: The authentication expiration + - _cloud_id: The cloud ID + - _scopes: The scopes needed to authenticate, read:jira-user read:jira-work write:jira-work + - AUTH_URL: The URL to authenticate with Jira + - PARAMS_TEMPLATE: The template for the parameters to authenticate with Jira + + Methods: + - __init__: Initialize the Jira object + - input_authorization_code: Input the authorization code + - auth_code_url: Generate the URL to authorize the application + - get_auth: Get the access token and refresh token + - get_cloud_id: Get the cloud ID from Jira + - get_access_token: Get the access token + - refresh_access_token: Refresh the access token from Jira + - test_connection: Test the connection to Jira and return a Connection object + - get_projects: Get the projects from Jira + - get_available_issue_types: Get the available issue types for a project + - send_findings: Send the findings to Jira and create an issue + + Raises: + - JiraGetAuthResponseError: Failed to get the access token and refresh token + - JiraGetCloudIDNoResourcesError: No resources were found in Jira when getting the cloud id + - JiraGetCloudIDResponseError: Failed to get the cloud ID, response code did not match 200 + - JiraGetCloudIDError: Failed to get the cloud ID from Jira + - JiraAuthenticationError: Failed to authenticate + - JiraRefreshTokenError: Failed to refresh the access token + - JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match 200 + - JiraGetAccessTokenError: Failed to get the access token + - JiraNoProjectsError: No projects found in Jira + - JiraGetProjectsError: Failed to get projects from Jira + - JiraGetProjectsResponseError: Failed to get projects from Jira, response code did not match 200 + - JiraInvalidIssueTypeError: The issue type is invalid + - JiraGetAvailableIssueTypesError: Failed to get available issue types from Jira + - JiraGetAvailableIssueTypesResponseError: Failed to get available issue types from Jira, response code did not match 200 + - JiraCreateIssueError: Failed to create an issue in Jira + - JiraSendFindingsResponseError: Failed to send the findings to Jira + - JiraTestConnectionError: Failed to test the connection + + Usage: + jira = Jira( + redirect_uri="http://localhost:8080", + client_id="client_id", + client_secret="client_secret + ) + jira.send_findings(findings=findings, project_key="KEY") + """ + _redirect_uri: str = None _client_id: str = None _client_secret: str = None - _state_param: str = None _access_token: str = None _refresh_token: str = None _auth_expiration: int = None _cloud_id: str = None _scopes: list[str] = None + AUTH_URL = "https://auth.atlassian.com/authorize" + PARAMS_TEMPLATE = { + "audience": "api.atlassian.com", + "client_id": None, + "scope": None, + "redirect_uri": None, + "state": None, + "response_type": "code", + "prompt": "consent", + } + TOKEN_URL = "https://auth.atlassian.com/oauth/token" + API_TOKEN_URL = "https://api.atlassian.com/oauth/token/accessible-resources" def __init__( self, @@ -61,26 +131,6 @@ def redirect_uri(self): def client_id(self): return self._client_id - @property - def client_secret(self): - return self._client_secret - - @property - def state_param(self): - return self._state_param - - @property - def access_token(self): - return self._access_token - - @access_token.setter - def access_token(self, value): - self._access_token = value - - @property - def refresh_token(self): - return self._refresh_token - @property def auth_expiration(self): return self._auth_expiration @@ -101,31 +151,57 @@ def cloud_id(self, value): def scopes(self): return self._scopes + def get_params(self, state_encoded): + return { + **self.PARAMS_TEMPLATE, + "client_id": self.client_id, + "scope": " ".join(self.scopes), + "redirect_uri": self.redirect_uri, + "state": state_encoded, + } + + # TODO: Add static credentials for future use @staticmethod def input_authorization_code(auth_url: str = None) -> str: + """Input the authorization code + + Args: + - auth_url: The URL to authorize the application + + Returns: + - str: The authorization code from Jira + """ print(f"Authorize the application by visiting this URL: {auth_url}") return input("Enter the authorization code from Jira: ") def auth_code_url(self) -> str: - """Generate the URL to authorize the application""" + """Generate the URL to authorize the application + + Returns: + - str: The URL to authorize the application + + Raises: + - JiraGetAuthResponseError: Failed to get the access token and refresh token + """ # Generate the state parameter random_bytes = os.urandom(24) state_encoded = base64.urlsafe_b64encode(random_bytes).decode("utf-8") - self._state_param = state_encoded # Generate the URL - params = { - "audience": "api.atlassian.com", - "client_id": self.client_id, - "scope": " ".join(self.scopes), - "redirect_uri": self.redirect_uri, - "state": state_encoded, - "response_type": "code", - "prompt": "consent", - } + params = self.get_params(state_encoded) - return ( - f"https://auth.atlassian.com/authorize?{requests.compat.urlencode(params)}" - ) + return f"{self.AUTH_URL}?{requests.compat.urlencode(params)}" + + @staticmethod + def get_timestamp_from_seconds(seconds: int) -> str: + """Get the timestamp adding the seconds to the current time + + Args: + - seconds: The seconds to add to the current time + + Returns: + - str: The timestamp + """ + return (datetime.now() + timedelta(seconds=seconds)).isoformat() def get_auth(self, auth_code: str = None) -> None: """Get the access token and refresh token @@ -138,33 +214,34 @@ def get_auth(self, auth_code: str = None) -> None: Raises: - JiraGetAuthResponseError: Failed to get the access token and refresh token - - JiraGetCloudIdNoResourcesError: No resources were found in Jira when getting the cloud id - - JiraGetCloudIdResponseError: Failed to get the cloud ID, response code did not match 200 - - JiraGetCloudIdError: Failed to get the cloud ID from Jira + - JiraGetCloudIDNoResourcesError: No resources were found in Jira when getting the cloud id + - JiraGetCloudIDResponseError: Failed to get the cloud ID, response code did not match 200 + - JiraGetCloudIDError: Failed to get the cloud ID from Jira - JiraAuthenticationError: Failed to authenticate - JiraRefreshTokenError: Failed to refresh the access token - JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match 200 - JiraGetAccessTokenError: Failed to get the access token """ try: - url = "https://auth.atlassian.com/oauth/token" body = { "grant_type": "authorization_code", "client_id": self.client_id, - "client_secret": self.client_secret, + "client_secret": self._client_secret, "code": auth_code, "redirect_uri": self.redirect_uri, } headers = {"Content-Type": "application/json"} - response = requests.post(url, json=body, headers=headers) + response = requests.post(self.TOKEN_URL, json=body, headers=headers) if response.status_code == 200: tokens = response.json() self._access_token = tokens.get("access_token") self._refresh_token = tokens.get("refresh_token") - self._auth_expiration = tokens.get("expires_in") - self._cloud_id = self.get_cloud_id(self.access_token) + self._auth_expiration = self.get_timestamp_from_seconds( + tokens.get("expires_in") + ) + self._cloud_id = self.get_cloud_id(self._access_token) else: response_error = ( f"Failed to get auth: {response.status_code} - {response.json()}" @@ -172,16 +249,17 @@ def get_auth(self, auth_code: str = None) -> None: raise JiraGetAuthResponseError( message=response_error, file=os.path.basename(__file__) ) - except JiraGetCloudIdNoResourcesError as no_resources_error: + except JiraGetCloudIDNoResourcesError as no_resources_error: raise no_resources_error - except JiraGetCloudIdResponseError as response_error: + except JiraGetCloudIDResponseError as response_error: raise response_error - except JiraGetCloudIdError as cloud_id_error: + except JiraGetCloudIDError as cloud_id_error: raise cloud_id_error except Exception as e: - logger.error(f"Failed to get auth: {e}") + message_error = f"Failed to get auth: {e}" + logger.error(message_error) raise JiraAuthenticationError( - message="Failed to authenticate with Jira", + message=message_error, file=os.path.basename(__file__), ) @@ -195,35 +273,38 @@ def get_cloud_id(self, access_token: str = None) -> str: - str: The cloud ID Raises: - - JiraGetCloudIdNoResourcesError: No resources were found in Jira when getting the cloud id - - JiraGetCloudIdResponseError: Failed to get the cloud ID, response code did not match 200 - - JiraGetCloudIdError: Failed to get the cloud ID from Jira + - JiraGetCloudIDNoResourcesError: No resources were found in Jira when getting the cloud id + - JiraGetCloudIDResponseError: Failed to get the cloud ID, response code did not match 200 + - JiraGetCloudIDError: Failed to get the cloud ID from Jira """ try: - url = "https://api.atlassian.com/oauth/token/accessible-resources" headers = {"Authorization": f"Bearer {access_token}"} - response = requests.get(url, headers=headers) + response = requests.get(self.API_TOKEN_URL, headers=headers) if response.status_code == 200: resources = response.json() if len(resources) > 0: return resources[0].get("id") else: - logger.error("No resources found") - raise JiraGetCloudIdNoResourcesError( - message="No resources were found in Jira when getting the cloud id", + error_message = ( + "No resources were found in Jira when getting the cloud id" + ) + logger.error(error_message) + raise JiraGetCloudIDNoResourcesError( + message=error_message, file=os.path.basename(__file__), ) else: response_error = f"Failed to get cloud id: {response.status_code} - {response.json()}" logger.warning(response_error) - raise JiraGetCloudIdResponseError( + raise JiraGetCloudIDResponseError( message=response_error, file=os.path.basename(__file__) ) except Exception as e: - logger.error(f"Failed to get cloud id: {e}") - raise JiraGetCloudIdError( - message="Failed to get the cloud ID from Jira", + error_message = f"Failed to get the cloud ID from Jira: {e}" + logger.error(error_message) + raise JiraGetCloudIDError( + message=error_message, file=os.path.basename(__file__), ) @@ -239,8 +320,10 @@ def get_access_token(self) -> str: - JiraGetAccessTokenError: Failed to get the access token """ try: - if self.auth_expiration and self.auth_expiration > 0: - return self.access_token + if self.auth_expiration and datetime.now() < datetime.fromisoformat( + self.auth_expiration + ): + return self._access_token else: return self.refresh_access_token() except JiraRefreshTokenError as refresh_error: @@ -269,8 +352,8 @@ def refresh_access_token(self) -> str: body = { "grant_type": "refresh_token", "client_id": self.client_id, - "client_secret": self.client_secret, - "refresh_token": self.refresh_token, + "client_secret": self._client_secret, + "refresh_token": self._refresh_token, } headers = {"Content-Type": "application/json"} @@ -280,8 +363,10 @@ def refresh_access_token(self) -> str: tokens = response.json() self._access_token = tokens.get("access_token") self._refresh_token = tokens.get("refresh_token") - self._auth_expiration = tokens.get("expires_in") - return self.access_token + self._auth_expiration = self.get_timestamp_from_seconds( + tokens.get("expires_in") + ) + return self._access_token else: response_error = f"Failed to refresh access token: {response.status_code} - {response.json()}" logger.warning(response_error) @@ -315,9 +400,9 @@ def test_connection( - Connection: The connection object Raises: - - JiraGetCloudIdNoResourcesError: No resources were found in Jira when getting the cloud id - - JiraGetCloudIdResponseError: Failed to get the cloud ID, response code did not match 200 - - JiraGetCloudIdError: Failed to get the cloud ID from Jira + - JiraGetCloudIDNoResourcesError: No resources were found in Jira when getting the cloud id + - JiraGetCloudIDResponseError: Failed to get the cloud ID, response code did not match 200 + - JiraGetCloudIDError: Failed to get the cloud ID from Jira - JiraAuthenticationError: Failed to authenticate - JiraTestConnectionError: Failed to test the connection """ @@ -342,21 +427,21 @@ def test_connection( return Connection(is_connected=True) else: return Connection(is_connected=False, error=response.json()) - except JiraGetCloudIdNoResourcesError as no_resources_error: + except JiraGetCloudIDNoResourcesError as no_resources_error: logger.error( f"{no_resources_error.__class__.__name__}[{no_resources_error.__traceback__.tb_lineno}]: {no_resources_error}" ) if raise_on_exception: raise no_resources_error return Connection(error=no_resources_error) - except JiraGetCloudIdResponseError as response_error: + except JiraGetCloudIDResponseError as response_error: logger.error( f"{response_error.__class__.__name__}[{response_error.__traceback__.tb_lineno}]: {response_error}" ) if raise_on_exception: raise response_error return Connection(error=response_error) - except JiraGetCloudIdError as cloud_id_error: + except JiraGetCloudIDError as cloud_id_error: logger.error( f"{cloud_id_error.__class__.__name__}[{cloud_id_error.__traceback__.tb_lineno}]: {cloud_id_error}" ) @@ -379,11 +464,11 @@ def test_connection( ) return Connection(is_connected=False, error=error) - def get_projects(self) -> list[dict]: + def get_projects(self) -> list[Dict[str, str]]: """Get the projects from Jira Returns: - - list[dict]: The projects following the format {"key": str, "name": str} + - list[Dict[str, str]]: The projects from Jira as a list of dictionaries, the projects format is [{"key": "KEY", "name": "NAME"}] Raises: - JiraNoProjectsError: No projects found in Jira @@ -460,7 +545,10 @@ def get_available_issue_types(self, project_key: str = None) -> list[str]: access_token = self.get_access_token() if not access_token: - return ValueError("Failed to get access token") + return JiraNoTokenError( + message="No token was found", + file=os.path.basename(__file__), + ) headers = {"Authorization": f"Bearer {access_token}"} response = requests.get( @@ -478,7 +566,6 @@ def get_available_issue_types(self, project_key: str = None) -> list[str]: issue_types = response.json()["projects"][0]["issuetypes"] return [issue_type["name"] for issue_type in issue_types] else: - # Must be replaced with proper error handling from custom exceptions response_error = f"Failed to get available issue types: {response.status_code} - {response.json()}" logger.warning(response_error) raise JiraGetAvailableIssueTypesResponseError( @@ -495,516 +582,543 @@ def get_available_issue_types(self, project_key: str = None) -> list[str]: file=os.path.basename(__file__), ) - def send_findings( - self, - findings: list[Finding] = None, - project_key: str = None, - issue_type: str = "Bug", - ): - """ - Send the findings to Jira + @staticmethod + def get_color_from_status(status: str) -> str: + """Get the color from the status Args: - - findings: The findings to send - - project_key: The project key - - issue_type: The issue type + - status: The status of the finding - Raises: - - JiraRefreshTokenError: Failed to refresh the access token - - JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match 200 - - JiraCreateIssueError: Failed to create an issue in Jira - - JiraSendFindingsResponseError: Failed to send the findings to Jira + Returns: + - str: The color of the status """ - try: - access_token = self.get_access_token() - - if not access_token: - return ValueError("Failed to get access token") - - available_issue_types = self.get_available_issue_types(project_key) + if status == "PASS": + return "#008000" + if status == "FAIL": + return "#FF0000" + if status == "MUTED": + return "#FFA500" - if issue_type not in available_issue_types: - logger.error("The issue type is invalid") - raise JiraInvalidIssueTypeError( - message="The issue type is invalid", file=os.path.basename(__file__) - ) - headers = { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - } - - for finding in findings: - if finding.status.value == "PASS": - status_color = "#008000" - else: - status_color = "#FF0000" - - adf_description = { - "type": "doc", - "version": 1, + @staticmethod + def get_adf_description( + check_id: str = None, + check_title: str = None, + severity: str = None, + status: str = None, + status_color: str = None, + status_extended: str = None, + provider: str = None, + region: str = None, + resource_uid: str = None, + resource_name: str = None, + risk: str = None, + recommendation_text: str = None, + recommendation_url: str = None, + ) -> dict: + return { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Prowler has discovered the following finding:", - } - ], - }, + "type": "text", + "text": "Prowler has discovered the following finding:", + } + ], + }, + { + "type": "table", + "attrs": {"layout": "full-width"}, + "content": [ { - "type": "table", - "attrs": {"layout": "full-width"}, + "type": "tableRow", "content": [ { - "type": "tableRow", + "type": "tableCell", + "attrs": {"colwidth": [1]}, "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [1]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Check Id", - "marks": [ - {"type": "strong"} - ], - } - ], + "type": "text", + "text": "Check Id", + "marks": [{"type": "strong"}], } ], - }, + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [3]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": finding.metadata.CheckID, - "marks": [{"type": "code"}], - } - ], + "type": "text", + "text": check_id, + "marks": [{"type": "code"}], } ], - }, + } ], }, + ], + }, + { + "type": "tableRow", + "content": [ { - "type": "tableRow", + "type": "tableCell", + "attrs": {"colwidth": [1]}, "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [1]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Check Title", - "marks": [ - {"type": "strong"} - ], - } - ], + "type": "text", + "text": "Check Title", + "marks": [{"type": "strong"}], } ], - }, + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [3]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": finding.metadata.CheckTitle, - } - ], + "type": "text", + "text": check_title, } ], - }, + } ], }, + ], + }, + { + "type": "tableRow", + "content": [ { - "type": "tableRow", + "type": "tableCell", + "attrs": {"colwidth": [1]}, "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [1]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Severity", - "marks": [ - {"type": "strong"} - ], - } - ], + "type": "text", + "text": "Severity", + "marks": [{"type": "strong"}], } ], - }, + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [3]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": finding.metadata.Severity.value.upper(), - } - ], + "type": "text", + "text": severity, } ], - }, + } ], }, + ], + }, + { + "type": "tableRow", + "content": [ { - "type": "tableRow", + "type": "tableCell", + "attrs": {"colwidth": [1]}, "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [1]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Status", - "marks": [ - {"type": "strong"} - ], - } - ], + "type": "text", + "text": "Status", + "marks": [{"type": "strong"}], } ], - }, + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [3]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ + "type": "text", + "text": status, + "marks": [ + {"type": "strong"}, { - "type": "text", - "text": finding.status.value, - "marks": [ - {"type": "strong"}, - { - "type": "textColor", - "attrs": { - "color": status_color - }, - }, - ], - } + "type": "textColor", + "attrs": { + "color": status_color + }, + }, ], } ], - }, + } ], }, + ], + }, + { + "type": "tableRow", + "content": [ { - "type": "tableRow", + "type": "tableCell", + "attrs": {"colwidth": [1]}, "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [1]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Status Extended", - "marks": [ - {"type": "strong"} - ], - } - ], + "type": "text", + "text": "Status Extended", + "marks": [{"type": "strong"}], } ], - }, + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [3]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": finding.status_extended, - } - ], + "type": "text", + "text": status_extended, } ], - }, + } ], }, + ], + }, + { + "type": "tableRow", + "content": [ { - "type": "tableRow", + "type": "tableCell", + "attrs": {"colwidth": [1]}, "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [1]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Provider", - "marks": [ - {"type": "strong"} - ], - } - ], + "type": "text", + "text": "Provider", + "marks": [{"type": "strong"}], } ], - }, + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [3]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": finding.metadata.Provider, - "marks": [{"type": "code"}], - } - ], + "type": "text", + "text": provider, + "marks": [{"type": "code"}], } ], - }, + } ], }, + ], + }, + { + "type": "tableRow", + "content": [ { - "type": "tableRow", + "type": "tableCell", + "attrs": {"colwidth": [1]}, "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [1]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Region", - "marks": [ - {"type": "strong"} - ], - } - ], + "type": "text", + "text": "Region", + "marks": [{"type": "strong"}], } ], - }, + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [3]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": finding.region, - "marks": [{"type": "code"}], - } - ], + "type": "text", + "text": region, + "marks": [{"type": "code"}], } ], - }, + } ], }, + ], + }, + { + "type": "tableRow", + "content": [ { - "type": "tableRow", + "type": "tableCell", + "attrs": {"colwidth": [1]}, "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [1]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Resource UID", - "marks": [ - {"type": "strong"} - ], - } - ], + "type": "text", + "text": "Resource UID", + "marks": [{"type": "strong"}], } ], - }, + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [3]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": finding.resource_uid, - "marks": [{"type": "code"}], - } - ], + "type": "text", + "text": resource_uid, + "marks": [{"type": "code"}], } ], - }, + } ], }, + ], + }, + { + "type": "tableRow", + "content": [ { - "type": "tableRow", + "type": "tableCell", + "attrs": {"colwidth": [1]}, "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [1]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Resource Name", - "marks": [ - {"type": "strong"} - ], - } - ], + "type": "text", + "text": "Resource Name", + "marks": [{"type": "strong"}], } ], - }, + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [3]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": finding.resource_name, - "marks": [{"type": "code"}], - } - ], + "type": "text", + "text": resource_name, + "marks": [{"type": "code"}], } ], - }, + } ], }, + ], + }, + { + "type": "tableRow", + "content": [ { - "type": "tableRow", + "type": "tableCell", + "attrs": {"colwidth": [1]}, "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [1]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Risk", - "marks": [ - {"type": "strong"} - ], - } - ], + "type": "text", + "text": "Risk", + "marks": [{"type": "strong"}], } ], - }, + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [3]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": finding.metadata.Risk, - } - ], + "type": "text", + "text": risk, } ], - }, + } ], }, + ], + }, + { + "type": "tableRow", + "content": [ { - "type": "tableRow", + "type": "tableCell", + "attrs": {"colwidth": [1]}, "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [1]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Recommendation", - "marks": [ - {"type": "strong"} - ], - } - ], + "type": "text", + "text": "Recommendation", + "marks": [{"type": "strong"}], } ], - }, + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ { - "type": "tableCell", - "attrs": {"colwidth": [3]}, + "type": "paragraph", "content": [ { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": finding.metadata.Remediation.Recommendation.Text - + " ", - }, + "type": "text", + "text": recommendation_text + " ", + }, + { + "type": "text", + "text": recommendation_url, + "marks": [ { - "type": "text", - "text": finding.metadata.Remediation.Recommendation.Url, - "marks": [ - { - "type": "link", - "attrs": { - "href": finding.metadata.Remediation.Recommendation.Url - }, - } - ], - }, + "type": "link", + "attrs": { + "href": recommendation_url + }, + } ], - } + }, ], - }, + } ], }, ], }, ], - } + }, + ], + } + + def send_findings( + self, + findings: list[Finding] = None, + project_key: str = None, + issue_type: str = "Bug", + ): + """ + Send the findings to Jira + + Args: + - findings: The findings to send + - project_key: The project key + - issue_type: The issue type + + Raises: + - JiraRefreshTokenError: Failed to refresh the access token + - JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match 200 + - JiraCreateIssueError: Failed to create an issue in Jira + - JiraSendFindingsResponseError: Failed to send the findings to Jira + """ + try: + access_token = self.get_access_token() + + if not access_token: + raise JiraNoTokenError( + message="No token was found", + file=os.path.basename(__file__), + ) + + available_issue_types = self.get_available_issue_types(project_key) + + if issue_type not in available_issue_types: + logger.error("The issue type is invalid") + raise JiraInvalidIssueTypeError( + message="The issue type is invalid", file=os.path.basename(__file__) + ) + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + findings = findings[:1] + + for finding in findings: + status_color = self.get_color_from_status(finding.status.value) + adf_description = self.get_adf_description( + check_id=finding.metadata.CheckID, + check_title=finding.metadata.CheckTitle, + severity=finding.metadata.Severity.value.upper(), + status=finding.status.value, + status_color=status_color, + status_extended=finding.status_extended, + provider=finding.metadata.Provider, + region=finding.region, + resource_uid=finding.resource_uid, + resource_name=finding.resource_name, + risk=finding.metadata.Risk, + recommendation_text=finding.metadata.Remediation.Recommendation.Text, + recommendation_url=finding.metadata.Remediation.Recommendation.Url, + ) payload = { "fields": { "project": {"key": project_key}, diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index c8eb18a9ea..d161c75ea3 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -1,19 +1,23 @@ +from datetime import datetime, timedelta from unittest.mock import MagicMock, PropertyMock, patch from urllib.parse import parse_qs, urlparse import pytest +from freezegun import freeze_time from prowler.lib.outputs.jira.exceptions.exceptions import ( JiraAuthenticationError, JiraCreateIssueError, JiraGetAvailableIssueTypesError, - JiraGetCloudIdError, + JiraGetCloudIDError, JiraGetProjectsError, JiraNoProjectsError, JiraRefreshTokenError, ) from prowler.lib.outputs.jira.jira import Jira +TEST_DATETIME = "2023-01-01T12:01:01+00:00" + class TestJiraIntegration: @pytest.fixture(autouse=True) @@ -64,6 +68,7 @@ def test_auth_code_url(self, mock_get_auth): assert query_params["response_type"][0] == "code" assert query_params["prompt"][0] == "consent" + @freeze_time(TEST_DATETIME) @patch("prowler.lib.outputs.jira.jira.requests.post") @patch.object(Jira, "get_cloud_id", return_value="test_cloud_id") def test_get_auth_successful(self, mock_get_cloud_id, mock_post): @@ -84,7 +89,10 @@ def test_get_auth_successful(self, mock_get_cloud_id, mock_post): assert self.jira_integration._access_token == "test_access_token" assert self.jira_integration._refresh_token == "test_refresh_token" - assert self.jira_integration._auth_expiration == 3600 + assert ( + self.jira_integration._auth_expiration + == (datetime.now() + timedelta(seconds=3600)).isoformat() + ) assert self.jira_integration._cloud_id == "test_cloud_id" @patch( @@ -114,26 +122,26 @@ def test_get_cloud_id_successful(self, mock_get): @patch("prowler.lib.outputs.jira.jira.requests.get") def test_get_cloud_id_no_resources(self, mock_get): - """Test get_cloud_id raises JiraGetCloudIdNoResourcesError when no resources are found, later JiraGetCloudIdError will be raised.""" + """Test get_cloud_id raises JiraGetCloudIDNoResourcesError when no resources are found, later JiraGetCloudIDError will be raised.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = [] mock_get.return_value = mock_response - with pytest.raises(JiraGetCloudIdError): + with pytest.raises(JiraGetCloudIDError): self.jira_integration.get_cloud_id("test_access_token") @patch("prowler.lib.outputs.jira.jira.requests.get") def test_get_cloud_id_response_error(self, mock_get): - """Test get_cloud_id raises JiraGetCloudIdResponseError when response code is not 200, later JiraGetCloudIdError will be raised.""" + """Test get_cloud_id raises JiraGetCloudIDResponseError when response code is not 200, later JiraGetCloudIDError will be raised.""" mock_response = MagicMock() mock_response.status_code = 404 mock_response.json.return_value = {"error": "Not Found"} mock_get.return_value = mock_response - with pytest.raises(JiraGetCloudIdError): + with pytest.raises(JiraGetCloudIDError): self.jira_integration.get_cloud_id("test_access_token") @patch( @@ -141,11 +149,11 @@ def test_get_cloud_id_response_error(self, mock_get): side_effect=Exception("Connection error"), ) def test_get_cloud_id_unexpected_error(self, mock_get): - """Test get_cloud_id raises JiraGetCloudIdError on an unexpected exception.""" + """Test get_cloud_id raises JiraGetCloudIDError on an unexpected exception.""" # To disable vulture mock_get = mock_get - with pytest.raises(JiraGetCloudIdError): + with pytest.raises(JiraGetCloudIDError): self.jira_integration.get_cloud_id("test_access_token") @patch.object(Jira, "refresh_access_token", return_value="new_access_token") @@ -158,13 +166,26 @@ def test_get_access_token_refresh(self, mock_refresh_access_token): assert access_token == "new_access_token" mock_refresh_access_token.assert_called_once() - def test_get_access_token_valid_token(self): - """Test get_access_token returns existing token if not expired.""" + @freeze_time(TEST_DATETIME) + @patch("prowler.lib.outputs.jira.jira.requests.post") + @patch.object(Jira, "get_cloud_id", return_value="test_cloud_id") + def test_get_access_token_valid_token(self, mock_get_cloud_id, mock_post): + """Test successful token retrieval in get_auth.""" + # To disable vulture + mock_get_cloud_id = mock_get_cloud_id - self.jira_integration.auth_expiration = 100 - self.jira_integration.access_token = "valid_access_token" - access_token = self.jira_integration.get_access_token() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "valid_access_token", + "refresh_token": "test_refresh_token", + "expires_in": 3600, + } + mock_post.return_value = mock_response + + self.jira_integration.get_auth("test_auth_code") + access_token = self.jira_integration.get_access_token() assert access_token == "valid_access_token" @patch.object(Jira, "refresh_access_token", side_effect=JiraRefreshTokenError) @@ -174,20 +195,24 @@ def test_get_access_token_refresh_error(self, mock_refresh_access_token): # To disable vulture mock_refresh_access_token = mock_refresh_access_token - self.jira_integration.auth_expiration = 0 + self.jira_integration.auth_expiration = ( + datetime.now() + timedelta(seconds=0) + ).isoformat() with pytest.raises(JiraRefreshTokenError): self.jira_integration.get_access_token() + @freeze_time(TEST_DATETIME) @patch("prowler.lib.outputs.jira.jira.requests.post") def test_refresh_access_token_successful(self, mock_post): """Test successful access token refresh in refresh_access_token.""" mock_response = MagicMock() mock_response.status_code = 200 + expires_in_value = 3600 mock_response.json.return_value = { "access_token": "new_access_token", "refresh_token": "new_refresh_token", - "expires_in": 3600, + "expires_in": expires_in_value, } mock_post.return_value = mock_response @@ -196,7 +221,10 @@ def test_refresh_access_token_successful(self, mock_post): assert new_access_token == "new_access_token" assert self.jira_integration._access_token == "new_access_token" assert self.jira_integration._refresh_token == "new_refresh_token" - assert self.jira_integration._auth_expiration == 3600 + assert ( + self.jira_integration._auth_expiration + == (datetime.now() + timedelta(seconds=expires_in_value)).isoformat() + ) @patch("prowler.lib.outputs.jira.jira.requests.post") def test_refresh_access_token_response_error(self, mock_post): From c4f6d8a5c95846560bb24a85cab843569bdc178d Mon Sep 17 00:00:00 2001 From: pedrooot Date: Fri, 8 Nov 2024 11:57:08 +0100 Subject: [PATCH 13/18] feat(jira): add docstrings --- prowler/lib/outputs/jira/jira.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 9b7d232029..d5db2ab3bc 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -46,6 +46,8 @@ class Jira: - _scopes: The scopes needed to authenticate, read:jira-user read:jira-work write:jira-work - AUTH_URL: The URL to authenticate with Jira - PARAMS_TEMPLATE: The template for the parameters to authenticate with Jira + - TOKEN_URL: The URL to get the access token from Jira + - API_TOKEN_URL: The URL to get the accessible resources from Jira Methods: - __init__: Initialize the Jira object From 28351012097666403cf4b0f6924afa3369400b40 Mon Sep 17 00:00:00 2001 From: pedrooot Date: Fri, 8 Nov 2024 12:11:34 +0100 Subject: [PATCH 14/18] feat(jira): resolve comments --- prowler/lib/outputs/jira/jira.py | 20 +++++++++----------- tests/lib/outputs/jira/jira_test.py | 4 ++-- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index d5db2ab3bc..486744fddc 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -41,7 +41,7 @@ class Jira: - _client_secret: The client secret - _access_token: The access token - _refresh_token: The refresh token - - _auth_expiration: The authentication expiration + - _expiration_date: The authentication expiration - _cloud_id: The cloud ID - _scopes: The scopes needed to authenticate, read:jira-user read:jira-work write:jira-work - AUTH_URL: The URL to authenticate with Jira @@ -95,7 +95,7 @@ class Jira: _client_secret: str = None _access_token: str = None _refresh_token: str = None - _auth_expiration: int = None + _expiration_date: int = None _cloud_id: str = None _scopes: list[str] = None AUTH_URL = "https://auth.atlassian.com/authorize" @@ -135,11 +135,11 @@ def client_id(self): @property def auth_expiration(self): - return self._auth_expiration + return self._expiration_date @auth_expiration.setter def auth_expiration(self, value): - self._auth_expiration = value + self._expiration_date = value @property def cloud_id(self): @@ -194,14 +194,14 @@ def auth_code_url(self) -> str: return f"{self.AUTH_URL}?{requests.compat.urlencode(params)}" @staticmethod - def get_timestamp_from_seconds(seconds: int) -> str: + def get_timestamp_from_seconds(seconds: int) -> datetime: """Get the timestamp adding the seconds to the current time Args: - seconds: The seconds to add to the current time Returns: - - str: The timestamp + - datetime: The timestamp with the seconds added """ return (datetime.now() + timedelta(seconds=seconds)).isoformat() @@ -240,7 +240,7 @@ def get_auth(self, auth_code: str = None) -> None: tokens = response.json() self._access_token = tokens.get("access_token") self._refresh_token = tokens.get("refresh_token") - self._auth_expiration = self.get_timestamp_from_seconds( + self._expiration_date = self.get_timestamp_from_seconds( tokens.get("expires_in") ) self._cloud_id = self.get_cloud_id(self._access_token) @@ -365,7 +365,7 @@ def refresh_access_token(self) -> str: tokens = response.json() self._access_token = tokens.get("access_token") self._refresh_token = tokens.get("refresh_token") - self._auth_expiration = self.get_timestamp_from_seconds( + self._expiration_date = self.get_timestamp_from_seconds( tokens.get("expires_in") ) return self._access_token @@ -1065,7 +1065,7 @@ def send_findings( self, findings: list[Finding] = None, project_key: str = None, - issue_type: str = "Bug", + issue_type: str = None, ): """ Send the findings to Jira @@ -1102,8 +1102,6 @@ def send_findings( "Content-Type": "application/json", } - findings = findings[:1] - for finding in findings: status_color = self.get_color_from_status(finding.status.value) adf_description = self.get_adf_description( diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index d161c75ea3..56368b1be8 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -90,7 +90,7 @@ def test_get_auth_successful(self, mock_get_cloud_id, mock_post): assert self.jira_integration._access_token == "test_access_token" assert self.jira_integration._refresh_token == "test_refresh_token" assert ( - self.jira_integration._auth_expiration + self.jira_integration._expiration_date == (datetime.now() + timedelta(seconds=3600)).isoformat() ) assert self.jira_integration._cloud_id == "test_cloud_id" @@ -222,7 +222,7 @@ def test_refresh_access_token_successful(self, mock_post): assert self.jira_integration._access_token == "new_access_token" assert self.jira_integration._refresh_token == "new_refresh_token" assert ( - self.jira_integration._auth_expiration + self.jira_integration._expiration_date == (datetime.now() + timedelta(seconds=expires_in_value)).isoformat() ) From c973636c377fda82baeaafd3786a453978af165f Mon Sep 17 00:00:00 2001 From: pedrooot Date: Fri, 8 Nov 2024 12:40:27 +0100 Subject: [PATCH 15/18] feat(jira): resolve comments --- .../lib/outputs/jira/exceptions/exceptions.py | 11 +++++++++ prowler/lib/outputs/jira/jira.py | 21 +++++++++++----- tests/lib/outputs/jira/jira_test.py | 24 ++++++++++++------- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/prowler/lib/outputs/jira/exceptions/exceptions.py b/prowler/lib/outputs/jira/exceptions/exceptions.py index 4a14b5f8df..a8267ffba6 100644 --- a/prowler/lib/outputs/jira/exceptions/exceptions.py +++ b/prowler/lib/outputs/jira/exceptions/exceptions.py @@ -78,6 +78,10 @@ class JiraBaseException(ProwlerException): "message": "No token was found.", "remediation": "Make sure the token is set when using the Jira integration.", }, + (9018, "JiraInvalidProjectKeyError"): { + "message": "The project key is invalid.", + "remediation": "Please check the project key and try again.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -218,3 +222,10 @@ def __init__(self, file=None, original_exception=None, message=None): super().__init__( 9017, file=file, original_exception=original_exception, message=message ) + + +class JiraInvalidProjectKeyError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 9018, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 486744fddc..100ed0d6d6 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -21,6 +21,7 @@ JiraGetProjectsError, JiraGetProjectsResponseError, JiraInvalidIssueTypeError, + JiraInvalidProjectKeyError, JiraNoProjectsError, JiraNoTokenError, JiraRefreshTokenError, @@ -466,7 +467,7 @@ def test_connection( ) return Connection(is_connected=False, error=error) - def get_projects(self) -> list[Dict[str, str]]: + def get_projects(self) -> Dict[str, str]: """Get the projects from Jira Returns: @@ -492,11 +493,10 @@ def get_projects(self) -> list[Dict[str, str]]: ) if response.status_code == 200: - # Return the Project Key and Name - projects = [ - {"key": project.get("key"), "name": project.get("name")} - for project in response.json() - ] + # Return the Project Key and Name, using only a dictionary + projects = { + project["key"]: project["name"] for project in response.json() + } if len(projects) == 0: logger.error("No projects found") raise JiraNoProjectsError( @@ -1090,6 +1090,15 @@ def send_findings( file=os.path.basename(__file__), ) + projects = self.get_projects() + + if project_key not in projects: + logger.error("The project key is invalid") + raise JiraInvalidProjectKeyError( + message="The project key is invalid", + file=os.path.basename(__file__), + ) + available_issue_types = self.get_available_issue_types(project_key) if issue_type not in available_issue_types: diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index 56368b1be8..bee44311e2 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -315,11 +315,7 @@ def test_get_projects_successful( mock_get.return_value = mock_response projects = self.jira_integration.get_projects() - - expected_projects = [ - {"key": "PROJ1", "name": "Project One"}, - {"key": "PROJ2", "name": "Project Two"}, - ] + expected_projects = {"PROJ1": "Project One", "PROJ2": "Project Two"} assert projects == expected_projects @patch.object(Jira, "get_access_token", return_value="valid_access_token") @@ -471,14 +467,20 @@ def test_get_available_issue_types_refresh_token_error(self, mock_get_access_tok @patch.object( Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"] ) + @patch.object(Jira, "get_projects", return_value={"TEST-1": "Test Project"}) @patch("prowler.lib.outputs.jira.jira.requests.post") def test_send_findings_successful( - self, mock_post, mock_get_available_issue_types, mock_get_access_token + self, + mock_post, + mock_get_available_issue_types, + mock_get_projects, + mock_get_access_token, ): """Test successful sending of findings to Jira.""" # To disable vulture mock_get_available_issue_types = mock_get_available_issue_types mock_get_access_token = mock_get_access_token + mock_get_projects = mock_get_projects mock_response = MagicMock() mock_response.status_code = 201 @@ -502,7 +504,7 @@ def test_send_findings_successful( self.jira_integration.cloud_id = "valid_cloud_id" self.jira_integration.send_findings( - findings=[finding], project_key="TEST", issue_type="Bug" + findings=[finding], project_key="TEST-1", issue_type="Bug" ) expected_url = ( @@ -510,7 +512,7 @@ def test_send_findings_successful( ) expected_json = { "fields": { - "project": {"key": "TEST"}, + "project": {"key": "TEST-1"}, "summary": "[Prowler] HIGH - CHECK-1 - resource-1", "description": { "type": "doc", @@ -1029,3 +1031,9 @@ def test_send_findings_response_error( self.jira_integration.send_findings( findings=[finding], project_key="TEST", issue_type="Bug" ) + + def test_get_color_from_status(self): + """Test that get_color_from_status returns the correct color for a status.""" + assert self.jira_integration.get_color_from_status("FAIL") == "#FF0000" + assert self.jira_integration.get_color_from_status("PASS") == "#008000" + assert self.jira_integration.get_color_from_status("MUTED") == "#FFA500" From 08e9d4963959582fc150e8f8a74df36cf1a7a048 Mon Sep 17 00:00:00 2001 From: pedrooot Date: Fri, 8 Nov 2024 12:44:32 +0100 Subject: [PATCH 16/18] feat(jira): resolve comments --- tests/lib/outputs/jira/jira_test.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index bee44311e2..238f433894 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -1032,8 +1032,10 @@ def test_send_findings_response_error( findings=[finding], project_key="TEST", issue_type="Bug" ) - def test_get_color_from_status(self): + @pytest.mark.parametrize( + "status, expected_color", + [("FAIL", "#FF0000"), ("PASS", "#008000"), ("MUTED", "#FFA500")], + ) + def test_get_color_from_status(self, status, expected_color): """Test that get_color_from_status returns the correct color for a status.""" - assert self.jira_integration.get_color_from_status("FAIL") == "#FF0000" - assert self.jira_integration.get_color_from_status("PASS") == "#008000" - assert self.jira_integration.get_color_from_status("MUTED") == "#FFA500" + assert self.jira_integration.get_color_from_status(status) == expected_color From f3a03803ab35e66f120d7fe183a99c95997912bc Mon Sep 17 00:00:00 2001 From: pedrooot Date: Mon, 11 Nov 2024 13:03:53 +0100 Subject: [PATCH 17/18] feat(jira): add notes about class usage --- prowler/lib/outputs/jira/jira.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 100ed0d6d6..324904f1e6 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -36,6 +36,9 @@ class Jira: """ Jira class to interact with the Jira API + [Note] + This feature is delimited to the Jira Cloud ID, for now the tickets will be created for the default cloud id. + Attributes: - _redirect_uri: The redirect URI - _client_id: The client ID From a207aabcdb4a90cb81cd12e7381a4234f49d1b5f Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Mon, 11 Nov 2024 13:13:43 +0100 Subject: [PATCH 18/18] doc: add more info to the TODO --- prowler/lib/outputs/jira/jira.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 324904f1e6..34629b7e42 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -37,7 +37,7 @@ class Jira: Jira class to interact with the Jira API [Note] - This feature is delimited to the Jira Cloud ID, for now the tickets will be created for the default cloud id. + This integration is limited to a single Jira Cloud, therefore all the issues will be created for same Jira Cloud ID. We will need to work on the ability of providing a Jira Cloud ID if the user is present in more than one. Attributes: - _redirect_uri: The redirect URI