mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-09 03:07:05 +00:00
Compare commits
33 Commits
aws-region
...
feat/findi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0d538235e | ||
|
|
e0c2c51f1f | ||
|
|
883bdfdddf | ||
|
|
8af9110a23 | ||
|
|
caca046a64 | ||
|
|
42b9402ed5 | ||
|
|
7b491677e6 | ||
|
|
5a0f246655 | ||
|
|
da7363478d | ||
|
|
42ae4cd017 | ||
|
|
b214007266 | ||
|
|
ea42e4374b | ||
|
|
2db2641611 | ||
|
|
9df1ac01eb | ||
|
|
99f190e7b1 | ||
|
|
53e564eeb0 | ||
|
|
279eedf959 | ||
|
|
c55f0cb36d | ||
|
|
2c1c33eab2 | ||
|
|
aa91f2748b | ||
|
|
5339468b33 | ||
|
|
44899bfe30 | ||
|
|
3e963fc77a | ||
|
|
c57bca3279 | ||
|
|
820f5c5d0a | ||
|
|
2596b1a0e4 | ||
|
|
a8bc66bb69 | ||
|
|
8521e21e2c | ||
|
|
0bd177c6ad | ||
|
|
c99ed991b7 | ||
|
|
7c0034524a | ||
|
|
749110de75 | ||
|
|
5fff3b920d |
@@ -6153,7 +6153,15 @@ class IntegrationViewSet(BaseRLSViewSet):
|
||||
tags=["Integration"],
|
||||
summary="Send findings to a Jira integration",
|
||||
description="Send a set of filtered findings to the given integration. At least one finding filter must be "
|
||||
"provided.",
|
||||
"provided.\n\n"
|
||||
"## Known Limitations\n\n"
|
||||
"### Issue Types with Required Custom Fields\n\n"
|
||||
"Certain Jira issue types (such as Epic) may require mandatory custom fields that Prowler does not "
|
||||
"currently populate when creating work items. If a selected issue type enforces required fields beyond "
|
||||
'the standard set (e.g., "Team", "Epic Name"), the work item creation will fail.\n\n'
|
||||
"To avoid this, select an issue type that does not require additional custom fields - **Task**, **Bug**, "
|
||||
"or **Story** typically work without restrictions. If unsure which issue types are available for a project, "
|
||||
'Prowler automatically fetches and displays them in the "Issue Type" selector when sending a finding.',
|
||||
responses={202: OpenApiResponse(response=TaskSerializer)},
|
||||
filters=True,
|
||||
)
|
||||
@@ -6175,6 +6183,10 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
|
||||
def list(self, request, *args, **kwargs):
|
||||
raise MethodNotAllowed(method="GET")
|
||||
|
||||
@extend_schema(exclude=True)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
raise MethodNotAllowed(method="GET")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "issue_types":
|
||||
return IntegrationJiraIssueTypesSerializer
|
||||
|
||||
146
poetry.lock
generated
146
poetry.lock
generated
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "about-time"
|
||||
@@ -605,21 +605,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]]
|
||||
@@ -1888,7 +1888,6 @@ files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
markers = {dev = "platform_system == \"Windows\" or sys_platform == \"win32\""}
|
||||
|
||||
[[package]]
|
||||
name = "contextlib2"
|
||||
@@ -1983,62 +1982,75 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
|
||||
|
||||
[[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\""}
|
||||
typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""}
|
||||
|
||||
[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]]
|
||||
@@ -3071,7 +3083,7 @@ files = [
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=22.2.0"
|
||||
jsonschema-specifications = ">=2023.3.6"
|
||||
jsonschema-specifications = ">=2023.03.6"
|
||||
referencing = ">=0.28.4"
|
||||
rpds-py = ">=0.7.1"
|
||||
|
||||
@@ -3151,7 +3163,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=14.5.14"
|
||||
certifi = ">=14.05.14"
|
||||
durationpy = ">=0.7"
|
||||
google-auth = ">=1.0.1"
|
||||
oauthlib = ">=3.2.2"
|
||||
@@ -4074,23 +4086,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\""]
|
||||
@@ -4963,7 +4976,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
astroid = ">=3.3.8,<=3.4.0.dev0"
|
||||
astroid = ">=3.3.8,<=3.4.0-dev0"
|
||||
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
|
||||
dill = [
|
||||
{version = ">=0.2", markers = "python_version < \"3.11\""},
|
||||
@@ -5024,18 +5037,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"]
|
||||
@@ -5808,10 +5822,10 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.37.4,<2.0a0"
|
||||
botocore = ">=1.37.4,<2.0a.0"
|
||||
|
||||
[package.extras]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "safety"
|
||||
@@ -6729,4 +6743,4 @@ files = [
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10,<3.13"
|
||||
content-hash = "65f1f9833d61f90f1f89ed70b3677f76c0693bae275dd39699df01c05050bbe6"
|
||||
content-hash = "91739ee5e383337160f9f08b76944ab4e8629c94084c8a9d115246862557f7c5"
|
||||
|
||||
@@ -29,10 +29,13 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
- `return` statements in `finally` blocks replaced across IAM, Organizations, GCP provider, and custom checks metadata to stop silently swallowing exceptions [(#10102)](https://github.com/prowler-cloud/prowler/pull/10102)
|
||||
- `JiraConnection` now includes issue types per project fetched during `test_connection`, fixing `JiraInvalidIssueTypeError` on non-English Jira instances [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534)
|
||||
- `--list-checks` and `--list-checks-json` now include `threat-detection` category checks in their output [(#10578)](https://github.com/prowler-cloud/prowler/pull/10578)
|
||||
- Missing `__init__.py` in `codebuild_project_uses_allowed_github_organizations` check preventing discovery by `--list-checks` [(#10584)](https://github.com/prowler-cloud/prowler/pull/10584)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Sensitive CLI flag values (tokens, keys, passwords) in HTML output "Parameters used" field now redacted to prevent credential leaks [(#10518)](https://github.com/prowler-cloud/prowler/pull/10518)
|
||||
- `cryptography` bumped from 44.0.3 to 46.0.6 ([CVE-2026-26007](https://github.com/pyca/cryptography/security/advisories/GHSA-r6ph-v2qm-q3c2), [CVE-2026-34073](https://github.com/pyca/cryptography/security/advisories/GHSA-m959-cc7f-wv43)), `oci` to 2.169.0, and `alibabacloud-tea-openapi` to 0.4.4 [(#10535)](https://github.com/prowler-cloud/prowler/pull/10535)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -271,6 +271,8 @@ def prowler():
|
||||
categories=categories,
|
||||
resource_groups=resource_groups,
|
||||
provider=provider,
|
||||
list_checks=getattr(args, "list_checks", False)
|
||||
or getattr(args, "list_checks_json", False),
|
||||
)
|
||||
|
||||
# if --list-checks-json, dump a json file and exit
|
||||
|
||||
@@ -20,6 +20,7 @@ def load_checks_to_execute(
|
||||
compliance_frameworks: list = None,
|
||||
categories: set = None,
|
||||
resource_groups: set = None,
|
||||
list_checks: bool = False,
|
||||
) -> set:
|
||||
"""Generate the list of checks to execute based on the cloud provider and the input arguments given"""
|
||||
try:
|
||||
@@ -209,7 +210,12 @@ def load_checks_to_execute(
|
||||
):
|
||||
checks_to_execute.add(check_name)
|
||||
# Only execute threat detection checks if threat-detection category is set
|
||||
if (not categories or "threat-detection" not in categories) and not check_list:
|
||||
# Skip this exclusion when listing checks (--list-checks or --list-checks-json)
|
||||
if (
|
||||
(not categories or "threat-detection" not in categories)
|
||||
and not check_list
|
||||
and not list_checks
|
||||
):
|
||||
for threat_detection_check in check_categories.get("threat-detection", []):
|
||||
checks_to_execute.discard(threat_detection_check)
|
||||
|
||||
|
||||
@@ -3145,17 +3145,6 @@
|
||||
"aws-us-gov": []
|
||||
}
|
||||
},
|
||||
"connecthealth": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
"us-east-1",
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": []
|
||||
}
|
||||
},
|
||||
"connectparticipant": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
@@ -5570,7 +5559,6 @@
|
||||
"ap-southeast-3",
|
||||
"ap-southeast-4",
|
||||
"ap-southeast-5",
|
||||
"ap-southeast-6",
|
||||
"ap-southeast-7",
|
||||
"ca-central-1",
|
||||
"ca-west-1",
|
||||
@@ -5882,9 +5870,7 @@
|
||||
"cn-north-1",
|
||||
"cn-northwest-1"
|
||||
],
|
||||
"aws-eusc": [
|
||||
"eusc-de-east-1"
|
||||
],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": [
|
||||
"us-gov-east-1",
|
||||
"us-gov-west-1"
|
||||
@@ -7281,7 +7267,6 @@
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-3",
|
||||
"ap-southeast-5",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-north-1",
|
||||
@@ -8539,7 +8524,6 @@
|
||||
"ap-southeast-3",
|
||||
"ap-southeast-4",
|
||||
"ap-southeast-5",
|
||||
"ap-southeast-7",
|
||||
"ca-central-1",
|
||||
"ca-west-1",
|
||||
"eu-central-1",
|
||||
@@ -8569,6 +8553,7 @@
|
||||
"notificationscontacts": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
"ap-southeast-5",
|
||||
"us-east-1"
|
||||
],
|
||||
"aws-cn": [],
|
||||
@@ -8684,14 +8669,10 @@
|
||||
"regions": {
|
||||
"aws": [
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-south-1",
|
||||
"ap-south-2",
|
||||
"ap-southeast-2",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-2"
|
||||
@@ -9072,7 +9053,6 @@
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-east-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
@@ -10640,36 +10620,19 @@
|
||||
"s3vectors": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-east-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
"ap-south-1",
|
||||
"ap-south-2",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-3",
|
||||
"ap-southeast-4",
|
||||
"ap-southeast-5",
|
||||
"ap-southeast-6",
|
||||
"ap-southeast-7",
|
||||
"ca-central-1",
|
||||
"ca-west-1",
|
||||
"eu-central-1",
|
||||
"eu-central-2",
|
||||
"eu-north-1",
|
||||
"eu-south-1",
|
||||
"eu-south-2",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"mx-central-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-1",
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [],
|
||||
@@ -11815,38 +11778,26 @@
|
||||
"regions": {
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-east-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
"ap-south-1",
|
||||
"ap-south-2",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-3",
|
||||
"ap-southeast-4",
|
||||
"ap-southeast-5",
|
||||
"ap-southeast-6",
|
||||
"ap-southeast-7",
|
||||
"ca-central-1",
|
||||
"ca-west-1",
|
||||
"eu-central-1",
|
||||
"eu-central-2",
|
||||
"eu-north-1",
|
||||
"eu-south-1",
|
||||
"eu-south-2",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"il-central-1",
|
||||
"me-central-1",
|
||||
"me-south-1",
|
||||
"mx-central-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-1",
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [],
|
||||
@@ -12110,9 +12061,7 @@
|
||||
"cn-north-1",
|
||||
"cn-northwest-1"
|
||||
],
|
||||
"aws-eusc": [
|
||||
"eusc-de-east-1"
|
||||
],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": [
|
||||
"us-gov-east-1",
|
||||
"us-gov-west-1"
|
||||
@@ -12335,17 +12284,6 @@
|
||||
"aws-us-gov": []
|
||||
}
|
||||
},
|
||||
"sustainability": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
"us-east-1",
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": []
|
||||
}
|
||||
},
|
||||
"swf": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
@@ -12523,7 +12461,6 @@
|
||||
"regions": {
|
||||
"aws": [
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-3",
|
||||
"ap-south-1",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
@@ -12537,7 +12474,6 @@
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"me-central-1",
|
||||
"mx-central-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
|
||||
@@ -46,7 +46,7 @@ dependencies = [
|
||||
"boto3==1.40.61",
|
||||
"botocore==1.40.61",
|
||||
"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",
|
||||
@@ -75,10 +75,10 @@ dependencies = [
|
||||
"uuid6==2024.7.10",
|
||||
"py-iam-expand==0.1.0",
|
||||
"h2==4.3.0",
|
||||
"oci==2.160.3",
|
||||
"oci==2.169.0",
|
||||
"alibabacloud_credentials==1.0.3",
|
||||
"alibabacloud_ram20150501==1.2.0",
|
||||
"alibabacloud_tea_openapi==0.4.1",
|
||||
"alibabacloud_tea_openapi==0.4.4",
|
||||
"alibabacloud_sts20150401==1.1.6",
|
||||
"alibabacloud_vpc20160428==6.13.0",
|
||||
"alibabacloud_ecs20140526==7.2.5",
|
||||
|
||||
@@ -629,3 +629,49 @@ class TestCheckLoader:
|
||||
provider=self.provider,
|
||||
)
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_list_checks_includes_threat_detection(self):
|
||||
"""Test that list_checks=True includes threat-detection checks (fixes #10576)"""
|
||||
bulk_checks_metadata = {
|
||||
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata(),
|
||||
CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME: self.get_threat_detection_check_metadata(),
|
||||
}
|
||||
|
||||
result = load_checks_to_execute(
|
||||
bulk_checks_metadata=bulk_checks_metadata,
|
||||
provider=self.provider,
|
||||
list_checks=True,
|
||||
)
|
||||
assert CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME in result
|
||||
assert S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME in result
|
||||
|
||||
def test_list_checks_with_service_includes_threat_detection(self):
|
||||
"""Test that list_checks=True with service filter includes threat-detection checks (fixes #10576)"""
|
||||
bulk_checks_metadata = {
|
||||
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata(),
|
||||
CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME: self.get_threat_detection_check_metadata(),
|
||||
}
|
||||
service_list = ["cloudtrail"]
|
||||
|
||||
result = load_checks_to_execute(
|
||||
bulk_checks_metadata=bulk_checks_metadata,
|
||||
service_list=service_list,
|
||||
provider=self.provider,
|
||||
list_checks=True,
|
||||
)
|
||||
assert CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME in result
|
||||
assert S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME not in result
|
||||
|
||||
def test_scan_still_excludes_threat_detection_by_default(self):
|
||||
"""Test that without list_checks, threat-detection checks are still excluded"""
|
||||
bulk_checks_metadata = {
|
||||
S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata(),
|
||||
CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME: self.get_threat_detection_check_metadata(),
|
||||
}
|
||||
|
||||
result = load_checks_to_execute(
|
||||
bulk_checks_metadata=bulk_checks_metadata,
|
||||
provider=self.provider,
|
||||
)
|
||||
assert CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME not in result
|
||||
assert S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME in result
|
||||
|
||||
@@ -163,6 +163,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
|
||||
alias: "production",
|
||||
},
|
||||
status: "FAIL",
|
||||
delta: "new",
|
||||
severity: "critical",
|
||||
first_seen_at: null,
|
||||
last_seen_at: "2024-01-01T00:00:00Z",
|
||||
@@ -178,5 +179,6 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].checkId).toBe("s3_check");
|
||||
expect(result[0].resourceName).toBe("my-bucket");
|
||||
expect(result[0].delta).toBe("new");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,7 @@ interface FindingGroupResourceAttributes {
|
||||
resource: ResourceInfo;
|
||||
provider: ProviderInfo;
|
||||
status: string;
|
||||
delta?: string | null;
|
||||
severity: string;
|
||||
first_seen_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
@@ -137,14 +138,15 @@ export function adaptFindingGroupResourcesResponse(
|
||||
providerAlias: item.attributes.provider?.alias || "",
|
||||
providerUid: item.attributes.provider?.uid || "",
|
||||
resourceName: item.attributes.resource?.name || "-",
|
||||
resourceType: item.attributes.resource?.type || "-",
|
||||
resourceGroup: item.attributes.resource?.resource_group || "-",
|
||||
resourceUid: item.attributes.resource?.uid || "-",
|
||||
service: item.attributes.resource?.service || "-",
|
||||
region: item.attributes.resource?.region || "-",
|
||||
severity: (item.attributes.severity || "informational") as Severity,
|
||||
status: item.attributes.status,
|
||||
delta: item.attributes.delta || null,
|
||||
isMuted: item.attributes.status === "MUTED",
|
||||
// TODO: remove fallback once the API returns muted_reason in finding-group-resources
|
||||
mutedReason: item.attributes.muted_reason || undefined,
|
||||
firstSeenAt: item.attributes.first_seen_at,
|
||||
lastSeenAt: item.attributes.last_seen_at,
|
||||
|
||||
@@ -47,10 +47,6 @@ import {
|
||||
getLatestFindingGroupResources,
|
||||
} from "./finding-groups";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blocker 1 + 2: FAIL-first sort and FAIL-only filter for drill-down resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -169,7 +165,7 @@ describe("getLatestFindingGroupResources — SSRF path traversal protection", ()
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blocker 1: Resources list must show FAIL first (sort=-status)
|
||||
// Resources list keeps FAIL-first sort but no longer forces FAIL-only filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => {
|
||||
@@ -181,30 +177,30 @@ describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => {
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
});
|
||||
|
||||
it("should include sort=-status in the API call so FAIL resources appear first", async () => {
|
||||
it("should include the composite sort so FAIL resources appear first, then severity", async () => {
|
||||
// Given
|
||||
const checkId = "s3_bucket_public_access";
|
||||
|
||||
// When
|
||||
await getFindingGroupResources({ checkId });
|
||||
|
||||
// Then — the URL must contain sort=-status
|
||||
// Then — the URL must contain the composite sort
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("sort")).toBe("-status");
|
||||
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
|
||||
});
|
||||
|
||||
it("should include filter[status]=FAIL in the API call so only impacted resources are shown", async () => {
|
||||
it("should not force filter[status]=FAIL so PASS resources can also be shown", async () => {
|
||||
// Given
|
||||
const checkId = "s3_bucket_public_access";
|
||||
|
||||
// When
|
||||
await getFindingGroupResources({ checkId });
|
||||
|
||||
// Then — the URL must contain filter[status]=FAIL
|
||||
// Then — the URL should not add a hardcoded status filter
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("filter[status]")).toBe("FAIL");
|
||||
expect(url.searchParams.get("filter[status]")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,7 +213,7 @@ describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () =>
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
});
|
||||
|
||||
it("should include sort=-status in the API call so FAIL resources appear first", async () => {
|
||||
it("should include the composite sort so FAIL resources appear first, then severity", async () => {
|
||||
// Given
|
||||
const checkId = "iam_user_mfa_enabled";
|
||||
|
||||
@@ -227,10 +223,10 @@ describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () =>
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("sort")).toBe("-status");
|
||||
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
|
||||
});
|
||||
|
||||
it("should include filter[status]=FAIL in the API call so only impacted resources are shown", async () => {
|
||||
it("should not force filter[status]=FAIL so PASS resources can also be shown", async () => {
|
||||
// Given
|
||||
const checkId = "iam_user_mfa_enabled";
|
||||
|
||||
@@ -240,7 +236,7 @@ describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () =>
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("filter[status]")).toBe("FAIL");
|
||||
expect(url.searchParams.get("filter[status]")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -257,7 +253,7 @@ describe("getFindingGroupResources — triangulation: params coexist", () => {
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
});
|
||||
|
||||
it("should send sort=-status AND filter[status]=FAIL alongside pagination params", async () => {
|
||||
it("should send the composite sort alongside pagination params without forcing filter[status]", async () => {
|
||||
// Given
|
||||
const checkId = "s3_bucket_versioning";
|
||||
|
||||
@@ -269,8 +265,8 @@ describe("getFindingGroupResources — triangulation: params coexist", () => {
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("page[number]")).toBe("2");
|
||||
expect(url.searchParams.get("page[size]")).toBe("50");
|
||||
expect(url.searchParams.get("sort")).toBe("-status");
|
||||
expect(url.searchParams.get("filter[status]")).toBe("FAIL");
|
||||
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
|
||||
expect(url.searchParams.get("filter[status]")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,7 +279,7 @@ describe("getLatestFindingGroupResources — triangulation: params coexist", ()
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
});
|
||||
|
||||
it("should send sort=-status AND filter[status]=FAIL alongside pagination params", async () => {
|
||||
it("should send the composite sort alongside pagination params without forcing filter[status]", async () => {
|
||||
// Given
|
||||
const checkId = "iam_root_mfa_enabled";
|
||||
|
||||
@@ -295,16 +291,16 @@ describe("getLatestFindingGroupResources — triangulation: params coexist", ()
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("page[number]")).toBe("3");
|
||||
expect(url.searchParams.get("page[size]")).toBe("20");
|
||||
expect(url.searchParams.get("sort")).toBe("-status");
|
||||
expect(url.searchParams.get("filter[status]")).toBe("FAIL");
|
||||
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
|
||||
expect(url.searchParams.get("filter[status]")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blocker: Duplicate filter[status] — caller-supplied status must be stripped
|
||||
// Caller filters should propagate unchanged to the drill-down resources endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getFindingGroupResources — Blocker: caller filter[status] is always overridden to FAIL", () => {
|
||||
describe("getFindingGroupResources — caller filters are preserved", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
@@ -313,23 +309,7 @@ describe("getFindingGroupResources — Blocker: caller filter[status] is always
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
});
|
||||
|
||||
it("should use filter[status]=FAIL even when caller passes filter[status]=PASS", async () => {
|
||||
// Given — caller explicitly passes PASS, which must be ignored
|
||||
const checkId = "s3_bucket_public_access";
|
||||
const filters = { "filter[status]": "PASS" };
|
||||
|
||||
// When
|
||||
await getFindingGroupResources({ checkId, filters });
|
||||
|
||||
// Then — the final URL must have exactly one filter[status]=FAIL, not PASS
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
const allStatusValues = url.searchParams.getAll("filter[status]");
|
||||
expect(allStatusValues).toHaveLength(1);
|
||||
expect(allStatusValues[0]).toBe("FAIL");
|
||||
});
|
||||
|
||||
it("should not have duplicate filter[status] params when caller passes filter[status]", async () => {
|
||||
it("should preserve caller filter[status] when explicitly provided", async () => {
|
||||
// Given
|
||||
const checkId = "s3_bucket_public_access";
|
||||
const filters = { "filter[status]": "PASS" };
|
||||
@@ -337,14 +317,56 @@ describe("getFindingGroupResources — Blocker: caller filter[status] is always
|
||||
// When
|
||||
await getFindingGroupResources({ checkId, filters });
|
||||
|
||||
// Then — no duplicates
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.getAll("filter[status]")).toHaveLength(1);
|
||||
const allStatusValues = url.searchParams.getAll("filter[status]");
|
||||
expect(allStatusValues).toHaveLength(1);
|
||||
expect(allStatusValues[0]).toBe("PASS");
|
||||
});
|
||||
|
||||
it("should translate a single group status__in filter into filter[status] for resources", async () => {
|
||||
// Given
|
||||
const checkId = "s3_bucket_public_access";
|
||||
const filters = {
|
||||
"filter[status__in]": "PASS",
|
||||
"filter[severity__in]": "medium",
|
||||
"filter[provider_type__in]": "aws",
|
||||
};
|
||||
|
||||
// When
|
||||
await getFindingGroupResources({ checkId, filters });
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("filter[status]")).toBe("PASS");
|
||||
expect(url.searchParams.get("filter[status__in]")).toBeNull();
|
||||
expect(url.searchParams.get("filter[severity__in]")).toBe("medium");
|
||||
expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws");
|
||||
});
|
||||
|
||||
it("should keep the composite sort when the resource search filter is applied", async () => {
|
||||
// Given
|
||||
const checkId = "s3_bucket_public_access";
|
||||
const filters = {
|
||||
"filter[name__icontains]": "bucket-prod",
|
||||
"filter[severity__in]": "high",
|
||||
};
|
||||
|
||||
// When
|
||||
await getFindingGroupResources({ checkId, filters });
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
|
||||
expect(url.searchParams.get("filter[name__icontains]")).toBe("bucket-prod");
|
||||
expect(url.searchParams.get("filter[severity__in]")).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLatestFindingGroupResources — Blocker: caller filter[status] is always overridden to FAIL", () => {
|
||||
describe("getLatestFindingGroupResources — caller filters are preserved", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
@@ -353,23 +375,7 @@ describe("getLatestFindingGroupResources — Blocker: caller filter[status] is a
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
});
|
||||
|
||||
it("should use filter[status]=FAIL even when caller passes filter[status]=PASS", async () => {
|
||||
// Given — caller explicitly passes PASS, which must be ignored
|
||||
const checkId = "iam_user_mfa_enabled";
|
||||
const filters = { "filter[status]": "PASS" };
|
||||
|
||||
// When
|
||||
await getLatestFindingGroupResources({ checkId, filters });
|
||||
|
||||
// Then — the final URL must have exactly one filter[status]=FAIL, not PASS
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
const allStatusValues = url.searchParams.getAll("filter[status]");
|
||||
expect(allStatusValues).toHaveLength(1);
|
||||
expect(allStatusValues[0]).toBe("FAIL");
|
||||
});
|
||||
|
||||
it("should not have duplicate filter[status] params when caller passes filter[status]", async () => {
|
||||
it("should preserve caller filter[status] when explicitly provided", async () => {
|
||||
// Given
|
||||
const checkId = "iam_user_mfa_enabled";
|
||||
const filters = { "filter[status]": "PASS" };
|
||||
@@ -377,9 +383,53 @@ describe("getLatestFindingGroupResources — Blocker: caller filter[status] is a
|
||||
// When
|
||||
await getLatestFindingGroupResources({ checkId, filters });
|
||||
|
||||
// Then — no duplicates
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.getAll("filter[status]")).toHaveLength(1);
|
||||
const allStatusValues = url.searchParams.getAll("filter[status]");
|
||||
expect(allStatusValues).toHaveLength(1);
|
||||
expect(allStatusValues[0]).toBe("PASS");
|
||||
});
|
||||
|
||||
it("should translate a single group status__in filter into filter[status] for latest resources", async () => {
|
||||
// Given
|
||||
const checkId = "iam_user_mfa_enabled";
|
||||
const filters = {
|
||||
"filter[status__in]": "PASS",
|
||||
"filter[severity__in]": "low",
|
||||
"filter[provider_type__in]": "aws",
|
||||
};
|
||||
|
||||
// When
|
||||
await getLatestFindingGroupResources({ checkId, filters });
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("filter[status]")).toBe("PASS");
|
||||
expect(url.searchParams.get("filter[status__in]")).toBeNull();
|
||||
expect(url.searchParams.get("filter[severity__in]")).toBe("low");
|
||||
expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws");
|
||||
});
|
||||
|
||||
it("should keep the composite sort when the resource search filter is applied", async () => {
|
||||
// Given
|
||||
const checkId = "iam_user_mfa_enabled";
|
||||
const filters = {
|
||||
"filter[name__icontains]": "instance-prod",
|
||||
"filter[status__in]": "PASS,FAIL",
|
||||
};
|
||||
|
||||
// When
|
||||
await getLatestFindingGroupResources({ checkId, filters });
|
||||
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
|
||||
expect(url.searchParams.get("filter[name__icontains]")).toBe(
|
||||
"instance-prod",
|
||||
);
|
||||
expect(url.searchParams.get("filter[status__in]")).toBe("PASS,FAIL");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,17 +23,68 @@ function mapSearchFilter(
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export const getFindingGroups = async ({
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
sort = "",
|
||||
filters = {},
|
||||
}) => {
|
||||
function splitCsvFilterValues(value: string | string[] | undefined): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.flatMap((item) => item.split(","))
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeFindingGroupResourceFilters(
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
): Record<string, string | string[] | undefined> {
|
||||
const normalized = { ...filters };
|
||||
const exactStatusFilter = normalized["filter[status]"];
|
||||
|
||||
if (exactStatusFilter !== undefined) {
|
||||
delete normalized["filter[status__in]"];
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const statusValues = splitCsvFilterValues(normalized["filter[status__in]"]);
|
||||
if (statusValues.length === 1) {
|
||||
normalized["filter[status]"] = statusValues[0];
|
||||
delete normalized["filter[status__in]"];
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const DEFAULT_FINDING_GROUPS_SORT =
|
||||
"-severity,-delta,-fail_count,-last_seen_at";
|
||||
|
||||
interface FetchFindingGroupsParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: string;
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
async function fetchFindingGroupsEndpoint(
|
||||
endpoint: string,
|
||||
{
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
sort = DEFAULT_FINDING_GROUPS_SORT,
|
||||
filters = {},
|
||||
}: FetchFindingGroupsParams,
|
||||
) {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
if (isNaN(Number(page)) || page < 1) redirect("/findings");
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/finding-groups`);
|
||||
const url = new URL(`${apiBaseUrl}/${endpoint}`);
|
||||
|
||||
if (page) url.searchParams.append("page[number]", page.toString());
|
||||
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
|
||||
@@ -45,120 +96,60 @@ export const getFindingGroups = async ({
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
return handleApiResponse(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching finding groups:", error);
|
||||
console.error(`Error fetching ${endpoint}:`, error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const getLatestFindingGroups = async ({
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
sort = "",
|
||||
filters = {},
|
||||
}) => {
|
||||
export const getFindingGroups = async (params: FetchFindingGroupsParams = {}) =>
|
||||
fetchFindingGroupsEndpoint("finding-groups", params);
|
||||
|
||||
export const getLatestFindingGroups = async (
|
||||
params: FetchFindingGroupsParams = {},
|
||||
) => fetchFindingGroupsEndpoint("finding-groups/latest", params);
|
||||
|
||||
interface FetchFindingGroupResourcesParams {
|
||||
checkId: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
async function fetchFindingGroupResourcesEndpoint(
|
||||
endpointPrefix: string,
|
||||
{
|
||||
checkId,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
filters = {},
|
||||
}: FetchFindingGroupResourcesParams,
|
||||
) {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const normalizedFilters = normalizeFindingGroupResourceFilters(filters);
|
||||
|
||||
if (isNaN(Number(page)) || page < 1) redirect("/findings");
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/finding-groups/latest`);
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/${endpointPrefix}/${encodeURIComponent(checkId)}/resources`,
|
||||
);
|
||||
|
||||
if (page) url.searchParams.append("page[number]", page.toString());
|
||||
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
|
||||
if (sort) url.searchParams.append("sort", sort);
|
||||
url.searchParams.append("sort", "-severity,-delta,-last_seen_at");
|
||||
|
||||
appendSanitizedProviderFilters(url, mapSearchFilter(filters));
|
||||
appendSanitizedProviderFilters(url, normalizedFilters);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
return handleApiResponse(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching latest finding groups:", error);
|
||||
console.error(`Error fetching ${endpointPrefix} resources:`, error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const getFindingGroupResources = async ({
|
||||
checkId,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
filters = {},
|
||||
}: {
|
||||
checkId: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
export const getFindingGroupResources = async (
|
||||
params: FetchFindingGroupResourcesParams,
|
||||
) => fetchFindingGroupResourcesEndpoint("finding-groups", params);
|
||||
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/finding-groups/${encodeURIComponent(checkId)}/resources`,
|
||||
);
|
||||
|
||||
if (page) url.searchParams.append("page[number]", page.toString());
|
||||
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
|
||||
// sort=-status is kept for future-proofing: if the filter[status]=FAIL
|
||||
// constraint is ever relaxed to allow multiple statuses, the sort ensures
|
||||
// FAIL resources still appear first in the result set.
|
||||
url.searchParams.append("sort", "-status");
|
||||
|
||||
appendSanitizedProviderFilters(url, filters);
|
||||
|
||||
// Use .set() AFTER appendSanitizedProviderFilters so our hardcoded FAIL
|
||||
// always wins, even if the caller passed a different filter[status] value.
|
||||
// Using .set() instead of .append() prevents duplicate filter[status] params.
|
||||
url.searchParams.set("filter[status]", "FAIL");
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
headers,
|
||||
});
|
||||
|
||||
return handleApiResponse(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching finding group resources:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const getLatestFindingGroupResources = async ({
|
||||
checkId,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
filters = {},
|
||||
}: {
|
||||
checkId: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/finding-groups/latest/${encodeURIComponent(checkId)}/resources`,
|
||||
);
|
||||
|
||||
if (page) url.searchParams.append("page[number]", page.toString());
|
||||
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
|
||||
// sort=-status is kept for future-proofing: if the filter[status]=FAIL
|
||||
// constraint is ever relaxed to allow multiple statuses, the sort ensures
|
||||
// FAIL resources still appear first in the result set.
|
||||
url.searchParams.append("sort", "-status");
|
||||
|
||||
appendSanitizedProviderFilters(url, filters);
|
||||
|
||||
// Use .set() AFTER appendSanitizedProviderFilters so our hardcoded FAIL
|
||||
// always wins, even if the caller passed a different filter[status] value.
|
||||
// Using .set() instead of .append() prevents duplicate filter[status] params.
|
||||
url.searchParams.set("filter[status]", "FAIL");
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
headers,
|
||||
});
|
||||
|
||||
return handleApiResponse(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching latest finding group resources:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
export const getLatestFindingGroupResources = async (
|
||||
params: FetchFindingGroupResourcesParams,
|
||||
) => fetchFindingGroupResourcesEndpoint("finding-groups/latest", params);
|
||||
|
||||
@@ -379,6 +379,9 @@ export const getLatestFindingsByResourceUid = async ({
|
||||
);
|
||||
|
||||
url.searchParams.append("filter[resource_uid]", resourceUid);
|
||||
url.searchParams.append("filter[status]", "FAIL");
|
||||
url.searchParams.append("filter[muted]", "include");
|
||||
url.searchParams.append("sort", "-severity,status,-updated_at");
|
||||
if (page) url.searchParams.append("page[number]", page.toString());
|
||||
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
|
||||
|
||||
|
||||
37
ui/app/(prowler)/findings/page.test.ts
Normal file
37
ui/app/(prowler)/findings/page.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Source-level assertions for the findings page.
|
||||
*
|
||||
* Directly importing page.tsx triggers deep transitive imports
|
||||
* (next-auth → next/server) that vitest cannot resolve without the
|
||||
* full Next.js build pipeline. These tests verify key architectural
|
||||
* invariants via source analysis instead.
|
||||
*/
|
||||
describe("findings page", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pagePath = path.join(currentDir, "page.tsx");
|
||||
const source = readFileSync(pagePath, "utf8");
|
||||
|
||||
it("only passes sort to fetchFindingGroups when the user has an explicit sort param", () => {
|
||||
expect(source).toContain("...(encodedSort && { sort: encodedSort })");
|
||||
});
|
||||
|
||||
it("normalizes scan filters with the required inserted_at params before fetching historical finding groups", () => {
|
||||
expect(source).toContain("resolveFindingScanDateFilters");
|
||||
});
|
||||
|
||||
it("uses getLatestFindingGroups for non-date/scan queries and getFindingGroups for historical", () => {
|
||||
expect(source).toContain("hasDateOrScan");
|
||||
expect(source).toContain("getFindingGroups");
|
||||
expect(source).toContain("getLatestFindingGroups");
|
||||
});
|
||||
|
||||
it("guards errors array access with a length check", () => {
|
||||
expect(source).toContain("errors?.length > 0");
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@/actions/finding-groups";
|
||||
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { getScan, getScans } from "@/actions/scans";
|
||||
import { FindingsFilters } from "@/components/findings/findings-filters";
|
||||
import {
|
||||
FindingsGroupTable,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
extractSortAndKey,
|
||||
hasDateOrScanFilter,
|
||||
} from "@/lib";
|
||||
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
|
||||
import { ScanEntity, ScanProps } from "@/types";
|
||||
import { SearchParamsProps } from "@/types/components";
|
||||
|
||||
@@ -39,16 +40,28 @@ export default async function Findings({
|
||||
// TODO: Re-implement deep link support (/findings?id=<uuid>) using the grouped view's resource detail drawer
|
||||
// once the legacy FindingDetailsSheet is fully deprecated (still used by /resources and overview dashboard).
|
||||
|
||||
const [metadataInfoData, providersData, scansData] = await Promise.all([
|
||||
(hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({
|
||||
query,
|
||||
sort: encodedSort,
|
||||
filters,
|
||||
}),
|
||||
const [providersData, scansData] = await Promise.all([
|
||||
getProviders({ pageSize: 50 }),
|
||||
getScans({ pageSize: 50 }),
|
||||
]);
|
||||
|
||||
const filtersWithScanDates = await resolveFindingScanDateFilters({
|
||||
filters,
|
||||
scans: scansData?.data || [],
|
||||
loadScan: async (scanId: string) => {
|
||||
const response = await getScan(scanId);
|
||||
return response?.data;
|
||||
},
|
||||
});
|
||||
|
||||
const metadataInfoData = await (
|
||||
hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo
|
||||
)({
|
||||
query,
|
||||
sort: encodedSort,
|
||||
filters: filtersWithScanDates,
|
||||
});
|
||||
|
||||
// Extract unique regions, services, categories, groups from the new endpoint
|
||||
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
|
||||
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
|
||||
@@ -88,7 +101,10 @@ export default async function Findings({
|
||||
/>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTableFindings />}>
|
||||
<SSRDataTable searchParams={resolvedSearchParams} />
|
||||
<SSRDataTable
|
||||
searchParams={resolvedSearchParams}
|
||||
filters={filtersWithScanDates}
|
||||
/>
|
||||
</Suspense>
|
||||
</FilterTransitionWrapper>
|
||||
</ContentLayout>
|
||||
@@ -97,19 +113,15 @@ export default async function Findings({
|
||||
|
||||
const SSRDataTable = async ({
|
||||
searchParams,
|
||||
filters,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
filters: Record<string, string>;
|
||||
}) => {
|
||||
const page = parseInt(searchParams.page?.toString() || "1", 10);
|
||||
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
|
||||
const defaultSort = "-severity,-fail_count,-last_seen_at";
|
||||
|
||||
const { encodedSort } = extractSortAndKey({
|
||||
...searchParams,
|
||||
sort: searchParams.sort ?? defaultSort,
|
||||
});
|
||||
|
||||
const { filters } = extractFiltersAndQuery(searchParams);
|
||||
const { encodedSort } = extractSortAndKey(searchParams);
|
||||
// Check if the searchParams contain any date or scan filter
|
||||
const hasDateOrScan = hasDateOrScanFilter(searchParams);
|
||||
|
||||
@@ -119,7 +131,7 @@ const SSRDataTable = async ({
|
||||
|
||||
const findingGroupsData = await fetchFindingGroups({
|
||||
page,
|
||||
sort: encodedSort,
|
||||
...(encodedSort && { sort: encodedSort }),
|
||||
filters,
|
||||
pageSize,
|
||||
});
|
||||
@@ -131,7 +143,7 @@ const SSRDataTable = async ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{findingGroupsData?.errors && (
|
||||
{findingGroupsData?.errors?.length > 0 && (
|
||||
<div className="text-small mb-4 flex rounded-lg border border-red-500 bg-red-100 p-2 text-red-700">
|
||||
<p className="mr-2 font-semibold">Error:</p>
|
||||
<p>{findingGroupsData.errors[0].detail}</p>
|
||||
|
||||
@@ -18,7 +18,12 @@ import {
|
||||
createProviderDetailsMapping,
|
||||
extractProviderUIDs,
|
||||
} from "@/lib/provider-helpers";
|
||||
import { ProviderProps, ScanProps, SearchParamsProps } from "@/types";
|
||||
import {
|
||||
ExpandedScanData,
|
||||
ProviderProps,
|
||||
ScanProps,
|
||||
SearchParamsProps,
|
||||
} from "@/types";
|
||||
|
||||
export default async function Scans({
|
||||
searchParams,
|
||||
@@ -30,7 +35,34 @@ export default async function Scans({
|
||||
const filteredParams = { ...resolvedSearchParams };
|
||||
delete filteredParams.scanId;
|
||||
|
||||
const providersData = await getAllProviders();
|
||||
const [providersData, completedScansData] = await Promise.all([
|
||||
getAllProviders(),
|
||||
getScans({
|
||||
filters: { "filter[state]": "completed" },
|
||||
pageSize: 50,
|
||||
fields: { scans: "name,completed_at,provider" },
|
||||
include: "provider",
|
||||
}),
|
||||
]);
|
||||
|
||||
const completedScans: ExpandedScanData[] = (completedScansData?.data ?? [])
|
||||
.map((scan: ScanProps) => {
|
||||
const providerId = scan.relationships?.provider?.data?.id;
|
||||
const providerData = completedScansData?.included?.find(
|
||||
(item: { type: string; id: string }) =>
|
||||
item.type === "providers" && item.id === providerId,
|
||||
);
|
||||
if (!providerData) return null;
|
||||
return {
|
||||
...scan,
|
||||
providerInfo: {
|
||||
provider: providerData.attributes.provider,
|
||||
uid: providerData.attributes.uid,
|
||||
alias: providerData.attributes.alias,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as ExpandedScanData[];
|
||||
|
||||
const providerInfo =
|
||||
providersData?.data
|
||||
@@ -90,6 +122,7 @@ export default async function Scans({
|
||||
<ScansFilters
|
||||
providerUIDs={providerUIDs}
|
||||
providerDetails={providerDetails}
|
||||
completedScans={completedScans}
|
||||
/>
|
||||
<div className="flex items-center justify-end">
|
||||
<MutedFindingsConfigButton />
|
||||
|
||||
@@ -18,11 +18,12 @@ import { Button } from "@/components/shadcn";
|
||||
import { ExpandableSection } from "@/components/ui/expandable-section";
|
||||
import { DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { useFilterBatch } from "@/hooks/use-filter-batch";
|
||||
import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories";
|
||||
import { FilterType, FINDING_STATUS_DISPLAY_NAMES, ScanEntity } from "@/types";
|
||||
import { getCategoryLabel, getGroupLabel } from "@/lib/categories";
|
||||
import { FilterType, ScanEntity } from "@/types";
|
||||
import { DATA_TABLE_FILTER_MODE, FilterParam } from "@/types/filters";
|
||||
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
|
||||
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { getFindingsFilterDisplayValue } from "./findings-filters.utils";
|
||||
|
||||
interface FindingsFiltersProps {
|
||||
/** Provider data for ProviderTypeSelector and AccountsSelector */
|
||||
@@ -58,49 +59,6 @@ const FILTER_KEY_LABELS: Record<FilterParam, string> = {
|
||||
"filter[muted]": "Muted",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a raw filter value into a human-readable display string.
|
||||
* - Provider types: uses shared getProviderDisplayName utility
|
||||
* - Severities: uses shared SEVERITY_DISPLAY_NAMES (e.g. "critical" → "Critical")
|
||||
* - Status: uses shared FINDING_STATUS_DISPLAY_NAMES (e.g. "FAIL" → "Fail")
|
||||
* - Categories: uses getCategoryLabel (handles IAM, EC2, IMDSv1, etc.)
|
||||
* - Resource groups: uses getGroupLabel (underscore-delimited)
|
||||
* - Date (filter[inserted_at]): returns the ISO date string as-is (YYYY-MM-DD)
|
||||
* - Other values: uses formatLabel as a generic fallback (avoids naive capitalisation)
|
||||
*/
|
||||
const formatFilterValue = (filterKey: string, value: string): string => {
|
||||
if (!value) return value;
|
||||
if (filterKey === "filter[provider_type__in]") {
|
||||
return getProviderDisplayName(value);
|
||||
}
|
||||
if (filterKey === "filter[severity__in]") {
|
||||
return (
|
||||
SEVERITY_DISPLAY_NAMES[
|
||||
value.toLowerCase() as keyof typeof SEVERITY_DISPLAY_NAMES
|
||||
] ?? formatLabel(value)
|
||||
);
|
||||
}
|
||||
if (filterKey === "filter[status__in]") {
|
||||
return (
|
||||
FINDING_STATUS_DISPLAY_NAMES[
|
||||
value as keyof typeof FINDING_STATUS_DISPLAY_NAMES
|
||||
] ?? formatLabel(value)
|
||||
);
|
||||
}
|
||||
if (filterKey === "filter[category__in]") {
|
||||
return getCategoryLabel(value);
|
||||
}
|
||||
if (filterKey === "filter[resource_groups__in]") {
|
||||
return getGroupLabel(value);
|
||||
}
|
||||
// Date filter: preserve ISO date string (YYYY-MM-DD) — do not run through formatLabel
|
||||
if (filterKey === "filter[inserted_at]") {
|
||||
return value;
|
||||
}
|
||||
// Generic fallback: handles hyphen/underscore-delimited IDs with smart capitalisation
|
||||
return formatLabel(value);
|
||||
};
|
||||
|
||||
export const FindingsFilters = ({
|
||||
providers,
|
||||
completedScanIds,
|
||||
@@ -185,7 +143,10 @@ export const FindingsFilters = ({
|
||||
key,
|
||||
label,
|
||||
value,
|
||||
displayValue: formatFilterValue(key, value),
|
||||
displayValue: getFindingsFilterDisplayValue(key, value, {
|
||||
providers,
|
||||
scans: scanDetails,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
148
ui/components/findings/findings-filters.utils.test.ts
Normal file
148
ui/components/findings/findings-filters.utils.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
|
||||
import { getFindingsFilterDisplayValue } from "./findings-filters.utils";
|
||||
|
||||
function makeProvider(
|
||||
overrides: Partial<ProviderProps> & { id: string },
|
||||
): ProviderProps {
|
||||
return {
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "aws",
|
||||
uid: "123456789012",
|
||||
alias: "Production Account",
|
||||
status: "completed",
|
||||
resources: 10,
|
||||
connection: { connected: true, last_checked_at: "2026-04-07T10:00:00Z" },
|
||||
scanner_args: {
|
||||
only_logs: false,
|
||||
excluded_checks: [],
|
||||
aws_retries_max_attempts: 3,
|
||||
},
|
||||
inserted_at: "2026-04-07T10:00:00Z",
|
||||
updated_at: "2026-04-07T10:00:00Z",
|
||||
created_by: { object: "user", id: "user-1" },
|
||||
},
|
||||
relationships: {
|
||||
secret: { data: null },
|
||||
provider_groups: { meta: { count: 0 }, data: [] },
|
||||
},
|
||||
...overrides,
|
||||
} as ProviderProps;
|
||||
}
|
||||
|
||||
function makeScanMap(
|
||||
scanId: string,
|
||||
overrides?: Partial<ScanEntity>,
|
||||
): { [scanId: string]: ScanEntity } {
|
||||
return {
|
||||
[scanId]: {
|
||||
id: scanId,
|
||||
providerInfo: {
|
||||
provider: "aws",
|
||||
alias: "Scan Account",
|
||||
uid: "123456789012",
|
||||
},
|
||||
attributes: {
|
||||
name: "Nightly scan",
|
||||
completed_at: "2026-04-07T10:00:00Z",
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const providers = [makeProvider({ id: "provider-1" })];
|
||||
const scans = [makeScanMap("scan-1")];
|
||||
|
||||
describe("getFindingsFilterDisplayValue", () => {
|
||||
it("shows the account alias for provider_id filters instead of the raw provider id", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue("filter[provider_id__in]", "provider-1", {
|
||||
providers,
|
||||
}),
|
||||
).toBe("Production Account");
|
||||
});
|
||||
|
||||
it("falls back to the provider uid when the alias is empty", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue("filter[provider_id__in]", "provider-2", {
|
||||
providers: [
|
||||
...providers,
|
||||
makeProvider({
|
||||
id: "provider-2",
|
||||
attributes: {
|
||||
...providers[0].attributes,
|
||||
alias: "",
|
||||
uid: "210987654321",
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).toBe("210987654321");
|
||||
});
|
||||
|
||||
it("keeps the raw value when the provider cannot be resolved", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue(
|
||||
"filter[provider_id__in]",
|
||||
"missing-provider",
|
||||
{ providers },
|
||||
),
|
||||
).toBe("missing-provider");
|
||||
});
|
||||
|
||||
it("shows the resolved scan badge label for scan filters instead of formatting the raw scan id", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue("filter[scan__in]", "scan-1", { scans }),
|
||||
).toBe("Scan Account");
|
||||
});
|
||||
|
||||
it("falls back to the scan provider uid when the alias is missing", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue("filter[scan__in]", "scan-2", {
|
||||
scans: [
|
||||
...scans,
|
||||
makeScanMap("scan-2", {
|
||||
providerInfo: { provider: "aws", uid: "210987654321" },
|
||||
attributes: {
|
||||
name: "Weekly scan",
|
||||
completed_at: "2026-04-08T10:00:00Z",
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).toBe("210987654321");
|
||||
});
|
||||
|
||||
it("keeps the raw scan value when the scan cannot be resolved", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue("filter[scan__in]", "missing-scan", {
|
||||
scans,
|
||||
}),
|
||||
).toBe("missing-scan");
|
||||
});
|
||||
|
||||
it("passes through date values for inserted_at__gte filters", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue(
|
||||
"filter[inserted_at__gte]",
|
||||
"2026-04-03",
|
||||
{},
|
||||
),
|
||||
).toBe("2026-04-03");
|
||||
});
|
||||
|
||||
it("passes through date values for inserted_at__lte filters", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue(
|
||||
"filter[inserted_at__lte]",
|
||||
"2026-04-07",
|
||||
{},
|
||||
),
|
||||
).toBe("2026-04-07");
|
||||
});
|
||||
});
|
||||
80
ui/components/findings/findings-filters.utils.ts
Normal file
80
ui/components/findings/findings-filters.utils.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories";
|
||||
import { FINDING_STATUS_DISPLAY_NAMES } from "@/types";
|
||||
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
|
||||
|
||||
interface GetFindingsFilterDisplayValueOptions {
|
||||
providers?: ProviderProps[];
|
||||
scans?: Array<{ [scanId: string]: ScanEntity }>;
|
||||
}
|
||||
|
||||
function getProviderAccountDisplayValue(
|
||||
providerId: string,
|
||||
providers: ProviderProps[],
|
||||
): string {
|
||||
const provider = providers.find((item) => item.id === providerId);
|
||||
if (!provider) {
|
||||
return providerId;
|
||||
}
|
||||
|
||||
return provider.attributes.alias || provider.attributes.uid || providerId;
|
||||
}
|
||||
|
||||
function getScanDisplayValue(
|
||||
scanId: string,
|
||||
scans: Array<{ [scanId: string]: ScanEntity }>,
|
||||
): string {
|
||||
const scan = scans.find((item) => item[scanId])?.[scanId];
|
||||
if (!scan) {
|
||||
return scanId;
|
||||
}
|
||||
|
||||
return scan.providerInfo.alias || scan.providerInfo.uid || scanId;
|
||||
}
|
||||
|
||||
export function getFindingsFilterDisplayValue(
|
||||
filterKey: string,
|
||||
value: string,
|
||||
options: GetFindingsFilterDisplayValueOptions = {},
|
||||
): string {
|
||||
if (!value) return value;
|
||||
if (filterKey === "filter[provider_type__in]") {
|
||||
return getProviderDisplayName(value);
|
||||
}
|
||||
if (filterKey === "filter[provider_id__in]") {
|
||||
return getProviderAccountDisplayValue(value, options.providers || []);
|
||||
}
|
||||
if (filterKey === "filter[scan__in]") {
|
||||
return getScanDisplayValue(value, options.scans || []);
|
||||
}
|
||||
if (filterKey === "filter[severity__in]") {
|
||||
return (
|
||||
SEVERITY_DISPLAY_NAMES[
|
||||
value.toLowerCase() as keyof typeof SEVERITY_DISPLAY_NAMES
|
||||
] ?? formatLabel(value)
|
||||
);
|
||||
}
|
||||
if (filterKey === "filter[status__in]") {
|
||||
return (
|
||||
FINDING_STATUS_DISPLAY_NAMES[
|
||||
value as keyof typeof FINDING_STATUS_DISPLAY_NAMES
|
||||
] ?? formatLabel(value)
|
||||
);
|
||||
}
|
||||
if (filterKey === "filter[category__in]") {
|
||||
return getCategoryLabel(value);
|
||||
}
|
||||
if (filterKey === "filter[resource_groups__in]") {
|
||||
return getGroupLabel(value);
|
||||
}
|
||||
if (
|
||||
filterKey === "filter[inserted_at]" ||
|
||||
filterKey === "filter[inserted_at__gte]" ||
|
||||
filterKey === "filter[inserted_at__lte]"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return formatLabel(value);
|
||||
}
|
||||
@@ -17,11 +17,20 @@ vi.mock("next/navigation", () => ({
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Checkbox: ({
|
||||
"aria-label": ariaLabel,
|
||||
onCheckedChange,
|
||||
...props
|
||||
}: InputHTMLAttributes<HTMLInputElement> & {
|
||||
"aria-label"?: string;
|
||||
size?: string;
|
||||
}) => <input type="checkbox" aria-label={ariaLabel} {...props} />,
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={ariaLabel}
|
||||
onChange={(event) => onCheckedChange?.(event.target.checked)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
@@ -52,7 +61,13 @@ vi.mock("./impacted-providers-cell", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./impacted-resources-cell", () => ({
|
||||
ImpactedResourcesCell: () => null,
|
||||
ImpactedResourcesCell: ({
|
||||
impacted,
|
||||
total,
|
||||
}: {
|
||||
impacted: number;
|
||||
total: number;
|
||||
}) => <span>{`${impacted}/${total}`}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("./notification-indicator", () => ({
|
||||
@@ -94,6 +109,7 @@ function makeGroup(overrides?: Partial<FindingGroupRow>): FindingGroupRow {
|
||||
function renderFindingCell(
|
||||
checkTitle: string,
|
||||
onDrillDown: (checkId: string, group: FindingGroupRow) => void,
|
||||
overrides?: Partial<FindingGroupRow>,
|
||||
) {
|
||||
const columns = getColumnFindingGroups({
|
||||
rowSelection: {},
|
||||
@@ -107,7 +123,7 @@ function renderFindingCell(
|
||||
);
|
||||
if (!findingColumn?.cell) throw new Error("finding column not found");
|
||||
|
||||
const group = makeGroup({ checkTitle });
|
||||
const group = makeGroup({ checkTitle, ...overrides });
|
||||
// Render the cell directly with a minimal row mock
|
||||
const CellComponent = findingColumn.cell as (props: {
|
||||
row: { original: FindingGroupRow };
|
||||
@@ -116,6 +132,67 @@ function renderFindingCell(
|
||||
render(<div>{CellComponent({ row: { original: group } })}</div>);
|
||||
}
|
||||
|
||||
function renderImpactedResourcesCell(overrides?: Partial<FindingGroupRow>) {
|
||||
const columns = getColumnFindingGroups({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
onDrillDown: vi.fn(),
|
||||
});
|
||||
|
||||
const impactedResourcesColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "impactedResources",
|
||||
);
|
||||
if (!impactedResourcesColumn?.cell) {
|
||||
throw new Error("impactedResources column not found");
|
||||
}
|
||||
|
||||
const group = makeGroup(overrides);
|
||||
const CellComponent = impactedResourcesColumn.cell as (props: {
|
||||
row: { original: FindingGroupRow };
|
||||
}) => ReactNode;
|
||||
|
||||
render(<div>{CellComponent({ row: { original: group } })}</div>);
|
||||
}
|
||||
|
||||
function renderSelectCell(overrides?: Partial<FindingGroupRow>) {
|
||||
const toggleSelected = vi.fn();
|
||||
const columns = getColumnFindingGroups({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
onDrillDown: vi.fn(),
|
||||
});
|
||||
|
||||
const selectColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "select",
|
||||
);
|
||||
if (!selectColumn?.cell) {
|
||||
throw new Error("select column not found");
|
||||
}
|
||||
|
||||
const group = makeGroup(overrides);
|
||||
const CellComponent = selectColumn.cell as (props: {
|
||||
row: {
|
||||
id: string;
|
||||
original: FindingGroupRow;
|
||||
toggleSelected: (selected: boolean) => void;
|
||||
};
|
||||
}) => ReactNode;
|
||||
|
||||
render(
|
||||
<div>
|
||||
{CellComponent({
|
||||
row: {
|
||||
id: "0",
|
||||
original: group,
|
||||
toggleSelected,
|
||||
},
|
||||
})}
|
||||
</div>,
|
||||
);
|
||||
|
||||
return { toggleSelected };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 5: Accessibility — <p onClick> → <button>
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -191,4 +268,60 @@ describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
// Then — native button handles Enter natively
|
||||
expect(onDrillDown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should allow expanding a group that only has PASS resources", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
|
||||
renderFindingCell("My Passing Check", onDrillDown, {
|
||||
resourcesTotal: 2,
|
||||
resourcesFail: 0,
|
||||
status: "PASS",
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "My Passing Check",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(onDrillDown).toHaveBeenCalledTimes(1);
|
||||
expect(onDrillDown).toHaveBeenCalledWith(
|
||||
"s3_check",
|
||||
expect.objectContaining({
|
||||
resourcesTotal: 2,
|
||||
resourcesFail: 0,
|
||||
status: "PASS",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("column-finding-groups — impacted resources count", () => {
|
||||
it("should keep impacted resources based on failing resources only", () => {
|
||||
// Given/When
|
||||
renderImpactedResourcesCell({
|
||||
resourcesTotal: 5,
|
||||
resourcesFail: 3,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("3/5")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("column-finding-groups — group selection", () => {
|
||||
it("should disable the row checkbox when the group has zero impacted resources", () => {
|
||||
renderSelectCell({
|
||||
resourcesTotal: 2,
|
||||
resourcesFail: 0,
|
||||
status: "PASS",
|
||||
});
|
||||
|
||||
expect(screen.getByRole("checkbox", { name: "Select row" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { cn } from "@/lib";
|
||||
import { FindingGroupRow, ProviderType } from "@/types";
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
import { canMuteFindingGroup } from "./finding-group-selection";
|
||||
import { ImpactedProvidersCell } from "./impacted-providers-cell";
|
||||
import { ImpactedResourcesCell } from "./impacted-resources-cell";
|
||||
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
|
||||
@@ -26,6 +27,9 @@ interface GetColumnFindingGroupsOptions {
|
||||
hasResourceSelection?: boolean;
|
||||
}
|
||||
|
||||
const VISIBLE_DISABLED_CHECKBOX_CLASS =
|
||||
"disabled:opacity-100 disabled:bg-bg-input-primary/60 disabled:border-border-input-primary/70";
|
||||
|
||||
export function getColumnFindingGroups({
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
@@ -56,6 +60,7 @@ export function getColumnFindingGroups({
|
||||
<div className="w-4" />
|
||||
<Checkbox
|
||||
size="sm"
|
||||
className={VISIBLE_DISABLED_CHECKBOX_CLASS}
|
||||
checked={headerChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
table.toggleAllPageRowsSelected(checked === true)
|
||||
@@ -80,7 +85,12 @@ export function getColumnFindingGroups({
|
||||
? DeltaValues.CHANGED
|
||||
: DeltaValues.NONE;
|
||||
|
||||
const canExpand = group.resourcesFail > 0;
|
||||
const canExpand = group.resourcesTotal > 0;
|
||||
const canSelect = canMuteFindingGroup({
|
||||
resourcesFail: group.resourcesFail,
|
||||
resourcesTotal: group.resourcesTotal,
|
||||
mutedCount: group.mutedCount,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -104,11 +114,13 @@ export function getColumnFindingGroups({
|
||||
)}
|
||||
<Checkbox
|
||||
size="sm"
|
||||
className={VISIBLE_DISABLED_CHECKBOX_CLASS}
|
||||
checked={
|
||||
rowSelection[row.id] && isExpanded && hasResourceSelection
|
||||
? "indeterminate"
|
||||
: !!rowSelection[row.id]
|
||||
}
|
||||
disabled={!canSelect}
|
||||
onCheckedChange={(checked) => {
|
||||
// When indeterminate (resources selected), clicking deselects the group
|
||||
if (
|
||||
@@ -155,7 +167,7 @@ export function getColumnFindingGroups({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const group = row.original;
|
||||
const canExpand = group.resourcesFail > 0;
|
||||
const canExpand = group.resourcesTotal > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
203
ui/components/findings/table/column-finding-resources.test.tsx
Normal file
203
ui/components/findings/table/column-finding-resources.test.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Checkbox: ({
|
||||
"aria-label": ariaLabel,
|
||||
onCheckedChange,
|
||||
...props
|
||||
}: InputHTMLAttributes<HTMLInputElement> & {
|
||||
"aria-label"?: string;
|
||||
size?: string;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={ariaLabel}
|
||||
onChange={(event) => onCheckedChange?.(event.target.checked)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/mute-findings-modal", () => ({
|
||||
MuteFindingsModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
SendToJiraModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/services/IconServices", () => ({
|
||||
JiraIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
ActionDropdown: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
ActionDropdownItem: ({ label }: { label: string }) => (
|
||||
<button>{label}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/info-field/info-field", () => ({
|
||||
InfoField: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/spinner/spinner", () => ({
|
||||
Spinner: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities", () => ({
|
||||
DateWithTime: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities/entity-info", () => ({
|
||||
EntityInfo: ({
|
||||
entityAlias,
|
||||
entityId,
|
||||
}: {
|
||||
entityAlias?: string;
|
||||
entityId?: string;
|
||||
}) => (
|
||||
<div>
|
||||
<span>{entityAlias}</span>
|
||||
<span>{entityId}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
SeverityBadge: ({ severity }: { severity: string }) => (
|
||||
<span>{severity}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table/data-table-column-header", () => ({
|
||||
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table/status-finding-badge", () => ({
|
||||
StatusFindingBadge: ({ status }: { status: string }) => <span>{status}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/date-utils", () => ({
|
||||
getFailingForLabel: () => "2d",
|
||||
}));
|
||||
|
||||
const notificationIndicatorMock = vi.fn((_props: unknown) => null);
|
||||
|
||||
vi.mock("./notification-indicator", () => ({
|
||||
NotificationIndicator: (props: unknown) => {
|
||||
notificationIndicatorMock(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
|
||||
import { getColumnFindingResources } from "./column-finding-resources";
|
||||
|
||||
function makeResource(
|
||||
overrides?: Partial<FindingResourceRow>,
|
||||
): FindingResourceRow {
|
||||
return {
|
||||
id: "resource-row-1",
|
||||
rowType: "resource",
|
||||
findingId: "finding-1",
|
||||
checkId: "s3_check",
|
||||
providerType: "aws",
|
||||
providerAlias: "production",
|
||||
providerUid: "123456789",
|
||||
resourceName: "my-bucket",
|
||||
resourceType: "bucket",
|
||||
resourceGroup: "default",
|
||||
resourceUid: "arn:aws:s3:::my-bucket",
|
||||
service: "s3",
|
||||
region: "us-east-1",
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
delta: "new",
|
||||
isMuted: false,
|
||||
firstSeenAt: null,
|
||||
lastSeenAt: "2024-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("column-finding-resources", () => {
|
||||
it("should pass delta to NotificationIndicator for resource rows", () => {
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
});
|
||||
|
||||
const selectColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "select",
|
||||
);
|
||||
if (!selectColumn?.cell) {
|
||||
throw new Error("select column not found");
|
||||
}
|
||||
|
||||
const CellComponent = selectColumn.cell as (props: {
|
||||
row: {
|
||||
id: string;
|
||||
original: FindingResourceRow;
|
||||
toggleSelected: (selected: boolean) => void;
|
||||
};
|
||||
}) => ReactNode;
|
||||
|
||||
render(
|
||||
<div>
|
||||
{CellComponent({
|
||||
row: {
|
||||
id: "0",
|
||||
original: makeResource(),
|
||||
toggleSelected: vi.fn(),
|
||||
},
|
||||
})}
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Select resource")).toBeInTheDocument();
|
||||
expect(notificationIndicatorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
delta: "new",
|
||||
isMuted: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render the resource EntityInfo with resourceName as alias", () => {
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
});
|
||||
|
||||
const resourceColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "resource",
|
||||
);
|
||||
if (!resourceColumn?.cell) {
|
||||
throw new Error("resource column not found");
|
||||
}
|
||||
|
||||
const CellComponent = resourceColumn.cell as (props: {
|
||||
row: { original: FindingResourceRow };
|
||||
}) => ReactNode;
|
||||
|
||||
render(
|
||||
<div>
|
||||
{CellComponent({
|
||||
row: {
|
||||
original: makeResource(),
|
||||
},
|
||||
})}
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("my-bucket")).toBeInTheDocument();
|
||||
expect(screen.getByText("arn:aws:s3:::my-bucket")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -25,11 +25,16 @@ import {
|
||||
import { getFailingForLabel } from "@/lib/date-utils";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
|
||||
import { canMuteFindingResource } from "./finding-resource-selection";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import { NotificationIndicator } from "./notification-indicator";
|
||||
import {
|
||||
type DeltaType,
|
||||
NotificationIndicator,
|
||||
} from "./notification-indicator";
|
||||
|
||||
const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
const resource = row.original;
|
||||
const canMute = canMuteFindingResource(resource);
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
||||
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
|
||||
@@ -81,7 +86,7 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{!resource.isMuted && (
|
||||
{canMute && (
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
@@ -111,7 +116,7 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
)
|
||||
}
|
||||
label={isResolving ? "Resolving..." : getMuteLabel()}
|
||||
disabled={resource.isMuted || isResolving}
|
||||
disabled={!canMute || isResolving}
|
||||
onSelect={handleMuteClick}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
@@ -171,6 +176,7 @@ export function getColumnFindingResources({
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationIndicator
|
||||
delta={row.original.delta as DeltaType | undefined}
|
||||
isMuted={row.original.isMuted}
|
||||
mutedReason={row.original.mutedReason}
|
||||
/>
|
||||
@@ -178,7 +184,7 @@ export function getColumnFindingResources({
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={!!rowSelection[row.id]}
|
||||
disabled={row.original.isMuted}
|
||||
disabled={!canMuteFindingResource(row.original)}
|
||||
onCheckedChange={(checked) => row.toggleSelected(checked === true)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Select resource"
|
||||
@@ -198,7 +204,7 @@ export function getColumnFindingResources({
|
||||
<div className="max-w-[240px]">
|
||||
<EntityInfo
|
||||
nameIcon={<Container className="size-4" />}
|
||||
entityAlias={row.original.resourceGroup}
|
||||
entityAlias={row.original.resourceName}
|
||||
entityId={row.original.resourceUid}
|
||||
/>
|
||||
</div>
|
||||
@@ -213,8 +219,12 @@ export function getColumnFindingResources({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const rawStatus = row.original.status;
|
||||
const status =
|
||||
rawStatus === "MUTED" ? "FAIL" : (rawStatus as FindingStatus);
|
||||
const status: FindingStatus =
|
||||
rawStatus === "MUTED" || rawStatus === "FAIL"
|
||||
? "FAIL"
|
||||
: rawStatus === "PASS"
|
||||
? "PASS"
|
||||
: "FAIL";
|
||||
return <StatusFindingBadge status={status} />;
|
||||
},
|
||||
enableSorting: false,
|
||||
|
||||
45
ui/components/findings/table/finding-group-selection.test.ts
Normal file
45
ui/components/findings/table/finding-group-selection.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { canMuteFindingGroup } from "./finding-group-selection";
|
||||
|
||||
describe("canMuteFindingGroup", () => {
|
||||
it("returns false when impacted resources is zero", () => {
|
||||
expect(
|
||||
canMuteFindingGroup({
|
||||
resourcesFail: 0,
|
||||
resourcesTotal: 2,
|
||||
mutedCount: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when all resources are already muted", () => {
|
||||
expect(
|
||||
canMuteFindingGroup({
|
||||
resourcesFail: 3,
|
||||
resourcesTotal: 3,
|
||||
mutedCount: 3,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when all failing resources are muted even if PASS resources exist", () => {
|
||||
expect(
|
||||
canMuteFindingGroup({
|
||||
resourcesFail: 2,
|
||||
resourcesTotal: 5,
|
||||
mutedCount: 2,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when the group still has failing resources to mute", () => {
|
||||
expect(
|
||||
canMuteFindingGroup({
|
||||
resourcesFail: 2,
|
||||
resourcesTotal: 5,
|
||||
mutedCount: 1,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
13
ui/components/findings/table/finding-group-selection.ts
Normal file
13
ui/components/findings/table/finding-group-selection.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
interface FindingGroupSelectionState {
|
||||
resourcesFail: number;
|
||||
resourcesTotal: number;
|
||||
mutedCount: number;
|
||||
}
|
||||
|
||||
export function canMuteFindingGroup({
|
||||
resourcesFail,
|
||||
mutedCount,
|
||||
}: FindingGroupSelectionState): boolean {
|
||||
const allMuted = mutedCount > 0 && mutedCount === resourcesFail;
|
||||
return resourcesFail > 0 && !allMuted;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
|
||||
import { canMuteFindingResource } from "./finding-resource-selection";
|
||||
|
||||
function makeResource(
|
||||
overrides?: Partial<FindingResourceRow>,
|
||||
): FindingResourceRow {
|
||||
return {
|
||||
id: "finding-1",
|
||||
rowType: "resource",
|
||||
findingId: "finding-1",
|
||||
checkId: "check-1",
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
providerUid: "123456789012",
|
||||
resourceName: "bucket-a",
|
||||
resourceType: "Bucket",
|
||||
resourceGroup: "bucket-a",
|
||||
resourceUid: "arn:aws:s3:::bucket-a",
|
||||
service: "s3",
|
||||
region: "us-east-1",
|
||||
severity: "high",
|
||||
status: "FAIL",
|
||||
isMuted: false,
|
||||
firstSeenAt: null,
|
||||
lastSeenAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("canMuteFindingResource", () => {
|
||||
it("should allow muting FAIL resources that are not muted", () => {
|
||||
expect(canMuteFindingResource(makeResource())).toBe(true);
|
||||
});
|
||||
|
||||
it("should disable muting for PASS resources", () => {
|
||||
expect(canMuteFindingResource(makeResource({ status: "PASS" }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should disable muting for already muted resources", () => {
|
||||
expect(canMuteFindingResource(makeResource({ isMuted: true }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { FindingResourceRow } from "@/types";
|
||||
|
||||
export function canMuteFindingResource(resource: FindingResourceRow): boolean {
|
||||
return resource.status === "FAIL" && !resource.isMuted;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { FindingGroupRow, FindingResourceRow } from "@/types";
|
||||
|
||||
import { FloatingMuteButton } from "../floating-mute-button";
|
||||
import { getColumnFindingResources } from "./column-finding-resources";
|
||||
import { canMuteFindingResource } from "./finding-resource-selection";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import { ImpactedResourcesCell } from "./impacted-resources-cell";
|
||||
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
|
||||
@@ -82,7 +83,7 @@ export function FindingsGroupDrillDown({
|
||||
setIsLoading(loading);
|
||||
};
|
||||
|
||||
const { sentinelRef, refresh, loadMore } = useInfiniteResources({
|
||||
const { sentinelRef, refresh, loadMore, totalCount } = useInfiniteResources({
|
||||
checkId: group.checkId,
|
||||
hasDateOrScanFilter: hasDateOrScan,
|
||||
filters,
|
||||
@@ -95,7 +96,7 @@ export function FindingsGroupDrillDown({
|
||||
const drawer = useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: group.checkId,
|
||||
totalResourceCount: group.resourcesTotal,
|
||||
totalResourceCount: totalCount ?? group.resourcesTotal,
|
||||
onRequestMoreResources: loadMore,
|
||||
});
|
||||
|
||||
@@ -108,7 +109,7 @@ export function FindingsGroupDrillDown({
|
||||
const selectedFindingIds = Object.keys(rowSelection)
|
||||
.filter((key) => rowSelection[key])
|
||||
.map((idx) => resources[parseInt(idx)]?.findingId)
|
||||
.filter(Boolean);
|
||||
.filter((id): id is string => id !== null && id !== undefined && id !== "");
|
||||
|
||||
/** Converts resource_ids (display) → resourceUids → finding UUIDs via API. */
|
||||
const resolveResourceIds = async (ids: string[]) => {
|
||||
@@ -124,10 +125,10 @@ export function FindingsGroupDrillDown({
|
||||
});
|
||||
};
|
||||
|
||||
const selectableRowCount = resources.filter((r) => !r.isMuted).length;
|
||||
const selectableRowCount = resources.filter(canMuteFindingResource).length;
|
||||
|
||||
const getRowCanSelect = (row: Row<FindingResourceRow>): boolean => {
|
||||
return !row.original.isMuted;
|
||||
return canMuteFindingResource(row.original);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { FindingGroupRow, MetaDataProps } from "@/types";
|
||||
|
||||
import { FloatingMuteButton } from "../floating-mute-button";
|
||||
import { getColumnFindingGroups } from "./column-finding-groups";
|
||||
import { canMuteFindingGroup } from "./finding-group-selection";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import {
|
||||
InlineResourceContainer,
|
||||
@@ -88,13 +89,21 @@ export function FindingsGroupTable({
|
||||
.filter(Boolean);
|
||||
|
||||
// Count of selectable rows (groups where not ALL findings are muted)
|
||||
const selectableRowCount = safeData.filter(
|
||||
(g) => !(g.mutedCount > 0 && g.mutedCount === g.resourcesTotal),
|
||||
const selectableRowCount = safeData.filter((g) =>
|
||||
canMuteFindingGroup({
|
||||
resourcesFail: g.resourcesFail,
|
||||
resourcesTotal: g.resourcesTotal,
|
||||
mutedCount: g.mutedCount,
|
||||
}),
|
||||
).length;
|
||||
|
||||
const getRowCanSelect = (row: Row<FindingGroupRow>): boolean => {
|
||||
const group = row.original;
|
||||
return !(group.mutedCount > 0 && group.mutedCount === group.resourcesTotal);
|
||||
return canMuteFindingGroup({
|
||||
resourcesFail: group.resourcesFail,
|
||||
resourcesTotal: group.resourcesTotal,
|
||||
mutedCount: group.mutedCount,
|
||||
});
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
@@ -136,8 +145,8 @@ export function FindingsGroupTable({
|
||||
};
|
||||
|
||||
const handleDrillDown = (checkId: string, group: FindingGroupRow) => {
|
||||
// No impacted resources → nothing to show, skip drill-down
|
||||
if (group.resourcesFail === 0) return;
|
||||
// No resources in the group → nothing to show, skip drill-down
|
||||
if (group.resourcesTotal === 0) return;
|
||||
|
||||
// Toggle: same group = collapse, different = switch
|
||||
if (expandedCheckId === checkId) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { hasDateOrScanFilter } from "@/lib";
|
||||
import { FindingGroupRow, FindingResourceRow } from "@/types";
|
||||
|
||||
import { getColumnFindingResources } from "./column-finding-resources";
|
||||
import { canMuteFindingResource } from "./finding-resource-selection";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import {
|
||||
ResourceDetailDrawer,
|
||||
@@ -180,7 +181,7 @@ export function InlineResourceContainer({
|
||||
setIsLoading(loading);
|
||||
};
|
||||
|
||||
const { sentinelRef, refresh, loadMore } = useInfiniteResources({
|
||||
const { sentinelRef, refresh, loadMore, totalCount } = useInfiniteResources({
|
||||
checkId: group.checkId,
|
||||
hasDateOrScanFilter: hasDateOrScan,
|
||||
filters,
|
||||
@@ -194,7 +195,7 @@ export function InlineResourceContainer({
|
||||
const drawer = useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: group.checkId,
|
||||
totalResourceCount: group.resourcesTotal,
|
||||
totalResourceCount: totalCount ?? group.resourcesTotal,
|
||||
onRequestMoreResources: loadMore,
|
||||
});
|
||||
|
||||
@@ -222,10 +223,10 @@ export function InlineResourceContainer({
|
||||
});
|
||||
};
|
||||
|
||||
const selectableRowCount = resources.filter((r) => !r.isMuted).length;
|
||||
const selectableRowCount = resources.filter(canMuteFindingResource).length;
|
||||
|
||||
const getRowCanSelect = (row: Row<FindingResourceRow>): boolean => {
|
||||
return !row.original.isMuted;
|
||||
return canMuteFindingResource(row.original);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -10,17 +11,17 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
const {
|
||||
mockGetComplianceIcon,
|
||||
mockGetCompliancesOverview,
|
||||
mockRouterPush,
|
||||
mockWindowOpen,
|
||||
mockSearchParamsState,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetComplianceIcon: vi.fn((_: string) => null as string | null),
|
||||
mockGetCompliancesOverview: vi.fn(),
|
||||
mockRouterPush: vi.fn(),
|
||||
mockWindowOpen: vi.fn(),
|
||||
mockSearchParamsState: { value: "" },
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockRouterPush, refresh: vi.fn() }),
|
||||
useRouter: () => ({ refresh: vi.fn() }),
|
||||
usePathname: () => "/findings",
|
||||
useSearchParams: () => new URLSearchParams(mockSearchParamsState.value),
|
||||
redirect: vi.fn(),
|
||||
@@ -104,10 +105,30 @@ vi.mock("@/components/shadcn/card/card", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
ActionDropdown: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
ActionDropdown: ({
|
||||
children,
|
||||
ariaLabel,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
ariaLabel?: string;
|
||||
}) => (
|
||||
<div role="menu" aria-label={ariaLabel}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
ActionDropdownItem: ({
|
||||
label,
|
||||
disabled,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onSelect?: () => void;
|
||||
}) => (
|
||||
<button type="button" disabled={disabled} onClick={onSelect}>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
ActionDropdownItem: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/skeleton/skeleton", () => ({
|
||||
@@ -125,7 +146,25 @@ vi.mock("@/components/shadcn/tooltip", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/mute-findings-modal", () => ({
|
||||
MuteFindingsModal: () => null,
|
||||
MuteFindingsModal: ({
|
||||
isOpen,
|
||||
findingIds,
|
||||
onComplete,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
findingIds: string[];
|
||||
onComplete?: () => void;
|
||||
}) =>
|
||||
isOpen
|
||||
? globalThis.document?.body &&
|
||||
// Render into body to mirror the real modal portal behavior.
|
||||
createPortal(
|
||||
<button type="button" onClick={onComplete}>
|
||||
{`Confirm mute ${findingIds.join(",")}`}
|
||||
</button>,
|
||||
globalThis.document.body,
|
||||
)
|
||||
: null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
@@ -547,9 +586,14 @@ describe("ResourceDetailDrawerContent — compliance icon styling", () => {
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("should resolve the clicked framework against the selected scan and navigate to compliance detail", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal("open", mockWindowOpen);
|
||||
mockSearchParamsState.value =
|
||||
"filter[scan__in]=scan-selected&filter[region__in]=eu-west-1";
|
||||
mockGetCompliancesOverview.mockResolvedValue({
|
||||
@@ -595,14 +639,17 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
expect(mockGetCompliancesOverview).toHaveBeenCalledWith({
|
||||
scanId: "scan-selected",
|
||||
});
|
||||
expect(mockRouterPush).toHaveBeenCalledWith(
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
"/compliance/PCI-DSS?complianceId=compliance-1&version=4.0&scanId=scan-selected&filter%5Bregion__in%5D=eu-west-1",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
|
||||
it("should use the current finding scan when no scan filter is active", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal("open", mockWindowOpen);
|
||||
mockGetCompliancesOverview.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
@@ -662,8 +709,134 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
expect(mockGetCompliancesOverview).toHaveBeenCalledWith({
|
||||
scanId: "scan-from-finding",
|
||||
});
|
||||
expect(mockRouterPush).toHaveBeenCalledWith(
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
"/compliance/PCI-DSS?complianceId=compliance-2&version=4.0&scanId=scan-from-finding&scanData=%7B%22id%22%3A%22scan-from-finding%22%2C%22providerInfo%22%3A%7B%22provider%22%3A%22aws%22%2C%22alias%22%3A%22prod%22%2C%22uid%22%3A%22123456789%22%7D%2C%22attributes%22%3A%7B%22name%22%3A%22Nightly+scan%22%2C%22completed_at%22%3A%222026-03-30T10%3A05%3A00Z%22%7D%7D",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
|
||||
it("should navigate when the finding framework is a short alias of the compliance overview framework", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal("open", mockWindowOpen);
|
||||
mockGetComplianceIcon.mockImplementation((framework: string) =>
|
||||
framework.toLowerCase().includes("kisa") ? "/kisa.svg" : null,
|
||||
);
|
||||
mockGetCompliancesOverview.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "compliance-kisa",
|
||||
type: "compliance-overviews",
|
||||
attributes: {
|
||||
framework: "KISA-ISMS-P",
|
||||
version: "1.0",
|
||||
requirements_passed: 5,
|
||||
requirements_failed: 1,
|
||||
requirements_manual: 0,
|
||||
total_requirements: 6,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const findingWithScan = {
|
||||
...mockFinding,
|
||||
scan: {
|
||||
id: "scan-from-finding",
|
||||
name: "Nightly scan",
|
||||
trigger: "manual",
|
||||
state: "completed",
|
||||
uniqueResourceCount: 25,
|
||||
progress: 100,
|
||||
duration: 300,
|
||||
startedAt: "2026-03-30T10:00:00Z",
|
||||
completedAt: "2026-03-30T10:05:00Z",
|
||||
insertedAt: "2026-03-30T09:59:00Z",
|
||||
scheduledAt: null,
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={{
|
||||
...mockCheckMeta,
|
||||
complianceFrameworks: ["KISA"],
|
||||
}}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={findingWithScan}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Open KISA compliance details",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(mockGetCompliancesOverview).toHaveBeenCalledWith({
|
||||
scanId: "scan-from-finding",
|
||||
});
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
"/compliance/KISA-ISMS-P?complianceId=compliance-kisa&version=1.0&scanId=scan-from-finding&scanData=%7B%22id%22%3A%22scan-from-finding%22%2C%22providerInfo%22%3A%7B%22provider%22%3A%22aws%22%2C%22alias%22%3A%22prod%22%2C%22uid%22%3A%22123456789%22%7D%2C%22attributes%22%3A%7B%22name%22%3A%22Nightly+scan%22%2C%22completed_at%22%3A%222026-03-30T10%3A05%3A00Z%22%7D%7D",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — other findings mute refresh", () => {
|
||||
it("should update only the muted other-finding row without refreshing the current finding group", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onMuteComplete = vi.fn();
|
||||
const otherFinding: ResourceDrawerFinding = {
|
||||
...mockFinding,
|
||||
id: "finding-2",
|
||||
uid: "uid-2",
|
||||
checkId: "ec2_check",
|
||||
checkTitle: "EC2 Check",
|
||||
updatedAt: "2026-03-30T10:05:00Z",
|
||||
};
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[otherFinding]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={onMuteComplete}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const row = screen.getByText("EC2 Check").closest("tr");
|
||||
expect(row).not.toBeNull();
|
||||
|
||||
await user.click(
|
||||
within(row as HTMLElement).getByRole("button", { name: "Mute" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Confirm mute finding-2" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
within(row as HTMLElement).getByRole("button", { name: "Muted" }),
|
||||
).toBeDisabled();
|
||||
expect(onMuteComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getCompliancesOverview } from "@/actions/compliances";
|
||||
@@ -84,7 +84,90 @@ function normalizeComplianceFrameworkName(framework: string): string {
|
||||
return framework
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, "-");
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/-+/g, "-");
|
||||
}
|
||||
|
||||
function stripComplianceVersionSuffix(framework: string): string {
|
||||
return framework.replace(/-\d+(?:\.\d+)*$/g, "");
|
||||
}
|
||||
|
||||
function canonicalComplianceKey(framework: string): string {
|
||||
return stripComplianceVersionSuffix(
|
||||
normalizeComplianceFrameworkName(framework),
|
||||
)
|
||||
.replace(/[^a-z0-9]+/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function complianceTokens(framework: string): string[] {
|
||||
return stripComplianceVersionSuffix(
|
||||
normalizeComplianceFrameworkName(framework),
|
||||
)
|
||||
.split("-")
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean)
|
||||
.filter((token) => !/^\d+(?:\.\d+)*$/.test(token));
|
||||
}
|
||||
|
||||
function complianceMatchScore(
|
||||
sourceFramework: string,
|
||||
targetFramework: string,
|
||||
): number {
|
||||
const normalizedSource = normalizeComplianceFrameworkName(sourceFramework);
|
||||
const normalizedTarget = normalizeComplianceFrameworkName(targetFramework);
|
||||
|
||||
if (normalizedSource === normalizedTarget) {
|
||||
return 5;
|
||||
}
|
||||
|
||||
const canonicalSource = canonicalComplianceKey(sourceFramework);
|
||||
const canonicalTarget = canonicalComplianceKey(targetFramework);
|
||||
|
||||
if (canonicalSource === canonicalTarget) {
|
||||
return 4;
|
||||
}
|
||||
|
||||
if (canonicalSource && canonicalTarget) {
|
||||
const sourceTokens = canonicalSource.split("-");
|
||||
const targetTokens = canonicalTarget.split("-");
|
||||
if (
|
||||
sourceTokens.length !== targetTokens.length &&
|
||||
(sourceTokens.every((t) => targetTokens.includes(t)) ||
|
||||
targetTokens.every((t) => sourceTokens.includes(t)))
|
||||
) {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
const sourceTokens = complianceTokens(sourceFramework);
|
||||
const targetTokens = complianceTokens(targetFramework);
|
||||
if (!sourceTokens.length || !targetTokens.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const sourceMatchesTarget = sourceTokens.every((token) =>
|
||||
targetTokens.includes(token),
|
||||
);
|
||||
const targetMatchesSource = targetTokens.every((token) =>
|
||||
sourceTokens.includes(token),
|
||||
);
|
||||
|
||||
if (sourceMatchesTarget || targetMatchesSource) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (
|
||||
sourceTokens.some((token) => targetTokens.includes(token)) &&
|
||||
canonicalSource &&
|
||||
canonicalTarget &&
|
||||
(canonicalTarget.includes(canonicalSource) ||
|
||||
canonicalSource.includes(canonicalTarget))
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseSelectedScanIds(scanFilterValue: string | null): string[] {
|
||||
@@ -110,12 +193,13 @@ function resolveComplianceMatch(
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedFramework = normalizeComplianceFrameworkName(framework);
|
||||
const match = compliances.find(
|
||||
(compliance) =>
|
||||
normalizeComplianceFrameworkName(compliance.attributes.framework) ===
|
||||
normalizedFramework,
|
||||
);
|
||||
const match = compliances
|
||||
.map((compliance) => ({
|
||||
compliance,
|
||||
score: complianceMatchScore(framework, compliance.attributes.framework),
|
||||
}))
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)[0]?.compliance;
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
@@ -202,13 +286,15 @@ export function ResourceDetailDrawerContent({
|
||||
onNavigateNext,
|
||||
onMuteComplete,
|
||||
}: ResourceDetailDrawerContentProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
||||
const [resolvingFramework, setResolvingFramework] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [optimisticallyMutedIds, setOptimisticallyMutedIds] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
|
||||
// Initial load — no check metadata yet
|
||||
if (!checkMeta && isLoading) {
|
||||
@@ -284,7 +370,7 @@ export function ResourceDetailDrawerContent({
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(
|
||||
window.open(
|
||||
buildComplianceDetailHref({
|
||||
complianceId: complianceMatch.complianceId,
|
||||
framework: complianceMatch.framework,
|
||||
@@ -294,6 +380,8 @@ export function ResourceDetailDrawerContent({
|
||||
currentFinding: f,
|
||||
includeScanData: f?.scan?.id === complianceScanId,
|
||||
}),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error resolving compliance detail:", error);
|
||||
@@ -428,10 +516,10 @@ export function ResourceDetailDrawerContent({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation: "Impacted Resource (X of N)" */}
|
||||
{/* Navigation: "Resource (X of N)" */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="tag" className="rounded text-sm">
|
||||
Impacted Resource
|
||||
Resource
|
||||
<span className="font-bold">{currentIndex + 1}</span>
|
||||
<span className="font-normal">of</span>
|
||||
<span className="font-bold">{totalResources}</span>
|
||||
@@ -477,7 +565,7 @@ export function ResourceDetailDrawerContent({
|
||||
/>
|
||||
<EntityInfo
|
||||
nameIcon={<Container className="size-4" />}
|
||||
entityAlias={f.resourceGroup}
|
||||
entityAlias={f.resourceName}
|
||||
entityId={f.resourceUid}
|
||||
idLabel="UID"
|
||||
/>
|
||||
@@ -505,7 +593,9 @@ export function ResourceDetailDrawerContent({
|
||||
<InfoField label="Failing for" variant="compact">
|
||||
{getFailingForLabel(f.firstSeenAt) || "-"}
|
||||
</InfoField>
|
||||
<div className="hidden md:block" />
|
||||
<InfoField label="Group" variant="compact">
|
||||
{f.resourceGroup || "-"}
|
||||
</InfoField>
|
||||
|
||||
{/* Row 3: IDs */}
|
||||
<InfoField label="Check ID" variant="compact">
|
||||
@@ -529,6 +619,11 @@ export function ResourceDetailDrawerContent({
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
</InfoField>
|
||||
|
||||
{/* Row 4: Resource metadata */}
|
||||
<InfoField label="Resource type" variant="compact">
|
||||
{f.resourceType || "-"}
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
{/* Actions button — fixed size, aligned with row 1 */}
|
||||
@@ -757,10 +852,7 @@ export function ResourceDetailDrawerContent({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-text-neutral-primary text-sm font-medium">
|
||||
Failed Findings For This Resource
|
||||
</h4>
|
||||
<div className="flex items-center justify-end">
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
{otherFindings.length} Total Entries
|
||||
</span>
|
||||
@@ -796,7 +888,18 @@ export function ResourceDetailDrawerContent({
|
||||
<TableBody>
|
||||
{otherFindings.length > 0 ? (
|
||||
otherFindings.map((finding) => (
|
||||
<OtherFindingRow key={finding.id} finding={finding} />
|
||||
<OtherFindingRow
|
||||
key={finding.id}
|
||||
finding={finding}
|
||||
isOptimisticallyMuted={optimisticallyMutedIds.has(
|
||||
finding.id,
|
||||
)}
|
||||
onMuted={() =>
|
||||
setOptimisticallyMutedIds((prev) =>
|
||||
new Set(prev).add(finding.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
@@ -908,19 +1011,32 @@ export function ResourceDetailDrawerContent({
|
||||
);
|
||||
}
|
||||
|
||||
function OtherFindingRow({ finding }: { finding: ResourceDrawerFinding }) {
|
||||
function OtherFindingRow({
|
||||
finding,
|
||||
isOptimisticallyMuted,
|
||||
onMuted,
|
||||
}: {
|
||||
finding: ResourceDrawerFinding;
|
||||
isOptimisticallyMuted: boolean;
|
||||
onMuted: () => void;
|
||||
}) {
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
||||
const isMuted = finding.isMuted || isOptimisticallyMuted;
|
||||
|
||||
const findingUrl = `/findings?filter%5Bcheck_id__in%5D=${encodeURIComponent(finding.checkId)}&filter%5Bmuted%5D=include`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!finding.isMuted && (
|
||||
{!isMuted && (
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={[finding.id]}
|
||||
onComplete={() => {
|
||||
setIsMuteModalOpen(false);
|
||||
onMuted();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SendToJiraModal
|
||||
@@ -934,7 +1050,7 @@ function OtherFindingRow({ finding }: { finding: ResourceDrawerFinding }) {
|
||||
onClick={() => window.open(findingUrl, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<TableCell className="w-10">
|
||||
<NotificationIndicator isMuted={finding.isMuted} />
|
||||
<NotificationIndicator isMuted={isMuted} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusFindingBadge status={finding.status as FindingStatus} />
|
||||
@@ -955,14 +1071,14 @@ function OtherFindingRow({ finding }: { finding: ResourceDrawerFinding }) {
|
||||
<ActionDropdown ariaLabel="Finding actions">
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
finding.isMuted ? (
|
||||
isMuted ? (
|
||||
<VolumeOff className="size-5" />
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
)
|
||||
}
|
||||
label={finding.isMuted ? "Muted" : "Mute"}
|
||||
disabled={finding.isMuted}
|
||||
label={isMuted ? "Muted" : "Mute"}
|
||||
disabled={isMuted}
|
||||
onSelect={() => setIsMuteModalOpen(true)}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/shadcn/skeleton/skeleton", () => ({
|
||||
Skeleton: ({ className }: { className?: string }) => (
|
||||
<div data-testid="skeleton-block" data-class={className ?? ""} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { ResourceDetailSkeleton } from "./resource-detail-skeleton";
|
||||
|
||||
describe("ResourceDetailSkeleton", () => {
|
||||
it("should include placeholders for group and resource type fields", () => {
|
||||
render(<ResourceDetailSkeleton />);
|
||||
|
||||
const blocks = screen.getAllByTestId("skeleton-block");
|
||||
const classes = blocks.map(
|
||||
(block) => block.getAttribute("data-class") ?? "",
|
||||
);
|
||||
|
||||
expect(classes).toContain("h-3.5 w-10 rounded");
|
||||
expect(classes).toContain("h-5 w-18 rounded");
|
||||
expect(classes).toContain("h-3.5 w-20 rounded");
|
||||
expect(classes).toContain("h-5 w-28 rounded");
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
|
||||
/**
|
||||
* Skeleton placeholder for the resource info grid in the detail drawer.
|
||||
* Mirrors the 4-column layout: EntityInfo × 2, InfoField × 2 per row,
|
||||
* plus the actions button.
|
||||
* Mirrors the drawer layout so added metadata fields don't leave visual gaps
|
||||
* while the next resource is loading.
|
||||
*/
|
||||
export function ResourceDetailSkeleton() {
|
||||
return (
|
||||
@@ -15,16 +15,19 @@ export function ResourceDetailSkeleton() {
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-20" />
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-24" />
|
||||
|
||||
{/* Row 2: Last detected, First seen, Failing for */}
|
||||
{/* Row 2: Last detected, First seen, Failing for, Group */}
|
||||
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-32" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-32" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-16" />
|
||||
<div className="hidden md:block" />
|
||||
<InfoFieldSkeleton labelWidth="w-10" valueWidth="w-18" />
|
||||
|
||||
{/* Row 3: Check ID, Finding ID, Finding UID */}
|
||||
<InfoFieldSkeleton labelWidth="w-14" valueWidth="w-36" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-36" />
|
||||
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-36" />
|
||||
|
||||
{/* Row 4: Resource type */}
|
||||
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-28" />
|
||||
</div>
|
||||
|
||||
{/* Actions button */}
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock("next/navigation", () => ({
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
|
||||
import { useResourceDetailDrawer } from "./use-resource-detail-drawer";
|
||||
@@ -60,6 +61,46 @@ function makeResource(
|
||||
} as FindingResourceRow;
|
||||
}
|
||||
|
||||
function makeDrawerFinding(
|
||||
overrides?: Partial<ResourceDrawerFinding>,
|
||||
): ResourceDrawerFinding {
|
||||
return {
|
||||
id: "finding-1",
|
||||
uid: "uid-1",
|
||||
checkId: "s3_check",
|
||||
checkTitle: "S3 Check",
|
||||
status: "FAIL",
|
||||
severity: "high",
|
||||
delta: null,
|
||||
isMuted: false,
|
||||
mutedReason: null,
|
||||
firstSeenAt: null,
|
||||
updatedAt: null,
|
||||
resourceId: "resource-1",
|
||||
resourceUid: "arn:aws:s3:::my-bucket",
|
||||
resourceName: "my-bucket",
|
||||
resourceService: "s3",
|
||||
resourceRegion: "us-east-1",
|
||||
resourceType: "bucket",
|
||||
resourceGroup: "default",
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
providerUid: "123",
|
||||
risk: "high",
|
||||
description: "desc",
|
||||
statusExtended: "status",
|
||||
complianceFrameworks: [],
|
||||
categories: [],
|
||||
remediation: {
|
||||
recommendation: { text: "", url: "" },
|
||||
code: { cli: "", other: "", nativeiac: "", terraform: "" },
|
||||
},
|
||||
additionalUrls: [],
|
||||
scan: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 2: AbortController cleanup on unmount
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -128,3 +169,212 @@ describe("useResourceDetailDrawer — unmount cleanup", () => {
|
||||
expect(abortSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should exclude the current finding from otherFindings and preserve API order", async () => {
|
||||
const resources = [makeResource()];
|
||||
|
||||
getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] });
|
||||
adaptFindingsByResourceResponseMock.mockReturnValue([
|
||||
makeDrawerFinding({
|
||||
id: "current",
|
||||
checkId: "s3_check",
|
||||
checkTitle: "Current",
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
}),
|
||||
makeDrawerFinding({
|
||||
id: "other-1",
|
||||
checkId: "check-other-1",
|
||||
checkTitle: "Other 1",
|
||||
status: "PASS",
|
||||
severity: "critical",
|
||||
}),
|
||||
makeDrawerFinding({
|
||||
id: "other-2",
|
||||
checkId: "check-other-2",
|
||||
checkTitle: "Other 2",
|
||||
status: "FAIL",
|
||||
severity: "medium",
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: "s3_check",
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.openDrawer(0);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.otherFindings.map((finding) => finding.id)).toEqual([
|
||||
"other-1",
|
||||
"other-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should keep isNavigating true for a cached resource long enough to render skeletons", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const resources = [
|
||||
makeResource({
|
||||
id: "row-1",
|
||||
findingId: "finding-1",
|
||||
resourceUid: "arn:aws:s3:::first-bucket",
|
||||
resourceName: "first-bucket",
|
||||
}),
|
||||
makeResource({
|
||||
id: "row-2",
|
||||
findingId: "finding-2",
|
||||
resourceUid: "arn:aws:s3:::second-bucket",
|
||||
resourceName: "second-bucket",
|
||||
}),
|
||||
];
|
||||
|
||||
getLatestFindingsByResourceUidMock.mockImplementation(
|
||||
async ({ resourceUid }: { resourceUid: string }) => ({
|
||||
data: [resourceUid],
|
||||
}),
|
||||
);
|
||||
adaptFindingsByResourceResponseMock.mockImplementation(
|
||||
(response: { data: string[] }) => [
|
||||
makeDrawerFinding({
|
||||
id: response.data[0].includes("first") ? "finding-1" : "finding-2",
|
||||
resourceUid: response.data[0],
|
||||
resourceName: response.data[0].includes("first")
|
||||
? "first-bucket"
|
||||
: "second-bucket",
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: "s3_check",
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.openDrawer(0);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.navigateNext();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.currentIndex).toBe(1);
|
||||
expect(result.current.currentFinding?.id).toBe("finding-2");
|
||||
|
||||
act(() => {
|
||||
result.current.navigatePrev();
|
||||
});
|
||||
|
||||
expect(result.current.currentIndex).toBe(0);
|
||||
expect(result.current.isNavigating).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.isNavigating).toBe(false);
|
||||
expect(result.current.currentFinding?.id).toBe("finding-1");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should keep isNavigating true for a fast uncached navigation long enough to avoid flicker", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-08T15:00:00.000Z"));
|
||||
|
||||
const resources = [
|
||||
makeResource({
|
||||
id: "row-1",
|
||||
findingId: "finding-1",
|
||||
resourceUid: "arn:aws:s3:::first-bucket",
|
||||
resourceName: "first-bucket",
|
||||
}),
|
||||
makeResource({
|
||||
id: "row-2",
|
||||
findingId: "finding-2",
|
||||
resourceUid: "arn:aws:s3:::second-bucket",
|
||||
resourceName: "second-bucket",
|
||||
}),
|
||||
];
|
||||
|
||||
getLatestFindingsByResourceUidMock.mockImplementation(
|
||||
async ({ resourceUid }: { resourceUid: string }) => ({
|
||||
data: [resourceUid],
|
||||
}),
|
||||
);
|
||||
adaptFindingsByResourceResponseMock.mockImplementation(
|
||||
(response: { data: string[] }) => [
|
||||
makeDrawerFinding({
|
||||
id: response.data[0].includes("first") ? "finding-1" : "finding-2",
|
||||
resourceUid: response.data[0],
|
||||
resourceName: response.data[0].includes("first")
|
||||
? "first-bucket"
|
||||
: "second-bucket",
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: "s3_check",
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.openDrawer(0);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.navigateNext();
|
||||
});
|
||||
|
||||
expect(result.current.currentIndex).toBe(1);
|
||||
expect(result.current.isNavigating).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.currentFinding?.id).toBe("finding-2");
|
||||
expect(result.current.isNavigating).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(119);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.isNavigating).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.runOnlyPendingTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.isNavigating).toBe(false);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
} from "@/actions/findings";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
|
||||
// Keep fast carousel navigations in a loading state for one short beat so
|
||||
// React doesn't batch away the skeleton frame when switching resources.
|
||||
const MIN_NAVIGATION_SKELETON_MS = 300;
|
||||
|
||||
/**
|
||||
* Check-level metadata that is identical across all resources for a given check.
|
||||
* Extracted once on first successful fetch and kept stable during navigation.
|
||||
@@ -83,18 +87,65 @@ export function useResourceDetailDrawer({
|
||||
const cacheRef = useRef<Map<string, ResourceDrawerFinding[]>>(new Map());
|
||||
const checkMetaRef = useRef<CheckMeta | null>(null);
|
||||
const fetchControllerRef = useRef<AbortController | null>(null);
|
||||
const navigationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const navigationStartedAtRef = useRef<number | null>(null);
|
||||
|
||||
const clearNavigationTimeout = () => {
|
||||
if (navigationTimeoutRef.current !== null) {
|
||||
clearTimeout(navigationTimeoutRef.current);
|
||||
navigationTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const finishNavigation = () => {
|
||||
clearNavigationTimeout();
|
||||
setIsLoading(false);
|
||||
|
||||
const navigationStartedAt = navigationStartedAtRef.current;
|
||||
if (navigationStartedAt === null) {
|
||||
navigationStartedAtRef.current = null;
|
||||
setIsNavigating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - navigationStartedAt;
|
||||
const remaining = Math.max(0, MIN_NAVIGATION_SKELETON_MS - elapsed);
|
||||
|
||||
if (remaining === 0) {
|
||||
navigationStartedAtRef.current = null;
|
||||
setIsNavigating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
navigationTimeoutRef.current = setTimeout(() => {
|
||||
setIsNavigating(false);
|
||||
navigationStartedAtRef.current = null;
|
||||
navigationTimeoutRef.current = null;
|
||||
}, remaining);
|
||||
};
|
||||
|
||||
const startNavigation = () => {
|
||||
clearNavigationTimeout();
|
||||
navigationStartedAtRef.current = Date.now();
|
||||
setIsNavigating(true);
|
||||
};
|
||||
|
||||
// Abort any in-flight request on unmount to prevent state updates
|
||||
// on an already-unmounted component.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
fetchControllerRef.current?.abort();
|
||||
clearNavigationTimeout();
|
||||
navigationStartedAtRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchFindings = async (resourceUid: string) => {
|
||||
// Abort any in-flight request to prevent stale data from out-of-order responses
|
||||
fetchControllerRef.current?.abort();
|
||||
clearNavigationTimeout();
|
||||
const controller = new AbortController();
|
||||
fetchControllerRef.current = controller;
|
||||
|
||||
@@ -106,8 +157,7 @@ export function useResourceDetailDrawer({
|
||||
if (main) checkMetaRef.current = extractCheckMeta(main);
|
||||
}
|
||||
setFindings(cached);
|
||||
setIsLoading(false);
|
||||
setIsNavigating(false);
|
||||
finishNavigation();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -135,8 +185,7 @@ export function useResourceDetailDrawer({
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setIsLoading(false);
|
||||
setIsNavigating(false);
|
||||
finishNavigation();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -145,8 +194,11 @@ export function useResourceDetailDrawer({
|
||||
const resource = resources[index];
|
||||
if (!resource) return;
|
||||
|
||||
clearNavigationTimeout();
|
||||
navigationStartedAtRef.current = null;
|
||||
setCurrentIndex(index);
|
||||
setIsOpen(true);
|
||||
setIsNavigating(false);
|
||||
setFindings([]);
|
||||
fetchFindings(resource.resourceUid);
|
||||
};
|
||||
@@ -159,7 +211,7 @@ export function useResourceDetailDrawer({
|
||||
const resource = resources[currentIndex];
|
||||
if (!resource) return;
|
||||
cacheRef.current.delete(resource.resourceUid);
|
||||
setIsNavigating(true);
|
||||
startNavigation();
|
||||
fetchFindings(resource.resourceUid);
|
||||
};
|
||||
|
||||
@@ -168,7 +220,7 @@ export function useResourceDetailDrawer({
|
||||
if (!resource) return;
|
||||
|
||||
setCurrentIndex(index);
|
||||
setIsNavigating(true);
|
||||
startNavigation();
|
||||
fetchFindings(resource.resourceUid);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,20 +3,23 @@
|
||||
import { X } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { ScanSelector } from "@/components/compliance/compliance-header";
|
||||
import { filterScans } from "@/components/filters/data-filters";
|
||||
import { FilterControls } from "@/components/filters/filter-controls";
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { useRelatedFilters } from "@/hooks";
|
||||
import { FilterEntity, FilterType } from "@/types";
|
||||
import { ExpandedScanData, FilterEntity, FilterType } from "@/types";
|
||||
|
||||
interface ScansFiltersProps {
|
||||
providerUIDs: string[];
|
||||
providerDetails: { [uid: string]: FilterEntity }[];
|
||||
completedScans?: ExpandedScanData[];
|
||||
}
|
||||
|
||||
export const ScansFilters = ({
|
||||
providerUIDs,
|
||||
providerDetails,
|
||||
completedScans = [],
|
||||
}: ScansFiltersProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -36,24 +39,50 @@ export const ScansFilters = ({
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
};
|
||||
|
||||
const scanIdChip = idFilter ? (
|
||||
<div className="flex items-center">
|
||||
<Badge
|
||||
variant="tag"
|
||||
className="max-w-[300px] shrink-0 cursor-default gap-1 truncate"
|
||||
>
|
||||
<span className="text-text-neutral-secondary mr-1 text-xs">Scan:</span>
|
||||
<span className="truncate">{idFilter}</span>
|
||||
const handleScanChange = (selectedScanId: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("filter[id__in]", selectedScanId);
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
};
|
||||
|
||||
const scanIdElement = idFilter ? (
|
||||
completedScans.length > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<ScanSelector
|
||||
scans={completedScans}
|
||||
selectedScanId={idFilter}
|
||||
onSelectionChange={handleScanChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear scan filter"
|
||||
className="hover:text-text-neutral-primary ml-0.5 shrink-0"
|
||||
className="text-text-neutral-secondary hover:text-text-neutral-primary shrink-0"
|
||||
onClick={handleDismissIdFilter}
|
||||
>
|
||||
<X className="size-3" />
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<Badge
|
||||
variant="tag"
|
||||
className="max-w-[300px] shrink-0 cursor-default gap-1 truncate"
|
||||
>
|
||||
<span className="text-text-neutral-secondary mr-1 text-xs">
|
||||
Scan:
|
||||
</span>
|
||||
<span className="truncate">{idFilter}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear scan filter"
|
||||
className="hover:text-text-neutral-primary ml-0.5 shrink-0"
|
||||
onClick={handleDismissIdFilter}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
@@ -68,7 +97,7 @@ export const ScansFilters = ({
|
||||
index: 1,
|
||||
},
|
||||
]}
|
||||
prependElement={scanIdChip}
|
||||
prependElement={scanIdElement}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
|
||||
inner:
|
||||
"rounded-[12px] backdrop-blur-[46px] border-border-neutral-tertiary bg-bg-neutral-tertiary",
|
||||
danger:
|
||||
"gap-1 rounded-[12px] border-border-error-primary bg-bg-fail-secondary",
|
||||
"gap-1 rounded-[12px] border-[rgba(67,34,50,0.5)] bg-[rgba(67,34,50,0.2)] dark:border-[rgba(67,34,50,0.7)] dark:bg-[rgba(67,34,50,0.3)]",
|
||||
},
|
||||
padding: {
|
||||
default: "",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DateWithTimeProps {
|
||||
@@ -33,25 +38,52 @@ export const DateWithTime = ({
|
||||
?.substring(0, 3)
|
||||
.toUpperCase() || "";
|
||||
|
||||
return (
|
||||
const fullText = showTime
|
||||
? `${formattedDate} ${formattedTime} ${timezone}`
|
||||
: formattedDate;
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={cn(
|
||||
"gap-1",
|
||||
inline
|
||||
? "inline-flex flex-row flex-wrap items-center"
|
||||
? "inline-flex flex-row items-center overflow-hidden"
|
||||
: "flex flex-col",
|
||||
)}
|
||||
>
|
||||
<span className="text-text-neutral-primary text-sm whitespace-nowrap">
|
||||
<span
|
||||
className={cn(
|
||||
"text-text-neutral-primary text-sm whitespace-nowrap",
|
||||
inline && "truncate",
|
||||
)}
|
||||
>
|
||||
{formattedDate}
|
||||
</span>
|
||||
{showTime && (
|
||||
<span className="text-text-neutral-tertiary text-xs font-medium whitespace-nowrap">
|
||||
<span
|
||||
className={cn(
|
||||
"text-text-neutral-tertiary text-xs font-medium whitespace-nowrap",
|
||||
inline && "truncate",
|
||||
)}
|
||||
>
|
||||
{formattedTime} {timezone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 overflow-hidden">{content}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{fullText}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
} catch {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
@@ -163,6 +163,38 @@ describe("useInfiniteResources", () => {
|
||||
findingGroupActionsMock.getLatestFindingGroupResources,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should forward the active finding-group filters to the resources endpoint", async () => {
|
||||
// Given
|
||||
const apiResponse = makeApiResponse([], { pages: 1 });
|
||||
const filters = {
|
||||
"filter[status__in]": "PASS",
|
||||
"filter[severity__in]": "medium",
|
||||
"filter[provider_type__in]": "aws",
|
||||
};
|
||||
findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
|
||||
apiResponse,
|
||||
);
|
||||
findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
|
||||
[],
|
||||
);
|
||||
|
||||
// When
|
||||
renderHook(() => useInfiniteResources(defaultOptions({ filters })));
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
expect(
|
||||
findingGroupActionsMock.getLatestFindingGroupResources,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
checkId: "check_1",
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
filters,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when all resources fit in one page", () => {
|
||||
|
||||
@@ -32,6 +32,8 @@ interface UseInfiniteResourcesReturn {
|
||||
refresh: () => void;
|
||||
/** Imperatively load the next page (e.g. from drawer navigation). */
|
||||
loadMore: () => void;
|
||||
/** Total number of resources matching current filters (from API pagination). */
|
||||
totalCount: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +62,7 @@ export function useInfiniteResources({
|
||||
const currentCheckIdRef = useRef(checkId);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const totalCountRef = useRef<number | null>(null);
|
||||
|
||||
// Store latest values in refs so the fetch function always reads current values
|
||||
// without being recreated on every render
|
||||
@@ -70,6 +73,7 @@ export function useInfiniteResources({
|
||||
const onSetLoadingRef = useRef(onSetLoading);
|
||||
|
||||
// Keep refs in sync with latest props
|
||||
currentCheckIdRef.current = checkId;
|
||||
hasDateOrScanRef.current = hasDateOrScanFilter;
|
||||
filtersRef.current = filters;
|
||||
onSetResourcesRef.current = onSetResources;
|
||||
@@ -110,6 +114,7 @@ export function useInfiniteResources({
|
||||
);
|
||||
const totalPages = response?.meta?.pagination?.pages ?? 1;
|
||||
const hasMore = page < totalPages;
|
||||
totalCountRef.current = response?.meta?.pagination?.count ?? null;
|
||||
|
||||
// Commit the page number only after a successful (non-aborted) fetch.
|
||||
// This prevents a premature pageRef increment from loadNextPage being
|
||||
@@ -209,5 +214,10 @@ export function useInfiniteResources({
|
||||
fetchPage(1, false, currentCheckIdRef.current, controller.signal);
|
||||
}
|
||||
|
||||
return { sentinelRef, refresh, loadMore: loadNextPage };
|
||||
return {
|
||||
sentinelRef,
|
||||
refresh,
|
||||
loadMore: loadNextPage,
|
||||
totalCount: totalCountRef.current,
|
||||
};
|
||||
}
|
||||
|
||||
112
ui/lib/findings-scan-filters.test.ts
Normal file
112
ui/lib/findings-scan-filters.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildFindingScanDateFilters,
|
||||
resolveFindingScanDateFilters,
|
||||
} from "./findings-scan-filters";
|
||||
|
||||
describe("buildFindingScanDateFilters", () => {
|
||||
it("uses an exact inserted_at filter when all selected scans belong to the same day", () => {
|
||||
expect(
|
||||
buildFindingScanDateFilters([
|
||||
"2026-04-07T10:00:00Z",
|
||||
"2026-04-07T18:30:00Z",
|
||||
]),
|
||||
).toEqual({
|
||||
"filter[inserted_at]": "2026-04-07",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores whitespace-only date strings", () => {
|
||||
expect(buildFindingScanDateFilters([" ", "2026-04-07T10:00:00Z"])).toEqual(
|
||||
{
|
||||
"filter[inserted_at]": "2026-04-07",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a date range when selected scans span multiple days", () => {
|
||||
expect(
|
||||
buildFindingScanDateFilters([
|
||||
"2026-04-03T10:00:00Z",
|
||||
"2026-04-07T18:30:00Z",
|
||||
"2026-04-05T12:00:00Z",
|
||||
]),
|
||||
).toEqual({
|
||||
"filter[inserted_at__gte]": "2026-04-03",
|
||||
"filter[inserted_at__lte]": "2026-04-07",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFindingScanDateFilters", () => {
|
||||
it("adds the required inserted_at filter for a selected scan when the URL only contains scan__in", async () => {
|
||||
const result = await resolveFindingScanDateFilters({
|
||||
filters: {
|
||||
"filter[muted]": "false",
|
||||
"filter[scan__in]": "scan-1",
|
||||
},
|
||||
scans: [
|
||||
{
|
||||
id: "scan-1",
|
||||
attributes: {
|
||||
inserted_at: "2026-04-07T10:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
loadScan: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
"filter[muted]": "false",
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[inserted_at]": "2026-04-07",
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches missing scan details when the selected scan is not present in the prefetched scans list", async () => {
|
||||
const loadScan = vi.fn().mockResolvedValue({
|
||||
id: "scan-2",
|
||||
attributes: {
|
||||
inserted_at: "2026-04-05T08:00:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolveFindingScanDateFilters({
|
||||
filters: {
|
||||
"filter[scan__in]": "scan-2",
|
||||
},
|
||||
scans: [],
|
||||
loadScan,
|
||||
});
|
||||
|
||||
expect(loadScan).toHaveBeenCalledWith("scan-2");
|
||||
expect(result).toEqual({
|
||||
"filter[scan__in]": "scan-2",
|
||||
"filter[inserted_at]": "2026-04-05",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not override an explicit inserted_at filter already chosen in the frontend", async () => {
|
||||
const result = await resolveFindingScanDateFilters({
|
||||
filters: {
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[inserted_at__gte]": "2026-04-01",
|
||||
},
|
||||
scans: [
|
||||
{
|
||||
id: "scan-1",
|
||||
attributes: {
|
||||
inserted_at: "2026-04-07T10:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
loadScan: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[inserted_at__gte]": "2026-04-01",
|
||||
});
|
||||
});
|
||||
});
|
||||
99
ui/lib/findings-scan-filters.ts
Normal file
99
ui/lib/findings-scan-filters.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
interface ScanDateSource {
|
||||
id: string;
|
||||
attributes?: {
|
||||
inserted_at?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResolveFindingScanDateFiltersOptions {
|
||||
filters: Record<string, string>;
|
||||
scans: ScanDateSource[];
|
||||
loadScan: (scanId: string) => Promise<ScanDateSource | null | undefined>;
|
||||
}
|
||||
|
||||
const INSERTED_AT_FILTER_KEYS = [
|
||||
"filter[inserted_at]",
|
||||
"filter[inserted_at__date]",
|
||||
"filter[inserted_at__gte]",
|
||||
"filter[inserted_at__lte]",
|
||||
] as const;
|
||||
|
||||
function getScanFilterIds(filters: Record<string, string>): string[] {
|
||||
const scanIds = filters["filter[scan__in]"] || filters["filter[scan]"] || "";
|
||||
return Array.from(new Set(scanIds.split(",").filter(Boolean)));
|
||||
}
|
||||
|
||||
function formatScanDate(dateTime?: string): string | null {
|
||||
if (!dateTime) return null;
|
||||
const [date] = dateTime.split("T");
|
||||
return date?.trim() || null;
|
||||
}
|
||||
|
||||
function hasInsertedAtFilter(filters: Record<string, string>): boolean {
|
||||
return INSERTED_AT_FILTER_KEYS.some((key) => Boolean(filters[key]));
|
||||
}
|
||||
|
||||
export function buildFindingScanDateFilters(
|
||||
scanInsertedAtValues: string[],
|
||||
): Record<string, string> {
|
||||
const dates = Array.from(
|
||||
new Set(scanInsertedAtValues.map(formatScanDate).filter(Boolean)),
|
||||
).sort() as string[];
|
||||
|
||||
if (dates.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (dates.length === 1) {
|
||||
return {
|
||||
"filter[inserted_at]": dates[0],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
"filter[inserted_at__gte]": dates[0],
|
||||
"filter[inserted_at__lte]": dates[dates.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveFindingScanDateFilters({
|
||||
filters,
|
||||
scans,
|
||||
loadScan,
|
||||
}: ResolveFindingScanDateFiltersOptions): Promise<Record<string, string>> {
|
||||
const scanIds = getScanFilterIds(filters);
|
||||
|
||||
if (scanIds.length === 0 || hasInsertedAtFilter(filters)) {
|
||||
return filters;
|
||||
}
|
||||
|
||||
const scansById = new Map(scans.map((scan) => [scan.id, scan]));
|
||||
const missingScanIds = scanIds.filter((scanId) => !scansById.has(scanId));
|
||||
|
||||
if (missingScanIds.length > 0) {
|
||||
const missingScans = await Promise.all(
|
||||
missingScanIds.map((scanId) => loadScan(scanId)),
|
||||
);
|
||||
|
||||
missingScans.forEach((scan) => {
|
||||
if (scan) {
|
||||
scansById.set(scan.id, scan);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const scanInsertedAtValues = scanIds
|
||||
.map((scanId) => scansById.get(scanId)?.attributes?.inserted_at)
|
||||
.filter((insertedAt): insertedAt is string => Boolean(insertedAt));
|
||||
|
||||
const dateFilters = buildFindingScanDateFilters(scanInsertedAtValues);
|
||||
|
||||
if (Object.keys(dateFilters).length === 0) {
|
||||
return filters;
|
||||
}
|
||||
|
||||
return {
|
||||
...filters,
|
||||
...dateFilters,
|
||||
};
|
||||
}
|
||||
@@ -34,12 +34,14 @@ export interface FindingResourceRow {
|
||||
providerAlias: string;
|
||||
providerUid: string;
|
||||
resourceName: string;
|
||||
resourceType: string;
|
||||
resourceGroup: string;
|
||||
resourceUid: string;
|
||||
service: string;
|
||||
region: string;
|
||||
severity: Severity;
|
||||
status: string;
|
||||
delta?: string | null;
|
||||
isMuted: boolean;
|
||||
mutedReason?: string;
|
||||
firstSeenAt: string | null;
|
||||
|
||||
Reference in New Issue
Block a user