Compare commits

...

4 Commits

Author SHA1 Message Date
Prowler Bot
1b45724ca8 chore(api): Update prowler dependency to v5.24 for release 5.24.0 (#10709)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-04-15 18:57:37 +02:00
Pepe Fagoaga
ba5b23245f chore: review changelog for v5.24 (#10707) 2026-04-15 18:05:55 +02:00
Daniel Barranquero
43913b1592 feat(aws): support excluding regions from scans via CLI, env var, and config (#10688) 2026-04-15 17:59:46 +02:00
Alan Buscaglia
9e31160887 fix(ui): improve attack paths scan table UX and fix info banner variant (#10704)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-04-15 17:33:29 +02:00
18 changed files with 636 additions and 358 deletions

1
.gitignore vendored
View File

@@ -84,6 +84,7 @@ continue.json
.continuerc.json
# AI Coding Assistants - OpenCode
.opencode/
opencode.json
# AI Coding Assistants - GitHub Copilot

View File

@@ -2,7 +2,7 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.25.0] (Prowler UNRELEASED)
## [1.25.0] (Prowler v5.24.0)
### 🔄 Changed

166
api/poetry.lock generated
View File

@@ -682,21 +682,21 @@ requests = ">=2.21.0,<3.0.0"
[[package]]
name = "alibabacloud-tea-openapi"
version = "0.4.1"
version = "0.4.4"
description = "Alibaba Cloud openapi SDK Library for Python"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "alibabacloud_tea_openapi-0.4.1-py3-none-any.whl", hash = "sha256:e46bfa3ca34086d2c357d217a0b7284ecbd4b3bab5c88e075e73aec637b0e4a0"},
{file = "alibabacloud_tea_openapi-0.4.1.tar.gz", hash = "sha256:2384b090870fdb089c3c40f3fb8cf0145b8c7d6c14abbac521f86a01abb5edaf"},
{file = "alibabacloud_tea_openapi-0.4.4-py3-none-any.whl", hash = "sha256:cea6bc1fe35b0319a8752cb99eb0ecb0dab7ca1a71b99c12970ba0867410995f"},
{file = "alibabacloud_tea_openapi-0.4.4.tar.gz", hash = "sha256:1b0917bc03cd49417da64945e92731716d53e2eb8707b235f54e45b7473221ce"},
]
[package.dependencies]
alibabacloud-credentials = ">=1.0.2,<2.0.0"
alibabacloud-gateway-spi = ">=0.0.2,<1.0.0"
alibabacloud-tea-util = ">=0.3.13,<1.0.0"
cryptography = ">=3.0.0,<45.0.0"
cryptography = {version = ">=3.0.0,<47.0.0", markers = "python_version >= \"3.8\""}
darabonba-core = ">=1.0.3,<2.0.0"
[[package]]
@@ -2503,62 +2503,74 @@ dev = ["bandit", "coverage", "flake8", "pydocstyle", "pylint", "pytest", "pytest
[[package]]
name = "cryptography"
version = "44.0.3"
version = "46.0.6"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.7"
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
groups = ["main", "dev"]
files = [
{file = "cryptography-44.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:962bc30480a08d133e631e8dfd4783ab71cc9e33d5d7c1e192f0b7c06397bb88"},
{file = "cryptography-44.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffc61e8f3bf5b60346d89cd3d37231019c17a081208dfbbd6e1605ba03fa137"},
{file = "cryptography-44.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58968d331425a6f9eedcee087f77fd3c927c88f55368f43ff7e0a19891f2642c"},
{file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e28d62e59a4dbd1d22e747f57d4f00c459af22181f0b2f787ea83f5a876d7c76"},
{file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af653022a0c25ef2e3ffb2c673a50e5a0d02fecc41608f4954176f1933b12359"},
{file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43"},
{file = "cryptography-44.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:c6cd67722619e4d55fdb42ead64ed8843d64638e9c07f4011163e46bc512cf01"},
{file = "cryptography-44.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b424563394c369a804ecbee9b06dfb34997f19d00b3518e39f83a5642618397d"},
{file = "cryptography-44.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c91fc8e8fd78af553f98bc7f2a1d8db977334e4eea302a4bfd75b9461c2d8904"},
{file = "cryptography-44.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:25cd194c39fa5a0aa4169125ee27d1172097857b27109a45fadc59653ec06f44"},
{file = "cryptography-44.0.3-cp37-abi3-win32.whl", hash = "sha256:3be3f649d91cb182c3a6bd336de8b61a0a71965bd13d1a04a0e15b39c3d5809d"},
{file = "cryptography-44.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:3883076d5c4cc56dbef0b898a74eb6992fdac29a7b9013870b34efe4ddb39a0d"},
{file = "cryptography-44.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:5639c2b16764c6f76eedf722dbad9a0914960d3489c0cc38694ddf9464f1bb2f"},
{file = "cryptography-44.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ffef566ac88f75967d7abd852ed5f182da252d23fac11b4766da3957766759"},
{file = "cryptography-44.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:192ed30fac1728f7587c6f4613c29c584abdc565d7417c13904708db10206645"},
{file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7d5fe7195c27c32a64955740b949070f21cba664604291c298518d2e255931d2"},
{file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f07943aa4d7dad689e3bb1638ddc4944cc5e0921e3c227486daae0e31a05e54"},
{file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb90f60e03d563ca2445099edf605c16ed1d5b15182d21831f58460c48bffb93"},
{file = "cryptography-44.0.3-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ab0b005721cc0039e885ac3503825661bd9810b15d4f374e473f8c89b7d5460c"},
{file = "cryptography-44.0.3-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bb0847e6363c037df8f6ede57d88eaf3410ca2267fb12275370a76f85786a6f"},
{file = "cryptography-44.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0cc66c74c797e1db750aaa842ad5b8b78e14805a9b5d1348dc603612d3e3ff5"},
{file = "cryptography-44.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6866df152b581f9429020320e5eb9794c8780e90f7ccb021940d7f50ee00ae0b"},
{file = "cryptography-44.0.3-cp39-abi3-win32.whl", hash = "sha256:c138abae3a12a94c75c10499f1cbae81294a6f983b3af066390adee73f433028"},
{file = "cryptography-44.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334"},
{file = "cryptography-44.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:cad399780053fb383dc067475135e41c9fe7d901a97dd5d9c5dfb5611afc0d7d"},
{file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:21a83f6f35b9cc656d71b5de8d519f566df01e660ac2578805ab245ffd8523f8"},
{file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4"},
{file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:e909df4053064a97f1e6565153ff8bb389af12c5c8d29c343308760890560aff"},
{file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dad80b45c22e05b259e33ddd458e9e2ba099c86ccf4e88db7bbab4b747b18d06"},
{file = "cryptography-44.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:479d92908277bed6e1a1c69b277734a7771c2b78633c224445b5c60a9f4bc1d9"},
{file = "cryptography-44.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:896530bc9107b226f265effa7ef3f21270f18a2026bc09fed1ebd7b66ddf6375"},
{file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9b4d4a5dbee05a2c390bf212e78b99434efec37b17a4bff42f50285c5c8c9647"},
{file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259"},
{file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dd3db61b8fe5be220eee484a17233287d0be6932d056cf5738225b9c05ef4fff"},
{file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:978631ec51a6bbc0b7e58f23b68a8ce9e5f09721940933e9c217068388789fe5"},
{file = "cryptography-44.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c"},
{file = "cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053"},
{file = "cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19"},
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738"},
{file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c"},
{file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f"},
{file = "cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2"},
{file = "cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124"},
{file = "cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4"},
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a"},
{file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d"},
{file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736"},
{file = "cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed"},
{file = "cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4"},
{file = "cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa"},
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58"},
{file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb"},
{file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72"},
{file = "cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c"},
{file = "cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f"},
{file = "cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead"},
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8"},
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0"},
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b"},
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a"},
{file = "cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e"},
{file = "cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759"},
]
[package.dependencies]
cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""}
cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""}
[package.extras]
docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""]
docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"]
docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"]
nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""]
pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"]
nox = ["nox[uv] (>=2024.4.15)"]
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi (>=2024)", "cryptography-vectors (==44.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.6)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
[[package]]
@@ -3740,19 +3752,19 @@ urllib3 = ["packaging", "urllib3"]
[[package]]
name = "google-auth-httplib2"
version = "0.2.1"
version = "0.2.0"
description = "Google Authentication Library: httplib2 transport"
optional = false
python-versions = ">=3.7"
python-versions = "*"
groups = ["main"]
files = [
{file = "google_auth_httplib2-0.2.1-py3-none-any.whl", hash = "sha256:1be94c611db91c01f9703e7f62b0a59bbd5587a95571c7b6fade510d648bc08b"},
{file = "google_auth_httplib2-0.2.1.tar.gz", hash = "sha256:5ef03be3927423c87fb69607b42df23a444e434ddb2555b73b3679793187b7de"},
{file = "google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05"},
{file = "google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d"},
]
[package.dependencies]
google-auth = ">=1.32.0,<3.0.0"
httplib2 = ">=0.19.0,<1.0.0"
google-auth = "*"
httplib2 = ">=0.19.0"
[[package]]
name = "google-cloud-access-context-manager"
@@ -5925,23 +5937,24 @@ signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
[[package]]
name = "oci"
version = "2.160.3"
version = "2.169.0"
description = "Oracle Cloud Infrastructure Python SDK"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "oci-2.160.3-py3-none-any.whl", hash = "sha256:858bff3e697098bdda44833d2476bfb4632126f0182178e7dbde4dbd156d71f0"},
{file = "oci-2.160.3.tar.gz", hash = "sha256:57514889be3b713a8385d86e3ba8a33cf46e3563c2a7e29a93027fb30b8a2537"},
{file = "oci-2.169.0-py3-none-any.whl", hash = "sha256:c71bb5143f307791082b3e33cc1545c2490a518cfed85ab1948ef5107c36d30b"},
{file = "oci-2.169.0.tar.gz", hash = "sha256:f3c5fff00b01783b5325ea7b13bf140053ec1e9f41da20bfb9c8a349ee7662fa"},
]
[package.dependencies]
certifi = "*"
circuitbreaker = {version = ">=1.3.1,<3.0.0", markers = "python_version >= \"3.7\""}
cryptography = ">=3.2.1,<46.0.0"
pyOpenSSL = ">=17.5.0,<25.0.0"
cryptography = ">=3.2.1,<47.0.0"
pyOpenSSL = ">=17.5.0,<27.0.0"
python-dateutil = ">=2.5.3,<3.0.0"
pytz = ">=2016.10"
urllib3 = {version = ">=2.6.3", markers = "python_version >= \"3.10.0\""}
[package.extras]
adk = ["docstring-parser (>=0.16) ; python_version >= \"3.10\" and python_version < \"4\"", "mcp (>=1.6.0) ; python_version >= \"3.10\" and python_version < \"4\"", "pydantic (>=2.10.6) ; python_version >= \"3.10\" and python_version < \"4\"", "rich (>=13.9.4) ; python_version >= \"3.10\" and python_version < \"4\""]
@@ -6659,7 +6672,7 @@ files = [
[[package]]
name = "prowler"
version = "5.23.0"
version = "5.24.0"
description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks."
optional = false
python-versions = ">=3.10,<3.13"
@@ -6679,7 +6692,7 @@ alibabacloud-rds20140815 = "12.0.0"
alibabacloud_sas20181203 = "6.1.0"
alibabacloud-sls20201230 = "5.9.0"
alibabacloud_sts20150401 = "1.1.6"
alibabacloud_tea_openapi = "0.4.1"
alibabacloud_tea_openapi = "0.4.4"
alibabacloud_vpc20160428 = "6.13.0"
alive-progress = "3.3.0"
awsipranges = "0.3.3"
@@ -6714,14 +6727,14 @@ boto3 = "1.40.61"
botocore = "1.40.61"
cloudflare = "4.3.1"
colorama = "0.4.6"
cryptography = "44.0.3"
cryptography = "46.0.6"
dash = "3.1.1"
dash-bootstrap-components = "2.0.3"
defusedxml = ">=0.7.1"
defusedxml = "0.7.1"
detect-secrets = "1.5.0"
dulwich = "0.23.0"
google-api-python-client = "2.163.0"
google-auth-httplib2 = ">=0.1,<0.3"
google-auth-httplib2 = "0.2.0"
h2 = "4.3.0"
jsonschema = "4.23.0"
kubernetes = "32.0.1"
@@ -6729,14 +6742,14 @@ markdown = "3.10.2"
microsoft-kiota-abstractions = "1.9.2"
msgraph-sdk = "1.23.0"
numpy = "2.0.2"
oci = "2.160.3"
oci = "2.169.0"
openstacksdk = "4.2.0"
pandas = "2.2.3"
py-iam-expand = "0.1.0"
py-ocsf-models = "0.8.1"
pydantic = ">=2.0,<3.0"
pydantic = "2.12.5"
pygithub = "2.8.0"
python-dateutil = ">=2.9.0.post0,<3.0.0"
python-dateutil = "2.9.0.post0"
pytz = "2025.1"
schema = "0.7.5"
shodan = "1.31.0"
@@ -6748,8 +6761,8 @@ uuid6 = "2024.7.10"
[package.source]
type = "git"
url = "https://github.com/prowler-cloud/prowler.git"
reference = "master"
resolved_reference = "6ac90eb1b58590b6f2f51645dbef17b9231053f4"
reference = "v5.24"
resolved_reference = "ba5b23245f4805f46d67e67fc059aefd6831f7b3"
[[package]]
name = "psutil"
@@ -6958,11 +6971,11 @@ description = "C parser in Python"
optional = false
python-versions = ">=3.10"
groups = ["main", "dev"]
markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
files = [
{file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
{file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"},
]
markers = {main = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\"", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""}
[[package]]
name = "pydantic"
@@ -7288,18 +7301,19 @@ tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", "
[[package]]
name = "pyopenssl"
version = "24.3.0"
version = "26.0.0"
description = "Python wrapper module around the OpenSSL library"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pyOpenSSL-24.3.0-py3-none-any.whl", hash = "sha256:e474f5a473cd7f92221cc04976e48f4d11502804657a08a989fb3be5514c904a"},
{file = "pyopenssl-24.3.0.tar.gz", hash = "sha256:49f7a019577d834746bc55c5fce6ecbcec0f2b4ec5ce1cf43a9a173b8138bb36"},
{file = "pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81"},
{file = "pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc"},
]
[package.dependencies]
cryptography = ">=41.0.5,<45"
cryptography = ">=46.0.0,<47"
typing-extensions = {version = ">=4.9", markers = "python_version < \"3.13\" and python_version >= \"3.8\""}
[package.extras]
docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"]
@@ -9400,4 +9414,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.13"
content-hash = "077e89853cfe3a6d934841488cfa5a98ff6c92b71f74b817b71387d11559f143"
content-hash = "44caea5e040c54d4c726144d644e67c942b5acf7e316b1fcb2c22b0947614fbe"

View File

@@ -25,7 +25,7 @@ dependencies = [
"defusedxml==0.7.1",
"gunicorn==23.0.0",
"lxml==5.3.2",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.24",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",

View File

@@ -33,6 +33,41 @@ To scan a particular AWS region with Prowler, use:
prowler aws -f/--region eu-west-1 us-east-1
```
### Excluding Specific Regions
To scan all supported AWS regions except a specific subset, use the `--excluded-region` flag:
```console
prowler aws --excluded-region eu-west-1 me-south-1
```
You can also configure the exclusion list with the `PROWLER_AWS_DISALLOWED_REGIONS` environment variable as a comma-separated list:
```console
export PROWLER_AWS_DISALLOWED_REGIONS="eu-west-1,me-south-1"
prowler aws
```
Or with the AWS provider configuration in `config.yaml`:
```yaml
aws:
disallowed_regions:
- eu-west-1
- me-south-1
```
When more than one source is set, precedence is:
1. `--excluded-region`
2. `PROWLER_AWS_DISALLOWED_REGIONS`
3. `aws.disallowed_regions` in `config.yaml`
<Note>
For self-hosted App or API-triggered scans, set `PROWLER_AWS_DISALLOWED_REGIONS` in the runtime environment of the backend scan containers such as `api` and `worker`. The `ui` container does not enforce AWS region selection.
</Note>
### AWS Credentials Configuration
For details on configuring AWS credentials, refer to the following [Botocore](https://github.com/boto/botocore) [file](https://github.com/boto/botocore/blob/22a19ea7c4c2c4dd7df4ab8c32733cba0c7597a4/botocore/data/partitions.json).

View File

@@ -2,7 +2,7 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.24.0] (Prowler UNRELEASED)
## [5.24.0] (Prowler v5.24.0)
### 🚀 Added
@@ -13,10 +13,11 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `iam_role_access_not_stale_to_bedrock` and `iam_user_access_not_stale_to_bedrock` checks for AWS provider [(#10536)](https://github.com/prowler-cloud/prowler/pull/10536)
- `iam_policy_no_wildcard_marketplace_subscribe` and `iam_inline_policy_no_wildcard_marketplace_subscribe` checks for AWS provider [(#10525)](https://github.com/prowler-cloud/prowler/pull/10525)
- `bedrock_vpc_endpoints_configured` check for AWS provider [(#10591)](https://github.com/prowler-cloud/prowler/pull/10591)
- `exchange_organization_delicensing_resiliency_enabled` check for m365 provider [(#10608)](https://github.com/prowler-cloud/prowler/pull/10608)
- `exchange_organization_delicensing_resiliency_enabled` check for M365 provider [(#10608)](https://github.com/prowler-cloud/prowler/pull/10608)
- `entra_conditional_access_policy_mfa_enforced_for_guest_users` check for M365 provider [(#10616)](https://github.com/prowler-cloud/prowler/pull/10616)
- `entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced` check for m365 provider [(#10618)](https://github.com/prowler-cloud/prowler/pull/10618)
- `entra_conditional_access_policy_block_unknown_device_platforms` check for m365 provider [(#10615)](https://github.com/prowler-cloud/prowler/pull/10615)
- `entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced` check for M365 provider [(#10618)](https://github.com/prowler-cloud/prowler/pull/10618)
- `entra_conditional_access_policy_block_unknown_device_platforms` check for M365 provider [(#10615)](https://github.com/prowler-cloud/prowler/pull/10615)
- `--excluded-region` CLI flag, `PROWLER_AWS_DISALLOWED_REGIONS` environment variable, and `aws.disallowed_regions` config entry to skip specific AWS regions during scans [(#10688)](https://github.com/prowler-cloud/prowler/pull/10688)
### 🔄 Changed
@@ -24,6 +25,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Normalize Conditional Access platform values in Entra models and simplify platform-based checks [(#10635)](https://github.com/prowler-cloud/prowler/pull/10635)
### 🐞 Fixed
- Vercel firewall config handling for team-scoped projects and current API response shapes [(#10695)](https://github.com/prowler-cloud/prowler/pull/10695)
---
@@ -790,7 +792,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- S3 `test_connection` uses AWS S3 API `HeadBucket` instead of `GetBucketLocation` [(#8456)](https://github.com/prowler-cloud/prowler/pull/8456)
- Add more validations to Azure Storage models when some values are None to avoid serialization issues [(#8325)](https://github.com/prowler-cloud/prowler/pull/8325)
- `sns_topics_not_publicly_accessible` false positive with `aws:SourceArn` conditions [(#8326)](https://github.com/prowler-cloud/prowler/issues/8326)
- Remove typo from description req 1.2.3 - Prowler ThreatScore m365 [(#8384)](https://github.com/prowler-cloud/prowler/pull/8384)
- Remove typo from description req 1.2.3 - Prowler ThreatScore M365 [(#8384)](https://github.com/prowler-cloud/prowler/pull/8384)
- Way of counting FAILED/PASS reqs from `kisa_isms_p_2023_aws` table [(#8382)](https://github.com/prowler-cloud/prowler/pull/8382)
- Use default tenant domain instead of first domain in list for Azure and M365 providers [(#8402)](https://github.com/prowler-cloud/prowler/pull/8402)
- Avoid multiple module error calls in M365 provider [(#8353)](https://github.com/prowler-cloud/prowler/pull/8353)
@@ -831,7 +833,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Title & description wording for `iam_user_accesskey_unused` check for AWS provider [(#8233)](https://github.com/prowler-cloud/prowler/pull/8233)
- Add GitHub provider to lateral panel in documentation and change -h environment variable output [(#8246)](https://github.com/prowler-cloud/prowler/pull/8246)
- Show `m365_identity_type` and `m365_identity_id` in cloud reports [(#8247)](https://github.com/prowler-cloud/prowler/pull/8247)
- Show `M365_identity_type` and `M365_identity_id` in cloud reports [(#8247)](https://github.com/prowler-cloud/prowler/pull/8247)
- Ensure `is_service_role` only returns `True` for service roles [(#8274)](https://github.com/prowler-cloud/prowler/pull/8274)
- Update DynamoDB check metadata to fix broken link [(#8273)](https://github.com/prowler-cloud/prowler/pull/8273)
- Show correct count of findings in Dashboard Security Posture page [(#8270)](https://github.com/prowler-cloud/prowler/pull/8270)
@@ -953,9 +955,9 @@ All notable changes to the **Prowler SDK** are documented in this file.
### Fixed
- `m365_powershell test_credentials` to use sanitized credentials [(#7761)](https://github.com/prowler-cloud/prowler/pull/7761)
- `M365_powershell test_credentials` to use sanitized credentials [(#7761)](https://github.com/prowler-cloud/prowler/pull/7761)
- `admincenter_users_admins_reduced_license_footprint` check logic to pass when admin user has no license [(#7779)](https://github.com/prowler-cloud/prowler/pull/7779)
- `m365_powershell` to close the PowerShell sessions in msgraph services [(#7816)](https://github.com/prowler-cloud/prowler/pull/7816)
- `M365_powershell` to close the PowerShell sessions in msgraph services [(#7816)](https://github.com/prowler-cloud/prowler/pull/7816)
- `defender_ensure_notify_alerts_severity_is_high`check to accept high or lower severity [(#7862)](https://github.com/prowler-cloud/prowler/pull/7862)
- Replace `Directory.Read.All` permission with `Domain.Read.All` which is more restrictive [(#7888)](https://github.com/prowler-cloud/prowler/pull/7888)
- Split calls to list Azure Functions attributes [(#7778)](https://github.com/prowler-cloud/prowler/pull/7778)
@@ -1029,7 +1031,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- New check `teams_meeting_chat_anonymous_users_disabled` [(#7579)](https://github.com/prowler-cloud/prowler/pull/7579)
- Prowler Threat Score Compliance Framework [(#7603)](https://github.com/prowler-cloud/prowler/pull/7603)
- Documentation for M365 provider [(#7622)](https://github.com/prowler-cloud/prowler/pull/7622)
- Support for m365 provider in Prowler Dashboard [(#7633)](https://github.com/prowler-cloud/prowler/pull/7633)
- Support for M365 provider in Prowler Dashboard [(#7633)](https://github.com/prowler-cloud/prowler/pull/7633)
- New check for Modern Authentication enabled for Exchange Online in M365 [(#7636)](https://github.com/prowler-cloud/prowler/pull/7636)
- New check `sharepoint_onedrive_sync_restricted_unmanaged_devices` [(#7589)](https://github.com/prowler-cloud/prowler/pull/7589)
- New check for Additional Storage restricted for Exchange in M365 [(#7638)](https://github.com/prowler-cloud/prowler/pull/7638)
@@ -1039,7 +1041,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- New check for MailTips full enabled for Exchange in M365 [(#7637)](https://github.com/prowler-cloud/prowler/pull/7637)
- New check for Comprehensive Attachments Filter Applied for Defender in M365 [(#7661)](https://github.com/prowler-cloud/prowler/pull/7661)
- Modified check `exchange_mailbox_properties_auditing_enabled` to make it configurable [(#7662)](https://github.com/prowler-cloud/prowler/pull/7662)
- snapshots to m365 documentation [(#7673)](https://github.com/prowler-cloud/prowler/pull/7673)
- snapshots to M365 documentation [(#7673)](https://github.com/prowler-cloud/prowler/pull/7673)
- support for static credentials for sending findings to Amazon S3 and AWS Security Hub [(#7322)](https://github.com/prowler-cloud/prowler/pull/7322)
- Prowler ThreatScore for M365 provider [(#7692)](https://github.com/prowler-cloud/prowler/pull/7692)
- Microsoft User and User Credential auth to reports [(#7681)](https://github.com/prowler-cloud/prowler/pull/7681)

View File

@@ -69,11 +69,11 @@ from prowler.lib.outputs.compliance.cis.cis_gcp import GCPCIS
from prowler.lib.outputs.compliance.cis.cis_github import GithubCIS
from prowler.lib.outputs.compliance.cis.cis_googleworkspace import GoogleWorkspaceCIS
from prowler.lib.outputs.compliance.cis.cis_kubernetes import KubernetesCIS
from prowler.lib.outputs.compliance.cis.cis_m365 import M365CIS
from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS
from prowler.lib.outputs.compliance.cisa_scuba.cisa_scuba_googleworkspace import (
GoogleWorkspaceCISASCuBA,
)
from prowler.lib.outputs.compliance.cis.cis_m365 import M365CIS
from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS
from prowler.lib.outputs.compliance.compliance import display_compliance_table
from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA
from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA
@@ -1311,8 +1311,12 @@ def prowler():
global_provider.identity.audited_regions,
)
if not global_provider.identity.audited_regions
else global_provider.identity.audited_regions
else set(global_provider.identity.audited_regions)
)
if global_provider._enabled_regions is not None:
security_hub_regions = security_hub_regions.intersection(
global_provider._enabled_regions
)
security_hub = SecurityHub(
aws_account_id=global_provider.identity.account,

View File

@@ -3,6 +3,10 @@ aws:
# AWS Global Configuration
# aws.mute_non_default_regions --> Set to True to muted failed findings in non-default regions for AccessAnalyzer, GuardDuty, SecurityHub, DRS and Config
mute_non_default_regions: False
# aws.disallowed_regions --> List of AWS regions to exclude from the scan.
# Also settable via the PROWLER_AWS_DISALLOWED_REGIONS environment variable or
# the --excluded-region CLI flag. Precedence: CLI > env var > config file.
# disallowed_regions: []
# If you want to mute failed findings only in specific regions, create a file with the following syntax and run it with `prowler aws -w mutelist.yaml`:
# Mutelist:
# Accounts:

View File

@@ -111,6 +111,7 @@ class AwsProvider(Provider):
mfa: bool = False,
profile: str = None,
regions: set = set(),
excluded_regions: set = None,
organizations_role_arn: str = None,
scan_unused_services: bool = False,
resource_tags: list[str] = [],
@@ -136,6 +137,10 @@ class AwsProvider(Provider):
- mfa: A boolean indicating whether MFA is enabled.
- profile: The name of the AWS CLI profile to use.
- regions: A set of regions to audit.
- excluded_regions: A set of regions to skip during the scan. Applied
on top of `regions` and of the account's enabled regions. Also
settable via the PROWLER_AWS_DISALLOWED_REGIONS environment variable
or the `disallowed_regions` key in the provider config file.
- organizations_role_arn: The ARN of the AWS Organizations IAM role to assume.
- scan_unused_services: A boolean indicating whether to scan unused services. False by default.
- resource_tags: A list of tags to filter the resources to audit.
@@ -190,6 +195,33 @@ class AwsProvider(Provider):
logger.info("Initializing AWS provider ...")
# Load provider config early because provider-level settings can affect
# bootstrap region selection before the scan starts.
if config_content is not None:
self._audit_config = config_content
else:
if not config_path:
config_path = default_config_file_path
self._audit_config = load_and_validate_config_file(self._type, config_path)
excluded_regions = self.resolve_excluded_regions(
excluded_regions, self._audit_config
)
# Normalize excluded_regions and prune the include-list up front so
# every downstream consumer (identity, STS region, service/region
# enumeration) sees an already-filtered view.
if excluded_regions and regions:
regions = set(regions) - excluded_regions
if not regions:
raise AWSArgumentTypeValidationError(
message=(
"All requested AWS regions are excluded by the "
"disallowed regions configuration."
),
file=pathlib.Path(__file__).name,
)
######## AWS Session
logger.info("Generating original session ...")
@@ -215,7 +247,7 @@ class AwsProvider(Provider):
# After the session is created, validate it
logger.info("Validating credentials ...")
sts_region = get_aws_region_for_sts(
self.session.current_session.region_name, regions
self.session.current_session.region_name, regions, excluded_regions
)
# Validate the credentials
@@ -229,7 +261,9 @@ class AwsProvider(Provider):
######## AWS Provider Identity
# Get profile region
profile_region = self.get_profile_region(self._session.current_session)
profile_region = self.get_profile_region(
self._session.current_session, excluded_regions
)
# Set identity
self._identity = self.set_identity(
@@ -332,7 +366,26 @@ class AwsProvider(Provider):
)
########
# Parse Scan Tags
# Get Enabled Regions
self._enabled_regions = self.get_aws_enabled_regions(
self._session.current_session
)
# Apply the exclusion to the account's enabled regions. This is the
# gate used by generate_regional_clients, so skipped regions never get
# a boto3 client created for them and cannot stall the scan.
if excluded_regions:
if self._enabled_regions is not None:
self._enabled_regions = self._enabled_regions - excluded_regions
if self._identity.audited_regions:
self._identity.audited_regions = (
set(self._identity.audited_regions) - excluded_regions
)
logger.info(f"Excluding AWS regions from scan: {sorted(excluded_regions)}")
self._excluded_regions = excluded_regions
# Parse Scan Tags after region exclusions are applied so tag discovery
# also skips disallowed regions.
if resource_tags:
self._audit_resources = self.get_tagged_resources(resource_tags)
@@ -340,22 +393,9 @@ class AwsProvider(Provider):
if resource_arn:
self._audit_resources = resource_arn
# Get Enabled Regions
self._enabled_regions = self.get_aws_enabled_regions(
self._session.current_session
)
# Set ignore unused services
self._scan_unused_services = scan_unused_services
# Audit Config
if config_content:
self._audit_config = config_content
else:
if not config_path:
config_path = default_config_file_path
self._audit_config = load_and_validate_config_file(self._type, config_path)
# Fixer Config
self._fixer_config = fixer_config
@@ -468,12 +508,53 @@ class AwsProvider(Provider):
)
@staticmethod
def get_profile_region(session: Session):
profile_region = AWS_REGION_US_EAST_1
if session.region_name:
profile_region = session.region_name
def resolve_excluded_regions(
excluded_regions: set | list | tuple | None,
audit_config: dict | None,
) -> set[str]:
"""Resolve AWS region exclusions with precedence arg > env > config."""
if excluded_regions is not None:
raw_regions = excluded_regions
else:
raw_regions = Provider.get_excluded_regions_from_env()
if not raw_regions and isinstance(audit_config, dict):
raw_regions = audit_config.get("disallowed_regions") or []
return profile_region
return {str(region).strip() for region in raw_regions if str(region).strip()}
@staticmethod
def get_bootstrap_region_candidates(session_region: str | None) -> tuple[str, ...]:
"""Return safe fallback regions for bootstrap AWS calls."""
if session_region:
if session_region.startswith("cn-"):
return ("cn-north-1", "cn-northwest-1")
if session_region.startswith("us-gov-"):
return ("us-gov-east-1", "us-gov-west-1")
if session_region.startswith("eusc-"):
return ("eusc-de-east-1",)
if session_region.startswith("us-iso"):
return (session_region,)
return (AWS_STS_GLOBAL_ENDPOINT_REGION, "us-east-2", "us-west-2", "eu-west-1")
@staticmethod
def get_profile_region(
session: Session, excluded_regions: set[str] | None = None
) -> str:
excluded_regions = set(excluded_regions or ())
session_region = session.region_name
if session_region and session_region not in excluded_regions:
return session_region
for region in AwsProvider.get_bootstrap_region_candidates(session_region):
if region not in excluded_regions:
if session_region and session_region != region:
logger.info(
f"Configured AWS profile region {session_region} is excluded; using {region} for bootstrap clients."
)
return region
return session_region or AWS_REGION_US_EAST_1
@staticmethod
def set_identity(
@@ -701,12 +782,15 @@ class AwsProvider(Provider):
Caller Identity ARN: arn:aws:iam::123456789012:user/prowler
```
"""
# Beautify audited regions, set "all" if there is no filter region
regions = (
", ".join(self._identity.audited_regions)
if self._identity.audited_regions is not None
else "all"
)
# Beautify audited regions. If the scan includes all regions but some
# are explicitly excluded, reflect that in the banner instead of
# showing the misleading "all" label.
if self._identity.audited_regions:
regions = ", ".join(sorted(self._identity.audited_regions))
elif getattr(self, "_excluded_regions", None):
regions = f"all except {', '.join(sorted(self._excluded_regions))}"
else:
regions = "all"
# Beautify audited profile, set "default" if there is no profile set
profile = (
self._identity.profile if self._identity.profile is not None else "default"
@@ -745,6 +829,8 @@ class AwsProvider(Provider):
service_regions = AwsProvider.get_available_aws_service_regions(
service, self._identity.partition, self._identity.audited_regions
)
if getattr(self, "_excluded_regions", None):
service_regions = service_regions - self._excluded_regions
# Get the regions enabled for the account and get the intersection with the service available regions
if self._enabled_regions is not None:
@@ -962,6 +1048,8 @@ class AwsProvider(Provider):
service_regions = AwsProvider.get_available_aws_service_regions(
service, self._identity.partition, self._identity.audited_regions
)
if getattr(self, "_excluded_regions", None):
service_regions = service_regions - self._excluded_regions
default_region = self.get_global_region()
# global region of the partition when all regions are audited and there is no profile region
if self._identity.profile_region in service_regions:
@@ -1565,13 +1653,19 @@ def read_aws_regions_file() -> dict:
# TODO: This can be moved to another class since it doesn't need self
def get_aws_region_for_sts(session_region: str, regions: set[str]) -> str:
def get_aws_region_for_sts(
session_region: str,
regions: set[str],
excluded_regions: set[str] | None = None,
) -> str:
"""
Get the AWS region for the STS Assume Role operation.
Args:
- session_region (str): The region configured in the AWS session.
- regions (set[str]): The regions passed with the -f/--region/--filter-region option.
- excluded_regions (set[str] | None): Regions that should be avoided for
bootstrap calls when possible.
Returns:
str: The AWS region for the STS Assume Role operation
@@ -1579,20 +1673,21 @@ def get_aws_region_for_sts(session_region: str, regions: set[str]) -> str:
Example:
aws_region = get_aws_region_for_sts(session_region, regions)
"""
# If there is no region passed with -f/--region/--filter-region
if regions is None or len(regions) == 0:
# If you have a region configured in your AWS config or credentials file
if session_region is not None:
aws_region = session_region
else:
# If there is no region set passed with -f/--region
# we use the Global STS Endpoint Region, us-east-1
aws_region = AWS_STS_GLOBAL_ENDPOINT_REGION
else:
# Get the first region passed to the -f/--region
aws_region = list(regions)[0]
excluded_regions = set(excluded_regions or ())
return aws_region
if regions:
for region in regions:
if region not in excluded_regions:
return region
if session_region and session_region not in excluded_regions:
return session_region
for region in AwsProvider.get_bootstrap_region_candidates(session_region):
if region not in excluded_regions:
return region
return session_region or AWS_STS_GLOBAL_ENDPOINT_REGION
# TODO: this duplicates the provider arguments validation library

View File

@@ -66,6 +66,16 @@ def init_parser(self):
help="AWS region names to run Prowler against",
choices=AwsProvider.get_regions(partition=None),
)
aws_regions_subparser.add_argument(
"--excluded-region",
"--excluded-regions",
nargs="+",
help=(
"AWS region names to exclude from the scan. Overrides the "
"PROWLER_AWS_DISALLOWED_REGIONS environment variable when set."
),
choices=AwsProvider.get_regions(partition=None),
)
# AWS Organizations
aws_orgs_subparser = aws_parser.add_argument_group("AWS Organizations")
aws_orgs_subparser.add_argument(

View File

@@ -30,10 +30,12 @@ def quick_inventory(provider: AwsProvider, args):
ec2_client = provider.session.current_session.client(
"ec2", region_name=provider.identity.profile_region
)
excluded_regions = getattr(provider, "_excluded_regions", set())
# Get all the available regions
provider.identity.audited_regions = [
region["RegionName"]
for region in ec2_client.describe_regions()["Regions"]
if region["RegionName"] not in excluded_regions
]
with alive_bar(

View File

@@ -1,4 +1,5 @@
import importlib
import os
import pkgutil
import sys
from abc import ABC, abstractmethod
@@ -135,6 +136,18 @@ class Provider(ABC):
"""
return set()
@staticmethod
def get_excluded_regions_from_env() -> set:
"""Parse the PROWLER_AWS_DISALLOWED_REGIONS environment variable.
The variable is a comma-separated list of region identifiers to skip
during scans (e.g. "me-south-1, ap-east-1"). Whitespace around entries
is tolerated and empty entries are dropped. Returns an empty set when
the variable is unset or contains no usable values.
"""
raw = os.environ.get("PROWLER_AWS_DISALLOWED_REGIONS", "")
return {region.strip() for region in raw.split(",") if region.strip()}
@staticmethod
def get_global_provider() -> "Provider":
return Provider._global
@@ -160,6 +173,11 @@ class Provider(ABC):
if not isinstance(Provider._global, provider_class):
if "aws" in provider_class_name.lower():
excluded_regions = (
set(arguments.excluded_region)
if getattr(arguments, "excluded_region", None)
else None
)
provider_class(
retries_max_attempts=arguments.aws_retries_max_attempts,
role_arn=arguments.role,
@@ -169,6 +187,7 @@ class Provider(ABC):
mfa=arguments.mfa,
profile=arguments.profile,
regions=set(arguments.region) if arguments.region else None,
excluded_regions=excluded_regions,
organizations_role_arn=arguments.organizations_role,
scan_unused_services=arguments.scan_unused_services,
resource_tags=arguments.resource_tag,

View File

@@ -839,6 +839,132 @@ aws:
assert isinstance(aws_provider, AwsProvider)
@mock_aws
def test_excluded_regions_removed_from_enabled_regions(self):
aws_provider = AwsProvider(excluded_regions={AWS_REGION_EU_WEST_1})
assert AWS_REGION_EU_WEST_1 not in aws_provider._enabled_regions
assert AWS_REGION_EU_WEST_1 not in aws_provider.generate_regional_clients("ec2")
@mock_aws
def test_excluded_regions_pruned_from_input_regions(self):
aws_provider = AwsProvider(
regions={AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1},
excluded_regions={AWS_REGION_EU_WEST_1},
)
assert AWS_REGION_EU_WEST_1 not in aws_provider._identity.audited_regions
assert AWS_REGION_US_EAST_1 in aws_provider._identity.audited_regions
@mock_aws
def test_excluded_regions_from_config_file(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
tmp.write(f"aws:\n disallowed_regions:\n - {AWS_REGION_EU_WEST_1}\n")
config_path = tmp.name
try:
aws_provider = AwsProvider(config_path=config_path)
assert AWS_REGION_EU_WEST_1 not in aws_provider._enabled_regions
assert aws_provider._excluded_regions == {AWS_REGION_EU_WEST_1}
finally:
os.remove(config_path)
@mock_aws
def test_excluded_regions_from_env_on_direct_provider_init(self):
with mock.patch.dict(
os.environ,
{"PROWLER_AWS_DISALLOWED_REGIONS": AWS_REGION_EU_WEST_1},
clear=False,
):
aws_provider = AwsProvider()
assert aws_provider._excluded_regions == {AWS_REGION_EU_WEST_1}
assert AWS_REGION_EU_WEST_1 not in aws_provider._enabled_regions
@mock_aws
def test_excluded_regions_precedence_explicit_over_env_and_config(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
tmp.write(f"aws:\n disallowed_regions:\n - {AWS_REGION_EU_WEST_1}\n")
config_path = tmp.name
try:
with mock.patch.dict(
os.environ,
{"PROWLER_AWS_DISALLOWED_REGIONS": AWS_REGION_US_EAST_1},
clear=False,
):
aws_provider = AwsProvider(
config_path=config_path,
excluded_regions={AWS_REGION_US_EAST_2},
)
assert aws_provider._excluded_regions == {AWS_REGION_US_EAST_2}
assert AWS_REGION_US_EAST_2 not in aws_provider._enabled_regions
assert AWS_REGION_EU_WEST_1 in aws_provider._enabled_regions
assert AWS_REGION_US_EAST_1 in aws_provider._enabled_regions
finally:
os.remove(config_path)
@mock_aws
def test_excluded_regions_from_config_avoid_excluded_profile_region(
self, monkeypatch
):
monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION_EU_WEST_1)
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
tmp.write(f"aws:\n disallowed_regions:\n - {AWS_REGION_EU_WEST_1}\n")
config_path = tmp.name
try:
aws_provider = AwsProvider(config_path=config_path)
assert aws_provider.identity.profile_region == AWS_REGION_US_EAST_1
finally:
os.remove(config_path)
@mock_aws
def test_aws_provider_raises_when_all_input_regions_are_excluded(self):
with raises(AWSArgumentTypeValidationError):
AwsProvider(
regions={AWS_REGION_EU_WEST_1},
excluded_regions={AWS_REGION_EU_WEST_1},
)
def test_get_excluded_regions_from_env_parses_comma_list(self):
with mock.patch.dict(
os.environ,
{"PROWLER_AWS_DISALLOWED_REGIONS": " me-south-1 , ap-east-1 ,, "},
):
assert Provider.get_excluded_regions_from_env() == {
"me-south-1",
"ap-east-1",
}
def test_get_excluded_regions_from_env_ignores_legacy_generic_name(self):
with mock.patch.dict(
os.environ,
{"PROWLER_DISALLOWED_REGIONS": "me-south-1"},
clear=True,
):
assert Provider.get_excluded_regions_from_env() == set()
def test_get_excluded_regions_from_env_unset(self):
with mock.patch.dict(os.environ, {}, clear=True):
assert Provider.get_excluded_regions_from_env() == set()
@mock_aws
def test_print_credentials_shows_all_except_excluded_regions(self):
aws_provider = AwsProvider(
excluded_regions={AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1}
)
with patch(
"prowler.providers.aws.aws_provider.print_boxes"
) as mock_print_boxes:
aws_provider.print_credentials()
report_lines = mock_print_boxes.call_args.args[0]
assert any(
"AWS Regions:" in line and "all except eu-west-1, us-east-1" in line
for line in report_lines
)
@mock_aws
def test_generate_regional_clients_all_enabled_regions(self):
aws_provider = AwsProvider()
@@ -2033,6 +2159,24 @@ aws:
== AWS_REGION_EU_WEST_1
)
def test_get_aws_region_for_sts_avoids_excluded_session_region(self):
input_regions = None
session_region = AWS_REGION_EU_WEST_1
assert (
get_aws_region_for_sts(
session_region, input_regions, {AWS_REGION_EU_WEST_1}
)
== AWS_REGION_US_EAST_1
)
def test_get_profile_region_avoids_excluded_session_region(self):
mocked_session = mock.Mock(region_name=AWS_REGION_EU_WEST_1)
assert (
AwsProvider.get_profile_region(mocked_session, {AWS_REGION_EU_WEST_1})
== AWS_REGION_US_EAST_1
)
@mock_aws
def test_set_session_config_default(self):
aws_provider = AwsProvider()

View File

@@ -2,7 +2,7 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.24.0] (Prowler UNRELEASED)
## [1.24.0] (Prowler v5.24.0)
### 🚀 Added
@@ -12,11 +12,9 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🔄 Changed
- Attack Paths scan selection: contextual button labels based on graph availability, tooltips on disabled actions, green dot indicator for selectable scans, and a warning banner when viewing data from a previous scan cycle [(#10685)](https://github.com/prowler-cloud/prowler/pull/10685)
### 🔄 Changed
- Remove legacy finding detail sheet, row-details wrapper, and resource detail panel; unify findings and resources around new side drawers [(#10692)](https://github.com/prowler-cloud/prowler/pull/10692)
- Attack Paths "View Finding" now opens the finding drawer inline over the graph instead of navigating to `/findings` in a new tab, preserving graph zoom, selection, and filter state
- Attack Paths scan table: replace action buttons with radio buttons, add dedicated Graph column, use info-colored In Progress badge, remove redundant Progress column, and fix info banner variant [(#10704)](https://github.com/prowler-cloud/prowler/pull/10704)
### 🐞 Fixed
@@ -51,8 +49,9 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🐞 Fixed
- Preserve query parameters in callbackUrl during invitation flow [(#10571)](https://github.com/prowler-cloud/prowler/pull/10571)
- Attack Paths scan auto-refresh now correctly detects "available" (queued) scans as active [(#10476)](https://github.com/prowler-cloud/prowler/pull/10476)
- Attack Paths empty state not showing when no scans exist [(#10469)](https://github.com/prowler-cloud/prowler/pull/10469)
- Clear Filters now resets all filters including muted findings and auto-applies, Clear all in pills only removes pill-visible sub-filters, and the discard icon is now an Undo text button [(#10446)](https://github.com/prowler-cloud/prowler/pull/10446)
- Send to Jira modal now dynamically fetches and displays available issue types per project instead of hardcoding `"Task"`, fixing failures on non-English Jira instances [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534)
- Exclude service filter from finding group resources endpoint to prevent empty results when a service filter is active [(#10652)](https://github.com/prowler-cloud/prowler/pull/10652)
---

View File

@@ -169,14 +169,14 @@ describe("ScanListTable", () => {
expect(screen.getByText("12 Total Entries")).toBeInTheDocument();
expect(screen.getByText("Page 1 of 3")).toBeInTheDocument();
await user.click(screen.getAllByRole("button", { name: "Select scan" })[0]);
await user.click(screen.getAllByRole("radio", { name: "Select scan" })[0]);
expect(pushMock).toHaveBeenCalledWith(
"/attack-paths?scanPage=1&scanPageSize=5&scanId=scan-1",
);
});
it("enables the select button for a failed scan when graph data is ready", async () => {
it("enables the radio button for a failed scan when graph data is ready", async () => {
const user = userEvent.setup();
const failedScan: AttackPathScan = {
...createScan(1),
@@ -189,18 +189,18 @@ describe("ScanListTable", () => {
render(<ScanListTable scans={[failedScan]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeEnabled();
expect(button).toHaveTextContent("Select");
const radio = screen.getByRole("radio", { name: "Select scan" });
expect(radio).toBeEnabled();
expect(radio).toHaveAttribute("aria-checked", "false");
await user.click(button);
await user.click(radio);
expect(pushMock).toHaveBeenCalledWith(
"/attack-paths?scanPage=1&scanPageSize=5&scanId=scan-1",
);
});
it("disables the select button for a failed scan when graph data is not ready", () => {
it("disables the radio button for a failed scan when graph data is not ready", () => {
const failedScan: AttackPathScan = {
...createScan(1),
attributes: {
@@ -212,12 +212,11 @@ describe("ScanListTable", () => {
render(<ScanListTable scans={[failedScan]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeDisabled();
expect(button).toHaveTextContent("Failed");
const radio = screen.getByRole("radio", { name: "Scan not available" });
expect(radio).toBeDisabled();
});
it("shows 'Scheduled' label for a scheduled scan without graph data", () => {
it("shows a disabled radio button for a scheduled scan without graph data", () => {
const scheduledScan: AttackPathScan = {
...createScan(1),
attributes: {
@@ -232,12 +231,11 @@ describe("ScanListTable", () => {
render(<ScanListTable scans={[scheduledScan]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeDisabled();
expect(button).toHaveTextContent("Scheduled");
const radio = screen.getByRole("radio", { name: "Scan not available" });
expect(radio).toBeDisabled();
});
it("shows 'Running...' label for an executing scan without graph data", () => {
it("shows a disabled radio button for an executing scan without graph data", () => {
const executingScan: AttackPathScan = {
...createScan(1),
attributes: {
@@ -252,12 +250,11 @@ describe("ScanListTable", () => {
render(<ScanListTable scans={[executingScan]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeDisabled();
expect(button).toHaveTextContent("Running...");
const radio = screen.getByRole("radio", { name: "Scan not available" });
expect(radio).toBeDisabled();
});
it("enables Select for a scheduled scan when graph data is ready from a previous cycle", async () => {
it("enables the radio button for a scheduled scan when graph data is ready from a previous cycle", async () => {
const user = userEvent.setup();
const scheduledWithGraph: AttackPathScan = {
...createScan(1),
@@ -271,26 +268,26 @@ describe("ScanListTable", () => {
render(<ScanListTable scans={[scheduledWithGraph]} />);
const button = screen.getByRole("button", { name: "Select scan" });
expect(button).toBeEnabled();
expect(button).toHaveTextContent("Select");
const radio = screen.getByRole("radio", { name: "Select scan" });
expect(radio).toBeEnabled();
expect(radio).toHaveAttribute("aria-checked", "false");
await user.click(button);
await user.click(radio);
expect(pushMock).toHaveBeenCalledWith(
"/attack-paths?scanPage=1&scanPageSize=5&scanId=scan-1",
);
});
it("shows a green dot next to the account name when graph data is ready", () => {
it("exposes an accessible label in the Graph column when graph data is ready", () => {
render(<ScanListTable scans={[createScan(1)]} />);
const dot = screen.getByLabelText("Graph data available");
expect(dot).toBeInTheDocument();
expect(dot).toHaveClass("bg-bg-pass-primary");
expect(screen.getByLabelText("Graph available")).toHaveClass(
"text-text-success-primary",
);
});
it("does not show a green dot when graph data is not ready", () => {
it("exposes an accessible label in the Graph column when graph data is not ready", () => {
const noGraphScan: AttackPathScan = {
...createScan(1),
attributes: {
@@ -301,8 +298,28 @@ describe("ScanListTable", () => {
render(<ScanListTable scans={[noGraphScan]} />);
expect(screen.getByLabelText("Graph not available")).toHaveClass(
"text-text-neutral-secondary",
);
});
it("renders a tooltip explaining a completed scan without graph data", () => {
const completedNoGraph: AttackPathScan = {
...createScan(1),
attributes: {
...createScan(1).attributes,
state: "completed",
graph_data_ready: false,
},
};
render(<ScanListTable scans={[completedNoGraph]} />);
expect(
screen.queryByLabelText("Graph data available"),
).not.toBeInTheDocument();
screen.getByRole("radio", { name: "Scan not available" }),
).toBeDisabled();
expect(
screen.getByText("This scan completed without producing graph data."),
).toBeInTheDocument();
});
});

View File

@@ -1,9 +1,13 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Check, Minus } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/shadcn/button/button";
import {
RadioGroup,
RadioGroupItem,
} from "@/components/shadcn/radio-group/radio-group";
import {
Tooltip,
TooltipContent,
@@ -38,44 +42,6 @@ const formatNullableDuration = (duration: number | null) => {
return formatDuration(duration);
};
const isSelectDisabled = (
scan: AttackPathScan,
selectedScanId: string | null,
) => {
return !scan.attributes.graph_data_ready || selectedScanId === scan.id;
};
const getSelectButtonLabel = (
scan: AttackPathScan,
selectedScanId: string | null,
) => {
if (selectedScanId === scan.id) {
return "Selected";
}
if (scan.attributes.graph_data_ready) {
return "Select";
}
if (scan.attributes.state === SCAN_STATES.SCHEDULED) {
return "Scheduled";
}
if (scan.attributes.state === SCAN_STATES.AVAILABLE) {
return "Queued";
}
if (scan.attributes.state === SCAN_STATES.EXECUTING) {
return "Running...";
}
if (scan.attributes.state === SCAN_STATES.FAILED) {
return "Failed";
}
return "Select";
};
const getDisabledTooltip = (scan: AttackPathScan): string | null => {
if (scan.attributes.graph_data_ready) {
return null;
@@ -97,7 +63,11 @@ const getDisabledTooltip = (scan: AttackPathScan): string | null => {
return "This scan failed. No graph data is available.";
}
return null;
if (scan.attributes.state === SCAN_STATES.COMPLETED) {
return "This scan completed without producing graph data.";
}
return "Graph data is not available for this scan.";
};
const getSelectedRowSelection = (
@@ -129,37 +99,65 @@ const buildMetadata = (
const getColumns = ({
selectedScanId,
onSelectScan,
}: {
selectedScanId: string | null;
onSelectScan: (scanId: string) => void;
}): ColumnDef<AttackPathScan>[] => [
{
id: "select",
header: () => <span className="text-sm font-medium">Select</span>,
cell: ({ row }) => {
const isSelected = selectedScanId === row.original.id;
const canSelect = row.original.attributes.graph_data_ready;
const tooltip = getDisabledTooltip(row.original);
const radio = (
<RadioGroupItem
value={row.original.id}
checked={isSelected}
disabled={!canSelect}
className={cn(
"size-5",
canSelect &&
!isSelected &&
"border-text-neutral-secondary cursor-pointer",
!canSelect && "disabled:opacity-70",
)}
aria-label={
isSelected
? "Selected scan"
: canSelect
? "Select scan"
: "Scan not available"
}
/>
);
if (!canSelect && !isSelected && tooltip) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>{radio}</span>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}
return radio;
},
enableSorting: false,
},
{
accessorKey: "provider",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Account" />
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span
className={cn(
"inline-block size-2 shrink-0 rounded-full",
row.original.attributes.graph_data_ready
? "bg-bg-pass-primary"
: "bg-transparent",
)}
aria-label={
row.original.attributes.graph_data_ready
? "Graph data available"
: undefined
}
/>
<EntityInfo
cloudProvider={row.original.attributes.provider_type as ProviderType}
entityAlias={row.original.attributes.provider_alias}
entityId={row.original.attributes.provider_uid}
/>
</div>
<EntityInfo
cloudProvider={row.original.attributes.provider_type as ProviderType}
entityAlias={row.original.attributes.provider_alias}
entityId={row.original.attributes.provider_uid}
/>
),
enableSorting: false,
},
@@ -182,22 +180,32 @@ const getColumns = ({
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => (
<ScanStatusBadge
status={row.original.attributes.state}
progress={row.original.attributes.progress}
graphDataReady={row.original.attributes.graph_data_ready}
/>
<div className="flex">
<ScanStatusBadge
status={row.original.attributes.state}
progress={row.original.attributes.progress}
/>
</div>
),
enableSorting: false,
},
{
accessorKey: "progress",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Progress" />
),
cell: ({ row }) => (
<span className="text-sm">{row.original.attributes.progress}%</span>
),
accessorKey: "graph_data_ready",
header: () => <span className="text-sm font-medium">Graph</span>,
cell: ({ row }) =>
row.original.attributes.graph_data_ready ? (
<Check
size={16}
aria-label="Graph available"
className="text-text-success-primary"
/>
) : (
<Minus
size={16}
aria-label="Graph not available"
className="text-text-neutral-secondary"
/>
),
enableSorting: false,
},
{
@@ -212,45 +220,6 @@ const getColumns = ({
),
enableSorting: false,
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => {
const isDisabled = isSelectDisabled(row.original, selectedScanId);
const tooltip = getDisabledTooltip(row.original);
const button = (
<Button
type="button"
aria-label="Select scan"
disabled={isDisabled}
variant={isDisabled ? "secondary" : "default"}
onClick={() => onSelectScan(row.original.id)}
className="w-full max-w-24"
>
{getSelectButtonLabel(row.original, selectedScanId)}
</Button>
);
if (isDisabled && tooltip) {
return (
<div className="flex justify-end">
<Tooltip>
<TooltipTrigger asChild>
<span className="w-full max-w-24" tabIndex={0}>
{button}
</span>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
</div>
);
}
return <div className="flex justify-end">{button}</div>;
},
enableSorting: false,
},
];
/**
@@ -300,19 +269,27 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
};
return (
<DataTable
columns={getColumns({
selectedScanId,
onSelectScan: handleSelectScan,
})}
data={paginatedScans}
metadata={buildMetadata(scans.length, currentPage, totalPages)}
controlledPage={currentPage}
controlledPageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
enableRowSelection
rowSelection={getSelectedRowSelection(paginatedScans, selectedScanId)}
/>
<RadioGroup
value={selectedScanId ?? ""}
onValueChange={handleSelectScan}
className="gap-0"
>
<DataTable
columns={getColumns({ selectedScanId })}
data={paginatedScans}
metadata={buildMetadata(scans.length, currentPage, totalPages)}
controlledPage={currentPage}
controlledPageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onRowClick={(row) => {
if (row.original.attributes.graph_data_ready) {
handleSelectScan(row.original.id);
}
}}
enableRowSelection
rowSelection={getSelectedRowSelection(paginatedScans, selectedScanId)}
/>
</RadioGroup>
);
};

View File

@@ -3,97 +3,55 @@
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/shadcn/badge/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
import type { ScanState } from "@/types/attack-paths";
import { SCAN_STATES } from "@/types/attack-paths";
const BADGE_CONFIG: Record<
ScanState,
{ className: string; label: string; showGraphDot: boolean }
> = {
const BADGE_CONFIG: Record<ScanState, { className: string; label: string }> = {
[SCAN_STATES.SCHEDULED]: {
className: "bg-bg-neutral-tertiary text-text-neutral-primary",
label: "Scheduled",
showGraphDot: true,
},
[SCAN_STATES.AVAILABLE]: {
className: "bg-bg-neutral-tertiary text-text-neutral-primary",
label: "Queued",
showGraphDot: true,
},
[SCAN_STATES.EXECUTING]: {
className: "bg-bg-warning-secondary text-text-neutral-primary",
className: "bg-bg-info-secondary text-text-info",
label: "In Progress",
showGraphDot: false,
},
[SCAN_STATES.COMPLETED]: {
className: "bg-bg-pass-secondary text-text-success-primary",
label: "Completed",
showGraphDot: false,
},
[SCAN_STATES.FAILED]: {
className: "bg-bg-fail-secondary text-text-error-primary",
label: "Failed",
showGraphDot: true,
},
};
interface ScanStatusBadgeProps {
status: ScanState;
progress?: number;
graphDataReady?: boolean;
}
export const ScanStatusBadge = ({
status,
progress = 0,
graphDataReady = false,
}: ScanStatusBadgeProps) => {
const config = BADGE_CONFIG[status];
const graphDot = graphDataReady && config.showGraphDot && (
<span className="bg-bg-pass-primary inline-block size-2 rounded-full" />
);
const tooltipText = graphDataReady
? "Graph available"
: status === SCAN_STATES.FAILED || status === SCAN_STATES.COMPLETED
? "Graph not available"
: "Graph not available yet";
const icon =
status === SCAN_STATES.EXECUTING ? (
<Loader2
size={14}
className={
graphDataReady
? "text-text-success-primary animate-spin"
: "animate-spin"
}
/>
) : (
graphDot
);
const label =
status === SCAN_STATES.EXECUTING
? `${config.label} (${progress}%)`
: config.label;
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge className={cn(config.className, "gap-2")}>
{icon}
<span>{label}</span>
</Badge>
</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
<Badge className={cn(config.className, "gap-2")}>
{status === SCAN_STATES.EXECUTING && (
<Loader2 size={14} className="animate-spin" />
)}
<span>{label}</span>
</Badge>
);
};

View File

@@ -1,6 +1,6 @@
"use client";
import { ArrowLeft, Info, Maximize2, TriangleAlert, X } from "lucide-react";
import { ArrowLeft, Info, Maximize2, X } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
@@ -120,8 +120,8 @@ export default function AttackPathsPage() {
// Check if there's an executing scan for auto-refresh
const hasExecutingScan = scans.some(
(scan) =>
scan.attributes.state === "executing" ||
scan.attributes.state === "scheduled",
scan.attributes.state === SCAN_STATES.EXECUTING ||
scan.attributes.state === SCAN_STATES.SCHEDULED,
);
// Detect if the selected scan is showing data from a previous cycle
@@ -358,11 +358,11 @@ export default function AttackPathsPage() {
<h2 className="dark:text-prowler-theme-pale/90 text-xl font-semibold">
Attack Paths
</h2>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-2 text-sm">
<p className="text-text-neutral-secondary mt-2 text-sm">
Select a scan, build a query, and visualize Attack Paths in your
infrastructure.
</p>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-1 text-xs">
<p className="text-text-neutral-secondary mt-1 text-xs">
Scans can be selected when data is available. A new scan does not
interrupt access to existing data.
</p>
@@ -394,11 +394,8 @@ export default function AttackPathsPage() {
{/* Banner: viewing data from a previous scan cycle */}
{isViewingPreviousCycleData && (
<Alert
variant="default"
className="border-border-warning-secondary bg-bg-warning-secondary"
>
<TriangleAlert className="text-text-warning-primary size-4" />
<Alert variant="info">
<Info className="size-4" />
<AlertTitle>Viewing data from a previous scan</AlertTitle>
<AlertDescription>
This scan is currently{" "}
@@ -605,7 +602,7 @@ export default function AttackPathsPage() {
<X size={16} />
</Button>
</div>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mb-4 text-xs">
<p className="text-text-neutral-secondary mb-4 text-xs">
{graphState.selectedNode?.labels.some(
(label) =>
label
@@ -628,7 +625,7 @@ export default function AttackPathsPage() {
<h4 className="mb-2 text-xs font-semibold">
Type
</h4>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
<p className="text-text-neutral-secondary text-xs">
{graphState.selectedNode?.labels
.map(formatNodeLabel)
.join(", ")}
@@ -678,7 +675,7 @@ export default function AttackPathsPage() {
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold">Node Details</h3>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-1 text-sm">
<p className="text-text-neutral-secondary mt-1 text-sm">
{String(
graphState.selectedNode.labels.some((label) =>
label.toLowerCase().includes("finding"),