diff --git a/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.metadata.json b/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.metadata.json index ca7ee4fe8c..9da1359e53 100644 --- a/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.metadata.json +++ b/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.metadata.json @@ -9,7 +9,7 @@ "Severity": "medium", "ResourceType": "NotDefined", "ResourceGroup": "governance", - "Description": "Each Okta brand's sign-in page must present the Standard Mandatory DOD Notice and Consent Banner (DTM-08-060) before granting access (DISA STIG V-273192 / OKTA-APP-000200). The check inspects the customized sign-in page HTML for distinctive banner phrases. Brands without a customized sign-in page fall through to MANUAL: the default Okta page is not retrievable via the Management API.", + "Description": "Each Okta brand's sign-in page must present the Standard Mandatory DOD Notice and Consent Banner (DTM-08-060) before granting access (DISA STIG V-273192 / OKTA-APP-000200). The check inspects sign-in page HTML returned by the Okta Management API, using the customized page when present and otherwise falling back to the default sign-in page.", "Risk": "Without the DOD Notice and Consent Banner, users are not informed that the system is a U.S. Government information system subject to monitoring. The absence of the banner weakens the legal basis for incident response and prosecution and breaks alignment with federal laws, Executive Orders, directives, policies, regulations, standards, and guidance.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.py b/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.py index 63d46f1ced..4e85532cc0 100644 --- a/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.py +++ b/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.py @@ -2,18 +2,31 @@ from prowler.lib.check.models import Check, CheckReportOkta from prowler.providers.okta.services.signon.signon_client import signon_client from prowler.providers.okta.services.signon.signon_service import SignInPage -# Distinctive substrings drawn from the DTM-08-060 Standard Mandatory DOD -# Notice and Consent Banner. The full banner is ~1300 characters and is -# highly variable across HTML encodings, so the check matches on a -# tolerant marker set: a customized sign-in page is considered to carry -# the banner when at least two of these markers appear (case-insensitive). -BANNER_MARKERS = ( - "u.s. government", - "usg information system", - "only authorized use", - "may be intercepted", +# Distinctive marker groups drawn from the DTM-08-060 Standard Mandatory +# DOD Notice and Consent Banner. The HTML can vary across brands, so the +# check looks for the banner's core ideas rather than requiring an exact +# string match. +BANNER_MARKER_GROUPS = ( + ("u.s. government", "us government"), + ("information system", "information systems"), + ("authorized use only", "authorized use"), + ( + "subject to monitoring", + "may be intercepted", + "searched, monitored, and recorded", + "consent to monitoring", + ), ) -MIN_MARKER_MATCHES = 2 + + +def _matched_banner_groups(content_lower: str) -> list[str]: + matched_markers: list[str] = [] + for marker_group in BANNER_MARKER_GROUPS: + for marker in marker_group: + if marker in content_lower: + matched_markers.append(marker) + break + return matched_markers class signon_dod_warning_banner_configured(Check): @@ -21,9 +34,9 @@ class signon_dod_warning_banner_configured(Check): Okta must display the Standard Mandatory DOD Notice and Consent Banner (DTM-08-060) before granting access to the application. The - check inspects each brand's customized sign-in page; tenants without - a customized page fall through to MANUAL because the default Okta - page content is not retrievable via the Management API. + check inspects each brand's sign-in page HTML returned by the Okta + Management API, using the customized page when present and otherwise + falling back to the default sign-in page. """ def execute(self) -> list[CheckReportOkta]: @@ -64,39 +77,41 @@ class signon_dod_warning_banner_configured(Check): if page.fetch_error: report.status = "MANUAL" report.status_extended = ( - f"Could not retrieve the customized sign-in page for " + f"Could not retrieve the sign-in page for " f"brand '{page.brand_name or page.brand_id}' ({page.fetch_error}). " - "Inspect the brand customization manually to confirm the " + "Inspect the brand manually to confirm the " "DOD Notice and Consent Banner (DTM-08-060) is displayed." ) findings.append(report) continue - if not page.is_customized or not page.page_content: + if not page.page_content: report.status = "MANUAL" report.status_extended = ( - f"No customized sign-in page is configured for brand " - f"'{page.brand_name or page.brand_id}'. The DOD Notice " - "and Consent Banner cannot be audited via API — verify " - "the default sign-in page in the Admin Console." + f"Sign-in page content for brand " + f"'{page.brand_name or page.brand_id}' could not be " + "retrieved from the Okta API. Verify the DOD Notice and " + "Consent Banner (DTM-08-060) manually in the Admin Console." ) findings.append(report) continue + page_type = "customized" if page.is_customized else "default" content_lower = page.page_content.lower() - matches = sum(1 for marker in BANNER_MARKERS if marker in content_lower) + matches = _matched_banner_groups(content_lower) - if matches >= MIN_MARKER_MATCHES: + if len(matches) == len(BANNER_MARKER_GROUPS): report.status = "PASS" report.status_extended = ( - f"DOD Notice and Consent Banner detected on the customized " + f"DOD Notice and Consent Banner detected on the {page_type} " f"sign-in page for brand '{page.brand_name or page.brand_id}' " - f"({matches} of {len(BANNER_MARKERS)} marker phrases matched)." + f"({len(matches)} of {len(BANNER_MARKER_GROUPS)} required " + "marker groups matched)." ) else: report.status = "FAIL" report.status_extended = ( - f"Customized sign-in page for brand " + f"{page_type.title()} sign-in page for brand " f"'{page.brand_name or page.brand_id}' does not contain " "the DOD Notice and Consent Banner (DTM-08-060)." ) diff --git a/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.py b/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.py index 6e574e7327..2229394afb 100644 --- a/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.py +++ b/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.py @@ -93,6 +93,15 @@ class signon_global_session_lifetime_18h(Check): ) return [report] + if lifetime == 0: + report.status = "FAIL" + report.status_extended = ( + f"Priority 1 non-default rule '{priority_one_rule.name}' in " + f"Default Global Session Policy '{policy.name}' disables the " + "maximum Okta global session lifetime by setting it to 0 minutes." + ) + return [report] + if lifetime <= threshold: report.status = "PASS" report.status_extended = ( diff --git a/prowler/providers/okta/services/signon/signon_service.py b/prowler/providers/okta/services/signon/signon_service.py index 7abe15a46d..d47ad41fc1 100644 --- a/prowler/providers/okta/services/signon/signon_service.py +++ b/prowler/providers/okta/services/signon/signon_service.py @@ -36,10 +36,11 @@ class Signon(OktaService): policy carries its rules; downstream checks read directly from this structure. - Also populates `self.sign_in_pages` keyed by brand id with the - customized sign-in page HTML, used by the DOD warning-banner check. - Brands without a customized page are tracked with `is_customized=False` - so the check can render a MANUAL finding. + Also populates `self.sign_in_pages` keyed by brand id with sign-in page + HTML used by the DOD warning-banner check. When a brand has no + customized page, the service falls back to the default sign-in page + exposed by the Okta Management API and tracks it with + `is_customized=False`. """ def __init__(self, provider): @@ -153,40 +154,51 @@ class Signon(OktaService): for brand in all_brands: brand_id = getattr(brand, "id", "") or "" brand_name = getattr(brand, "name", "") or "" - page_result = await self.client.get_customized_sign_in_page(brand_id) - page_err = page_result[-1] - page_data = page_result[0] - if page_err is not None: - err_text = str(page_err).lower() - # 404 is the documented response when a brand has no - # customized sign-in page yet. The default Okta page still - # shows, but its content is not retrievable via API — flag - # the brand for MANUAL review downstream. - if ( - "404" in err_text - or "not found" in err_text - or "e0000007" in err_text - ): - result[brand_id] = SignInPage( - brand_id=brand_id, - brand_name=brand_name, - is_customized=False, - ) - else: - result[brand_id] = SignInPage( - brand_id=brand_id, - brand_name=brand_name, - is_customized=False, - fetch_error=str(page_err), - ) - continue - result[brand_id] = SignInPage( + result[brand_id] = await self._fetch_sign_in_page(brand_id, brand_name) + return result + + async def _fetch_sign_in_page(self, brand_id: str, brand_name: str) -> "SignInPage": + page_result = await self.client.get_customized_sign_in_page(brand_id) + page_err = page_result[-1] + page_data = page_result[0] + if page_err is None: + return SignInPage( brand_id=brand_id, brand_name=brand_name, is_customized=True, page_content=getattr(page_data, "page_content", None), ) - return result + + if not self._is_missing_customized_page_error(page_err): + return SignInPage( + brand_id=brand_id, + brand_name=brand_name, + is_customized=False, + fetch_error=str(page_err), + ) + + default_page_result = await self.client.get_default_sign_in_page(brand_id) + default_page_err = default_page_result[-1] + default_page_data = default_page_result[0] + if default_page_err is not None: + return SignInPage( + brand_id=brand_id, + brand_name=brand_name, + is_customized=False, + fetch_error=str(default_page_err), + ) + + return SignInPage( + brand_id=brand_id, + brand_name=brand_name, + is_customized=False, + page_content=getattr(default_page_data, "page_content", None), + ) + + @staticmethod + def _is_missing_customized_page_error(error) -> bool: + err_text = str(error).lower() + return "404" in err_text or "not found" in err_text or "e0000007" in err_text @staticmethod async def _paginate(fetch): diff --git a/tests/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured_test.py b/tests/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured_test.py index bbdaca2ae1..619142f390 100644 --- a/tests/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured_test.py +++ b/tests/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured_test.py @@ -58,6 +58,7 @@ class Test_signon_dod_warning_banner_configured: assert "DOD Notice and Consent Banner detected" in ( findings[0].status_extended ) + assert "customized sign-in page" in findings[0].status_extended def test_fail_when_customized_page_missing_banner(self): page = sign_in_page( @@ -83,7 +84,31 @@ class Test_signon_dod_warning_banner_configured: assert findings[0].status == "FAIL" assert "does not contain" in findings[0].status_extended - def test_manual_when_no_customization(self): + def test_pass_when_default_page_contains_banner(self): + page = sign_in_page( + brand_id="brand-1", + brand_name="Primary", + is_customized=False, + page_content=f"{DOD_BANNER_HTML_SNIPPET}", + ) + signon_client = build_signon_client(sign_in_pages={"brand-1": page}) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_dod_warning_banner_configured.signon_dod_warning_banner_configured import ( + signon_dod_warning_banner_configured, + ) + + findings = signon_dod_warning_banner_configured().execute() + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "default sign-in page" in findings[0].status_extended + + def test_manual_when_page_content_missing(self): page = sign_in_page( brand_id="brand-1", brand_name="Primary", @@ -105,7 +130,9 @@ class Test_signon_dod_warning_banner_configured: findings = signon_dod_warning_banner_configured().execute() assert len(findings) == 1 assert findings[0].status == "MANUAL" - assert "No customized sign-in page" in findings[0].status_extended + assert "could not be retrieved from the Okta API" in ( + findings[0].status_extended + ) def test_manual_when_fetch_error(self): page = sign_in_page( @@ -132,6 +159,33 @@ class Test_signon_dod_warning_banner_configured: assert "Could not retrieve" in findings[0].status_extended assert "403" in findings[0].status_extended + def test_fail_when_only_partial_banner_markers_are_present(self): + page = sign_in_page( + brand_id="brand-1", + brand_name="Primary", + is_customized=True, + page_content=( + "This U.S. Government portal is for authorized use " + "only." + ), + ) + signon_client = build_signon_client(sign_in_pages={"brand-1": page}) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_dod_warning_banner_configured.signon_dod_warning_banner_configured import ( + signon_dod_warning_banner_configured, + ) + + findings = signon_dod_warning_banner_configured().execute() + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "does not contain" in findings[0].status_extended + def test_emits_one_finding_per_brand(self): compliant = sign_in_page( brand_id="brand-prod", @@ -149,6 +203,7 @@ class Test_signon_dod_warning_banner_configured: brand_id="brand-legacy", brand_name="Legacy", is_customized=False, + page_content=f"{DOD_BANNER_HTML_SNIPPET}", ) signon_client = build_signon_client( sign_in_pages={ @@ -174,5 +229,5 @@ class Test_signon_dod_warning_banner_configured: assert by_brand == { "brand-prod": "PASS", "brand-sandbox": "FAIL", - "brand-legacy": "MANUAL", + "brand-legacy": "PASS", } diff --git a/tests/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h_test.py b/tests/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h_test.py index ee1e6cca31..bd2a6780d8 100644 --- a/tests/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h_test.py +++ b/tests/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h_test.py @@ -120,6 +120,33 @@ class Test_signon_global_session_lifetime_18h: assert findings[0].status == "FAIL" assert "does not define" in findings[0].status_extended + def test_fail_when_lifetime_is_disabled_with_zero(self): + policy = default_policy( + [ + non_default_rule("Unlimited Lifetime", lifetime_min=0, priority=1), + default_rule(priority=2), + ] + ) + signon_client = build_signon_client({"pol-default": policy}) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_global_session_lifetime_18h.signon_global_session_lifetime_18h import ( + signon_global_session_lifetime_18h, + ) + + findings = signon_global_session_lifetime_18h().execute() + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "0 minutes" in findings[0].status_extended + assert "disables the maximum Okta global session lifetime" in ( + findings[0].status_extended + ) + def test_fail_when_default_rule_is_priority_one(self): policy = default_policy( [ diff --git a/tests/providers/okta/services/signon/signon_service_test.py b/tests/providers/okta/services/signon/signon_service_test.py index b9c9f27898..3e2d4ddaa8 100644 --- a/tests/providers/okta/services/signon/signon_service_test.py +++ b/tests/providers/okta/services/signon/signon_service_test.py @@ -192,6 +192,7 @@ class Test_Signon_service_brands: provider, brands_response, sign_in_page_responses: dict, + default_sign_in_page_responses: dict | None = None, ): async def fake_list_policies(*_a, **_k): return ([], _resp({}), None) @@ -202,6 +203,9 @@ class Test_Signon_service_brands: async def fake_get_sign_in_page(brand_id, *_a, **_k): return sign_in_page_responses[brand_id] + async def fake_get_default_sign_in_page(brand_id, *_a, **_k): + return default_sign_in_page_responses[brand_id] + with mock.patch( "prowler.providers.okta.lib.service.service.OktaSDKClient" ) as mocked_client_cls: @@ -209,6 +213,7 @@ class Test_Signon_service_brands: mocked.list_policies = fake_list_policies mocked.list_brands = fake_list_brands mocked.get_customized_sign_in_page = fake_get_sign_in_page + mocked.get_default_sign_in_page = fake_get_default_sign_in_page mocked_client_cls.return_value = mocked return Signon(provider) @@ -229,7 +234,27 @@ class Test_Signon_service_brands: assert result.page_content == "banner here" assert result.fetch_error is None - def test_404_marks_brand_as_not_customized(self): + def test_404_falls_back_to_default_sign_in_page(self): + provider = set_mocked_okta_provider() + brand = _fake_brand("brand-1", "Primary") + default_page = _fake_sign_in_page("default banner here") + service = self._build_with_brands( + provider, + brands_response=([brand], _resp({}), None), + sign_in_page_responses={ + "brand-1": (None, _resp({}), Exception("404 Not Found")) + }, + default_sign_in_page_responses={"brand-1": (default_page, _resp({}), None)}, + ) + + assert service.sign_in_pages["brand-1"].is_customized is False + assert service.sign_in_pages["brand-1"].fetch_error is None + assert ( + service.sign_in_pages["brand-1"].page_content + == "default banner here" + ) + + def test_default_sign_in_page_error_captured_when_customized_page_missing(self): provider = set_mocked_okta_provider() brand = _fake_brand("brand-1", "Primary") service = self._build_with_brands( @@ -238,10 +263,14 @@ class Test_Signon_service_brands: sign_in_page_responses={ "brand-1": (None, _resp({}), Exception("404 Not Found")) }, + default_sign_in_page_responses={ + "brand-1": (None, _resp({}), Exception("403 Forbidden")) + }, ) - assert service.sign_in_pages["brand-1"].is_customized is False - assert service.sign_in_pages["brand-1"].fetch_error is None + result = service.sign_in_pages["brand-1"] + assert result.is_customized is False + assert "403" in result.fetch_error def test_403_captured_into_fetch_error(self): provider = set_mocked_okta_provider() @@ -252,6 +281,7 @@ class Test_Signon_service_brands: sign_in_page_responses={ "brand-1": (None, _resp({}), Exception("403 Forbidden: invalid_scope")) }, + default_sign_in_page_responses={}, ) result = service.sign_in_pages["brand-1"] @@ -291,8 +321,16 @@ class Test_Signon_service_brands: "brand-a": (page_a, _resp({}), None), "brand-b": (None, _resp({}), Exception("404 not found")), }, + default_sign_in_page_responses={ + "brand-b": ( + _fake_sign_in_page("default B"), + _resp({}), + None, + ) + }, ) assert set(service.sign_in_pages.keys()) == {"brand-a", "brand-b"} assert service.sign_in_pages["brand-a"].page_content == "A" assert service.sign_in_pages["brand-b"].is_customized is False + assert service.sign_in_pages["brand-b"].page_content == "default B"