diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx index a30bae3810..da820eb45a 100644 --- a/docs/user-guide/providers/okta/authentication.mdx +++ b/docs/user-guide/providers/okta/authentication.mdx @@ -30,15 +30,17 @@ If a different authentication method is needed (SSWS API token, OAuth with user ### Required OAuth Scopes -For the initial check (`signon_global_session_idle_timeout_15min`) only one scope is required: +The bundled signon checks require the following read-only scopes: - `okta.policies.read` +- `okta.brands.read` -Additional scopes will be needed as more services and checks are added, this are the current ones needed: +Additional scopes will be needed as more services and checks are added. These are the current ones needed: | Scope | Used by | |---|---| | `okta.policies.read` | Sign-on / password / authentication policies | +| `okta.brands.read` | Sign-in page customizations (DOD Notice and Consent Banner check) | ### Required Admin Role @@ -96,7 +98,7 @@ Okta displays the private key **only once**. If you close the modal without copy ### 5. Grant the required OAuth scopes -On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. For the initial release, granting only `okta.policies.read` is sufficient. +On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled signon checks require `okta.policies.read` and `okta.brands.read`. ![Okta — grant OAuth scopes](/user-guide/providers/okta/images/grant-permissions.png) @@ -130,8 +132,8 @@ export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem" # or export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)" -# Optional — defaults to "okta.policies.read" -export OKTA_SCOPES="okta.policies.read" +# Optional — defaults to "okta.policies.read,okta.brands.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read" uv run python prowler-cli.py okta ``` @@ -172,7 +174,7 @@ Prowler validates credentials at startup by listing one sign-on policy. This err Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role: -- **`invalid_scope`** — the `okta.policies.read` scope is not granted on the service app. Grant it from **Okta API Scopes**. +- **`invalid_scope`** — one of the requested scopes (`okta.policies.read` or `okta.brands.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**. - **`Forbidden` / `not authorized`** — the **Read-Only Administrator** role is not assigned to the service app. Assign it from **Admin roles**. ### `invalid_dpop_proof` diff --git a/docs/user-guide/providers/okta/getting-started-okta.mdx b/docs/user-guide/providers/okta/getting-started-okta.mdx index 6486736bd5..028b901abf 100644 --- a/docs/user-guide/providers/okta/getting-started-okta.mdx +++ b/docs/user-guide/providers/okta/getting-started-okta.mdx @@ -12,7 +12,7 @@ Set up authentication for Okta with the [Okta Authentication](/user-guide/provid - An Okta organization. The UI examples below use **Identity Engine** terminology such as **Global Session Policy**; Classic Engine exposes the equivalent sign-on policy concepts under older names. - A **Super Administrator** account on that organization for the one-time service-app setup. -- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read` scope granted and the **Read-Only Administrator** role assigned. +- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read` and `okta.brands.read` scopes granted and the **Read-Only Administrator** role assigned. - Python 3.10+ and Prowler 5.27.0 or later installed locally. @@ -44,8 +44,8 @@ Follow the [Okta Authentication](/user-guide/providers/okta/authentication) guid export OKTA_ORG_DOMAIN="acme.okta.com" export OKTA_CLIENT_ID="0oa1234567890abcdef" export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem" -# Optional — defaults to "okta.policies.read" -export OKTA_SCOPES="okta.policies.read" +# Optional — defaults to "okta.policies.read,okta.brands.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read" ``` The private key file may contain either a PEM-encoded RSA key or a JWK JSON document. @@ -113,20 +113,21 @@ This is stricter than simply finding the same timeout value somewhere else in th ### Default Scopes -Prowler requests a fixed set of OAuth scopes on every token exchange. The default is a single scope that covers the bundled initial check: +Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover the bundled signon checks: - `okta.policies.read` +- `okta.brands.read` -The service app must have that scope granted in the **Okta API Scopes** tab. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization. +The service app must have these scopes granted in the **Okta API Scopes** tab. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization. When additional checks are enabled — or when running against a service app that exposes a different scope set — override the default with `OKTA_SCOPES` (comma-separated string for the env var) or `--okta-scopes` (space-separated list for the CLI): ```bash # Environment variable — comma-separated -export OKTA_SCOPES="okta.policies.read,okta.apps.read,okta.users.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.users.read" # CLI flag — space-separated -prowler okta --okta-scopes okta.policies.read okta.apps.read okta.users.read +prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.users.read ``` For the full catalog of OAuth scopes exposed by the Okta Management API, refer to the [Okta OAuth 2.0 scopes documentation](https://developer.okta.com/docs/api/oauth2/). diff --git a/prowler/providers/okta/lib/arguments/arguments.py b/prowler/providers/okta/lib/arguments/arguments.py index 3ad27de1c7..82c891e70c 100644 --- a/prowler/providers/okta/lib/arguments/arguments.py +++ b/prowler/providers/okta/lib/arguments/arguments.py @@ -35,8 +35,8 @@ def init_parser(self): nargs="+", help=( "OAuth scopes to request, space-separated " - "(e.g. okta.policies.read okta.users.read). Defaults to the " - "read scopes required by the bundled checks." + "(e.g. okta.policies.read okta.brands.read okta.users.read). " + "Defaults to the read scopes required by the bundled checks." ), default=None, metavar="OKTA_SCOPES", diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py index d26cfaee5c..53095d5488 100644 --- a/prowler/providers/okta/okta_provider.py +++ b/prowler/providers/okta/okta_provider.py @@ -29,7 +29,7 @@ from prowler.providers.okta.exceptions.exceptions import ( from prowler.providers.okta.lib.mutelist.mutelist import OktaMutelist from prowler.providers.okta.models import OktaIdentityInfo, OktaSession -DEFAULT_SCOPES = ["okta.policies.read"] +DEFAULT_SCOPES = ["okta.policies.read", "okta.brands.read"] # Accept only Okta-managed domains. Custom (vanity) domains are rejected on # purpose — they're a recurring source of typos and silent misconfig and # Prowler's audience overwhelmingly uses Okta-managed hosts. The TLDs below diff --git a/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/__init__.py b/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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 new file mode 100644 index 0000000000..ca7ee4fe8c --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "okta", + "CheckID": "signon_dod_warning_banner_configured", + "CheckTitle": "Okta sign-in page displays the Standard Mandatory DOD Notice and Consent Banner", + "CheckType": [], + "ServiceName": "signon", + "SubServiceName": "", + "ResourceIdTemplate": "", + "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.", + "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": [ + "https://help.okta.com/oie/en-us/content/topics/settings/settings-customization.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/CustomPages/", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Brands/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Follow the supplemental 'Okta DOD Warning Banner Configuration Guide' shipped with the DISA Okta IDaaS STIG package\n2. Sign in to the Okta Admin Console as a Super Admin\n3. Go to Customizations > Brands and select the brand\n4. Edit the Sign-in page customization\n5. Insert the full DTM-08-060 Standard Mandatory DOD Notice and Consent Banner text in the page content\n6. Publish the customization and verify the banner is presented before sign-in", + "Terraform": "" + }, + "Recommendation": { + "Text": "Customize the Okta sign-in page for each brand to display the Standard Mandatory DOD Notice and Consent Banner (DTM-08-060) before users authenticate.", + "Url": "https://hub.prowler.com/check/signon_dod_warning_banner_configured" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} 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 new file mode 100644 index 0000000000..63d46f1ced --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.py @@ -0,0 +1,105 @@ +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", +) +MIN_MARKER_MATCHES = 2 + + +class signon_dod_warning_banner_configured(Check): + """STIG V-273192 / OKTA-APP-000200. + + 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. + """ + + def execute(self) -> list[CheckReportOkta]: + org_domain = signon_client.provider.identity.org_domain + findings: list[CheckReportOkta] = [] + + if not signon_client.sign_in_pages: + placeholder = SignInPage( + brand_id="no-brands-detected", + brand_name="(no brands detected)", + is_customized=False, + ) + report = CheckReportOkta( + metadata=self.metadata(), + resource=placeholder, + org_domain=org_domain, + resource_name=placeholder.brand_name, + resource_id=placeholder.brand_id, + ) + report.status = "MANUAL" + report.status_extended = ( + "No Okta brands were retrieved from the Brands API. Verify " + "the sign-in page for the organization displays the DOD " + "Notice and Consent Banner (DTM-08-060) in the Admin Console." + ) + findings.append(report) + return findings + + for page in signon_client.sign_in_pages.values(): + report = CheckReportOkta( + metadata=self.metadata(), + resource=page, + org_domain=org_domain, + resource_name=page.brand_name or page.brand_id, + resource_id=page.brand_id, + ) + + if page.fetch_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve the customized sign-in page for " + f"brand '{page.brand_name or page.brand_id}' ({page.fetch_error}). " + "Inspect the brand customization 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: + 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." + ) + findings.append(report) + continue + + content_lower = page.page_content.lower() + matches = sum(1 for marker in BANNER_MARKERS if marker in content_lower) + + if matches >= MIN_MARKER_MATCHES: + report.status = "PASS" + report.status_extended = ( + f"DOD Notice and Consent Banner detected on the customized " + f"sign-in page for brand '{page.brand_name or page.brand_id}' " + f"({matches} of {len(BANNER_MARKERS)} marker phrases matched)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Customized 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)." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/okta/services/signon/signon_service.py b/prowler/providers/okta/services/signon/signon_service.py index 12c1349a2c..7abe15a46d 100644 --- a/prowler/providers/okta/services/signon/signon_service.py +++ b/prowler/providers/okta/services/signon/signon_service.py @@ -30,11 +30,16 @@ def _next_after_cursor(resp) -> Optional[str]: class Signon(OktaService): - """Fetches OKTA_SIGN_ON policies and their rules. + """Fetches OKTA_SIGN_ON policies, rules, and brand sign-in pages. Populates `self.global_session_policies` keyed by policy id. Each 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. """ def __init__(self, provider): @@ -42,6 +47,7 @@ class Signon(OktaService): self.global_session_policies: dict[str, GlobalSessionPolicy] = ( self._list_global_session_policies() ) + self.sign_in_pages: dict[str, SignInPage] = self._list_sign_in_pages() def _list_global_session_policies(self) -> dict: logger.info("Signon - Listing OKTA_SIGN_ON policies and rules...") @@ -125,6 +131,63 @@ class Signon(OktaService): ) return rules_out + def _list_sign_in_pages(self) -> dict: + logger.info("Signon - Listing brand sign-in pages...") + try: + return self._run(self._fetch_brands_and_pages()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_brands_and_pages(self) -> dict: + result: dict[str, SignInPage] = {} + all_brands, err = await self._paginate( + lambda after: self.client.list_brands(after=after) + ) + if err is not None: + logger.error(f"Error listing brands: {err}") + return result + + 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( + brand_id=brand_id, + brand_name=brand_name, + is_customized=True, + page_content=getattr(page_data, "page_content", None), + ) + return result + @staticmethod async def _paginate(fetch): """Drain all pages of an SDK list call. @@ -176,3 +239,11 @@ class GlobalSessionPolicy(BaseModel): status: str = "" is_default: bool = False rules: list[GlobalSessionPolicyRule] = [] + + +class SignInPage(BaseModel): + brand_id: str + brand_name: str = "" + is_customized: bool = False + page_content: Optional[str] = None + fetch_error: Optional[str] = None diff --git a/tests/providers/okta/okta_fixtures.py b/tests/providers/okta/okta_fixtures.py index 42e68b02c5..58eed16862 100644 --- a/tests/providers/okta/okta_fixtures.py +++ b/tests/providers/okta/okta_fixtures.py @@ -16,7 +16,7 @@ def set_mocked_okta_provider( session = OktaSession( org_domain=OKTA_ORG_DOMAIN, client_id=OKTA_CLIENT_ID, - scopes=["okta.policies.read"], + scopes=["okta.policies.read", "okta.brands.read"], private_key=OKTA_PRIVATE_KEY, ) if identity is None: 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 new file mode 100644 index 0000000000..bbdaca2ae1 --- /dev/null +++ b/tests/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured_test.py @@ -0,0 +1,178 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.signon.signon_fixtures import ( + DOD_BANNER_HTML_SNIPPET, + build_signon_client, + sign_in_page, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.signon." + "signon_dod_warning_banner_configured." + "signon_dod_warning_banner_configured.signon_client" +) + + +class Test_signon_dod_warning_banner_configured: + def test_manual_when_no_brands_detected(self): + signon_client = build_signon_client(sign_in_pages={}) + 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 == "MANUAL" + assert "No Okta brands were retrieved" in findings[0].status_extended + + def test_pass_when_customized_page_contains_banner(self): + page = sign_in_page( + brand_id="brand-1", + brand_name="Primary", + is_customized=True, + 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 "DOD Notice and Consent Banner detected" in ( + findings[0].status_extended + ) + + def test_fail_when_customized_page_missing_banner(self): + page = sign_in_page( + brand_id="brand-1", + brand_name="Primary", + is_customized=True, + page_content="

Welcome to ACME

", + ) + 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_manual_when_no_customization(self): + page = sign_in_page( + brand_id="brand-1", + brand_name="Primary", + is_customized=False, + page_content=None, + ) + 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 == "MANUAL" + assert "No customized sign-in page" in findings[0].status_extended + + def test_manual_when_fetch_error(self): + page = sign_in_page( + brand_id="brand-1", + brand_name="Primary", + is_customized=False, + fetch_error="403 Forbidden: invalid_scope", + ) + 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 == "MANUAL" + assert "Could not retrieve" in findings[0].status_extended + assert "403" in findings[0].status_extended + + def test_emits_one_finding_per_brand(self): + compliant = sign_in_page( + brand_id="brand-prod", + brand_name="Prod", + is_customized=True, + page_content=f"{DOD_BANNER_HTML_SNIPPET}", + ) + missing = sign_in_page( + brand_id="brand-sandbox", + brand_name="Sandbox", + is_customized=True, + page_content="No banner here", + ) + no_custom = sign_in_page( + brand_id="brand-legacy", + brand_name="Legacy", + is_customized=False, + ) + signon_client = build_signon_client( + sign_in_pages={ + "brand-prod": compliant, + "brand-sandbox": missing, + "brand-legacy": no_custom, + } + ) + 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) == 3 + by_brand = {f.resource_id: f.status for f in findings} + assert by_brand == { + "brand-prod": "PASS", + "brand-sandbox": "FAIL", + "brand-legacy": "MANUAL", + } diff --git a/tests/providers/okta/services/signon/signon_service_test.py b/tests/providers/okta/services/signon/signon_service_test.py index 4762b16b88..b9c9f27898 100644 --- a/tests/providers/okta/services/signon/signon_service_test.py +++ b/tests/providers/okta/services/signon/signon_service_test.py @@ -3,6 +3,7 @@ from unittest import mock from prowler.providers.okta.services.signon.signon_service import ( GlobalSessionPolicy, GlobalSessionPolicyRule, + SignInPage, Signon, _next_after_cursor, ) @@ -48,12 +49,29 @@ def _fake_rule( return r +def _fake_brand(brand_id: str, name: str): + b = mock.MagicMock() + b.id = brand_id + b.name = name + return b + + +def _fake_sign_in_page(page_content: str): + p = mock.MagicMock() + p.page_content = page_content + return p + + def _resp(headers: dict = None): r = mock.MagicMock() r.headers = headers or {} return r +async def _empty_brands(*_a, **_k): + return ([], _resp({}), None) + + class Test_next_after_cursor: def test_no_resp_returns_none(self): assert _next_after_cursor(None) is None @@ -97,6 +115,7 @@ class Test_Signon_service: mocked = mock.MagicMock() mocked.list_policies = fake_list_policies mocked.list_policy_rules = fake_list_rules + mocked.list_brands = _empty_brands mocked_client_cls.return_value = mocked service = Signon(provider) @@ -140,6 +159,7 @@ class Test_Signon_service: mocked = mock.MagicMock() mocked.list_policies = fake_list_policies mocked.list_policy_rules = fake_list_rules + mocked.list_brands = _empty_brands mocked_client_cls.return_value = mocked service = Signon(provider) @@ -157,7 +177,122 @@ class Test_Signon_service: ) as mocked_client_cls: mocked = mock.MagicMock() mocked.list_policies = failing + mocked.list_brands = _empty_brands mocked_client_cls.return_value = mocked service = Signon(provider) assert service.global_session_policies == {} + + +class Test_Signon_service_brands: + """Brand sign-in page fetching for the DOD banner check.""" + + def _build_with_brands( + self, + provider, + brands_response, + sign_in_page_responses: dict, + ): + async def fake_list_policies(*_a, **_k): + return ([], _resp({}), None) + + async def fake_list_brands(*_a, **_k): + return brands_response + + async def fake_get_sign_in_page(brand_id, *_a, **_k): + return sign_in_page_responses[brand_id] + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_brands = fake_list_brands + mocked.get_customized_sign_in_page = fake_get_sign_in_page + mocked_client_cls.return_value = mocked + return Signon(provider) + + def test_fetches_brand_with_customized_page(self): + provider = set_mocked_okta_provider() + brand = _fake_brand("brand-1", "Primary") + page = _fake_sign_in_page("banner here") + service = self._build_with_brands( + provider, + brands_response=([brand], _resp({}), None), + sign_in_page_responses={"brand-1": (page, _resp({}), None)}, + ) + + assert "brand-1" in service.sign_in_pages + result = service.sign_in_pages["brand-1"] + assert isinstance(result, SignInPage) + assert result.is_customized is True + assert result.page_content == "banner here" + assert result.fetch_error is None + + def test_404_marks_brand_as_not_customized(self): + provider = set_mocked_okta_provider() + brand = _fake_brand("brand-1", "Primary") + service = self._build_with_brands( + provider, + brands_response=([brand], _resp({}), None), + sign_in_page_responses={ + "brand-1": (None, _resp({}), Exception("404 Not Found")) + }, + ) + + assert service.sign_in_pages["brand-1"].is_customized is False + assert service.sign_in_pages["brand-1"].fetch_error is None + + def test_403_captured_into_fetch_error(self): + provider = set_mocked_okta_provider() + brand = _fake_brand("brand-1", "Primary") + service = self._build_with_brands( + provider, + brands_response=([brand], _resp({}), None), + sign_in_page_responses={ + "brand-1": (None, _resp({}), Exception("403 Forbidden: invalid_scope")) + }, + ) + + result = service.sign_in_pages["brand-1"] + assert result.is_customized is False + assert "403" in result.fetch_error + + def test_returns_empty_on_brands_api_error(self): + provider = set_mocked_okta_provider() + + async def fake_list_policies(*_a, **_k): + return ([], _resp({}), None) + + async def failing_brands(*_a, **_k): + return ([], _resp({}), Exception("Brands API unavailable")) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_brands = failing_brands + mocked_client_cls.return_value = mocked + service = Signon(provider) + + assert service.sign_in_pages == {} + + def test_handles_multiple_brands(self): + provider = set_mocked_okta_provider() + brand_a = _fake_brand("brand-a", "Brand A") + brand_b = _fake_brand("brand-b", "Brand B") + page_a = _fake_sign_in_page("A") + + service = self._build_with_brands( + provider, + brands_response=([brand_a, brand_b], _resp({}), None), + sign_in_page_responses={ + "brand-a": (page_a, _resp({}), None), + "brand-b": (None, _resp({}), Exception("404 not found")), + }, + ) + + 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