From 349611d52d7e4377ee50404ebaa43af6141b58db Mon Sep 17 00:00:00 2001 From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> Date: Thu, 21 May 2026 12:48:06 +0200 Subject: [PATCH] feat(okta): 4 new signon service checks (#11224) --- .../providers/okta/authentication.mdx | 14 +- .../providers/okta/getting-started-okta.mdx | 15 +- prowler/CHANGELOG.md | 1 + .../providers/okta/lib/arguments/arguments.py | 4 +- prowler/providers/okta/models.py | 6 + prowler/providers/okta/okta_provider.py | 66 +++- .../okta/services/signon/lib/__init__.py | 0 .../services/signon/lib/signon_helpers.py | 146 +++++++ .../__init__.py | 0 ...od_warning_banner_configured.metadata.json | 38 ++ .../signon_dod_warning_banner_configured.py | 129 ++++++ .../__init__.py | 0 ...ssion_cookies_not_persistent.metadata.json | 37 ++ ...n_global_session_cookies_not_persistent.py | 100 +++++ ...l_session_idle_timeout_15min.metadata.json | 10 +- ...ignon_global_session_idle_timeout_15min.py | 186 ++++----- .../__init__.py | 0 ..._global_session_lifetime_18h.metadata.json | 37 ++ .../signon_global_session_lifetime_18h.py | 114 ++++++ .../__init__.py | 0 ...policy_network_zone_enforced.metadata.json | 38 ++ ...al_session_policy_network_zone_enforced.py | 95 +++++ .../okta/services/signon/signon_service.py | 113 +++++- tests/providers/okta/okta_fixtures.py | 3 +- tests/providers/okta/okta_provider_test.py | 122 ++++++ ...gnon_dod_warning_banner_configured_test.py | 257 ++++++++++++ .../okta/services/signon/signon_fixtures.py | 130 ++++++ ...bal_session_cookies_not_persistent_test.py | 189 +++++++++ ..._global_session_idle_timeout_15min_test.py | 370 +++++++----------- ...signon_global_session_lifetime_18h_test.py | 212 ++++++++++ ...ssion_policy_network_zone_enforced_test.py | 222 +++++++++++ .../services/signon/signon_service_test.py | 271 ++++++++++++- 32 files changed, 2563 insertions(+), 362 deletions(-) create mode 100644 prowler/providers/okta/services/signon/lib/__init__.py create mode 100644 prowler/providers/okta/services/signon/lib/signon_helpers.py create mode 100644 prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/__init__.py create mode 100644 prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.metadata.json create mode 100644 prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.py create mode 100644 prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/__init__.py create mode 100644 prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent.metadata.json create mode 100644 prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent.py create mode 100644 prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/__init__.py create mode 100644 prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.metadata.json create mode 100644 prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.py create mode 100644 prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/__init__.py create mode 100644 prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced.metadata.json create mode 100644 prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced.py create mode 100644 tests/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured_test.py create mode 100644 tests/providers/okta/services/signon/signon_fixtures.py create mode 100644 tests/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent_test.py create mode 100644 tests/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h_test.py create mode 100644 tests/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced_test.py 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/CHANGELOG.md b/prowler/CHANGELOG.md index 10b3654290..b3916f16b8 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Google Workspace Groups service with 3 new checks [(#11186)](https://github.com/prowler-cloud/prowler/pull/11186) - `ses_identity_dkim_enabled` check for AWS provider [(#10923)](https://github.com/prowler-cloud/prowler/pull/10923) - `sagemaker_models_registry_in_use` check for AWS provider, verifying that at least one SageMaker Model Package Group has an approved model package to enforce ML governance workflows [(#11196)](https://github.com/prowler-cloud/prowler/pull/11196) +- `signon_dod_warning_banner_configured`, `signon_global_session_lifetime_18h`, `signon_global_session_cookies_not_persistent` and `signon_global_session_policy_network_zone_enforced` checks for Okta provider [(#11224)](https://github.com/prowler-cloud/prowler/pull/11224) ### 🔄 Changed 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/models.py b/prowler/providers/okta/models.py index 0f9c14a87b..b3f5c1f1cc 100644 --- a/prowler/providers/okta/models.py +++ b/prowler/providers/okta/models.py @@ -32,6 +32,12 @@ class OktaSession(BaseModel): class OktaIdentityInfo(BaseModel): org_domain: str client_id: str + # Scopes actually granted in the access token (`scp` claim). Used by + # services to distinguish "no data" from "no permission" so checks can + # surface the missing scope rather than a misleading FAIL. Empty when + # decoding the token was not possible — callers must treat empty as + # "unknown" and fall back to attempting the API call. + granted_scopes: list[str] = [] class OktaOutputOptions(ProviderOutputOptions): diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py index 34958caab2..a6bff16d80 100644 --- a/prowler/providers/okta/okta_provider.py +++ b/prowler/providers/okta/okta_provider.py @@ -1,4 +1,6 @@ import asyncio +import base64 +import json import os import re from os import environ @@ -30,7 +32,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 @@ -285,14 +287,32 @@ class OktaProvider(Provider): org URL plus the service-app client ID. We still hit the cheapest scope-covered endpoint (`list_policies` with limit=1) to fail loud when credentials, scopes, or the granted admin role are wrong. + + After the probe succeeds, the access token's `scp` claim is + decoded and exposed on the identity. Services compare it against + their required scope so checks can emit "missing scope X" rather + than a misleading "no resources returned" finding. """ async def _probe(): client = OktaSDKClient(session.to_sdk_config()) - return await client.list_policies(type="OKTA_SIGN_ON", limit="1") + result = await client.list_policies(type="OKTA_SIGN_ON", limit="1") + access_token = None + # The OAuth helper caches the token on `_access_token` after + # the first authenticated call. Reach through `_request_executor` + # — a documented internal but a moving target across SDK + # versions, so any failure here degrades silently to empty + # granted_scopes (services then fall back to attempting calls). + try: + oauth = getattr(client._request_executor, "_oauth", None) + if oauth is not None: + access_token = getattr(oauth, "_access_token", None) + except Exception: + access_token = None + return result, access_token try: - result = asyncio.run(_probe()) + result, access_token = asyncio.run(_probe()) # SDK returns (items, resp, err) on the normal path and (items, err) # only on early request-creation errors. The error is always last. err = result[-1] @@ -305,6 +325,11 @@ class OktaProvider(Provider): "forbidden", "not authorized", "permission", + # Okta emits HTTP 400 `consent_required` when none of the + # requested scopes are consented on the service app — + # semantically a permission gap, not a credential one. + "consent_required", + "not allowed", ) if any(signal in err_text for signal in permission_signals): raise OktaInsufficientPermissionsError( @@ -321,6 +346,7 @@ class OktaProvider(Provider): return OktaIdentityInfo( org_domain=session.org_domain, client_id=session.client_id, + granted_scopes=OktaProvider._decode_token_scopes(access_token), ) except (OktaInvalidCredentialsError, OktaInsufficientPermissionsError): raise @@ -330,6 +356,40 @@ class OktaProvider(Provider): ) raise OktaSetUpIdentityError(original_exception=error) + @staticmethod + def _decode_token_scopes(access_token: Optional[str]) -> list[str]: + """Return the `scp` claim from a JWT access token, or `[]` on failure. + + No signature verification: the token came from Okta over TLS via + the SDK's OAuth handshake, so the only thing we extract is the + scope claim. Any decode error returns an empty list — callers + must treat empty as "unknown" rather than "no scopes granted". + """ + if not access_token: + return [] + try: + parts = access_token.split(".") + if len(parts) < 2: + return [] + payload_b64 = parts[1] + # Base64url pad to a multiple of 4 — JWT segments are + # unpadded per RFC 7515. + padding = "=" * (-len(payload_b64) % 4) + payload_bytes = base64.urlsafe_b64decode(payload_b64 + padding) + payload = json.loads(payload_bytes) + scp = payload.get("scp") + if isinstance(scp, list): + return [str(s) for s in scp if s] + if isinstance(scp, str): + return [s for s in scp.split(" ") if s] + return [] + except Exception as error: + logger.warning( + f"Could not decode Okta access token scopes: " + f"{error.__class__.__name__}: {error}" + ) + return [] + def print_credentials(self): report_lines = [ f"Okta Domain: {Fore.YELLOW}{self.identity.org_domain}{Style.RESET_ALL}", diff --git a/prowler/providers/okta/services/signon/lib/__init__.py b/prowler/providers/okta/services/signon/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/signon/lib/signon_helpers.py b/prowler/providers/okta/services/signon/lib/signon_helpers.py new file mode 100644 index 0000000000..7c84fe5733 --- /dev/null +++ b/prowler/providers/okta/services/signon/lib/signon_helpers.py @@ -0,0 +1,146 @@ +"""Shared helpers for the OKTA sign-on STIG checks. + +The four `signon_global_session_*` checks share the same plumbing: +they iterate active Global Session Policies in priority order, locate +each policy's Priority 1 active rule, and emit one finding per policy. +This module centralises that plumbing so each check can stay focused +on its STIG-specific predicate. +""" + +from typing import Optional + +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.signon.signon_service import ( + GlobalSessionPolicy, + GlobalSessionPolicyRule, + SignInPage, +) + + +def active_policies( + global_session_policies: dict[str, GlobalSessionPolicy], +) -> list[GlobalSessionPolicy]: + """Return active policies sorted by priority (ascending, name as tiebreaker). + + A policy with no `status` is treated as ACTIVE because the Okta SDK + sometimes omits the field on default policies. + """ + return sorted( + [ + policy + for policy in global_session_policies.values() + if not policy.status or policy.status.upper() == "ACTIVE" + ], + key=lambda policy: ( + policy.priority if policy.priority is not None else float("inf"), + policy.name, + ), + ) + + +def priority_one_active_rule( + policy: GlobalSessionPolicy, +) -> Optional[GlobalSessionPolicyRule]: + """Return the policy's Priority 1 active rule, or None. + + Okta's evaluator skips inactive rules, so we first filter to active + rules and pick the highest-priority one. If that rule is not at + priority 1 we return None — the policy effectively has no + priority-1 rule for evaluation purposes. + """ + active_rules = sorted( + [ + rule + for rule in policy.rules + if not rule.status or rule.status.upper() == "ACTIVE" + ], + key=lambda rule: ( + rule.priority if rule.priority is not None else float("inf"), + rule.name, + ), + ) + if not active_rules: + return None + candidate = active_rules[0] + if candidate.priority != 1: + return None + return candidate + + +def policy_label(policy: GlobalSessionPolicy) -> str: + kind = "default" if policy.is_default else "custom" + priority = policy.priority if policy.priority is not None else "unset" + return f"Global Session Policy '{policy.name}' (priority {priority}, {kind})" + + +def no_active_policies_finding( + metadata, org_domain: str, status_extended: str +) -> CheckReportOkta: + """Build the FAIL finding emitted when no active sign-on policies exist.""" + placeholder = GlobalSessionPolicy( + id="signon-policies-missing", + name="(no active sign-on policies)", + priority=1, + status="MISSING", + is_default=False, + rules=[], + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "FAIL" + report.status_extended = status_extended + return report + + +_SCOPE_ADVICE = ( + "Grant it on the service app's Okta API Scopes tab in the Okta Admin " + "Console, then re-run the check." +) + + +def missing_policy_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding for a sign-on policy check when the API scope is not granted.""" + placeholder = GlobalSessionPolicy( + id="signon-policies-scope-missing", + name="(scope not granted)", + priority=1, + status="MISSING", + is_default=False, + rules=[], + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Global Session Policies: the Okta service app " + f"is missing the required `{scope}` API scope. {_SCOPE_ADVICE}" + ) + return report + + +def missing_brand_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding for a brand/sign-in-page check when the API scope is not granted.""" + placeholder = SignInPage( + brand_id="signon-brands-scope-missing", + brand_name="(scope not granted)", + is_customized=False, + ) + report = CheckReportOkta( + metadata=metadata, + resource=placeholder, + org_domain=org_domain, + resource_name=placeholder.brand_name, + resource_id=placeholder.brand_id, + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Okta brand sign-in pages: the Okta service app " + f"is missing the required `{scope}` API scope. {_SCOPE_ADVICE}" + ) + return report 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..523795c8ac --- /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": "informational", + "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.\n\nThe check inspects the 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.\n\nAligns with **DISA STIG V-273192 / OKTA-APP-000200**.", + "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.\n\n- **Legal basis** for incident response and prosecution is weakened\n- **Alignment** with federal laws, Executive Orders, directives, and standards is broken\n- **Implied consent** to monitoring cannot be asserted on connection", + "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. Navigate 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 into 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.\n\nApplies only to Okta tenants under **U.S. Department of Defense** scope; non-DOD organizations can mute this check.", + "Url": "https://hub.prowler.com/check/signon_dod_warning_banner_configured" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Applicable only to Okta tenants under U.S. Department of Defense scope (DISA Okta IDaaS STIG, control V-273192 / OKTA-APP-000200). For non-DOD organizations this check is not applicable and can be muted." +} 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..347f04f245 --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured.py @@ -0,0 +1,129 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.signon.lib.signon_helpers import ( + missing_brand_scope_finding, +) +from prowler.providers.okta.services.signon.signon_client import signon_client +from prowler.providers.okta.services.signon.signon_service import SignInPage + +# 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", + ), +) + + +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): + """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 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]: + org_domain = signon_client.provider.identity.org_domain + findings: list[CheckReportOkta] = [] + + missing_scope = signon_client.missing_scope.get("sign_in_pages") + if missing_scope: + return [ + missing_brand_scope_finding(self.metadata(), org_domain, missing_scope) + ] + + 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 sign-in page for " + f"brand '{page.brand_name or page.brand_id}' ({page.fetch_error}). " + "Inspect the brand manually to confirm the " + "DOD Notice and Consent Banner (DTM-08-060) is displayed." + ) + findings.append(report) + continue + + if not page.page_content: + report.status = "MANUAL" + report.status_extended = ( + 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 = _matched_banner_groups(content_lower) + + if len(matches) == len(BANNER_MARKER_GROUPS): + report.status = "PASS" + report.status_extended = ( + 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"({len(matches)} of {len(BANNER_MARKER_GROUPS)} required " + "marker groups matched)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + 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)." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/__init__.py b/prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent.metadata.json b/prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent.metadata.json new file mode 100644 index 0000000000..c4738fd5bf --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "signon_global_session_cookies_not_persistent", + "CheckTitle": "Default Global Session Policy has a Priority 1 non-default rule disabling persistent global session cookies", + "CheckType": [], + "ServiceName": "signon", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "Every active Okta **Global Session Policy** needs a **Priority 1** rule that is **not** the built-in `Default Rule`, setting *Okta global session cookies persist across browser sessions* to `Disabled`.\n\nOkta evaluates policies by group assignment, so a permissive custom policy can govern users. Aligns with **DISA STIG V-273206 / OKTA-APP-001710**.", + "Risk": "Persistent global session cookies keep an authenticated Okta session alive across browser restarts.\n\n- **Surviving sessions** outlive the browsing context the user expected\n- **Cached authorization decisions** remain in effect after the browser closes\n- **Forgotten or shared devices** continue to hold authenticated access until cookies expire on their own", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/identity-engine/policies/about-okta-sign-on-policies.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Policy/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Global Session Policy**.\n3. Open the **Default Policy** (and repeat for every other active policy).\n4. Add or edit a non-default rule.\n5. Move that rule to **Priority 1** so it is evaluated before the built-in `Default Rule`.\n6. Set *Okta global session cookies persist across browser sessions* to `Disabled`.\n7. Save the rule.", + "Terraform": "```hcl\nresource \"okta_policy_rule_signon\" \"\" {\n policy_id = okta_policy_signon.default.id\n name = \"\"\n status = \"ACTIVE\"\n priority = 1 # Critical: rule must sit at Priority 1 before the Default Rule\n session_persistent = false # Critical: disable persistent global session cookies\n}\n```" + }, + "Recommendation": { + "Text": "Configure each active **Global Session Policy** so a non-default rule at **Priority 1**:\n- Sets *Okta global session cookies persist across browser sessions* to `Disabled`\n- Is enabled (`ACTIVE`) and evaluated before the built-in `Default Rule`\n\nReview group assignments to confirm the rule actually governs the intended users.", + "Url": "https://hub.prowler.com/check/signon_global_session_cookies_not_persistent" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent.py b/prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent.py new file mode 100644 index 0000000000..c5ffd849b5 --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent.py @@ -0,0 +1,100 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.signon.lib.signon_helpers import ( + active_policies, + missing_policy_scope_finding, + no_active_policies_finding, + policy_label, + priority_one_active_rule, +) +from prowler.providers.okta.services.signon.signon_client import signon_client +from prowler.providers.okta.services.signon.signon_service import GlobalSessionPolicy + + +class signon_global_session_cookies_not_persistent(Check): + """STIG V-273206 / OKTA-APP-001710. + + Every active Global Session Policy must have an active Priority 1 + rule that is not the built-in Default Rule, and that rule must + disable persistent global session cookies so the session does not + survive across browser restarts. + + Okta evaluates sign-on policies in priority order based on group + assignments, so a permissive custom policy can govern a user's + session even when the Default Policy is strict. The check emits one + finding per active policy to surface that risk. + """ + + def execute(self) -> list[CheckReportOkta]: + org_domain = signon_client.provider.identity.org_domain + + missing_scope = signon_client.missing_scope.get("global_session_policies") + if missing_scope: + return [ + missing_policy_scope_finding(self.metadata(), org_domain, missing_scope) + ] + + policies = active_policies(signon_client.global_session_policies) + if not policies: + return [ + no_active_policies_finding( + self.metadata(), + org_domain, + "No active Okta Global Session Policies were returned by the API. " + "STIG V-273206 requires the policy that governs each user to enforce " + "a Priority 1 non-default rule that disables persistent global " + "session cookies.", + ) + ] + + findings: list[CheckReportOkta] = [] + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + status, status_extended = _evaluate_policy(policy) + report.status = status + report.status_extended = status_extended + findings.append(report) + return findings + + +def _evaluate_policy(policy: GlobalSessionPolicy) -> tuple[str, str]: + label = policy_label(policy) + rule = priority_one_active_rule(policy) + + if rule is None: + return ( + "FAIL", + f"{label} has no Priority 1 active rule. STIG V-273206 requires " + "a non-default Priority 1 rule that disables persistent global " + "session cookies.", + ) + + if rule.is_default or rule.name == "Default Rule": + return ( + "FAIL", + f"{label} uses '{rule.name}' as its active Priority 1 rule. " + "The STIG requires a non-default Priority 1 rule.", + ) + + use_persistent_cookie = rule.use_persistent_cookie + if use_persistent_cookie is None: + return ( + "FAIL", + f"Priority 1 non-default rule '{rule.name}' in {label} " + "does not assert the 'Okta global session cookies persist across " + "browser sessions' setting.", + ) + + if use_persistent_cookie is False: + return ( + "PASS", + f"Priority 1 non-default rule '{rule.name}' in {label} " + "disables persistent global session cookies.", + ) + return ( + "FAIL", + f"Priority 1 non-default rule '{rule.name}' in {label} " + "allows persistent global session cookies, leaving the session active " + "across browser restarts.", + ) diff --git a/prowler/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min.metadata.json b/prowler/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min.metadata.json index 6a191b4a8a..f31a835864 100644 --- a/prowler/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min.metadata.json +++ b/prowler/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min.metadata.json @@ -9,8 +9,8 @@ "Severity": "medium", "ResourceType": "NotDefined", "ResourceGroup": "governance", - "Description": "The **Default Global Session Policy** must have a **Priority 1** rule that is **not** the built-in `Default Rule`, and that rule must set **Maximum Okta global session idle time** to 15 minutes or less. The threshold defaults to 15 minutes and is overridable via the `okta_max_session_idle_minutes` key in the audit config.", - "Risk": "Without a 15-minute idle timeout, an unattended workstation leaves an authenticated Okta session open indefinitely, allowing an attacker physical or remote access to take over the user's identity and pivot into every downstream application that trusts Okta SSO.", + "Description": "Every active Okta **Global Session Policy** needs a **Priority 1** rule that is **not** the built-in `Default Rule`, setting *Maximum Okta global session idle time* to `15` minutes or less.\n\nOkta evaluates policies by group assignment, so a permissive custom policy can govern users. Threshold override: `okta_max_session_idle_minutes`. Aligns with **DISA STIG V-273186**.", + "Risk": "Without a `15`-minute idle timeout, an unattended workstation leaves an authenticated Okta session open indefinitely.\n\n- **Session takeover** of the user's identity by anyone with physical or remote access\n- **Lateral movement** into every downstream application that trusts Okta SSO\n- **Bypassed reauthentication** even after the user has stepped away", "RelatedUrl": "", "AdditionalURLs": [ "https://help.okta.com/oie/en-us/content/topics/identity-engine/policies/about-okta-sign-on-policies.htm", @@ -20,11 +20,11 @@ "Code": { "CLI": "", "NativeIaC": "", - "Other": "1. Sign in to the Okta Admin Console as a Super Admin\n2. Go to Security > Global Session Policy\n3. Open the Default Policy\n4. Add or edit a non-default rule\n5. Move that rule to Priority 1 so it is evaluated before the built-in Default Rule\n6. Set 'Maximum Okta global session idle time' to 15 minutes or less\n7. Save the rule", - "Terraform": "resource \"okta_policy_rule_signon\" \"prowler_idle_timeout_15min\" {\n policy_id = okta_policy_signon.default.id\n name = \"Prowler-enforced idle timeout\"\n status = \"ACTIVE\"\n session_idle = 15\n session_persistent = false\n}\n" + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Global Session Policy**.\n3. Open the **Default Policy** (and repeat for every other active policy).\n4. Add or edit a non-default rule.\n5. Move that rule to **Priority 1** so it is evaluated before the built-in `Default Rule`.\n6. Set *Maximum Okta global session idle time* to `15` minutes or less.\n7. Save the rule.", + "Terraform": "```hcl\nresource \"okta_policy_rule_signon\" \"\" {\n policy_id = okta_policy_signon.default.id\n name = \"\"\n status = \"ACTIVE\"\n priority = 1 # Critical: rule must sit at Priority 1 before the Default Rule\n session_idle = 15 # Critical: enforce idle timeout at 15 minutes or less\n session_persistent = false # Critical: avoid persistent global session cookies\n}\n```" }, "Recommendation": { - "Text": "Configure the Default Global Session Policy so its Priority 1 non-default rule sets the Maximum Okta global session idle time to 15 minutes or less.", + "Text": "Configure each active **Global Session Policy** so a non-default rule at **Priority 1**:\n- Sets *Maximum Okta global session idle time* to `15` minutes or less\n- Is enabled (`ACTIVE`) and evaluated before the built-in `Default Rule`\n\nReview group assignments to confirm the rule actually governs the intended users.", "Url": "https://hub.prowler.com/check/signon_global_session_idle_timeout_15min" } }, diff --git a/prowler/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min.py b/prowler/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min.py index e34c375fd2..3f20a22adb 100644 --- a/prowler/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min.py +++ b/prowler/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min.py @@ -1,4 +1,11 @@ from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.signon.lib.signon_helpers import ( + active_policies, + missing_policy_scope_finding, + no_active_policies_finding, + policy_label, + priority_one_active_rule, +) from prowler.providers.okta.services.signon.signon_client import signon_client from prowler.providers.okta.services.signon.signon_service import GlobalSessionPolicy @@ -8,11 +15,16 @@ DEFAULT_THRESHOLD_MINUTES = 15 class signon_global_session_idle_timeout_15min(Check): """STIG V-273186 / OKTA-APP-000020. - The DISA STIG requires the Okta Default Policy to have an active - Priority 1 rule that is not the built-in Default Rule, and that - rule must set the maximum Okta global session idle time to the - configured threshold or lower (defaults to 15 minutes per STIG; - override via `okta_max_session_idle_minutes` in the audit config). + Every active Global Session Policy must have an active Priority 1 + rule that is not the built-in Default Rule, and that rule must set + the maximum Okta global session idle time to the configured + threshold or lower (defaults to 15 minutes per STIG; override via + `okta_max_session_idle_minutes` in the audit config). + + Okta evaluates sign-on policies in priority order based on group + assignments, so a permissive custom policy can govern a user's + session even when the Default Policy is strict. The check emits one + finding per active policy to surface that risk. """ def execute(self) -> list[CheckReportOkta]: @@ -21,106 +33,74 @@ class signon_global_session_idle_timeout_15min(Check): "okta_max_session_idle_minutes", DEFAULT_THRESHOLD_MINUTES ) org_domain = signon_client.provider.identity.org_domain - policy = self._get_default_policy() - report = CheckReportOkta( - metadata=self.metadata(), resource=policy, org_domain=org_domain + + missing_scope = signon_client.missing_scope.get("global_session_policies") + if missing_scope: + return [ + missing_policy_scope_finding(self.metadata(), org_domain, missing_scope) + ] + + policies = active_policies(signon_client.global_session_policies) + if not policies: + return [ + no_active_policies_finding( + self.metadata(), + org_domain, + "No active Okta Global Session Policies were returned by the API. " + "STIG V-273186 requires the policy that governs each user to enforce " + "a Priority 1 non-default rule with a 15-minute idle timeout.", + ) + ] + + findings: list[CheckReportOkta] = [] + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + status, status_extended = _evaluate_policy(policy, threshold) + report.status = status + report.status_extended = status_extended + findings.append(report) + return findings + + +def _evaluate_policy(policy: GlobalSessionPolicy, threshold: int) -> tuple[str, str]: + label = policy_label(policy) + rule = priority_one_active_rule(policy) + + if rule is None: + return ( + "FAIL", + f"{label} has no Priority 1 active rule. STIG V-273186 requires " + f"a non-default Priority 1 rule with idle timeout <= {threshold} " + "minutes.", ) - if policy.id == "default-policy-missing": - report.status = "FAIL" - report.status_extended = ( - "Default Global Session Policy was not found. STIG V-273186 " - "requires the Default Policy to contain an active Priority 1 " - f"non-default rule with idle timeout <= {threshold} minutes." - ) - return [report] - - if policy.status and policy.status.upper() != "ACTIVE": - report.status = "FAIL" - report.status_extended = ( - f"Default Global Session Policy '{policy.name}' is in " - f"status '{policy.status}'. STIG V-273186 requires an active " - "Default Policy with an active Priority 1 non-default rule." - ) - return [report] - - active_rules = sorted( - [ - rule - for rule in policy.rules - if not rule.status or rule.status.upper() == "ACTIVE" - ], - key=lambda rule: ( - rule.priority if rule.priority is not None else float("inf"), - rule.name, - ), + if rule.is_default or rule.name == "Default Rule": + return ( + "FAIL", + f"{label} uses '{rule.name}' as its active Priority 1 rule. " + "The STIG requires a non-default Priority 1 rule.", ) - if not active_rules: - report.status = "FAIL" - report.status_extended = ( - f"Default Global Session Policy '{policy.name}' has no active " - "rules. STIG V-273186 requires an active Priority 1 non-default " - f"rule with idle timeout <= {threshold} minutes." - ) - return [report] - priority_one_rule = active_rules[0] - if priority_one_rule.priority != 1: - report.status = "FAIL" - report.status_extended = ( - f"Default Global Session Policy '{policy.name}' has no active " - f"Priority 1 rule. The first active rule is '{priority_one_rule.name}' " - f"at priority {priority_one_rule.priority}." - ) - return [report] - - if priority_one_rule.is_default or priority_one_rule.name == "Default Rule": - report.status = "FAIL" - report.status_extended = ( - f"Default Global Session Policy '{policy.name}' uses " - f"'{priority_one_rule.name}' as its active Priority 1 rule. " - "The STIG requires a non-default Priority 1 rule." - ) - return [report] - - idle_timeout = priority_one_rule.max_session_idle_minutes - if idle_timeout is None: - report.status = "FAIL" - report.status_extended = ( - f"Priority 1 non-default rule '{priority_one_rule.name}' in " - f"Default Global Session Policy '{policy.name}' does not define " - "a maximum Okta global session idle time." - ) - return [report] - - if idle_timeout <= threshold: - report.status = "PASS" - report.status_extended = ( - f"Priority 1 non-default rule '{priority_one_rule.name}' in " - f"Default Global Session Policy '{policy.name}' sets the " - f"maximum Okta global session idle time to {idle_timeout} " - f"minutes, meeting the configured threshold of {threshold} minutes." - ) - else: - report.status = "FAIL" - report.status_extended = ( - f"Priority 1 non-default rule '{priority_one_rule.name}' in " - f"Default Global Session Policy '{policy.name}' sets the " - f"maximum Okta global session idle time to {idle_timeout} " - f"minutes, exceeding the configured threshold of {threshold} minutes." - ) - return [report] - - @staticmethod - def _get_default_policy() -> GlobalSessionPolicy: - for policy in signon_client.global_session_policies.values(): - if policy.is_default or policy.name == "Default Policy": - return policy - return GlobalSessionPolicy( - id="default-policy-missing", - name="Default Policy", - priority=1, - status="MISSING", - is_default=True, - rules=[], + idle_timeout = rule.max_session_idle_minutes + if idle_timeout is None: + return ( + "FAIL", + f"Priority 1 non-default rule '{rule.name}' in {label} " + "does not define a maximum Okta global session idle time.", ) + + if idle_timeout <= threshold: + return ( + "PASS", + f"Priority 1 non-default rule '{rule.name}' in {label} " + f"sets the maximum Okta global session idle time to {idle_timeout} " + f"minutes, meeting the configured threshold of {threshold} minutes.", + ) + return ( + "FAIL", + f"Priority 1 non-default rule '{rule.name}' in {label} " + f"sets the maximum Okta global session idle time to {idle_timeout} " + f"minutes, exceeding the configured threshold of {threshold} minutes.", + ) diff --git a/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/__init__.py b/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.metadata.json b/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.metadata.json new file mode 100644 index 0000000000..84d88b3c76 --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "signon_global_session_lifetime_18h", + "CheckTitle": "Default Global Session Policy has a Priority 1 non-default rule limiting session lifetime to 18 hours", + "CheckType": [], + "ServiceName": "signon", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "Every active Okta **Global Session Policy** needs a **Priority 1** rule that is **not** the built-in `Default Rule`, setting *Maximum Okta global session lifetime* to `18` hours or less.\n\nOkta evaluates policies by group assignment, so a permissive custom policy can govern users. Threshold override: `okta_max_session_lifetime_minutes` (minutes). Aligns with **DISA STIG V-273203**.", + "Risk": "Without an enforced session lifetime, an authenticated Okta session can be reused indefinitely without reauthentication.\n\n- **Stolen session material** continues to grant access long after sign-in\n- **Authorization changes** (role revocation, group removal) take effect only on the next reauth\n- **Token replay** against downstream apps stays viable for the session window", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/identity-engine/policies/about-okta-sign-on-policies.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Policy/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Global Session Policy**.\n3. Open the **Default Policy** (and repeat for every other active policy).\n4. Add or edit a non-default rule.\n5. Move that rule to **Priority 1** so it is evaluated before the built-in `Default Rule`.\n6. Set *Maximum Okta global session lifetime* to `18` hours or less. Do **not** set it to `0`, which disables the limit.\n7. Save the rule.", + "Terraform": "```hcl\nresource \"okta_policy_rule_signon\" \"\" {\n policy_id = okta_policy_signon.default.id\n name = \"\"\n status = \"ACTIVE\"\n priority = 1 # Critical: rule must sit at Priority 1 before the Default Rule\n session_lifetime = 1080 # Critical: 18 hours in minutes; do not use 0 (disables the limit)\n session_persistent = false # Critical: avoid persistent global session cookies\n}\n```" + }, + "Recommendation": { + "Text": "Configure each active **Global Session Policy** so a non-default rule at **Priority 1**:\n- Sets *Maximum Okta global session lifetime* to `18` hours or less (`1080` minutes)\n- Never sets the lifetime to `0`, which disables the limit\n- Is enabled (`ACTIVE`) and evaluated before the built-in `Default Rule`\n\nReview group assignments to confirm the rule actually governs the intended users.", + "Url": "https://hub.prowler.com/check/signon_global_session_lifetime_18h" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} 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 new file mode 100644 index 0000000000..25003eef90 --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.py @@ -0,0 +1,114 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.signon.lib.signon_helpers import ( + active_policies, + missing_policy_scope_finding, + no_active_policies_finding, + policy_label, + priority_one_active_rule, +) +from prowler.providers.okta.services.signon.signon_client import signon_client +from prowler.providers.okta.services.signon.signon_service import GlobalSessionPolicy + +DEFAULT_THRESHOLD_MINUTES = 18 * 60 + + +class signon_global_session_lifetime_18h(Check): + """STIG V-273203 / OKTA-APP-001665. + + Every active Global Session Policy must have an active Priority 1 + rule that is not the built-in Default Rule, and that rule must set + the maximum Okta global session lifetime to the configured threshold + or lower (defaults to 18 hours per STIG; override via + `okta_max_session_lifetime_minutes` in the audit config). + + Okta evaluates sign-on policies in priority order based on group + assignments, so a permissive custom policy can govern a user's + session even when the Default Policy is strict. The check emits one + finding per active policy to surface that risk. + """ + + def execute(self) -> list[CheckReportOkta]: + audit_config = signon_client.audit_config or {} + threshold = audit_config.get( + "okta_max_session_lifetime_minutes", DEFAULT_THRESHOLD_MINUTES + ) + org_domain = signon_client.provider.identity.org_domain + + missing_scope = signon_client.missing_scope.get("global_session_policies") + if missing_scope: + return [ + missing_policy_scope_finding(self.metadata(), org_domain, missing_scope) + ] + + policies = active_policies(signon_client.global_session_policies) + if not policies: + return [ + no_active_policies_finding( + self.metadata(), + org_domain, + "No active Okta Global Session Policies were returned by the API. " + "STIG V-273203 requires the policy that governs each user to enforce " + "a Priority 1 non-default rule with an 18-hour session lifetime.", + ) + ] + + findings: list[CheckReportOkta] = [] + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + status, status_extended = _evaluate_policy(policy, threshold) + report.status = status + report.status_extended = status_extended + findings.append(report) + return findings + + +def _evaluate_policy(policy: GlobalSessionPolicy, threshold: int) -> tuple[str, str]: + label = policy_label(policy) + rule = priority_one_active_rule(policy) + + if rule is None: + return ( + "FAIL", + f"{label} has no Priority 1 active rule. STIG V-273203 requires " + f"a non-default Priority 1 rule with session lifetime <= {threshold} " + "minutes.", + ) + + if rule.is_default or rule.name == "Default Rule": + return ( + "FAIL", + f"{label} uses '{rule.name}' as its active Priority 1 rule. " + "The STIG requires a non-default Priority 1 rule.", + ) + + lifetime = rule.max_session_lifetime_minutes + if lifetime is None: + return ( + "FAIL", + f"Priority 1 non-default rule '{rule.name}' in {label} " + "does not define a maximum Okta global session lifetime.", + ) + + if lifetime == 0: + return ( + "FAIL", + f"Priority 1 non-default rule '{rule.name}' in {label} " + "disables the maximum Okta global session lifetime by setting it " + "to 0 minutes.", + ) + + if lifetime <= threshold: + return ( + "PASS", + f"Priority 1 non-default rule '{rule.name}' in {label} " + f"sets the maximum Okta global session lifetime to {lifetime} " + f"minutes, meeting the configured threshold of {threshold} minutes.", + ) + return ( + "FAIL", + f"Priority 1 non-default rule '{rule.name}' in {label} " + f"sets the maximum Okta global session lifetime to {lifetime} minutes, " + f"exceeding the configured threshold of {threshold} minutes.", + ) diff --git a/prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/__init__.py b/prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced.metadata.json b/prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced.metadata.json new file mode 100644 index 0000000000..b1009ba970 --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "okta", + "CheckID": "signon_global_session_policy_network_zone_enforced", + "CheckTitle": "Default Global Session Policy applies a Network Zone condition aligned with the Access Control Policy", + "CheckType": [], + "ServiceName": "signon", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "Every active Okta **Global Session Policy** must apply the *IF User's IP is* condition, mapped to a **Network Zone**, on its **Priority 1** active rule.\n\nUnlike the idle / lifetime / cookie STIGs, this control does **not** exclude the built-in `Default Rule`. Okta evaluates policies by group assignment. Aligns with **DISA STIG V-279691**.", + "Risk": "When the Global Session Policy does not restrict access by **Network Zone**, every authenticated entity establishes a session regardless of source IP.\n\n- **Stolen credentials** reach the Okta dashboard from any internet-routable address\n- **Out-of-band sessions** bypass the organization's Access Control Policy\n- **Network anomalies** cannot become deny decisions at sign-on", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/security/network/network-zones.htm", + "https://help.okta.com/oie/en-us/content/topics/identity-engine/policies/about-okta-sign-on-policies.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Policy/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Networks** and define the **Network Zones** (allow / deny) that match the organization's Access Control Policy.\n3. Navigate to **Security** > **Global Session Policy**.\n4. Open the **Default Policy** (and repeat for every other active policy).\n5. Edit the rule that sits at **Priority 1**, or add a new one and move it to **Priority 1**.\n6. Under *Conditions*, set *IF User's IP is* to `In zone` (allow) or `Not in zone` (deny) and select the **Network Zone**.\n7. Save the rule.", + "Terraform": "```hcl\nresource \"okta_policy_rule_signon\" \"\" {\n policy_id = okta_policy_signon.default.id\n name = \"\"\n status = \"ACTIVE\"\n priority = 1 # Critical: rule must sit at Priority 1\n network_connection = \"ZONE\" # Critical: bind the rule to a Network Zone\n network_includes = [okta_network_zone.allowed.id] # Critical: zones that reflect the Access Control Policy\n}\n```" + }, + "Recommendation": { + "Text": "Configure the **Priority 1** active rule in each Global Session Policy so it:\n- Maps the *IF User's IP is* condition to a **Network Zone** aligned with the organization's Access Control Policy\n- Uses `In zone` for allow-list zones and `Not in zone` for deny-list zones\n- Is enabled (`ACTIVE`) and evaluated before the built-in `Default Rule`, or *is* the `Default Rule` itself\n\nReview group assignments to confirm the rule actually governs the intended users.", + "Url": "https://hub.prowler.com/check/signon_global_session_policy_network_zone_enforced" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced.py b/prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced.py new file mode 100644 index 0000000000..7a7e51706c --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced.py @@ -0,0 +1,95 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.signon.lib.signon_helpers import ( + active_policies, + missing_policy_scope_finding, + no_active_policies_finding, + policy_label, + priority_one_active_rule, +) +from prowler.providers.okta.services.signon.signon_client import signon_client +from prowler.providers.okta.services.signon.signon_service import GlobalSessionPolicy + + +class signon_global_session_policy_network_zone_enforced(Check): + """STIG V-279691 / OKTA-APP-003242. + + Every active Global Session Policy must apply an "IF User's IP is" + condition mapped to a Network Zone on its Priority 1 active rule so + access can be allowed or denied per the organization's Access + Control Policy. + + Unlike the idle / lifetime / persistent-cookie STIGs, V-279691 does + not exclude the built-in Default Rule, so a zone condition on the + Default Rule is still effective when no non-default rule sits at + Priority 1. + + The check emits one finding per active policy because Okta evaluates + sign-on policies in priority order based on group assignments, and a + permissive custom policy can govern a user's session even when the + Default Policy is strict. + """ + + def execute(self) -> list[CheckReportOkta]: + org_domain = signon_client.provider.identity.org_domain + + missing_scope = signon_client.missing_scope.get("global_session_policies") + if missing_scope: + return [ + missing_policy_scope_finding(self.metadata(), org_domain, missing_scope) + ] + + policies = active_policies(signon_client.global_session_policies) + if not policies: + return [ + no_active_policies_finding( + self.metadata(), + org_domain, + "No active Okta Global Session Policies were returned by the API. " + "STIG V-279691 requires the policy that governs each user to map " + "User's IP to a Network Zone on its Priority 1 active rule.", + ) + ] + + findings: list[CheckReportOkta] = [] + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + status, status_extended = _evaluate_policy(policy) + report.status = status + report.status_extended = status_extended + findings.append(report) + return findings + + +def _evaluate_policy(policy: GlobalSessionPolicy) -> tuple[str, str]: + label = policy_label(policy) + rule = priority_one_active_rule(policy) + + if rule is None: + return ( + "FAIL", + f"{label} has no Priority 1 active rule. STIG V-279691 requires " + "the policy to apply an IP-based Network Zone condition on its " + "Priority 1 active rule.", + ) + + rule_kind = ( + "built-in Default Rule" + if rule.is_default or rule.name == "Default Rule" + else "non-default rule" + ) + has_zones = bool(rule.network_zones_include or rule.network_zones_exclude) + + if has_zones: + return ( + "PASS", + f"Priority 1 active {rule_kind} '{rule.name}' in {label} maps " + "User's IP to a Network Zone.", + ) + return ( + "FAIL", + f"Priority 1 active {rule_kind} '{rule.name}' in {label} does not " + "map User's IP to a Network Zone. The policy cannot allow or deny " + "access based on the organization's Access Control Policy.", + ) diff --git a/prowler/providers/okta/services/signon/signon_service.py b/prowler/providers/okta/services/signon/signon_service.py index 12c1349a2c..7c32363501 100644 --- a/prowler/providers/okta/services/signon/signon_service.py +++ b/prowler/providers/okta/services/signon/signon_service.py @@ -29,18 +29,51 @@ def _next_after_cursor(resp) -> Optional[str]: return None +REQUIRED_SCOPES: dict[str, str] = { + "global_session_policies": "okta.policies.read", + "sign_in_pages": "okta.brands.read", +} + + 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 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`. + + Before each fetch the service compares its required OAuth scope + (see `REQUIRED_SCOPES`) against the access token's granted scopes + (`provider.identity.granted_scopes`). When a scope is known to be + missing, the fetch is skipped and the resource is recorded in + `self.missing_scope` so checks can report the missing scope explicitly + instead of emitting a misleading "no resources returned" finding. + When granted_scopes is empty (token decode unavailable), the service + treats permissions as unknown and attempts the fetch — preserving + the prior behavior. """ def __init__(self, provider): super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + self.global_session_policies: dict[str, GlobalSessionPolicy] = ( - self._list_global_session_policies() + {} + if self.missing_scope["global_session_policies"] + else self._list_global_session_policies() + ) + self.sign_in_pages: dict[str, SignInPage] = ( + {} if self.missing_scope["sign_in_pages"] else self._list_sign_in_pages() ) def _list_global_session_policies(self) -> dict: @@ -125,6 +158,74 @@ 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 "" + 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), + ) + + 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): """Drain all pages of an SDK list call. @@ -176,3 +277,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..2b7ca6927c 100644 --- a/tests/providers/okta/okta_fixtures.py +++ b/tests/providers/okta/okta_fixtures.py @@ -16,13 +16,14 @@ 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: identity = OktaIdentityInfo( org_domain=OKTA_ORG_DOMAIN, client_id=OKTA_CLIENT_ID, + granted_scopes=["okta.policies.read", "okta.brands.read"], ) provider = MagicMock() diff --git a/tests/providers/okta/okta_provider_test.py b/tests/providers/okta/okta_provider_test.py index da325d84ca..5f2757edae 100644 --- a/tests/providers/okta/okta_provider_test.py +++ b/tests/providers/okta/okta_provider_test.py @@ -1,3 +1,5 @@ +import base64 +import json from unittest import mock import pytest @@ -20,6 +22,54 @@ from tests.providers.okta.okta_fixtures import ( ) +def _make_jwt(payload: dict) -> str: + """Build an unsigned JWT carrying the given payload dict. + + The signature segment is irrelevant — `_decode_token_scopes` reads + the payload without verification. + """ + + def _b64u(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + header = _b64u(json.dumps({"alg": "none"}).encode()) + body = _b64u(json.dumps(payload).encode()) + return f"{header}.{body}.sig" + + +class Test_OktaProvider_decode_token_scopes: + def test_returns_scopes_from_list_scp_claim(self): + token = _make_jwt({"scp": ["okta.policies.read", "okta.brands.read"]}) + assert OktaProvider._decode_token_scopes(token) == [ + "okta.policies.read", + "okta.brands.read", + ] + + def test_returns_scopes_from_space_separated_scp_string(self): + token = _make_jwt({"scp": "okta.policies.read okta.brands.read"}) + assert OktaProvider._decode_token_scopes(token) == [ + "okta.policies.read", + "okta.brands.read", + ] + + def test_returns_empty_list_when_token_is_none(self): + assert OktaProvider._decode_token_scopes(None) == [] + + def test_returns_empty_list_when_token_is_empty_string(self): + assert OktaProvider._decode_token_scopes("") == [] + + def test_returns_empty_list_when_scp_claim_missing(self): + token = _make_jwt({"sub": "client-id"}) + assert OktaProvider._decode_token_scopes(token) == [] + + def test_returns_empty_list_when_token_is_malformed(self): + assert OktaProvider._decode_token_scopes("not.a.jwt-with-bad-base64!!") == [] + + def test_returns_empty_list_when_payload_is_not_json(self): + bad = base64.urlsafe_b64encode(b"not json").rstrip(b"=").decode() + assert OktaProvider._decode_token_scopes(f"hdr.{bad}.sig") == [] + + @pytest.fixture def _clear_okta_env(monkeypatch): for var in ( @@ -272,6 +322,49 @@ class Test_OktaProvider_setup_identity: assert identity.org_domain == OKTA_ORG_DOMAIN assert identity.client_id == OKTA_CLIENT_ID + def test_populates_granted_scopes_from_access_token_scp_claim( + self, _clear_okta_env, tmp_path + ): + session = self._session(tmp_path) + + async def fake_list_policies(*_a, **_k): + return ([], mock.MagicMock(headers={}), None) + + with mock.patch( + "prowler.providers.okta.okta_provider.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked._request_executor._oauth._access_token = _make_jwt( + {"scp": ["okta.policies.read", "okta.brands.read"]} + ) + mocked_client_cls.return_value = mocked + identity = OktaProvider.setup_identity(session) + + assert identity.granted_scopes == [ + "okta.policies.read", + "okta.brands.read", + ] + + def test_granted_scopes_empty_when_token_unavailable( + self, _clear_okta_env, tmp_path + ): + session = self._session(tmp_path) + + async def fake_list_policies(*_a, **_k): + return ([], mock.MagicMock(headers={}), None) + + with mock.patch( + "prowler.providers.okta.okta_provider.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked._request_executor._oauth._access_token = None + mocked_client_cls.return_value = mocked + identity = OktaProvider.setup_identity(session) + + assert identity.granted_scopes == [] + def test_raises_invalid_credentials_when_probe_returns_error( self, _clear_okta_env, tmp_path ): @@ -323,6 +416,35 @@ class Test_OktaProvider_setup_identity: with pytest.raises(OktaInsufficientPermissionsError): OktaProvider.setup_identity(session) + def test_raises_insufficient_permissions_on_consent_required( + self, _clear_okta_env, tmp_path + ): + # When zero requested scopes are consented on the service app, Okta + # rejects the token request with HTTP 400 `consent_required` rather + # than `invalid_scope` — must still be classified as a permission + # gap so the user is pointed at the Okta API Scopes tab, not at + # credential troubleshooting. + session = self._session(tmp_path) + + async def failing_list_policies(*_a, **_k): + return ( + [], + None, + Exception( + "Okta HTTP 400 consent_required You are not allowed any " + "of the requested scopes." + ), + ) + + with mock.patch( + "prowler.providers.okta.okta_provider.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = failing_list_policies + mocked_client_cls.return_value = mocked + with pytest.raises(OktaInsufficientPermissionsError): + OktaProvider.setup_identity(session) + def test_wraps_unexpected_errors_in_setup_identity_error( self, _clear_okta_env, tmp_path ): 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..96406a5fd1 --- /dev/null +++ b/tests/providers/okta/services/signon/signon_dod_warning_banner_configured/signon_dod_warning_banner_configured_test.py @@ -0,0 +1,257 @@ +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_missing_brand_scope_returns_manual_finding_naming_the_scope(self): + signon_client = build_signon_client( + missing_scope={ + "global_session_policies": None, + "sign_in_pages": "okta.brands.read", + } + ) + 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 "okta.brands.read" in findings[0].status_extended + assert "missing the required" 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 + ) + assert "customized sign-in page" 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_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", + 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 "could not be retrieved from the Okta API" 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_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", + 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, + page_content=f"{DOD_BANNER_HTML_SNIPPET}", + ) + 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": "PASS", + } diff --git a/tests/providers/okta/services/signon/signon_fixtures.py b/tests/providers/okta/services/signon/signon_fixtures.py new file mode 100644 index 0000000000..c2188670bc --- /dev/null +++ b/tests/providers/okta/services/signon/signon_fixtures.py @@ -0,0 +1,130 @@ +"""Shared helpers for `signon` service check tests. + +The original idle-timeout check test file defined these helpers locally; +they were extracted here so the four checks added on top of the same +service (`signon_global_session_lifetime_18h`, +`signon_global_session_cookies_not_persistent`, +`signon_global_session_policy_network_zone_enforced`, +`signon_dod_warning_banner_configured`) can reuse them without copy-paste. +""" + +from unittest import mock + +from prowler.providers.okta.services.signon.signon_service import ( + GlobalSessionPolicy, + GlobalSessionPolicyRule, + SignInPage, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_signon_client( + policies: dict = None, + audit_config: dict = None, + sign_in_pages: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.global_session_policies = policies or {} + client.provider = set_mocked_okta_provider() + client.audit_config = audit_config or {} + client.sign_in_pages = sign_in_pages or {} + # Default to "all scopes granted" so existing tests keep working. + client.missing_scope = missing_scope or { + "global_session_policies": None, + "sign_in_pages": None, + } + return client + + +def default_policy(rules): + return GlobalSessionPolicy( + id="pol-default", + name="Default Policy", + priority=99, + status="ACTIVE", + is_default=True, + rules=rules, + ) + + +def custom_policy(rules, name: str = "Admins Policy"): + return GlobalSessionPolicy( + id="pol-custom", + name=name, + priority=1, + status="ACTIVE", + is_default=False, + rules=rules, + ) + + +def default_rule( + idle_min: int = 480, + lifetime_min: int = None, + use_persistent_cookie: bool = None, + priority: int = 2, + status: str = "ACTIVE", +): + return GlobalSessionPolicyRule( + id="rule-default", + name="Default Rule", + priority=priority, + status=status, + is_default=True, + max_session_idle_minutes=idle_min, + max_session_lifetime_minutes=lifetime_min, + use_persistent_cookie=use_persistent_cookie, + ) + + +def non_default_rule( + name: str, + *, + idle_min: int = None, + lifetime_min: int = None, + use_persistent_cookie: bool = None, + network_zones_include: list = None, + network_zones_exclude: list = None, + priority: int = 1, + status: str = "ACTIVE", +): + return GlobalSessionPolicyRule( + id=f"rule-{name.lower().replace(' ', '-')}", + name=name, + priority=priority, + status=status, + is_default=False, + max_session_idle_minutes=idle_min, + max_session_lifetime_minutes=lifetime_min, + use_persistent_cookie=use_persistent_cookie, + network_zones_include=network_zones_include or [], + network_zones_exclude=network_zones_exclude or [], + ) + + +def sign_in_page( + brand_id: str = "brand-1", + brand_name: str = "Default Brand", + is_customized: bool = True, + page_content: str = None, + fetch_error: str = None, +): + return SignInPage( + brand_id=brand_id, + brand_name=brand_name, + is_customized=is_customized, + page_content=page_content, + fetch_error=fetch_error, + ) + + +# Condensed DTM-08-060 banner that covers all four marker groups the check +# requires (see BANNER_MARKER_GROUPS in the check module). Lets PASS tests +# avoid pasting the full ~1300-char banner verbatim. +DOD_BANNER_HTML_SNIPPET = ( + "
You are accessing a U.S. Government (USG) Information System " + "(IS) that is provided for USG-authorized use only. " + "Communications using, or data stored on, this IS may be intercepted, " + "searched, monitored, and recorded.
" +) diff --git a/tests/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent_test.py b/tests/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent_test.py new file mode 100644 index 0000000000..cd10d84be8 --- /dev/null +++ b/tests/providers/okta/services/signon/signon_global_session_cookies_not_persistent/signon_global_session_cookies_not_persistent_test.py @@ -0,0 +1,189 @@ +from unittest import mock + +from prowler.providers.okta.services.signon.signon_service import ( + GlobalSessionPolicy, + GlobalSessionPolicyRule, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.signon.signon_fixtures import ( + build_signon_client, + custom_policy, + default_policy, + default_rule, + non_default_rule, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.signon." + "signon_global_session_cookies_not_persistent." + "signon_global_session_cookies_not_persistent.signon_client" +) + + +def _run_check(signon_client): + 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_cookies_not_persistent.signon_global_session_cookies_not_persistent import ( + signon_global_session_cookies_not_persistent, + ) + + return signon_global_session_cookies_not_persistent().execute() + + +class Test_signon_global_session_cookies_not_persistent: + def test_no_policies(self): + findings = _run_check(build_signon_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Global Session Policies" in findings[0].status_extended + + def test_pass_when_priority_one_rule_disables_persistent_cookies(self): + policy = default_policy( + [ + non_default_rule( + "Non-persistent cookies", + use_persistent_cookie=False, + priority=1, + ), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "disables persistent global session cookies" in ( + findings[0].status_extended + ) + assert "priority 99, default" in findings[0].status_extended + + def test_fail_when_priority_one_rule_uses_persistent_cookies(self): + policy = default_policy( + [ + non_default_rule( + "Persistent cookies enabled", + use_persistent_cookie=True, + priority=1, + ), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "allows persistent global session cookies" in ( + findings[0].status_extended + ) + + def test_fail_when_priority_one_rule_does_not_assert_setting(self): + policy = default_policy( + [ + GlobalSessionPolicyRule( + id="rule-no-session", + name="No Session Block", + priority=1, + status="ACTIVE", + is_default=False, + use_persistent_cookie=None, + ), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "does not assert" in findings[0].status_extended + + def test_emits_one_finding_per_policy(self): + admins_policy = custom_policy( + [ + non_default_rule( + "Sticky admin", + use_persistent_cookie=True, + priority=1, + ) + ], + name="Admins Policy", + ) + strict_default = default_policy( + [ + non_default_rule( + "Non-persistent", + use_persistent_cookie=False, + priority=1, + ), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-custom": admins_policy, "pol-default": strict_default} + ) + ) + assert len(findings) == 2 + by_name = {f.resource_name: f for f in findings} + assert by_name["Admins Policy"].status == "FAIL" + assert "priority 1, custom" in by_name["Admins Policy"].status_extended + assert by_name["Default Policy"].status == "PASS" + + def test_inactive_policy_is_skipped(self): + inactive = GlobalSessionPolicy( + id="pol-inactive", + name="Disabled Policy", + priority=1, + status="INACTIVE", + is_default=False, + rules=[non_default_rule("Sticky", use_persistent_cookie=True, priority=1)], + ) + active_default = default_policy( + [ + non_default_rule( + "Non-persistent", + use_persistent_cookie=False, + priority=1, + ), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-inactive": inactive, "pol-default": active_default} + ) + ) + assert len(findings) == 1 + assert findings[0].resource_name == "Default Policy" + assert findings[0].status == "PASS" + + def test_missing_scope_returns_manual_finding_naming_the_scope(self): + findings = _run_check( + build_signon_client( + missing_scope={ + "global_session_policies": "okta.policies.read", + "sign_in_pages": None, + } + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + assert "missing the required" in findings[0].status_extended + + def test_fail_when_all_policies_inactive(self): + only_inactive = GlobalSessionPolicy( + id="pol-default", + name="Default Policy", + priority=99, + status="INACTIVE", + is_default=True, + rules=[ + non_default_rule("Compliant", use_persistent_cookie=False, priority=1) + ], + ) + findings = _run_check(build_signon_client({"pol-default": only_inactive})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Global Session Policies" in findings[0].status_extended diff --git a/tests/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min_test.py b/tests/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min_test.py index 912fd2014e..177a4b1c8f 100644 --- a/tests/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min_test.py +++ b/tests/providers/okta/services/signon/signon_global_session_idle_timeout_15min/signon_global_session_idle_timeout_15min_test.py @@ -5,6 +5,13 @@ from prowler.providers.okta.services.signon.signon_service import ( GlobalSessionPolicyRule, ) from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.signon.signon_fixtures import ( + build_signon_client, + custom_policy, + default_policy, + default_rule, + non_default_rule, +) CHECK_PATH = ( "prowler.providers.okta.services.signon." @@ -13,128 +20,53 @@ CHECK_PATH = ( ) -def _build_signon_client(policies, audit_config: dict = None): - client = mock.MagicMock() - client.global_session_policies = policies - client.provider = set_mocked_okta_provider() - client.audit_config = audit_config or {} - return client +def _run_check(signon_client): + 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_idle_timeout_15min.signon_global_session_idle_timeout_15min import ( + signon_global_session_idle_timeout_15min, + ) - -def _default_policy(rules): - return GlobalSessionPolicy( - id="pol-default", - name="Default Policy", - priority=99, - status="ACTIVE", - is_default=True, - rules=rules, - ) - - -def _custom_policy(rules): - return GlobalSessionPolicy( - id="pol-custom", - name="Admins Policy", - priority=1, - status="ACTIVE", - is_default=False, - rules=rules, - ) - - -def _default_rule(idle_min=480, priority=2, status="ACTIVE"): - return GlobalSessionPolicyRule( - id="rule-default", - name="Default Rule", - priority=priority, - status=status, - is_default=True, - max_session_idle_minutes=idle_min, - ) - - -def _non_default_rule(name, idle_min, priority=1, status="ACTIVE"): - return GlobalSessionPolicyRule( - id=f"rule-{name.lower().replace(' ', '-')}", - name=name, - priority=priority, - status=status, - is_default=False, - max_session_idle_minutes=idle_min, - ) + return signon_global_session_idle_timeout_15min().execute() class Test_signon_global_session_idle_timeout_15min: def test_no_policies(self): - signon_client = _build_signon_client({}) - 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_idle_timeout_15min.signon_global_session_idle_timeout_15min import ( - signon_global_session_idle_timeout_15min, - ) - - findings = signon_global_session_idle_timeout_15min().execute() - assert len(findings) == 1 - assert findings[0].status == "FAIL" - assert "was not found" in findings[0].status_extended + findings = _run_check(build_signon_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Global Session Policies" in findings[0].status_extended def test_pass_when_priority_one_non_default_rule_is_compliant(self): - policy = _default_policy( + policy = default_policy( [ - _non_default_rule("Strict 15min", 15, priority=1), - _default_rule(priority=2), + non_default_rule("Strict 15min", idle_min=15, 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_idle_timeout_15min.signon_global_session_idle_timeout_15min import ( - signon_global_session_idle_timeout_15min, - ) - - findings = signon_global_session_idle_timeout_15min().execute() - assert len(findings) == 1 - assert findings[0].status == "PASS" - assert "Strict 15min" in findings[0].status_extended - assert "Priority 1 non-default rule" in findings[0].status_extended + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Strict 15min" in findings[0].status_extended + assert "Default Policy" in findings[0].status_extended + assert "priority 99, default" in findings[0].status_extended def test_fail_when_only_default_rule(self): - policy = _default_policy([_default_rule(priority=1)]) - 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_idle_timeout_15min.signon_global_session_idle_timeout_15min import ( - signon_global_session_idle_timeout_15min, - ) - - findings = signon_global_session_idle_timeout_15min().execute() - assert len(findings) == 1 - assert findings[0].status == "FAIL" - assert "uses 'Default Rule' as its active Priority 1 rule" in ( - findings[0].status_extended - ) + policy = default_policy([default_rule(priority=1)]) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "uses 'Default Rule' as its active Priority 1 rule" in ( + findings[0].status_extended + ) def test_fail_when_priority_one_non_default_rule_has_null_idle(self): - # Rules without a session block leave max_session_idle_minutes as - # None. The check must treat those as non-compliant — they cannot - # enforce any timeout. - policy = _default_policy( + policy = default_policy( [ GlobalSessionPolicyRule( id="rule-no-session", @@ -144,161 +76,135 @@ class Test_signon_global_session_idle_timeout_15min: is_default=False, max_session_idle_minutes=None, ), - _default_rule(priority=2), + 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_idle_timeout_15min.signon_global_session_idle_timeout_15min import ( - signon_global_session_idle_timeout_15min, - ) - - findings = signon_global_session_idle_timeout_15min().execute() - assert len(findings) == 1 - assert findings[0].status == "FAIL" - assert "No Session Block" in findings[0].status_extended - assert "does not define" in findings[0].status_extended + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No Session Block" in findings[0].status_extended + assert "does not define" in findings[0].status_extended def test_fail_when_priority_one_non_default_rule_exceeds_threshold(self): - policy = _default_policy( + policy = default_policy( [ - _non_default_rule("Loose 60min", 60, priority=1), - _default_rule(priority=2), + non_default_rule("Loose 60min", idle_min=60, 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_idle_timeout_15min.signon_global_session_idle_timeout_15min import ( - signon_global_session_idle_timeout_15min, - ) - - findings = signon_global_session_idle_timeout_15min().execute() - assert len(findings) == 1 - assert findings[0].status == "FAIL" - assert "Loose 60min" in findings[0].status_extended - assert "exceeding the configured threshold" in findings[0].status_extended + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "Loose 60min" in findings[0].status_extended + assert "exceeding the configured threshold" in findings[0].status_extended def test_fail_when_compliant_non_default_rule_is_not_priority_one(self): - policy = _default_policy( + policy = default_policy( [ - _default_rule(priority=1), - _non_default_rule("Strict 15min", 15, priority=2), + default_rule(priority=1), + non_default_rule("Strict 15min", idle_min=15, 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_idle_timeout_15min.signon_global_session_idle_timeout_15min import ( - signon_global_session_idle_timeout_15min, - ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "uses 'Default Rule' as its active Priority 1 rule" in ( + findings[0].status_extended + ) - findings = signon_global_session_idle_timeout_15min().execute() - assert len(findings) == 1 - assert findings[0].status == "FAIL" - assert "uses 'Default Rule' as its active Priority 1 rule" in ( - findings[0].status_extended - ) - - def test_ignores_other_custom_policies(self): - default_policy = _default_policy( + def test_emits_one_finding_per_policy(self): + # Custom policy at priority 1 with a permissive rule + Default Policy + # with a strict rule -> two findings, ordered by policy priority. + admins_policy = custom_policy( [ - _non_default_rule("Strict 15min", 15, priority=1), - _default_rule(priority=2), + non_default_rule("Admin Loose", idle_min=120, priority=1), + default_rule(priority=2), + ], + name="Admins Policy", + ) + strict_default = default_policy( + [ + non_default_rule("Strict 15min", idle_min=15, priority=1), + default_rule(priority=2), ] ) - custom_policy = _custom_policy( + findings = _run_check( + build_signon_client( + {"pol-custom": admins_policy, "pol-default": strict_default} + ) + ) + assert len(findings) == 2 + by_name = {f.resource_name: f for f in findings} + assert by_name["Admins Policy"].status == "FAIL" + assert "priority 1, custom" in by_name["Admins Policy"].status_extended + assert by_name["Default Policy"].status == "PASS" + assert "priority 99, default" in by_name["Default Policy"].status_extended + + def test_inactive_policy_is_skipped(self): + inactive = GlobalSessionPolicy( + id="pol-inactive", + name="Disabled Policy", + priority=1, + status="INACTIVE", + is_default=False, + rules=[non_default_rule("Loose 120min", idle_min=120, priority=1)], + ) + active_default = default_policy( [ - _non_default_rule("Loose Admin Rule", 60, priority=1), - _default_rule(priority=2), + non_default_rule("Strict 15min", idle_min=15, priority=1), + default_rule(priority=2), ] ) - signon_client = _build_signon_client( - {"pol-custom": custom_policy, "pol-default": 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_idle_timeout_15min.signon_global_session_idle_timeout_15min import ( - signon_global_session_idle_timeout_15min, + findings = _run_check( + build_signon_client( + {"pol-inactive": inactive, "pol-default": active_default} ) + ) + assert len(findings) == 1 + assert findings[0].resource_name == "Default Policy" + assert findings[0].status == "PASS" - findings = signon_global_session_idle_timeout_15min().execute() - assert len(findings) == 1 - assert findings[0].status == "PASS" - assert findings[0].resource_name == "Default Policy" - - def test_fail_when_default_policy_is_inactive(self): - policy = GlobalSessionPolicy( + def test_fail_when_all_policies_inactive(self): + only_inactive = GlobalSessionPolicy( id="pol-default", name="Default Policy", priority=99, status="INACTIVE", is_default=True, - rules=[_non_default_rule("Strict 15min", 15, priority=1)], + rules=[non_default_rule("Strict 15min", idle_min=15, priority=1)], ) - 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_idle_timeout_15min.signon_global_session_idle_timeout_15min import ( - signon_global_session_idle_timeout_15min, - ) + findings = _run_check(build_signon_client({"pol-default": only_inactive})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Global Session Policies" in findings[0].status_extended - findings = signon_global_session_idle_timeout_15min().execute() - assert len(findings) == 1 - assert findings[0].status == "FAIL" - assert "status 'INACTIVE'" in findings[0].status_extended + def test_missing_scope_returns_manual_finding_naming_the_scope(self): + findings = _run_check( + build_signon_client( + missing_scope={ + "global_session_policies": "okta.policies.read", + "sign_in_pages": None, + } + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + assert "missing the required" in findings[0].status_extended def test_threshold_overridden_via_audit_config(self): - # 30-minute rule fails the STIG default of 15, but passes a relaxed - # threshold of 60 minutes set in audit_config. - policy = _default_policy( + policy = default_policy( [ - _non_default_rule("Relaxed 30min", 30, priority=1), - _default_rule(priority=2), + non_default_rule("Relaxed 30min", idle_min=30, priority=1), + default_rule(priority=2), ] ) - signon_client = _build_signon_client( - {"pol-default": policy}, - audit_config={"okta_max_session_idle_minutes": 60}, - ) - 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_idle_timeout_15min.signon_global_session_idle_timeout_15min import ( - signon_global_session_idle_timeout_15min, + findings = _run_check( + build_signon_client( + {"pol-default": policy}, + audit_config={"okta_max_session_idle_minutes": 60}, ) - - findings = signon_global_session_idle_timeout_15min().execute() - assert len(findings) == 1 - assert findings[0].status == "PASS" - assert "threshold of 60 minutes" in findings[0].status_extended + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "threshold of 60 minutes" in findings[0].status_extended 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 new file mode 100644 index 0000000000..1a22c20573 --- /dev/null +++ b/tests/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h_test.py @@ -0,0 +1,212 @@ +from unittest import mock + +from prowler.providers.okta.services.signon.signon_service import ( + GlobalSessionPolicy, + GlobalSessionPolicyRule, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.signon.signon_fixtures import ( + build_signon_client, + custom_policy, + default_policy, + default_rule, + non_default_rule, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.signon." + "signon_global_session_lifetime_18h." + "signon_global_session_lifetime_18h.signon_client" +) + + +def _run_check(signon_client): + 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, + ) + + return signon_global_session_lifetime_18h().execute() + + +class Test_signon_global_session_lifetime_18h: + def test_no_policies(self): + findings = _run_check(build_signon_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Global Session Policies" in findings[0].status_extended + + def test_pass_when_priority_one_non_default_rule_is_compliant(self): + policy = default_policy( + [ + non_default_rule("18h rule", lifetime_min=1080, priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "18h rule" in findings[0].status_extended + assert "1080 minutes" in findings[0].status_extended + assert "priority 99, default" in findings[0].status_extended + + def test_fail_when_lifetime_exceeds_threshold(self): + policy = default_policy( + [ + non_default_rule("Loose 24h rule", lifetime_min=1440, priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "1440 minutes" in findings[0].status_extended + assert "exceeding the configured threshold" in findings[0].status_extended + + def test_fail_when_priority_one_rule_has_no_lifetime(self): + policy = default_policy( + [ + GlobalSessionPolicyRule( + id="rule-no-session", + name="No Session Block", + priority=1, + status="ACTIVE", + is_default=False, + max_session_lifetime_minutes=None, + ), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + 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), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + 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( + [ + default_rule(priority=1, lifetime_min=1080), + non_default_rule("Compliant", lifetime_min=1080, priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "uses 'Default Rule' as its active Priority 1 rule" in ( + findings[0].status_extended + ) + + def test_emits_one_finding_per_policy(self): + admins_policy = custom_policy( + [ + non_default_rule("Admin Long Lived", lifetime_min=2880, priority=1), + default_rule(priority=2), + ], + name="Admins Policy", + ) + strict_default = default_policy( + [ + non_default_rule("18h rule", lifetime_min=1080, priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-custom": admins_policy, "pol-default": strict_default} + ) + ) + assert len(findings) == 2 + by_name = {f.resource_name: f for f in findings} + assert by_name["Admins Policy"].status == "FAIL" + assert "priority 1, custom" in by_name["Admins Policy"].status_extended + assert by_name["Default Policy"].status == "PASS" + + def test_inactive_policy_is_skipped(self): + inactive = GlobalSessionPolicy( + id="pol-inactive", + name="Disabled Policy", + priority=1, + status="INACTIVE", + is_default=False, + rules=[non_default_rule("Loose", lifetime_min=2880, priority=1)], + ) + active_default = default_policy( + [ + non_default_rule("18h rule", lifetime_min=1080, priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-inactive": inactive, "pol-default": active_default} + ) + ) + assert len(findings) == 1 + assert findings[0].resource_name == "Default Policy" + assert findings[0].status == "PASS" + + def test_fail_when_all_policies_inactive(self): + only_inactive = GlobalSessionPolicy( + id="pol-default", + name="Default Policy", + priority=99, + status="INACTIVE", + is_default=True, + rules=[non_default_rule("18h rule", lifetime_min=1080, priority=1)], + ) + findings = _run_check(build_signon_client({"pol-default": only_inactive})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Global Session Policies" in findings[0].status_extended + + def test_missing_scope_returns_manual_finding_naming_the_scope(self): + findings = _run_check( + build_signon_client( + missing_scope={ + "global_session_policies": "okta.policies.read", + "sign_in_pages": None, + } + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + assert "missing the required" in findings[0].status_extended + + def test_threshold_overridden_via_audit_config(self): + policy = default_policy( + [ + non_default_rule("Relaxed 24h", lifetime_min=1440, priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-default": policy}, + audit_config={"okta_max_session_lifetime_minutes": 1440}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "threshold of 1440 minutes" in findings[0].status_extended diff --git a/tests/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced_test.py b/tests/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced_test.py new file mode 100644 index 0000000000..1d3067a257 --- /dev/null +++ b/tests/providers/okta/services/signon/signon_global_session_policy_network_zone_enforced/signon_global_session_policy_network_zone_enforced_test.py @@ -0,0 +1,222 @@ +from unittest import mock + +from prowler.providers.okta.services.signon.signon_service import ( + GlobalSessionPolicy, + GlobalSessionPolicyRule, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.signon.signon_fixtures import ( + build_signon_client, + custom_policy, + default_policy, + default_rule, + non_default_rule, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.signon." + "signon_global_session_policy_network_zone_enforced." + "signon_global_session_policy_network_zone_enforced.signon_client" +) + + +def _run_check(signon_client): + 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_policy_network_zone_enforced.signon_global_session_policy_network_zone_enforced import ( + signon_global_session_policy_network_zone_enforced, + ) + + return signon_global_session_policy_network_zone_enforced().execute() + + +class Test_signon_global_session_policy_network_zone_enforced: + def test_no_policies(self): + findings = _run_check(build_signon_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Global Session Policies" in findings[0].status_extended + + def test_pass_when_priority_one_non_default_rule_includes_zone(self): + policy = default_policy( + [ + non_default_rule( + "Allow-from-VPN", + network_zones_include=["zone-corp"], + priority=1, + ), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Allow-from-VPN" in findings[0].status_extended + assert "non-default rule" in findings[0].status_extended + assert "priority 99, default" in findings[0].status_extended + + def test_pass_when_priority_one_non_default_rule_excludes_zone(self): + policy = default_policy( + [ + non_default_rule( + "Block-blacklist", + network_zones_exclude=["zone-blocked"], + priority=1, + ), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Block-blacklist" in findings[0].status_extended + + def test_pass_when_only_default_rule_has_zones(self): + policy = default_policy( + [ + GlobalSessionPolicyRule( + id="rule-default-zoned", + name="Default Rule", + priority=1, + status="ACTIVE", + is_default=True, + network_zones_include=["zone-corp"], + ), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "built-in Default Rule" in findings[0].status_extended + + def test_fail_when_priority_one_rule_has_no_zones(self): + policy = default_policy( + [ + non_default_rule("Plain non-default", priority=1), + default_rule(priority=2), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "Plain non-default" in findings[0].status_extended + assert "does not map" in findings[0].status_extended + + def test_fail_when_only_lower_priority_rule_has_zones(self): + policy = default_policy( + [ + non_default_rule("No-zones top", priority=1), + non_default_rule( + "Zoned-but-low", + network_zones_include=["zone-corp"], + priority=2, + ), + default_rule(priority=3), + ] + ) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No-zones top" in findings[0].status_extended + + def test_fail_when_only_default_rule_has_no_zones(self): + policy = default_policy([default_rule(priority=1)]) + findings = _run_check(build_signon_client({"pol-default": policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "built-in Default Rule" in findings[0].status_extended + + def test_emits_one_finding_per_policy(self): + admins_policy = custom_policy( + [ + non_default_rule("No-zones admin", priority=1), + default_rule(priority=2), + ], + name="Admins Policy", + ) + zoned_default = default_policy( + [ + non_default_rule( + "Allow-corp", + network_zones_include=["zone-corp"], + priority=1, + ), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-custom": admins_policy, "pol-default": zoned_default} + ) + ) + assert len(findings) == 2 + by_name = {f.resource_name: f for f in findings} + assert by_name["Admins Policy"].status == "FAIL" + assert "priority 1, custom" in by_name["Admins Policy"].status_extended + assert by_name["Default Policy"].status == "PASS" + + def test_inactive_policy_is_skipped(self): + inactive = GlobalSessionPolicy( + id="pol-inactive", + name="Disabled Policy", + priority=1, + status="INACTIVE", + is_default=False, + rules=[non_default_rule("No-zones", priority=1)], + ) + active_default = default_policy( + [ + non_default_rule( + "Allow-corp", + network_zones_include=["zone-corp"], + priority=1, + ), + default_rule(priority=2), + ] + ) + findings = _run_check( + build_signon_client( + {"pol-inactive": inactive, "pol-default": active_default} + ) + ) + assert len(findings) == 1 + assert findings[0].resource_name == "Default Policy" + assert findings[0].status == "PASS" + + def test_fail_when_all_policies_inactive(self): + only_inactive = GlobalSessionPolicy( + id="pol-default", + name="Default Policy", + priority=99, + status="INACTIVE", + is_default=True, + rules=[ + non_default_rule( + "Allow-corp", + network_zones_include=["zone-corp"], + priority=1, + ) + ], + ) + findings = _run_check(build_signon_client({"pol-default": only_inactive})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Global Session Policies" in findings[0].status_extended + + def test_missing_scope_returns_manual_finding_naming_the_scope(self): + findings = _run_check( + build_signon_client( + missing_scope={ + "global_session_policies": "okta.policies.read", + "sign_in_pages": None, + } + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + assert "missing the required" in findings[0].status_extended diff --git a/tests/providers/okta/services/signon/signon_service_test.py b/tests/providers/okta/services/signon/signon_service_test.py index 4762b16b88..c408bc1221 100644 --- a/tests/providers/okta/services/signon/signon_service_test.py +++ b/tests/providers/okta/services/signon/signon_service_test.py @@ -1,12 +1,18 @@ from unittest import mock +from prowler.providers.okta.models import OktaIdentityInfo from prowler.providers.okta.services.signon.signon_service import ( GlobalSessionPolicy, GlobalSessionPolicyRule, + SignInPage, Signon, _next_after_cursor, ) -from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.okta_fixtures import ( + OKTA_CLIENT_ID, + OKTA_ORG_DOMAIN, + set_mocked_okta_provider, +) def _fake_policy( @@ -48,12 +54,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 +120,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 +164,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 +182,251 @@ 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 == {} + + def test_skips_policy_fetch_when_scope_missing(self): + identity = OktaIdentityInfo( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + granted_scopes=["okta.brands.read"], # policies scope missing + ) + provider = set_mocked_okta_provider(identity=identity) + + list_policies_called = False + + async def fake_list_policies(*_a, **_k): + nonlocal list_policies_called + list_policies_called = True + return ([], _resp({}), None) + + 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 = _empty_brands + mocked_client_cls.return_value = mocked + service = Signon(provider) + + assert list_policies_called is False + assert service.global_session_policies == {} + assert service.missing_scope["global_session_policies"] == "okta.policies.read" + assert service.missing_scope["sign_in_pages"] is None + + def test_unknown_granted_scopes_falls_back_to_attempting_fetch(self): + # When the JWT couldn't be decoded, granted_scopes is empty and the + # service must still attempt the fetch — preserves prior behavior. + identity = OktaIdentityInfo( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + granted_scopes=[], + ) + provider = set_mocked_okta_provider(identity=identity) + + list_policies_called = False + + async def fake_list_policies(*_a, **_k): + nonlocal list_policies_called + list_policies_called = True + return ([], _resp({}), None) + + 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 = _empty_brands + mocked_client_cls.return_value = mocked + service = Signon(provider) + + assert list_policies_called is True + assert service.missing_scope["global_session_policies"] is None + assert service.missing_scope["sign_in_pages"] is None + + +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, + default_sign_in_page_responses: dict | None = None, + ): + 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] + + 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: + 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.get_default_sign_in_page = fake_get_default_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_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( + 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": (None, _resp({}), Exception("403 Forbidden")) + }, + ) + + 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() + 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")) + }, + default_sign_in_page_responses={}, + ) + + 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_skips_brand_fetch_when_scope_missing(self): + identity = OktaIdentityInfo( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + granted_scopes=["okta.policies.read"], # brands scope missing + ) + provider = set_mocked_okta_provider(identity=identity) + + async def fake_list_policies(*_a, **_k): + return ([], _resp({}), None) + + list_brands_called = False + + async def fake_list_brands(*_a, **_k): + nonlocal list_brands_called + list_brands_called = True + return ([], _resp({}), None) + + 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_client_cls.return_value = mocked + service = Signon(provider) + + assert list_brands_called is False + assert service.sign_in_pages == {} + assert service.missing_scope["sign_in_pages"] == "okta.brands.read" + assert service.missing_scope["global_session_policies"] is None + + 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")), + }, + 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"