From a21cb64a945692eddca7c573d50bea653ad27357 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 10 Jun 2026 10:34:50 +0200 Subject: [PATCH 001/126] fix(ui): extend integration poll timeouts to 60s (#11519) --- ui/CHANGELOG.md | 1 + ui/actions/integrations/integrations.ts | 2 +- ui/actions/integrations/jira-dispatch.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 49e6ddf9f0..a3d12493d6 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -54,6 +54,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Compliance page now loads the most recent scan when opened from the sidebar instead of showing the "no compliance data available" alert [(#11374)](https://github.com/prowler-cloud/prowler/pull/11374) - Invitation links now show specific expired, no-longer-valid, and invalid-token messages based on API error responses [(#11376)](https://github.com/prowler-cloud/prowler/pull/11376) +- Jira dispatch and provider connection-test polling no longer show a false timeout for longer-running tasks; both poll windows now extend to 60 seconds [(#11519)](https://github.com/prowler-cloud/prowler/pull/11519) ### 🔐 Security diff --git a/ui/actions/integrations/integrations.ts b/ui/actions/integrations/integrations.ts index 40084a0da1..8945842fe4 100644 --- a/ui/actions/integrations/integrations.ts +++ b/ui/actions/integrations/integrations.ts @@ -286,7 +286,7 @@ const pollTaskUntilComplete = async ( taskId: string, ): Promise => { const settled = await pollTaskUntilSettled(taskId, { - maxAttempts: 10, + maxAttempts: 20, delayMs: 3000, }); diff --git a/ui/actions/integrations/jira-dispatch.ts b/ui/actions/integrations/jira-dispatch.ts index 72bfc1176d..9e3b25679f 100644 --- a/ui/actions/integrations/jira-dispatch.ts +++ b/ui/actions/integrations/jira-dispatch.ts @@ -148,7 +148,7 @@ export const pollJiraDispatchTask = async ( { success: true; message: string } | { success: false; error: string } > => { const res = await pollTaskUntilSettled(taskId, { - maxAttempts: 10, + maxAttempts: 30, delayMs: 2000, }); if (!res.ok) { From 4a5a49b5bb0c57af8bf6ceec9bdf40fa56d7ee59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 10 Jun 2026 10:55:31 +0200 Subject: [PATCH 002/126] fix(api): store and refresh Resource.name on every scan (#11476) Co-authored-by: Josema Camacho --- api/CHANGELOG.md | 1 + api/src/backend/tasks/jobs/scan.py | 9 +++ api/src/backend/tasks/tests/test_scan.py | 73 ++++++++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index dc404079ec..ab40743b36 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to the **Prowler API** are documented in this file. ### 🐞 Fixed - Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) +- Resource `name` is now stored and refreshed on every scan, so resources no longer keep an empty name [(#11476)](https://github.com/prowler-cloud/prowler/pull/11476) ### 🔐 Security diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index db5018db90..ded3ee57dc 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -269,6 +269,7 @@ def _store_resources( provider=provider_instance, uid=finding.resource_uid, defaults={ + "name": finding.resource_name, "region": finding.region, "service": finding.service_name, "type": finding.resource_type, @@ -276,6 +277,7 @@ def _store_resources( ) if not created: + resource_instance.name = finding.resource_name resource_instance.region = finding.region resource_instance.service = finding.service_name resource_instance.type = finding.resource_type @@ -704,6 +706,12 @@ def _process_finding_micro_batch( if finding.region and resource_instance.region != finding.region: resource_instance.region = finding.region updated = True + if ( + finding.resource_name + and resource_instance.name != finding.resource_name + ): + resource_instance.name = finding.resource_name + updated = True if resource_instance.service != finding.service_name: resource_instance.service = finding.service_name updated = True @@ -945,6 +953,7 @@ def _process_finding_micro_batch( Resource.objects.bulk_update( resources_to_bulk_update, [ + "name", "metadata", "details", "partition", diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 8d3d0be93a..6961659acf 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -315,6 +315,7 @@ class TestPerformScan: provider=provider_instance, uid=finding.resource_uid, defaults={ + "name": finding.resource_name, "region": finding.region, "service": finding.service_name, "type": finding.resource_type, @@ -348,6 +349,7 @@ class TestPerformScan: resource_instance = MagicMock() resource_instance.uid = finding.resource_uid + resource_instance.name = "old_name" resource_instance.region = "us-west-1" resource_instance.service = "old_service" resource_instance.type = "old_type" @@ -366,6 +368,7 @@ class TestPerformScan: provider=provider_instance, uid=finding.resource_uid, defaults={ + "name": finding.resource_name, "region": finding.region, "service": finding.service_name, "type": finding.resource_type, @@ -373,6 +376,7 @@ class TestPerformScan: ) # Check that resource fields were updated + assert resource_instance.name == finding.resource_name assert resource_instance.region == finding.region assert resource_instance.service == finding.service_name assert resource_instance.type == finding.resource_type @@ -1565,6 +1569,75 @@ class TestProcessFindingMicroBatch: assert resource_cache[finding.resource_uid].service == finding.service_name assert tag_cache.keys() == {("team", "devsec")} + def test_process_finding_micro_batch_refreshes_empty_resource_name( + self, tenants_fixture, scans_fixture + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = scan.provider + + # Old resource stored before names were persisted: empty name. + existing_resource = Resource.objects.create( + tenant_id=tenant.id, + provider=provider, + uid="arn:aws:s3:::my-bucket", + name="", + region="us-east-1", + service="s3", + type="bucket", + ) + + finding = FakeFinding( + uid="finding-empty-name", + status=StatusChoices.PASS, + status_extended="passing", + severity=Severity.low, + check_id="s3_bucket_public_access", + resource_uid=existing_resource.uid, + resource_name="my-bucket", + region="us-east-1", + service_name="s3", + resource_type="bucket", + partition="aws", + raw={"status": "PASS"}, + metadata={"source": "prowler"}, + ) + + resource_cache = {existing_resource.uid: existing_resource} + tag_cache = {} + last_status_cache = {} + resource_failed_findings_cache = {existing_resource.uid: 0} + unique_resources: set[tuple[str, str]] = set() + scan_resource_cache: set[tuple[str, str, str, str]] = set() + mute_rules_cache = {} + scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {} + scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {} + group_resources_cache: dict[str, set] = {} + + with ( + patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction), + patch("api.db_utils.rls_transaction", new=noop_rls_transaction), + ): + _process_finding_micro_batch( + str(tenant.id), + [finding], + scan, + provider, + resource_cache, + tag_cache, + last_status_cache, + resource_failed_findings_cache, + unique_resources, + scan_resource_cache, + mute_rules_cache, + scan_categories_cache, + scan_resource_groups_cache, + group_resources_cache, + ) + + existing_resource.refresh_from_db() + assert existing_resource.name == finding.resource_name + def test_process_finding_micro_batch_skips_long_uid( self, tenants_fixture, scans_fixture ): 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 003/126] 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" From 61cd4aea3fb74ad33433987435b19f0fb2ef93cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 10 Jun 2026 11:22:42 +0200 Subject: [PATCH 004/126] feat(compliance): add Okta IDaaS STIG V1R2 framework (#11428) Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Co-authored-by: Daniel Barranquero --- api/CHANGELOG.md | 1 + api/src/backend/tasks/jobs/export.py | 6 + prowler/CHANGELOG.md | 1 + prowler/__main__.py | 30 + prowler/compliance/okta/__init__.py | 0 .../okta/okta_idaas_stig_v1r2_okta.json | 638 ++++++++++++++++++ prowler/lib/check/compliance_models.py | 21 + prowler/lib/outputs/compliance/compliance.py | 12 + .../compliance/okta_idaas_stig/__init__.py | 0 .../compliance/okta_idaas_stig/models.py | 32 + .../okta_idaas_stig/okta_idaas_stig.py | 98 +++ .../okta_idaas_stig/okta_idaas_stig_okta.py | 95 +++ .../display_compliance_table_test.py | 9 + tests/lib/outputs/compliance/fixtures.py | 45 ++ .../okta_idaas_stig_okta_test.py | 139 ++++ ui/CHANGELOG.md | 1 + .../asd-essential-eight-details.tsx | 6 +- .../aws-well-architected-details.tsx | 6 +- .../compliance-custom-details/ccc-details.tsx | 14 +- .../compliance-custom-details/cis-details.tsx | 4 +- .../compliance-custom-details/csa-details.tsx | 20 +- .../dora-details.tsx | 6 +- .../compliance-custom-details/ens-details.tsx | 4 +- .../generic-details.tsx | 6 +- .../mitre-details.tsx | 8 +- .../okta-idaas-stig-details.tsx | 65 ++ .../shared-components.tsx | 58 +- .../threat-details.tsx | 10 +- .../icons/compliance/IconCompliance.test.ts | 6 + .../icons/compliance/IconCompliance.tsx | 2 + ui/components/icons/compliance/okta.svg | 1 + ui/components/shadcn/badge/badge.test.tsx | 30 + ui/components/shadcn/badge/badge.tsx | 1 + ui/lib/compliance/ccc.tsx | 9 +- ui/lib/compliance/compliance-mapper.test.ts | 6 + ui/lib/compliance/compliance-mapper.ts | 14 + ui/lib/compliance/csa.tsx | 6 +- ui/lib/compliance/okta-idaas-stig.tsx | 163 +++++ ui/types/compliance.ts | 44 ++ 39 files changed, 1521 insertions(+), 96 deletions(-) create mode 100644 prowler/compliance/okta/__init__.py create mode 100644 prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json create mode 100644 prowler/lib/outputs/compliance/okta_idaas_stig/__init__.py create mode 100644 prowler/lib/outputs/compliance/okta_idaas_stig/models.py create mode 100644 prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py create mode 100644 prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py create mode 100644 tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py create mode 100644 ui/components/compliance/compliance-custom-details/okta-idaas-stig-details.tsx create mode 100644 ui/components/icons/compliance/okta.svg create mode 100644 ui/components/shadcn/badge/badge.test.tsx create mode 100644 ui/lib/compliance/okta-idaas-stig.tsx diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index ab40743b36..2033dcc420 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the **Prowler API** are documented in this file. - Opt-in automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: when enabled via `DJANGO_TASK_RECOVERY_ENABLED` (off by default), stuck summary and deletion tasks are detected and re-run instead of staying pending forever (scan and Jira tasks are excluded), with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) - DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) - Label Postgres connections with `application_name=":"` (component injected per process via `DJANGO_APP_COMPONENT`) so connections are attributable by component in `pg_stat_activity` [(#11494)](https://github.com/prowler-cloud/prowler/pull/11494) +- DISA Okta IDaaS STIG V1R2 compliance framework export support for the Okta provider [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428) ### 🔄 Changed diff --git a/api/src/backend/tasks/jobs/export.py b/api/src/backend/tasks/jobs/export.py index 185acb7f99..96bd03ee6c 100644 --- a/api/src/backend/tasks/jobs/export.py +++ b/api/src/backend/tasks/jobs/export.py @@ -58,6 +58,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import ( AzureMitreAttack, ) from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import ( + OktaIDaaSSTIG, +) from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import ( ProwlerThreatScoreAlibaba, ) @@ -152,6 +155,9 @@ COMPLIANCE_CLASS_MAP = { ProwlerThreatScoreAlibaba, ), ], + "okta": [ + (lambda name: name.startswith("okta_idaas_stig"), OktaIDaaSSTIG), + ], } diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index dcb250d316..c9542d8345 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🚀 Added +- DISA Okta IDaaS STIG V1R2 compliance framework for the Okta provider, with a dedicated CSV output formatter and terminal summary table [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428) - `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278) - DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) - Okta authenticator and password policy checks for STIG-aligned hardening requirements [(#11465)](https://github.com/prowler-cloud/prowler/pull/11465) diff --git a/prowler/__main__.py b/prowler/__main__.py index 6cbaf575d9..703a9e49a0 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -102,6 +102,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import ( AzureMitreAttack, ) from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import ( + OktaIDaaSSTIG, +) from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import ( ProwlerThreatScoreAlibaba, ) @@ -1314,6 +1317,33 @@ def prowler(): ) generated_outputs["compliance"].append(generic_compliance) generic_compliance.batch_write_data_to_file() + elif provider == "okta": + for compliance_name in input_compliance_frameworks: + if compliance_name.startswith("okta_idaas_stig"): + # Generate Okta IDaaS STIG Finding Object + filename = ( + f"{output_options.output_directory}/compliance/" + f"{output_options.output_filename}_{compliance_name}.csv" + ) + okta_idaas_stig = OktaIDaaSSTIG( + findings=finding_outputs, + compliance=bulk_compliance_frameworks[compliance_name], + file_path=filename, + ) + generated_outputs["compliance"].append(okta_idaas_stig) + okta_idaas_stig.batch_write_data_to_file() + else: + filename = ( + f"{output_options.output_directory}/compliance/" + f"{output_options.output_filename}_{compliance_name}.csv" + ) + generic_compliance = GenericCompliance( + findings=finding_outputs, + compliance=bulk_compliance_frameworks[compliance_name], + file_path=filename, + ) + generated_outputs["compliance"].append(generic_compliance) + generic_compliance.batch_write_data_to_file() else: # Dynamic fallback: any external/custom provider try: diff --git a/prowler/compliance/okta/__init__.py b/prowler/compliance/okta/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json b/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json new file mode 100644 index 0000000000..bac3d9132e --- /dev/null +++ b/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json @@ -0,0 +1,638 @@ +{ + "Framework": "Okta-IDaaS-STIG", + "Name": "DISA Okta Identity as a Service (IDaaS) STIG V1R2", + "Version": "1R2", + "Provider": "Okta", + "Description": "Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS), Version 1 Release 2 (Benchmark Date: 05 Jan 2026).", + "Requirements": [ + { + "Id": "OKTA-APP-000020", + "Name": "Okta must log out a session after a 15-minute period of inactivity.", + "Description": "A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate physical vicinity of the information system but does not log out because of the temporary nature of the absence. Rather than relying on the user to manually lock their application session prior to vacating the vicinity, applications must be able to identify when a user's application session has idled and take action to initiate the session lock. The session lock is implemented at the point where session activity can be determined and/or controlled. This is typically at the operating system level and results in a system lock. However, it may be at the application level where the application interface window is secured instead. Satisfies: SRG-APP-000003, SRG-APP-000190", + "Checks": [ + "signon_global_session_idle_timeout_15min" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273186r1098825_rule", + "StigID": "OKTA-APP-000020", + "CCI": [ + "CCI-000057", + "CCI-001133" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the edit icon next to the Priority 1 rule. 4. Verify the \"Maximum Okta global session idle time\" is set to 15 minutes. If \"Maximum Okta global session idle time\" is not set to 15 minutes, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the Rules table, make these updates: - Click \"Add rule\". - Set \"Maximum Okta global session idle time\" to 15 minutes." + } + ] + }, + { + "Id": "OKTA-APP-000025", + "Name": "The Okta Admin Console must log out a session after a 15-minute period of inactivity.", + "Description": "A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate physical vicinity of the information system but does not log out because of the temporary nature of the absence. Rather than relying on the user to manually lock their application session prior to vacating the vicinity, applications must be able to identify when a user's application session has idled and take action to initiate the session lock. The session lock is implemented at the point where session activity can be determined and/or controlled. This is typically at the operating system level and results in a system lock. However, it may be at the application level where the application interface window is secured instead.", + "Checks": [ + "application_admin_console_session_idle_timeout_15min" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273187r1098828_rule", + "StigID": "OKTA-APP-000025", + "CCI": [ + "CCI-000057" + ], + "CheckText": "From the Admin Console: 1. Select Applications >> Applications >> Okta Admin Console. 2. In the Sign On tab, under \"Okta Admin Console session\", verify the \"Maximum app session idle time\" is set to 15 minutes. If the \"Maximum app session idle time\" is not set to 15 minutes, this is a finding.", + "FixText": "From the Admin Console: 1. Select Applications >> Applications >> Okta Admin Console. 2. In the Sign On tab, under \"Okta Admin Console session\", set the \"Maximum app session idle time\" to 15 minutes." + } + ] + }, + { + "Id": "OKTA-APP-000090", + "Name": "Okta must automatically disable accounts after a 35-day period of account inactivity.", + "Description": "Attackers that are able to exploit an inactive account can potentially obtain and maintain undetected access to an application. Owners of inactive accounts will not notice if unauthorized access to their user account has been obtained. Applications must track periods of user inactivity and disable accounts after 35 days of inactivity. Such a process greatly reduces the risk that accounts will be hijacked, leading to a data compromise. To address access requirements, many application developers choose to integrate their applications with enterprise-level authentication/access mechanisms that meet or exceed access control policy requirements. Such integration allows the application developer to off-load those access control functions and focus on core application features and functionality. This policy does not apply to emergency accounts or infrequently used accounts. Infrequently used accounts are local login administrator accounts used by system administrators when network or normal login/access is not available. Emergency accounts are administrator accounts created in response to crisis situations. Satisfies: SRG-APP-000025, SRG-APP-000163, SRG-APP-000700", + "Checks": [ + "user_inactivity_automation_35d_enabled" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273188r1098831_rule", + "StigID": "OKTA-APP-000090", + "CCI": [ + "CCI-000017", + "CCI-000795", + "CCI-003627" + ], + "CheckText": "If Okta Services rely on external directory services for user sourcing, this is not applicable, and the connected directory services must perform this function. Go to Workflows >> Automations and verify that an Automation has been created to disable accounts after 35 days of inactivity. If the Okta configuration does not automatically disable accounts after a 35-day period of account inactivity, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Workflow >> Automations and select \"Add Automation\". 2. Create a name for the Automation (e.g., \"User Inactivity\"). 3. Click \"Add Condition\" and select \"User Inactivity in Okta\". 4. In the duration field, enter 35 days and click \"Save\". 5 Click the edit button next to \"Select Schedule\". 6. Configure the \"Schedule\" field for \"Run Daily\" and set the \"Time\" field to an organizationally defined time to run this automation. Click \"Save\". 7. Click the edit button next to \"Select group membership\". 8. In the \"Applies to\" field, select the group \"Everyone\" by typing it into the field. Click \"Save\". 9. Click \"Add Action\" and select \"Change User lifecycle state in Okta\". 10. In the \"Change user state to\" field, select \"Suspended\" and click \"Save\". 11. Click the \"Inactive\" button near the top of the section screen and select \"Activate\"." + } + ] + }, + { + "Id": "OKTA-APP-000170", + "Name": "Okta must enforce the limit of three consecutive invalid login attempts by a user during a 15-minute time period.", + "Description": "By limiting the number of failed login attempts, the risk of unauthorized system access via user password guessing, otherwise known as brute forcing, is reduced. Limits are imposed by locking the account. Satisfies: SRG-APP-000065, SRG-APP-000345", + "Checks": [ + "authenticator_password_lockout_threshold_3" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273189r1098834_rule", + "StigID": "OKTA-APP-000170", + "CCI": [ + "CCI-000044", + "CCI-002238" + ], + "CheckText": "If Okta Services rely on external directory services for user sourcing, this check is not applicable, and the connected directory services must perform this function. From the Admin Console: 1. Go to Security >> Authenticators. 2. Click the \"Actions\" button next to \"Password\" and select \"Edit\". 3. For each Password Policy, verify the \"Lock Out\" section has the following values: - \"Lock out after 3 unsuccessful attempts\" is checked. - The value is set to \"3\". If Okta Services are not configured to automatically lock user accounts after three consecutive invalid login attempts, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. Click the \"Actions\" button next to \"Password\" and select \"Edit\". 3. For each Password Policy, ensure the \"Lock Out\" section has the following values: - \"Lock out after 3 unsuccessful attempts\" is checked. - The value is set to \"3\"." + } + ] + }, + { + "Id": "OKTA-APP-000180", + "Name": "The Okta Dashboard application must be configured to allow authentication only via non-phishable authenticators.", + "Description": "Requiring the use of non-phishable authenticators protects against brute force/password dictionary attacks. This provides a better level of security while removing the need to lock out accounts after three attempts in 15 minutes.", + "Checks": [ + "application_dashboard_phishing_resistant_authentication" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273190r1099763_rule", + "StigID": "OKTA-APP-000180", + "CCI": [ + "CCI-000044" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, verify the \"Phishing resistant\" box is checked. This will ensure that only phishing-resistant factors are used to access the Okta Dashboard. If in the \"Possession factor constraints are\" section the \"Phishing resistant\" box is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, ensure the \"Phishing resistant\" box is checked." + } + ] + }, + { + "Id": "OKTA-APP-000190", + "Name": "The Okta Admin Console application must be configured to allow authentication only via non-phishable authenticators.", + "Description": "Requiring the use of non-phishable authenticators protects against brute force/password dictionary attacks. This provides a better level of security while removing the need to lock out accounts after three attempts in 15 minutes.", + "Checks": [ + "application_admin_console_phishing_resistant_authentication" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273191r1099764_rule", + "StigID": "OKTA-APP-000190", + "CCI": [ + "CCI-000044" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, verify the \"Phishing resistant\" box is checked. This will ensure that only phishing-resistant factors are used to access the Okta Dashboard. If in the \"Possession factor constraints are\" section the \"Phishing resistant\" box is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, ensure the \"Phishing resistant\" box is checked." + } + ] + }, + { + "Id": "OKTA-APP-000200", + "Name": "Okta must display the Standard Mandatory DOD Notice and Consent Banner before granting access to the application.", + "Description": "Display of the DOD-approved use notification before granting access to the application ensures that privacy and security notification verbiage used is consistent with applicable federal laws, Executive Orders, directives, policies, regulations, standards, and guidance. System use notifications are required only for access via login interfaces with human users and are not required when such human interfaces do not exist. The banner must be formatted in accordance with DTM-08-060. Use the following verbiage for applications that can accommodate banners of 1300 characters: \"You are accessing a U.S. Government (USG) Information System (IS) that is provided for USG-authorized use only. By using this IS (which includes any device attached to this IS), you consent to the following conditions: -The USG routinely intercepts and monitors communications on this IS for purposes including, but not limited to, penetration testing, COMSEC monitoring, network operations and defense, personnel misconduct (PM), law enforcement (LE), and counterintelligence (CI) investigations. -At any time, the USG may inspect and seize data stored on this IS. -Communications using, or data stored on, this IS are not private, are subject to routine monitoring, interception, and search, and may be disclosed or used for any USG-authorized purpose. -This IS includes security measures (e.g., authentication and access controls) to protect USG interests--not for your personal benefit or privacy. -Notwithstanding the above, using this IS does not constitute consent to PM, LE or CI investigative searching or monitoring of the content of privileged communications, or work product, related to personal representation or services by attorneys, psychotherapists, or clergy, and their assistants. Such communications and work product are private and confidential. See User Agreement for details.\" Use the following verbiage for operating systems that have severe limitations on the number of characters that can be displayed in the banner: \"I've read & consent to terms in IS user agreem't.\" Satisfies: SRG-APP-000068, SRG-APP-000069, SRG-APP-000070", + "Checks": [ + "signon_dod_warning_banner_configured" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273192r1098843_rule", + "StigID": "OKTA-APP-000200", + "CCI": [ + "CCI-000048", + "CCI-000050", + "CCI-001384", + "CCI-001385", + "CCI-001386", + "CCI-001387", + "CCI-001388" + ], + "CheckText": "Attempt to log in to the Okta tenant and verify the DOD-approved warning banner is in place. If the required warning banner is not present and complete, this is a finding.", + "FixText": "Follow the supplemental instructions in the \"Okta DOD Warning Banner Configuration Guide\" provided with this STIG package." + } + ] + }, + { + "Id": "OKTA-APP-000560", + "Name": "The Okta Admin Console application must be configured to use multifactor authentication.", + "Description": "Without the use of multifactor authentication, the ease of access to privileged functions is greatly increased. Multifactor authentication requires using two or more factors to achieve authentication. Factors include: (i) something a user knows (e.g., password/PIN); (ii) something a user has (e.g., cryptographic identification device, token); or (iii) something a user is (e.g., biometric). A privileged account is defined as an information system account with authorizations of a privileged user. Network access is defined as access to an information system by a user (or a process acting on behalf of a user) communicating through a network (e.g., local area network, wide area network, or the internet). Satisfies: SRG-APP-000149, SRG-APP-000154", + "Checks": [ + "application_admin_console_mfa_required" + ], + "Attributes": [ + { + "Section": "CAT I (High)", + "Severity": "high", + "RuleID": "SV-273193r1098846_rule", + "StigID": "OKTA-APP-000560", + "CCI": [ + "CCI-000765", + "CCI-004046" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, verify that either \"Password/IdP + Another factor\" or \"Any 2 factor types\" is selected. If either of these settings is incorrect, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, select either \"Password/IdP + Another factor\" or \"Any 2 factor types\"." + } + ] + }, + { + "Id": "OKTA-APP-000570", + "Name": "The Okta Dashboard application must be configured to use multifactor authentication.", + "Description": "To ensure accountability and prevent unauthenticated access, nonprivileged users must use multifactor authentication to prevent potential misuse and compromise of the system. Multifactor authentication uses two or more factors to achieve authentication. Factors include: (i) Something you know (e.g., password/PIN); (ii) Something you have (e.g., cryptographic identification device, token); or (iii) Something you are (e.g., biometric). A nonprivileged account is any information system account with authorizations of a nonprivileged user. Network access is any access to an application by a user (or process acting on behalf of a user) where the access is obtained through a network connection. Applications integrating with the DOD Active Directory and using the DOD CAC are examples of compliant multifactor authentication solutions. Satisfies: SRG-APP-000150, SRG-APP-000155", + "Checks": [ + "application_dashboard_mfa_required" + ], + "Attributes": [ + { + "Section": "CAT I (High)", + "Severity": "high", + "RuleID": "SV-273194r1098849_rule", + "StigID": "OKTA-APP-000570", + "CCI": [ + "CCI-000766", + "CCI-004046" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, verify that either \"Password/IdP + Another factor\" or \"Any 2 factor types\" is selected. If either of these settings is incorrect, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, select either \"Password/IdP + Another factor\" or \"Any 2 factor types\"." + } + ] + }, + { + "Id": "OKTA-APP-000650", + "Name": "Okta must enforce a minimum 15-character password length.", + "Description": "Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password length is one factor of several that helps to determine strength and how long it takes to crack a password. The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised. Use of more characters in a password helps to exponentially increase the time and/or resources required to compromise the password.", + "Checks": [ + "authenticator_password_minimum_length_15" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273195r1098852_rule", + "StigID": "OKTA-APP-000650", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify the \"Minimum Length\" field is set to at least \"15\" characters. If any policy is not set to at least \"15\", this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set the \"Minimum Length\" field to at least \"15\" characters." + } + ] + }, + { + "Id": "OKTA-APP-000670", + "Name": "Okta must enforce password complexity by requiring that at least one uppercase character be used.", + "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password is, the greater the number of possible combinations that need to be tested before the password is compromised.", + "Checks": [ + "authenticator_password_complexity_uppercase" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273196r1098855_rule", + "StigID": "OKTA-APP-000670", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Upper case letter\" is checked. For each policy, if \"Upper case letter\" is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Upper case letter\" to checked." + } + ] + }, + { + "Id": "OKTA-APP-000680", + "Name": "Okta must enforce password complexity by requiring that at least one lowercase character be used.", + "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised.", + "Checks": [ + "authenticator_password_complexity_lowercase" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273197r1098858_rule", + "StigID": "OKTA-APP-000680", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Lower case letter\" is checked. For each policy, if \"Lower case letter\" is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Lower case letter\" to checked." + } + ] + }, + { + "Id": "OKTA-APP-000690", + "Name": "Okta must enforce password complexity by requiring that at least one numeric character be used.", + "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised.", + "Checks": [ + "authenticator_password_complexity_number" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273198r1098861_rule", + "StigID": "OKTA-APP-000690", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Number (0-9)\" is checked. For each policy, if \"Number (0-9)\" is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Number (0-9)\" to checked." + } + ] + }, + { + "Id": "OKTA-APP-000700", + "Name": "Okta must enforce password complexity by requiring that at least one special character be used.", + "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor in determining how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised. Special characters are not alphanumeric. Examples include: ~ ! @ # $ % ^ *.", + "Checks": [ + "authenticator_password_complexity_symbol" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273199r1098864_rule", + "StigID": "OKTA-APP-000700", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Symbol (e.g., !@#$%^&*)\" is checked. For each policy, if \"Symbol (e.g., !@#$%^&*)\" is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Symbol (e.g., !@#$%^&*)\" to checked." + } + ] + }, + { + "Id": "OKTA-APP-000740", + "Name": "Okta must enforce 24 hours/one day as the minimum password lifetime.", + "Description": "Enforcing a minimum password lifetime helps prevent repeated password changes to defeat the password reuse or history enforcement requirement. Restricting this setting limits the user's ability to change their password. Passwords must be changed at specific policy-based intervals; however, if the application allows the user to immediately and continually change their password, it could be changed repeatedly in a short period of time to defeat the organization's policy regarding password reuse. Satisfies: SRG-APP-000173, SRG-APP-000870", + "Checks": [ + "authenticator_password_minimum_age_24h" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273200r1098867_rule", + "StigID": "OKTA-APP-000740", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Minimum password age is XX hours\" is set to at least \"24\". For each policy, if \"Minimum password age is XX hours\" is not set to at least \"24\", this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Minimum password age is XX hours\" to at least \"24\"." + } + ] + }, + { + "Id": "OKTA-APP-000745", + "Name": "Okta must enforce a 60-day maximum password lifetime restriction.", + "Description": "Any password, no matter how complex, can eventually be cracked. Therefore, passwords must be changed at specific intervals. One method of minimizing this risk is to use complex passwords and periodically change them. If the application does not limit the lifetime of passwords and force users to change their passwords, there is the risk that the system and/or application passwords could be compromised. This requirement does not include emergency administration accounts, which are meant for access to the application in case of failure. These accounts are not required to have maximum password lifetime restrictions.", + "Checks": [ + "authenticator_password_maximum_age_60d" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273201r1098870_rule", + "StigID": "OKTA-APP-000745", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Password expires after XX days\" is set to \"60\". For each policy, if \"Password expires after XX days\" is not set to \"60\", this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Password expires after XX days\" to \"60\"." + } + ] + }, + { + "Id": "OKTA-APP-001430", + "Name": "Okta must off-load audit records onto a central log server.", + "Description": "Information stored in one location is vulnerable to accidental or incidental deletion or alteration. Off-loading is a common process in information systems with limited audit storage capacity. Satisfies: SRG-APP-000358, SRG-APP-000080, SRG-APP-000125", + "Checks": [ + "systemlog_streaming_enabled" + ], + "Attributes": [ + { + "Section": "CAT I (High)", + "Severity": "high", + "RuleID": "SV-273202r1099766_rule", + "StigID": "OKTA-APP-001430", + "CCI": [ + "CCI-001851", + "CCI-000166", + "CCI-001348" + ], + "CheckText": "From the Admin Console: 1. Go to Reports >> Log Streaming. 2. Verify that a Log Stream connection is configured and active. Alternately, interview the information system security manager (ISSM) and verify that an external Security Information and Event Management (SIEM) system is pulling Okta logs via an Application Programming Interface (API). If either of these is not configured, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Reports >> Log Streaming. 2. Select either \"AWS EventBridge\" or \"Splunk Cloud\" and click \"Next\". 3. Complete the necessary fields and click \"Save\". If Log Streaming is not an option because the SIEM required is not an option, customers can use the Okta Log API to export system logs in real time." + } + ] + }, + { + "Id": "OKTA-APP-001665", + "Name": "Okta must be configured to limit the global session lifetime to 18 hours.", + "Description": "Without reauthentication, users may access resources or perform tasks for which they do not have authorization. When applications provide the capability to change security roles or escalate the functional capability of the application, it is critical the user reauthenticate. In addition to the reauthentication requirements associated with session locks, organizations may require reauthentication of individuals and/or devices in other situations, including (but not limited to) the following circumstances. (i) When authenticators change; (ii) When roles change; (iii) When security categories of information systems change; (iv) When the execution of privileged functions occurs; (v) After a fixed period of time; or (vi) Periodically. Within the DOD, the minimum circumstances requiring reauthentication are privilege escalation and role changes.", + "Checks": [ + "signon_global_session_lifetime_18h" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273203r1099958_rule", + "StigID": "OKTA-APP-001665", + "CCI": [ + "CCI-002038" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the \"Edit\" icon next to the Priority 1 rule. 4. Verify \"Maximum Okta global session lifetime\" is set to 18 hours. If the above is not set, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the Rules table, make these updates: - Click \"Add rule\". - Set \"Maximum Okta global session lifetime\" to 18 hours." + } + ] + }, + { + "Id": "OKTA-APP-001670", + "Name": "Okta must be configured to accept Personal Identity Verification (PIV) credentials.", + "Description": "The use of PIV credentials facilitates standardization and reduces the risk of unauthorized access. DOD has mandated the use of the common access card (CAC) to support identity management and personal authentication for systems covered under HSPD 12, as well as a primary component of layered protection for national security systems. Satisfies: SRG-APP-000391, SRG-APP-000402, SRG-APP-000403", + "Checks": [ + "authenticator_smart_card_active" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273204r1098879_rule", + "StigID": "OKTA-APP-001670", + "CCI": [ + "CCI-001953", + "CCI-002009", + "CCI-002010" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. Verify that \"Smart Card Authenticator\" is listed and has \"Status\" listed as \"Active\". If \"Smart Card Authenticator\" is not listed or is not listed as \"Active\", this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. In the \"Setup\" tab, click \"Add authenticator\". 3. Select the configured Smart Card Identity Provider and finish configuration." + } + ] + }, + { + "Id": "OKTA-APP-001700", + "Name": "The Okta Verify application must be configured to connect only to FIPS-compliant devices.", + "Description": "Without device-to-device authentication, communications with malicious devices may be established. Bidirectional authentication provides stronger safeguards to validate the identity of other devices for connections that are of greater risk. Currently, DOD requires the use of AES for bidirectional authentication because it is the only FIPS-validated AES cipher block algorithm. For distributed architectures (e.g., service-oriented architectures), the decisions regarding the validation of authentication claims may be made by services separate from the services acting on those decisions. In such situations, it is necessary to provide authentication decisions (as opposed to the actual authenticators) to the services that need to act on those decisions. A local connection is any connection with a device communicating without the use of a network. A network connection is any connection with a device that communicates through a network (e.g., local area or wide area network; the internet). A remote connection is any connection with a device communicating through an external network (e.g., the internet). Because of the challenges of applying this requirement on a large scale, organizations are encouraged to apply the requirement only to those limited number (and type) of devices that truly need to support this capability.", + "Checks": [ + "authenticator_okta_verify_fips_compliant" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273205r1098882_rule", + "StigID": "OKTA-APP-001700", + "CCI": [ + "CCI-001967" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. From the \"Setup\" tab, select \"Edit Okta Verify\". 3. Review the \"FIPS Compliance\" field. If FIPS-compliant authentication is not enabled, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. From the \"Setup\" tab, select \"Edit Okta Verify\". 3. In the \"FIPS Compliance\" field, choose whether users enrolling in Okta Verify can use FIPS-compliant devices only or any device. 4. Click \"Save\" after making any changes." + } + ] + }, + { + "Id": "OKTA-APP-001710", + "Name": "Okta must be configured to disable persistent global session cookies.", + "Description": "If cached authentication information is out of date, the validity of the authentication information may be questionable. Satisfies: SRG-APP-000400, SRG-APP-000157", + "Checks": [ + "signon_global_session_cookies_not_persistent" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273206r1098885_rule", + "StigID": "OKTA-APP-001710", + "CCI": [ + "CCI-002007", + "CCI-001942" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the \"Edit\" icon next to the Priority 1 rule. 4. Verify \"Okta global session cookies persist across browser sessions\" is set to \"Disabled\". If the above it not set, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the \"Rules\" table, make these updates: - Click \"Add rule\". - Set \"Okta global session cookies persist across browser sessions\" to Disable." + } + ] + }, + { + "Id": "OKTA-APP-001920", + "Name": "Okta must be configured to use only DOD-approved certificate authorities.", + "Description": "Untrusted Certificate Authorities (CA) can issue certificates, but they may be issued by organizations or individuals that seek to compromise DOD systems or by organizations with insufficient security controls. If the CA used for verifying the certificate is not DOD approved, trust of this CA has not been established. The DOD will accept only PKI certificates obtained from a DOD-approved internal or external CA. Reliance on CAs for the establishment of secure sessions includes, for example, the use of Transport Layer Security (TLS) certificates. This requirement focuses on communications protection for the application session rather than for the network packet. This requirement applies to applications that use communications sessions. This includes, but is not limited to, web-based applications and Service-Oriented Architectures (SOA). Satisfies: SRG-APP-000427, SRG-APP-000910", + "Checks": [ + "idp_smart_card_dod_approved_ca" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273207r1098888_rule", + "StigID": "OKTA-APP-001920", + "CCI": [ + "CCI-002470", + "CCI-004909" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Identity Providers (IdPs). 2. Review the list of IdPs with \"Type\" as \"Smart Card\". If the IdP is not listed as \"Active\", this is a finding. 3. Select Actions >> Configure. 4. Under \"Certificate chain\", verify the certificate is from a DOD-approved CA. If the certificate is not from a DOD-approved CA, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Identity Providers. 2. Click \"Add identity provider.\" 3. Click \"Smart Card IdP\". Click \"Next\". 4. Enter the name of the identity provider. 5. Build a certificate chain: - Click \"Browse\" to open a file explorer. Select the certificate file to add and click \"Open\". - To add another certificate, click \"Add Another\" and repeat step 1. - Click \"Build certificate chain\". On success, the chain and its certificates are shown. If the build failed, correct any issues and try again. - Click \"Reset certificate chain\" if replacing the current chain with a new one. 6. In \"IdP username\", select the \"idpuser.subjectAltNameUpn\" attribute. This is the attribute that stores the Electronic Data Interchange Personnel Identifier (EDIPI) on the CAC. 7. In the \"Match Against\" field, select the Okta Profile Attribute in which the EDIPI is to be stored." + } + ] + }, + { + "Id": "OKTA-APP-002980", + "Name": "Okta must validate passwords against a list of commonly used, expected, or compromised passwords.", + "Description": "Password-based authentication applies to passwords regardless of whether they are used in single-factor or multifactor authentication. Long passwords or passphrases are preferable over shorter passwords. Enforced composition rules provide marginal security benefits while decreasing usability. However, organizations may choose to establish certain rules for password generation (e.g., minimum character length for long passwords) under certain circumstances and can enforce this requirement in IA-5(1)(h). Account recovery can occur, for example, in situations when a password is forgotten. Cryptographically protected passwords include salted one-way cryptographic hashes of passwords. The list of commonly used, compromised, or expected passwords includes passwords obtained from previous breach corpuses, dictionary words, and repetitive or sequential characters. The list includes context-specific words, such as the name of the service, username, and derivatives thereof.", + "Checks": [ + "authenticator_password_common_password_check" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273208r1099769_rule", + "StigID": "OKTA-APP-002980", + "CCI": [ + "CCI-004058" + ], + "CheckText": "From the Admin Console: 1. Navigate to Security >> Authenticators. 2. Click the \"Actions\" button next to the Password authenticator and select \"Edit\". 3. Under the \"Password Settings\" section, verify the \"Common Password Check\" box is checked. If \"Common Password Check\" is not selected, this is a finding.", + "FixText": "From the Admin Console: 1. Navigate to Security >> Authenticators. 2. Click the \"Actions\" button next to the Password authenticator and select \"Edit\". 3. Under the \"Password Settings\" section, check the \"Common Password Check\" box." + } + ] + }, + { + "Id": "OKTA-APP-003010", + "Name": "Okta must prohibit password reuse for a minimum of five generations.", + "Description": "Password-based authentication applies to passwords regardless of whether they are used in single-factor or multifactor authentication. Long passwords or passphrases are preferable over shorter passwords. Enforced composition rules provide marginal security benefits while decreasing usability. However, organizations may choose to establish certain rules for password generation (e.g., minimum character length for long passwords) under certain circumstances and can enforce this requirement in IA-5(1)(h). Account recovery can occur, for example, in situations when a password is forgotten. Cryptographically protected passwords include salted one-way cryptographic hashes of passwords. The list of commonly used, compromised, or expected passwords includes passwords obtained from previous breach corpuses, dictionary words, and repetitive or sequential characters. The list includes context-specific words, such as the name of the service, username, and derivatives thereof.", + "Checks": [ + "authenticator_password_history_5" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273209r1098894_rule", + "StigID": "OKTA-APP-003010", + "CCI": [ + "CCI-004061" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password row\" and select \"Edit\". 3. For each listed policy, verify \"Enforce password history for last XX passwords\" is set to \"5\". If any policy is not set to at least \"5\", this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Enforce password history for last XX passwords\" to \"5\"." + } + ] + }, + { + "Id": "OKTA-APP-003240", + "Name": "Okta API tokens must be configured with Network Zones to restrict authorization from known networks.", + "Description": "An access token is a piece of data that represents the authorization granted to a user or NPE to access specific systems or information resources. Access tokens enable controlled access to services and resources. Properly managing the lifecycle of access tokens, including their issuance, validation, and revocation, is crucial to maintaining confidentiality of data and systems. Restricting token validity to a specific audience, e.g., an application or security domain, and restricting token validity lifetimes are important practices. Access tokens are revoked or invalidated if they are compromised, lost, or are no longer needed to mitigate the risks associated with stolen or misused tokens. API tokens have the potential to be replicated or stolen (just like a password). Because of this, it is important to only allow API tokens to authenticate from known IP ranges as this limits an adversary's ability to use a token to gain access.", + "Checks": [ + "apitoken_restricted_to_network_zone" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279689r1155066_rule", + "StigID": "OKTA-APP-003240", + "CCI": [ + "CCI-005165", + "CCI-000366" + ], + "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, click the token name link. 4. In the \"Security\" section, verify the \"Token can be used from\" setting is mapped to a known network zone for the application calling the API. If a network zone for each API access token is not defined, this is a finding.", + "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, click the token name link. 4. In the \"Security\" section, click \"Edit\". 5. Set the \"Token can be used from\" setting to the known network zone for the application calling the API. 6. Click \"Save\"." + } + ] + }, + { + "Id": "OKTA-APP-003241", + "Name": "Okta API tokens must be created under new dedicated user accounts.", + "Description": "An access token is a piece of data that represents the authorization granted to a user or NPE to access specific systems or information resources. Access tokens enable controlled access to services and resources. Properly managing the lifecycle of access tokens, including their issuance, validation, and revocation, is crucial to maintaining confidentiality of data and systems. Restricting token validity to a specific audience, e.g., an application or security domain, and restricting token validity lifetimes are important practices. Access tokens are revoked or invalidated if they are compromised, lost, or are no longer needed to mitigate the risks associated with stolen or misused tokens. When API tokens are created, they inherit the permissions of the user that created them. Therefore, API tokens should only be created from dedicated accounts and permissions must be constrained to least privilege for that dedicated user account and token. No API tokens should be created using a Super Admin account.", + "Checks": [ + "apitoken_not_super_admin" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279690r1155069_rule", + "StigID": "OKTA-APP-003241", + "CCI": [ + "CCI-005165", + "CCI-000366" + ], + "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, verify that the Role listed is not \"Super Admin\", and that the account has been specifically created for that token. 4. Click the account name to be token to the user profile for that user. 5. Verify the user only has an administrator role (standard or customer) applied that is correctly scoped as required and documented in the Okta Access Control policy. If the token is using a Super Administrator account, or one that is not properly scoped per the Access Control policy, this is a finding. Note: If a Super Admin token is required for system operation, then this permanent finding.", + "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed that has \"Super Admin\" or an improperly scoped Admin account, delete the token and create a new one with the appropriately scoped permissions. 4. Verify the application performing the API calls with the new token has been updated." + } + ] + }, + { + "Id": "OKTA-APP-003242", + "Name": "The Okta Global Session policy must be configured to allow or deny IP based access in accordance with the Access Control policy for Okta.", + "Description": "To mitigate the risk of unauthorized access to sensitive information by entities that have been issued certificates by DOD-approved PKIs, all DOD systems (e.g., networks, web servers, and web portals) must be properly configured to incorporate access control methods that do not rely solely on the possession of a certificate for access. Successful authentication must not automatically give an entity access to an asset or security boundary. Authorization procedures and controls must be implemented to ensure each authenticated entity also has a validated and current authorization. Authorization is the process of determining whether an entity, once authenticated, is permitted to access a specific asset. Information systems use access control policies and enforcement mechanisms to implement this requirement. Access Control policies include identity-based policies, role-based policies, and attribute-based policies. Access enforcement mechanisms include access control lists, access control matrices, and cryptography. These policies and mechanisms must be employed by the application to control access between users (or processes acting on behalf of users) and objects (e.g., devices, files, records, processes, programs, and domains) in the information system. The Okta Global Session Policy is applied at the organization level and before any application-specific authentication policies are processed. The Okta authorization package should contain an access control policy that defines IP ranges from which to either allow or deny access. This list (either as an explicit allow or explicit deny) can be implemented in the Global Session Policy.", + "Checks": [ + "signon_global_session_policy_network_zone_enforced" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279691r1155072_rule", + "StigID": "OKTA-APP-003242", + "CCI": [ + "CCI-000213" + ], + "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Global Session Policy\" item. 2. In the \"Policy Settings\" section, verify the \"IF User's IP is\" setting is correctly set to either allow or deny based on the organization defined policy. If the Okta Global Session Policy is not configured to restrict access to specific IP ranges, this is a finding.", + "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Global Session Policy\" item. 2. In the Policy Settings section, configure the \"IF User's IP is\" setting to correctly set the appropriate network to either allow or deny based on the Access Control Policy." + } + ] + }, + { + "Id": "OKTA-APP-003243", + "Name": "Okta must be configured with Network Zones defined to block anonymized proxies according to organizationally defined policy.", + "Description": "A mechanism to detect and prevent unauthorized communication flow must be configured or provided as part of the system design. If information flow is not enforced based on approved authorizations, the system may become compromised. Information flow control regulates where information is allowed to travel within a system and between interconnected systems. The flow of all application information must be monitored and controlled so it does not introduce any unacceptable risk to the systems or data. Application-specific examples of enforcement occurs in systems that employ rule sets or establish configuration settings that restrict information system services, or provide a message filtering capability based on message content (e.g., implementing key word searches or using document characteristics). Applications providing information flow control must be able to enforce approved authorizations for controlling the flow of information between interconnected systems in accordance with applicable policy. Working with the organizational CSSP, the ISSM should obtain a list of known anonymizer proxies that exist on the commercial internet. If this is not available from the CSSP, then the Okta-provided \"Enhanced dynamic zone blocklist\" should be activated.", + "Checks": [ + "network_zone_block_anonymized_proxies" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279692r1155075_rule", + "StigID": "OKTA-APP-003243", + "CCI": [ + "CCI-001414" + ], + "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Networks' item. 2. If the CSSP has provided a list of anonymizers to block, verify the \"IP Block list\" is configured with them. a. Click the pencil icon next to IP Block list. b. Verify the \"Gateway IPs\" section contains all of the IP ranges in the provided list. 3. If the CSSP is not able to provide a list, then implement the Okta managed list. a. Verify the \"Enhanced dynamic zone blocklist\" is set to \"Active\". If Network Zones are not configured to block anonymous proxies, this is a finding.", + "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Networks\" item. 2. If the CSSP has provided a list of anonymizers to block, add the IP ranges to the \"IP Block list\". a. Click the pencil icon next to IP Block list. b. Add the IP ranges to the \"Gateway IPs\" section and click \"Save\". 3. If the CSSP is not able to provide a list, then implement the Okta managed list. a. Set the \"Enhanced dynamic zone blocklist\" to \"Active\"." + } + ] + }, + { + "Id": "OKTA-APP-003244", + "Name": "For each application integrated with Okta, network zones must be defined in its authentication policy.", + "Description": "A mechanism to detect and prevent unauthorized communication flow must be configured or provided as part of the system design. If information flow is not enforced based on approved authorizations, the system may become compromised. Information flow control regulates where information is allowed to travel within a system and between interconnected systems. The flow of all application information must be monitored and controlled so it does not introduce any unacceptable risk to the systems or data. Application-specific examples of enforcement occurs in systems that employ rule sets or establish configuration settings that restrict information system services, or provide a message filtering capability based on message content (e.g., implementing key word searches or using document characteristics). Applications providing information flow control must be able to enforce approved authorizations for controlling the flow of information between interconnected systems in accordance with applicable policy. Each application in Okta should have a well defined access control policy that takes into account the end user network. This should be documented in the Access Control policy for each application. As an example, access to an application may be restricted to a specific location by policy. In this case, a network defining that specific location should be created.", + "Checks": [ + "application_authentication_policy_network_zone_enforced" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279693r1155078_rule", + "StigID": "OKTA-APP-003244", + "CCI": [ + "CCI-001414" + ], + "CheckText": "For each application integrated into Okta: 1. From the Admin console, open the \"Security\" menu, and then select \"Networks\". 2. Verify the list of networks includes all necessary allow or block lists. If any application is not configured with network zones, this is a finding.", + "FixText": "For each application, starting at the admin console: 1. Open the \"Applications\" group from the Menu, and then click the \"Applications\" menu item. 2. Click the application name. 3. Click the \"Sign On\" tab. 4. Scroll to the \"User Authentication\" section, and then click \"Edit\". 5. Select the appropriate Authentication policy from the pull down, and then click \"Save\". 6. Click \"View Policy Details\". 7. For each nondefault rule: a. Select \"Edit\" from the Actions menu. b. In the \"IF\" section, verify the \"User is\" setting has the appropriate allow or deny range has been selected based on the Access Control policy for the application. c. Scroll down to the bottom and click \"Save\". 8. For the Catch-All rule: a. Select \"Edit\" from the Actions menu. b. Scroll down to the \"Then\" section. c. For the \"Access is\" setting, select \"Denied\", and then click \"Save\"." + } + ] + } + ] +} diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py index 3610893008..f883bf60b1 100644 --- a/prowler/lib/check/compliance_models.py +++ b/prowler/lib/check/compliance_models.py @@ -283,6 +283,26 @@ class CSA_CCM_Requirement_Attribute(BaseModel): ScopeApplicability: list[dict] +class STIG_Requirement_Attribute_Severity(str, Enum): + """DISA STIG Requirement Attribute Severity (maps to CAT I/II/III)""" + + high = "high" + medium = "medium" + low = "low" + + +class STIG_Requirement_Attribute(BaseModel): + """DISA STIG Requirement Attribute""" + + Section: str + Severity: STIG_Requirement_Attribute_Severity + RuleID: str + StigID: str + CCI: Optional[list[str]] = None + CheckText: Optional[str] = None + FixText: Optional[str] = None + + # Base Compliance Model # TODO: move this to compliance folder class Compliance_Requirement(BaseModel): @@ -303,6 +323,7 @@ class Compliance_Requirement(BaseModel): CCC_Requirement_Attribute, C5Germany_Requirement_Attribute, CSA_CCM_Requirement_Attribute, + STIG_Requirement_Attribute, # Generic_Compliance_Requirement_Attribute must be the last one since it is the fallback for generic compliance framework Generic_Compliance_Requirement_Attribute, ] diff --git a/prowler/lib/outputs/compliance/compliance.py b/prowler/lib/outputs/compliance/compliance.py index 65ad8af0b3..4e4bd78232 100644 --- a/prowler/lib/outputs/compliance/compliance.py +++ b/prowler/lib/outputs/compliance/compliance.py @@ -18,6 +18,9 @@ from prowler.lib.outputs.compliance.kisa_ismsp.kisa_ismsp import get_kisa_ismsp_ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack import ( get_mitre_attack_table, ) +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig import ( + get_okta_idaas_stig_table, +) from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore import ( get_prowler_threatscore_table, ) @@ -252,6 +255,15 @@ def display_compliance_table( output_directory, compliance_overview, ) + elif compliance_framework.startswith("okta_idaas_stig"): + get_okta_idaas_stig_table( + findings, + bulk_checks_metadata, + compliance_framework, + output_filename, + output_directory, + compliance_overview, + ) else: # Try provider-specific table first, fall back to generic from prowler.providers.common.provider import Provider diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/__init__.py b/prowler/lib/outputs/compliance/okta_idaas_stig/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/models.py b/prowler/lib/outputs/compliance/okta_idaas_stig/models.py new file mode 100644 index 0000000000..674d9656b7 --- /dev/null +++ b/prowler/lib/outputs/compliance/okta_idaas_stig/models.py @@ -0,0 +1,32 @@ +from typing import Optional + +from pydantic.v1 import BaseModel + + +class OktaIDaaSSTIGModel(BaseModel): + """ + OktaIDaaSSTIGModel generates a finding's output in DISA Okta IDaaS STIG Compliance format. + """ + + Provider: str + Description: str + OrganizationDomain: str + AssessmentDate: str + Requirements_Id: str + Requirements_Name: str + Requirements_Description: str + Requirements_Attributes_Section: str + Requirements_Attributes_Severity: str + Requirements_Attributes_RuleID: str + Requirements_Attributes_StigID: str + Requirements_Attributes_CCI: Optional[list[str]] = None + Requirements_Attributes_CheckText: Optional[str] = None + Requirements_Attributes_FixText: Optional[str] = None + Status: str + StatusExtended: str + ResourceId: str + ResourceName: str + CheckId: str + Muted: bool + Framework: str + Name: str diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py new file mode 100644 index 0000000000..5c76055a06 --- /dev/null +++ b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py @@ -0,0 +1,98 @@ +from colorama import Fore, Style +from tabulate import tabulate + +from prowler.config.config import orange_color + + +def get_okta_idaas_stig_table( + findings: list, + bulk_checks_metadata: dict, + compliance_framework: str, + output_filename: str, + output_directory: str, + compliance_overview: bool, +): + section_table = { + "Provider": [], + "Section": [], + "Status": [], + "Muted": [], + } + pass_count = [] + fail_count = [] + muted_count = [] + sections = {} + for index, finding in enumerate(findings): + check = bulk_checks_metadata[finding.check_metadata.CheckID] + check_compliances = check.Compliance + for compliance in check_compliances: + if compliance.Framework == "Okta-IDaaS-STIG": + for requirement in compliance.Requirements: + for attribute in requirement.Attributes: + section = attribute.Section + + if section not in sections: + sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} + + if finding.muted: + if index not in muted_count: + muted_count.append(index) + sections[section]["Muted"] += 1 + else: + if finding.status == "FAIL" and index not in fail_count: + fail_count.append(index) + sections[section]["FAIL"] += 1 + elif finding.status == "PASS" and index not in pass_count: + pass_count.append(index) + sections[section]["PASS"] += 1 + + sections = dict(sorted(sections.items())) + for section in sections: + section_table["Provider"].append(compliance.Provider) + section_table["Section"].append(section) + if sections[section]["FAIL"] > 0: + section_table["Status"].append( + f"{Fore.RED}FAIL({sections[section]['FAIL']}){Style.RESET_ALL}" + ) + else: + if sections[section]["PASS"] > 0: + section_table["Status"].append( + f"{Fore.GREEN}PASS({sections[section]['PASS']}){Style.RESET_ALL}" + ) + else: + section_table["Status"].append(f"{Fore.GREEN}PASS{Style.RESET_ALL}") + section_table["Muted"].append( + f"{orange_color}{sections[section]['Muted']}{Style.RESET_ALL}" + ) + + if ( + len(fail_count) + len(pass_count) + len(muted_count) > 1 + ): # If there are no resources, don't print the compliance table + print( + f"\nCompliance Status of {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Framework:" + ) + total_findings_count = len(fail_count) + len(pass_count) + len(muted_count) + overview_table = [ + [ + f"{Fore.RED}{round(len(fail_count) / total_findings_count * 100, 2)}% ({len(fail_count)}) FAIL{Style.RESET_ALL}", + f"{Fore.GREEN}{round(len(pass_count) / total_findings_count * 100, 2)}% ({len(pass_count)}) PASS{Style.RESET_ALL}", + f"{orange_color}{round(len(muted_count) / total_findings_count * 100, 2)}% ({len(muted_count)}) MUTED{Style.RESET_ALL}", + ] + ] + print(tabulate(overview_table, tablefmt="rounded_grid")) + if not compliance_overview: + if len(fail_count) > 0 and len(section_table["Section"]) > 0: + print( + f"\nFramework {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Results:" + ) + print( + tabulate( + section_table, + tablefmt="rounded_grid", + headers="keys", + ) + ) + print(f"\nDetailed results of {compliance_framework.upper()} are in:") + print( + f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework}.csv\n" + ) diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py new file mode 100644 index 0000000000..25f71b4def --- /dev/null +++ b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py @@ -0,0 +1,95 @@ +from prowler.config.config import timestamp +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput +from prowler.lib.outputs.compliance.okta_idaas_stig.models import OktaIDaaSSTIGModel +from prowler.lib.outputs.finding import Finding + + +class OktaIDaaSSTIG(ComplianceOutput): + """ + This class represents the Okta IDaaS STIG compliance output. + + Attributes: + - _data (list): A list to store transformed data from findings. + - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. + + Methods: + - transform: Transforms findings into Okta IDaaS STIG compliance format. + """ + + def transform( + self, + findings: list[Finding], + compliance: Compliance, + _compliance_name: str, + ) -> None: + """ + Transforms a list of findings into Okta IDaaS STIG compliance format. + + Parameters: + - findings (list): A list of findings. + - compliance (Compliance): A compliance model. + - _compliance_name (str): The name of the compliance model (unused). + + Returns: + - None + """ + for finding in findings: + for requirement in compliance.Requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + for attribute in requirement.Attributes: + compliance_row = OktaIDaaSSTIGModel( + Provider=finding.provider, + Description=compliance.Description, + OrganizationDomain=finding.account_name, + AssessmentDate=str(timestamp), + Requirements_Id=requirement.Id, + Requirements_Name=requirement.Name, + Requirements_Description=requirement.Description, + Requirements_Attributes_Section=attribute.Section, + Requirements_Attributes_Severity=attribute.Severity.value, + Requirements_Attributes_RuleID=attribute.RuleID, + Requirements_Attributes_StigID=attribute.StigID, + Requirements_Attributes_CCI=attribute.CCI, + Requirements_Attributes_CheckText=attribute.CheckText, + Requirements_Attributes_FixText=attribute.FixText, + Status=finding.status, + StatusExtended=finding.status_extended, + ResourceId=finding.resource_uid, + ResourceName=finding.resource_name, + CheckId=finding.check_id, + Muted=finding.muted, + Framework=compliance.Framework, + Name=compliance.Name, + ) + self._data.append(compliance_row) + # Add manual requirements to the compliance output + for requirement in compliance.Requirements: + if not requirement.Checks: + for attribute in requirement.Attributes: + compliance_row = OktaIDaaSSTIGModel( + Provider=compliance.Provider.lower(), + Description=compliance.Description, + OrganizationDomain="", + AssessmentDate=str(timestamp), + Requirements_Id=requirement.Id, + Requirements_Name=requirement.Name, + Requirements_Description=requirement.Description, + Requirements_Attributes_Section=attribute.Section, + Requirements_Attributes_Severity=attribute.Severity.value, + Requirements_Attributes_RuleID=attribute.RuleID, + Requirements_Attributes_StigID=attribute.StigID, + Requirements_Attributes_CCI=attribute.CCI, + Requirements_Attributes_CheckText=attribute.CheckText, + Requirements_Attributes_FixText=attribute.FixText, + Status="MANUAL", + StatusExtended="Manual check", + ResourceId="manual_check", + ResourceName="Manual check", + CheckId="manual", + Muted=False, + Framework=compliance.Framework, + Name=compliance.Name, + ) + self._data.append(compliance_row) diff --git a/tests/lib/outputs/compliance/display_compliance_table_test.py b/tests/lib/outputs/compliance/display_compliance_table_test.py index a55789a7fd..bd854d6d7d 100644 --- a/tests/lib/outputs/compliance/display_compliance_table_test.py +++ b/tests/lib/outputs/compliance/display_compliance_table_test.py @@ -103,6 +103,15 @@ class TestDispatchStartswith: display_compliance_table(compliance_framework=framework_name, **_COMMON) mock_fn.assert_called_once() + @pytest.mark.parametrize( + "framework_name", + ["okta_idaas_stig_v1r2_okta"], + ) + @patch(f"{MODULE}.get_okta_idaas_stig_table") + def test_okta_idaas_stig_dispatch(self, mock_fn, framework_name): + display_compliance_table(compliance_framework=framework_name, **_COMMON) + mock_fn.assert_called_once() + @pytest.mark.parametrize( "framework_name", [ diff --git a/tests/lib/outputs/compliance/fixtures.py b/tests/lib/outputs/compliance/fixtures.py index f085460abd..a8fc7aa7e5 100644 --- a/tests/lib/outputs/compliance/fixtures.py +++ b/tests/lib/outputs/compliance/fixtures.py @@ -16,6 +16,7 @@ from prowler.lib.check.compliance_models import ( Mitre_Requirement_Attribute_Azure, Mitre_Requirement_Attribute_GCP, Prowler_ThreatScore_Requirement_Attribute, + STIG_Requirement_Attribute, ) CIS_1_4_AWS = Compliance( @@ -1258,3 +1259,47 @@ ASD_ESSENTIAL_EIGHT_AWS = Compliance( ), ], ) + +OKTA_IDAAS_STIG_OKTA = Compliance( + Framework="Okta-IDaaS-STIG", + Name="DISA Okta Identity as a Service (IDaaS) STIG V1R2", + Version="1R2", + Provider="Okta", + Description="Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).", + Requirements=[ + Compliance_Requirement( + Id="OKTA-APP-000020", + Name="Okta must log out a session after a 15-minute period of inactivity.", + Description="A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate vicinity of the information system.", + Attributes=[ + STIG_Requirement_Attribute( + Section="CAT II (Medium)", + Severity="medium", + RuleID="SV-273186r1098825_rule", + StigID="OKTA-APP-000020", + CCI=["CCI-000057", "CCI-001133"], + CheckText="Verify the Global Session Policy logs out a session after 15 minutes of inactivity.", + FixText="From the Admin Console configure the Global Session Policy idle timeout to 15 minutes.", + ) + ], + Checks=["signon_global_session_idle_timeout_15min"], + ), + Compliance_Requirement( + Id="OKTA-APP-000650", + Name="Okta must enforce a minimum 15-character password length.", + Description="The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised.", + Attributes=[ + STIG_Requirement_Attribute( + Section="CAT II (Medium)", + Severity="medium", + RuleID="SV-273209r1098894_rule", + StigID="OKTA-APP-000650", + CCI=["CCI-000205"], + CheckText="Verify the password policy enforces a minimum length of 15 characters.", + FixText="From the Admin Console set the minimum password length to 15 characters.", + ) + ], + Checks=[], + ), + ], +) diff --git a/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py new file mode 100644 index 0000000000..fa616c906e --- /dev/null +++ b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py @@ -0,0 +1,139 @@ +from datetime import datetime +from io import StringIO +from unittest import mock + +from freezegun import freeze_time +from mock import patch + +from prowler.lib.outputs.compliance.okta_idaas_stig.models import OktaIDaaSSTIGModel +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import ( + OktaIDaaSSTIG, +) +from tests.lib.outputs.compliance.fixtures import OKTA_IDAAS_STIG_OKTA +from tests.lib.outputs.fixtures.fixtures import generate_finding_output + +OKTA_ORG_DOMAIN = "dev-12345.okta.com" + + +class TestOktaIDaaSSTIG: + def test_output_transform(self): + findings = [ + generate_finding_output( + provider="okta", + account_uid=OKTA_ORG_DOMAIN, + account_name=OKTA_ORG_DOMAIN, + region="global", + service_name="signon", + check_id="signon_global_session_idle_timeout_15min", + resource_uid="okta-global-session-policy", + resource_name="Default Policy", + compliance={"Okta-IDaaS-STIG-1R2": ["OKTA-APP-000020"]}, + ) + ] + + output = OktaIDaaSSTIG(findings, OKTA_IDAAS_STIG_OKTA) + output_data = output.data[0] + assert isinstance(output_data, OktaIDaaSSTIGModel) + assert output_data.Provider == "okta" + assert output_data.Framework == OKTA_IDAAS_STIG_OKTA.Framework + assert output_data.Name == OKTA_IDAAS_STIG_OKTA.Name + assert output_data.OrganizationDomain == OKTA_ORG_DOMAIN + assert output_data.Description == OKTA_IDAAS_STIG_OKTA.Description + assert output_data.Requirements_Id == OKTA_IDAAS_STIG_OKTA.Requirements[0].Id + assert ( + output_data.Requirements_Name == OKTA_IDAAS_STIG_OKTA.Requirements[0].Name + ) + assert ( + output_data.Requirements_Description + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Description + ) + assert ( + output_data.Requirements_Attributes_Section + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].Section + ) + assert ( + output_data.Requirements_Attributes_Severity + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].Severity.value + ) + assert ( + output_data.Requirements_Attributes_RuleID + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].RuleID + ) + assert ( + output_data.Requirements_Attributes_StigID + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].StigID + ) + assert ( + output_data.Requirements_Attributes_CCI + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].CCI + ) + assert ( + output_data.Requirements_Attributes_CheckText + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].CheckText + ) + assert ( + output_data.Requirements_Attributes_FixText + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].FixText + ) + assert output_data.Status == "PASS" + assert output_data.StatusExtended == "" + assert output_data.ResourceId == "okta-global-session-policy" + assert output_data.ResourceName == "Default Policy" + assert output_data.CheckId == "signon_global_session_idle_timeout_15min" + assert output_data.Muted is False + # Test manual check + output_data_manual = output.data[1] + assert output_data_manual.Provider == "okta" + assert output_data_manual.Framework == OKTA_IDAAS_STIG_OKTA.Framework + assert output_data_manual.Name == OKTA_IDAAS_STIG_OKTA.Name + assert output_data_manual.OrganizationDomain == "" + assert ( + output_data_manual.Requirements_Id + == OKTA_IDAAS_STIG_OKTA.Requirements[1].Id + ) + assert ( + output_data_manual.Requirements_Attributes_Severity + == OKTA_IDAAS_STIG_OKTA.Requirements[1].Attributes[0].Severity.value + ) + assert ( + output_data_manual.Requirements_Attributes_StigID + == OKTA_IDAAS_STIG_OKTA.Requirements[1].Attributes[0].StigID + ) + assert output_data_manual.Status == "MANUAL" + assert output_data_manual.StatusExtended == "Manual check" + assert output_data_manual.ResourceId == "manual_check" + assert output_data_manual.ResourceName == "Manual check" + assert output_data_manual.CheckId == "manual" + assert output_data_manual.Muted is False + + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta.timestamp", + "2025-01-01 00:00:00", + ) + def test_batch_write_data_to_file(self): + mock_file = StringIO() + findings = [ + generate_finding_output( + provider="okta", + account_uid=OKTA_ORG_DOMAIN, + account_name=OKTA_ORG_DOMAIN, + region="global", + service_name="signon", + check_id="signon_global_session_idle_timeout_15min", + resource_uid="okta-global-session-policy", + resource_name="Default Policy", + compliance={"Okta-IDaaS-STIG-1R2": ["OKTA-APP-000020"]}, + ) + ] + output = OktaIDaaSSTIG(findings, OKTA_IDAAS_STIG_OKTA) + output._file_descriptor = mock_file + + with patch.object(mock_file, "close", return_value=None): + output.batch_write_data_to_file() + + mock_file.seek(0) + content = mock_file.read() + expected_csv = f"PROVIDER;DESCRIPTION;ORGANIZATIONDOMAIN;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_NAME;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SEVERITY;REQUIREMENTS_ATTRIBUTES_RULEID;REQUIREMENTS_ATTRIBUTES_STIGID;REQUIREMENTS_ATTRIBUTES_CCI;REQUIREMENTS_ATTRIBUTES_CHECKTEXT;REQUIREMENTS_ATTRIBUTES_FIXTEXT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED;FRAMEWORK;NAME\r\nokta;Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).;{OKTA_ORG_DOMAIN};{datetime.now()};OKTA-APP-000020;Okta must log out a session after a 15-minute period of inactivity.;A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate vicinity of the information system.;CAT II (Medium);medium;SV-273186r1098825_rule;OKTA-APP-000020;['CCI-000057', 'CCI-001133'];Verify the Global Session Policy logs out a session after 15 minutes of inactivity.;From the Admin Console configure the Global Session Policy idle timeout to 15 minutes.;PASS;;okta-global-session-policy;Default Policy;signon_global_session_idle_timeout_15min;False;Okta-IDaaS-STIG;DISA Okta Identity as a Service (IDaaS) STIG V1R2\r\nokta;Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).;;{datetime.now()};OKTA-APP-000650;Okta must enforce a minimum 15-character password length.;The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised.;CAT II (Medium);medium;SV-273209r1098894_rule;OKTA-APP-000650;['CCI-000205'];Verify the password policy enforces a minimum length of 15 characters.;From the Admin Console set the minimum password length to 15 characters.;MANUAL;Manual check;manual_check;Manual check;manual;False;Okta-IDaaS-STIG;DISA Okta Identity as a Service (IDaaS) STIG V1R2\r\n" + + assert content == expected_csv diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index a3d12493d6..dc8c0fa708 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added +- DISA Okta IDaaS STIG V1R2 compliance framework support with its dedicated mapper, details panel, and icon [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428) - DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) ### 🔄 Changed diff --git a/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.tsx b/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.tsx index 363eba2b10..2cb022a174 100644 --- a/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.tsx +++ b/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.tsx @@ -85,7 +85,7 @@ export const ASDEssentialEightCustomDetails = ({ )} @@ -93,7 +93,7 @@ export const ASDEssentialEightCustomDetails = ({ )} @@ -101,7 +101,7 @@ export const ASDEssentialEightCustomDetails = ({ )} diff --git a/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx b/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx index 96ed16a8f1..00aa51e4c5 100644 --- a/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx +++ b/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx @@ -52,7 +52,7 @@ export const AWSWellArchitectedCustomDetails = ({ )} @@ -60,7 +60,7 @@ export const AWSWellArchitectedCustomDetails = ({ )} @@ -68,7 +68,7 @@ export const AWSWellArchitectedCustomDetails = ({ )} diff --git a/ui/components/compliance/compliance-custom-details/ccc-details.tsx b/ui/components/compliance/compliance-custom-details/ccc-details.tsx index 309358264d..e3db174280 100644 --- a/ui/components/compliance/compliance-custom-details/ccc-details.tsx +++ b/ui/components/compliance/compliance-custom-details/ccc-details.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib"; +import { Badge } from "@/components/shadcn/badge/badge"; import { CCC_MAPPING_SECTIONS, CCC_TEXT_SECTIONS } from "@/lib/compliance/ccc"; import { Requirement } from "@/types/compliance"; @@ -46,7 +46,7 @@ export const CCCCustomDetails = ({ requirement }: CCCDetailsProps) => { )} @@ -68,15 +68,9 @@ export const CCCCustomDetails = ({ requirement }: CCCDetailsProps) => {
{mapping.Identifiers.map((identifier, idx) => ( - + {identifier} - + ))}
diff --git a/ui/components/compliance/compliance-custom-details/cis-details.tsx b/ui/components/compliance/compliance-custom-details/cis-details.tsx index c46f061188..25823038d5 100644 --- a/ui/components/compliance/compliance-custom-details/cis-details.tsx +++ b/ui/components/compliance/compliance-custom-details/cis-details.tsx @@ -41,7 +41,7 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => { )} @@ -49,7 +49,7 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => { )} diff --git a/ui/components/compliance/compliance-custom-details/csa-details.tsx b/ui/components/compliance/compliance-custom-details/csa-details.tsx index 738e4a8aab..81dbcc158a 100644 --- a/ui/components/compliance/compliance-custom-details/csa-details.tsx +++ b/ui/components/compliance/compliance-custom-details/csa-details.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib"; +import { Badge } from "@/components/shadcn/badge/badge"; import { CSA_MAPPING_SECTIONS } from "@/lib/compliance/csa"; import { Requirement } from "@/types/compliance"; @@ -36,28 +36,28 @@ export const CSACustomDetails = ({ requirement }: CSADetailsProps) => { )} {requirement.iaas && ( )} {requirement.paas && ( )} {requirement.saas && ( )} @@ -72,15 +72,9 @@ export const CSACustomDetails = ({ requirement }: CSADetailsProps) => {
{mapping.Identifiers.map((identifier, idx) => ( - + {identifier} - + ))}
diff --git a/ui/components/compliance/compliance-custom-details/dora-details.tsx b/ui/components/compliance/compliance-custom-details/dora-details.tsx index 3d84e891ba..f27412be5b 100644 --- a/ui/components/compliance/compliance-custom-details/dora-details.tsx +++ b/ui/components/compliance/compliance-custom-details/dora-details.tsx @@ -26,21 +26,21 @@ export const DORACustomDetails = ({ requirement }: DORADetailsProps) => { )} {requirement.article && ( )} {requirement.article_title && ( )} diff --git a/ui/components/compliance/compliance-custom-details/ens-details.tsx b/ui/components/compliance/compliance-custom-details/ens-details.tsx index 9e947cea14..2950c495cd 100644 --- a/ui/components/compliance/compliance-custom-details/ens-details.tsx +++ b/ui/components/compliance/compliance-custom-details/ens-details.tsx @@ -28,7 +28,7 @@ export const ENSCustomDetails = ({ )} @@ -36,7 +36,7 @@ export const ENSCustomDetails = ({ )} diff --git a/ui/components/compliance/compliance-custom-details/generic-details.tsx b/ui/components/compliance/compliance-custom-details/generic-details.tsx index caa3fec05d..323e3d9a99 100644 --- a/ui/components/compliance/compliance-custom-details/generic-details.tsx +++ b/ui/components/compliance/compliance-custom-details/generic-details.tsx @@ -26,7 +26,7 @@ export const GenericCustomDetails = ({ )} @@ -34,7 +34,7 @@ export const GenericCustomDetails = ({ )} @@ -42,7 +42,7 @@ export const GenericCustomDetails = ({ )} diff --git a/ui/components/compliance/compliance-custom-details/mitre-details.tsx b/ui/components/compliance/compliance-custom-details/mitre-details.tsx index 8fa7fa276d..53fc583f2c 100644 --- a/ui/components/compliance/compliance-custom-details/mitre-details.tsx +++ b/ui/components/compliance/compliance-custom-details/mitre-details.tsx @@ -37,7 +37,7 @@ export const MITRECustomDetails = ({ )} @@ -81,17 +81,17 @@ export const MITRECustomDetails = ({ {service.comment && ( diff --git a/ui/components/compliance/compliance-custom-details/okta-idaas-stig-details.tsx b/ui/components/compliance/compliance-custom-details/okta-idaas-stig-details.tsx new file mode 100644 index 0000000000..0869b7f390 --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/okta-idaas-stig-details.tsx @@ -0,0 +1,65 @@ +import { Severity, SeverityBadge } from "@/components/ui/table"; +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBadge, + ComplianceBadgeContainer, + ComplianceChipContainer, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, +} from "./shared-components"; + +export const OktaIDaaSStigCustomDetails = ({ + requirement, +}: { + requirement: Requirement; +}) => { + const severity = requirement.severity as string | undefined; + const stigId = requirement.stig_id as string | undefined; + const ruleId = requirement.rule_id as string | undefined; + const cci = requirement.cci as string[] | undefined; + const checkText = requirement.check_text as string | undefined; + const fixText = requirement.fix_text as string | undefined; + + return ( + + + {severity && ( +
+ + Severity: + + +
+ )} + {stigId && ( + + )} + {ruleId && ( + + )} +
+ + {requirement.description && ( + + {requirement.description} + + )} + + + + {checkText && ( + + {checkText} + + )} + + {fixText && ( + + {fixText} + + )} +
+ ); +}; diff --git a/ui/components/compliance/compliance-custom-details/shared-components.tsx b/ui/components/compliance/compliance-custom-details/shared-components.tsx index d5c69499af..22ba330d57 100644 --- a/ui/components/compliance/compliance-custom-details/shared-components.tsx +++ b/ui/components/compliance/compliance-custom-details/shared-components.tsx @@ -1,4 +1,12 @@ -import { cn } from "@/lib/utils"; +import { VariantProps } from "class-variance-authority"; + +import { Badge, badgeVariants } from "@/components/shadcn/badge/badge"; + +// Variants come straight from the canonical shadcn Badge so compliance panels +// share the same badge vocabulary (and tokens) as the rest of the app. +export type ComplianceBadgeVariant = NonNullable< + VariantProps["variant"] +>; export const ComplianceDetailContainer = ({ children, @@ -43,55 +51,28 @@ export const ComplianceBadgeContainer = ({ return
{children}
; }; -type BadgeColor = - | "red" // Risk/Level/Severity - | "blue" // Assessment/Method - | "orange" // Type/Category - | "green" // Weight/Score (positive) - | "purple" // Profile - | "indigo" // IDs/References - | "gray"; // Additional Info/Neutral - export const ComplianceBadge = ({ label, value, - color, + variant, conditional = false, }: { label: string; value: string | number; - color: BadgeColor; + variant: ComplianceBadgeVariant; conditional?: boolean; }) => { - const actualColor = conditional && Number(value) === 0 ? "gray" : color; - - const colorClasses = { - red: "bg-red-50 text-red-700 ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/20", - blue: "bg-blue-50 text-blue-700 ring-blue-600/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/20", - orange: - "bg-orange-50 text-orange-700 ring-orange-600/10 dark:bg-orange-400/10 dark:text-orange-400 dark:ring-orange-400/20", - green: - "bg-green-50 text-green-700 ring-green-600/10 dark:bg-green-400/10 dark:text-green-400 dark:ring-green-400/20", - purple: - "bg-purple-50 text-purple-700 ring-purple-600/10 dark:bg-purple-400/10 dark:text-purple-400 dark:ring-purple-400/20", - indigo: - "bg-indigo-50 text-indigo-700 ring-indigo-600/10 dark:bg-indigo-400/10 dark:text-indigo-400 dark:ring-indigo-400/20", - gray: "bg-gray-50 text-gray-600 ring-gray-500/10 dark:bg-gray-400/10 dark:text-gray-400 dark:ring-gray-400/20", - }; + // A "conditional" metric badge with a zero value drops to a neutral variant + // so empty scores don't read as a meaningful (e.g. positive) result. + const actualVariant: ComplianceBadgeVariant = + conditional && Number(value) === 0 ? "secondary" : variant; return (
{label}: - - {value} - + {value}
); }; @@ -132,12 +113,9 @@ export const ComplianceChipContainer = ({
{items.map((item: string, index: number) => ( - + {item} - + ))}
diff --git a/ui/components/compliance/compliance-custom-details/threat-details.tsx b/ui/components/compliance/compliance-custom-details/threat-details.tsx index 822612234d..542583ff0e 100644 --- a/ui/components/compliance/compliance-custom-details/threat-details.tsx +++ b/ui/components/compliance/compliance-custom-details/threat-details.tsx @@ -34,7 +34,7 @@ export const ThreatCustomDetails = ({ )} @@ -42,7 +42,7 @@ export const ThreatCustomDetails = ({ )} @@ -50,7 +50,7 @@ export const ThreatCustomDetails = ({ )} @@ -61,13 +61,13 @@ export const ThreatCustomDetails = ({ {requirement.totalFindings > 0 && ( )} diff --git a/ui/components/icons/compliance/IconCompliance.test.ts b/ui/components/icons/compliance/IconCompliance.test.ts index ca5f5a363e..b956b80548 100644 --- a/ui/components/icons/compliance/IconCompliance.test.ts +++ b/ui/components/icons/compliance/IconCompliance.test.ts @@ -58,6 +58,12 @@ describe("getComplianceIcon", () => { expect(getComplianceIcon("prowler_threatscore_aws")).toBe(threatLogo); }); + it("resolves the Okta IDaaS STIG via the `okta` keyword", () => { + const oktaLogo = getComplianceIcon("Okta-IDaaS-STIG"); + expect(oktaLogo).toBeDefined(); + expect(getComplianceIcon("okta_idaas_stig_v1r2_okta")).toBe(oktaLogo); + }); + it("resolves ASD Essential Eight by the framework keyword, not by `aws`", () => { const essentialLogo = getComplianceIcon("ASD-Essential-Eight"); expect(essentialLogo).toBeDefined(); diff --git a/ui/components/icons/compliance/IconCompliance.tsx b/ui/components/icons/compliance/IconCompliance.tsx index fe493e2096..ab0fb9a36a 100644 --- a/ui/components/icons/compliance/IconCompliance.tsx +++ b/ui/components/icons/compliance/IconCompliance.tsx @@ -18,6 +18,7 @@ import KISALogo from "./kisa.svg"; import MITRELogo from "./mitre-attack.svg"; import NIS2Logo from "./nis2.svg"; import NISTLogo from "./nist.svg"; +import OktaLogo from "./okta.svg"; import PCILogo from "./pci-dss.svg"; import PROWLERTHREATLogo from "./prowlerThreat.svg"; import RBILogo from "./rbi.svg"; @@ -72,6 +73,7 @@ const COMPLIANCE_LOGOS = [ // compliance_id is just `dora`, no provider suffix. ["dora", DORALogo], ["secnumcloud", ANSSILogo], + ["okta", OktaLogo], ["aws", AWSLogo], ] as const; diff --git a/ui/components/icons/compliance/okta.svg b/ui/components/icons/compliance/okta.svg new file mode 100644 index 0000000000..91b8acbb20 --- /dev/null +++ b/ui/components/icons/compliance/okta.svg @@ -0,0 +1 @@ + diff --git a/ui/components/shadcn/badge/badge.test.tsx b/ui/components/shadcn/badge/badge.test.tsx new file mode 100644 index 0000000000..adae2f54b8 --- /dev/null +++ b/ui/components/shadcn/badge/badge.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Badge } from "./badge"; + +describe("Badge", () => { + it("renders its children", () => { + render(Assessment); + expect(screen.getByText("Assessment")).toBeInTheDocument(); + }); + + it("applies the info variant token classes", () => { + const { container } = render(Info); + const badge = container.querySelector("[data-slot='badge']"); + // The info variant is built from the existing design-system blue token + // (bg-data-info) rather than a bespoke palette. + expect(badge?.className).toContain("bg-bg-data-info/15"); + expect(badge?.className).toContain("text-bg-data-info"); + }); + + it("merges a custom className", () => { + const { container } = render( + + Tag + , + ); + const badge = container.querySelector("[data-slot='badge']"); + expect(badge?.className).toContain("extra-class"); + }); +}); diff --git a/ui/components/shadcn/badge/badge.tsx b/ui/components/shadcn/badge/badge.tsx index f200fda7e2..fd1a6efd11 100644 --- a/ui/components/shadcn/badge/badge.tsx +++ b/ui/components/shadcn/badge/badge.tsx @@ -24,6 +24,7 @@ const badgeVariants = cva( "border-bg-warning/30 bg-bg-warning-secondary/20 text-text-warning-primary", error: "border-transparent bg-bg-fail-secondary text-text-error-primary", + info: "border-transparent bg-bg-data-info/15 text-bg-data-info", }, }, defaultVariants: { diff --git a/ui/lib/compliance/ccc.tsx b/ui/lib/compliance/ccc.tsx index fc6f7e898a..59df6a7c09 100644 --- a/ui/lib/compliance/ccc.tsx +++ b/ui/lib/compliance/ccc.tsx @@ -1,6 +1,7 @@ import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import { ComplianceBadgeVariant } from "@/components/compliance/compliance-custom-details/shared-components"; import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; import { FindingStatus } from "@/components/ui/table/status-finding-badge"; import { @@ -39,7 +40,7 @@ export interface CCCTextSection { export interface CCCMappingSection { title: string; key: keyof Requirement; - colorClasses: string; + variant: ComplianceBadgeVariant; } export const CCC_TEXT_SECTIONS: CCCTextSection[] = [ @@ -71,14 +72,12 @@ export const CCC_MAPPING_SECTIONS: CCCMappingSection[] = [ { title: "Threat Mappings", key: "section_threat_mappings", - colorClasses: - "bg-red-50 text-red-700 ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/20", + variant: "error", }, { title: "Guideline Mappings", key: "section_guideline_mappings", - colorClasses: - "bg-blue-50 text-blue-700 ring-blue-600/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/20", + variant: "info", }, ]; diff --git a/ui/lib/compliance/compliance-mapper.test.ts b/ui/lib/compliance/compliance-mapper.test.ts index 9526b50b07..25933b8dff 100644 --- a/ui/lib/compliance/compliance-mapper.test.ts +++ b/ui/lib/compliance/compliance-mapper.test.ts @@ -62,6 +62,10 @@ vi.mock( "@/components/compliance/compliance-custom-details/mitre-details", () => ({ MITRECustomDetails: stubFactory("MITREStub") }), ); +vi.mock( + "@/components/compliance/compliance-custom-details/okta-idaas-stig-details", + () => ({ OktaIDaaSStigCustomDetails: stubFactory("OktaIDaaSStigStub") }), +); vi.mock( "@/components/compliance/compliance-custom-details/threat-details", () => ({ ThreatCustomDetails: stubFactory("ThreatStub") }), @@ -144,6 +148,7 @@ describe("getComplianceMapper", () => { { framework: "ProwlerThreatScore", expected: "ThreatStub" }, { framework: "CCC", expected: "CCCStub" }, { framework: "CSA-CCM", expected: "CSAStub" }, + { framework: "Okta-IDaaS-STIG", expected: "OktaIDaaSStigStub" }, ]; for (const { framework, expected } of wiring) { @@ -188,6 +193,7 @@ describe("getComplianceMapper", () => { "ProwlerThreatScore", "CCC", "CSA-CCM", + "Okta-IDaaS-STIG", ]) { const mapper = getComplianceMapper(framework); expect(Object.keys(mapper).sort(), framework).toEqual(expectedKeys); diff --git a/ui/lib/compliance/compliance-mapper.ts b/ui/lib/compliance/compliance-mapper.ts index ba8c97ecfc..2e44958c73 100644 --- a/ui/lib/compliance/compliance-mapper.ts +++ b/ui/lib/compliance/compliance-mapper.ts @@ -12,6 +12,7 @@ import { GenericCustomDetails } from "@/components/compliance/compliance-custom- import { ISOCustomDetails } from "@/components/compliance/compliance-custom-details/iso-details"; import { KISACustomDetails } from "@/components/compliance/compliance-custom-details/kisa-details"; import { MITRECustomDetails } from "@/components/compliance/compliance-custom-details/mitre-details"; +import { OktaIDaaSStigCustomDetails } from "@/components/compliance/compliance-custom-details/okta-idaas-stig-details"; import { ThreatCustomDetails } from "@/components/compliance/compliance-custom-details/threat-details"; import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; import { @@ -74,6 +75,10 @@ import { mapComplianceData as mapMITREComplianceData, toAccordionItems as toMITREAccordionItems, } from "./mitre"; +import { + mapComplianceData as mapOktaIDaaSStigComplianceData, + toAccordionItems as toOktaIDaaSStigAccordionItems, +} from "./okta-idaas-stig"; import { getTopFailedSections as getThreatScoreTopFailedSections, mapComplianceData as mapThetaComplianceData, @@ -213,6 +218,15 @@ const getComplianceMappers = (): Record => ({ getDetailsComponent: (requirement: Requirement) => createElement(CSACustomDetails, { requirement }), }, + "Okta-IDaaS-STIG": { + mapComplianceData: mapOktaIDaaSStigComplianceData, + toAccordionItems: toOktaIDaaSStigAccordionItems, + getTopFailedSections, + calculateCategoryHeatmapData: (data: Framework[]) => + calculateCategoryHeatmapData(data), + getDetailsComponent: (requirement: Requirement) => + createElement(OktaIDaaSStigCustomDetails, { requirement }), + }, // DORA (Regulation (EU) 2022/2554) — universal framework keyed by the // `framework` field of `prowler/compliance/dora.json` ("DORA"). Groups by // Pillar (5 enum values) and surfaces Pillar / Article / ArticleTitle in diff --git a/ui/lib/compliance/csa.tsx b/ui/lib/compliance/csa.tsx index 93974d6b47..c4624baa43 100644 --- a/ui/lib/compliance/csa.tsx +++ b/ui/lib/compliance/csa.tsx @@ -1,6 +1,7 @@ import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import { ComplianceBadgeVariant } from "@/components/compliance/compliance-custom-details/shared-components"; import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; import { FindingStatus } from "@/components/ui/table/status-finding-badge"; import { @@ -24,15 +25,14 @@ import { export interface CSAMappingSection { title: string; key: keyof Requirement; - colorClasses: string; + variant: ComplianceBadgeVariant; } export const CSA_MAPPING_SECTIONS: CSAMappingSection[] = [ { title: "Scope Applicability", key: "scope_applicability", - colorClasses: - "bg-blue-50 text-blue-700 ring-blue-600/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/20", + variant: "info", }, ]; diff --git a/ui/lib/compliance/okta-idaas-stig.tsx b/ui/lib/compliance/okta-idaas-stig.tsx new file mode 100644 index 0000000000..5a00886406 --- /dev/null +++ b/ui/lib/compliance/okta-idaas-stig.tsx @@ -0,0 +1,163 @@ +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; +import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; +import { FindingStatus } from "@/components/ui/table/status-finding-badge"; +import { + AttributesData, + Control, + Framework, + isOktaIDaaSStigAttributesMetadata, + OktaIDaaSStigRequirement, + Requirement, + REQUIREMENT_STATUS, + RequirementsData, + RequirementStatus, +} from "@/types/compliance"; + +import { + calculateFrameworkCounters, + createRequirementsMap, + findOrCreateCategory, + findOrCreateControl, + findOrCreateFramework, +} from "./commons"; + +const getStatusCounters = (status: RequirementStatus) => ({ + pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, + fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, + manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, +}); + +export const mapComplianceData = ( + attributesData: AttributesData, + requirementsData: RequirementsData, +): Framework[] => { + const attributes = attributesData?.data || []; + const requirementsMap = createRequirementsMap(requirementsData); + const frameworks: Framework[] = []; + + for (const attributeItem of attributes) { + const id = attributeItem.id; + const metadataArray = attributeItem.attributes?.attributes?.metadata; + const attrs = metadataArray?.[0]; + if (!isOktaIDaaSStigAttributesMetadata(attrs)) continue; + + const requirementData = requirementsMap.get(id); + if (!requirementData) continue; + + const frameworkName = attributeItem.attributes.framework; + // Level 1: Section maps to the STIG severity category (e.g. "CAT II (Medium)") + const categoryName = attrs.Section; + // Level 2: each requirement is its own control, labelled by its STIG ID + const controlLabel = id; + const description = attributeItem.attributes.description; + // Human-readable STIG title (e.g. "Okta must log out a session after a + // 15-minute period of inactivity."). Surface it next to the STIG ID and + // fall back to the bare ID when missing, mirroring DORA/CSA. + const requirementName = attributeItem.attributes.name || ""; + const status = requirementData.attributes.status || ""; + const checks = attributeItem.attributes.attributes.check_ids || []; + + const framework = findOrCreateFramework(frameworks, frameworkName); + const category = findOrCreateCategory(framework.categories, categoryName); + const control = findOrCreateControl(category.controls, controlLabel); + + const finalStatus: RequirementStatus = status as RequirementStatus; + const requirement = { + name: requirementName ? `${id} - ${requirementName}` : id, + description, + status: finalStatus, + check_ids: checks, + ...getStatusCounters(finalStatus), + severity: attrs.Severity, + rule_id: attrs.RuleID, + stig_id: attrs.StigID, + cci: attrs.CCI, + check_text: attrs.CheckText, + fix_text: attrs.FixText, + } satisfies OktaIDaaSStigRequirement; + + control.requirements.push(requirement); + } + + calculateFrameworkCounters(frameworks); + + return frameworks; +}; + +const createRequirementItem = ( + requirement: Requirement, + frameworkName: string, + categoryName: string, + controlIndex: number, + scanId: string, +): AccordionItemProps => ({ + key: `${frameworkName}-${categoryName}-control-${controlIndex}`, + title: ( + + ), + content: ( + + ), + items: [], +}); + +const createControlItem = ( + control: Control, + frameworkName: string, + categoryName: string, + controlIndex: number, + scanId: string, +): AccordionItemProps => + createRequirementItem( + control.requirements[0], + frameworkName, + categoryName, + controlIndex, + scanId, + ); + +export const toAccordionItems = ( + data: Framework[], + scanId: string | undefined, +): AccordionItemProps[] => { + const safeId = scanId || ""; + + return data.flatMap((framework) => + framework.categories.map((category) => ({ + key: `${framework.name}-${category.name}`, + title: ( + + ), + content: "", + items: category.controls.map((control, controlIndex) => + createControlItem( + control, + framework.name, + category.name, + controlIndex, + safeId, + ), + ), + })), + ); +}; diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts index e926efe356..fde9d260e9 100644 --- a/ui/types/compliance.ts +++ b/ui/types/compliance.ts @@ -279,6 +279,12 @@ const isOneOf = ( const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every((item) => typeof item === "string"); +const isOptionalString = (value: unknown): value is string | undefined => + value === undefined || typeof value === "string"; + +const isOptionalStringArray = (value: unknown): value is string[] | undefined => + value === undefined || isStringArray(value); + const ASD_METADATA_STRING_FIELDS = [ "Section", "Description", @@ -327,6 +333,43 @@ export interface ASDEssentialEightRequirement extends Requirement { references: ASDEssentialEightAttributesMetadata["References"]; } +export interface OktaIDaaSStigAttributesMetadata { + Section: string; + Severity: string; + RuleID: string; + StigID: string; + CCI?: string[]; + CheckText?: string; + FixText?: string; +} + +const OKTA_IDAAS_STIG_REQUIRED_STRING_FIELDS = [ + "Section", + "Severity", + "RuleID", + "StigID", +] as const satisfies readonly (keyof OktaIDaaSStigAttributesMetadata)[]; + +export const isOktaIDaaSStigAttributesMetadata = ( + value: unknown, +): value is OktaIDaaSStigAttributesMetadata => + isRecord(value) && + OKTA_IDAAS_STIG_REQUIRED_STRING_FIELDS.every( + (field) => typeof value[field] === "string", + ) && + isOptionalStringArray(value.CCI) && + isOptionalString(value.CheckText) && + isOptionalString(value.FixText); + +export interface OktaIDaaSStigRequirement extends Requirement { + severity: OktaIDaaSStigAttributesMetadata["Severity"]; + rule_id: OktaIDaaSStigAttributesMetadata["RuleID"]; + stig_id: OktaIDaaSStigAttributesMetadata["StigID"]; + cci: OktaIDaaSStigAttributesMetadata["CCI"]; + check_text: OktaIDaaSStigAttributesMetadata["CheckText"]; + fix_text: OktaIDaaSStigAttributesMetadata["FixText"]; +} + // DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554). // Universal framework — flat attributes dict with Pillar/Article/ArticleTitle. // `Pillar` is the canonical grouping key for tables and PDF; the enum mirrors @@ -374,6 +417,7 @@ export interface AttributesItemData { | CCCAttributesMetadata[] | CSAAttributesMetadata[] | ASDEssentialEightAttributesMetadata[] + | OktaIDaaSStigAttributesMetadata[] | DORAAttributesMetadata[] | GenericAttributesMetadata[]; check_ids: string[]; From bfb3fcea4c02e9cb8fd488bb5a8089b49d9617f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 10 Jun 2026 11:34:35 +0200 Subject: [PATCH 005/126] fix(e2e): use branch SDK changes to create the container (#11522) --- .github/workflows/ui-e2e-tests-v2.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index 5e50adb19c..1a3653f8c9 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -134,7 +134,17 @@ jobs: # docker-compose.yml references prowlercloud/prowler-api:latest from the registry, # which lags behind PR changes; build locally so E2E exercises the API image # produced by this PR. - run: docker build -t prowlercloud/prowler-api:latest ./api + # + # The image installs the SDK from git@master (api/uv.lock), so a PR changing BOTH the SDK + # and the API would run against the OLD SDK and crash on startup. Overlay the checkout's + # SDK source so both run together. New SDK dependencies still need an api/uv.lock bump. + run: | + docker build -t prowlercloud/prowler-api:pr-base ./api + docker build -t prowlercloud/prowler-api:latest -f - prowler <<'DOCKERFILE' + FROM prowlercloud/prowler-api:pr-base + RUN rm -rf /home/prowler/.venv/lib/python3.12/site-packages/prowler + COPY --chown=prowler:prowler . /home/prowler/.venv/lib/python3.12/site-packages/prowler + DOCKERFILE - name: Start API services run: | From ec0bb5383975c6fc5d4c9bdf26231d0d00f8deca Mon Sep 17 00:00:00 2001 From: Aryan Bhaskar <115079796+ARYAN03B@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:10:54 +0530 Subject: [PATCH 006/126] feat(bedrock): add bedrock_agent_role_least_privilege check (#11335) Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + .../__init__.py | 0 ...k_agent_role_least_privilege.metadata.json | 41 +++ .../bedrock_agent_role_least_privilege.py | 101 +++++++ .../aws/services/bedrock/bedrock_service.py | 18 ++ .../providers/aws/services/iam/iam_service.py | 33 +++ ...bedrock_agent_role_least_privilege_test.py | 275 ++++++++++++++++++ 7 files changed, 469 insertions(+) create mode 100644 prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/__init__.py create mode 100644 prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json create mode 100644 prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py create mode 100644 tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index c9542d8345..056ae34f89 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070) - `kms_key_rotation_max_90_days` check for GCP provider, verifying KMS customer-managed keys are rotated every 90 days or less in line with the CIS Benchmark [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516) - `exchange_mailbox_primary_smtp_uses_custom_domain` check for M365 provider [(#11215)](https://github.com/prowler-cloud/prowler/pull/11215) +- `bedrock_agent_role_least_privilege` check for AWS provider, flagging Bedrock Agent execution roles with full-access managed policies, broad `Resource:*` inline statements, or missing permissions boundaries [(#11335)](https://github.com/prowler-cloud/prowler/pull/11335) ### 🐞 Fixed diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json new file mode 100644 index 0000000000..7625b6a626 --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "bedrock_agent_role_least_privilege", + "CheckTitle": "Amazon Bedrock agent execution role follows least privilege", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis", + "TTPs/Privilege Escalation" + ], + "ServiceName": "bedrock", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**Bedrock Agent** execution roles (`agentResourceRoleArn`) should grant only the minimum permissions the agent needs. The evaluation FAILs when the role has an AWS-managed `*FullAccess` policy attached, has an inline statement allowing broad actions on `Resource: \"*\"`, or has no permissions boundary configured.", + "Risk": "An overly permissive **Bedrock Agent** execution role turns a successful **prompt injection** into AWS privilege escalation. A model coerced into calling tools can invoke any API the role allows — reading secrets, modifying IAM, exfiltrating data from S3, or pivoting laterally. **Least privilege** plus a **permissions boundary** keeps the blast radius bounded even when guardrails fail.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/agents-permissions.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege" + ], + "Remediation": { + "Code": { + "CLI": "aws iam put-role-permissions-boundary --role-name --permissions-boundary ", + "NativeIaC": "", + "Other": "1. Identify the Bedrock Agent's execution role (agentResourceRoleArn) in the IAM console\n2. Detach any AWS-managed *FullAccess policies (e.g. AmazonBedrockFullAccess, AdministratorAccess)\n3. Replace inline policies that use Resource: \"*\" with statements scoped to specific resource ARNs and minimal action sets\n4. Attach a permissions boundary that caps what the role can ever do, even if a future policy is added\n5. Re-run Prowler to confirm the check passes", + "Terraform": "```hcl\nresource \"aws_iam_role\" \"bedrock_agent\" {\n name = \"\"\n assume_role_policy = data.aws_iam_policy_document.trust.json\n permissions_boundary = aws_iam_policy.bedrock_agent_boundary.arn # CRITICAL: caps maximum privileges\n}\n\nresource \"aws_iam_role_policy\" \"bedrock_agent_inline\" {\n role = aws_iam_role.bedrock_agent.name\n policy = jsonencode({\n Version = \"2012-10-17\",\n Statement = [{\n Effect = \"Allow\",\n Action = [\"s3:GetObject\"], # CRITICAL: narrow action\n Resource = [\"arn:aws:s3:::my-rag-bucket/*\"] # CRITICAL: narrow resource\n }]\n })\n}\n```" + }, + "Recommendation": { + "Text": "Apply **least privilege** to every Bedrock Agent execution role: scope `Action` and `Resource` to exactly what the agent needs, avoid AWS-managed `*FullAccess` policies, and always attach a **permissions boundary** so that future policy edits cannot exceed an approved ceiling. Treat agent roles as high-risk because prompt injection can weaponize any granted permission.", + "Url": "https://hub.prowler.com/check/bedrock_agent_role_least_privilege" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py new file mode 100644 index 0000000000..e53183d90f --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py @@ -0,0 +1,101 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.bedrock.bedrock_agent_client import ( + bedrock_agent_client, +) +from prowler.providers.aws.services.iam.iam_client import iam_client +from prowler.providers.aws.services.iam.lib.policy import check_admin_access +from prowler.providers.aws.services.iam.lib.privilege_escalation import ( + check_privilege_escalation, +) + + +class bedrock_agent_role_least_privilege(Check): + """Ensure Bedrock Agent execution roles follow least privilege. + + A Bedrock Agent's execution role is evaluated against three criteria: + - No AWS-managed ``*FullAccess`` policy attached. + - No attached or inline policy granting administrative access or known + privilege escalation combinations. + - A permissions boundary is configured on the role. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Run the least-privilege evaluation across all Bedrock Agents. + + Returns: + A list of ``Check_Report_AWS`` with one entry per agent. The + status is ``FAIL`` when any of the criteria above is violated, + or when the execution role cannot be resolved in IAM. + """ + findings = [] + roles_by_arn = {role.arn: role for role in (iam_client.roles or [])} + + for agent in bedrock_agent_client.agents.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=agent) + report.status = "PASS" + report.status_extended = ( + f"Bedrock Agent {agent.name} execution role follows least privilege." + ) + + role = roles_by_arn.get(agent.role_arn) if agent.role_arn else None + if role is None: + report.status = "FAIL" + report.status_extended = ( + f"Bedrock Agent {agent.name} execution role could not be " + f"resolved in IAM and cannot be evaluated for least privilege." + ) + findings.append(report) + continue + + violations = [] + + for policy in role.attached_policies: + policy_arn = policy.get("PolicyArn", "") + policy_name = policy.get("PolicyName") or policy_arn + if policy_arn.startswith( + "arn:aws:iam::aws:policy/" + ) and policy_arn.endswith("FullAccess"): + violations.append( + f"managed policy {policy_name} grants full access" + ) + continue + policy_obj = iam_client.policies.get(policy_arn) + if policy_obj is None or not policy_obj.document: + continue + document = policy_obj.document + if check_admin_access(document): + violations.append( + f"managed policy {policy_name} grants administrative access" + ) + elif check_privilege_escalation(document): + violations.append( + f"managed policy {policy_name} allows privilege escalation" + ) + + for inline_name in role.inline_policies: + policy_obj = iam_client.policies.get(f"{role.arn}:policy/{inline_name}") + if policy_obj is None or not policy_obj.document: + continue + document = policy_obj.document + if check_admin_access(document): + violations.append( + f"inline policy {inline_name} grants administrative access" + ) + elif check_privilege_escalation(document): + violations.append( + f"inline policy {inline_name} allows privilege escalation" + ) + + if not role.permissions_boundary: + violations.append("no permissions boundary configured") + + if violations: + report.status = "FAIL" + report.status_extended = ( + f"Bedrock Agent {agent.name} execution role violates least " + f"privilege: {'; '.join(violations)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/bedrock/bedrock_service.py b/prowler/providers/aws/services/bedrock/bedrock_service.py index 7222456341..aead63a67f 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_service.py +++ b/prowler/providers/aws/services/bedrock/bedrock_service.py @@ -146,6 +146,7 @@ class BedrockAgent(AWSService): self.prompts = {} self.prompt_scanned_regions: set = set() self.__threading_call__(self._list_agents) + self.__threading_call__(self._get_agent, self.agents.values()) self.__threading_call__(self._list_prompts) self.__threading_call__(self._get_prompt, self.prompts.values()) self.__threading_call__(self._list_tags_for_resource, self.agents.values()) @@ -174,6 +175,22 @@ class BedrockAgent(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_agent(self, agent): + """Fetch full agent details to capture the execution role ARN. + + list_agents only returns summaries (no agentResourceRoleArn), so we + need a per-agent GetAgent call. Stored on the Agent model for use by + checks like bedrock_agent_role_least_privilege. + """ + logger.info("Bedrock Agent - Getting Agent...") + try: + agent_info = self.regional_clients[agent.region].get_agent(agentId=agent.id) + agent.role_arn = agent_info.get("agent", {}).get("agentResourceRoleArn") + except Exception as error: + logger.error( + f"{agent.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _list_prompts(self, regional_client): """List all prompts in a region.""" logger.info("Bedrock Agent - Listing Prompts...") @@ -236,6 +253,7 @@ class Agent(BaseModel): name: str arn: str guardrail_id: Optional[str] = None + role_arn: Optional[str] = None region: str tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/iam/iam_service.py b/prowler/providers/aws/services/iam/iam_service.py index d6cb150178..fa01550b11 100644 --- a/prowler/providers/aws/services/iam/iam_service.py +++ b/prowler/providers/aws/services/iam/iam_service.py @@ -103,6 +103,9 @@ class IAM(AWSService): self._get_user_temporary_credentials_usage() self.organization_features = [] self._list_organizations_features() + # ListRoles does not echo PermissionsBoundary; backfill via GetRole. + if self.roles: + self.__threading_call__(self._get_role_permissions_boundary, self.roles) # List missing tags self.__threading_call__(self._list_tags, self.users) self.__threading_call__(self._list_tags, self.roles) @@ -133,6 +136,7 @@ class IAM(AWSService): arn=role["Arn"], assume_role_policy=role["AssumeRolePolicyDocument"], is_service_role=is_service_role(role), + permissions_boundary=role.get("PermissionsBoundary"), ) ) except ClientError as error: @@ -460,6 +464,34 @@ class IAM(AWSService): f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_role_permissions_boundary(self, role): + """Backfill ``role.permissions_boundary`` via ``GetRole``. + + ``ListRoles`` does not return ``PermissionsBoundary`` in practice, so + the value is fetched per role and stored on the ``Role`` model. + + Args: + role: The ``Role`` instance to enrich. + """ + try: + response = self.client.get_role(RoleName=role.name) + role.permissions_boundary = response.get("Role", {}).get( + "PermissionsBoundary" + ) + except ClientError as error: + if error.response["Error"]["Code"] == "NoSuchEntity": + logger.warning( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _list_attached_role_policies(self): logger.info("IAM - List Attached Role Policies...") try: @@ -1139,6 +1171,7 @@ class Role(BaseModel): is_service_role: bool attached_policies: list[dict] = [] inline_policies: list[str] = [] + permissions_boundary: Optional[dict] = None tags: Optional[list] diff --git a/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py b/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py new file mode 100644 index 0000000000..c6e5d7f756 --- /dev/null +++ b/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py @@ -0,0 +1,275 @@ +from json import dumps +from unittest import mock + +import botocore +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +AGENT_ID = "test-agent-id" +AGENT_NAME = "test-agent-name" +AGENT_ARN = ( + f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:agent/{AGENT_ID}" +) +ROLE_NAME = "AmazonBedrockExecutionRoleForAgents_test" +ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/{ROLE_NAME}" +BOUNDARY_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/AgentBoundary" + +ASSUME_ROLE_POLICY_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "bedrock.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], +} + +BOUNDARY_POLICY_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "bedrock:*", "Resource": "*"}], +} + +NARROW_INLINE_POLICY = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::my-rag-bucket/*"], + } + ], +} + +BROAD_INLINE_POLICY = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"}], +} + + +# Mock both ListAgents and GetAgent at the botocore level. moto's bedrock-agent +# support is incomplete for our needs (GetAgent often doesn't echo back the +# role ARN we set), so we control the responses directly. We also need to keep +# IAM calls going to moto. +make_api_call = botocore.client.BaseClient._make_api_call + + +def _mock_bedrock_agent_factory(role_arn): + """Return a mock_make_api_call function that returns role_arn from GetAgent. + + Pass role_arn=None to simulate an agent whose role can't be resolved. + """ + + def _mock_make_api_call(self, operation_name, kwarg): + if operation_name == "ListAgents": + return { + "agentSummaries": [ + {"agentId": AGENT_ID, "agentName": AGENT_NAME}, + ] + } + if operation_name == "GetAgent": + return { + "agent": { + "agentId": AGENT_ID, + "agentName": AGENT_NAME, + "agentResourceRoleArn": role_arn, + } + } + if operation_name == "ListTagsForResource": + return {"tags": {}} + if operation_name == "ListPrompts": + return {"promptSummaries": []} + return make_api_call(self, operation_name, kwarg) + + return _mock_make_api_call + + +def _setup_role( + *, + attached_policy_arns=(), + inline_policies=None, + permissions_boundary=None, +): + """Create an IAM role in moto with the given configuration. Returns the role ARN.""" + iam = client("iam", region_name=AWS_REGION_US_EAST_1) + + if permissions_boundary: + iam.create_policy( + PolicyName="AgentBoundary", + PolicyDocument=dumps(BOUNDARY_POLICY_DOCUMENT), + ) + + create_kwargs = { + "RoleName": ROLE_NAME, + "AssumeRolePolicyDocument": dumps(ASSUME_ROLE_POLICY_DOCUMENT), + } + if permissions_boundary: + create_kwargs["PermissionsBoundary"] = permissions_boundary + iam.create_role(**create_kwargs) + + for policy_arn in attached_policy_arns: + iam.attach_role_policy(RoleName=ROLE_NAME, PolicyArn=policy_arn) + + for policy_name, policy_document in (inline_policies or {}).items(): + iam.put_role_policy( + RoleName=ROLE_NAME, + PolicyName=policy_name, + PolicyDocument=dumps(policy_document), + ) + + return ROLE_ARN + + +def _run_check(role_arn_for_get_agent): + """Build the IAM + BedrockAgent services, patch them in, run the check.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "botocore.client.BaseClient._make_api_call", + new=_mock_bedrock_agent_factory(role_arn_for_get_agent), + ): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege import ( + bedrock_agent_role_least_privilege, + ) + + return bedrock_agent_role_least_privilege().execute() + + +class Test_bedrock_agent_role_least_privilege: + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_no_agents(self): + """No agents in the account -> zero findings.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege import ( + bedrock_agent_role_least_privilege, + ) + + assert bedrock_agent_role_least_privilege().execute() == [] + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_compliant(self): + """Narrow inline policy + boundary + no *FullAccess attached -> PASS.""" + role_arn = _setup_role( + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "follows least privilege" in result[0].status_extended + assert result[0].resource_id == AGENT_ID + assert result[0].resource_arn == AGENT_ARN + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_full_access_attached(self): + """AmazonBedrockFullAccess attached -> FAIL.""" + role_arn = _setup_role( + attached_policy_arns=("arn:aws:iam::aws:policy/AmazonBedrockFullAccess",), + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "grants full access" in result[0].status_extended + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_administrator_access_attached(self): + """AdministratorAccess attached (no FullAccess suffix) -> FAIL via doc-based admin check.""" + role_arn = _setup_role( + attached_policy_arns=("arn:aws:iam::aws:policy/AdministratorAccess",), + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "managed policy AdministratorAccess grants administrative access" + in result[0].status_extended + ) + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_resource_star_broad_action(self): + """Inline statement with Action:* on Resource:* -> FAIL.""" + role_arn = _setup_role( + inline_policies={"BroadAccess": BROAD_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "grants administrative access" in result[0].status_extended + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_no_permissions_boundary(self): + """Otherwise clean role but missing permissions boundary -> FAIL.""" + role_arn = _setup_role( + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=None, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no permissions boundary configured" in result[0].status_extended + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_not_resolvable(self): + """role_arn returned by GetAgent doesn't match any IAM role -> FAIL.""" + result = _run_check( + role_arn_for_get_agent=f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/does-not-exist" + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "could not be resolved" in result[0].status_extended From 3c8fde25eeb5c2596b1be4f722be7126bfefee50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 10 Jun 2026 18:19:50 +0200 Subject: [PATCH 007/126] chore(cli): add banner about Prowler Cloud (#11528) --- prowler/__main__.py | 8 ++++++-- prowler/lib/banner.py | 42 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/prowler/__main__.py b/prowler/__main__.py index 703a9e49a0..533a704359 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -19,7 +19,7 @@ from prowler.config.config import ( orange_color, sarif_file_suffix, ) -from prowler.lib.banner import print_banner +from prowler.lib.banner import print_banner, print_prowler_cloud_banner from prowler.lib.check.check import ( exclude_checks_to_run, exclude_services_to_run, @@ -202,7 +202,7 @@ def prowler(): if not args.no_banner: legend = args.verbose or getattr(args, "fixer", None) - print_banner(legend) + print_banner(legend, provider) # We treat the compliance framework as another output format if compliance_framework: @@ -1476,6 +1476,10 @@ def prowler(): f"\nDetailed compliance results are in {Fore.YELLOW}{output_options.output_directory}/compliance/{Style.RESET_ALL}\n" ) + # Promote Prowler Cloud as the last thing the user sees after the results + if not args.no_banner and not args.only_logs: + print_prowler_cloud_banner(provider) + # If custom checks were passed, remove the modules if checks_folder: remove_custom_checks_module(checks_folder, provider) diff --git a/prowler/lib/banner.py b/prowler/lib/banner.py index 72031805e2..bb693fb961 100644 --- a/prowler/lib/banner.py +++ b/prowler/lib/banner.py @@ -3,12 +3,13 @@ from colorama import Fore, Style from prowler.config.config import banner_color, orange_color, prowler_version, timestamp -def print_banner(legend: bool = False): +def print_banner(legend: bool = False, provider: str = None): """ Prints the banner with optional legend for color codes. Parameters: - legend (bool): Flag to indicate whether to print the color legend or not. Default is False. + - provider (str): The provider being scanned, used to tailor the Prowler Cloud banner. Returns: - None @@ -20,13 +21,12 @@ def print_banner(legend: bool = False): | .__/|_| \___/ \_/\_/ |_|\___|_|v{prowler_version} |_|{Fore.BLUE} Get the most at https://cloud.prowler.com {Style.RESET_ALL} -{Fore.GREEN}New! Send findings from Prowler CLI to Prowler Cloud{Style.RESET_ALL} -{Fore.GREEN}More details here: goto.prowler.com/import-findings{Style.RESET_ALL} - {Fore.YELLOW}Date: {timestamp.strftime("%Y-%m-%d %H:%M:%S")}{Style.RESET_ALL} """ print(banner) + print_prowler_cloud_banner(provider) + if legend: print( f""" @@ -37,3 +37,37 @@ def print_banner(legend: bool = False): - {Fore.RED}FAIL (Fix required){Style.RESET_ALL} """ ) + + +def print_prowler_cloud_banner(provider: str = None): + """ + Prints a promotional banner highlighting what Prowler Cloud adds on top of + the open-source CLI. + + Shown at the start and end of a scan to let users know about the managed + platform capabilities they are missing (attack paths, AI, organizations, + continuous scanning, integrations and live compliance dashboards). + + Parameters: + - provider (str): The provider that was scanned, used to tailor the message. + + Returns: + - None + """ + check = f"{Fore.GREEN}✓{Style.RESET_ALL}" + bar = f"{banner_color}│{Style.RESET_ALL}" + print( + f""" +{bar} {Style.BRIGHT}You're getting a snapshot. Prowler Cloud gives you the full picture.{Style.RESET_ALL} +{bar} +{bar} {check} {Style.BRIGHT}Attack Path Visualization{Style.RESET_ALL} - see how attackers chain risks to reach your crown jewels +{bar} {check} {Style.BRIGHT}Lighthouse AI + MCP{Style.RESET_ALL} - autonomous triage, prioritization and remediation +{bar} {check} {Style.BRIGHT}Organizations{Style.RESET_ALL} - all your AWS accounts under one organization +{bar} {check} {Style.BRIGHT}Continuous scanning{Style.RESET_ALL} - scheduled scans with history, trends and alerts +{bar} {check} {Style.BRIGHT}Integrations{Style.RESET_ALL} - Jira, Slack, AWS Security Hub, Amazon S3, SSO and RBAC +{bar} {check} {Style.BRIGHT}Reports{Style.RESET_ALL} - download ready-to-share PDF reports +{bar} {check} {Style.BRIGHT}Live compliance{Style.RESET_ALL} - dashboards for 50+ frameworks, always up to date +{bar} +{bar} {Fore.BLUE}Start free at cloud.prowler.com{Style.RESET_ALL} +""" + ) From 368d3a2661cb8b08e081caf3c1d3a0a5253314d9 Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Wed, 10 Jun 2026 18:43:24 +0200 Subject: [PATCH 008/126] feat(stackit): add objectstorage checks (#11397) Co-authored-by: Hugo P.Brito Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> --- README.md | 2 +- prowler/CHANGELOG.md | 1 + .../services/objectstorage/__init__.py | 0 .../__init__.py | 0 ...torage_access_key_expiration.metadata.json | 37 + .../objectstorage_access_key_expiration.py | 27 + .../__init__.py | 0 ...e_bucket_object_lock_enabled.metadata.json | 37 + ...bjectstorage_bucket_object_lock_enabled.py | 31 + .../__init__.py | 0 ...rage_bucket_retention_policy.metadata.json | 39 ++ .../objectstorage_bucket_retention_policy.py | 30 + .../objectstorage/objectstorage_client.py | 6 + .../objectstorage/objectstorage_service.py | 306 +++++++++ prowler/providers/stackit/stackit_provider.py | 9 +- .../stackit/stackit_regions_by_service.json | 6 + pyproject.toml | 1 + ...bjectstorage_access_key_expiration_test.py | 150 ++++ ...storage_bucket_object_lock_enabled_test.py | 111 +++ ...ectstorage_bucket_retention_policy_test.py | 153 +++++ .../stackit_objectstorage_service_test.py | 645 ++++++++++++++++++ .../stackit/stackit_provider_test.py | 65 ++ uv.lock | 17 + 23 files changed, 1671 insertions(+), 2 deletions(-) create mode 100644 prowler/providers/stackit/services/objectstorage/__init__.py create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/__init__.py create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/__init__.py create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/__init__.py create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_client.py create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_service.py create mode 100644 tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py create mode 100644 tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py create mode 100644 tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py create mode 100644 tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py diff --git a/README.md b/README.md index d2808daf84..74e91021ca 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically | Vercel | 26 | 6 | 0 | 8 | Official | UI, API, CLI | | Okta | 1 | 1 | 0 | 1 | Official | CLI | | Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 0 | 1 | Unofficial | CLI | -| StackIT [Contact us](https://prowler.com/contact) | 4 | 1 | 0 | 1 | Unofficial | CLI | +| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 0 | 3 | Unofficial | CLI | | NHN | 6 | 2 | 1 | 0 | Unofficial | CLI | > [!Note] diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 056ae34f89..619401bf32 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -21,6 +21,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `kms_key_rotation_max_90_days` check for GCP provider, verifying KMS customer-managed keys are rotated every 90 days or less in line with the CIS Benchmark [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516) - `exchange_mailbox_primary_smtp_uses_custom_domain` check for M365 provider [(#11215)](https://github.com/prowler-cloud/prowler/pull/11215) - `bedrock_agent_role_least_privilege` check for AWS provider, flagging Bedrock Agent execution roles with full-access managed policies, broad `Resource:*` inline statements, or missing permissions boundaries [(#11335)](https://github.com/prowler-cloud/prowler/pull/11335) +- STACKIT ObjectStorage service with Object Lock, default retention policy, and access key expiration checks [(#11397)](https://github.com/prowler-cloud/prowler/pull/11397) ### 🐞 Fixed diff --git a/prowler/providers/stackit/services/objectstorage/__init__.py b/prowler/providers/stackit/services/objectstorage/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/__init__.py b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json new file mode 100644 index 0000000000..38da16c5fa --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "objectstorage_access_key_expiration", + "CheckTitle": "ObjectStorage access keys should have an expiration date", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**ObjectStorage access keys** should have an explicit expiration date. Long-lived credentials increase the blast radius of a credential compromise because they cannot expire on their own. Setting an expiration date enforces periodic rotation and limits the exposure window if a key is leaked.", + "Risk": "If an **ObjectStorage access key** is leaked, stolen, or forgotten without an expiration date, it remains usable indefinitely. An attacker can retain persistent access to object storage resources until the key is manually revoked.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/storage/object-storage/", + "https://docs.stackit.cloud/products/storage/object-storage/how-tos/create-and-delete-object-storage-credentials/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the STACKIT Portal navigate to Object Storage > Access Keys. 2. Delete the non-expiring access key. 3. Create a new access key with an expiration date appropriate for your rotation policy (e.g. 90 days). 4. Update all applications and services that use the old key with the new credentials.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create **ObjectStorage access keys** with an explicit expiration date and establish a rotation process. Delete non-expiring keys and replace them with time-limited credentials. A rotation period of **90 days or less** is recommended.", + "Url": "https://hub.prowler.com/check/objectstorage_access_key_expiration" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Access keys are scoped to credentials groups. This check evaluates all access keys across all credentials groups in the project." +} diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py new file mode 100644 index 0000000000..2fb49ac725 --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py @@ -0,0 +1,27 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_access_key_expiration(Check): + def execute(self): + findings = [] + for key in objectstorage_client.access_keys: + report = CheckReportStackIT( + metadata=self.metadata(), + resource=key, + ) + report.resource_id = key.key_id + report.resource_name = key.display_name + report.location = key.region + + if key.has_expiration(): + report.status = "PASS" + report.status_extended = f"Access key {key.display_name} has an expiration date set ({key.expires})." + else: + report.status = "FAIL" + report.status_extended = f"Access key {key.display_name} has no expiration date and never rotates." + + findings.append(report) + return findings diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/__init__.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json new file mode 100644 index 0000000000..cd587c72da --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "objectstorage_bucket_object_lock_enabled", + "CheckTitle": "ObjectStorage buckets should have S3 Object Lock enabled", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "storage", + "Description": "**S3 Object Lock** prevents objects from being deleted or overwritten for a fixed period or indefinitely. Enabling it protects against accidental deletion and ransomware by enforcing a **write-once-read-many (WORM)** model. Object Lock can only be enabled when the bucket is created.", + "Risk": "Without **Object Lock**, objects can be deleted or overwritten at any time, increasing the risk of data loss from accidental deletion, malicious actors, or ransomware. Backups and compliance data are particularly vulnerable.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/storage/object-storage/", + "https://docs.stackit.cloud/products/storage/object-storage/how-tos/object-lock-bucket/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Object Lock must be enabled at bucket creation time and cannot be enabled on an existing bucket. Create a new bucket with Object Lock enabled and migrate your data to it.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create **ObjectStorage buckets** with S3 Object Lock enabled for workloads that require data immutability, compliance archiving, or ransomware protection. Object Lock cannot be retroactively enabled on existing buckets.", + "Url": "https://hub.prowler.com/check/objectstorage_bucket_object_lock_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Object Lock can only be activated at bucket creation. Buckets without Object Lock are not necessarily misconfigured — evaluate based on the sensitivity and compliance requirements of the stored data." +} diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py new file mode 100644 index 0000000000..89a3c1d3fb --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_bucket_object_lock_enabled(Check): + def execute(self): + findings = [] + for bucket in objectstorage_client.buckets: + report = CheckReportStackIT( + metadata=self.metadata(), + resource=bucket, + ) + report.resource_id = bucket.name + report.resource_name = bucket.name + report.location = bucket.region + + if bucket.object_lock_enabled: + report.status = "PASS" + report.status_extended = ( + f"Bucket {bucket.name} has S3 Object Lock enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Bucket {bucket.name} does not have S3 Object Lock enabled." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/__init__.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json new file mode 100644 index 0000000000..44088e5889 --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "stackit", + "CheckID": "objectstorage_bucket_retention_policy", + "CheckTitle": "ObjectStorage buckets should have a default retention policy configured", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "storage", + "Description": "An **ObjectStorage default retention policy** automatically applies a minimum retention period to every object uploaded to the bucket, preventing deletion or overwriting before the period expires. Without it, objects can be removed immediately after upload, undermining compliance and data durability requirements.", + "Risk": "Buckets without a **default retention policy** offer no automatic protection against premature object deletion. Compliance data, audit logs, and backups may be deleted before their required retention period elapses.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/storage/object-storage/", + "https://docs.stackit.cloud/products/storage/object-storage/how-tos/object-lock-default-retention/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Use the STACKIT Object Storage API or Portal to set a default retention policy on the bucket. Choose COMPLIANCE mode for strict immutability or GOVERNANCE mode to allow privileged users to override the policy.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a **default retention policy** on every bucket that stores compliance-relevant or sensitive data. Choose `COMPLIANCE` mode for regulatory requirements and `GOVERNANCE` mode when administrative overrides are acceptable.", + "Url": "https://hub.prowler.com/check/objectstorage_bucket_retention_policy" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [ + "objectstorage_bucket_object_lock_enabled" + ], + "Notes": "A default retention policy requires Object Lock to be enabled on the bucket. Buckets without Object Lock cannot have a retention policy." +} diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py new file mode 100644 index 0000000000..a9dbc7ed2b --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_bucket_retention_policy(Check): + def execute(self): + findings = [] + for bucket in objectstorage_client.buckets: + report = CheckReportStackIT( + metadata=self.metadata(), + resource=bucket, + ) + report.resource_id = bucket.name + report.resource_name = bucket.name + report.location = bucket.region + + if bucket.retention_days and bucket.retention_days > 0: + report.status = "PASS" + report.status_extended = ( + f"Bucket {bucket.name} has a default retention policy of " + f"{bucket.retention_days} day(s) in {bucket.retention_mode} mode." + ) + else: + report.status = "FAIL" + report.status_extended = f"Bucket {bucket.name} does not have a default retention policy configured." + + findings.append(report) + return findings diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_client.py b/prowler/providers/stackit/services/objectstorage/objectstorage_client.py new file mode 100644 index 0000000000..56cdbfd0bf --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + ObjectStorageService, +) + +objectstorage_client = ObjectStorageService(Provider.get_global_provider()) diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_service.py b/prowler/providers/stackit/services/objectstorage/objectstorage_service.py new file mode 100644 index 0000000000..e37545ec24 --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_service.py @@ -0,0 +1,306 @@ +import json +from datetime import datetime, timezone +from typing import Optional + +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.stackit.stackit_provider import StackitProvider, suppress_stderr + + +class ObjectStorageService: + def __init__(self, provider: StackitProvider): + self.provider = provider + self.project_id = provider.identity.project_id + self.regional_clients = provider.generate_regional_clients("objectstorage") + + self.buckets: list[Bucket] = [] + self.access_keys: list[AccessKey] = [] + + self._fetch_all_regions() + + def _fetch_all_regions(self): + for region, client in self.regional_clients.items(): + try: + self._list_buckets(client, region) + self._list_access_keys(client, region) + except Exception as error: + if getattr(error, "status", None) == 404: + logger.info( + f"StackIT project {self.project_id} has no ObjectStorage " + f"presence in region {region}; skipping." + ) + continue + raise + + def _handle_api_call(self, api_function, *args, **kwargs): + try: + with suppress_stderr(): + return api_function(*args, **kwargs) + except Exception as e: + self.provider.handle_api_error(e) + raise + + def _list_buckets(self, client, region: str): + response = self._handle_api_call( + client.list_buckets, project_id=self.project_id, region=region + ) + + buckets_list = getattr(response, "buckets", None) or [] + if isinstance(response, dict): + buckets_list = response.get("buckets", []) + + for bucket_data in buckets_list: + try: + if hasattr(bucket_data, "name"): + name = bucket_data.name + object_lock_enabled = getattr( + bucket_data, "object_lock_enabled", False + ) + elif isinstance(bucket_data, dict): + name = bucket_data.get("name", "") + object_lock_enabled = bucket_data.get("objectLockEnabled", False) + else: + continue + except Exception as e: + logger.error(f"Error processing bucket: {e}") + continue + + retention_days, retention_mode = self._get_default_retention( + client, region, name + ) + + self.buckets.append( + Bucket( + name=name, + region=region, + project_id=self.project_id, + object_lock_enabled=object_lock_enabled, + retention_days=retention_days, + retention_mode=retention_mode, + ) + ) + + logger.info(f"Listed {len(buckets_list)} buckets in {region}") + + def _get_default_retention( + self, client, region: str, bucket_name: str + ) -> tuple[Optional[int], Optional[str]]: + try: + response = self._handle_api_call( + client.get_default_retention, + project_id=self.project_id, + region=region, + bucket_name=bucket_name, + ) + days = getattr(response, "days", None) + mode = getattr(response, "mode", None) + if isinstance(response, dict): + days = response.get("days") + mode = response.get("mode") + return days, str(mode) if mode else None + except Exception as e: + if getattr(e, "status", None) == 404: + return None, None + raise + + def _list_access_keys(self, client, region: str): + credentials_groups_response = self._handle_api_call( + client.list_credentials_groups, project_id=self.project_id, region=region + ) + + credentials_groups = ( + getattr(credentials_groups_response, "credentials_groups", None) or [] + ) + if isinstance(credentials_groups_response, dict): + credentials_groups = credentials_groups_response.get( + "credentialsGroups", + credentials_groups_response.get("credentials_groups", []), + ) + + total_keys = 0 + + for credentials_group_data in credentials_groups: + try: + if isinstance(credentials_group_data, dict): + credentials_group_id = credentials_group_data.get( + "id", + credentials_group_data.get( + "groupId", + credentials_group_data.get("credentialsGroupId", ""), + ), + ) + credentials_group_name = credentials_group_data.get( + "displayName", + credentials_group_data.get("name", credentials_group_id), + ) + else: + credentials_group_id = ( + getattr(credentials_group_data, "id", None) + or getattr(credentials_group_data, "group_id", None) + or getattr(credentials_group_data, "credentials_group_id", "") + ) + credentials_group_name = getattr( + credentials_group_data, + "display_name", + getattr(credentials_group_data, "name", credentials_group_id), + ) + except Exception as e: + logger.error(f"Error processing credentials group: {e}") + continue + + if not credentials_group_id: + continue + + response = self._list_access_keys_response( + client, region, credentials_group_id + ) + keys_list = self._extract_access_keys(response) + + for key_data in keys_list: + try: + if hasattr(key_data, "key_id"): + key_id = key_data.key_id + display_name = getattr(key_data, "display_name", key_id) + expires = getattr(key_data, "expires", None) + elif isinstance(key_data, dict): + key_id = key_data.get("keyId", key_data.get("key_id", "")) + display_name = key_data.get( + "displayName", key_data.get("display_name", key_id) + ) + expires = key_data.get("expires") + else: + continue + + if not key_id: + continue + + self.access_keys.append( + AccessKey( + key_id=key_id, + display_name=display_name, + expires=expires, + region=region, + project_id=self.project_id, + credentials_group_id=credentials_group_id, + credentials_group_name=credentials_group_name, + ) + ) + except Exception as e: + logger.error(f"Error processing access key: {e}") + continue + + total_keys += len(keys_list) + + logger.info(f"Listed {total_keys} access keys in {region}") + + def _list_access_keys_response( + self, client, region: str, credentials_group_id: str + ): + raw_method = None + if callable( + getattr(type(client), "list_access_keys_without_preload_content", None) + ): + raw_method = client.list_access_keys_without_preload_content + elif callable(vars(client).get("list_access_keys_without_preload_content")): + raw_method = vars(client)["list_access_keys_without_preload_content"] + + if raw_method: + response = self._handle_api_call( + raw_method, + project_id=self.project_id, + region=region, + credentials_group=credentials_group_id, + ) + self._raise_for_raw_response_status(response) + return response + + return self._handle_api_call( + client.list_access_keys, + project_id=self.project_id, + region=region, + credentials_group=credentials_group_id, + ) + + def _raise_for_raw_response_status(self, response): + status = getattr(response, "status", None) + if status is None: + status = getattr(response, "status_code", None) + if isinstance(status, int) and status >= 400: + error = Exception( + f"StackIT ObjectStorage list_access_keys failed with status {status}" + ) + error.status = status + self.provider.handle_api_error(error) + raise error + + @staticmethod + def _extract_access_keys(response) -> list: + payload = response + if not isinstance(payload, (dict, list)): + json_method = getattr(response, "json", None) + if callable(json_method): + payload = json_method() + elif hasattr(response, "data"): + payload = ObjectStorageService._parse_raw_json(response.data) + elif hasattr(response, "text"): + payload = ObjectStorageService._parse_raw_json(response.text) + + if isinstance(payload, dict): + return payload.get("accessKeys", payload.get("access_keys", [])) + if isinstance(payload, list): + return payload + return getattr(response, "access_keys", None) or [] + + @staticmethod + def _parse_raw_json(raw): + if raw in (None, b"", ""): + return {} + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8") + if isinstance(raw, str): + return json.loads(raw) + return raw + + +class Bucket(BaseModel): + name: str + region: str + project_id: str + object_lock_enabled: bool = False + retention_days: Optional[int] = None + retention_mode: Optional[str] = None + + +class AccessKey(BaseModel): + key_id: str + display_name: str + # None or a sentinel year-0001 date string means the key never expires. + expires: Optional[str] = None + region: str + project_id: str + credentials_group_id: Optional[str] = None + credentials_group_name: Optional[str] = None + + def has_expiration(self) -> bool: + """Return True if the key has a real (non-sentinel) expiration date.""" + if not self.expires: + return False + try: + expires_str = self.expires.replace("Z", "+00:00") + dt = datetime.fromisoformat(expires_str) + # Year 0001 (or earlier) is the SDK sentinel for "never expires" + return dt.year > 1 + except (ValueError, AttributeError): + return False + + def expires_within_days(self, days: int) -> bool: + """Return True if the key expires within the given number of days from now.""" + if not self.has_expiration(): + return False + expires_str = self.expires.replace("Z", "+00:00") + dt = datetime.fromisoformat(expires_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + delta = dt - datetime.now(tz=timezone.utc) + return delta.days <= days diff --git a/prowler/providers/stackit/stackit_provider.py b/prowler/providers/stackit/stackit_provider.py index 417555ef1d..4c34afd31b 100644 --- a/prowler/providers/stackit/stackit_provider.py +++ b/prowler/providers/stackit/stackit_provider.py @@ -15,6 +15,7 @@ from colorama import Style # loader and surfacing as a misleading empty report. from stackit.core.configuration import Configuration from stackit.iaas import DefaultApi as IaasDefaultApi +from stackit.objectstorage import DefaultApi as ObjectStorageDefaultApi from stackit.resourcemanager import DefaultApi as ResourceManagerDefaultApi from prowler.config.config import ( @@ -224,11 +225,17 @@ class StackitProvider(Provider): return json_regions.intersection(audited_regions) return json_regions + _SERVICE_API_CLASS = { + "iaas": IaasDefaultApi, + "objectstorage": ObjectStorageDefaultApi, + } + def generate_regional_clients(self, service: str = "iaas") -> dict: """Generate regional API clients for the given service. Returns dict: {"eu01": DefaultApi_client, "eu02": DefaultApi_client} """ + api_class = self._SERVICE_API_CLASS.get(service, IaasDefaultApi) regional_clients = {} service_regions = self.get_available_service_regions( service, self._audited_regions @@ -240,7 +247,7 @@ class StackitProvider(Provider): self._service_account_key_path, self._service_account_key, ) - client = IaasDefaultApi(config) + client = api_class(config) client.region = region # Attach region attribute regional_clients[region] = client diff --git a/prowler/providers/stackit/stackit_regions_by_service.json b/prowler/providers/stackit/stackit_regions_by_service.json index d9c8cdb9be..5841d4e32d 100644 --- a/prowler/providers/stackit/stackit_regions_by_service.json +++ b/prowler/providers/stackit/stackit_regions_by_service.json @@ -5,6 +5,12 @@ "eu01", "eu02" ] + }, + "objectstorage": { + "regions": [ + "eu01", + "eu02" + ] } } } diff --git a/pyproject.toml b/pyproject.toml index f029f3d6f7..28a4b531cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,7 @@ dependencies = [ "slack-sdk==3.39.0", "stackit-core==0.2.0", "stackit-iaas==1.4.0", + "stackit-objectstorage==1.4.0", "stackit-resourcemanager==0.8.0", "tabulate==0.9.0", "tzlocal==5.3.1", diff --git a/tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py b/tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py new file mode 100644 index 0000000000..2d1e6b7875 --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py @@ -0,0 +1,150 @@ +from unittest import mock + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + AccessKey, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_objectstorage_access_key_expiration: + def test_no_access_keys(self): + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 0 + + def test_access_key_with_expiration(self): + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [ + AccessKey( + key_id="key-123", + display_name="my-key", + expires="2027-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has an expiration date set" in result[0].status_extended + assert result[0].resource_id == "key-123" + assert result[0].resource_name == "my-key" + assert result[0].location == "eu01" + + def test_access_key_no_expiration_none(self): + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [ + AccessKey( + key_id="key-456", + display_name="never-expiring-key", + expires=None, + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no expiration date" in result[0].status_extended + assert result[0].resource_id == "key-456" + + def test_access_key_no_expiration_sentinel(self): + """Year-0001 date is the SDK sentinel for 'never expires'.""" + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [ + AccessKey( + key_id="key-789", + display_name="sentinel-key", + expires="0001-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no expiration date" in result[0].status_extended diff --git a/tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py new file mode 100644 index 0000000000..a802dac78f --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py @@ -0,0 +1,111 @@ +from unittest import mock + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + Bucket, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_objectstorage_bucket_object_lock_enabled: + def test_no_buckets(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_object_lock_enabled.objectstorage_bucket_object_lock_enabled import ( + objectstorage_bucket_object_lock_enabled, + ) + + check = objectstorage_bucket_object_lock_enabled() + result = check.execute() + assert len(result) == 0 + + def test_bucket_object_lock_enabled(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_object_lock_enabled.objectstorage_bucket_object_lock_enabled import ( + objectstorage_bucket_object_lock_enabled, + ) + + check = objectstorage_bucket_object_lock_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has S3 Object Lock enabled" in result[0].status_extended + assert result[0].resource_id == "my-bucket" + assert result[0].resource_name == "my-bucket" + assert result[0].location == "eu01" + + def test_bucket_object_lock_disabled(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_object_lock_enabled.objectstorage_bucket_object_lock_enabled import ( + objectstorage_bucket_object_lock_enabled, + ) + + check = objectstorage_bucket_object_lock_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not have S3 Object Lock enabled" in result[0].status_extended + assert result[0].resource_id == "my-bucket" diff --git a/tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py new file mode 100644 index 0000000000..59fdf73370 --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py @@ -0,0 +1,153 @@ +from unittest import mock + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + Bucket, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_objectstorage_bucket_retention_policy: + def test_no_buckets(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 0 + + def test_bucket_with_retention_policy(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=True, + retention_days=30, + retention_mode="COMPLIANCE", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "30 day(s)" in result[0].status_extended + assert "COMPLIANCE" in result[0].status_extended + assert result[0].resource_id == "my-bucket" + assert result[0].location == "eu01" + + def test_bucket_without_retention_policy(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=False, + retention_days=None, + retention_mode=None, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "does not have a default retention policy" in result[0].status_extended + ) + assert result[0].resource_id == "my-bucket" + + def test_bucket_retention_zero_days(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=True, + retention_days=0, + retention_mode="GOVERNANCE", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py b/tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py new file mode 100644 index 0000000000..59f6559a54 --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py @@ -0,0 +1,645 @@ +from types import SimpleNamespace +from unittest import mock + +import pytest + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + AccessKey, + ObjectStorageService, +) +from tests.providers.stackit.stackit_fixtures import STACKIT_PROJECT_ID + + +class TestObjectStorageService: + def test_list_buckets_keeps_bucket_when_retention_not_configured(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + not_found_error = Exception("not found") + not_found_error.status = 404 + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace( + buckets=[ + SimpleNamespace( + name="my-bucket", + object_lock_enabled=True, + ) + ] + ) + client.get_default_retention.side_effect = not_found_error + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 1 + assert service.buckets[0].name == "my-bucket" + assert service.buckets[0].object_lock_enabled is True + assert service.buckets[0].retention_days is None + assert service.buckets[0].retention_mode is None + + def test_list_buckets_propagates_unexpected_retention_api_errors(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + api_error = Exception("service unavailable") + api_error.status = 503 + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace( + buckets=[ + SimpleNamespace( + name="my-bucket", + object_lock_enabled=True, + ) + ] + ) + client.get_default_retention.side_effect = api_error + + with pytest.raises(Exception, match="service unavailable"): + service._list_buckets(client, "eu01") + + assert service.buckets == [] + service.provider.handle_api_error.assert_called_once_with(api_error) + + def test_init_creates_service_with_no_regions(self): + provider = mock.MagicMock() + provider.identity.project_id = STACKIT_PROJECT_ID + provider.generate_regional_clients.return_value = {} + + service = ObjectStorageService(provider) + + assert service.project_id == STACKIT_PROJECT_ID + assert service.buckets == [] + assert service.access_keys == [] + provider.generate_regional_clients.assert_called_once_with("objectstorage") + + def test_fetch_all_regions_skips_404_region(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + service.access_keys = [] + + not_found = Exception("not found") + not_found.status = 404 + service.regional_clients = {"eu01": mock.MagicMock()} + + with mock.patch.object(service, "_list_buckets", side_effect=not_found): + service._fetch_all_regions() + + assert service.buckets == [] + + def test_fetch_all_regions_reraises_non_404_error(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + service.access_keys = [] + + server_error = Exception("internal server error") + server_error.status = 500 + service.regional_clients = {"eu01": mock.MagicMock()} + + with mock.patch.object(service, "_list_buckets", side_effect=server_error): + with pytest.raises(Exception, match="internal server error"): + service._fetch_all_regions() + + def test_list_buckets_with_dict_api_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + not_found = Exception("not found") + not_found.status = 404 + + client = mock.MagicMock() + client.list_buckets.return_value = { + "buckets": [ + SimpleNamespace(name="dict-response-bucket", object_lock_enabled=True) + ] + } + client.get_default_retention.side_effect = not_found + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 1 + assert service.buckets[0].name == "dict-response-bucket" + + def test_list_buckets_with_dict_bucket_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + not_found = Exception("not found") + not_found.status = 404 + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace( + buckets=[{"name": "dict-bucket", "objectLockEnabled": True}] + ) + client.get_default_retention.side_effect = not_found + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 1 + assert service.buckets[0].name == "dict-bucket" + assert service.buckets[0].object_lock_enabled is True + + def test_list_buckets_skips_unknown_bucket_type(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace(buckets=[42]) + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 0 + + def test_get_default_retention_with_dict_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + + client = mock.MagicMock() + client.get_default_retention.return_value = {"days": 14, "mode": "GOVERNANCE"} + + days, mode = service._get_default_retention(client, "eu01", "my-bucket") + + assert days == 14 + assert mode == "GOVERNANCE" + + def test_list_access_keys_with_object_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-001", display_name="main-group")] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[ + SimpleNamespace( + key_id="key-001", + display_name="my-key", + expires="2027-01-01T00:00:00+00:00", + ) + ] + ) + + service._list_access_keys(client, "eu01") + + client.list_credentials_groups.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01" + ) + client.list_access_keys.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01", credentials_group="cg-001" + ) + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-001" + assert service.access_keys[0].display_name == "my-key" + assert service.access_keys[0].region == "eu01" + assert service.access_keys[0].expires == "2027-01-01T00:00:00+00:00" + assert service.access_keys[0].credentials_group_id == "cg-001" + assert service.access_keys[0].credentials_group_name == "main-group" + + def test_list_access_keys_with_credentials_group_id_object_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[ + SimpleNamespace( + credentials_group_id="cg-sdk", + display_name="sdk-group", + ) + ] + ) + client.list_access_keys.return_value = SimpleNamespace(access_keys=[]) + + service._list_access_keys(client, "eu01") + + client.list_access_keys.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01", credentials_group="cg-sdk" + ) + + def test_list_access_keys_collects_keys_from_multiple_credentials_groups(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[ + SimpleNamespace(id="cg-001", display_name="group-one"), + SimpleNamespace(id="cg-002", display_name="group-two"), + ] + ) + client.list_access_keys.side_effect = [ + SimpleNamespace( + access_keys=[ + SimpleNamespace( + key_id="key-001", + display_name="key-one", + expires="2027-01-01T00:00:00+00:00", + ) + ] + ), + SimpleNamespace( + access_keys=[ + SimpleNamespace( + key_id="key-002", + display_name="key-two", + expires=None, + ) + ] + ), + ] + + service._list_access_keys(client, "eu01") + + assert client.list_access_keys.call_args_list == [ + mock.call( + project_id=STACKIT_PROJECT_ID, + region="eu01", + credentials_group="cg-001", + ), + mock.call( + project_id=STACKIT_PROJECT_ID, + region="eu01", + credentials_group="cg-002", + ), + ] + assert [key.key_id for key in service.access_keys] == ["key-001", "key-002"] + assert service.access_keys[1].expires is None + assert service.access_keys[1].has_expiration() is False + assert [key.credentials_group_id for key in service.access_keys] == [ + "cg-001", + "cg-002", + ] + + def test_list_access_keys_with_dict_api_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = { + "credentialsGroups": [{"id": "cg-dict", "displayName": "dict-group"}] + } + client.list_access_keys.return_value = { + "accessKeys": [ + {"keyId": "key-dict", "displayName": "dict-key", "expires": None} + ] + } + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-dict" + assert service.access_keys[0].display_name == "dict-key" + assert service.access_keys[0].expires is None + assert service.access_keys[0].has_expiration() is False + assert service.access_keys[0].credentials_group_id == "cg-dict" + assert service.access_keys[0].credentials_group_name == "dict-group" + + def test_list_access_keys_with_raw_json_response_and_null_expires(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class RawResponse: + status = 200 + + def json(self): + return { + "accessKeys": [ + { + "keyId": "key-raw", + "displayName": "raw-key", + "expires": None, + } + ] + } + + class FakeClient: + def __init__(self): + self.list_credentials_groups = mock.MagicMock( + return_value=SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-raw")] + ) + ) + self.list_access_keys = mock.MagicMock() + self.raw_call = None + + def list_access_keys_without_preload_content(self, **kwargs): + self.raw_call = kwargs + return RawResponse() + + client = FakeClient() + + service._list_access_keys(client, "eu01") + + assert client.raw_call == { + "project_id": STACKIT_PROJECT_ID, + "region": "eu01", + "credentials_group": "cg-raw", + } + client.list_access_keys.assert_not_called() + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-raw" + assert service.access_keys[0].expires is None + assert service.access_keys[0].has_expiration() is False + + def test_list_access_keys_with_raw_data_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class RawResponse: + status = 200 + data = b'{"accessKeys":[{"keyId":"key-data","displayName":"data-key"}]}' + + class FakeClient: + def __init__(self): + self.list_credentials_groups = mock.MagicMock( + return_value=SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-data")] + ) + ) + + def list_access_keys_without_preload_content(self, **kwargs): + return RawResponse() + + service._list_access_keys(FakeClient(), "eu01") + + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-data" + assert service.access_keys[0].display_name == "data-key" + + def test_list_access_keys_raw_response_propagates_non_success_status(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class RawResponse: + status = 503 + + class FakeClient: + def __init__(self): + self.list_credentials_groups = mock.MagicMock( + return_value=SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-error")] + ) + ) + + def list_access_keys_without_preload_content(self, **kwargs): + return RawResponse() + + with pytest.raises(Exception, match="status 503") as error: + service._list_access_keys(FakeClient(), "eu01") + + assert error.value.status == 503 + service.provider.handle_api_error.assert_called_once_with(error.value) + + def test_list_access_keys_with_dict_key_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[{"id": "cg-456", "displayName": "group-456"}] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[ + { + "keyId": "key-456", + "displayName": "my-dict-key", + "expires": "2028-06-01T00:00:00+00:00", + } + ] + ) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-456" + assert service.access_keys[0].display_name == "my-dict-key" + assert service.access_keys[0].credentials_group_id == "cg-456" + + def test_list_access_keys_skips_unknown_type(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-001")] + ) + client.list_access_keys.return_value = SimpleNamespace(access_keys=[42]) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + + def test_list_access_keys_no_keys(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-empty")] + ) + client.list_access_keys.return_value = SimpleNamespace(access_keys=[]) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + + def test_list_access_keys_no_credentials_groups(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[] + ) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + client.list_access_keys.assert_not_called() + + def test_list_access_keys_skips_malformed_credentials_groups(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[ + 42, + {}, + SimpleNamespace(id="cg-valid", display_name="valid-group"), + ] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[SimpleNamespace(key_id="key-valid")] + ) + + service._list_access_keys(client, "eu01") + + client.list_access_keys.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01", credentials_group="cg-valid" + ) + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-valid" + + def test_fetch_all_regions_calls_both_list_methods(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + service.access_keys = [] + + service.regional_clients = {"eu01": mock.MagicMock()} + + with ( + mock.patch.object(service, "_list_buckets") as mock_buckets, + mock.patch.object(service, "_list_access_keys") as mock_keys, + ): + service._fetch_all_regions() + + mock_buckets.assert_called_once() + mock_keys.assert_called_once() + + def test_list_buckets_handles_bucket_processing_error(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + class BrokenBucket: + @property + def name(self): + raise RuntimeError("broken bucket attribute") + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace(buckets=[BrokenBucket()]) + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 0 + + def test_list_access_keys_handles_key_processing_error(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class BrokenKey: + @property + def key_id(self): + raise RuntimeError("broken key attribute") + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-001")] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[BrokenKey()] + ) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + + +class TestAccessKeyModel: + def test_has_expiration_with_invalid_date_string(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="not-a-valid-date", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.has_expiration() is False + + def test_expires_within_days_when_no_expiration(self): + key = AccessKey( + key_id="k", + display_name="k", + expires=None, + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires is None + assert key.has_expiration() is False + assert key.expires_within_days(90) is False + + def test_expires_within_days_when_expiring_soon(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="2026-06-15T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(90) is True + + def test_expires_within_days_when_not_expiring_soon(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="2030-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(30) is False + + def test_expires_within_days_with_naive_datetime(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="2026-06-10T00:00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(90) is True + + def test_expires_within_days_with_sentinel_key(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="0001-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(90) is False diff --git a/tests/providers/stackit/stackit_provider_test.py b/tests/providers/stackit/stackit_provider_test.py index 8bcbe17f84..13bc7f4698 100644 --- a/tests/providers/stackit/stackit_provider_test.py +++ b/tests/providers/stackit/stackit_provider_test.py @@ -411,3 +411,68 @@ class Test_StackitProvider_Handle_API_Error: with pytest.raises(RuntimeError) as excinfo: StackitProvider.handle_api_error(original) assert excinfo.value is original + + +class TestGenerateRegionalClients: + """Tests for StackitProvider.generate_regional_clients.""" + + def _make_provider(self): + provider = object.__new__(StackitProvider) + provider._service_account_key_path = "/tmp/sa-key.json" + provider._service_account_key = None + provider._audited_regions = None + return provider + + def _fake_classes(self): + class FakeConfig: + pass + + class FakeIaasClient: + def __init__(self, config): + pass + + class FakeObjStorageClient: + def __init__(self, config): + pass + + return FakeConfig, FakeIaasClient, FakeObjStorageClient + + def test_objectstorage_service_uses_objectstorage_api_class(self, monkeypatch): + FakeConfig, FakeIaasClient, FakeObjStorageClient = self._fake_classes() + + monkeypatch.setattr( + StackitProvider, + "_SERVICE_API_CLASS", + {"iaas": FakeIaasClient, "objectstorage": FakeObjStorageClient}, + ) + provider = self._make_provider() + monkeypatch.setattr( + provider, "get_available_service_regions", lambda _s, _r: ["eu01"] + ) + with patch.object( + StackitProvider, "_build_sdk_configuration", return_value=FakeConfig() + ): + clients = provider.generate_regional_clients("objectstorage") + + assert "eu01" in clients + assert isinstance(clients["eu01"], FakeObjStorageClient) + + def test_iaas_service_uses_iaas_api_class(self, monkeypatch): + FakeConfig, FakeIaasClient, FakeObjStorageClient = self._fake_classes() + + monkeypatch.setattr( + StackitProvider, + "_SERVICE_API_CLASS", + {"iaas": FakeIaasClient, "objectstorage": FakeObjStorageClient}, + ) + provider = self._make_provider() + monkeypatch.setattr( + provider, "get_available_service_regions", lambda _s, _r: ["eu01"] + ) + with patch.object( + StackitProvider, "_build_sdk_configuration", return_value=FakeConfig() + ): + clients = provider.generate_regional_clients("iaas") + + assert "eu01" in clients + assert isinstance(clients["eu01"], FakeIaasClient) diff --git a/uv.lock b/uv.lock index e42e3ba8af..3a66d2826b 100644 --- a/uv.lock +++ b/uv.lock @@ -3325,6 +3325,7 @@ dependencies = [ { name = "slack-sdk" }, { name = "stackit-core" }, { name = "stackit-iaas" }, + { name = "stackit-objectstorage" }, { name = "stackit-resourcemanager" }, { name = "tabulate" }, { name = "tzlocal" }, @@ -3433,6 +3434,7 @@ requires-dist = [ { name = "slack-sdk", specifier = "==3.39.0" }, { name = "stackit-core", specifier = "==0.2.0" }, { name = "stackit-iaas", specifier = "==1.4.0" }, + { name = "stackit-objectstorage", specifier = "==1.4.0" }, { name = "stackit-resourcemanager", specifier = "==0.8.0" }, { name = "tabulate", specifier = "==0.9.0" }, { name = "tzlocal", specifier = "==5.3.1" }, @@ -4298,6 +4300,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/51/2201164d7bfacf47539888c735f10f6320c188252384957aa1b23121a210/stackit_iaas-1.4.0-py3-none-any.whl", hash = "sha256:3f4a32321b57ac238f73e5d660c6428186b92cc0425c1f0783ba801e377149d9", size = 316588, upload-time = "2026-05-13T09:43:14.943Z" }, ] +[[package]] +name = "stackit-objectstorage" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/80/b790756af40a5c6d979dd688b2557394ac54b594eb4c08edc33157ba890f/stackit_objectstorage-1.4.0.tar.gz", hash = "sha256:4a3812b4de102b199f061706a802909f9e53ae9b0858769d5bd720f814c8bdbe", size = 31814, upload-time = "2026-05-13T09:43:05.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/f1/ffa8d5e2ec9f818c72a6f045691364eb4e927ee86641993a70882d00205a/stackit_objectstorage-1.4.0-py3-none-any.whl", hash = "sha256:1a3285c6840d95cff591d84fd21803575cb0d010c398e6575ed92987b9c39866", size = 65061, upload-time = "2026-05-13T09:43:04.13Z" }, +] + [[package]] name = "stackit-resourcemanager" version = "0.8.0" From e085e14247d66c789dbb1109fe6fc0e753be9aaf Mon Sep 17 00:00:00 2001 From: sahil-sols <168274064+sahil-sols@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:19:06 +0530 Subject: [PATCH 009/126] fix(aws): order-independent CloudWatch metric filter pattern checks (#11345) Co-authored-by: Sahil Pugalia Co-authored-by: Cursor Co-authored-by: Lydia Vilchez --- prowler/CHANGELOG.md | 1 + ...etwork_acls_alarm_configured.metadata.json | 3 +- ...hanges_to_network_acls_alarm_configured.py | 12 +- ...es_to_network_gateways_alarm_configured.py | 12 +- ...oute_tables_alarm_configured.metadata.json | 2 +- ...o_network_route_tables_alarm_configured.py | 14 +- ...dwatch_changes_to_vpcs_alarm_configured.py | 17 +- ...ws_config_configuration_changes_enabled.py | 11 +- ...loudtrail_configuration_changes_enabled.py | 11 +- ...g_metric_filter_authentication_failures.py | 6 +- ...metric_filter_aws_organizations_changes.py | 28 ++- ...isable_or_scheduled_deletion_of_kms_cmk.py | 6 +- ...for_s3_bucket_policy_changes.metadata.json | 3 +- ...ric_filter_for_s3_bucket_policy_changes.py | 16 +- ...metric_filter_policy_changes.metadata.json | 1 - ...dwatch_log_metric_filter_policy_changes.py | 22 ++- ...og_metric_filter_security_group_changes.py | 12 +- ...c_filter_sign_in_without_mfa.metadata.json | 1 - ...h_log_metric_filter_sign_in_without_mfa.py | 6 +- .../services/cloudwatch/lib/metric_filters.py | 39 ++++ ...s_to_network_acls_alarm_configured_test.py | 182 ++++++++++++++++++ ..._network_gateways_alarm_configured_test.py | 92 +++++++++ ...work_route_tables_alarm_configured_test.py | 182 ++++++++++++++++++ ...h_changes_to_vpcs_alarm_configured_test.py | 182 ++++++++++++++++++ ...nfig_configuration_changes_enabled_test.py | 94 +++++++++ ...rail_configuration_changes_enabled_test.py | 94 +++++++++ ...ric_filter_authentication_failures_test.py | 92 +++++++++ ...c_filter_aws_organizations_changes_test.py | 92 +++++++++ ...e_or_scheduled_deletion_of_kms_cmk_test.py | 94 +++++++++ ...ilter_for_s3_bucket_policy_changes_test.py | 92 +++++++++ ...h_log_metric_filter_policy_changes_test.py | 92 +++++++++ ...tric_filter_security_group_changes_test.py | 92 +++++++++ ..._metric_filter_sign_in_without_mfa_test.py | 92 +++++++++ .../cloudwatch/cloudwatch_service_test.py | 14 ++ 34 files changed, 1689 insertions(+), 20 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 619401bf32..cd6db78f37 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -29,6 +29,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `entra_users_mfa_capable` no longer flags pre-provisioned users with future `employeeHireDate`; future-hire date comparisons now tolerate naive datetimes [(#11511)](https://github.com/prowler-cloud/prowler/pull/11511) - M365 Admin Center group enumeration now follows Microsoft Graph pagination so group-scoped checks include groups beyond the first page [(#11510)](https://github.com/prowler-cloud/prowler/pull/11510) - GCP `kms_key_rotation_enabled` check now only verifies that automatic key rotation is enabled (any interval) instead of enforcing a 90-day period, resolving the mismatch between the check and its documentation; the CIS, Prowler ThreatScore, and CCC requirements that mandate a 90-day maximum were remapped to the new `kms_key_rotation_max_90_days` check [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516) +- AWS CloudWatch log metric filter checks now validate `filterPattern` clauses regardless of order [(#11345)](https://github.com/prowler-cloud/prowler/pull/11345) --- diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json index 20ed5f0adb..9070c7b0c7 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json @@ -17,9 +17,8 @@ "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", - "https://www.clouddefense.ai/compliance-rules/cis-v130/monitoring/cis-v130-4-11", "https://support.icompaas.com/support/solutions/articles/62000084031-ensure-a-log-metric-filter-and-alarm-exist-for-changes-to-network-access-control-lists-nacl-", - "https://trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/network-acl-changes-alarm.html", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/network-acl-changes-alarm.html", "https://support.icompaas.com/support/solutions/articles/62000233134-4-11-ensure-network-access-control-list-nacl-changes-are-monitored-manual-" ], "Remediation": { diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py index 20d68a0121..68f45a8d27 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_acls_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateNetworkAcl.+\$\.eventName\s*=\s*.?CreateNetworkAclEntry.+\$\.eventName\s*=\s*.?DeleteNetworkAcl.+\$\.eventName\s*=\s*.?DeleteNetworkAclEntry.+\$\.eventName\s*=\s*.?ReplaceNetworkAclEntry.+\$\.eventName\s*=\s*.?ReplaceNetworkAclAssociation.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateNetworkAcl", + "CreateNetworkAclEntry", + "DeleteNetworkAcl", + "DeleteNetworkAclEntry", + "ReplaceNetworkAclEntry", + "ReplaceNetworkAclAssociation", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py index 5f6eda3973..f7bf8e1d22 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_gateways_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateCustomerGateway.+\$\.eventName\s*=\s*.?DeleteCustomerGateway.+\$\.eventName\s*=\s*.?AttachInternetGateway.+\$\.eventName\s*=\s*.?CreateInternetGateway.+\$\.eventName\s*=\s*.?DeleteInternetGateway.+\$\.eventName\s*=\s*.?DetachInternetGateway.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateCustomerGateway", + "DeleteCustomerGateway", + "AttachInternetGateway", + "CreateInternetGateway", + "DeleteInternetGateway", + "DetachInternetGateway", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json index 82490a63da..89949cfbd6 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json @@ -37,5 +37,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "Logging and Monitoring" } diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py index f8fcc8eacb..460765cb2f 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,18 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_route_tables_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?ec2.amazonaws.com.+\$\.eventName\s*=\s*.?CreateRoute.+\$\.eventName\s*=\s*.?CreateRouteTable.+\$\.eventName\s*=\s*.?ReplaceRoute.+\$\.eventName\s*=\s*.?ReplaceRouteTableAssociation.+\$\.eventName\s*=\s*.?DeleteRouteTable.+\$\.eventName\s*=\s*.?DeleteRoute.+\$\.eventName\s*=\s*.?DisassociateRouteTable.?" + pattern = build_metric_filter_pattern( + event_source="ec2.amazonaws.com", + event_names=[ + "CreateRoute", + "CreateRouteTable", + "ReplaceRoute", + "ReplaceRouteTableAssociation", + "DeleteRouteTable", + "DeleteRoute", + "DisassociateRouteTable", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py index d7606647c4..be4fb0859d 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,21 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_vpcs_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateVpc.+\$\.eventName\s*=\s*.?DeleteVpc.+\$\.eventName\s*=\s*.?ModifyVpcAttribute.+\$\.eventName\s*=\s*.?AcceptVpcPeeringConnection.+\$\.eventName\s*=\s*.?CreateVpcPeeringConnection.+\$\.eventName\s*=\s*.?DeleteVpcPeeringConnection.+\$\.eventName\s*=\s*.?RejectVpcPeeringConnection.+\$\.eventName\s*=\s*.?AttachClassicLinkVpc.+\$\.eventName\s*=\s*.?DetachClassicLinkVpc.+\$\.eventName\s*=\s*.?DisableVpcClassicLink.+\$\.eventName\s*=\s*.?EnableVpcClassicLink.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateVpc", + "DeleteVpc", + "ModifyVpcAttribute", + "AcceptVpcPeeringConnection", + "CreateVpcPeeringConnection", + "DeleteVpcPeeringConnection", + "RejectVpcPeeringConnection", + "AttachClassicLinkVpc", + "DetachClassicLinkVpc", + "DisableVpcClassicLink", + "EnableVpcClassicLink", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py index 11bf08d99d..49bf9a03a3 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -15,7 +16,15 @@ class cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_change Check ): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?config.amazonaws.com.+\$\.eventName\s*=\s*.?StopConfigurationRecorder.+\$\.eventName\s*=\s*.?DeleteDeliveryChannel.+\$\.eventName\s*=\s*.?PutDeliveryChannel.+\$\.eventName\s*=\s*.?PutConfigurationRecorder.?" + pattern = build_metric_filter_pattern( + event_source="config.amazonaws.com", + event_names=[ + "StopConfigurationRecorder", + "DeleteDeliveryChannel", + "PutDeliveryChannel", + "PutConfigurationRecorder", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py index eb272ecfb1..e9567315f4 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -15,7 +16,15 @@ class cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_change Check ): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateTrail.+\$\.eventName\s*=\s*.?UpdateTrail.+\$\.eventName\s*=\s*.?DeleteTrail.+\$\.eventName\s*=\s*.?StartLogging.+\$\.eventName\s*=\s*.?StopLogging.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateTrail", + "UpdateTrail", + "DeleteTrail", + "StartLogging", + "StopLogging", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py index c217cbbaf6..1b2e5173bb 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,10 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_authentication_failures(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?ConsoleLogin.+\$\.errorMessage\s*=\s*.?Failed authentication.?" + pattern = build_metric_filter_pattern( + event_names=["ConsoleLogin"], + extra_clauses=[("errorMessage", "=", "Failed authentication")], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py index af7ec82119..9976a885bb 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,32 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_aws_organizations_changes(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?organizations\.amazonaws\.com.+\$\.eventName\s*=\s*.?AcceptHandshake.+\$\.eventName\s*=\s*.?AttachPolicy.+\$\.eventName\s*=\s*.?CancelHandshake.+\$\.eventName\s*=\s*.?CreateAccount.+\$\.eventName\s*=\s*.?CreateOrganization.+\$\.eventName\s*=\s*.?CreateOrganizationalUnit.+\$\.eventName\s*=\s*.?CreatePolicy.+\$\.eventName\s*=\s*.?DeclineHandshake.+\$\.eventName\s*=\s*.?DeleteOrganization.+\$\.eventName\s*=\s*.?DeleteOrganizationalUnit.+\$\.eventName\s*=\s*.?DeletePolicy.+\$\.eventName\s*=\s*.?EnableAllFeatures.+\$\.eventName\s*=\s*.?EnablePolicyType.+\$\.eventName\s*=\s*.?InviteAccountToOrganization.+\$\.eventName\s*=\s*.?LeaveOrganization.+\$\.eventName\s*=\s*.?DetachPolicy.+\$\.eventName\s*=\s*.?DisablePolicyType.+\$\.eventName\s*=\s*.?MoveAccount.+\$\.eventName\s*=\s*.?RemoveAccountFromOrganization.+\$\.eventName\s*=\s*.?UpdateOrganizationalUnit.+\$\.eventName\s*=\s*.?UpdatePolicy.?" + pattern = build_metric_filter_pattern( + event_source="organizations.amazonaws.com", + event_names=[ + "AcceptHandshake", + "AttachPolicy", + "CancelHandshake", + "CreateAccount", + "CreateOrganization", + "CreateOrganizationalUnit", + "CreatePolicy", + "DeclineHandshake", + "DeleteOrganization", + "DeleteOrganizationalUnit", + "DeletePolicy", + "EnableAllFeatures", + "EnablePolicyType", + "InviteAccountToOrganization", + "LeaveOrganization", + "DetachPolicy", + "DisablePolicyType", + "MoveAccount", + "RemoveAccountFromOrganization", + "UpdateOrganizationalUnit", + "UpdatePolicy", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py index 4cfed985f7..20d1d62a5a 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,10 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?kms.amazonaws.com.+\$\.eventName\s*=\s*.?DisableKey.+\$\.eventName\s*=\s*.?ScheduleKeyDeletion.?" + pattern = build_metric_filter_pattern( + event_source="kms.amazonaws.com", + event_names=["DisableKey", "ScheduleKeyDeletion"], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json index 2d77c42704..6292de6071 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json @@ -17,8 +17,7 @@ "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", - "https://support.icompaas.com/support/solutions/articles/62000086674-ensure-a-log-metric-filter-and-alarm-exist-for-s3-bucket-policy-changes", - "https://www.tenable.com/audits/items/CIS_Amazon_Web_Services_Foundations_v5.0.0_L1.audit:8101350d6907e07863ac6748689b3e12" + "https://support.icompaas.com/support/solutions/articles/62000086674-ensure-a-log-metric-filter-and-alarm-exist-for-s3-bucket-policy-changes" ], "Remediation": { "Code": { diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py index 45d09b3528..d5fe1ef994 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,20 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_for_s3_bucket_policy_changes(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?s3.amazonaws.com.+\$\.eventName\s*=\s*.?PutBucketAcl.+\$\.eventName\s*=\s*.?PutBucketPolicy.+\$\.eventName\s*=\s*.?PutBucketCors.+\$\.eventName\s*=\s*.?PutBucketLifecycle.+\$\.eventName\s*=\s*.?PutBucketReplication.+\$\.eventName\s*=\s*.?DeleteBucketPolicy.+\$\.eventName\s*=\s*.?DeleteBucketCors.+\$\.eventName\s*=\s*.?DeleteBucketLifecycle.+\$\.eventName\s*=\s*.?DeleteBucketReplication.?" + pattern = build_metric_filter_pattern( + event_source="s3.amazonaws.com", + event_names=[ + "PutBucketAcl", + "PutBucketPolicy", + "PutBucketCors", + "PutBucketLifecycle", + "PutBucketReplication", + "DeleteBucketPolicy", + "DeleteBucketCors", + "DeleteBucketLifecycle", + "DeleteBucketReplication", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json index 75cba1ee62..b1c36be2d8 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json @@ -17,7 +17,6 @@ "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", - "https://www.clouddefense.ai/compliance-rules/cis-v140/monitoring/cis-v140-4-4", "https://www.intelligentdiscovery.io/controls/cloudwatch/cloudwatch-alarm-iam-policy-change" ], "Remediation": { diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py index f5efd04dde..4347a9b3aa 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,26 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_policy_changes(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?DeleteGroupPolicy.+\$\.eventName\s*=\s*.?DeleteRolePolicy.+\$\.eventName\s*=\s*.?DeleteUserPolicy.+\$\.eventName\s*=\s*.?PutGroupPolicy.+\$\.eventName\s*=\s*.?PutRolePolicy.+\$\.eventName\s*=\s*.?PutUserPolicy.+\$\.eventName\s*=\s*.?CreatePolicy.+\$\.eventName\s*=\s*.?DeletePolicy.+\$\.eventName\s*=\s*.?CreatePolicyVersion.+\$\.eventName\s*=\s*.?DeletePolicyVersion.+\$\.eventName\s*=\s*.?AttachRolePolicy.+\$\.eventName\s*=\s*.?DetachRolePolicy.+\$\.eventName\s*=\s*.?AttachUserPolicy.+\$\.eventName\s*=\s*.?DetachUserPolicy.+\$\.eventName\s*=\s*.?AttachGroupPolicy.+\$\.eventName\s*=\s*.?DetachGroupPolicy.?" + pattern = build_metric_filter_pattern( + event_names=[ + "DeleteGroupPolicy", + "DeleteRolePolicy", + "DeleteUserPolicy", + "PutGroupPolicy", + "PutRolePolicy", + "PutUserPolicy", + "CreatePolicy", + "DeletePolicy", + "CreatePolicyVersion", + "DeletePolicyVersion", + "AttachRolePolicy", + "DetachRolePolicy", + "AttachUserPolicy", + "DetachUserPolicy", + "AttachGroupPolicy", + "DetachGroupPolicy", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py index a7972e420c..3557632904 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_security_group_changes(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?AuthorizeSecurityGroupIngress.+\$\.eventName\s*=\s*.?AuthorizeSecurityGroupEgress.+\$\.eventName\s*=\s*.?RevokeSecurityGroupIngress.+\$\.eventName\s*=\s*.?RevokeSecurityGroupEgress.+\$\.eventName\s*=\s*.?CreateSecurityGroup.+\$\.eventName\s*=\s*.?DeleteSecurityGroup.?" + pattern = build_metric_filter_pattern( + event_names=[ + "AuthorizeSecurityGroupIngress", + "AuthorizeSecurityGroupEgress", + "RevokeSecurityGroupIngress", + "RevokeSecurityGroupEgress", + "CreateSecurityGroup", + "DeleteSecurityGroup", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json index 20058c1ed8..76ffd832ff 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json @@ -21,7 +21,6 @@ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/console-sign-in-without-mfa.html", "https://www.tenable.com/audits/items/CIS_Amazon_Web_Services_Foundations_v3.0.0_L1.audit:1957056ee174cc38502d5f5f1864333b", - "https://www.clouddefense.ai/compliance-rules/gdpr/data-protection/log-metric-filter-console-login-mfa", "https://www.intelligentdiscovery.io/controls/cloudwatch/cloudwatch-alarm-no-mfa", "https://support.icompaas.com/support/solutions/articles/62000083605-ensure-a-log-metric-filter-and-alarm-exist-for-management-console-sign-in-without-mfa" ], diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py index 8437600646..07475a6185 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,10 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_sign_in_without_mfa(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?ConsoleLogin.+\$\.additionalEventData\.MFAUsed\s*!=\s*.?Yes.?" + pattern = build_metric_filter_pattern( + event_names=["ConsoleLogin"], + extra_clauses=[("additionalEventData.MFAUsed", "!=", "Yes")], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py b/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py index 84d70b4083..e5d104b840 100644 --- a/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py +++ b/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py @@ -3,6 +3,45 @@ import re from prowler.lib.check.models import Check_Report_AWS +def build_metric_filter_pattern( + *, + event_names: list[str] | None = None, + event_source: str | None = None, + extra_clauses: list[tuple[str, str, str]] | None = None, +) -> str: + """Build a regex pattern to match a CloudWatch Logs filterPattern string. + + All clauses must be present for the pattern to match, regardless of the + order in which AWS stores them. Event names are matched exactly, so a + short name like ``CreateRoute`` will not be satisfied by a longer one + like ``CreateRouteTable``. + + Pass the result directly to ``check_cloudwatch_log_metric_filter``. + + Args: + event_names: AWS API action names to require (``$.eventName``). + event_source: optional service principal to require (``$.eventSource``), + e.g. ``"ec2.amazonaws.com"``. + extra_clauses: additional conditions as ``(field, operator, value)`` + tuples, where ``operator`` is ``"="`` or ``"!="``. Example: + ``("additionalEventData.MFAUsed", "!=", "Yes")``. + + Returns: + A regex string for use with ``re.search(..., flags=re.DOTALL)``. + """ + parts: list[str] = [] + if event_source is not None: + parts.append(rf"(?=.*\$\.eventSource\s*=\s*.?{re.escape(event_source)})") + for name in event_names or []: + parts.append(rf"(?=.*\$\.eventName\s*=\s*.?{re.escape(name)}\b)") + for field, operator, value in extra_clauses or []: + if operator not in ("=", "!="): + raise ValueError(f"unsupported operator {operator!r}; expected '=' or '!='") + op = r"\s*!=\s*" if operator == "!=" else r"\s*=\s*" + parts.append(rf"(?=.*\$\.{re.escape(field)}{op}.?{re.escape(value)})") + return "".join(parts) + + def check_cloudwatch_log_metric_filter( metric_filter_pattern: str, trails: list, diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py index 66c2099843..928ff2b47c 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py @@ -674,3 +674,185 @@ class Test_cloudwatch_changes_to_network_acls_alarm_configured: result = check.execute() assert len(result) == 0 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = ReplaceNetworkAclAssociation) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = CreateNetworkAcl) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured import ( + cloudwatch_changes_to_network_acls_alarm_configured, + ) + + check = cloudwatch_changes_to_network_acls_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_substring_only_no_match(self): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured import ( + cloudwatch_changes_to_network_acls_alarm_configured, + ) + + check = cloudwatch_changes_to_network_acls_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudWatch log groups found with metric filters or alarms associated." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py index afe0f7d3ce..50c8663437 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py @@ -616,3 +616,95 @@ class Test_cloudwatch_changes_to_network_gateways_alarm_configured: ) assert result[0].region == AWS_REGION_US_EAST_1 assert result[0].resource_tags == [{}] + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = DetachInternetGateway) || ($.eventName = DeleteInternetGateway) || ($.eventName = CreateInternetGateway) || ($.eventName = AttachInternetGateway) || ($.eventName = DeleteCustomerGateway) || ($.eventName = CreateCustomerGateway) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured import ( + cloudwatch_changes_to_network_gateways_alarm_configured, + ) + + check = cloudwatch_changes_to_network_gateways_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py index 7ec6e32c56..929793eb34 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py @@ -596,3 +596,185 @@ class Test_cloudwatch_changes_to_network_route_tables_alarm_configured: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = ec2.amazonaws.com) && ($.eventName = DisassociateRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DeleteRouteTable) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = ReplaceRoute) || ($.eventName = CreateRouteTable) || ($.eventName = CreateRoute) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured import ( + cloudwatch_changes_to_network_route_tables_alarm_configured, + ) + + check = cloudwatch_changes_to_network_route_tables_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_substring_only_no_match(self): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = ec2.amazonaws.com) && ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DisassociateRouteTable) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured import ( + cloudwatch_changes_to_network_route_tables_alarm_configured, + ) + + check = cloudwatch_changes_to_network_route_tables_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudWatch log groups found with metric filters or alarms associated." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py index 31f27030ff..57d33cb3fe 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py @@ -596,3 +596,185 @@ class Test_cloudwatch_changes_to_vpcs_alarm_configured: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = EnableVpcClassicLink) || ($.eventName = DisableVpcClassicLink) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = ModifyVpcAttribute) || ($.eventName = DeleteVpc) || ($.eventName = CreateVpc) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured import ( + cloudwatch_changes_to_vpcs_alarm_configured, + ) + + check = cloudwatch_changes_to_vpcs_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_substring_only_no_match(self): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured import ( + cloudwatch_changes_to_vpcs_alarm_configured, + ) + + check = cloudwatch_changes_to_vpcs_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudWatch log groups found with metric filters or alarms associated." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py index 30982553b2..62ca14d4ba 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py @@ -665,3 +665,97 @@ class Test_cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_c result = check.execute() assert len(result) == 0 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = config.amazonaws.com) && (($.eventName = PutConfigurationRecorder) || ($.eventName = PutDeliveryChannel) || ($.eventName = DeleteDeliveryChannel) || ($.eventName = StopConfigurationRecorder)) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled import ( + cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled, + ) + + check = ( + cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled_test.py index fcb98fda3e..3b555bf05b 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled_test.py @@ -610,3 +610,97 @@ class Test_cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_c == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = StopLogging) || ($.eventName = StartLogging) || ($.eventName = DeleteTrail) || ($.eventName = UpdateTrail) || ($.eventName = CreateTrail) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled import ( + cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled, + ) + + check = ( + cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py index 66b9d8116b..201be76f6d 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_authentication_failures: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.errorMessage = Failed authentication) && ($.eventName = ConsoleLogin) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures import ( + cloudwatch_log_metric_filter_authentication_failures, + ) + + check = cloudwatch_log_metric_filter_authentication_failures() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py index 3aaf475cbe..534c50c906 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_aws_organizations_changes: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = organizations.amazonaws.com) && ($.eventName = UpdatePolicy) || ($.eventName = UpdateOrganizationalUnit) || ($.eventName = RemoveAccountFromOrganization) || ($.eventName = MoveAccount) || ($.eventName = DisablePolicyType) || ($.eventName = DetachPolicy) || ($.eventName = LeaveOrganization) || ($.eventName = InviteAccountToOrganization) || ($.eventName = EnablePolicyType) || ($.eventName = EnableAllFeatures) || ($.eventName = DeletePolicy) || ($.eventName = DeleteOrganizationalUnit) || ($.eventName = DeleteOrganization) || ($.eventName = DeclineHandshake) || ($.eventName = CreatePolicy) || ($.eventName = CreateOrganizationalUnit) || ($.eventName = CreateOrganization) || ($.eventName = CreateAccount) || ($.eventName = CancelHandshake) || ($.eventName = AttachPolicy) || ($.eventName = AcceptHandshake) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes import ( + cloudwatch_log_metric_filter_aws_organizations_changes, + ) + + check = cloudwatch_log_metric_filter_aws_organizations_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk_test.py index 7eb1cae331..f13263cf9f 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk_test.py @@ -610,3 +610,97 @@ class Test_cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = kms.amazonaws.com) && (($.eventName = ScheduleKeyDeletion) || ($.eventName = DisableKey)) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk import ( + cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk, + ) + + check = ( + cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py index 7d2d631f11..aa88f5164d 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_for_s3_bucket_policy_changes: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = s3.amazonaws.com) && (($.eventName = DeleteBucketReplication) || ($.eventName = DeleteBucketLifecycle) || ($.eventName = DeleteBucketCors) || ($.eventName = DeleteBucketPolicy) || ($.eventName = PutBucketReplication) || ($.eventName = PutBucketLifecycle) || ($.eventName = PutBucketCors) || ($.eventName = PutBucketPolicy) || ($.eventName = PutBucketAcl)) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes import ( + cloudwatch_log_metric_filter_for_s3_bucket_policy_changes, + ) + + check = cloudwatch_log_metric_filter_for_s3_bucket_policy_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py index 06e36688d6..fd3e86d313 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_unauthorized_api_calls: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = DetachGroupPolicy) || ($.eventName = AttachGroupPolicy) || ($.eventName = DetachUserPolicy) || ($.eventName = AttachUserPolicy) || ($.eventName = DetachRolePolicy) || ($.eventName = AttachRolePolicy) || ($.eventName = DeletePolicyVersion) || ($.eventName = CreatePolicyVersion) || ($.eventName = DeletePolicy) || ($.eventName = CreatePolicy) || ($.eventName = PutUserPolicy) || ($.eventName = PutRolePolicy) || ($.eventName = PutGroupPolicy) || ($.eventName = DeleteUserPolicy) || ($.eventName = DeleteRolePolicy) || ($.eventName = DeleteGroupPolicy) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes import ( + cloudwatch_log_metric_filter_policy_changes, + ) + + check = cloudwatch_log_metric_filter_policy_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py index ede85e8e6e..35dee7b516 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py @@ -599,3 +599,95 @@ class Test_cloudwatch_log_metric_filter_unauthorized_api_calls: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = DeleteSecurityGroup) || ($.eventName = CreateSecurityGroup) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = AuthorizeSecurityGroupIngress) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes import ( + cloudwatch_log_metric_filter_security_group_changes, + ) + + check = cloudwatch_log_metric_filter_security_group_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py index df67472cdd..6944860d75 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_sign_in_without_mfa: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.additionalEventData.MFAUsed != Yes) && ($.eventName = ConsoleLogin) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa import ( + cloudwatch_log_metric_filter_sign_in_without_mfa, + ) + + check = cloudwatch_log_metric_filter_sign_in_without_mfa() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py index 8ae7ed980f..33d4bde7d7 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py @@ -1,3 +1,4 @@ +import pytest from boto3 import client from moto import mock_aws @@ -5,6 +6,9 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( CloudWatch, Logs, ) +from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, +) from tests.providers.aws.utils import ( AWS_ACCOUNT_NUMBER, AWS_REGION_US_EAST_1, @@ -216,3 +220,13 @@ class Test_CloudWatch_Service: assert logs.log_groups[arn].kms_id == "test_kms_id" assert logs.log_groups[arn].region == AWS_REGION_US_EAST_1 assert logs.log_groups[arn].tags == [{}] + + +class Test_build_metric_filter_pattern: + @pytest.mark.parametrize("bad_operator", ["==", "~=", "<", "<>", ">=", ""]) + def test_rejects_unsupported_operator(self, bad_operator): + with pytest.raises(ValueError, match="unsupported operator"): + build_metric_filter_pattern( + event_names=["ConsoleLogin"], + extra_clauses=[("errorMessage", bad_operator, "Failed authentication")], + ) From 75f95559d629f371b5618c3f4c243d8b54a975a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 10 Jun 2026 19:04:40 +0200 Subject: [PATCH 010/126] fix(api): warm compliance caches when starting the worker (#11530) --- api/CHANGELOG.md | 1 + api/src/backend/api/compliance.py | 63 +++++++++++++ api/src/backend/api/exceptions.py | 26 ++++++ api/src/backend/api/tests/test_compliance.py | 93 +++++++++++++++++++ api/src/backend/api/tests/test_views.py | 33 +++++++ api/src/backend/api/v1/views.py | 10 ++ api/src/backend/config/guniconf.py | 25 +++++ ui/CHANGELOG.md | 1 + ui/actions/compliances/compliances.ts | 7 ++ .../compliance/[compliancetitle]/page.tsx | 11 +++ .../compliance/compliance-warming.tsx | 49 ++++++++++ ui/components/compliance/index.ts | 1 + 12 files changed, 320 insertions(+) create mode 100644 ui/components/compliance/compliance-warming.tsx diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 2033dcc420..6759b28741 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to the **Prowler API** are documented in this file. - Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) - Resource `name` is now stored and refreshed on every scan, so resources no longer keep an empty name [(#11476)](https://github.com/prowler-cloud/prowler/pull/11476) +- Compliance catalog now warms in a background thread after each worker forks, and `compliance-overviews/attributes` returns `503` while warming, so the first request after a deploy no longer trips the Gunicorn worker timeout [(#4554)](https://github.com/prowler-cloud/prowler-cloud/pull/4554) ### 🔐 Security diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py index 678aff8d57..202825185b 100644 --- a/api/src/backend/api/compliance.py +++ b/api/src/backend/api/compliance.py @@ -1,3 +1,5 @@ +import logging +import threading from collections.abc import Iterable, Mapping from api.models import Provider @@ -6,8 +8,19 @@ from prowler.lib.check.compliance_models import ( ) from prowler.lib.check.models import CheckMetadata +logger = logging.getLogger(__name__) + AVAILABLE_COMPLIANCE_FRAMEWORKS = {} +# Per-process readiness flags for the background compliance warm-up. +# `STARTED` is set as soon as warming begins (only happens under Gunicorn via +# the post_fork hook); `WARMED` is set when it finishes. The attributes +# endpoint checks both: it returns 503 only while warming is in progress. +# Under `runserver` warming never runs, so `STARTED` stays clear and the +# endpoint keeps lazy-loading as before. +COMPLIANCE_WARMING_STARTED = threading.Event() +COMPLIANCE_WARMED = threading.Event() + class LazyComplianceTemplate(Mapping): """Lazy-load compliance templates per provider on first access.""" @@ -174,6 +187,56 @@ def _ensure_provider_loaded(provider_type: Provider.ProviderChoices) -> None: PROWLER_CHECKS._cache[provider_type] = checks +def warm_compliance_caches( + provider_types: Iterable[str] | None = None, +) -> list[str]: + """ + Eagerly populate the per-process compliance caches at server startup. + + Moves the cold-cache catalog load off the request thread so the first + request does not trip the Gunicorn worker timeout. Reads only on-disk + metadata (no database access). Each provider is warmed in isolation; + failures are logged and fall back to lazy loading. + + Args: + provider_types (Iterable[str] | None): Subset to warm. Defaults to all. + + Returns: + list[str]: Provider types that could not be warmed. + """ + if provider_types is None: + provider_types = Provider.ProviderChoices.values + provider_types = list(provider_types) + + COMPLIANCE_WARMING_STARTED.set() + logger.info("Compliance cache warm-up started for providers: %s", provider_types) + + failed = [] + for provider_type in provider_types: + try: + get_compliance_frameworks(provider_type) + _ensure_provider_loaded(provider_type) + # Prowler check loading may sys.exit (SystemExit, not Exception). + except (Exception, SystemExit): + logger.warning( + "Failed to warm compliance caches for provider '%s'; " + "loading lazily on first request", + provider_type, + exc_info=True, + ) + failed.append(provider_type) + + # Mark as warmed even when some providers failed: a failed provider falls + # back to a single-provider lazy load, which stays under the worker timeout. + COMPLIANCE_WARMED.set() + logger.info( + "Compliance cache warm-up finished (providers warmed: %d, failed: %s)", + len(provider_types) - len(failed), + failed, + ) + return failed + + def load_prowler_checks( prowler_compliance, provider_types: Iterable[str] | None = None ): diff --git a/api/src/backend/api/exceptions.py b/api/src/backend/api/exceptions.py index 78f8c64c7d..4f6f26c2ea 100644 --- a/api/src/backend/api/exceptions.py +++ b/api/src/backend/api/exceptions.py @@ -187,6 +187,32 @@ class UpstreamServiceUnavailableError(APIException): ) +class ComplianceWarmingError(APIException): + """Compliance catalog is still warming (503 Service Unavailable). + + Returned by the compliance attributes endpoint while the per-process + catalog warm-up is in progress, so the request thread never triggers the + slow cold load that would trip the Gunicorn worker timeout. + """ + + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_detail = ( + "Compliance data is still loading. Please try again in a few seconds." + ) + default_code = "compliance_warming" + + def __init__(self, detail=None): + super().__init__( + detail=[ + { + "detail": detail or self.default_detail, + "status": str(self.status_code), + "code": self.default_code, + } + ] + ) + + class UpstreamInternalError(APIException): """Unexpected error communicating with provider (500 Internal Server Error). diff --git a/api/src/backend/api/tests/test_compliance.py b/api/src/backend/api/tests/test_compliance.py index 508e5abaca..99a31ea12c 100644 --- a/api/src/backend/api/tests/test_compliance.py +++ b/api/src/backend/api/tests/test_compliance.py @@ -10,6 +10,7 @@ from api.compliance import ( get_prowler_provider_checks, get_prowler_provider_compliance, load_prowler_checks, + warm_compliance_caches, ) from api.models import Provider from prowler.lib.check.compliance_models import ( @@ -267,11 +268,17 @@ def reset_compliance_cache(): """Reset the module-level cache so each test starts cold.""" previous = dict(compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS) compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear() + # The warming flags are module-global; clear them so they do not leak + # between tests that call warm_compliance_caches. + compliance_module.COMPLIANCE_WARMING_STARTED.clear() + compliance_module.COMPLIANCE_WARMED.clear() try: yield finally: compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear() compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.update(previous) + compliance_module.COMPLIANCE_WARMING_STARTED.clear() + compliance_module.COMPLIANCE_WARMED.clear() class TestGetComplianceFrameworks: @@ -321,3 +328,89 @@ class TestGetComplianceFrameworks: f"loadable by get_bulk_compliance_frameworks_universal: " f"{sorted(missing)}" ) + + +class TestWarmComplianceCaches: + def test_warms_all_provider_types_by_default(self, reset_compliance_cache): + provider_types = list(Provider.ProviderChoices.values) + with ( + patch("api.compliance.get_compliance_frameworks") as mock_frameworks, + patch("api.compliance._ensure_provider_loaded") as mock_ensure, + ): + warm_compliance_caches() + + warmed = {call.args[0] for call in mock_frameworks.call_args_list} + assert warmed == set(provider_types) + assert mock_frameworks.call_count == len(provider_types) + assert mock_ensure.call_count == len(provider_types) + + def test_warms_only_requested_provider_types(self, reset_compliance_cache): + with ( + patch("api.compliance.get_compliance_frameworks") as mock_frameworks, + patch("api.compliance._ensure_provider_loaded") as mock_ensure, + ): + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + mock_frameworks.assert_called_once_with(Provider.ProviderChoices.AWS) + mock_ensure.assert_called_once_with(Provider.ProviderChoices.AWS) + + def test_populates_module_cache(self, reset_compliance_cache): + with ( + patch( + "api.compliance.get_bulk_compliance_frameworks_universal" + ) as mock_get_bulk, + patch("api.compliance._ensure_provider_loaded"), + ): + mock_get_bulk.return_value = {"cis_1.4_aws": MagicMock()} + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + assert ( + Provider.ProviderChoices.AWS + in compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS + ) + + def test_failing_provider_does_not_abort_the_rest(self, reset_compliance_cache): + """A failing provider (even on SystemExit) is isolated; others warm.""" + providers = [Provider.ProviderChoices.AWS, Provider.ProviderChoices.OKTA] + + def fake_frameworks(provider_type): + if provider_type == Provider.ProviderChoices.OKTA: + raise SystemExit(1) + return [] + + with ( + patch( + "api.compliance.get_compliance_frameworks", side_effect=fake_frameworks + ), + patch("api.compliance._ensure_provider_loaded") as mock_ensure, + ): + failed = warm_compliance_caches(providers) + + assert failed == [Provider.ProviderChoices.OKTA] + mock_ensure.assert_called_once_with(Provider.ProviderChoices.AWS) + + def test_sets_readiness_flags(self, reset_compliance_cache): + assert not compliance_module.COMPLIANCE_WARMING_STARTED.is_set() + assert not compliance_module.COMPLIANCE_WARMED.is_set() + + with ( + patch("api.compliance.get_compliance_frameworks"), + patch("api.compliance._ensure_provider_loaded"), + ): + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + assert compliance_module.COMPLIANCE_WARMING_STARTED.is_set() + assert compliance_module.COMPLIANCE_WARMED.is_set() + + def test_marks_warmed_even_when_a_provider_fails(self, reset_compliance_cache): + """A failed provider still leaves the caches flagged as warmed.""" + with ( + patch( + "api.compliance.get_compliance_frameworks", + side_effect=SystemExit(1), + ), + patch("api.compliance._ensure_provider_loaded"), + ): + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + assert compliance_module.COMPLIANCE_WARMED.is_set() diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index d213e8c855..ab90bdbde0 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -9578,6 +9578,39 @@ class TestComplianceOverviewViewSet: ) assert response.status_code == status.HTTP_400_BAD_REQUEST + def test_compliance_overview_attributes_503_while_warming( + self, authenticated_client + ): + from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED + + COMPLIANCE_WARMING_STARTED.set() + COMPLIANCE_WARMED.clear() + try: + response = authenticated_client.get( + reverse("complianceoverview-attributes"), + {"filter[compliance_id]": "aws_account_security_onboarding_aws"}, + ) + finally: + COMPLIANCE_WARMING_STARTED.clear() + + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert response.json()["errors"][0]["code"] == "compliance_warming" + + def test_compliance_overview_attributes_serves_when_warming_not_started( + self, authenticated_client + ): + # Dev fallback: under runserver warming never runs, so the guard must + # not refuse — the endpoint lazily loads and serves as before. + from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED + + COMPLIANCE_WARMING_STARTED.clear() + COMPLIANCE_WARMED.clear() + response = authenticated_client.get( + reverse("complianceoverview-attributes"), + {"filter[compliance_id]": "aws_account_security_onboarding_aws"}, + ) + assert response.status_code == status.HTTP_200_OK + def test_compliance_overview_task_management_integration( self, authenticated_client, compliance_requirements_overviews_fixture ): diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 9c91c3201f..45100f1018 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -114,6 +114,8 @@ from api.attack_paths import get_queries_for_provider, get_query_by_id from api.attack_paths import views_helpers as attack_paths_views_helpers from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset from api.compliance import ( + COMPLIANCE_WARMED, + COMPLIANCE_WARMING_STARTED, PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, get_compliance_frameworks, get_prowler_provider_compliance, @@ -122,6 +124,7 @@ from api.constants import SEVERITY_ORDER from api.db_router import MainRouter from api.db_utils import rls_transaction from api.exceptions import ( + ComplianceWarmingError, TaskFailedException, UpstreamAccessDeniedError, UpstreamAuthenticationError, @@ -5059,6 +5062,13 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): @action(detail=False, methods=["get"], url_name="attributes") def attributes(self, request): + # While the background warm-up is in progress, refuse immediately + # instead of falling through to the slow cold load on the request + # thread (which would trip the Gunicorn worker timeout). `is_set()` is + # a non-blocking flag read, so this never touches the loader. + if COMPLIANCE_WARMING_STARTED.is_set() and not COMPLIANCE_WARMED.is_set(): + raise ComplianceWarmingError() + compliance_id = request.query_params.get("filter[compliance_id]") if not compliance_id: raise ValidationError( diff --git a/api/src/backend/config/guniconf.py b/api/src/backend/config/guniconf.py index 536fd97abb..a16c8de9a0 100644 --- a/api/src/backend/config/guniconf.py +++ b/api/src/backend/config/guniconf.py @@ -1,6 +1,7 @@ import logging import multiprocessing import os +import threading from config.env import env @@ -11,6 +12,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.production") import django # noqa: E402 django.setup() +from api.compliance import warm_compliance_caches # noqa: E402 from config.django.production import LOGGING as DJANGO_LOGGERS, DEBUG # noqa: E402 from config.custom_logging import BackendLogger # noqa: E402 @@ -41,3 +43,26 @@ def on_reload(_): def when_ready(_): gunicorn_logger.info("Gunicorn server is ready") + + +def _warm_compliance_caches_in_background(): + """Warm compliance caches off the request path and log the outcome.""" + failed = warm_compliance_caches() + if failed: + gunicorn_logger.warning("Compliance caches warmed (skipped: %s)", failed) + else: + gunicorn_logger.info("Compliance caches warmed") + + +def post_fork(_server, worker): + """Warm compliance caches after each worker fork. + + Warm compliance caches in a background thread so the worker becomes ready + immediately. A request for a not-yet-warmed provider lazily loads just that + provider, which stays well under the worker timeout. + """ + threading.Thread( + target=_warm_compliance_caches_in_background, + name="warm-compliance-caches", + daemon=True, + ).start() diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index dc8c0fa708..57d0427dc9 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🔄 Changed - Renamed "Customer Support" to "Support Desk" in the side menu, showing it only in Prowler Cloud/Enterprise, while "Community Support" now shows only in Prowler OSS [(#11508)](https://github.com/prowler-cloud/prowler/pull/11508) +- Compliance detail page now shows a "still loading" retry state while the API warms its compliance catalog, instead of rendering an empty page [(#4554)](https://github.com/prowler-cloud/prowler-cloud/pull/4554) --- diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index d5f4fd4954..58e7c5b5a0 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -84,6 +84,13 @@ export const getComplianceAttributes = async (complianceId: string) => { headers, }); + // The compliance catalog is still warming after a deploy/restart. Signal + // the page to render the "still loading" state instead of letting this + // become a thrown 5xx (which would be captured as a server error). + if (response.status === 503) { + return { warming: true as const, status: 503 }; + } + return handleApiResponse(response); } catch (error) { console.error("Error fetching compliance attributes:", error); diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index 71e7870f8d..445710e48f 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -13,6 +13,7 @@ import { ClientAccordionWrapper, ComplianceDownloadContainer, ComplianceHeader, + ComplianceWarming, RequirementsStatusCard, RequirementsStatusCardSkeleton, // SectionsFailureRateCard, @@ -92,6 +93,16 @@ export default async function ComplianceDetail({ : Promise.resolve(null), ]); + // The compliance catalog is still warming after a deploy/restart. Show the + // "still loading" state with a Try Again instead of rendering an empty page. + if (attributesData?.warming) { + return ( + + + + ); + } + if (selectedScanResponse?.data) { const scan = selectedScanResponse.data; const providerId = scan.relationships?.provider?.data?.id; diff --git a/ui/components/compliance/compliance-warming.tsx b/ui/components/compliance/compliance-warming.tsx new file mode 100644 index 0000000000..19864ae121 --- /dev/null +++ b/ui/components/compliance/compliance-warming.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Icon } from "@iconify/react"; +import { useRouter } from "next/navigation"; + +import { Button } from "@/components/shadcn/button/button"; +import { Card, CardContent } from "@/components/shadcn/card/card"; + +export const ComplianceWarming = () => { + const router = useRouter(); + + return ( +
+
+ + +
+
+ +
+

+ Compliance data is still loading +

+

+ This can happen for a few seconds right after an update. + Please try again shortly. +

+
+
+ +
+
+
+
+
+ ); +}; diff --git a/ui/components/compliance/index.ts b/ui/components/compliance/index.ts index e0bd631200..caea32e264 100644 --- a/ui/components/compliance/index.ts +++ b/ui/components/compliance/index.ts @@ -19,6 +19,7 @@ export * from "./compliance-header/compliance-scan-info"; export * from "./compliance-header/data-compliance"; export * from "./compliance-header/scan-selector"; export * from "./compliance-overview-grid"; +export * from "./compliance-warming"; export * from "./no-scans-available"; export * from "./skeletons/bar-chart-skeleton"; export * from "./skeletons/compliance-accordion-skeleton"; From 989c3b174e9c58a58e6cdb60a7ac93b87aa4c59c Mon Sep 17 00:00:00 2001 From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:31:08 +0200 Subject: [PATCH 011/126] fix(bedrock): per-finding severity for long-term API key check (#11526) --- prowler/CHANGELOG.md | 1 + ...key_no_long_term_credentials.metadata.json | 19 +- ...edrock_api_key_no_long_term_credentials.py | 81 ++- ...k_api_key_no_long_term_credentials_test.py | 593 +++++------------- 4 files changed, 220 insertions(+), 474 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index cd6db78f37..96ccb4bb29 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -30,6 +30,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - M365 Admin Center group enumeration now follows Microsoft Graph pagination so group-scoped checks include groups beyond the first page [(#11510)](https://github.com/prowler-cloud/prowler/pull/11510) - GCP `kms_key_rotation_enabled` check now only verifies that automatic key rotation is enabled (any interval) instead of enforcing a 90-day period, resolving the mismatch between the check and its documentation; the CIS, Prowler ThreatScore, and CCC requirements that mandate a 90-day maximum were remapped to the new `kms_key_rotation_max_90_days` check [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516) - AWS CloudWatch log metric filter checks now validate `filterPattern` clauses regardless of order [(#11345)](https://github.com/prowler-cloud/prowler/pull/11345) +- AWS `bedrock_api_key_no_long_term_credentials` now applies severity per finding (never-expires keys correctly flag as critical, no leak across findings) and aligns title and wording with AWS guidance to prefer short-term Bedrock API keys [(#11526)](https://github.com/prowler-cloud/prowler/pull/11526) --- diff --git a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json index f9b8ee0df9..c85279e4a4 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json +++ b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json @@ -1,7 +1,7 @@ { "Provider": "aws", "CheckID": "bedrock_api_key_no_long_term_credentials", - "CheckTitle": "Amazon Bedrock API key is expired", + "CheckTitle": "Amazon Bedrock long-term API key has expired", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", @@ -14,23 +14,24 @@ "Severity": "high", "ResourceType": "AwsIamUser", "ResourceGroup": "IAM", - "Description": "**Bedrock API keys** are evaluated for **lifetime** and **expiration**.\n\nThe finding identifies keys that are long-lived, set to expire far in the future, or configured to `never expire`, and distinguishes them from keys that have already expired.", - "Risk": "Long-lived or non-expiring keys enable persistent access if compromised.\n- Confidentiality: unauthorized inference and exposure of prompts/outputs\n- Availability/Cost: uncontrolled usage and spend spikes\n- Integrity: actions can continue without timely revocation or rotation", + "Description": "AWS recommends Amazon Bedrock **long-term API keys** only for **exploration**; production workloads should use **short-term API keys** (session-scoped, valid up to **12 hours**). This check fails for any active long-term Bedrock API key, escalating to `critical` severity when configured to **never expire**. Already-expired keys pass — they can no longer authenticate.", + "Risk": "Long-term Bedrock API keys persist beyond a session until their stored expiration, and keys set to **never expire** grant indefinite access until manually revoked, enabling unauthorized inference, uncontrolled usage and spend, and activity that continues past timely revocation.", "RelatedUrl": "", "AdditionalURLs": [ - "https://docs.aws.amazon.com/ja_jp/bedrock/latest/userguide/getting-started-api-keys.html", - "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials", - "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html" + "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html", + "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-generate.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds-programmatic-access.html#security-creds-alternatives-to-long-term-access-keys", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials" ], "Remediation": { "Code": { "CLI": "aws iam delete-service-specific-credential --user-name --service-specific-credential-id ", "NativeIaC": "", - "Other": "1. Sign in to the AWS Management Console and open IAM\n2. Go to Users > select > Security credentials\n3. In \"API keys for Amazon Bedrock\", find the non-expired key and click Delete\n4. Confirm deletion to remove the key (removes the long-term credential so the check passes)", + "Other": "1. Sign in to the AWS Management Console and open IAM\n2. Go to Users > select the IAM user backing the Bedrock API key > Security credentials\n3. In \"API keys for Amazon Bedrock\", select the active long-term key and click Delete\n4. For workloads that still need Bedrock access, generate a short-term API key from the Bedrock console (Short-term API keys tab), or call the Bedrock API with short-term credentials issued by AWS STS", "Terraform": "" }, "Recommendation": { - "Text": "Prefer **short-term credentials** and **IAM roles**; avoid `never expire`.\n\nEnforce **least privilege**, strict **rotation**, and automatic **expiration** for any long-term key. Store secrets securely, monitor with audit logs, and revoke unused or stale keys quickly.", + "Text": "Use short-term Amazon Bedrock API keys for any non-exploratory workload — they are bound to the IAM principal's session, valid for at most 12 hours, scoped to a single Region, and can be auto-refreshed by the SDK. For existing long-term keys, delete the underlying IAM service-specific credential. If a long-term key must be retained for an exploration scenario, set an explicit short expiration and never select `never expire`.", "Url": "https://hub.prowler.com/check/bedrock_api_key_no_long_term_credentials" } }, @@ -40,5 +41,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "This check verifies that Amazon Bedrock API keys have expiration dates set. API keys without expiration dates are considered long-term credentials and pose a security risk. The check follows security best practices for credential management and the principle of least privilege." + "Notes": "AWS recommends against using long-term Amazon Bedrock API keys outside of exploration; production workloads should use short-term API keys (session-scoped, valid up to 12 hours). The IAM `ListServiceSpecificCredentials` API only enumerates long-term keys — short-term keys are session-scoped credentials that never appear here. The check therefore passes only when an existing long-term key has already expired and can no longer authenticate; any active long-term key fails, with critical severity when it is configured to never expire." } diff --git a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py index 2edffd7ccb..9697ecbff0 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py +++ b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py @@ -1,49 +1,62 @@ from datetime import datetime, timezone -from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.check.models import Check, Check_Report_AWS, Severity from prowler.providers.aws.services.iam.iam_client import iam_client +# Days threshold above which a Bedrock long-term API key is considered effectively non-expiring. +NEVER_EXPIRES_THRESHOLD_DAYS = 10000 + class bedrock_api_key_no_long_term_credentials(Check): - """ - Bedrock API keys should be short-lived to reduce the risk of unauthorized access. - This check verifies if there are any long-term Bedrock API keys. - If there are, it checks if they are expired or will be expired. - If they are expired, it will be marked as PASS. - If they are not expired, it will be marked as FAIL and the severity will be critical if the key will never expire. + """Amazon Bedrock long-term API keys should not be used outside of exploration. + + AWS recommends short-term Bedrock API keys (session-scoped, valid up to 12 hours) + for any non-exploratory workload. ``ListServiceSpecificCredentials`` only enumerates + long-term keys, so every key inspected here is by definition a long-term credential. + + PASS when the long-term key has already expired (it can no longer authenticate). + FAIL (critical) when the key is configured to never expire. + FAIL (high) for any other active long-term key. """ def execute(self): - """ - Execute the Bedrock API key no long-term credentials check. - - Iterate over all the Bedrock API keys and check if they are expired or will be expired. - - Returns: - List[Check_Report_AWS]: A list of report objects with the results of the check. - """ - findings = [] for api_key in iam_client.service_specific_credentials: if api_key.service_name != "bedrock.amazonaws.com": continue - if api_key.expiration_date: - report = Check_Report_AWS(metadata=self.metadata(), resource=api_key) - # Check if the expiration date is in the future - if api_key.expiration_date > datetime.now(timezone.utc): - report.status = "FAIL" - # Get the days until the expiration date - days_until_expiration = ( - api_key.expiration_date - datetime.now(timezone.utc) - ).days - if days_until_expiration > 10000: - self.Severity = "critical" - report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists and never expires." - else: - report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists and will expire in {days_until_expiration} days." - else: - report.status = "PASS" - report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists but has expired." - findings.append(report) + if not api_key.expiration_date: + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=api_key) + now = datetime.now(timezone.utc) + + if api_key.expiration_date <= now: + report.status = "PASS" + report.status_extended = ( + f"Bedrock long-term API key {api_key.id} in user " + f"{api_key.user.name} has already expired and can no longer " + f"authenticate." + ) + elif (api_key.expiration_date - now).days > NEVER_EXPIRES_THRESHOLD_DAYS: + report.status = "FAIL" + report.check_metadata.Severity = Severity.critical + report.status_extended = ( + f"Bedrock long-term API key {api_key.id} in user " + f"{api_key.user.name} is configured to never expire. Use " + f"short-term Bedrock API keys (session-scoped, valid up to " + f"12 hours) for non-exploratory workloads instead." + ) + else: + days_until_expiration = (api_key.expiration_date - now).days + report.status = "FAIL" + report.status_extended = ( + f"Bedrock long-term API key {api_key.id} in user " + f"{api_key.user.name} is active and will expire in " + f"{days_until_expiration} days. Use short-term Bedrock API " + f"keys (session-scoped, valid up to 12 hours) for " + f"non-exploratory workloads instead." + ) + + findings.append(report) return findings diff --git a/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py b/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py index bf4f6381b9..d75d248c7c 100644 --- a/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py +++ b/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py @@ -3,466 +3,197 @@ from unittest import mock from moto import mock_aws +from prowler.lib.check.models import Severity from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider +BEDROCK_SERVICE = "bedrock.amazonaws.com" + + +def _make_user(name="test_user"): + from prowler.providers.aws.services.iam.iam_service import User + + return User( + name=name, + arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/{name}", + attached_policies=[], + inline_policies=[], + ) + + +def _make_credential( + user, + credential_id="test-credential-id", + expiration_delta_days=None, + service_name=BEDROCK_SERVICE, +): + from prowler.providers.aws.services.iam.iam_service import ServiceSpecificCredential + + expiration_date = ( + datetime.now(timezone.utc) + timedelta(days=expiration_delta_days) + if expiration_delta_days is not None + else None + ) + return ServiceSpecificCredential( + arn=( + f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/{user.name}/" + f"credential/{credential_id}" + ), + user=user, + status="Active", + create_date=datetime.now(timezone.utc), + service_user_name=None, + service_credential_alias=None, + expiration_date=expiration_date, + id=credential_id, + service_name=service_name, + region=AWS_REGION_US_EAST_1, + ) + + +def _run_check(credentials): + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + iam.service_specific_credentials = credentials + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", + new=iam, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( + bedrock_api_key_no_long_term_credentials, + ) + + check = bedrock_api_key_no_long_term_credentials() + return check.execute() + class Test_bedrock_api_key_no_long_term_credentials: @mock_aws def test_no_bedrock_api_keys(self): - from prowler.providers.aws.services.iam.iam_service import IAM - - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=IAM(aws_provider), - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 0 + assert _run_check([]) == [] @mock_aws - def test_bedrock_api_key_with_future_expiration_date(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_active_short_expiration_key_fails_high(self): + # Per AWS guidance, every active long-term key is a finding regardless of + # how soon it expires. Short remaining lifetime does not downgrade severity. + credential = _make_credential(_make_user(), expiration_delta_days=30) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with future expiration date - expiration_date = datetime.now(timezone.utc) + timedelta(days=30) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 1 - assert result[0].status == "FAIL" - assert "will expire in" in result[0].status_extended - assert "test-credential-id" in result[0].status_extended - assert "test_user" in result[0].status_extended - assert result[0].resource_id == "test-credential-id" - assert result[0].region == AWS_REGION_US_EAST_1 + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + assert "is active and will expire in" in result[0].status_extended + assert "short-term Bedrock API keys" in result[0].status_extended @mock_aws - def test_bedrock_api_key_with_critical_expiration_date(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_active_long_expiration_key_fails_high(self): + credential = _make_credential(_make_user(), expiration_delta_days=365) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with very far future expiration date (>10000 days) - expiration_date = datetime.now(timezone.utc) + timedelta(days=15000) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 1 - assert result[0].status == "FAIL" - assert "never expires" in result[0].status_extended - assert "test-credential-id" in result[0].status_extended - assert "test_user" in result[0].status_extended - assert result[0].resource_id == "test-credential-id" - assert result[0].region == AWS_REGION_US_EAST_1 - assert check.Severity == "critical" + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + assert "is active and will expire in" in result[0].status_extended @mock_aws - def test_bedrock_api_key_with_expired_date(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_never_expires_key_fails_critical(self): + # >10000 days approximates AWS's "no expiration" sentinel (~100 years). + credential = _make_credential(_make_user(), expiration_delta_days=15000) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with past expiration date - expiration_date = datetime.now(timezone.utc) - timedelta(days=30) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 1 - assert result[0].status == "PASS" - assert "has expired" in result[0].status_extended - assert "test-credential-id" in result[0].status_extended - assert "test_user" in result[0].status_extended - assert result[0].resource_id == "test-credential-id" - assert result[0].region == AWS_REGION_US_EAST_1 + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "configured to never expire" in result[0].status_extended + assert "short-term Bedrock API keys" in result[0].status_extended @mock_aws - def test_bedrock_api_key_without_expiration_date_ignored(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_already_expired_key_passes(self): + credential = _make_credential(_make_user(), expiration_delta_days=-30) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential without expiration date (should be ignored) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=None, # No expiration date - should be ignored - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 0 + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has already expired" in result[0].status_extended @mock_aws - def test_non_bedrock_api_key_ignored(self): - from prowler.providers.aws.services.iam.iam_service import IAM - - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential for a different service - expiration_date = datetime.now(timezone.utc) + timedelta(days=30) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="codecommit.amazonaws.com", # Different service - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 0 + def test_key_without_expiration_date_ignored(self): + credential = _make_credential(_make_user(), expiration_delta_days=None) + assert _run_check([credential]) == [] @mock_aws - def test_multiple_bedrock_api_keys_mixed_scenarios(self): - from prowler.providers.aws.services.iam.iam_service import IAM - - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, + def test_non_bedrock_service_ignored(self): + credential = _make_credential( + _make_user(), + expiration_delta_days=30, + service_name="codecommit.amazonaws.com", ) + assert _run_check([credential]) == [] - # Create mock users - mock_user1 = User( - name="test_user1", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user1", - attached_policies=[], - inline_policies=[], + @mock_aws + def test_mixed_scenarios(self): + user1, user2, user3 = ( + _make_user("u1"), + _make_user("u2"), + _make_user("u3"), ) - - mock_user2 = User( - name="test_user2", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user2", - attached_policies=[], - inline_policies=[], - ) - - mock_user3 = User( - name="test_user3", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user3", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with future expiration date - expiration_date1 = datetime.now(timezone.utc) + timedelta(days=30) - mock_credential1 = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user1/credential/test-credential-id-1", - user=mock_user1, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date1, - id="test-credential-id-1", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - # Create a mock service-specific credential with critical expiration date - expiration_date2 = datetime.now(timezone.utc) + timedelta(days=15000) - mock_credential2 = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user2/credential/test-credential-id-2", - user=mock_user2, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date2, - id="test-credential-id-2", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - # Create a mock service-specific credential with expired date - expiration_date3 = datetime.now(timezone.utc) - timedelta(days=30) - mock_credential3 = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user3/credential/test-credential-id-3", - user=mock_user3, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date3, - id="test-credential-id-3", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [ - mock_credential1, - mock_credential2, - mock_credential3, + credentials = [ + _make_credential(user1, "active-key", expiration_delta_days=191), + _make_credential(user2, "never-key", expiration_delta_days=15000), + _make_credential(user3, "expired-key", expiration_delta_days=-30), ] + result = _run_check(credentials) - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, + assert len(result) == 3 + by_id = {r.resource_id: r for r in result} + + assert by_id["active-key"].status == "FAIL" + assert by_id["active-key"].check_metadata.Severity == Severity.high + + assert by_id["never-key"].status == "FAIL" + assert by_id["never-key"].check_metadata.Severity == Severity.critical + + assert by_id["expired-key"].status == "PASS" + + @mock_aws + def test_severity_does_not_leak_never_then_active(self): + """Regression: a never-expires key processed before an active key must + not bleed `critical` severity into the active finding.""" + credentials = [ + _make_credential( + _make_user("u-never"), "never-key", expiration_delta_days=15000 ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, + _make_credential( + _make_user("u-active"), "active-key", expiration_delta_days=191 ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) + ] + result = _run_check(credentials) - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() + by_id = {r.resource_id: r for r in result} + assert by_id["never-key"].check_metadata.Severity == Severity.critical + assert by_id["active-key"].check_metadata.Severity == Severity.high - assert len(result) == 3 + @mock_aws + def test_severity_does_not_leak_active_then_never(self): + """Regression: same as above with the reverse iteration order.""" + credentials = [ + _make_credential( + _make_user("u-active"), "active-key", expiration_delta_days=191 + ), + _make_credential( + _make_user("u-never"), "never-key", expiration_delta_days=15000 + ), + ] + result = _run_check(credentials) - # Check the credential with future expiration date (FAIL) - fail_result1 = next( - r for r in result if r.resource_id == "test-credential-id-1" - ) - assert fail_result1.status == "FAIL" - assert "will expire in" in fail_result1.status_extended - assert "test-credential-id-1" in fail_result1.status_extended - assert "test_user1" in fail_result1.status_extended - - # Check the credential with critical expiration date (FAIL) - fail_result2 = next( - r for r in result if r.resource_id == "test-credential-id-2" - ) - assert fail_result2.status == "FAIL" - assert "never expires" in fail_result2.status_extended - assert "test-credential-id-2" in fail_result2.status_extended - assert "test_user2" in fail_result2.status_extended - - # Check the credential with expired date (PASS) - pass_result = next( - r for r in result if r.resource_id == "test-credential-id-3" - ) - assert pass_result.status == "PASS" - assert "has expired" in pass_result.status_extended - assert "test-credential-id-3" in pass_result.status_extended - assert "test_user3" in pass_result.status_extended + by_id = {r.resource_id: r for r in result} + assert by_id["active-key"].check_metadata.Severity == Severity.high + assert by_id["never-key"].check_metadata.Severity == Severity.critical From 285974b7d4e6b497f2862bd37d3ac7709d1d515f Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Thu, 11 Jun 2026 09:08:25 +0200 Subject: [PATCH 012/126] chore(changelog): v5.30.0 (#11540) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> --- api/CHANGELOG.md | 6 +++--- prowler/CHANGELOG.md | 10 +++++----- ui/CHANGELOG.md | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 6759b28741..8f3585fd20 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler API** are documented in this file. -## [1.31.0] (Prowler UNRELEASED) +## [1.31.0] (Prowler v5.30.0) ### 🚀 Added @@ -19,11 +19,11 @@ All notable changes to the **Prowler API** are documented in this file. - Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) - Resource `name` is now stored and refreshed on every scan, so resources no longer keep an empty name [(#11476)](https://github.com/prowler-cloud/prowler/pull/11476) -- Compliance catalog now warms in a background thread after each worker forks, and `compliance-overviews/attributes` returns `503` while warming, so the first request after a deploy no longer trips the Gunicorn worker timeout [(#4554)](https://github.com/prowler-cloud/prowler-cloud/pull/4554) +- Compliance catalog now warms in background during startup. `compliance-overviews/attributes` returns `503` while warming, so the first request after a deploy no longer trips the API timeout [(#4554)](https://github.com/prowler-cloud/prowler-cloud/pull/4554) ### 🔐 Security -- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) flagged by osv-scanner in `api/uv.lock` [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) +- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) --- diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 96ccb4bb29..bb206f5a96 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler SDK** are documented in this file. -## [5.30.0] (Prowler UNRELEASED) +## [5.30.0] (Prowler v5.30.0) ### 🚀 Added @@ -32,6 +32,10 @@ All notable changes to the **Prowler SDK** are documented in this file. - AWS CloudWatch log metric filter checks now validate `filterPattern` clauses regardless of order [(#11345)](https://github.com/prowler-cloud/prowler/pull/11345) - AWS `bedrock_api_key_no_long_term_credentials` now applies severity per finding (never-expires keys correctly flag as critical, no leak across findings) and aligns title and wording with AWS guidance to prefer short-term Bedrock API keys [(#11526)](https://github.com/prowler-cloud/prowler/pull/11526) +### 🔐 Security + +- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) + --- ## [5.29.3] (Prowler v5.29.3) @@ -43,10 +47,6 @@ All notable changes to the **Prowler SDK** are documented in this file. - Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474) - GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467) -### 🔐 Security - -- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) flagged by osv-scanner [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) - --- ## [5.29.1] (Prowler v5.29.1) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 57d0427dc9..2676778fef 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.30.0] (Prowler UNRELEASED) +## [1.30.0] (Prowler v5.30.0) ### 🚀 Added From f1d741214a60df17158c3fdc97804fd1fde64f3a Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:44:17 +0200 Subject: [PATCH 013/126] fix(ui): adapt risk pipeline sankey layout (#11527) --- ui/CHANGELOG.md | 4 + .../graphs/sankey-chart.layout.test.ts | 84 ++++++++++++++++ ui/components/graphs/sankey-chart.layout.ts | 70 +++++++++++++ ui/components/graphs/sankey-chart.test.tsx | 98 +++++++++++++++++++ ui/components/graphs/sankey-chart.tsx | 28 ++++-- 5 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 ui/components/graphs/sankey-chart.layout.test.ts create mode 100644 ui/components/graphs/sankey-chart.layout.ts create mode 100644 ui/components/graphs/sankey-chart.test.tsx diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 2676778fef..67a0a59e5d 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -14,6 +14,10 @@ All notable changes to the **Prowler UI** are documented in this file. - Renamed "Customer Support" to "Support Desk" in the side menu, showing it only in Prowler Cloud/Enterprise, while "Community Support" now shows only in Prowler OSS [(#11508)](https://github.com/prowler-cloud/prowler/pull/11508) - Compliance detail page now shows a "still loading" retry state while the API warms its compliance catalog, instead of rendering an empty page [(#4554)](https://github.com/prowler-cloud/prowler-cloud/pull/4554) +### 🐞 Fixed + +- Risk Pipeline Sankey chart now adapts height and node spacing for dense provider datasets, keeping provider and severity labels readable [(#11527)](https://github.com/prowler-cloud/prowler/pull/11527) + --- ## [1.29.3] (Prowler v5.29.3) diff --git a/ui/components/graphs/sankey-chart.layout.test.ts b/ui/components/graphs/sankey-chart.layout.test.ts new file mode 100644 index 0000000000..95fe9b624e --- /dev/null +++ b/ui/components/graphs/sankey-chart.layout.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { getSankeyLayoutConfig } from "./sankey-chart.layout"; + +describe("getSankeyLayoutConfig", () => { + it("keeps default size when provider count is at baseline", () => { + const config = getSankeyLayoutConfig({ + baseHeight: 460, + nodes: [ + { name: "AWS" }, + { name: "High" }, + { name: "Medium" }, + { name: "Low" }, + { name: "Azure" }, + { name: "Info" }, + { name: "GCP" }, + ], + links: [{ source: 0 }, { source: 4 }, { source: 6 }], + }); + + expect(config).toEqual({ + height: 460, + nodePadding: 50, + }); + }); + + it("increases height and reduces node padding for denser graphs", () => { + const config = getSankeyLayoutConfig({ + baseHeight: 460, + nodes: Array.from({ length: 24 }, (_, index) => ({ + name: `Provider ${index}`, + })), + links: [ + { source: 0 }, + { source: 1 }, + { source: 2 }, + { source: 3 }, + { source: 4 }, + { source: 5 }, + { source: 6 }, + { source: 7 }, + { source: 8 }, + { source: 9 }, + { source: 10 }, + { source: 11 }, + ], + }); + + expect(config).toEqual({ + height: 844, + nodePadding: 38, + }); + }); + + it("clamps padding to minimum when provider count is very large", () => { + const config = getSankeyLayoutConfig({ + baseHeight: 460, + nodes: Array.from({ length: 120 }, (_, index) => ({ + name: `Provider ${index}`, + })), + links: Array.from({ length: 100 }, (_, index) => ({ + source: index, + })), + }); + + expect(config.nodePadding).toBe(14); + expect(config.height).toBe(1400); + }); + + it("falls back to node-based provider estimation when no link sources exist", () => { + const config = getSankeyLayoutConfig({ + baseHeight: 460, + nodes: Array.from({ length: 8 }, (_, index) => ({ + name: `Node ${index}`, + })), + links: [], + }); + + expect(config).toEqual({ + height: 460, + nodePadding: 50, + }); + }); +}); diff --git a/ui/components/graphs/sankey-chart.layout.ts b/ui/components/graphs/sankey-chart.layout.ts new file mode 100644 index 0000000000..fd8ba39562 --- /dev/null +++ b/ui/components/graphs/sankey-chart.layout.ts @@ -0,0 +1,70 @@ +export interface SankeyNodeLike { + name: string; +} + +export interface SankeyLinkLike { + source: number; +} + +export interface SankeyLayoutInput { + baseHeight: number; + nodes: SankeyNodeLike[]; + links: SankeyLinkLike[]; +} + +export interface SankeyLayoutConfig { + height: number; + nodePadding: number; +} + +const SANKEY_DEFAULT_NODE_PADDING = 50; +const SANKEY_MIN_NODE_PADDING = 14; +const SANKEY_HEIGHT_GROWTH_PER_PROVIDER = 64; +const SANKEY_BASE_PROVIDER_COUNT = 6; +const SANKEY_MAX_HEIGHT = 1400; + +const getProviderNodeCount = ({ nodes, links }: SankeyLayoutInput): number => { + const uniqueSourceIndexes = new Set(); + + links.forEach((link) => { + uniqueSourceIndexes.add(link.source); + }); + + if (uniqueSourceIndexes.size > 0) { + return uniqueSourceIndexes.size; + } + + return Math.max(0, nodes.length - 5); +}; + +const getNodePaddingForProviderCount = (providerNodeCount: number): number => { + const compactedProviders = Math.max( + 0, + providerNodeCount - SANKEY_BASE_PROVIDER_COUNT, + ); + return Math.max( + SANKEY_MIN_NODE_PADDING, + Math.round(SANKEY_DEFAULT_NODE_PADDING - compactedProviders * 2), + ); +}; + +export const getSankeyLayoutConfig = ( + params: SankeyLayoutInput, +): SankeyLayoutConfig => { + const providerNodeCount = getProviderNodeCount(params); + const extraProviders = Math.max( + 0, + providerNodeCount - SANKEY_BASE_PROVIDER_COUNT, + ); + const dynamicHeight = Math.min( + SANKEY_MAX_HEIGHT, + Math.round( + params.baseHeight + extraProviders * SANKEY_HEIGHT_GROWTH_PER_PROVIDER, + ), + ); + + return { + height: dynamicHeight, + nodePadding: getNodePaddingForProviderCount(providerNodeCount), + }; +}; diff --git a/ui/components/graphs/sankey-chart.test.tsx b/ui/components/graphs/sankey-chart.test.tsx new file mode 100644 index 0000000000..48c9e01a04 --- /dev/null +++ b/ui/components/graphs/sankey-chart.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { CustomNode, SankeyChart } from "./sankey-chart"; +import { getSankeyLayoutConfig } from "./sankey-chart.layout"; + +const mockPush = vi.fn(); + +vi.mock("@/lib", () => ({ + applyFailNonMutedFilters: (filters: unknown) => filters, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush }), + useSearchParams: () => new URLSearchParams(), +})); + +describe("SankeyChart", () => { + it("uses layout-configured height for the empty-state container", () => { + const data = { + nodes: Array.from({ length: 14 }, (_, index) => ({ + name: `Node ${index}`, + })), + links: Array.from({ length: 12 }, (_, index) => ({ + source: index, + target: 13, + value: 0, + })), + }; + + const baseHeight = 460; + const layoutConfig = getSankeyLayoutConfig({ + baseHeight, + nodes: data.nodes, + links: data.links, + }); + + const { container } = render( + , + ); + + expect( + screen.getByText("No failed findings to display"), + ).toBeInTheDocument(); + expect(container.firstElementChild).toHaveStyle({ + height: `${layoutConfig.height}px`, + }); + }); + + it("renders risk and severity node labels with middle-aligned text", () => { + const x = 10; + const y = 20; + const width = 70; + const height = 80; + const nodeCenterY = y + height / 2; + + render( + + + , + ); + + const textElements = screen.getAllByText(/High|9/); + const nameLabel = textElements.find( + (element) => element.textContent === "High", + ); + const valueLabel = textElements.find( + (element) => element.textContent === "9", + ); + + expect(nameLabel).toBeDefined(); + expect(valueLabel).toBeDefined(); + + if (!nameLabel || !valueLabel) { + throw new Error( + "Expected both node name and value labels to be rendered.", + ); + } + + expect(nameLabel).toHaveAttribute("dominant-baseline", "middle"); + expect(valueLabel).toHaveAttribute("dominant-baseline", "middle"); + + const nameY = Number.parseFloat(nameLabel.getAttribute("y") || "0"); + const valueY = Number.parseFloat(valueLabel.getAttribute("y") || "0"); + + expect(nameY).toBeLessThan(nodeCenterY); + expect(valueY).toBeGreaterThan(nodeCenterY); + expect((nameY + valueY) / 2).toBeCloseTo(nodeCenterY); + }); +}); diff --git a/ui/components/graphs/sankey-chart.tsx b/ui/components/graphs/sankey-chart.tsx index 0a41d0e4e7..f65647d73e 100644 --- a/ui/components/graphs/sankey-chart.tsx +++ b/ui/components/graphs/sankey-chart.tsx @@ -11,6 +11,7 @@ import { initializeChartColors } from "@/lib/charts/colors"; import { PROVIDER_DISPLAY_NAMES } from "@/types/providers"; import { SEVERITY_FILTER_MAP } from "@/types/severities"; +import { getSankeyLayoutConfig } from "./sankey-chart.layout"; import { ChartTooltip } from "./shared/chart-tooltip"; // Reverse mapping from display name to provider type for URL filters @@ -73,6 +74,7 @@ interface NodeTooltipState { const TOOLTIP_OFFSET_PX = 10; const MIN_LINK_WIDTH = 4; +const NODE_LABEL_LINE_SPACING = 13; interface TooltipPayload { payload: { @@ -88,7 +90,7 @@ interface TooltipProps { payload?: TooltipPayload[]; } -interface CustomNodeProps { +export interface CustomNodeProps { x: number; y: number; width: number; @@ -148,7 +150,7 @@ const CustomTooltip = ({ active, payload }: TooltipProps) => { return null; }; -const CustomNode = ({ +export const CustomNode = ({ x, y, width, @@ -215,6 +217,10 @@ const CustomNode = ({ const iconSize = 24; const iconGap = 8; + const nodeCenterY = y + height / 2; + const nodeNameY = nodeCenterY - NODE_LABEL_LINE_SPACING / 2; + const nodeValueY = nodeCenterY + NODE_LABEL_LINE_SPACING / 2; + // Calculate text position accounting for icon const textOffsetX = isOut ? x - 6 : x + width + 6; const iconOffsetX = isOut @@ -260,8 +266,9 @@ const CustomNode = ({ : textOffsetX + iconSize + iconGap * 2 : textOffsetX } - y={y + height / 2} + y={nodeNameY} fontSize="14" + dominantBaseline="middle" fill="var(--color-text-neutral-primary)" > {nodeName} @@ -275,8 +282,9 @@ const CustomNode = ({ : textOffsetX + iconSize + iconGap * 2 : textOffsetX } - y={y + height / 2 + 13} + y={nodeValueY} fontSize="12" + dominantBaseline="middle" fill="var(--color-text-neutral-secondary)" > {payload.value} @@ -531,11 +539,17 @@ export function SankeyChart({ // Check if there's actual data to display (links with values > 0) const hasData = data.links.some((link) => link.value > 0); + const layoutConfig = getSankeyLayoutConfig({ + baseHeight: height, + nodes: data.nodes, + links: data.links, + }); + if (!hasData) { return (
@@ -549,12 +563,12 @@ export function SankeyChart({ return (
- + From c4378d5992137773fe7766fa12c97b82403ed396 Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Thu, 11 Jun 2026 15:28:25 +0200 Subject: [PATCH 014/126] chore(release): Bump versions to v5.31.0 (#11548) Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com> --- .env | 2 +- api/pyproject.toml | 2 +- api/src/backend/api/specs/v1.yaml | 2 +- api/uv.lock | 2 +- docs/getting-started/installation/prowler-app.mdx | 4 ++-- prowler/config/config.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.env b/.env index c6085538a0..c0364e29ae 100644 --- a/.env +++ b/.env @@ -145,7 +145,7 @@ SENTRY_RELEASE=local NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT} #### Prowler release version #### -NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.30.0 +NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.31.0 # Social login credentials SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google" diff --git a/api/pyproject.toml b/api/pyproject.toml index 5e18ffac20..2b34eb6e19 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -68,7 +68,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.31.0" +version = "1.32.0" [tool.uv] # Transitive pins matching master to avoid silent drift; bump deliberately. diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index d2a30ca897..18566c5c71 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Prowler API - version: 1.31.0 + version: 1.32.0 description: |- Prowler API specification. diff --git a/api/uv.lock b/api/uv.lock index 9689a9cd0a..bb42355e20 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -4499,7 +4499,7 @@ dependencies = [ [[package]] name = "prowler-api" -version = "1.31.0" +version = "1.32.0" source = { virtual = "." } dependencies = [ { name = "cartography" }, diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index dea9c09446..eea9075cda 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -128,8 +128,8 @@ To update the environment file: Edit the `.env` file and change version values: ```env -PROWLER_UI_VERSION="5.29.0" -PROWLER_API_VERSION="5.29.0" +PROWLER_UI_VERSION="5.30.0" +PROWLER_API_VERSION="5.30.0" ``` diff --git a/prowler/config/config.py b/prowler/config/config.py index 71c59d82a3..9e2b079da6 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -49,7 +49,7 @@ class _MutableTimestamp: timestamp = _MutableTimestamp(datetime.today()) timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc)) -prowler_version = "5.30.0" +prowler_version = "5.31.0" html_logo_url = "https://github.com/prowler-cloud/prowler/" square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png" aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png" diff --git a/pyproject.toml b/pyproject.toml index 28a4b531cc..41fec77a42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,7 +124,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"} name = "prowler" readme = "README.md" requires-python = ">=3.10,<3.13" -version = "5.30.0" +version = "5.31.0" [project.scripts] prowler = "prowler.__main__:prowler" diff --git a/uv.lock b/uv.lock index 3a66d2826b..be25aea7da 100644 --- a/uv.lock +++ b/uv.lock @@ -3245,7 +3245,7 @@ wheels = [ [[package]] name = "prowler" -version = "5.30.0" +version = "5.31.0" source = { editable = "." } dependencies = [ { name = "alibabacloud-actiontrail20200706" }, From 610febb5d56ed9e1d008bde9b43ff5f132d276a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Thu, 11 Jun 2026 15:53:44 +0200 Subject: [PATCH 015/126] fix(api): bump prowler SDK lock to v5.30.0 for okta_idaas_stig (#11553) --- api/uv.lock | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/api/uv.lock b/api/uv.lock index bb42355e20..d36993b516 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -4415,8 +4415,8 @@ wheels = [ [[package]] name = "prowler" -version = "5.27.0" -source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" } +version = "5.30.0" +source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f1d741214a60df17158c3fdc97804fd1fde64f3a" } dependencies = [ { name = "alibabacloud-actiontrail20200706" }, { name = "alibabacloud-credentials" }, @@ -4489,9 +4489,14 @@ dependencies = [ { name = "pygithub" }, { name = "python-dateutil" }, { name = "pytz" }, + { name = "scaleway" }, { name = "schema" }, { name = "shodan" }, { name = "slack-sdk" }, + { name = "stackit-core" }, + { name = "stackit-iaas" }, + { name = "stackit-objectstorage" }, + { name = "stackit-resourcemanager" }, { name = "tabulate" }, { name = "tzlocal" }, { name = "uuid6" }, @@ -5531,6 +5536,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, ] +[[package]] +name = "stackit-core" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/90/20f9ec7387eec4067cfd3d29055d0e2b5e1e0322c601a7f48125fd8ea35f/stackit_core-0.2.0.tar.gz", hash = "sha256:b8af91877cdb060d6969a303d8cf20bc0b33b345afd91f679c44a987381e2d47", size = 8987, upload-time = "2025-06-12T08:24:45.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b4/7b53187ce68956870d864ccb9ccfb68066c9df9de1c9568fd2feb03c4504/stackit_core-0.2.0-py3-none-any.whl", hash = "sha256:04632fc6742790d08ddfcb7f2313e04d1254827397a80250f838a2f81b92645b", size = 10240, upload-time = "2025-06-12T08:24:44.214Z" }, +] + +[[package]] +name = "stackit-iaas" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/07/24e65278300d5c3cb19cb1660bff924c80812cf8aad3e715f826bae5aa80/stackit_iaas-1.4.0.tar.gz", hash = "sha256:93523b23442350c7ebefd9129485c4c2a539f694a9c36a0f8edfaba9862057ea", size = 116236, upload-time = "2026-05-13T09:43:15.996Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/51/2201164d7bfacf47539888c735f10f6320c188252384957aa1b23121a210/stackit_iaas-1.4.0-py3-none-any.whl", hash = "sha256:3f4a32321b57ac238f73e5d660c6428186b92cc0425c1f0783ba801e377149d9", size = 316588, upload-time = "2026-05-13T09:43:14.943Z" }, +] + +[[package]] +name = "stackit-objectstorage" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/80/b790756af40a5c6d979dd688b2557394ac54b594eb4c08edc33157ba890f/stackit_objectstorage-1.4.0.tar.gz", hash = "sha256:4a3812b4de102b199f061706a802909f9e53ae9b0858769d5bd720f814c8bdbe", size = 31814, upload-time = "2026-05-13T09:43:05.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/f1/ffa8d5e2ec9f818c72a6f045691364eb4e927ee86641993a70882d00205a/stackit_objectstorage-1.4.0-py3-none-any.whl", hash = "sha256:1a3285c6840d95cff591d84fd21803575cb0d010c398e6575ed92987b9c39866", size = 65061, upload-time = "2026-05-13T09:43:04.13Z" }, +] + +[[package]] +name = "stackit-resourcemanager" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/2d/f458f18e48ed2b1c83df52cff7dbdfd5dd904fb2980ffd9385876e47bbd9/stackit_resourcemanager-0.8.0.tar.gz", hash = "sha256:f44542beab4130857f5a7f465cf02defeef657bdf63c1beeb3102f0ba3c003fe", size = 33943, upload-time = "2026-05-13T09:43:08.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" }, +] + [[package]] name = "statsd" version = "4.0.1" From ce27053c2dd2e97b7589402c83bcb78bb08d41e1 Mon Sep 17 00:00:00 2001 From: Zeus Almightee <53497290+ernestprovo23@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:53:28 -0400 Subject: [PATCH 016/126] feat(aws): add securityhub + config org-wide delegated admin checks (#11259) Co-authored-by: Lydia Vilchez --- prowler/CHANGELOG.md | 9 + .../__init__.py | 0 ...d_org_aggregator_all_regions.metadata.json | 44 ++ ...ed_admin_and_org_aggregator_all_regions.py | 115 ++++ .../aws/services/config/config_service.py | 131 +++++ .../__init__.py | 0 ...ed_admin_enabled_all_regions.metadata.json | 44 ++ ...hub_delegated_admin_enabled_all_regions.py | 84 +++ .../securityhub/securityhub_service.py | 101 +++- ...min_and_org_aggregator_all_regions_test.py | 491 +++++++++++++++++ ...elegated_admin_enabled_all_regions_test.py | 512 ++++++++++++++++++ 11 files changed, 1530 insertions(+), 1 deletion(-) create mode 100644 prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/__init__.py create mode 100644 prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json create mode 100644 prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py create mode 100644 prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/__init__.py create mode 100644 prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json create mode 100644 prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py create mode 100644 tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py create mode 100644 tests/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index bb206f5a96..62fbb35d30 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [5.31.0] (Prowler UNRELEASED) + +### 🚀 Added + +- `securityhub_delegated_admin_enabled_all_regions` check for AWS provider, verifying that Security Hub has a delegated administrator, is active in all opted-in regions, and has organization auto-enable on [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) +- `config_delegated_admin_and_org_aggregator_all_regions` check for AWS provider, verifying that AWS Config has a delegated administrator and an organization aggregator covering all AWS regions [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) + +--- + ## [5.30.0] (Prowler v5.30.0) ### 🚀 Added diff --git a/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/__init__.py b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json new file mode 100644 index 0000000000..cbbd7a66dd --- /dev/null +++ b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "config_delegated_admin_and_org_aggregator_all_regions", + "CheckTitle": "AWS Config has a delegated administrator and an organization aggregator covering all AWS regions", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "config", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsConfigConfigurationAggregator", + "ResourceGroup": "governance", + "Description": "**AWS Config** has a delegated administrator registered via AWS Organizations and at least one Configuration Aggregator with an OrganizationAggregationSource that covers all AWS regions, ensuring centralized org-wide configuration visibility.", + "Risk": "Without an org-wide **AWS Config** aggregator and a delegated administrator, configuration data is fragmented across accounts and regions, **compliance reporting** is incomplete, and **drift detection** is delayed. Adversaries or misconfigurations can persist in unmonitored accounts, eroding **audit readiness** and **regulatory posture**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/config/latest/developerguide/aggregate-data.html", + "https://docs.aws.amazon.com/config/latest/developerguide/set-up-aggregator-cli.html", + "https://docs.aws.amazon.com/organizations/latest/userguide/services-that-can-integrate-config.html" + ], + "Remediation": { + "Code": { + "CLI": "aws organizations register-delegated-administrator --account-id --service-principal config.amazonaws.com && aws configservice put-configuration-aggregator --configuration-aggregator-name org-aggregator --organization-aggregation-source RoleArn=,AllAwsRegions=true", + "NativeIaC": "", + "Other": "1. From the AWS Organizations management account, register the delegated administrator for config.amazonaws.com\n2. In the delegated admin account, open AWS Config\n3. Create a Configuration Aggregator and select Add my organization as the source\n4. Enable Include all AWS Regions\n5. Confirm an IAM role with AWSConfigRoleForOrganizations is attached\n6. Verify the aggregator status reaches SUCCEEDED for all member accounts", + "Terraform": "" + }, + "Recommendation": { + "Text": "Register a **delegated administrator** for AWS Config via AWS Organizations and create at least one **Configuration Aggregator** with an OrganizationAggregationSource that covers **all AWS regions**. This centralizes configuration data across the organization for unified compliance and audit reporting.", + "Url": "https://hub.prowler.com/check/config_delegated_admin_and_org_aggregator_all_regions" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [ + "config_recorder_all_regions_enabled", + "guardduty_delegated_admin_enabled_all_regions" + ], + "Notes": "This check requires execution from the organization management account or delegated administrator account to access organization-level APIs." +} diff --git a/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py new file mode 100644 index 0000000000..f56ea191f8 --- /dev/null +++ b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py @@ -0,0 +1,115 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.config.config_client import config_client +from prowler.providers.aws.services.config.config_service import Aggregator + + +class config_delegated_admin_and_org_aggregator_all_regions(Check): + """Ensure AWS Config has a delegated admin and an org aggregator covering all regions. + + This check verifies that: + 1. A delegated administrator is registered for the config.amazonaws.com + service principal via AWS Organizations. + 2. At least one AWS Config Configuration Aggregator exists with an + OrganizationAggregationSource that covers all AWS regions + (AllAwsRegions=true). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. One finding per + aggregator-region, or a single synthetic FAIL when no aggregators + exist in any region. + """ + findings = [] + + has_delegated_admin = ( + bool(config_client.delegated_administrators) + and not config_client.delegated_administrators_lookup_failed + ) + delegated_admin_unknown = config_client.delegated_administrators_lookup_failed + + # No aggregators in any region: emit one synthetic FAIL anchored to the + # audited account in the default region. + if not config_client.aggregators: + synthetic = Aggregator( + name="unknown", + arn=config_client.get_unknown_arn( + region=config_client.region, + resource_type="config-aggregator", + ), + region=config_client.region, + all_aws_regions=False, + aws_regions=None, + organization_aggregation_source_present=False, + ) + report = Check_Report_AWS(metadata=self.metadata(), resource=synthetic) + if delegated_admin_unknown: + delegated_state = ( + "delegated administrator status could not be determined" + ) + elif has_delegated_admin: + delegated_state = "delegated administrator configured" + else: + delegated_state = ( + "no delegated administrator registered for config.amazonaws.com" + ) + report.status = "FAIL" + report.status_extended = ( + f"AWS Config has no Organization Aggregator configured in any " + f"region ({delegated_state})." + ) + findings.append(report) + return findings + + for region, aggregators_in_region in config_client.aggregators.items(): + for aggregator in aggregators_in_region: + report = Check_Report_AWS(metadata=self.metadata(), resource=aggregator) + + org_aware = aggregator.organization_aggregation_source_present + covers_all = aggregator.all_aws_regions + + issues = [] + if delegated_admin_unknown: + issues.append( + "delegated administrator status for config.amazonaws.com " + "could not be determined" + ) + elif not has_delegated_admin: + issues.append( + "no delegated administrator registered for config.amazonaws.com" + ) + if not org_aware: + issues.append( + f"aggregator {aggregator.name} is not an organization aggregator" + ) + elif not covers_all: + issues.append( + f"aggregator {aggregator.name} does not cover all AWS regions" + ) + + if issues: + report.status = "FAIL" + report.status_extended = ( + f"AWS Config aggregator {aggregator.name} in region " + f"{region} has issues: {', '.join(issues)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"AWS Config aggregator {aggregator.name} in region " + f"{region} is an organization aggregator covering all " + f"AWS regions with delegated admin configured." + ) + + # Support muting non-default regions if configured + if report.status == "FAIL" and ( + config_client.audit_config.get("mute_non_default_regions", False) + and region != config_client.region + ): + report.muted = True + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/config/config_service.py b/prowler/providers/aws/services/config/config_service.py index 443cee2233..85ab22fd8e 100644 --- a/prowler/providers/aws/services/config/config_service.py +++ b/prowler/providers/aws/services/config/config_service.py @@ -1,5 +1,6 @@ from typing import Optional +from botocore.client import ClientError from pydantic.v1 import BaseModel from prowler.lib.logger import logger @@ -12,10 +13,16 @@ class Config(AWSService): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.recorders = {} + self.aggregators: dict[str, list] = {} + self.delegated_administrators: list = [] + self.delegated_administrators_lookup_failed: bool = False self.__threading_call__(self.describe_configuration_recorders) self.__threading_call__( self._describe_configuration_recorder_status, self.recorders.values() ) + self.__threading_call__(self._describe_configuration_aggregators) + # Organizations API is not regional; single call. + self._list_config_delegated_administrators() def _get_recorder_arn_template(self, region): return f"arn:{self.audited_partition}:config:{region}:{self.audited_account}:recorder" @@ -73,6 +80,108 @@ class Config(AWSService): f"{recorder.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _describe_configuration_aggregators(self, regional_client): + """Describe AWS Config configuration aggregators per region. + + An aggregator counts as organization-aware when its + OrganizationAggregationSource key is present in the response. + """ + logger.info("Config - Describing Configuration Aggregators...") + try: + paginator = regional_client.get_paginator( + "describe_configuration_aggregators" + ) + region_aggregators: list = [] + for page in paginator.paginate(): + for aggregator in page.get("ConfigurationAggregators", []): + name = aggregator.get("ConfigurationAggregatorName", "") + arn = aggregator.get("ConfigurationAggregatorArn", "") + org_source = aggregator.get("OrganizationAggregationSource") + org_aware = org_source is not None + all_aws_regions = False + aws_regions: Optional[list] = None + if org_aware: + all_aws_regions = org_source.get("AllAwsRegions", False) + aws_regions = org_source.get("AwsRegions") + if not self.audit_resources or ( + is_resource_filtered(arn, self.audit_resources) + ): + region_aggregators.append( + Aggregator( + name=name, + arn=arn, + region=regional_client.region, + all_aws_regions=all_aws_regions, + aws_regions=aws_regions, + organization_aggregation_source_present=org_aware, + ) + ) + if region_aggregators: + self.aggregators[regional_client.region] = region_aggregators + except ClientError as error: + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "AccessDenied", + ): + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _list_config_delegated_administrators(self): + """List delegated administrators for the AWS Config service principal. + + Uses the Organizations API directly (not regional). Sets + delegated_administrators_lookup_failed to True on AccessDenied so callers + can surface the unknown delegated-admin state in findings. + """ + logger.info( + "Config - Listing delegated administrators for config.amazonaws.com..." + ) + try: + org_client = self.session.client("organizations") + paginator = org_client.get_paginator("list_delegated_administrators") + for page in paginator.paginate(ServicePrincipal="config.amazonaws.com"): + for admin in page.get("DelegatedAdministrators", []): + self.delegated_administrators.append( + ConfigDelegatedAdministrator( + id=admin.get("Id", ""), + arn=admin.get("Arn", ""), + name=admin.get("Name", ""), + email=admin.get("Email", ""), + status=admin.get("Status", ""), + joined_method=admin.get("JoinedMethod", ""), + ) + ) + except ClientError as error: + error_code = error.response["Error"]["Code"] + if error_code in ( + "AccessDeniedException", + "AccessDenied", + "AWSOrganizationsNotInUseException", + ): + self.delegated_administrators_lookup_failed = True + logger.warning( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + self.delegated_administrators_lookup_failed = True + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self.delegated_administrators_lookup_failed = True + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + class Recorder(BaseModel): name: str @@ -80,3 +189,25 @@ class Recorder(BaseModel): recording: Optional[bool] last_status: Optional[str] region: str + + +class Aggregator(BaseModel): + """Represents an AWS Config Configuration Aggregator.""" + + name: str + arn: str + region: str + all_aws_regions: bool = False + aws_regions: Optional[list] = None + organization_aggregation_source_present: bool = False + + +class ConfigDelegatedAdministrator(BaseModel): + """Represents a delegated administrator registered for config.amazonaws.com.""" + + id: str + arn: str + name: str + email: str + status: str + joined_method: str diff --git a/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/__init__.py b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json new file mode 100644 index 0000000000..99748671ae --- /dev/null +++ b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "securityhub_delegated_admin_enabled_all_regions", + "CheckTitle": "Security Hub has delegated admin configured and is enabled in all regions with organization auto-enable", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "securityhub", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsSecurityHubHub", + "ResourceGroup": "security", + "Description": "**AWS Security Hub** has a delegated administrator configured at the organization level, hubs are active in all opted-in regions, and organization auto-enable is active so that new member accounts are automatically enrolled.", + "Risk": "Without org-wide **AWS Security Hub** configuration, findings can be aggregated inconsistently, delegated admin may be missing in some regions, and new accounts will not be auto-enrolled. This fragments **security posture visibility**, delays **incident response**, and lets misconfigurations and compliance drift go undetected across the organization.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/securityhub/latest/userguide/designate-orgs-admin-account.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/accounts-orgs-auto-enable.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-regions.html" + ], + "Remediation": { + "Code": { + "CLI": "aws securityhub enable-organization-admin-account --admin-account-id && aws securityhub update-organization-configuration --auto-enable --auto-enable-standards DEFAULT", + "NativeIaC": "", + "Other": "1. Sign in to the AWS Organizations management account\n2. Open the AWS Organizations console\n3. Navigate to Services > AWS Security Hub\n4. Click Register delegated administrator and enter the security account ID\n5. Switch to the delegated admin account\n6. In Security Hub console, go to Settings > Accounts\n7. Enable auto-enable for new organization accounts\n8. Repeat hub enablement for all opted-in regions", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a **delegated administrator** for AWS Security Hub via AWS Organizations. Enable Security Hub in **all opted-in regions** and turn on **auto-enable** so new member accounts are automatically enrolled. This ensures uniform security posture monitoring across the entire organization.", + "Url": "https://hub.prowler.com/check/securityhub_delegated_admin_enabled_all_regions" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [ + "securityhub_enabled", + "guardduty_delegated_admin_enabled_all_regions" + ], + "Notes": "This check requires execution from the organization management account or delegated administrator account to access organization-level APIs." +} diff --git a/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py new file mode 100644 index 0000000000..4828752183 --- /dev/null +++ b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py @@ -0,0 +1,84 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.securityhub.securityhub_client import ( + securityhub_client, +) + + +class securityhub_delegated_admin_enabled_all_regions(Check): + """Ensure Security Hub has a delegated admin and is enabled in all regions. + + This check verifies that: + 1. A delegated administrator account is configured for Security Hub + 2. Security Hub is active (ACTIVE status) in each region + 3. Organization auto-enable is configured for new member accounts + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check for each region. + """ + findings = [] + + # Build a set of regions that have an organization admin account configured + regions_with_admin = { + admin.region + for admin in securityhub_client.organization_admin_accounts + if admin.admin_status == "ENABLED" + } + admin_lookup_failed = securityhub_client.organization_admin_lookup_failed + + for securityhub in securityhub_client.securityhubs: + report = Check_Report_AWS(metadata=self.metadata(), resource=securityhub) + + # Check if this region has a delegated admin + has_delegated_admin = securityhub.region in regions_with_admin + + # Check if hub is active + hub_active = securityhub.status == "ACTIVE" + + # Check if auto-enable is configured for organization members + auto_enable_on = securityhub.organization_auto_enable + + # Determine overall status + issues = [] + if admin_lookup_failed: + issues.append("delegated administrator status could not be determined") + elif not has_delegated_admin: + issues.append("no delegated administrator configured") + if not hub_active: + issues.append("Security Hub not enabled") + if ( + hub_active + and securityhub.organization_config_available + and not auto_enable_on + ): + # Only report auto-enable issue if hub is active and org config data + # is available (i.e., we could actually read AutoEnable from the API). + issues.append("organization auto-enable not configured") + + if issues: + report.status = "FAIL" + report.status_extended = ( + f"Security Hub in region {securityhub.region} has issues: " + f"{', '.join(issues)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Security Hub in region {securityhub.region} has delegated " + f"admin configured with hub active and organization auto-enable " + f"enabled." + ) + + # Support muting non-default regions if configured + if report.status == "FAIL" and ( + securityhub_client.audit_config.get("mute_non_default_regions", False) + and securityhub.region != securityhub_client.region + ): + report.muted = True + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/securityhub/securityhub_service.py b/prowler/providers/aws/services/securityhub/securityhub_service.py index 0799c6e048..5e2d445742 100644 --- a/prowler/providers/aws/services/securityhub/securityhub_service.py +++ b/prowler/providers/aws/services/securityhub/securityhub_service.py @@ -13,8 +13,14 @@ class SecurityHub(AWSService): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.securityhubs = [] + self.organization_admin_accounts = [] + self.organization_admin_lookup_failed: bool = False self.__threading_call__(self._describe_hub) self.__threading_call__(self._list_tags, self.securityhubs) + self.__threading_call__(self._list_organization_admin_accounts) + self.__threading_call__( + self._describe_organization_configuration, self.securityhubs + ) def _describe_hub(self, regional_client): logger.info("SecurityHub - Describing Hub...") @@ -104,6 +110,95 @@ class SecurityHub(AWSService): f"{resource.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_organization_admin_accounts(self, regional_client): + """List Security Hub delegated administrator accounts for the organization. + + This API is only available to the organization management account or + a delegated administrator account. + """ + logger.info("SecurityHub - listing organization admin accounts...") + try: + paginator = regional_client.get_paginator( + "list_organization_admin_accounts" + ) + for page in paginator.paginate(): + for admin in page.get("AdminAccounts", []): + admin_account = OrganizationAdminAccount( + admin_account_id=admin.get("AdminAccountId"), + admin_status=admin.get("AdminStatus"), + region=regional_client.region, + ) + # Avoid duplicates across regions for the same admin account + if not any( + existing.admin_account_id == admin_account.admin_account_id + and existing.region == admin_account.region + for existing in self.organization_admin_accounts + ): + self.organization_admin_accounts.append(admin_account) + except ClientError as error: + self.organization_admin_lookup_failed = True + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "InvalidAccessException", + "BadRequestException", + ): + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self.organization_admin_lookup_failed = True + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _describe_organization_configuration(self, securityhub): + """Describe the organization configuration for a Security Hub instance. + + This provides information about auto-enable settings for the organization. + Only invoked for hubs in ACTIVE status. + """ + logger.info("SecurityHub - describing organization configuration...") + try: + if securityhub.status != "ACTIVE": + return + regional_client = self.regional_clients[securityhub.region] + org_config = regional_client.describe_organization_configuration() + securityhub.organization_auto_enable = org_config.get("AutoEnable", False) + securityhub.auto_enable_standards = org_config.get( + "AutoEnableStandards", "NONE" + ) + securityhub.organization_config_available = True + except ClientError as error: + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "InvalidAccessException", + "BadRequestException", + ): + # Expected when not running from management or delegated admin account + logger.warning( + f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class OrganizationAdminAccount(BaseModel): + """Represents a Security Hub delegated administrator account.""" + + admin_account_id: str + admin_status: str # ENABLED or DISABLE_IN_PROGRESS + region: str + class SecurityHubHub(BaseModel): arn: str @@ -112,4 +207,8 @@ class SecurityHubHub(BaseModel): standards: str integrations: str region: str - tags: Optional[list] + tags: Optional[list] = [] + # Organization configuration fields + organization_auto_enable: bool = False + auto_enable_standards: str = "NONE" + organization_config_available: bool = False diff --git a/tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py b/tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py new file mode 100644 index 0000000000..f2acf555d1 --- /dev/null +++ b/tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py @@ -0,0 +1,491 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +orig = botocore.client.BaseClient._make_api_call + + +AGG_ARN_TEMPLATE = ( + "arn:aws:config:{region}:" + AWS_ACCOUNT_NUMBER + ":config-aggregator/{name}" +) + + +def _aggregator_payload( + name, region, *, org_aware=True, all_regions=True, aws_regions=None +): + payload = { + "ConfigurationAggregatorName": name, + "ConfigurationAggregatorArn": AGG_ARN_TEMPLATE.format(region=region, name=name), + } + if org_aware: + org_source = { + "RoleArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AWSConfigRoleForOrganizations", + "AllAwsRegions": all_regions, + } + if not all_regions and aws_regions: + org_source["AwsRegions"] = aws_regions + payload["OrganizationAggregationSource"] = org_source + return payload + + +def make_mock_no_aggregators_no_admin(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return {"ConfigurationAggregators": []} + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregator_not_org_aware(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "legacy-agg", + AWS_REGION_EU_WEST_1, + org_aware=False, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_org_aggregator_not_all_regions_with_admin(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "partial-org-agg", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=False, + aws_regions=[AWS_REGION_EU_WEST_1], + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + return { + "DelegatedAdministrators": [ + { + "Id": "123456789012", + "Arn": f"arn:aws:organizations::{AWS_ACCOUNT_NUMBER}:account/o-abc123/123456789012", + "Email": "admin@example.com", + "Name": "Security", + "Status": "ACTIVE", + "JoinedMethod": "CREATED", + } + ] + } + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_full_pass(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "org-aggregator", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=True, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + return { + "DelegatedAdministrators": [ + { + "Id": "123456789012", + "Arn": f"arn:aws:organizations::{AWS_ACCOUNT_NUMBER}:account/o-abc123/123456789012", + "Email": "admin@example.com", + "Name": "Security", + "Status": "ACTIVE", + "JoinedMethod": "CREATED", + } + ] + } + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_access_denied_on_orgs(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "org-aggregator", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=True, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform: organizations:ListDelegatedAdministrators", + } + }, + operation_name, + ) + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregators_access_denied(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "denied", + } + }, + operation_name, + ) + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregators_other_client_error(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "InternalServerError", + "Message": "boom", + } + }, + operation_name, + ) + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregators_unexpected_exception(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + raise RuntimeError("simulated transient error") + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_delegated_admins_unexpected_exception(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "org-aggregator", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=True, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + raise RuntimeError("simulated transient error") + return orig(self, operation_name, api_params) + + return _mock + + +class Test_config_delegated_admin_and_org_aggregator_all_regions: + @mock_aws + def test_no_aggregators_no_admin(self): + """Test when no aggregators exist in any region and no delegated admin is set.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_no_aggregators_no_admin(), + ): + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "no Organization Aggregator configured in any region" + in result[0].status_extended + ) + assert ( + "no delegated administrator registered for config.amazonaws.com" + in result[0].status_extended + ) + + @mock_aws + def test_aggregator_not_org_aware(self): + """Test when an aggregator exists but is not an organization aggregator.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregator_not_org_aware(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "is not an organization aggregator" + in eu_west_1_result.status_extended + ) + + @mock_aws + def test_org_aggregator_not_all_regions_with_admin(self): + """Test org aggregator that doesn't cover all AWS regions (delegated admin set).""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_org_aggregator_not_all_regions_with_admin(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "does not cover all AWS regions" in eu_west_1_result.status_extended + ) + + @mock_aws + def test_full_pass(self): + """Test PASS: delegated admin set and org aggregator covering all AWS regions.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_full_pass(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "PASS" + assert ( + "is an organization aggregator covering all AWS regions" + in eu_west_1_result.status_extended + ) + assert "delegated admin configured" in eu_west_1_result.status_extended + assert eu_west_1_result.resource_arn == AGG_ARN_TEMPLATE.format( + region=AWS_REGION_EU_WEST_1, name="org-aggregator" + ) + + @mock_aws + def test_access_denied_on_organizations(self): + """Test that AccessDenied on Organizations is reported as unknown admin state.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_access_denied_on_orgs(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + # The check still runs; aggregator coverage is satisfied but the + # delegated-admin status is unknown, so it must FAIL. + assert eu_west_1_result.status == "FAIL" + assert ( + "delegated administrator status for config.amazonaws.com could not be determined" + in eu_west_1_result.status_extended + ) + + @mock_aws + def test_aggregators_access_denied(self): + """AccessDenied on DescribeConfigurationAggregators is swallowed: no aggregators recorded for that region.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregators_access_denied(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.aggregators == {} + + @mock_aws + def test_aggregators_other_client_error(self): + """Non-access ClientError on DescribeConfigurationAggregators is logged at error level.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregators_other_client_error(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.aggregators == {} + + @mock_aws + def test_aggregators_unexpected_exception(self): + """Non-ClientError on DescribeConfigurationAggregators is caught by bare except.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregators_unexpected_exception(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.aggregators == {} + + @mock_aws + def test_delegated_admins_unexpected_exception(self): + """Non-ClientError on ListDelegatedAdministrators must still set lookup_failed.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_delegated_admins_unexpected_exception(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.delegated_administrators_lookup_failed is True + assert service.delegated_administrators == [] diff --git a/tests/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py b/tests/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py new file mode 100644 index 0000000000..01af147e9a --- /dev/null +++ b/tests/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py @@ -0,0 +1,512 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +orig = botocore.client.BaseClient._make_api_call + +HUB_ARN = f"arn:aws:securityhub:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:hub/default" + + +def _active_hub_responses(operation_name): + """Return a moto-friendly response for hub-describing API calls. + + Returns None if the operation is not one of the hub APIs (so the caller + can fall back to the default behavior). + """ + if operation_name == "DescribeHub": + return { + "HubArn": HUB_ARN, + "SubscribedAt": "2024-01-01T00:00:00.000Z", + "AutoEnableControls": True, + } + if operation_name == "GetEnabledStandards": + return {"StandardsSubscriptions": []} + if operation_name == "ListEnabledProductsForImport": + return {"ProductSubscriptions": []} + if operation_name == "ListTagsForResource": + return {"Tags": {}} + return None + + +def mock_make_api_call_org_admin_and_config(self, operation_name, api_params): + """Mock organization admin accounts and configuration APIs - PASS scenario.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + { + "AdminAccountId": "123456789012", + "AdminStatus": "ENABLED", + } + ] + } + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnable": True, + "AutoEnableStandards": "DEFAULT", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_org_admin_no_auto_enable(self, operation_name, api_params): + """Mock organization admin configured but auto-enable disabled.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + { + "AdminAccountId": "123456789012", + "AdminStatus": "ENABLED", + } + ] + } + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnable": False, + "AutoEnableStandards": "NONE", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_no_org_admin(self, operation_name, api_params): + """Mock no organization admin configured.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return {"AdminAccounts": []} + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnable": False, + "AutoEnableStandards": "NONE", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_securityhub_not_subscribed(self, operation_name, api_params): + """Simulate Security Hub not subscribed in the account (InvalidAccessException).""" + if operation_name == "DescribeHub": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "InvalidAccessException", + "Message": "Account is not subscribed to AWS Security Hub", + } + }, + operation_name, + ) + if operation_name == "ListOrganizationAdminAccounts": + return {"AdminAccounts": []} + return orig(self, operation_name, api_params) + + +def mock_make_api_call_admin_lookup_access_denied(self, operation_name, api_params): + """Hub is ACTIVE but ListOrganizationAdminAccounts is denied — lookup-failed path.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform: securityhub:ListOrganizationAdminAccounts", + } + }, + operation_name, + ) + if operation_name == "DescribeOrganizationConfiguration": + return {"AutoEnable": True, "AutoEnableStandards": "DEFAULT"} + return orig(self, operation_name, api_params) + + +def mock_make_api_call_admin_lookup_unexpected(self, operation_name, api_params): + """ListOrganizationAdminAccounts raises a non-ClientError — bare Exception branch.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + raise RuntimeError("simulated transient error") + if operation_name == "DescribeOrganizationConfiguration": + return {"AutoEnable": True, "AutoEnableStandards": "DEFAULT"} + return orig(self, operation_name, api_params) + + +def mock_make_api_call_describe_org_config_other_client_error( + self, operation_name, api_params +): + """DescribeOrganizationConfiguration raises a non-access ClientError — else branch.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + {"AdminAccountId": "123456789012", "AdminStatus": "ENABLED"} + ] + } + if operation_name == "DescribeOrganizationConfiguration": + raise botocore.exceptions.ClientError( + {"Error": {"Code": "InternalServerError", "Message": "boom"}}, + operation_name, + ) + return orig(self, operation_name, api_params) + + +def mock_make_api_call_describe_org_config_unexpected(self, operation_name, api_params): + """DescribeOrganizationConfiguration raises a non-ClientError — bare Exception branch.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + {"AdminAccountId": "123456789012", "AdminStatus": "ENABLED"} + ] + } + if operation_name == "DescribeOrganizationConfiguration": + raise RuntimeError("simulated transient error") + return orig(self, operation_name, api_params) + + +class Test_securityhub_delegated_admin_enabled_all_regions: + def teardown_method(self): + """Evict cached securityhub modules so legacy mock.patch-based tests + in the same session see a fresh import path.""" + import sys + + for mod in ( + "prowler.providers.aws.services.securityhub.securityhub_client", + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions", + ): + sys.modules.pop(mod, None) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_securityhub_not_subscribed, + ) + @mock_aws + def test_no_securityhub(self): + """Test when Security Hub is not subscribed in any region.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + # Should have findings for each region (with NOT_AVAILABLE hubs) + assert len(result) > 0 + # All should fail since hub is not enabled + for finding in result: + assert finding.status == "FAIL" + assert "Security Hub not enabled" in finding.status_extended + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_no_org_admin, + ) + @mock_aws + def test_securityhub_enabled_no_delegated_admin(self): + """Test when Security Hub is enabled but no delegated admin is configured.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "no delegated administrator configured" + in eu_west_1_result.status_extended + ) + assert eu_west_1_result.resource_arn == HUB_ARN + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_org_admin_no_auto_enable, + ) + @mock_aws + def test_securityhub_enabled_with_admin_no_auto_enable(self): + """Test when Security Hub is enabled with delegated admin but auto-enable is off.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "organization auto-enable not configured" + in eu_west_1_result.status_extended + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_org_admin_and_config, + ) + @mock_aws + def test_securityhub_enabled_with_admin_and_auto_enable(self): + """Test when Security Hub is enabled with delegated admin and auto-enable on (PASS).""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "PASS" + assert "delegated admin configured" in eu_west_1_result.status_extended + assert "auto-enable" in eu_west_1_result.status_extended + assert eu_west_1_result.resource_arn == HUB_ARN + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_admin_lookup_access_denied, + ) + @mock_aws + def test_admin_lookup_access_denied(self): + """AccessDenied on ListOrganizationAdminAccounts must FAIL with unknown-admin message.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "delegated administrator status could not be determined" + in eu_west_1_result.status_extended + ) + assert ( + "no delegated administrator configured" + not in eu_west_1_result.status_extended + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_admin_lookup_unexpected, + ) + @mock_aws + def test_admin_lookup_unexpected_exception(self): + """Non-ClientError raised from ListOrganizationAdminAccounts still sets lookup_failed.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + service = SecurityHub(aws_provider) + assert service.organization_admin_lookup_failed is True + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=service, + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + result = securityhub_delegated_admin_enabled_all_regions().execute() + assert result and result[0].status == "FAIL" + assert ( + "delegated administrator status could not be determined" + in result[0].status_extended + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_describe_org_config_other_client_error, + ) + @mock_aws + def test_describe_org_config_other_client_error(self): + """Non-access ClientError on DescribeOrganizationConfiguration is logged at error level.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + service = SecurityHub(aws_provider) + # organization_config_available stays False, so the auto-enable issue is suppressed + assert service.securityhubs[0].organization_config_available is False + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=service, + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + result = securityhub_delegated_admin_enabled_all_regions().execute() + # Admin is configured and hub is active; with org config unavailable the + # check should PASS because there are no other detectable issues. + assert result and result[0].status == "PASS" + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_describe_org_config_unexpected, + ) + @mock_aws + def test_describe_org_config_unexpected_exception(self): + """Non-ClientError on DescribeOrganizationConfiguration is caught by bare except.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + service = SecurityHub(aws_provider) + assert service.securityhubs[0].organization_config_available is False + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=service, + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + result = securityhub_delegated_admin_enabled_all_regions().execute() + assert result and result[0].status == "PASS" From 65f00a197bbbe203f068d854d24935f2265deb3d Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:32:28 +0200 Subject: [PATCH 017/126] fix(api): normalize OCI scan region credentials (#11558) --- api/CHANGELOG.md | 8 ++++++++ api/src/backend/api/tests/test_utils.py | 24 ++++++++++++++++++++++++ api/src/backend/api/utils.py | 6 ++++++ 3 files changed, 38 insertions(+) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 8f3585fd20..1756fe83a4 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.31.1] (Prowler UNRELEASED) + +### 🐞 Fixed + +- OCI scans now use API key credentials with the configured region instead of falling back to `/home/prowler/.oci/config` [(#11558)](https://github.com/prowler-cloud/prowler/pull/11558) + +--- + ## [1.31.0] (Prowler v5.30.0) ### 🚀 Added diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 3a7f030a9c..3fd9235dac 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -357,6 +357,30 @@ class TestGetProwlerProviderKwargs: expected_result = {**secret_dict, **expected_extra_kwargs} assert result == expected_result + def test_get_prowler_provider_kwargs_oraclecloud_converts_region_string_to_set( + self, + ): + secret_dict = { + "user": "ocid1.user.oc1..fake", + "fingerprint": "00:11:22:33:44:55:66:77", + "key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----", + "tenancy": "ocid1.tenancy.oc1..fake", + "region": "us-ashburn-1", + "pass_phrase": "fake-passphrase", + } + secret_mock = MagicMock() + secret_mock.secret = secret_dict + + provider = MagicMock() + provider.provider = Provider.ProviderChoices.ORACLECLOUD.value + provider.secret = secret_mock + provider.uid = "ocid1.tenancy.oc1..fake" + + result = get_prowler_provider_kwargs(provider) + + expected_result = {**secret_dict, "region": {"us-ashburn-1"}} + assert result == expected_result + def test_get_prowler_provider_kwargs_with_mutelist(self): provider_uid = "provider_uid" secret_dict = {"key": "value"} diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index 8da8d91226..678ed24772 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -243,6 +243,12 @@ def get_prowler_provider_kwargs( **prowler_provider_kwargs, "filter_accounts": [provider.uid], } + elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value: + if isinstance(prowler_provider_kwargs.get("region"), str): + prowler_provider_kwargs = { + **prowler_provider_kwargs, + "region": {prowler_provider_kwargs["region"]}, + } elif provider.provider == Provider.ProviderChoices.OPENSTACK.value: # clouds_yaml_content, clouds_yaml_cloud and provider_id are validated # in the provider itself, so it's not needed here. From bba594a1db82d0cd13610b815e8be0c4739d4776 Mon Sep 17 00:00:00 2001 From: Oleksandr_Sanin Date: Thu, 11 Jun 2026 17:40:41 +0200 Subject: [PATCH 018/126] feat(aws/sagemaker): add sagemaker_clarify_exists check (#11211) Signed-off-by: Oleksandr Sanin Signed-off-by: Oleksandr Yizchak Sanin Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + .../sagemaker_clarify_exists/__init__.py | 0 .../sagemaker_clarify_exists.metadata.json | 39 +++ .../sagemaker_clarify_exists.py | 54 ++++ .../services/sagemaker/sagemaker_service.py | 88 +++++++ .../sagemaker_clarify_exists_test.py | 247 ++++++++++++++++++ .../sagemaker/sagemaker_service_test.py | 4 +- 7 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/__init__.py create mode 100644 prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json create mode 100644 prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py create mode 100644 tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 62fbb35d30..eb81a58378 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `securityhub_delegated_admin_enabled_all_regions` check for AWS provider, verifying that Security Hub has a delegated administrator, is active in all opted-in regions, and has organization auto-enable on [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) - `config_delegated_admin_and_org_aggregator_all_regions` check for AWS provider, verifying that AWS Config has a delegated administrator and an organization aggregator covering all AWS regions [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) +- `sagemaker_clarify_exists` check for AWS provider [(#11211)](https://github.com/prowler-cloud/prowler/pull/11211) --- diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/__init__.py b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json new file mode 100644 index 0000000000..0e127a7759 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "aws", + "CheckID": "sagemaker_clarify_exists", + "CheckTitle": "Amazon SageMaker Clarify processing jobs exist in the region", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "sagemaker", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**SageMaker Clarify** provides bias detection and model explainability for ML workloads.\n\nThis check verifies that at least one SageMaker processing job using the AWS-managed Clarify container image exists in each successfully scanned region. The absence of Clarify jobs indicates that responsible-AI controls such as bias detection and explainability are not in place.", + "Risk": "Without **SageMaker Clarify** processing jobs, ML models may be deployed without bias analysis or explainability reports. This can lead to:\n- **Regulatory non-compliance** with AI governance frameworks\n- **Undetected bias** in model predictions affecting protected groups\n- **Lack of accountability** for ML model decisions in production", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/sagemaker/latest/dg/clarify-configure-processing-jobs.html", + "https://docs.aws.amazon.com/sagemaker/latest/dg-ecr-paths/sagemaker-algo-docker-registry-paths.html" + ], + "Remediation": { + "Code": { + "CLI": "aws sagemaker create-processing-job --processing-job-name clarify-bias-check --app-specification ImageUri= --role-arn --processing-resources 'ClusterConfig={InstanceCount=1,InstanceType=ml.m5.xlarge,VolumeSizeInGB=20}'", + "NativeIaC": "", + "Other": "1. Open the AWS Console and go to Amazon SageMaker\n2. Navigate to Processing > Processing jobs\n3. Click Create processing job\n4. Select the SageMaker Clarify container image for your region\n5. Configure input/output paths and the analysis configuration\n6. Click Create processing job", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create SageMaker Clarify processing jobs to evaluate models for bias and explainability before deployment. Integrate Clarify into your ML pipeline to ensure responsible AI practices.", + "Url": "https://hub.prowler.com/check/sagemaker_clarify_exists" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Results are generated per scanned region. Regions where `ListProcessingJobs` cannot be queried are omitted from the findings." +} diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py new file mode 100644 index 0000000000..9c559613d3 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py @@ -0,0 +1,54 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.sagemaker.sagemaker_client import sagemaker_client + + +class sagemaker_clarify_exists(Check): + """Check whether at least one SageMaker Clarify processing job exists per region. + + A region is reported only when ListProcessingJobs succeeded for it; regions + where the API call failed (e.g. AccessDenied, unsupported region) are + skipped at the service layer and produce no finding. + + - PASS: At least one processing job uses the AWS-managed Clarify container + image in the region (one finding per job). + - FAIL: No processing job uses the Clarify container image in the region + (one finding per region). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the SageMaker Clarify exists check. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for region in sorted(sagemaker_client.processing_jobs_scanned_regions): + clarify_jobs = sorted( + ( + job + for job in sagemaker_client.sagemaker_processing_jobs + if job.region == region + and job.image_uri + and "sagemaker-clarify-processing" in job.image_uri + ), + key=lambda job: job.name, + ) + + if clarify_jobs: + for job in clarify_jobs: + report = Check_Report_AWS(metadata=self.metadata(), resource=job) + report.status = "PASS" + report.status_extended = f"SageMaker Clarify processing job {job.name} exists in region {region}." + findings.append(report) + else: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.region = region + report.resource_id = "sagemaker-clarify" + report.resource_arn = f"arn:{sagemaker_client.audited_partition}:sagemaker:{region}:{sagemaker_client.audited_account}:processing-job" + report.status = "FAIL" + report.status_extended = ( + f"No SageMaker Clarify processing jobs found in region {region}." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_service.py b/prowler/providers/aws/services/sagemaker/sagemaker_service.py index 5093a4ac02..20ea4c0280 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_service.py +++ b/prowler/providers/aws/services/sagemaker/sagemaker_service.py @@ -15,6 +15,8 @@ class SageMaker(AWSService): self.sagemaker_notebook_instances = [] self.sagemaker_models = [] self.sagemaker_training_jobs = [] + self.sagemaker_processing_jobs = [] + self.processing_jobs_scanned_regions = set() self.sagemaker_domains = [] self.endpoint_configs = {} self.sagemaker_model_registries = [] @@ -24,6 +26,7 @@ class SageMaker(AWSService): self.__threading_call__(self._list_notebook_instances) self.__threading_call__(self._list_models) self.__threading_call__(self._list_training_jobs) + self.__threading_call__(self._list_processing_jobs) self.__threading_call__(self._list_endpoint_configs) self.__threading_call__(self._list_domains) self.__threading_call__(self._list_model_package_groups) @@ -37,6 +40,9 @@ class SageMaker(AWSService): self.__threading_call__( self._describe_training_job, self.sagemaker_training_jobs ) + self.__threading_call__( + self._describe_processing_job, self.sagemaker_processing_jobs + ) self.__threading_call__( self._describe_endpoint_config, list(self.endpoint_configs.values()) ) @@ -51,6 +57,9 @@ class SageMaker(AWSService): self.__threading_call__( self._list_tags_for_resource, self.sagemaker_training_jobs ) + self.__threading_call__( + self._list_tags_for_resource, self.sagemaker_processing_jobs + ) self.__threading_call__( self._list_tags_for_resource, list(self.endpoint_configs.values()) ) @@ -128,6 +137,66 @@ class SageMaker(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_processing_jobs(self, regional_client): + """List SageMaker processing jobs in a region. + + Populates ``self.sagemaker_processing_jobs`` with `ProcessingJob` + entries and adds ``regional_client.region`` to + ``self.processing_jobs_scanned_regions`` once pagination succeeds, so + regions where ``ListProcessingJobs`` fails are skipped by checks that + consume that set. + + Args: + regional_client: Regional SageMaker boto3 client. + """ + logger.info("SageMaker - listing processing jobs...") + try: + list_processing_jobs_paginator = regional_client.get_paginator( + "list_processing_jobs" + ) + for page in list_processing_jobs_paginator.paginate(): + for processing_job in page["ProcessingJobSummaries"]: + if not self.audit_resources or ( + is_resource_filtered( + processing_job["ProcessingJobArn"], self.audit_resources + ) + ): + self.sagemaker_processing_jobs.append( + ProcessingJob( + name=processing_job["ProcessingJobName"], + region=regional_client.region, + arn=processing_job["ProcessingJobArn"], + ) + ) + self.processing_jobs_scanned_regions.add(regional_client.region) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _describe_processing_job(self, processing_job): + """Describe a SageMaker processing job and enrich its image metadata. + + Reads ``AppSpecification.ImageUri`` from ``DescribeProcessingJob`` and + stores it on ``processing_job.image_uri``. Errors are logged and + swallowed so a failure in one job does not abort the scan. + + Args: + processing_job: ProcessingJob model to enrich in-place. + """ + logger.info("SageMaker - describing processing job...") + try: + regional_client = self.regional_clients[processing_job.region] + describe_processing_job = regional_client.describe_processing_job( + ProcessingJobName=processing_job.name + ) + app_spec = describe_processing_job.get("AppSpecification", {}) + processing_job.image_uri = app_spec.get("ImageUri") + except Exception as error: + logger.error( + f"{processing_job.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _describe_notebook_instance(self, notebook_instance): logger.info("SageMaker - describing notebook instances...") try: @@ -451,6 +520,25 @@ class TrainingJob(BaseModel): tags: Optional[list] = [] +class ProcessingJob(BaseModel): + """Represents a SageMaker processing job. + + Attributes: + name: Processing job name. + region: AWS region where the job lives. + arn: Processing job ARN. + image_uri: Container image URI from `AppSpecification.ImageUri`, + populated by `_describe_processing_job`. + tags: Resource tags, populated by `_list_tags_for_resource`. + """ + + name: str + region: str + arn: str + image_uri: Optional[str] = None + tags: Optional[list] = [] + + class ProductionVariant(BaseModel): name: str initial_instance_count: int diff --git a/tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py b/tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py new file mode 100644 index 0000000000..32f5487100 --- /dev/null +++ b/tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py @@ -0,0 +1,247 @@ +from unittest import mock + +from prowler.providers.aws.services.sagemaker.sagemaker_service import ProcessingJob +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/sagemaker-clarify-processing:1.0" +NON_CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/sagemaker-xgboost:1.0" +CUSTOM_CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/my-clarify-thing:1.0" +PROCESSING_JOB_ARN = f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/clarify-job" + + +class Test_sagemaker_clarify_exists: + def test_no_processing_jobs_no_scanned_regions(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [] + sagemaker_client.processing_jobs_scanned_regions = set() + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 0 + + def test_no_processing_jobs_region_scanned(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}." + ) + assert result[0].resource_id == "sagemaker-clarify" + + def test_non_clarify_processing_job(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="xgboost-job", + arn=f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/xgboost-job", + region=AWS_REGION_US_EAST_1, + image_uri=NON_CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}." + ) + + def test_custom_image_with_clarify_in_name_does_not_match(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="my-clarify-thing-job", + arn=f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/my-clarify-thing-job", + region=AWS_REGION_US_EAST_1, + image_uri=CUSTOM_CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}." + ) + + def test_clarify_processing_job_exists(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="clarify-job", + arn=PROCESSING_JOB_ARN, + region=AWS_REGION_US_EAST_1, + image_uri=CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"SageMaker Clarify processing job clarify-job exists in region {AWS_REGION_US_EAST_1}." + ) + assert result[0].resource_id == "clarify-job" + assert result[0].resource_arn == PROCESSING_JOB_ARN + + def test_mixed_regions(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="clarify-job", + arn=PROCESSING_JOB_ARN, + region=AWS_REGION_US_EAST_1, + image_uri=CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = { + AWS_REGION_US_EAST_1, + AWS_REGION_EU_WEST_1, + } + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + + assert len(result) == 2 + + results_by_region = {r.region: r for r in result} + + us_result = results_by_region[AWS_REGION_US_EAST_1] + assert us_result.status == "PASS" + assert ( + us_result.status_extended + == f"SageMaker Clarify processing job clarify-job exists in region {AWS_REGION_US_EAST_1}." + ) + + eu_result = results_by_region[AWS_REGION_EU_WEST_1] + assert eu_result.status == "FAIL" + assert ( + eu_result.status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_EU_WEST_1}." + ) diff --git a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py index d49a506fd9..50431c2e13 100644 --- a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py +++ b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py @@ -396,13 +396,13 @@ class Test_SageMaker_Service: sagemaker_service = SageMaker(audit_info) # Check that __threading_call__ was called for _list_tags_for_resource - # (one for each resource type: models, notebooks, training jobs, endpoint configs, domains) + # (one for each resource type: models, notebooks, training jobs, processing jobs, endpoint configs, domains) tag_calls = [ c for c in mock_threading_call.call_args_list if c[0][0] == sagemaker_service._list_tags_for_resource ] - assert len(tag_calls) == 5 + assert len(tag_calls) == 6 # Test SageMaker list model package groups def test_list_model_package_groups(self): From 20eca787676c44f383799d1700864c7d5f1a2115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Thu, 11 Jun 2026 18:00:36 +0200 Subject: [PATCH 019/126] fix(compliance): resolve provider from scan in attributes endp (#11546) --- api/CHANGELOG.md | 1 + api/src/backend/api/tests/test_views.py | 182 ++++++++++++++++++ api/src/backend/api/v1/views.py | 57 +++++- ui/CHANGELOG.md | 8 + ui/actions/compliances/compliances.ts | 11 +- .../compliance/[compliancetitle]/page.tsx | 2 +- 6 files changed, 258 insertions(+), 3 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 1756fe83a4..fc367813f7 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler API** are documented in this file. ### 🐞 Fixed +- `compliance-overviews/attributes` now resolves the provider from the scan, so multi-provider universal frameworks (e.g. CSA CCM) return the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) - OCI scans now use API key credentials with the configured region instead of falling back to `/home/prowler/.oci/config` [(#11558)](https://github.com/prowler-cloud/prowler/pull/11558) --- diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index ab90bdbde0..410bd23e19 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -9570,6 +9570,188 @@ class TestComplianceOverviewViewSet: assert "Category" in first_attr assert "AWSService" in first_attr + def test_compliance_overview_attributes_resolves_provider_from_scan( + self, authenticated_client, tenants_fixture, providers_fixture + ): + # csa_ccm_4.0 is a multi-provider universal framework: a single + # compliance_id whose requirements expose different checks per provider. + # Passing a scan must return the check IDs for that scan's provider, + # otherwise the endpoint defaults to the first provider that declares the + # framework and azure/gcp requirements end up with check IDs that match + # no findings. + tenant = tenants_fixture[0] + gcp_provider = providers_fixture[2] + azure_provider = providers_fixture[4] + assert gcp_provider.provider == Provider.ProviderChoices.GCP.value + assert azure_provider.provider == Provider.ProviderChoices.AZURE.value + + now = datetime.now(timezone.utc) + gcp_scan = Scan.objects.create( + name="gcp scan", + provider=gcp_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + azure_scan = Scan.objects.create( + name="azure scan", + provider=azure_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + + def request_attributes(scan_id=None): + params = {"filter[compliance_id]": "csa_ccm_4.0"} + if scan_id is not None: + params["filter[scan_id]"] = str(scan_id) + return authenticated_client.get( + reverse("complianceoverview-attributes"), params + ) + + def collect_check_ids(scan_id=None): + response = request_attributes(scan_id) + assert response.status_code == status.HTTP_200_OK + check_ids = set() + for item in response.json()["data"]: + check_ids.update(item["attributes"]["attributes"]["check_ids"]) + return check_ids + + gcp_check_ids = collect_check_ids(gcp_scan.id) + azure_check_ids = collect_check_ids(azure_scan.id) + + # Each scan resolves to its own provider's checks, and they differ. + assert gcp_check_ids + assert azure_check_ids + assert gcp_check_ids != azure_check_ids + + # The returned check IDs belong to the SDK's per-provider definition. + from api.compliance import get_prowler_provider_compliance + + def expected_check_ids(provider_type): + framework = get_prowler_provider_compliance(provider_type)["csa_ccm_4.0"] + expected = set() + for requirement in framework.requirements: + expected.update(requirement.checks.get(provider_type, [])) + return expected + + assert gcp_check_ids <= expected_check_ids(Provider.ProviderChoices.GCP.value) + assert azure_check_ids <= expected_check_ids( + Provider.ProviderChoices.AZURE.value + ) + + # An explicit scan_id is authoritative: a non-existent scan must fail + # closed with 404 instead of silently falling back to another provider. + missing_response = request_attributes("00000000-0000-0000-0000-000000000000") + assert missing_response.status_code == status.HTTP_404_NOT_FOUND + + # A malformed scan_id is rejected with 404 as well. + malformed_response = request_attributes("not-a-uuid") + assert malformed_response.status_code == status.HTTP_404_NOT_FOUND + + # An empty value (filter[scan_id]=) must not fall back to the legacy + # provider picker: the explicit (if blank) selector fails closed. + empty_response = request_attributes("") + assert empty_response.status_code == status.HTTP_404_NOT_FOUND + + # A scan belonging to another tenant is not visible (RLS), so it must + # return 404 rather than leaking the fallback provider's check IDs. + other_tenant = Tenant.objects.create(name="Other Compliance Tenant") + foreign_provider = Provider.objects.create( + provider="gcp", + uid="foreign-gcp-test", + alias="foreign_gcp", + tenant_id=other_tenant.id, + ) + foreign_scan = Scan.objects.create( + name="foreign scan", + provider=foreign_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=other_tenant.id, + started_at=now, + completed_at=now, + ) + foreign_response = request_attributes(foreign_scan.id) + assert foreign_response.status_code == status.HTTP_404_NOT_FOUND + + def test_compliance_overview_attributes_scan_scoped_by_provider_group( + self, + authenticated_client_no_permissions_rbac, + providers_fixture, + ): + # A user with limited visibility (no UNLIMITED_VISIBILITY) must only be + # able to resolve scans for providers in its provider groups. Tenant RLS + # alone is not enough here: both scans belong to the same tenant, so the + # endpoint has to scope the scan lookup by provider group, otherwise a + # restricted user could read another provider's compliance metadata. + client = authenticated_client_no_permissions_rbac + limited_user = client.user + membership = Membership.objects.filter(user=limited_user).first() + tenant = membership.tenant + + allowed_provider = providers_fixture[2] + denied_provider = providers_fixture[4] + assert allowed_provider.provider == Provider.ProviderChoices.GCP.value + assert denied_provider.provider == Provider.ProviderChoices.AZURE.value + + provider_group = ProviderGroup.objects.create( + name="limited-compliance-group", + tenant_id=tenant.id, + ) + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider_group=provider_group, + provider=allowed_provider, + ) + RoleProviderGroupRelationship.objects.create( + tenant_id=tenant.id, + role=limited_user.roles.first(), + provider_group=provider_group, + ) + + now = datetime.now(timezone.utc) + allowed_scan = Scan.objects.create( + name="allowed scan", + provider=allowed_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + denied_scan = Scan.objects.create( + name="denied scan", + provider=denied_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + + def request_attributes(scan_id): + return client.get( + reverse("complianceoverview-attributes"), + { + "filter[compliance_id]": "csa_ccm_4.0", + "filter[scan_id]": str(scan_id), + }, + ) + + # The scan in the user's provider group resolves normally. + assert request_attributes(allowed_scan.id).status_code == status.HTTP_200_OK + + # The scan outside the user's provider group is invisible, so it fails + # closed with 404 instead of leaking the other provider's check IDs. + assert ( + request_attributes(denied_scan.id).status_code == status.HTTP_404_NOT_FOUND + ) + def test_compliance_overview_attributes_missing_compliance_id( self, authenticated_client ): diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 45100f1018..0942adc8e1 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -30,6 +30,7 @@ from dj_rest_auth.registration.views import SocialLoginView from django.conf import settings as django_settings from django.contrib.postgres.aggregates import ArrayAgg, BoolAnd, StringAgg from django.contrib.postgres.search import SearchQuery +from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction from django.db.models import ( BooleanField, @@ -4644,6 +4645,16 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): location=OpenApiParameter.QUERY, description="Compliance framework ID to get attributes for.", ), + OpenApiParameter( + name="filter[scan_id]", + required=False, + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + description="Scan ID used to resolve the provider for " + "multi-provider universal frameworks (e.g. CSA CCM), so " + "the returned check IDs match the scan's provider. When omitted, " + "the first provider that declares the framework is used.", + ), ], responses={ 200: OpenApiResponse( @@ -5084,7 +5095,51 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): provider_type = None - # If we couldn't determine from database, try each provider type + # When a scan is provided, resolve the provider from it. Multi-provider + # universal frameworks (e.g. CSA CCM) share a single compliance_id + # across providers but expose different checks per provider, so the + # metadata (and therefore the check IDs the UI uses to fetch findings) + # must be returned for the scan's provider. Without this, the endpoint + # falls back to the first provider that declares the framework and + # returns its check IDs, leaving azure/gcp/... requirements with no + # matching findings. + scan_id = request.query_params.get("filter[scan_id]") + if "filter[scan_id]" in request.query_params: + # An explicit scan_id is authoritative: fail closed instead of + # falling back to another provider. Otherwise an invalid, empty + # (filter[scan_id]=) or inaccessible scan would silently return the + # first provider's check IDs, recreating the multi-provider mismatch + # this endpoint fixes. + if not scan_id: + raise NotFound(detail=f"Scan '{scan_id}' not found.") + + # Tenant isolation is already enforced by Postgres RLS on the + # connection (see BaseRLSViewSet). Scope the lookup by provider + # group as well so a user with limited visibility can't resolve + # another provider's scan and read its compliance metadata, mirroring + # the RBAC scoping get_queryset() applies to the rest of the ViewSet. + role = get_role(request.user, request.tenant_id) + if getattr(role, Permissions.UNLIMITED_VISIBILITY.value, False): + scan_queryset = Scan.objects.filter(tenant_id=request.tenant_id) + else: + scan_queryset = Scan.objects.filter(provider__in=get_providers(role)) + + try: + scan = scan_queryset.select_related("provider").get(id=scan_id) + except (Scan.DoesNotExist, DjangoValidationError, ValueError): + raise NotFound(detail=f"Scan '{scan_id}' not found.") + + provider_type = scan.provider.provider + if compliance_id not in get_compliance_frameworks(provider_type): + raise NotFound( + detail=( + f"Compliance framework '{compliance_id}' is not " + f"available for scan '{scan_id}'." + ) + ) + + # Fall back to the first provider that declares the framework. Keeps the + # endpoint working for provider-agnostic callers that omit the scan. if not provider_type: for pt in Provider.ProviderChoices.values: if compliance_id in get_compliance_frameworks(pt): diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 67a0a59e5d..277fabb5b0 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.30.1] (Prowler UNRELEASED) + +### 🐞 Fixed + +- Compliance attributes requests now pass the selected scan, so multi-provider universal frameworks (e.g. CSA CCM) load the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) + +--- + ## [1.30.0] (Prowler v5.30.0) ### 🚀 Added diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index 58e7c5b5a0..e06b06d29d 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -73,12 +73,21 @@ export const getComplianceOverviewMetadataInfo = async ({ } }; -export const getComplianceAttributes = async (complianceId: string) => { +export const getComplianceAttributes = async ( + complianceId: string, + scanId?: string, +) => { const headers = await getAuthHeaders({ contentType: false }); try { const url = new URL(`${apiBaseUrl}/compliance-overviews/attributes`); url.searchParams.append("filter[compliance_id]", complianceId); + // Pass the scan so multi-provider universal frameworks (e.g. CSA CCM) + // resolve the check IDs for the scan's provider instead of defaulting to + // the first provider that declares the framework. + if (scanId) { + url.searchParams.append("filter[scan_id]", scanId); + } const response = await fetch(url.toString(), { headers, diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index 445710e48f..f5d42f8bc8 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -87,7 +87,7 @@ export default async function ComplianceDetail({ "filter[scan_id]": selectedScanId ?? undefined, }, }), - getComplianceAttributes(complianceId), + getComplianceAttributes(complianceId, selectedScanId ?? undefined), selectedScanId ? getScan(selectedScanId, { include: "provider" }) : Promise.resolve(null), From a394c0fdf68e176d2cd3c3121551df3040983454 Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Thu, 11 Jun 2026 18:32:35 +0200 Subject: [PATCH 020/126] fix(api): drop_subgraph deletes relationships then nodes to cut Neo4j memory (#11557) --- api/CHANGELOG.md | 2 + api/src/backend/api/attack_paths/database.py | 20 ++++- .../api/tests/test_attack_paths_database.py | 81 +++++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index fc367813f7..5b5ea6b97e 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,9 +4,11 @@ All notable changes to the **Prowler API** are documented in this file. ## [1.31.1] (Prowler UNRELEASED) + ### 🐞 Fixed - `compliance-overviews/attributes` now resolves the provider from the scan, so multi-provider universal frameworks (e.g. CSA CCM) return the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) +- Attack Paths: `drop_subgraph` now deletes relationships first and then nodes in batches, using less memory on Neo4j when clearing a dense provider graph [(#11557)](https://github.com/prowler-cloud/prowler/pull/11557) - OCI scans now use API key credentials with the configured region instead of falling back to `/home/prowler/.oci/config` [(#11558)](https://github.com/prowler-cloud/prowler/pull/11558) --- diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py index d5cc1698a7..0e6cc083dc 100644 --- a/api/src/backend/api/attack_paths/database.py +++ b/api/src/backend/api/attack_paths/database.py @@ -175,7 +175,8 @@ def drop_subgraph(database: str, provider_id: str) -> int: """ Delete all nodes for a provider from the tenant database. - Uses batched deletion to avoid memory issues with large graphs. + Deletes relationships then nodes in batches (not `DETACH DELETE`) so a dense + provider's graph cannot exceed Neo4j's transaction memory limit. Silently returns 0 if the database doesn't exist. """ provider_label = get_provider_label(provider_id) @@ -183,13 +184,28 @@ def drop_subgraph(database: str, provider_id: str) -> int: try: with get_session(database) as session: + # Phase 1: delete relationships incident to provider nodes in batches. + deleted_count = 1 + while deleted_count > 0: + result = session.run( + f""" + MATCH (:`{provider_label}`)-[r]-() + WITH DISTINCT r LIMIT $batch_size + DELETE r + RETURN COUNT(r) AS deleted_rels_count + """, + {"batch_size": BATCH_SIZE}, + ) + deleted_count = result.single().get("deleted_rels_count", 0) + + # Phase 2: delete the now relationship-free nodes in batches. deleted_count = 1 while deleted_count > 0: result = session.run( f""" MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) WITH n LIMIT $batch_size - DETACH DELETE n + DELETE n RETURN COUNT(n) AS deleted_nodes_count """, {"batch_size": BATCH_SIZE}, diff --git a/api/src/backend/api/tests/test_attack_paths_database.py b/api/src/backend/api/tests/test_attack_paths_database.py index 3a29a1007d..7ca8a4accb 100644 --- a/api/src/backend/api/tests/test_attack_paths_database.py +++ b/api/src/backend/api/tests/test_attack_paths_database.py @@ -542,3 +542,84 @@ class TestHasProviderData: ): with pytest.raises(db_module.GraphDatabaseQueryException): db_module.has_provider_data("db-tenant-abc", "provider-123") + + +class TestDropSubgraph: + """Test drop_subgraph two-phase batched deletion of a provider's graph.""" + + @staticmethod + def _result(count): + result = MagicMock() + result.single.return_value.get.return_value = count + return result + + @staticmethod + def _session_ctx(session): + ctx = MagicMock() + ctx.__enter__.return_value = session + ctx.__exit__.return_value = False + return ctx + + def test_deletes_relationships_then_nodes_in_batches(self): + session = MagicMock() + # Phase 1 (relationships): one full batch then empty. + # Phase 2 (nodes): one full batch then empty. + session.run.side_effect = [ + self._result(1000), + self._result(0), + self._result(1000), + self._result(0), + ] + + with patch( + "api.attack_paths.database.get_session", + return_value=self._session_ctx(session), + ): + deleted = db_module.drop_subgraph("db-tenant-abc", "provider-123") + + # Only phase-2 node counts contribute to the return value. + assert deleted == 1000 + assert session.run.call_count == 4 + + queries = [call.args[0] for call in session.run.call_args_list] + + # Regression guard: the memory blow-up was caused by DETACH DELETE. + assert all("DETACH DELETE" not in query for query in queries) + + rel_queries = [query for query in queries if "DELETE r" in query] + node_queries = [query for query in queries if "DELETE n" in query] + assert rel_queries and node_queries + # DISTINCT avoids double-counting relationships matched from both ends. + assert all("DISTINCT r" in query for query in rel_queries) + + # Relationships must be fully drained before nodes are deleted. + first_node = next(i for i, q in enumerate(queries) if "DELETE n" in q) + last_rel = max(i for i, q in enumerate(queries) if "DELETE r" in q) + assert last_rel < first_node + + def test_returns_zero_when_database_not_found(self): + session_ctx = MagicMock() + session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException( + message="Database does not exist", + code="Neo.ClientError.Database.DatabaseNotFound", + ) + + with patch( + "api.attack_paths.database.get_session", + return_value=session_ctx, + ): + assert db_module.drop_subgraph("db-tenant-gone", "provider-123") == 0 + + def test_raises_on_other_errors(self): + session_ctx = MagicMock() + session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException( + message="Connection refused", + code="Neo.TransientError.General.UnknownError", + ) + + with patch( + "api.attack_paths.database.get_session", + return_value=session_ctx, + ): + with pytest.raises(db_module.GraphDatabaseQueryException): + db_module.drop_subgraph("db-tenant-abc", "provider-123") From 2e82f1564f6b5535baa0692776726c60fc469577 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:07:56 +0200 Subject: [PATCH 021/126] fix(ui): show threat map data for okta and google workspace accounts (#11542) --- ui/CHANGELOG.md | 1 + .../regions/threat-map.adapter.test.ts | 62 +++++++++++++ .../overview/regions/threat-map.adapter.ts | 15 +++ ui/components/graphs/threat-map.test.tsx | 93 +++++++++++++++++++ ui/components/graphs/threat-map.tsx | 14 ++- 5 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 ui/actions/overview/regions/threat-map.adapter.test.ts create mode 100644 ui/components/graphs/threat-map.test.tsx diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 277fabb5b0..4e5aa06e45 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🐞 Fixed +- Threat Map no longer shows an empty map for accounts that only have Okta or Google Workspace scans [(#11542)](https://github.com/prowler-cloud/prowler/pull/11542) - Compliance attributes requests now pass the selected scan, so multi-provider universal frameworks (e.g. CSA CCM) load the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) --- diff --git a/ui/actions/overview/regions/threat-map.adapter.test.ts b/ui/actions/overview/regions/threat-map.adapter.test.ts new file mode 100644 index 0000000000..515825bb0a --- /dev/null +++ b/ui/actions/overview/regions/threat-map.adapter.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { adaptRegionsOverviewToThreatMap } from "./threat-map.adapter"; +import type { RegionsOverviewResponse } from "./types"; + +function buildRegionsResponse( + rows: Array<{ providerType: string; region: string }>, +): RegionsOverviewResponse { + return { + data: rows.map(({ providerType, region }, index) => ({ + type: "regions-overview", + id: `region-${index}`, + attributes: { + provider_type: providerType, + region, + total: 10, + fail: 4, + muted: 0, + pass: 6, + }, + })), + meta: { version: "v1" }, + }; +} + +describe("adaptRegionsOverviewToThreatMap", () => { + it("maps okta regions to a global location", () => { + const response = buildRegionsResponse([ + { providerType: "okta", region: "global" }, + ]); + + const result = adaptRegionsOverviewToThreatMap(response); + + expect(result.locations).toHaveLength(1); + expect(result.locations[0]).toMatchObject({ + providerType: "okta", + region: "global", + name: "Okta - Global", + totalFindings: 10, + failFindings: 4, + }); + expect(result.regions).toEqual(["global"]); + }); + + it("maps googleworkspace regions to a global location", () => { + const response = buildRegionsResponse([ + { providerType: "googleworkspace", region: "global" }, + ]); + + const result = adaptRegionsOverviewToThreatMap(response); + + expect(result.locations).toHaveLength(1); + expect(result.locations[0]).toMatchObject({ + providerType: "googleworkspace", + region: "global", + name: "Google Workspace - Global", + totalFindings: 10, + failFindings: 4, + }); + expect(result.regions).toEqual(["global"]); + }); +}); diff --git a/ui/actions/overview/regions/threat-map.adapter.ts b/ui/actions/overview/regions/threat-map.adapter.ts index 0b7852b56a..6ee0958aad 100644 --- a/ui/actions/overview/regions/threat-map.adapter.ts +++ b/ui/actions/overview/regions/threat-map.adapter.ts @@ -261,6 +261,19 @@ const ALIBABACLOUD_COORDINATES: Record = { global: { lat: 30.3, lng: 120.2 }, // Global fallback (Hangzhou HQ) }; +// Okta is a SaaS identity platform without user-facing regions +const OKTA_COORDINATES: Record = { + global: { lat: 37.8, lng: -122.4 }, // Global fallback (San Francisco HQ) +}; + +// Google Workspace is a SaaS suite without user-facing regions +const GOOGLEWORKSPACE_COORDINATES: Record< + string, + { lat: number; lng: number } +> = { + global: { lat: 37.4, lng: -122.1 }, // Global fallback (Mountain View HQ) +}; + const PROVIDER_COORDINATES: Record< string, Record @@ -277,6 +290,8 @@ const PROVIDER_COORDINATES: Record< oraclecloud: ORACLECLOUD_COORDINATES, mongodbatlas: MONGODBATLAS_COORDINATES, alibabacloud: ALIBABACLOUD_COORDINATES, + okta: OKTA_COORDINATES, + googleworkspace: GOOGLEWORKSPACE_COORDINATES, }; // Returns [lng, lat] format for D3/GeoJSON compatibility diff --git a/ui/components/graphs/threat-map.test.tsx b/ui/components/graphs/threat-map.test.tsx new file mode 100644 index 0000000000..47d447bddc --- /dev/null +++ b/ui/components/graphs/threat-map.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ThreatMap } from "./threat-map"; +import type { ThreatMapData } from "./threat-map.types"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("./horizontal-bar-chart", () => ({ + HorizontalBarChart: () =>
, +})); + +function buildLocation(providerType: string, region: string) { + return { + id: `${providerType}-${region}`, + name: `${providerType} - ${region}`, + region, + regionCode: region, + providerType, + coordinates: [-122.4, 37.8] as [number, number], + totalFindings: 10, + failFindings: 4, + riskLevel: "high" as const, + severityData: [ + { name: "Fail", value: 4, percentage: 40 }, + { name: "Pass", value: 6, percentage: 60 }, + ], + }; +} + +describe("ThreatMap region selector", () => { + it("auto-selects the region when it is the only one available", () => { + const data: ThreatMapData = { + locations: [ + buildLocation("okta", "global"), + buildLocation("googleworkspace", "global"), + ], + regions: ["global"], + }; + + render(); + + const select = screen.getByRole("combobox", { + name: "Filter threat map by region", + }); + expect(select).toHaveValue("global"); + expect(screen.getByText("Global Regions")).toBeInTheDocument(); + expect( + screen.queryByText("Select a location on the map to view details"), + ).not.toBeInTheDocument(); + }); + + it("keeps All Regions as default when there are multiple regions", () => { + const data: ThreatMapData = { + locations: [ + buildLocation("aws", "us-east-1"), + buildLocation("okta", "global"), + ], + regions: ["global", "us-east-1"], + }; + + render(); + + const select = screen.getByRole("combobox", { + name: "Filter threat map by region", + }); + expect(select).toHaveValue("All Regions"); + expect( + screen.getByRole("option", { name: "All Regions" }), + ).toBeInTheDocument(); + }); + + it("shows the global option capitalized while keeping its filter value", () => { + const data: ThreatMapData = { + locations: [ + buildLocation("aws", "us-east-1"), + buildLocation("okta", "global"), + ], + regions: ["global", "us-east-1"], + }; + + render(); + + const globalOption = screen.getByRole("option", { name: "Global" }); + expect(globalOption).toHaveValue("global"); + expect( + screen.getByRole("option", { name: "us-east-1" }), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/components/graphs/threat-map.tsx b/ui/components/graphs/threat-map.tsx index 42b44aa8d1..b73f81336c 100644 --- a/ui/components/graphs/threat-map.tsx +++ b/ui/components/graphs/threat-map.tsx @@ -124,7 +124,11 @@ export function ThreatMap({ x: number; y: number; } | null>(null); - const [selectedRegion, setSelectedRegion] = useState("All Regions"); + // With a single region "All Regions" adds nothing, so it starts selected + const hasSingleRegion = data.regions.length === 1; + const [selectedRegion, setSelectedRegion] = useState( + hasSingleRegion ? data.regions[0] : "All Regions", + ); const [worldData, setWorldData] = useState(null); const [isLoadingMap, setIsLoadingMap] = useState(true); const [dimensions, setDimensions] = useState<{ @@ -424,10 +428,12 @@ export function ThreatMap({ onChange={(e) => setSelectedRegion(e.target.value)} className="border-border-neutral-primary bg-bg-neutral-secondary text-text-neutral-primary appearance-none rounded-lg border px-4 py-2 pr-10 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2" > - + {!hasSingleRegion && ( + + )} {sortedRegions.map((region) => ( ))} @@ -467,7 +473,7 @@ export function ThreatMap({