From 01b49f07430c2a1e4b9b82dca2e52d2e173428eb Mon Sep 17 00:00:00 2001 From: StylusFrost <43682773+StylusFrost@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:16:39 +0200 Subject: [PATCH] feat(dashboard): render dynamic-provider compliance frameworks (#11503) Co-authored-by: pedrooot --- dashboard/common_methods.py | 180 ++++++++++++++++ dashboard/compliance/generic.py | 44 ++++ dashboard/lib/layouts.py | 2 +- dashboard/pages/compliance.py | 88 +++++--- dashboard/pages/overview.py | 2 +- tests/dashboard/__init__.py | 0 tests/dashboard/common_methods_test.py | 81 +++++++ tests/dashboard/compliance/__init__.py | 0 tests/dashboard/compliance/generic_test.py | 204 ++++++++++++++++++ tests/dashboard/pages/__init__.py | 0 .../pages/compliance_dispatch_test.py | 179 +++++++++++++++ tests/dashboard/pages/conftest.py | 7 + tests/dashboard/pages/scope_columns_test.py | 60 ++++++ 13 files changed, 814 insertions(+), 33 deletions(-) create mode 100644 dashboard/compliance/generic.py create mode 100644 tests/dashboard/__init__.py create mode 100644 tests/dashboard/common_methods_test.py create mode 100644 tests/dashboard/compliance/__init__.py create mode 100644 tests/dashboard/compliance/generic_test.py create mode 100644 tests/dashboard/pages/__init__.py create mode 100644 tests/dashboard/pages/compliance_dispatch_test.py create mode 100644 tests/dashboard/pages/conftest.py create mode 100644 tests/dashboard/pages/scope_columns_test.py diff --git a/dashboard/common_methods.py b/dashboard/common_methods.py index a2f9ffe89b..b9f59513a5 100644 --- a/dashboard/common_methods.py +++ b/dashboard/common_methods.py @@ -1538,6 +1538,186 @@ def get_section_container_iso(data, section_1, section_2): return html.Div(section_containers, className="compliance-data-layout") +def _status_bar(success, failed, classname): + """Build the stacked PASS/FAIL bar shown next to an accordion title.""" + fig = go.Figure( + data=[ + go.Bar( + name="Failed", + x=[failed], + y=[""], + orientation="h", + marker=dict(color="#e77676"), + width=[0.8], + ), + go.Bar( + name="Success", + x=[success], + y=[""], + orientation="h", + marker=dict(color="#45cc6e"), + width=[0.8], + ), + ] + ) + fig.update_layout( + barmode="stack", + margin=dict(l=10, r=10, t=10, b=10), + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + showlegend=False, + width=350, + height=30, + xaxis=dict(showticklabels=False, showgrid=False, zeroline=False), + yaxis=dict(showticklabels=False, showgrid=False, zeroline=False), + annotations=[ + dict( + x=success + failed, + y=0, + xref="x", + yref="y", + text=str(success), + showarrow=False, + font=dict(color="#45cc6e", size=14), + xanchor="left", + yanchor="middle", + ), + dict( + x=0, + y=0, + xref="x", + yref="y", + text=str(failed), + showarrow=False, + font=dict(color="#e77676", size=14), + xanchor="right", + yanchor="middle", + ), + ], + ) + fig.add_annotation( + x=failed, + y=0.3, + text="|", + showarrow=False, + xanchor="center", + yanchor="middle", + font=dict(size=20), + ) + return dcc.Graph(figure=fig, config={"staticPlot": True}, className=classname) + + +def get_section_containers_generic(data, section_col, id_col): + """Two-level view: section -> requirement id (+ description) -> checks. + + Sorts lexicographically so arbitrary requirement IDs never crash the + version-aware sort used by the CIS renderer. + """ + data["STATUS"] = data["STATUS"].apply(map_status_to_icon) + data[section_col] = data[section_col].astype(str) + data[id_col] = data[id_col].astype(str) + data.sort_values(by=[section_col, id_col], inplace=True) + + counts_section = data.groupby([section_col, "STATUS"]).size().unstack(fill_value=0) + counts_id = ( + data.groupby([section_col, id_col, "STATUS"]).size().unstack(fill_value=0) + ) + + def count(counts, key, emoji): + return counts.loc[key, emoji] if emoji in counts.columns else 0 + + has_description = "REQUIREMENTS_DESCRIPTION" in data.columns + table_cols = ["CHECKID", "STATUS", "REGION", "ACCOUNTID", "RESOURCEID"] + + section_containers = [] + for section in data[section_col].unique(): + graph_div = html.Div( + _status_bar( + count(counts_section, section, pass_emoji), + count(counts_section, section, fail_emoji), + "info-bar", + ), + className="graph-section", + ) + + internal_items = [] + for req_id in data[data[section_col] == section][id_col].unique(): + specific_data = data[ + (data[section_col] == section) & (data[id_col] == req_id) + ] + data_table = dash_table.DataTable( + data=specific_data.to_dict("records"), + columns=[ + {"name": i, "id": i} + for i in table_cols + if i in specific_data.columns + ], + style_table={"overflowX": "auto"}, + style_as_list_view=True, + style_cell={"textAlign": "left", "padding": "5px"}, + ) + graph_div_req = html.Div( + _status_bar( + count(counts_id, (section, req_id), pass_emoji), + count(counts_id, (section, req_id), fail_emoji), + "info-bar-child", + ), + className="graph-section-req", + ) + + title = req_id + if has_description: + title = ( + f"{req_id} - {specific_data['REQUIREMENTS_DESCRIPTION'].iloc[0]}" + ) + if len(title) > 130: + title = title[:130] + " ..." + + internal_items.append( + html.Div( + [ + graph_div_req, + dbc.Accordion( + [ + dbc.AccordionItem( + title=title, + children=[ + html.Div( + [data_table], + className="inner-accordion-content", + ) + ], + ) + ], + start_collapsed=True, + flush=True, + ), + ], + className="accordion-inner--child", + ) + ) + + section_containers.append( + html.Div( + [ + graph_div, + dbc.Accordion( + [ + dbc.AccordionItem( + title=f"{section}", children=internal_items + ) + ], + start_collapsed=True, + flush=True, + ), + ], + className="accordion-inner", + ) + ) + + return html.Div(section_containers, className="compliance-data-layout") + + def get_section_containers_format4(data, section_1): data["STATUS"] = data["STATUS"].apply(map_status_to_icon) diff --git a/dashboard/compliance/generic.py b/dashboard/compliance/generic.py new file mode 100644 index 0000000000..f7d68bb52a --- /dev/null +++ b/dashboard/compliance/generic.py @@ -0,0 +1,44 @@ +import warnings + +from dashboard.common_methods import ( + get_section_containers_format4, + get_section_containers_generic, +) + +warnings.filterwarnings("ignore") + + +def get_table(data): + # Discover REQUIREMENTS_ATTRIBUTES_* columns at runtime. + attr_cols = [c for c in data.columns if c.startswith("REQUIREMENTS_ATTRIBUTES_")] + + # Section column (in priority order): + # 1. REQUIREMENTS_ATTRIBUTES_SECTION — most common convention + # 2. First discovered attribute column — covers novel schemas + # 3. None — no section, group flat by requirement id + if "REQUIREMENTS_ATTRIBUTES_SECTION" in attr_cols: + section_col = "REQUIREMENTS_ATTRIBUTES_SECTION" + elif attr_cols: + section_col = attr_cols[0] + else: + section_col = None + + base_cols = [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "STATUS", + "CHECKID", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + + # Two levels (section -> requirement id) when a section distinct from the + # id exists; otherwise group flat by requirement id. + if section_col and section_col != "REQUIREMENTS_ID": + needed = [section_col] + base_cols + aux = data[[c for c in needed if c in data.columns]].copy() + return get_section_containers_generic(aux, section_col, "REQUIREMENTS_ID") + + aux = data[[c for c in base_cols if c in data.columns]].copy() + return get_section_containers_format4(aux, "REQUIREMENTS_ID") diff --git a/dashboard/lib/layouts.py b/dashboard/lib/layouts.py index 930432b6c4..3fb230f314 100644 --- a/dashboard/lib/layouts.py +++ b/dashboard/lib/layouts.py @@ -156,7 +156,7 @@ def create_layout_compliance( html.Img(src="assets/favicon.ico", className="w-5 mr-3"), html.Span("Subscribe to Prowler Cloud"), ], - href="https://prowler.pro/", + href="https://cloud.prowler.com/", target="_blank", className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10", ), diff --git a/dashboard/pages/compliance.py b/dashboard/pages/compliance.py index c1da9f611e..773dd095da 100644 --- a/dashboard/pages/compliance.py +++ b/dashboard/pages/compliance.py @@ -215,6 +215,58 @@ else: ) +def _ensure_scope_columns(data): + """Guarantee ACCOUNTID and REGION exist. + + Scope columns always sit between DESCRIPTION and ASSESSMENTDATE, so derive + them positionally for any provider (e.g. Okta's ORGANIZATIONDOMAIN) and + fall back to "-" to avoid a KeyError. + """ + cols = list(data.columns) + scope = [] + if "DESCRIPTION" in cols and "ASSESSMENTDATE" in cols: + start, end = cols.index("DESCRIPTION") + 1, cols.index("ASSESSMENTDATE") + scope = [c for c in cols[start:end] if c not in ("ACCOUNTID", "REGION")] + + if "ACCOUNTID" not in data.columns: + if scope: + data.rename(columns={scope.pop(0): "ACCOUNTID"}, inplace=True) + else: + data["ACCOUNTID"] = "-" + if "REGION" not in data.columns: + if scope: + data.rename(columns={scope.pop(0): "REGION"}, inplace=True) + else: + data["REGION"] = "-" + return data + + +def _dispatch_compliance_renderer(data, analytics_input): + """Resolve the compliance renderer module and return (table, deduped_data). + + Tries to import the framework-specific builtin module. On + ModuleNotFoundError (dynamic/external provider with no dedicated module), + falls back to the generic renderer. Any other ImportError is re-raised. + get_table() is called OUTSIDE the try block so errors inside the renderer + surface as real exceptions rather than being swallowed. + """ + current = analytics_input.replace(".", "_") + target = f"dashboard.compliance.{current}" + try: + module = importlib.import_module(target) + except ModuleNotFoundError as exc: + if exc.name != target: + raise + from dashboard.compliance import generic as module + dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"] + if "MUTED" in data.columns: + dedup_columns.insert(2, "MUTED") + data = data.drop_duplicates(subset=dedup_columns) + if "threatscore" in analytics_input: + data = get_threatscore_mean_by_pillar(data) + return module.get_table(data), data + + @callback( [ Output("output", "children"), @@ -292,7 +344,7 @@ def display_data( data.rename(columns={"TENANCYID": "ACCOUNTID"}, inplace=True) # Filter the chosen level of the CIS - if is_level_1: + if is_level_1 and "REQUIREMENTS_ATTRIBUTES_PROFILE" in data.columns: data = data[data["REQUIREMENTS_ATTRIBUTES_PROFILE"].str.contains("Level 1")] # Rename the column PROJECTID to ACCOUNTID for GCP @@ -314,6 +366,9 @@ def display_data( data.rename(columns={"SUBSCRIPTION": "ACCOUNTID"}, inplace=True) data["REGION"] = "-" + # Normalize scope columns for any remaining (e.g. dynamic) provider. + data = _ensure_scope_columns(data) + # Filter ACCOUNT if account_filter == ["All"]: updated_cloud_account_values = data["ACCOUNTID"].unique() @@ -409,36 +464,7 @@ def display_data( # Check cases where the compliance start with AWS_ if "aws_" in analytics_input: analytics_input = analytics_input + "_aws" - try: - current = analytics_input.replace(".", "_") - compliance_module = importlib.import_module( - f"dashboard.compliance.{current}" - ) - # Build subset list based on available columns - dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"] - if "MUTED" in data.columns: - dedup_columns.insert(2, "MUTED") - data = data.drop_duplicates(subset=dedup_columns) - - if "threatscore" in analytics_input: - data = get_threatscore_mean_by_pillar(data) - - table = compliance_module.get_table(data) - except ModuleNotFoundError: - table = html.Div( - [ - html.H5( - "No data found for this compliance", - className="card-title", - style={"text-align": "left", "color": "black"}, - ) - ], - style={ - "width": "99%", - "margin-right": "0.8%", - "margin-bottom": "10px", - }, - ) + table, data = _dispatch_compliance_renderer(data, analytics_input) df = data.copy() # Remove Muted rows diff --git a/dashboard/pages/overview.py b/dashboard/pages/overview.py index 665aa8e195..e705f15e9f 100644 --- a/dashboard/pages/overview.py +++ b/dashboard/pages/overview.py @@ -1538,7 +1538,7 @@ def filter_data( html.Img(src="assets/favicon.ico", className="w-5 mr-3"), html.Span("Subscribe to Prowler Cloud"), ], - href="https://prowler.pro/", + href="https://cloud.prowler.com/", target="_blank", className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10", ), diff --git a/tests/dashboard/__init__.py b/tests/dashboard/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/common_methods_test.py b/tests/dashboard/common_methods_test.py new file mode 100644 index 0000000000..b2137589c5 --- /dev/null +++ b/tests/dashboard/common_methods_test.py @@ -0,0 +1,81 @@ +import pandas as pd +from dash import dash_table + +from dashboard.common_methods import get_section_containers_generic + + +def _datatable_column_ids(component): + """Collect the column ids of every DataTable in a Dash component tree.""" + if isinstance(component, dash_table.DataTable): + return [[c["id"] for c in component.columns]] + children = getattr(component, "children", None) + if children is None: + return [] + if not isinstance(children, (list, tuple)): + children = [children] + return [cols for child in children for cols in _datatable_column_ids(child)] + + +def _df(**extra): + data = { + "REQUIREMENTS_ID": ["req1"], + "STATUS": ["PASS"], + "CHECKID": ["check1"], + "REGION": ["us-east-1"], + "ACCOUNTID": ["123"], + "RESOURCEID": ["res1"], + } + data.update(extra) + return pd.DataFrame(data) + + +class TestGetSectionContainersGeneric: + def test_one_container_per_section(self): + """One outer container per distinct section value.""" + df = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A", "Sec B"], + "REQUIREMENTS_ID": ["req1", "req2", "req3"], + "STATUS": ["PASS", "FAIL", "PASS"], + "CHECKID": ["c1", "c2", "c3"], + "REGION": ["-"] * 3, + "ACCOUNTID": ["123"] * 3, + "RESOURCEID": ["r1", "r2", "r3"], + } + ) + result = get_section_containers_generic( + df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) + assert len(result.children) == 2 + + def test_inner_title_includes_id_and_description(self): + """Inner accordion title is ' - '.""" + df = _df( + REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"], + REQUIREMENTS_DESCRIPTION=["Ensure MFA"], + ) + rendered = str( + get_section_containers_generic( + df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) + ) + assert "req1 - Ensure MFA" in rendered + + def test_arbitrary_ids_do_not_crash(self): + """Non-numeric ids are sorted lexicographically without raising.""" + df = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A"] * 3, + "REQUIREMENTS_ID": ["AC-2(1)", "foo-bar", "step.1.2"], + "STATUS": ["PASS", "FAIL", "PASS"], + "CHECKID": ["c1", "c2", "c3"], + "REGION": ["-"] * 3, + "ACCOUNTID": ["123"] * 3, + "RESOURCEID": ["r1", "r2", "r3"], + } + ) + result = get_section_containers_generic( + df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) + tables = _datatable_column_ids(result) + assert tables and all("CHECKID" in cols for cols in tables) diff --git a/tests/dashboard/compliance/__init__.py b/tests/dashboard/compliance/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/compliance/generic_test.py b/tests/dashboard/compliance/generic_test.py new file mode 100644 index 0000000000..4e36833ada --- /dev/null +++ b/tests/dashboard/compliance/generic_test.py @@ -0,0 +1,204 @@ +import pandas as pd +from dash import dash_table, html + +from dashboard.compliance.generic import get_table + + +def _make_minimal_df(**extra_cols): + """Create a minimal valid DataFrame for get_table tests.""" + data = { + "REQUIREMENTS_ID": ["req1"], + "STATUS": ["PASS"], + "CHECKID": ["check1"], + "REGION": ["us-east-1"], + "ACCOUNTID": ["123456789"], + "RESOURCEID": ["res1"], + } + data.update(extra_cols) + return pd.DataFrame(data) + + +def _datatable_column_ids(component): + """Collect the column ids of every DataTable in a Dash component tree.""" + if isinstance(component, dash_table.DataTable): + return [[c["id"] for c in component.columns]] + children = getattr(component, "children", None) + if children is None: + return [] + if not isinstance(children, (list, tuple)): + children = [children] + return [cols for child in children for cols in _datatable_column_ids(child)] + + +class TestGetTable: + def test_groups_by_section(self): + """SC-001a: df with REQUIREMENTS_ATTRIBUTES_SECTION returns Div grouped by section.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": [ + "Section A", + "Section A", + "Section A", + "Section B", + "Section B", + ], + "REQUIREMENTS_ID": [ + "ctrl-alpha", + "ctrl-alpha", + "ctrl-alpha", + "ctrl-beta", + "ctrl-beta", + ], + "STATUS": ["PASS", "FAIL", "PASS", "FAIL", "FAIL"], + "CHECKID": ["check1", "check2", "check3", "check4", "check5"], + "REGION": ["us-east-1"] * 5, + "ACCOUNTID": ["123"] * 5, + "RESOURCEID": ["res1", "res2", "res3", "res4", "res5"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert result.className == "compliance-data-layout" + assert len(result.children) == 2 # one container per distinct section + + def test_flat_fallback_no_attributes(self): + """SC-001b: No REQUIREMENTS_ATTRIBUTES_* cols → grouped by REQUIREMENTS_ID.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ID": ["req1", "req1", "req2"], + "STATUS": ["PASS", "FAIL", "FAIL"], + "CHECKID": ["check1", "check2", "check3"], + "REGION": ["us-east-1"] * 3, + "ACCOUNTID": ["123"] * 3, + "RESOURCEID": ["res1", "res2", "res3"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert result.className == "compliance-data-layout" + # 2 distinct REQUIREMENTS_ID values → 2 group containers + assert len(result.children) == 2 + + def test_arbitrary_ids_no_crash(self): + """ADR-2 / R1 regression guard: non-numeric REQUIREMENTS_IDs must not raise ValueError. + + get_section_containers_cis sorts by version_tuple which calls int() on each + dotted/dashed segment and crashes on IDs like 'AC-2(1)'. Selecting format4 + (no version sort) is the fix. This test is a permanent guard against regression. + """ + data = pd.DataFrame( + { + "REQUIREMENTS_ID": ["AC-2(1)", "foo-bar", "step.1.2"], + "STATUS": ["PASS", "FAIL", "PASS"], + "CHECKID": ["check1", "check2", "check3"], + "REGION": ["us-east-1"] * 3, + "ACCOUNTID": ["123"] * 3, + "RESOURCEID": ["res1", "res2", "res3"], + } + ) + # Must not raise ValueError + result = get_table(data) + assert isinstance(result, html.Div) + + def test_discovers_multiple_attribute_columns(self): + """SC-005a: Multiple REQUIREMENTS_ATTRIBUTES_* cols present → no AttributeError; + component tree is non-empty.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec B"], + "REQUIREMENTS_ATTRIBUTES_CATEGORY": ["Cat 1", "Cat 2"], + "REQUIREMENTS_ATTRIBUTES_CONTROL_ID": ["C1", "C2"], + "REQUIREMENTS_ID": ["req1", "req2"], + "STATUS": ["PASS", "FAIL"], + "CHECKID": ["check1", "check2"], + "REGION": ["us-east-1"] * 2, + "ACCOUNTID": ["123"] * 2, + "RESOURCEID": ["res1", "res2"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert result.children # non-empty component tree + + def test_novel_attribute_column_names(self): + """SC-005b: Novel attr col names without a SECTION col → first attr col used as + grouping; returns a valid html.Div without any code change required.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_DOMAIN": ["Domain A", "Domain B"], + "REQUIREMENTS_ATTRIBUTES_SUBDOMAIN": ["Sub 1", "Sub 2"], + "REQUIREMENTS_ID": ["req1", "req2"], + "STATUS": ["PASS", "FAIL"], + "CHECKID": ["check1", "check2"], + "REGION": ["us-east-1"] * 2, + "ACCOUNTID": ["123"] * 2, + "RESOURCEID": ["res1", "res2"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert len(result.children) > 0 + + def test_manual_only_requirements(self): + """SC-008a: All rows have STATUS='MANUAL' → returns html.Div with non-empty + children; result is not the 'No data found' string.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec B"], + "REQUIREMENTS_ID": ["req1", "req2"], + "STATUS": ["MANUAL", "MANUAL"], + "CHECKID": ["check1", "check2"], + "REGION": ["us-east-1"] * 2, + "ACCOUNTID": ["123"] * 2, + "RESOURCEID": ["res1", "res2"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert not isinstance(result, str) + assert result.children # non-empty + + def test_empty_dataframe(self): + """SC-009a: Zero rows with correct column schema → valid html.Div; no exception.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": pd.Series([], dtype=str), + "REQUIREMENTS_ID": pd.Series([], dtype=str), + "STATUS": pd.Series([], dtype=str), + "CHECKID": pd.Series([], dtype=str), + "REGION": pd.Series([], dtype=str), + "ACCOUNTID": pd.Series([], dtype=str), + "RESOURCEID": pd.Series([], dtype=str), + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + + def test_get_table_returns_html_div(self): + """SC-012a: Smoke test — isinstance(get_table(df), html.Div) is True.""" + data = _make_minimal_df( + REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"], + ) + result = get_table(data) + assert isinstance(result, html.Div) + + +class TestNestedRendering: + def test_section_and_requirement_id_are_separate_levels(self): + """Section is the outer level; requirement id + description the inner.""" + data = _make_minimal_df( + REQUIREMENTS_ATTRIBUTES_SECTION=["3 Compute Services"], + REQUIREMENTS_DESCRIPTION=["Ensure only MFA enabled identities"], + ) + rendered = str(get_table(data)) + assert "3 Compute Services" in rendered + assert "req1 - Ensure only MFA enabled identities" in rendered + + def test_checks_table_is_nested_under_requirement(self): + """The checks table sits at the innermost level.""" + data = _make_minimal_df( + REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"], + REQUIREMENTS_DESCRIPTION=["Some requirement"], + ) + tables = _datatable_column_ids(get_table(data)) + assert tables and all("CHECKID" in cols for cols in tables) diff --git a/tests/dashboard/pages/__init__.py b/tests/dashboard/pages/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/pages/compliance_dispatch_test.py b/tests/dashboard/pages/compliance_dispatch_test.py new file mode 100644 index 0000000000..78bd88ce68 --- /dev/null +++ b/tests/dashboard/pages/compliance_dispatch_test.py @@ -0,0 +1,179 @@ +from unittest.mock import MagicMock, patch + +import pandas as pd +import pytest +from dash import html + +from dashboard.pages.compliance import _dispatch_compliance_renderer + + +def _make_dispatch_df(**extra_cols): + """Minimal DataFrame with the columns required by the dedup step.""" + data = { + "REQUIREMENTS_ID": ["req1", "req2"], + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A"], + "STATUS": ["PASS", "FAIL"], + "CHECKID": ["check1", "check2"], + "RESOURCEID": ["res1", "res2"], + "STATUSEXTENDED": ["", ""], + "REGION": ["us-east-1", "us-east-1"], + "ACCOUNTID": ["123456789", "123456789"], + } + data.update(extra_cols) + return pd.DataFrame(data) + + +class TestDispatchComplianceRenderer: + def test_builtin_name_uses_builtin_module(self): + """SC-002a: analytics_input='cis_4_0_aws' resolves real builtin module; + returns (html.Div, DataFrame) 2-tuple.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ID": ["1.1", "1.2"], + "REQUIREMENTS_DESCRIPTION": ["Description 1", "Description 2"], + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Section A", "Section A"], + "CHECKID": ["check1", "check2"], + "STATUS": ["PASS", "FAIL"], + "REGION": ["us-east-1", "us-east-1"], + "ACCOUNTID": ["123456789", "123456789"], + "RESOURCEID": ["res1", "res2"], + "STATUSEXTENDED": ["Pass", "Fail"], + } + ) + table, result_data = _dispatch_compliance_renderer(data, "cis_4_0_aws") + assert isinstance(table, html.Div) + assert isinstance(result_data, pd.DataFrame) + + def test_unknown_name_falls_back_to_generic(self): + """SC-003a: Unknown analytics_input raises ModuleNotFoundError → generic + fallback is called with the deduped dataframe.""" + data = _make_dispatch_df() + sentinel = MagicMock( + return_value=html.Div([], className="compliance-data-layout") + ) + + with patch("dashboard.compliance.generic.get_table", sentinel): + table, result_data = _dispatch_compliance_renderer(data, "myfw_dynprovider") + + sentinel.assert_called_once() + assert isinstance(table, html.Div) + assert isinstance(result_data, pd.DataFrame) + + def test_import_error_is_not_swallowed(self): + """SC-003b: ImportError (NOT ModuleNotFoundError) is re-raised; except clause + is exact — only ModuleNotFoundError routes to generic.""" + data = _make_dispatch_df() + + with patch( + "dashboard.pages.compliance.importlib.import_module", + side_effect=ImportError("custom error"), + ): + with pytest.raises(ImportError, match="custom error"): + _dispatch_compliance_renderer(data, "anything") + + def test_get_table_error_in_generic_surfaces(self): + """SC-004a: ValueError from generic.get_table propagates (not swallowed); + get_table is called OUTSIDE the try block.""" + data = _make_dispatch_df() + + with patch( + "dashboard.compliance.generic.get_table", + side_effect=ValueError("boom"), + ): + with pytest.raises(ValueError, match="boom"): + _dispatch_compliance_renderer(data, "myfw_dynprovider") + + def test_get_table_error_in_builtin_surfaces(self): + """REQ-004 / ADR-1: RuntimeError from a builtin get_table propagates; + proving get_table is called outside the try block.""" + data = _make_dispatch_df() + mock_module = MagicMock() + mock_module.get_table.side_effect = RuntimeError("table error") + + with patch( + "dashboard.pages.compliance.importlib.import_module", + return_value=mock_module, + ): + with pytest.raises(RuntimeError, match="table error"): + _dispatch_compliance_renderer(data, "some_builtin_fw") + + def test_dedup_applied_before_get_table(self): + """ADR-1: Duplicate rows (identical CHECKID/STATUS/RESOURCEID/STATUSEXTENDED) + are dropped; returned data has the deduplicated row count.""" + # Row 0 and row 1 are identical in all dedup-key columns; row 2 is unique. + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A", "Sec B"], + "REQUIREMENTS_ID": ["req1", "req1", "req2"], + "STATUS": ["PASS", "PASS", "FAIL"], + "CHECKID": ["check1", "check1", "check2"], + "RESOURCEID": ["res1", "res1", "res2"], + "STATUSEXTENDED": ["", "", ""], + "REGION": ["us-east-1"] * 3, + "ACCOUNTID": ["123"] * 3, + } + ) + mock_module = MagicMock() + mock_module.get_table.return_value = html.Div([]) + + with patch( + "dashboard.pages.compliance.importlib.import_module", + return_value=mock_module, + ): + table, result_data = _dispatch_compliance_renderer(data, "some_fw") + + assert len(result_data) == 2 # one duplicate removed + + def test_muted_column_added_to_dedup_when_present(self): + """ADR-1 edge case: When MUTED column is present, it is included in the dedup + subset at index 2; rows differing only in MUTED are kept as distinct rows.""" + # Both rows share CHECKID/STATUS/RESOURCEID/STATUSEXTENDED but differ in MUTED. + # With MUTED in dedup_columns, both rows are kept (2 rows after dedup). + # Without MUTED in dedup_columns, they would be collapsed to 1 row. + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A"], + "REQUIREMENTS_ID": ["req1", "req1"], + "STATUS": ["PASS", "PASS"], + "CHECKID": ["check1", "check1"], + "RESOURCEID": ["res1", "res1"], + "STATUSEXTENDED": ["", ""], + "MUTED": ["True", "False"], + "REGION": ["us-east-1", "us-east-1"], + "ACCOUNTID": ["123", "123"], + } + ) + mock_module = MagicMock() + mock_module.get_table.return_value = html.Div([]) + + with patch( + "dashboard.pages.compliance.importlib.import_module", + return_value=mock_module, + ): + table, result_data = _dispatch_compliance_renderer(data, "some_fw") + + # MUTED at idx 2 means these two rows have different dedup keys → both kept + assert len(result_data) == 2 + + def test_returns_table_and_data_tuple(self): + """ADR-1 interface contract: _dispatch_compliance_renderer returns a + 2-tuple (table, deduped_data).""" + data = pd.DataFrame( + { + "REQUIREMENTS_ID": ["1.1", "1.2"], + "REQUIREMENTS_DESCRIPTION": ["Desc 1", "Desc 2"], + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Section A", "Section A"], + "CHECKID": ["check1", "check2"], + "STATUS": ["PASS", "FAIL"], + "REGION": ["us-east-1", "us-east-1"], + "ACCOUNTID": ["123456789", "123456789"], + "RESOURCEID": ["res1", "res2"], + "STATUSEXTENDED": ["", ""], + } + ) + result = _dispatch_compliance_renderer(data, "cis_4_0_aws") + assert isinstance(result, tuple) + assert len(result) == 2 + table, deduped_data = result + assert isinstance(table, html.Div) + assert isinstance(deduped_data, pd.DataFrame) diff --git a/tests/dashboard/pages/conftest.py b/tests/dashboard/pages/conftest.py new file mode 100644 index 0000000000..a5c674c7de --- /dev/null +++ b/tests/dashboard/pages/conftest.py @@ -0,0 +1,7 @@ +import dash + +# Initialize a minimal Dash app so that dashboard page modules can call +# dash.register_page() during import without raising PageError. +# This module-level initialization runs during pytest collection, before +# any test file in this directory is imported. +_test_app = dash.Dash("prowler_test_app", use_pages=True, pages_folder="") diff --git a/tests/dashboard/pages/scope_columns_test.py b/tests/dashboard/pages/scope_columns_test.py new file mode 100644 index 0000000000..a8aea34221 --- /dev/null +++ b/tests/dashboard/pages/scope_columns_test.py @@ -0,0 +1,60 @@ +import pandas as pd + +from dashboard.pages.compliance import _ensure_scope_columns + + +def _df(columns): + """Build a one-row DataFrame preserving the given column order.""" + return pd.DataFrame({col: ["x"] for col in columns}) + + +class TestEnsureScopeColumns: + def test_aws_account_and_region_preserved(self): + """A provider that already emits ACCOUNTID and REGION is left untouched.""" + df = _df(["PROVIDER", "DESCRIPTION", "ACCOUNTID", "REGION", "ASSESSMENTDATE"]) + result = _ensure_scope_columns(df) + assert "ACCOUNTID" in result.columns + assert "REGION" in result.columns + assert result["ACCOUNTID"].iloc[0] == "x" + + def test_okta_single_scope_column_becomes_accountid(self): + """Okta's ORGANIZATIONDOMAIN becomes ACCOUNTID; REGION falls back.""" + df = _df(["PROVIDER", "DESCRIPTION", "ORGANIZATIONDOMAIN", "ASSESSMENTDATE"]) + df["ORGANIZATIONDOMAIN"] = ["trial-123.okta.com"] + result = _ensure_scope_columns(df) + assert "ACCOUNTID" in result.columns + assert "ORGANIZATIONDOMAIN" not in result.columns + assert result["ACCOUNTID"].iloc[0] == "trial-123.okta.com" + assert result["REGION"].iloc[0] == "-" + + def test_two_unknown_scope_columns_map_to_account_and_region(self): + """Two scope columns map positionally to ACCOUNTID and REGION.""" + df = _df(["PROVIDER", "DESCRIPTION", "TENANCYID", "LOCATION", "ASSESSMENTDATE"]) + df["TENANCYID"] = ["tenant-1"] + df["LOCATION"] = ["eu-west-1"] + result = _ensure_scope_columns(df) + assert result["ACCOUNTID"].iloc[0] == "tenant-1" + assert result["REGION"].iloc[0] == "eu-west-1" + + def test_no_scope_columns_fall_back_to_dash(self): + """No scope columns → both ACCOUNTID and REGION fall back to '-'.""" + df = _df(["PROVIDER", "DESCRIPTION", "ASSESSMENTDATE"]) + result = _ensure_scope_columns(df) + assert result["ACCOUNTID"].iloc[0] == "-" + assert result["REGION"].iloc[0] == "-" + + def test_missing_anchors_still_fall_back_to_dash(self): + """Without DESCRIPTION/ASSESSMENTDATE anchors, both fall back to '-'.""" + df = _df(["PROVIDER", "FOO", "BAR"]) + result = _ensure_scope_columns(df) + assert result["ACCOUNTID"].iloc[0] == "-" + assert result["REGION"].iloc[0] == "-" + + def test_existing_accountid_does_not_consume_region_scope(self): + """An existing ACCOUNTID is kept; the leftover scope becomes REGION.""" + df = _df(["PROVIDER", "DESCRIPTION", "ACCOUNTID", "LOCATION", "ASSESSMENTDATE"]) + df["ACCOUNTID"] = ["acc-1"] + df["LOCATION"] = ["us-east-2"] + result = _ensure_scope_columns(df) + assert result["ACCOUNTID"].iloc[0] == "acc-1" + assert result["REGION"].iloc[0] == "us-east-2"