diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx index 8d24eb90f8..caf8853a1b 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -166,6 +166,7 @@ The following list includes all the Okta checks with configurable variables that | Check Name | Value | Type | |---------------------------------------------------------------|------------------------------------|---------| +| `application_admin_console_session_idle_timeout_15min` | `okta_admin_console_idle_timeout_max_minutes` | Integer | | `signon_global_session_idle_timeout_15min` | `okta_max_session_idle_minutes` | Integer | ## Config YAML File Structure diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx index da820eb45a..c9154ee936 100644 --- a/docs/user-guide/providers/okta/authentication.mdx +++ b/docs/user-guide/providers/okta/authentication.mdx @@ -30,25 +30,49 @@ If a different authentication method is needed (SSWS API token, OAuth with user ### Required OAuth Scopes -The bundled signon checks require the following read-only scopes: +The bundled checks require the following read-only scopes: - `okta.policies.read` - `okta.brands.read` +- `okta.apps.read` 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.policies.read` | Sign-on, password, and authentication policies | | `okta.brands.read` | Sign-in page customizations (DOD Notice and Consent Banner check) | +| `okta.apps.read` | First-party app settings (Okta Admin Console session), integrated app inventory, and the Authentication Policies bound to Okta applications | ### Required Admin Role -The service application must be assigned the built-in **Read-Only Administrator** role. +The service application must be assigned **one** of the following Okta admin roles: -Okta's Management API enforces a two-layer authorization model: an OAuth **scope** decides which API endpoints the token can call, and an **admin role** decides whether the call returns data. With only a scope granted, the token mint succeeds but every read returns `403 Forbidden`. The Read-Only Administrator role is the minimum that lets the granted `okta.*.read` scopes actually return configuration data to Prowler's checks — without it, the credential probe at provider startup fails and the scan never gets to evaluate any check. +- **Read-Only Administrator** — covers every `signon` check and runs `application_authentication_policy_network_zone_enforced` against the apps it can see. **Visibility caveat:** under Read-Only Administrator the `/api/v1/apps` endpoint returns only the apps the service application is itself assigned to — typically just the service app's own row (for example, `Prowler Scanner`). The check still produces a finding for that app, but the rest of the org's app inventory is invisible at this role level. +- **Super Administrator** — required additionally to evaluate five application-service checks that target Okta's first-party apps (Okta Admin Console, Okta Dashboard). With Super Administrator, `application_authentication_policy_network_zone_enforced` also evaluates the full org-wide app inventory instead of the service-app-only slice. -Read-Only Administrator is intentionally the narrowest role that satisfies this requirement and aligns with the least-privilege guidance in DISA STIG. +Okta's Management API enforces a two-layer authorization model: an OAuth **scope** decides which API endpoints the token can call, and an **admin role** decides whether the call returns data. With only a scope granted, the token mint succeeds but every read returns `403 Forbidden`. Read-Only Administrator is the minimum role that lets the granted `okta.*.read` scopes return configuration data to Prowler's checks; without it, the credential probe at provider startup fails and the scan never gets to evaluate any check. + +#### When Super Administrator is required + +Four checks need to resolve the Authentication Policy bound to Okta's first-party apps (Okta Admin Console, Okta Dashboard) and depend on `/api/v1/apps` returning those system apps — which Okta restricts to Super Administrator: + +| Check | STIG | +|---|---| +| `application_admin_console_mfa_required` | V-273193 | +| `application_admin_console_phishing_resistant_authentication` | V-273191 | +| `application_dashboard_mfa_required` | V-273194 | +| `application_dashboard_phishing_resistant_authentication` | V-273190 | + +Okta filters the first-party apps (`saasure`, `okta_enduser`) out of `/api/v1/apps` for every role below Super Administrator, so `okta.apps.read` alone is not enough. The `okta.apps.manageFirstPartyApps` permission exists only in the paid Okta Identity Governance role `ACCESS_REQUESTS_ADMIN` and cannot be added to custom roles ([Okta Permissions Catalog](https://developer.okta.com/docs/api/openapi/okta-management/guides/permissions)). + +A fifth check — `application_admin_console_session_idle_timeout_15min` (STIG V-273187) — also requires Super Administrator: it calls `GET /api/v1/first-party-app-settings/admin-console`, which returns `403 E0000006` for every role below Super Administrator. + +When the service app runs with Read-Only Administrator, the five checks listed in this section return **MANUAL** instead of PASS/FAIL — the rest of the scan keeps running. + + +Read-Only Administrator stays the recommended default for the least-privilege framing that aligns with DISA STIG. Assign Super Administrator on a separate run when full coverage of the first-party app checks is needed. + ## Step-by-Step Setup @@ -98,19 +122,21 @@ 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. The bundled signon checks require `okta.policies.read` and `okta.brands.read`. +On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, and `okta.apps.read`. ![Okta — grant OAuth scopes](/user-guide/providers/okta/images/grant-permissions.png) -### 6. Assign the Read-Only Administrator role +### 6. Assign an admin role On the app, open the **Admin roles** tab and click **Edit assignments → Add assignment**: -- **Role:** Read-Only Administrator +- **Role:** Read-Only Administrator (default) — covers every `signon` check and runs the per-app network-zone check against the apps the service app can see (typically only the service app's own row). - **Resources:** All resources Save the changes. +To additionally evaluate the first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, and phishing-resistant authentication) and to widen the per-app network-zone check to the full org-wide app inventory, assign **Super Administrator** instead. Without Super Administrator, the five first-party checks return MANUAL and the network-zone check is limited to the service app's own visibility — the rest of the scan still runs. See [Required Admin Role](#required-admin-role) for the full breakdown. + ![Okta — grant Read-Only role](/user-guide/providers/okta/images/grant-roles.png) ### 7. [Optional] Verify DPoP setting @@ -132,8 +158,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,okta.brands.read" -export OKTA_SCOPES="okta.policies.read,okta.brands.read" +# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read" uv run python prowler-cli.py okta ``` @@ -174,8 +200,12 @@ 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`** — 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_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, or `okta.apps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**. +- **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**. + +### Application-service checks return MANUAL on first-party apps + +When the service app runs with Read-Only Administrator, the five application-service checks targeting the Okta Admin Console and Okta Dashboard return MANUAL. This is by design — Okta restricts the underlying endpoints (`/api/v1/first-party-app-settings/{appName}` and `/api/v1/apps` for first-party app `name` values `saasure` / `okta_enduser`) to **Super Administrator**. Assign the Super Administrator role to the service app to evaluate those checks. See [Required Admin Role](#required-admin-role) for the full list. ### `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 53ef6ba8e8..66d3dd1808 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` and `okta.brands.read` scopes granted and the **Read-Only Administrator** role assigned. +- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, and `okta.apps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers every `signon` check and runs the per-app network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown. - Python 3.10+ and Prowler 5.27.0 or later installed locally. @@ -85,8 +85,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,okta.brands.read" -export OKTA_SCOPES="okta.policies.read,okta.brands.read" +# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read" ``` The private key file may contain either a PEM-encoded RSA key or a JWK JSON document. @@ -128,6 +128,9 @@ okta: # okta.signon_global_session_idle_timeout_15min # Defaults to 15 minutes per DISA STIG V-273186. okta_max_session_idle_minutes: 15 + # okta.application_admin_console_session_idle_timeout_15min + # Defaults to 15 minutes per DISA STIG V-273187. + okta_admin_console_idle_timeout_max_minutes: 15 ``` To use a custom configuration: @@ -140,9 +143,10 @@ prowler okta --config-file /path/to/config.yaml Prowler for Okta includes security checks across the following services: -| Service | Description | -| ----------- | ----------------------------------------------------------------------------------- | -| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) | +| Service | Description | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) | +| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) | ## Troubleshooting @@ -154,10 +158,11 @@ 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 defaults cover the bundled signon checks: +Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On and Application services: - `okta.policies.read` - `okta.brands.read` +- `okta.apps.read` 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. diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index a619c42d49..08a01233d7 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🚀 Added +- `application` service for Okta provider with `application_admin_console_session_idle_timeout_15min`, `application_admin_console_mfa_required`, `application_admin_console_phishing_resistant_authentication`, `application_dashboard_mfa_required`, `application_dashboard_phishing_resistant_authentication`, and `application_authentication_policy_network_zone_enforced` checks [(#11358)](https://github.com/prowler-cloud/prowler/pull/11358) - AWS AI Security Framework compliance for AWS provider [(#11353)](https://github.com/prowler-cloud/prowler/pull/11353) - `storage_account_public_network_access_disabled` check for Azure provider and remapped the Azure CIS "Public Network Access is Disabled" requirements to it [(#11334)](https://github.com/prowler-cloud/prowler/pull/11334) diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index ae263789e7..28f07c2051 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -669,3 +669,9 @@ okta: # 15 per DISA STIG V-273186 (OKTA-APP-000020); raise it only with an # explicit risk acceptance. okta_max_session_idle_minutes: 15 + # Okta Applications + # okta.application_admin_console_session_idle_timeout_15min + # Maximum acceptable Okta Admin Console app idle timeout, in minutes. + # Defaults to 15 per DISA STIG V-273187 (OKTA-APP-000025); raise it only + # with an explicit risk acceptance. + okta_admin_console_idle_timeout_max_minutes: 15 diff --git a/prowler/providers/okta/lib/arguments/arguments.py b/prowler/providers/okta/lib/arguments/arguments.py index 82c891e70c..287f786b0f 100644 --- a/prowler/providers/okta/lib/arguments/arguments.py +++ b/prowler/providers/okta/lib/arguments/arguments.py @@ -35,7 +35,7 @@ def init_parser(self): nargs="+", help=( "OAuth scopes to request, space-separated " - "(e.g. okta.policies.read okta.brands.read okta.users.read). " + "(e.g. okta.policies.read okta.brands.read okta.apps.read). " "Defaults to the read scopes required by the bundled checks." ), default=None, diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py index a6bff16d80..0a7b0b8e9d 100644 --- a/prowler/providers/okta/okta_provider.py +++ b/prowler/providers/okta/okta_provider.py @@ -32,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", "okta.brands.read"] +DEFAULT_SCOPES = ["okta.policies.read", "okta.brands.read", "okta.apps.read"] # Accept only Okta-managed domains. Custom (vanity) domains are rejected on # purpose — they're a recurring source of typos and silent misconfig and # Prowler's audience overwhelmingly uses Okta-managed hosts. The TLDs below diff --git a/prowler/providers/okta/services/application/__init__.py b/prowler/providers/okta/services/application/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/application/application_admin_console_mfa_required/__init__.py b/prowler/providers/okta/services/application/application_admin_console_mfa_required/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required.metadata.json b/prowler/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required.metadata.json new file mode 100644 index 0000000000..dbe02f23f8 --- /dev/null +++ b/prowler/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "application_admin_console_mfa_required", + "CheckTitle": "Okta Admin Console authentication policy enforces multifactor authentication", + "CheckType": [], + "ServiceName": "application", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "The **Authentication Policy** bound to the **Okta Admin Console** app must require MFA. On its top active rule, *User must authenticate with* must be set to `Password / IdP + Another factor` or `Any 2 factor types` (`factorMode=2FA` in the API).", + "Risk": "Single-factor access to the Okta control plane is the highest-impact identity risk in the tenant.\n\n- **Credential compromise** is enough to take over every administrator account\n- **Lateral movement** into every downstream SaaS that trusts Okta SSO\n- **Privileged configuration changes** with no second-factor barrier", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/identity-engine/policies/about-app-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** > **Authentication Policies**.\n3. Open the **Okta Admin Console** policy.\n4. Edit the top active rule.\n5. Set *User must authenticate with* to `Password / IdP + Another factor` or `Any 2 factor types`.\n6. Save the rule.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require MFA on the top active rule of the Okta Admin Console authentication policy. Set *User must authenticate with* to `Password / IdP + Another factor` or `Any 2 factor types`.", + "Url": "https://hub.prowler.com/check/application_admin_console_mfa_required" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273193 / OKTA-APP-000560." +} diff --git a/prowler/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required.py b/prowler/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required.py new file mode 100644 index 0000000000..bf21a1a766 --- /dev/null +++ b/prowler/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required.py @@ -0,0 +1,89 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.application.application_client import ( + application_client, +) +from prowler.providers.okta.services.application.application_service import ( + ADMIN_CONSOLE_APP_NAME, +) +from prowler.providers.okta.services.application.lib.application_helpers import ( + app_label, + app_not_found_finding, + missing_app_scope_finding, + policy_missing_finding, + rule_label, + top_active_rule, +) + +ADMIN_CONSOLE_LABEL_HINT = "Okta Admin Console" + + +class application_admin_console_mfa_required(Check): + """STIG V-273193 / OKTA-APP-000560. + + The Authentication Policy bound to the Okta Admin Console app must + require multifactor authentication on its top rule: `User must + authenticate with` set to `Password / IdP + Another factor` or + `Any 2 factor types`. + + The underlying SDK exposes this as `AssuranceMethod.factor_mode` + with values `1FA` / `2FA`. + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + org_domain = application_client.provider.identity.org_domain + + for scope_key in ("built_in_apps", "access_policies"): + missing_scope = application_client.missing_scope.get(scope_key) + if missing_scope: + findings.append( + missing_app_scope_finding( + self.metadata(), + org_domain, + missing_scope, + ADMIN_CONSOLE_LABEL_HINT, + ) + ) + return findings + + app = application_client.built_in_apps.get(ADMIN_CONSOLE_APP_NAME) + if app is None: + findings.append( + app_not_found_finding( + self.metadata(), org_domain, ADMIN_CONSOLE_LABEL_HINT + ) + ) + return findings + + if app.access_policy_id is None or app.access_policy is None: + findings.append(policy_missing_finding(self.metadata(), org_domain, app)) + return findings + + report = CheckReportOkta( + metadata=self.metadata(), resource=app, org_domain=org_domain + ) + rule = top_active_rule(app) + if rule is None: + report.status = "FAIL" + report.status_extended = ( + f"{app_label(app)} has no active rules on its Authentication " + "Policy. The top rule must set `User must authenticate with` to " + "`Password / IdP + Another factor` or `Any 2 factor types`." + ) + elif rule.factor_mode == "2FA": + report.status = "PASS" + report.status_extended = ( + f"Top active {rule_label(rule)} on {app_label(app)} enforces " + "multifactor authentication (`factorMode=2FA`)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Top active {rule_label(rule)} on {app_label(app)} does not " + f"enforce multifactor authentication " + f"(`factorMode={rule.factor_mode or 'unset'}`). " + "Set `User must authenticate with` to `Password / IdP + Another " + "factor` or `Any 2 factor types`." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/__init__.py b/prowler/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication.metadata.json b/prowler/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication.metadata.json new file mode 100644 index 0000000000..0c86fdd974 --- /dev/null +++ b/prowler/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "application_admin_console_phishing_resistant_authentication", + "CheckTitle": "Okta Admin Console authentication policy enforces phishing-resistant factors", + "CheckType": [], + "ServiceName": "application", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "The **Authentication Policy** bound to the **Okta Admin Console** app must restrict possession factors to phishing-resistant authenticators (FIDO2/WebAuthn, PIV/CAC, Okta FastPass with biometrics). On the top active rule, *Possession factor constraints are: Phishing resistant* must be checked (`possession.phishingResistant=REQUIRED`).", + "Risk": "Phishable possession factors (SMS, voice, standard push, OTP delivered via reverse-proxy AiTM) leave the most privileged surface of the IdP exposed.\n\n- **Credential phishing** against administrators succeeds despite MFA\n- **Adversary-in-the-Middle attacks** capture session tokens through fake login pages\n- **Account takeover** of the tenant control plane", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/identity-engine/authenticators/phishing-resistant-auth.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** > **Authentication Policies**.\n3. Open the **Okta Admin Console** policy.\n4. Edit the top active rule.\n5. Under *Possession factor constraints are*, check **Phishing resistant**.\n6. Save the rule.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require phishing-resistant possession factors on the top active rule of the Okta Admin Console authentication policy.", + "Url": "https://hub.prowler.com/check/application_admin_console_phishing_resistant_authentication" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273191 / OKTA-APP-000190." +} diff --git a/prowler/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication.py b/prowler/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication.py new file mode 100644 index 0000000000..237ffe27bb --- /dev/null +++ b/prowler/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication.py @@ -0,0 +1,88 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.application.application_client import ( + application_client, +) +from prowler.providers.okta.services.application.application_service import ( + ADMIN_CONSOLE_APP_NAME, +) +from prowler.providers.okta.services.application.lib.application_helpers import ( + app_label, + app_not_found_finding, + missing_app_scope_finding, + policy_missing_finding, + rule_label, + top_active_rule, +) + +ADMIN_CONSOLE_LABEL_HINT = "Okta Admin Console" + + +class application_admin_console_phishing_resistant_authentication(Check): + """STIG V-273191 / OKTA-APP-000190. + + The Authentication Policy bound to the Okta Admin Console app must + restrict possession factors to phishing-resistant authenticators. + The underlying SDK exposes `phishingResistant` on each + `PossessionConstraint`; at least one constraint object on the top + rule must set `phishingResistant=REQUIRED` (constraints are OR-ed + by Okta semantics). + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + org_domain = application_client.provider.identity.org_domain + + for scope_key in ("built_in_apps", "access_policies"): + missing_scope = application_client.missing_scope.get(scope_key) + if missing_scope: + findings.append( + missing_app_scope_finding( + self.metadata(), + org_domain, + missing_scope, + ADMIN_CONSOLE_LABEL_HINT, + ) + ) + return findings + + app = application_client.built_in_apps.get(ADMIN_CONSOLE_APP_NAME) + if app is None: + findings.append( + app_not_found_finding( + self.metadata(), org_domain, ADMIN_CONSOLE_LABEL_HINT + ) + ) + return findings + + if app.access_policy_id is None or app.access_policy is None: + findings.append(policy_missing_finding(self.metadata(), org_domain, app)) + return findings + + report = CheckReportOkta( + metadata=self.metadata(), resource=app, org_domain=org_domain + ) + rule = top_active_rule(app) + if rule is None: + report.status = "FAIL" + report.status_extended = ( + f"{app_label(app)} has no active rules on its Authentication " + "Policy. The top rule must mark " + "`Possession factor constraints are: Phishing resistant`." + ) + elif rule.possession_phishing_resistant_required: + report.status = "PASS" + report.status_extended = ( + f"Top active {rule_label(rule)} on {app_label(app)} enforces " + "phishing-resistant possession factors " + "(`possession.phishingResistant=REQUIRED`)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Top active {rule_label(rule)} on {app_label(app)} does not " + "enforce phishing-resistant possession factors. Enable " + "`Possession factor constraints are: Phishing resistant` " + "on the rule." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/__init__.py b/prowler/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min.metadata.json b/prowler/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min.metadata.json new file mode 100644 index 0000000000..fda8cd7816 --- /dev/null +++ b/prowler/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "application_admin_console_session_idle_timeout_15min", + "CheckTitle": "Okta Admin Console app session idle timeout is 15 minutes or less", + "CheckType": [], + "ServiceName": "application", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "The integrated **Okta Admin Console** app must close idle privileged sessions. *Maximum app session idle time* on the **Sign On** tab must be `15` minutes or less.\n\nThreshold override: `okta_admin_console_idle_timeout_max_minutes`.", + "Risk": "An unattended administrator workstation leaves the Okta control plane open for session hijacking.\n\n- **Privileged session takeover** by anyone with physical or remote access to the workstation\n- **Tenant-wide configuration changes** under the absent administrator's identity\n- **Bypassed reauthentication** for the most sensitive surface of the IdP", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/guides/configure-signon-policy/main/", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/OktaApplicationSettings/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Applications** > **Applications** > **Okta Admin Console**.\n3. Open the **Sign On** tab.\n4. Under **Okta Admin Console session**, set *Maximum app session idle time* to `15` minutes or less.\n5. Save the changes.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the *Maximum app session idle time* of the Okta Admin Console first-party app to `15` minutes or less so privileged administrator sessions terminate on inactivity.", + "Url": "https://hub.prowler.com/check/application_admin_console_session_idle_timeout_15min" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273187 / OKTA-APP-000025." +} diff --git a/prowler/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min.py b/prowler/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min.py new file mode 100644 index 0000000000..6103360f07 --- /dev/null +++ b/prowler/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min.py @@ -0,0 +1,89 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.application.application_client import ( + application_client, +) +from prowler.providers.okta.services.application.application_service import ( + AdminConsoleAppSettings, +) +from prowler.providers.okta.services.application.lib.application_helpers import ( + missing_admin_console_settings_scope_finding, +) + +DEFAULT_THRESHOLD_MINUTES = 15 + + +class application_admin_console_session_idle_timeout_15min(Check): + """STIG V-273187 / OKTA-APP-000025. + + The Okta Admin Console first-party app must set its + `Maximum app session idle time` to 15 minutes (or less) so privileged + administrator sessions terminate on inactivity. Threshold override: + `okta_admin_console_idle_timeout_max_minutes` in the audit config. + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + audit_config = application_client.audit_config or {} + threshold = audit_config.get( + "okta_admin_console_idle_timeout_max_minutes", + DEFAULT_THRESHOLD_MINUTES, + ) + org_domain = application_client.provider.identity.org_domain + + missing_scope = application_client.missing_scope.get( + "admin_console_app_settings" + ) + if missing_scope: + findings.append( + missing_admin_console_settings_scope_finding( + self.metadata(), org_domain, missing_scope + ) + ) + return findings + + settings = application_client.admin_console_app_settings + if settings is None: + placeholder = AdminConsoleAppSettings() + report = CheckReportOkta( + metadata=self.metadata(), resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + "Could not retrieve the Okta Admin Console first-party app " + "settings. Okta restricts `GET /api/v1/first-party-app-settings/" + "admin-console` to the Super Administrator role; every other " + "role — including Read-Only Administrator — receives " + "`403 E0000006`. Assign Super Administrator to the service " + f"app to evaluate this check. The `Maximum app session idle " + f"time` must be set to {threshold} minutes or less." + ) + findings.append(report) + return findings + + report = CheckReportOkta( + metadata=self.metadata(), resource=settings, org_domain=org_domain + ) + idle = settings.session_idle_timeout_minutes + if idle is None: + report.status = "FAIL" + report.status_extended = ( + "The Okta Admin Console first-party app does not define a " + "`Maximum app session idle time`. This value must be " + f"{threshold} minutes or less." + ) + elif idle <= threshold: + report.status = "PASS" + report.status_extended = ( + "The Okta Admin Console first-party app sets the maximum " + f"app session idle time to {idle} minutes, meeting the " + f"configured threshold of {threshold} minutes." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "The Okta Admin Console first-party app sets the maximum " + f"app session idle time to {idle} minutes, exceeding the " + f"configured threshold of {threshold} minutes." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/application/application_authentication_policy_network_zone_enforced/__init__.py b/prowler/providers/okta/services/application/application_authentication_policy_network_zone_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced.metadata.json b/prowler/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced.metadata.json new file mode 100644 index 0000000000..fd9234e0a6 --- /dev/null +++ b/prowler/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "okta", + "CheckID": "application_authentication_policy_network_zone_enforced", + "CheckTitle": "Okta application authentication policies enforce Network Zones", + "CheckType": [], + "ServiceName": "application", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "Every active Okta application must be bound to an **Authentication Policy** that uses **Network Zones**. Each active non-default rule must map *User's IP* to `In zone` or `Not in zone`, and the active built-in *Catch-all Rule* must set *Access is* to `Denied`.", + "Risk": "Applications without network-aware authentication rules can be reached from unauthorized locations and bypass location-based access controls.\n\n- **Unauthorized access paths** from unmanaged or blocked networks\n- **Inconsistent information-flow enforcement** across SSO applications\n- **Residual access** when the fallback rule still allows traffic after policy misses", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/identity-engine/policies/about-app-sign-on-policies.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Application/", + "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 allow-list and deny-list zones required by policy.\n3. For each active application, open **Applications** > **Applications** > *Application* > **Sign On**.\n4. In **User Authentication**, bind the appropriate **Authentication Policy** and open **View Policy Details**.\n5. For each active non-default rule, set *User's IP* to `In zone` or `Not in zone` and select the correct **Network Zone**.\n6. Edit the built-in **Catch-all Rule** and set *Access is* to `Denied`.\n7. Save the policy.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require every active application Authentication Policy to use Network Zones on each active non-default rule and to deny access on the Catch-all Rule.", + "Url": "https://hub.prowler.com/check/application_authentication_policy_network_zone_enforced" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-279693 / OKTA-APP-003244." +} diff --git a/prowler/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced.py b/prowler/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced.py new file mode 100644 index 0000000000..5e6e2c9c76 --- /dev/null +++ b/prowler/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced.py @@ -0,0 +1,151 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.application.application_client import ( + application_client, +) +from prowler.providers.okta.services.application.application_service import ( + AuthenticationPolicyRule, + OktaBuiltInApp, +) +from prowler.providers.okta.services.application.lib.application_helpers import ( + active_apps, + app_label, + missing_integrated_apps_scope_finding, + no_active_apps_finding, + rule_has_network_zone, + rule_label, +) + + +class application_authentication_policy_network_zone_enforced(Check): + """STIG V-279693 / OKTA-APP-003244. + + Every active Okta application must be bound to an Authentication + Policy that uses Network Zones. Each active non-default rule must map + `User's IP` to an allow/deny zone, and the active Catch-all Rule + must deny access. + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + org_domain = application_client.provider.identity.org_domain + + for scope_key in ("integrated_apps", "access_policies"): + missing_scope = application_client.missing_scope.get(scope_key) + if missing_scope: + findings.append( + missing_integrated_apps_scope_finding( + self.metadata(), + org_domain, + missing_scope, + ) + ) + return findings + + apps = active_apps(application_client.integrated_apps) + if not apps: + findings.append(no_active_apps_finding(self.metadata(), org_domain)) + return findings + + for app in apps: + report = CheckReportOkta( + metadata=self.metadata(), + resource=app, + org_domain=org_domain, + resource_name=app.label or app.name, + resource_id=app.id, + ) + status, status_extended = _evaluate_app(app) + report.status = status + report.status_extended = status_extended + findings.append(report) + return findings + + +def _active_rules(app: OktaBuiltInApp) -> list[AuthenticationPolicyRule]: + if app.access_policy is None: + return [] + return sorted( + [ + rule + for rule in app.access_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, + ), + ) + + +def _evaluate_app(app: OktaBuiltInApp) -> tuple[str, str]: + label = app_label(app) + if app.access_policy_id is None or app.access_policy is None: + return ( + "FAIL", + f"{label} has no Authentication Policy bound to it. " + "Bind an Access Policy in Security > Authentication Policies.", + ) + + active_rules = _active_rules(app) + if not active_rules: + return ( + "FAIL", + f"{label} has no active rules on its Authentication Policy. " + "Every active non-default rule must enforce a Network Zone " + "condition, and the Catch-all Rule must set `Access is: Denied`.", + ) + + nondefault_rules = [ + rule + for rule in active_rules + if not rule.is_default and rule.name != "Catch-all Rule" + ] + if not nondefault_rules: + return ( + "FAIL", + f"{label} has no active non-default rules on its Authentication " + "Policy. Define at least one non-default rule that maps `User's " + "IP` to a Network Zone, and use the Catch-all Rule only as the " + "final deny path.", + ) + + missing_zone_rules = [ + rule.name for rule in nondefault_rules if not rule_has_network_zone(rule) + ] + if missing_zone_rules: + quoted_rules = ", ".join(f"'{rule_name}'" for rule_name in missing_zone_rules) + return ( + "FAIL", + f"{label} has active non-default rule(s) without Network Zones: " + f"{quoted_rules}. Configure `User's IP` to `In zone` or `Not in zone` " + "for every active non-default rule.", + ) + + catch_all_rule = next( + ( + rule + for rule in active_rules + if rule.is_default or rule.name == "Catch-all Rule" + ), + None, + ) + if catch_all_rule is None: + return ( + "FAIL", + f"{label} has no active Catch-all Rule. The Catch-all Rule must " + "deny access after the zoned non-default rules.", + ) + + if catch_all_rule.access != "DENY": + return ( + "FAIL", + f"Active {rule_label(catch_all_rule)} on {label} does not set " + f"`Access is` to `DENY` (`access={catch_all_rule.access or 'unset'}`). " + "Set the Catch-all Rule to deny access.", + ) + + return ( + "PASS", + f"{label} applies Network Zones on every active non-default rule and " + f"its active {rule_label(catch_all_rule)} denies access.", + ) diff --git a/prowler/providers/okta/services/application/application_client.py b/prowler/providers/okta/services/application/application_client.py new file mode 100644 index 0000000000..63658ea3c6 --- /dev/null +++ b/prowler/providers/okta/services/application/application_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.application.application_service import Application + +application_client = Application(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/application/application_dashboard_mfa_required/__init__.py b/prowler/providers/okta/services/application/application_dashboard_mfa_required/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required.metadata.json b/prowler/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required.metadata.json new file mode 100644 index 0000000000..1211eaec62 --- /dev/null +++ b/prowler/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "application_dashboard_mfa_required", + "CheckTitle": "Okta Dashboard authentication policy enforces multifactor authentication", + "CheckType": [], + "ServiceName": "application", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "The **Authentication Policy** bound to the **Okta Dashboard** app must require MFA for end users. On its top active rule, *User must authenticate with* must be set to `Password / IdP + Another factor` or `Any 2 factor types` (`factorMode=2FA` in the API).", + "Risk": "Single-factor access to the Okta Dashboard lets an attacker pivot from one compromised password into every downstream SSO app.\n\n- **Credential stuffing** and password reuse attacks succeed in one step\n- **Lateral movement** into every SaaS the user has access to via Okta SSO\n- **Weakened identity assurance** for every user signing in to the end-user portal", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/identity-engine/policies/about-app-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** > **Authentication Policies**.\n3. Open the **Okta Dashboard** policy.\n4. Edit the top active rule.\n5. Set *User must authenticate with* to `Password / IdP + Another factor` or `Any 2 factor types`.\n6. Save the rule.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require MFA on the top active rule of the Okta Dashboard authentication policy. Set *User must authenticate with* to `Password / IdP + Another factor` or `Any 2 factor types`.", + "Url": "https://hub.prowler.com/check/application_dashboard_mfa_required" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273194 / OKTA-APP-000570." +} diff --git a/prowler/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required.py b/prowler/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required.py new file mode 100644 index 0000000000..48c04083fb --- /dev/null +++ b/prowler/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required.py @@ -0,0 +1,85 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.application.application_client import ( + application_client, +) +from prowler.providers.okta.services.application.application_service import ( + DASHBOARD_APP_NAME, +) +from prowler.providers.okta.services.application.lib.application_helpers import ( + app_label, + app_not_found_finding, + missing_app_scope_finding, + policy_missing_finding, + rule_label, + top_active_rule, +) + +DASHBOARD_LABEL_HINT = "Okta Dashboard" + + +class application_dashboard_mfa_required(Check): + """STIG V-273194 / OKTA-APP-000570. + + The Authentication Policy bound to the Okta Dashboard app must + require multifactor authentication on its top rule for + non-privileged users: `User must authenticate with` set to + `Password / IdP + Another factor` or `Any 2 factor types` + (`AssuranceMethod.factor_mode == "2FA"`). + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + org_domain = application_client.provider.identity.org_domain + + for scope_key in ("built_in_apps", "access_policies"): + missing_scope = application_client.missing_scope.get(scope_key) + if missing_scope: + findings.append( + missing_app_scope_finding( + self.metadata(), + org_domain, + missing_scope, + DASHBOARD_LABEL_HINT, + ) + ) + return findings + + app = application_client.built_in_apps.get(DASHBOARD_APP_NAME) + if app is None: + findings.append( + app_not_found_finding(self.metadata(), org_domain, DASHBOARD_LABEL_HINT) + ) + return findings + + if app.access_policy_id is None or app.access_policy is None: + findings.append(policy_missing_finding(self.metadata(), org_domain, app)) + return findings + + report = CheckReportOkta( + metadata=self.metadata(), resource=app, org_domain=org_domain + ) + rule = top_active_rule(app) + if rule is None: + report.status = "FAIL" + report.status_extended = ( + f"{app_label(app)} has no active rules on its Authentication " + "Policy. The top rule must set `User must authenticate with` to " + "`Password / IdP + Another factor` or `Any 2 factor types`." + ) + elif rule.factor_mode == "2FA": + report.status = "PASS" + report.status_extended = ( + f"Top active {rule_label(rule)} on {app_label(app)} enforces " + "multifactor authentication (`factorMode=2FA`)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Top active {rule_label(rule)} on {app_label(app)} does not " + f"enforce multifactor authentication " + f"(`factorMode={rule.factor_mode or 'unset'}`). " + "Set `User must authenticate with` to `Password / IdP + Another " + "factor` or `Any 2 factor types`." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/__init__.py b/prowler/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication.metadata.json b/prowler/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication.metadata.json new file mode 100644 index 0000000000..1c8996280b --- /dev/null +++ b/prowler/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "application_dashboard_phishing_resistant_authentication", + "CheckTitle": "Okta Dashboard authentication policy enforces phishing-resistant factors", + "CheckType": [], + "ServiceName": "application", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "The **Authentication Policy** bound to the **Okta Dashboard** app must restrict possession factors to phishing-resistant authenticators. On the top active rule, *Possession factor constraints are: Phishing resistant* must be checked (`possession.phishingResistant=REQUIRED`).", + "Risk": "Phishable possession factors leave end-user SSO sessions exposed to credential phishing and AiTM proxies.\n\n- **Credential phishing** against end users succeeds despite MFA\n- **Session token theft** through reverse-proxy AiTM attacks (Evilginx-class tooling)\n- **Compromise of every downstream SSO app** the user has access to", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/identity-engine/authenticators/phishing-resistant-auth.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** > **Authentication Policies**.\n3. Open the **Okta Dashboard** policy.\n4. Edit the top active rule.\n5. Under *Possession factor constraints are*, check **Phishing resistant**.\n6. Save the rule.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require phishing-resistant possession factors on the top active rule of the Okta Dashboard authentication policy.", + "Url": "https://hub.prowler.com/check/application_dashboard_phishing_resistant_authentication" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273190 / OKTA-APP-000180." +} diff --git a/prowler/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication.py b/prowler/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication.py new file mode 100644 index 0000000000..11fa310da2 --- /dev/null +++ b/prowler/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication.py @@ -0,0 +1,84 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.application.application_client import ( + application_client, +) +from prowler.providers.okta.services.application.application_service import ( + DASHBOARD_APP_NAME, +) +from prowler.providers.okta.services.application.lib.application_helpers import ( + app_label, + app_not_found_finding, + missing_app_scope_finding, + policy_missing_finding, + rule_label, + top_active_rule, +) + +DASHBOARD_LABEL_HINT = "Okta Dashboard" + + +class application_dashboard_phishing_resistant_authentication(Check): + """STIG V-273190 / OKTA-APP-000180. + + The Authentication Policy bound to the Okta Dashboard app must + restrict possession factors to phishing-resistant authenticators on + its top active rule + (`possession.phishingResistant=REQUIRED`). + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + org_domain = application_client.provider.identity.org_domain + + for scope_key in ("built_in_apps", "access_policies"): + missing_scope = application_client.missing_scope.get(scope_key) + if missing_scope: + findings.append( + missing_app_scope_finding( + self.metadata(), + org_domain, + missing_scope, + DASHBOARD_LABEL_HINT, + ) + ) + return findings + + app = application_client.built_in_apps.get(DASHBOARD_APP_NAME) + if app is None: + findings.append( + app_not_found_finding(self.metadata(), org_domain, DASHBOARD_LABEL_HINT) + ) + return findings + + if app.access_policy_id is None or app.access_policy is None: + findings.append(policy_missing_finding(self.metadata(), org_domain, app)) + return findings + + report = CheckReportOkta( + metadata=self.metadata(), resource=app, org_domain=org_domain + ) + rule = top_active_rule(app) + if rule is None: + report.status = "FAIL" + report.status_extended = ( + f"{app_label(app)} has no active rules on its Authentication " + "Policy. The top rule must mark " + "`Possession factor constraints are: Phishing resistant`." + ) + elif rule.possession_phishing_resistant_required: + report.status = "PASS" + report.status_extended = ( + f"Top active {rule_label(rule)} on {app_label(app)} enforces " + "phishing-resistant possession factors " + "(`possession.phishingResistant=REQUIRED`)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Top active {rule_label(rule)} on {app_label(app)} does not " + "enforce phishing-resistant possession factors. Enable " + "`Possession factor constraints are: Phishing resistant` " + "on the rule." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/application/application_service.py b/prowler/providers/okta/services/application/application_service.py new file mode 100644 index 0000000000..de2fe0a658 --- /dev/null +++ b/prowler/providers/okta/services/application/application_service.py @@ -0,0 +1,592 @@ +import json +from typing import Optional +from urllib.parse import parse_qs, urlparse + +from pydantic import BaseModel, ValidationError + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.service import OktaService + +# These three keys are Okta-platform constants, not tenant-configurable: +# +# - `saasure` / `okta_enduser` are the `name` fields of the OIN catalog +# templates for the Okta Admin Console and Okta Dashboard built-in apps. +# The Okta SDK's `OINApplication.name` is documented as "the key name for +# the OIN app definition" — tied to the platform-level template, not +# editable by customers. The user-facing field is `label`, which we read +# only for display purposes in finding text. +# - `admin-console` is the Okta-defined URL key for +# `/api/v1/first-party-app-settings/{appName}`; per the SDK's own +# `get_first_party_app_settings` docstring it is the only value Okta +# currently supports on that endpoint. +# +# If Okta introduces a new first-party app or renames one of these at the +# platform level, both the constants and the check coverage need updating +# together. +ADMIN_CONSOLE_APP_NAME = "saasure" +DASHBOARD_APP_NAME = "okta_enduser" +ADMIN_CONSOLE_FIRST_PARTY_APP_KEY = "admin-console" + + +def _next_after_cursor(resp) -> Optional[str]: + """Extract the `after` cursor from a `Link: ...; rel="next"` header. + + Returns None when there is no next page. Header format follows RFC 5988 + and Okta's pagination guide. Mirrors the helper in `signon_service` — + duplicated rather than shared until a third Okta service appears. + """ + if resp is None: + return None + headers = getattr(resp, "headers", None) or {} + link = headers.get("link") or headers.get("Link") or "" + if not link: + return None + for part in link.split(","): + if 'rel="next"' not in part: + continue + url_segment = part.split(";", 1)[0].strip().lstrip("<").rstrip(">") + cursor = parse_qs(urlparse(url_segment).query).get("after", [None])[0] + if cursor: + return cursor + return None + + +REQUIRED_SCOPES: dict[str, str] = { + "admin_console_app_settings": "okta.apps.read", + "built_in_apps": "okta.apps.read", + "integrated_apps": "okta.apps.read", + "access_policies": "okta.policies.read", +} + + +class Application(OktaService): + """Fetches Okta first-party apps and their bound Authentication Policies. + + Populates: + - `self.admin_console_app_settings` — first-party Admin Console session + knobs (`sessionIdleTimeoutMinutes`, `sessionMaxLifetimeMinutes`). + - `self.built_in_apps` — keyed by canonical `name` (`saasure`, + `okta_enduser`). Each entry carries the resolved Authentication + Policy (Access Policy) and its rules. + - `self.integrated_apps` — lazily populated and keyed by application id. + Used by the per-application network-zone STIG to evaluate every + active app returned by `/api/v1/apps`. + + Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the + access token's granted scopes (`provider.identity.granted_scopes`). + When a scope is known to be missing, the corresponding fetch is + skipped and recorded in `self.missing_scope` so each check can emit + an explicit MANUAL finding instead of a misleading + "no resources returned". Empty granted_scopes means "unknown" — the + service attempts the fetch and lets the SDK fail loudly. + """ + + 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.admin_console_app_settings: Optional[AdminConsoleAppSettings] = ( + None + if self.missing_scope["admin_console_app_settings"] + else self._get_admin_console_app_settings() + ) + + # Apps and policies share the same SDK round-trips, so fetch them + # together. When either scope is missing we still attempt the + # other, but `built_in_apps` is only populated when both are + # available — checks then look at `missing_scope` to report which + # one is at fault. + if self.missing_scope["built_in_apps"] or self.missing_scope["access_policies"]: + self.built_in_apps: dict[str, OktaBuiltInApp] = {} + else: + self.built_in_apps = self._list_built_in_apps_with_policies() + self._integrated_apps: Optional[dict[str, OktaBuiltInApp]] = None + + @property + def integrated_apps(self) -> dict[str, "OktaBuiltInApp"]: + """List every Okta-integrated app with its Authentication Policy. + + This is fetched lazily because only the V-279693 check needs the + full app inventory; the bundled Admin Console / Dashboard checks + only need the two built-in apps. + """ + if self._integrated_apps is None: + if ( + self.missing_scope["integrated_apps"] + or self.missing_scope["access_policies"] + ): + self._integrated_apps = {} + else: + self._integrated_apps = self._list_integrated_apps_with_policies() + return self._integrated_apps + + def _get_admin_console_app_settings(self) -> Optional["AdminConsoleAppSettings"]: + logger.info("Application - Fetching first-party Admin Console settings...") + try: + return self._run(self._fetch_admin_console_app_settings()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + + async def _fetch_admin_console_app_settings( + self, + ) -> Optional["AdminConsoleAppSettings"]: + result = await self.client.get_first_party_app_settings( + ADMIN_CONSOLE_FIRST_PARTY_APP_KEY + ) + err = result[-1] + if err is not None: + # 404 means the org is on Classic engine or the endpoint isn't + # available — fall through to None and checks emit MANUAL. + logger.error(f"Error fetching first-party Admin Console settings: {err}") + return None + data = result[0] + if data is None: + return None + return AdminConsoleAppSettings( + session_idle_timeout_minutes=getattr( + data, "session_idle_timeout_minutes", None + ), + session_max_lifetime_minutes=getattr( + data, "session_max_lifetime_minutes", None + ), + ) + + def _list_built_in_apps_with_policies(self) -> dict: + logger.info("Application - Listing Okta built-in apps and policies...") + try: + return self._run(self._fetch_built_in_apps_and_policies()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + def _list_integrated_apps_with_policies(self) -> dict: + logger.info("Application - Listing integrated Okta apps and policies...") + try: + return self._run(self._fetch_integrated_apps_and_policies()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_built_in_apps_and_policies(self) -> dict: + # Per-app try/except: one app's SDK failure (e.g. ValidationError + # while deserializing its policy rules) must not erase findings + # for the other. + result: dict[str, OktaBuiltInApp] = {} + for app_name in (ADMIN_CONSOLE_APP_NAME, DASHBOARD_APP_NAME): + try: + built_in_app = await self._fetch_built_in_app(app_name) + except Exception as error: + logger.error( + f"Error fetching built-in app {app_name}: " + f"{error.__class__.__name__}: {error}" + ) + continue + if built_in_app is None: + continue + if built_in_app.access_policy_id: + try: + built_in_app.access_policy = await self._fetch_access_policy( + built_in_app.access_policy_id + ) + except Exception as error: + logger.error( + f"Error fetching access policy " + f"{built_in_app.access_policy_id} for {app_name}: " + f"{error.__class__.__name__}: {error}" + ) + built_in_app.access_policy = None + result[app_name] = built_in_app + return result + + async def _fetch_integrated_apps_and_policies(self) -> dict: + all_apps, err = await self._paginate( + lambda after: self.client.list_applications(after=after) + ) + if err is not None: + logger.error(f"Error listing integrated apps: {err}") + return {} + + # Per-app try/except: a single app's policy fetch failure must + # not drop the whole inventory. + result: dict[str, OktaBuiltInApp] = {} + for app in all_apps: + try: + app_model = _to_application_model(app) + except Exception as error: + logger.error( + f"Error projecting Okta app onto pydantic model " + f"(id={getattr(app, 'id', '?')}): " + f"{error.__class__.__name__}: {error}" + ) + continue + if app_model.access_policy_id: + try: + app_model.access_policy = await self._fetch_access_policy( + app_model.access_policy_id + ) + except Exception as error: + logger.error( + f"Error fetching access policy " + f"{app_model.access_policy_id} for app " + f"{app_model.name} ({app_model.id}): " + f"{error.__class__.__name__}: {error}" + ) + app_model.access_policy = None + result[app_model.id] = app_model + return result + + async def _fetch_built_in_app(self, app_name: str) -> Optional["OktaBuiltInApp"]: + # Filter by `name eq` so we don't paginate every app in the org + # for a single match. The two OIN-built-in apps are uniquely + # identified by their internal `name`. + apps, err = await self._paginate( + lambda after: self.client.list_applications( + filter=f'name eq "{app_name}"', after=after + ) + ) + if err is not None: + logger.error(f"Error listing app with name={app_name}: {err}") + return None + if not apps: + return None + return _to_application_model(apps[0]) + + async def _fetch_access_policy( + self, policy_id: str + ) -> Optional["AuthenticationPolicy"]: + # Okta's `list_policy_rules` does not accept an `after` cursor in + # the SDK signature, so we call once with a generous limit. Auth + # policies almost always have <10 rules; a warning is logged if + # the limit is hit. + rule_fetch_limit = 100 + try: + result = await self.client.list_policy_rules( + policy_id, limit=str(rule_fetch_limit) + ) + except ValidationError as ve: + # Upstream Okta SDK ↔ Management API enum drift: the SDK's + # strict pydantic validators (e.g. KnowledgeConstraint.types + # uppercase-only) reject values the API returns lowercase + # (e.g. ["password"]). Fall back to a raw-JSON fetch so the + # STIG evaluation isn't blocked by an upstream SDK bug. + logger.warning( + f"Okta SDK raised ValidationError parsing rules for policy " + f"{policy_id} ({ve.error_count()} error(s)) — falling back " + "to raw-JSON parse. This is an okta-sdk-python deserialization " + "bug; the workaround should be removed once upstream fixes it." + ) + return await self._fetch_access_policy_raw(policy_id, rule_fetch_limit) + + err = result[-1] + if err is not None: + logger.error(f"Error listing rules for access policy {policy_id}: {err}") + return AuthenticationPolicy( + id=policy_id, + name="", + status="", + is_default=False, + rules=[], + ) + all_rules = list(result[0] or []) + if len(all_rules) >= rule_fetch_limit: + logger.warning( + f"Access policy {policy_id} returned {len(all_rules)} rules — " + f"the per-policy fetch limit ({rule_fetch_limit}) was hit; any " + "rules beyond this limit are not evaluated by Prowler. Review " + "the policy in the Okta Admin Console." + ) + rules_out = [_rule_to_model(rule) for rule in all_rules] + return AuthenticationPolicy( + id=policy_id, + name="", + status="", + is_default=False, + rules=rules_out, + ) + + async def _fetch_access_policy_raw( + self, policy_id: str, rule_fetch_limit: int + ) -> Optional["AuthenticationPolicy"]: + """Raw-JSON fallback for `list_policy_rules`. + + Bypasses the Okta SDK's typed deserialization by calling the + request executor directly without a response type. The response + body is then `json.loads`-ed and projected onto our own pydantic + snapshot, which only validates the fields the STIG checks + actually read. This keeps the checks evaluable on tenants where + the Management API returns values the SDK validators reject. + """ + request, error = await self.client._request_executor.create_request( + method="GET", + url=f"/api/v1/policies/{policy_id}/rules?limit={rule_fetch_limit}", + body=None, + headers={"Accept": "application/json"}, + ) + if error is not None: + logger.error( + f"Raw rules fetch (create_request) failed for {policy_id}: {error}" + ) + return AuthenticationPolicy( + id=policy_id, name="", status="", is_default=False, rules=[] + ) + + _response, response_body, error = await self.client._request_executor.execute( + request + ) + if error is not None: + logger.error(f"Raw rules fetch (execute) failed for {policy_id}: {error}") + return AuthenticationPolicy( + id=policy_id, name="", status="", is_default=False, rules=[] + ) + + if isinstance(response_body, (bytes, bytearray)): + try: + response_body = response_body.decode("utf-8") + except UnicodeDecodeError as decode_err: + logger.error( + f"Could not decode rules response for {policy_id}: {decode_err}" + ) + return AuthenticationPolicy( + id=policy_id, name="", status="", is_default=False, rules=[] + ) + try: + rules_data = json.loads(response_body) if response_body else [] + except json.JSONDecodeError as decode_err: + logger.error(f"Could not parse rules JSON for {policy_id}: {decode_err}") + return AuthenticationPolicy( + id=policy_id, name="", status="", is_default=False, rules=[] + ) + + if not isinstance(rules_data, list): + logger.error( + f"Unexpected raw rules payload shape for {policy_id}: " + f"got {type(rules_data).__name__}, expected list" + ) + return AuthenticationPolicy( + id=policy_id, name="", status="", is_default=False, rules=[] + ) + + if len(rules_data) >= rule_fetch_limit: + logger.warning( + f"Access policy {policy_id} returned {len(rules_data)} rules " + f"via raw-JSON fallback — the per-policy fetch limit " + f"({rule_fetch_limit}) was hit; any rules beyond this limit " + "are not evaluated by Prowler." + ) + rules_out = [_raw_rule_to_model(rule) for rule in rules_data] + return AuthenticationPolicy( + id=policy_id, name="", status="", is_default=False, rules=rules_out + ) + + @staticmethod + async def _paginate(fetch): + """Drain all pages of an SDK list call. + + `fetch` is a callable taking the `after` cursor (or None) and + returning the SDK's `(items, resp, err)` tuple. Follows the + `Link: rel="next"` header until exhausted. Mirrors the helper in + `signon_service`. + """ + all_items = [] + result = await fetch(None) + err = result[-1] + if err is not None: + return [], err + items = result[0] + resp = result[1] if len(result) >= 3 else None + all_items.extend(items or []) + while True: + cursor = _next_after_cursor(resp) + if not cursor: + break + result = await fetch(cursor) + err = result[-1] + if err is not None: + return all_items, err + items = result[0] + resp = result[1] if len(result) >= 3 else None + all_items.extend(items or []) + return all_items, None + + +def _policy_id_from_href(href: Optional[str]) -> Optional[str]: + """Extract the trailing policy id from `.../policies/{id}` URLs.""" + if not href: + return None + path = urlparse(href).path or href + segment = path.rstrip("/").rsplit("/", 1)[-1] + return segment or None + + +def _rule_to_model(rule) -> "AuthenticationPolicyRule": + """Project an SDK `AccessPolicyRule` onto our pydantic snapshot. + + Pulls out the two STIG-relevant fields from the deeply nested + `actions.appSignOn.verificationMethod` tree: the assurance `factor_mode` + and whether any possession constraint requires phishing resistance. + """ + actions = getattr(rule, "actions", None) + app_sign_on = getattr(actions, "app_sign_on", None) if actions else None + verification_method = ( + getattr(app_sign_on, "verification_method", None) if app_sign_on else None + ) + factor_mode = _stringify_enum(getattr(verification_method, "factor_mode", None)) + verification_type = _stringify_enum(getattr(verification_method, "type", None)) + constraints = list(getattr(verification_method, "constraints", None) or []) + phishing_resistant_required = False + for constraint in constraints: + possession = getattr(constraint, "possession", None) + if possession is None: + continue + if ( + _stringify_enum(getattr(possession, "phishing_resistant", None)) + == "REQUIRED" + ): + phishing_resistant_required = True + break + + access_action = getattr(app_sign_on, "access", None) if app_sign_on else None + conditions = getattr(rule, "conditions", None) + network = getattr(conditions, "network", None) if conditions else None + return AuthenticationPolicyRule( + id=getattr(rule, "id", "") or "", + name=getattr(rule, "name", "") or "", + priority=getattr(rule, "priority", None), + status=getattr(rule, "status", "") or "", + is_default=bool(getattr(rule, "system", False)), + factor_mode=factor_mode, + possession_phishing_resistant_required=phishing_resistant_required, + constraints_count=len(constraints), + verification_method_type=verification_type, + access=_stringify_enum(access_action), + network_connection=_stringify_enum(getattr(network, "connection", None)), + network_zones_include=list(getattr(network, "include", None) or []), + network_zones_exclude=list(getattr(network, "exclude", None) or []), + ) + + +def _stringify_enum(value) -> Optional[str]: + """Return the string form of an enum-or-string value, or None.""" + if value is None: + return None + return getattr(value, "value", None) or str(value) + + +def _raw_rule_to_model(rule_dict: dict) -> "AuthenticationPolicyRule": + """Project a raw `/api/v1/policies/{id}/rules` JSON rule onto our model. + + Mirrors `_rule_to_model` but reads camelCase JSON keys (`appSignOn`, + `verificationMethod`, `phishingResistant`) instead of the SDK's + snake_case attribute names. Used by the raw-JSON fallback that + activates when the Okta SDK's strict enum validators reject values + the Management API returns. + """ + actions = rule_dict.get("actions") or {} + app_sign_on = actions.get("appSignOn") or {} + verification_method = app_sign_on.get("verificationMethod") or {} + factor_mode = verification_method.get("factorMode") + verification_type = verification_method.get("type") + constraints = verification_method.get("constraints") or [] + phishing_resistant_required = False + for constraint in constraints: + possession = (constraint or {}).get("possession") or {} + if possession.get("phishingResistant") == "REQUIRED": + phishing_resistant_required = True + break + + access_action = app_sign_on.get("access") + conditions = rule_dict.get("conditions") or {} + network = conditions.get("network") or {} + return AuthenticationPolicyRule( + id=rule_dict.get("id") or "", + name=rule_dict.get("name") or "", + priority=rule_dict.get("priority"), + status=rule_dict.get("status") or "", + is_default=bool(rule_dict.get("system", False)), + factor_mode=factor_mode, + possession_phishing_resistant_required=phishing_resistant_required, + constraints_count=len(constraints), + verification_method_type=verification_type, + access=access_action, + network_connection=network.get("connection"), + network_zones_include=list(network.get("include") or []), + network_zones_exclude=list(network.get("exclude") or []), + ) + + +class AdminConsoleAppSettings(BaseModel): + """First-party Okta Admin Console session settings. + + `id` and `name` are set to fixed sentinels so this can be passed as + the `resource` to `CheckReportOkta`, which reads those attributes. + """ + + id: str = "okta-admin-console-app-settings" + name: str = "Okta Admin Console (first-party app settings)" + session_idle_timeout_minutes: Optional[int] = None + session_max_lifetime_minutes: Optional[int] = None + + +class AuthenticationPolicyRule(BaseModel): + id: str + name: str + priority: Optional[int] = None + status: str = "" + is_default: bool = False + factor_mode: Optional[str] = None + possession_phishing_resistant_required: bool = False + constraints_count: int = 0 + verification_method_type: Optional[str] = None + access: Optional[str] = None + network_connection: Optional[str] = None + network_zones_include: list[str] = [] + network_zones_exclude: list[str] = [] + + +class AuthenticationPolicy(BaseModel): + id: str + name: str = "" + status: str = "" + is_default: bool = False + rules: list[AuthenticationPolicyRule] = [] + + +class OktaBuiltInApp(BaseModel): + # `id` matches the Okta-generated `0oa…` app identifier; `name` is the + # canonical internal name (`saasure`, `okta_enduser`). Both are read + # directly by `CheckReportOkta(resource=…)`. + id: str + name: str + label: str = "" + status: str = "" + access_policy_id: Optional[str] = None + access_policy: Optional[AuthenticationPolicy] = None + + +def _application_access_policy_id(app) -> Optional[str]: + links = getattr(app, "links", None) + access_policy_link = getattr(links, "access_policy", None) if links else None + return _policy_id_from_href( + getattr(access_policy_link, "href", None) if access_policy_link else None + ) + + +def _to_application_model(app) -> OktaBuiltInApp: + return OktaBuiltInApp( + id=getattr(app, "id", "") or "", + name=getattr(app, "name", "") or "", + label=getattr(app, "label", "") or "", + status=getattr(app, "status", "") or "", + access_policy_id=_application_access_policy_id(app), + ) diff --git a/prowler/providers/okta/services/application/lib/__init__.py b/prowler/providers/okta/services/application/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/application/lib/application_helpers.py b/prowler/providers/okta/services/application/lib/application_helpers.py new file mode 100644 index 0000000000..90cccc98ec --- /dev/null +++ b/prowler/providers/okta/services/application/lib/application_helpers.py @@ -0,0 +1,212 @@ +"""Shared helpers for the OKTA application STIG checks.""" + +from typing import Optional + +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.application.application_service import ( + AdminConsoleAppSettings, + AuthenticationPolicyRule, + OktaBuiltInApp, +) + + +def active_apps(apps: dict[str, OktaBuiltInApp]) -> list[OktaBuiltInApp]: + """Return active apps sorted by label/name, id as tiebreaker.""" + return sorted( + [ + app + for app in apps.values() + if not app.status or app.status.upper() == "ACTIVE" + ], + key=lambda app: (app.label or app.name, app.id), + ) + + +def top_active_rule( + app: OktaBuiltInApp, +) -> Optional[AuthenticationPolicyRule]: + """Return the topmost active rule on the app's Authentication Policy. + + Mirrors the STIG fix text — *"Click the 'Actions' button next to the + top rule and select 'Edit'"* — by returning the active rule with the + lowest `priority` value (= highest precedence). Downstream checks + separately reject the rule when it is the built-in Catch-all Rule. + + The priority value itself is intentionally not pinned to a specific + integer. Okta indexes Access Policy rule priorities inconsistently + across tenants and policy types (some responses report `0` for the + top rule, others `1`); the STIG only requires that the topmost rule + satisfy the predicate, not that it carry any specific priority literal. + """ + if app.access_policy is None: + return None + active_rules = sorted( + [ + rule + for rule in app.access_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 + return active_rules[0] + + +def app_label(app: OktaBuiltInApp) -> str: + """Format a human-readable label for an Okta application.""" + label = app.label or app.name + return f"Okta app '{label}' (app={app.name}, id={app.id})" + + +def rule_label(rule: AuthenticationPolicyRule) -> str: + """Format whether a rule is the built-in catch-all or a custom rule.""" + if rule.is_default or rule.name == "Catch-all Rule": + return f"built-in Catch-all Rule '{rule.name}'" + return f"non-default rule '{rule.name}'" + + +def rule_has_network_zone(rule: AuthenticationPolicyRule) -> bool: + """Return True when the rule maps User's IP to at least one Network Zone.""" + return bool(rule.network_zones_include or rule.network_zones_exclude) + + +_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_app_scope_finding( + metadata, org_domain: str, scope: str, app_label_hint: str +) -> CheckReportOkta: + """Build the MANUAL finding when an app/policy scope is not granted.""" + placeholder = OktaBuiltInApp( + id="okta-built-in-app-scope-missing", + name="(scope not granted)", + label=app_label_hint, + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not evaluate the authentication policy for {app_label_hint}: " + f"the Okta service app is missing the required `{scope}` API scope. " + f"{_SCOPE_ADVICE}" + ) + return report + + +def missing_integrated_apps_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding when the integrated-app inventory scope is not granted.""" + placeholder = OktaBuiltInApp( + id="okta-integrated-apps-scope-missing", + name="(scope not granted)", + label="Okta integrated applications", + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, + resource=placeholder, + org_domain=org_domain, + resource_name=placeholder.label, + resource_id=placeholder.id, + ) + report.status = "MANUAL" + report.status_extended = ( + "Could not retrieve Okta integrated applications and their " + f"authentication policies: the Okta service app is missing the " + f"required `{scope}` API scope. {_SCOPE_ADVICE}" + ) + return report + + +def missing_admin_console_settings_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding for the Admin Console idle timeout check when scope is missing.""" + placeholder = AdminConsoleAppSettings() + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + "Could not retrieve the Okta Admin Console first-party app settings: " + f"the Okta service app is missing the required `{scope}` API scope. " + f"{_SCOPE_ADVICE}" + ) + return report + + +def app_not_found_finding( + metadata, org_domain: str, app_label_hint: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when a built-in OIN app isn't returned. + + Okta filters the first-party apps (`saasure`, `okta_enduser`) out of + `/api/v1/apps` for every admin role below Super Administrator, so the + check has no way to resolve the app's bound Authentication Policy. + """ + placeholder = OktaBuiltInApp( + id="okta-built-in-app-missing", + name="(app not found)", + label=app_label_hint, + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"The {app_label_hint} first-party app was not returned by the Okta " + "API. Okta restricts the visibility of first-party apps " + "(`saasure`, `okta_enduser`) to the Super Administrator role; " + "every other role — including Read-Only Administrator — receives " + "an empty result. Assign Super Administrator to the service app " + "to evaluate this check." + ) + return report + + +def no_active_apps_finding(metadata, org_domain: str) -> CheckReportOkta: + """Build the MANUAL finding emitted when no active apps are returned.""" + placeholder = OktaBuiltInApp( + id="okta-apps-missing", + name="(no active apps)", + label="Okta applications", + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, + resource=placeholder, + org_domain=org_domain, + resource_name=placeholder.label, + resource_id=placeholder.id, + ) + report.status = "MANUAL" + report.status_extended = ( + "No active Okta applications were returned by the API. Verify the " + "tenant exposes applications to the Read-Only Administrator role and " + "review the application inventory manually." + ) + return report + + +def policy_missing_finding( + metadata, org_domain: str, app: OktaBuiltInApp +) -> CheckReportOkta: + """Build the FAIL finding when the built-in app has no bound Access Policy.""" + report = CheckReportOkta(metadata=metadata, resource=app, org_domain=org_domain) + report.status = "FAIL" + report.status_extended = ( + f"{app_label(app)} has no Authentication Policy bound to it. " + "Bind an Access Policy in Security > Authentication Policies." + ) + return report diff --git a/tests/config/fixtures/config.yaml b/tests/config/fixtures/config.yaml index 7305ed550b..39cba5f27d 100644 --- a/tests/config/fixtures/config.yaml +++ b/tests/config/fixtures/config.yaml @@ -493,3 +493,6 @@ okta: # Okta Sign-On Policies # okta.signon_global_session_idle_timeout_15min okta_max_session_idle_minutes: 15 + # Okta Applications + # okta.application_admin_console_session_idle_timeout_15min + okta_admin_console_idle_timeout_max_minutes: 15 diff --git a/tests/providers/okta/okta_fixtures.py b/tests/providers/okta/okta_fixtures.py index 2b7ca6927c..23d770cf88 100644 --- a/tests/providers/okta/okta_fixtures.py +++ b/tests/providers/okta/okta_fixtures.py @@ -16,14 +16,18 @@ def set_mocked_okta_provider( session = OktaSession( org_domain=OKTA_ORG_DOMAIN, client_id=OKTA_CLIENT_ID, - scopes=["okta.policies.read", "okta.brands.read"], + scopes=["okta.policies.read", "okta.brands.read", "okta.apps.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"], + granted_scopes=[ + "okta.policies.read", + "okta.brands.read", + "okta.apps.read", + ], ) provider = MagicMock() diff --git a/tests/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required_test.py b/tests/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required_test.py new file mode 100644 index 0000000000..e968c991a3 --- /dev/null +++ b/tests/providers/okta/services/application/application_admin_console_mfa_required/application_admin_console_mfa_required_test.py @@ -0,0 +1,149 @@ +from unittest import mock + +from prowler.providers.okta.services.application.application_service import ( + ADMIN_CONSOLE_APP_NAME, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.application.application_fixtures import ( + admin_console_app, + auth_policy_rule, + build_application_client, + catch_all_rule, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.application." + "application_admin_console_mfa_required." + "application_admin_console_mfa_required.application_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.application.application_admin_console_mfa_required.application_admin_console_mfa_required import ( + application_admin_console_mfa_required, + ) + + return application_admin_console_mfa_required().execute() + + +class Test_application_admin_console_mfa_required: + def test_pass_when_top_rule_enforces_2fa(self): + app = admin_console_app( + rules=[ + auth_policy_rule(name="MFA Required", priority=1, factor_mode="2FA"), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "MFA Required" in findings[0].status_extended + assert "factorMode=2FA" in findings[0].status_extended + + def test_fail_when_top_rule_is_1fa(self): + app = admin_console_app( + rules=[ + auth_policy_rule(name="Password Only", priority=1, factor_mode="1FA"), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "Password Only" in findings[0].status_extended + assert "factorMode=1FA" in findings[0].status_extended + + def test_pass_when_top_rule_is_default_and_enforces_2fa(self): + app = admin_console_app(rules=[catch_all_rule(priority=1, factor_mode="2FA")]) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Catch-all Rule" in findings[0].status_extended + assert "built-in Catch-all Rule" in findings[0].status_extended + + def test_pass_when_top_active_rule_is_not_priority_one(self): + # Top active rule is whichever has the lowest priority value; the + # check does not pin to `priority == 1` specifically because Okta + # indexes Access Policy rule priorities inconsistently. Here the + # only non-default rule sits at priority=2 and is still the top. + app = admin_console_app( + rules=[ + auth_policy_rule(name="MFA Required", priority=2, factor_mode="2FA"), + catch_all_rule(priority=3), + ] + ) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "MFA Required" in findings[0].status_extended + assert "factorMode=2FA" in findings[0].status_extended + + def test_fail_when_no_access_policy_bound(self): + app = admin_console_app(rules=[], access_policy_id=None) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "no Authentication Policy bound" in findings[0].status_extended + + def test_manual_when_app_not_returned(self): + client = build_application_client(built_in_apps={}) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "not returned by the Okta API" in findings[0].status_extended + + def test_manual_when_apps_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": "okta.apps.read", + "integrated_apps": None, + "access_policies": None, + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.apps.read" in findings[0].status_extended + + def test_manual_when_policies_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": None, + "integrated_apps": None, + "access_policies": "okta.policies.read", + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + + def test_inactive_rule_skipped(self): + # An inactive custom rule must be skipped; the active Catch-all + # then becomes the top rule. The check evaluates the catch-all + # directly (no `factor_mode` set on it in the fixture) and FAILs. + app = admin_console_app( + rules=[ + auth_policy_rule( + name="MFA Required", + priority=1, + factor_mode="2FA", + status="INACTIVE", + ), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "Catch-all Rule" in findings[0].status_extended + assert "does not enforce multifactor authentication" in ( + findings[0].status_extended + ) diff --git a/tests/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication_test.py b/tests/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication_test.py new file mode 100644 index 0000000000..9ceac0697d --- /dev/null +++ b/tests/providers/okta/services/application/application_admin_console_phishing_resistant_authentication/application_admin_console_phishing_resistant_authentication_test.py @@ -0,0 +1,129 @@ +from unittest import mock + +from prowler.providers.okta.services.application.application_service import ( + ADMIN_CONSOLE_APP_NAME, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.application.application_fixtures import ( + admin_console_app, + auth_policy_rule, + build_application_client, + catch_all_rule, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.application." + "application_admin_console_phishing_resistant_authentication." + "application_admin_console_phishing_resistant_authentication.application_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.application.application_admin_console_phishing_resistant_authentication.application_admin_console_phishing_resistant_authentication import ( + application_admin_console_phishing_resistant_authentication, + ) + + return application_admin_console_phishing_resistant_authentication().execute() + + +class Test_application_admin_console_phishing_resistant_authentication: + def test_pass_when_top_rule_requires_phishing_resistant(self): + app = admin_console_app( + rules=[ + auth_policy_rule( + name="Phishing Resistant", priority=1, phishing_resistant=True + ), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Phishing Resistant" in findings[0].status_extended + + def test_fail_when_top_rule_does_not_require(self): + app = admin_console_app( + rules=[ + auth_policy_rule( + name="Loose Rule", priority=1, phishing_resistant=False + ), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "does not enforce phishing-resistant" in findings[0].status_extended + + def test_pass_when_top_rule_is_default_and_requires_phishing_resistant(self): + app = admin_console_app( + rules=[catch_all_rule(priority=1, phishing_resistant=True)] + ) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Catch-all Rule" in findings[0].status_extended + assert "built-in Catch-all Rule" in findings[0].status_extended + + def test_pass_when_top_active_rule_is_not_priority_one(self): + # The check does not pin to `priority == 1` — Okta indexes Access + # Policy rule priorities inconsistently. The top active rule is + # whichever has the lowest priority value among active rules. + app = admin_console_app( + rules=[ + auth_policy_rule( + name="Phishing Resistant", priority=2, phishing_resistant=True + ), + catch_all_rule(priority=3), + ] + ) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Phishing Resistant" in findings[0].status_extended + + def test_fail_when_no_access_policy_bound(self): + app = admin_console_app(rules=[], access_policy_id=None) + client = build_application_client(built_in_apps={ADMIN_CONSOLE_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "no Authentication Policy bound" in findings[0].status_extended + + def test_manual_when_app_not_returned(self): + client = build_application_client(built_in_apps={}) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "not returned by the Okta API" in findings[0].status_extended + + def test_manual_when_apps_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": "okta.apps.read", + "integrated_apps": None, + "access_policies": None, + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.apps.read" in findings[0].status_extended + + def test_manual_when_policies_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": None, + "integrated_apps": None, + "access_policies": "okta.policies.read", + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended diff --git a/tests/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min_test.py b/tests/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min_test.py new file mode 100644 index 0000000000..08943cd690 --- /dev/null +++ b/tests/providers/okta/services/application/application_admin_console_session_idle_timeout_15min/application_admin_console_session_idle_timeout_15min_test.py @@ -0,0 +1,90 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.application.application_fixtures import ( + admin_console_settings, + build_application_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.application." + "application_admin_console_session_idle_timeout_15min." + "application_admin_console_session_idle_timeout_15min.application_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.application.application_admin_console_session_idle_timeout_15min.application_admin_console_session_idle_timeout_15min import ( + application_admin_console_session_idle_timeout_15min, + ) + + return application_admin_console_session_idle_timeout_15min().execute() + + +class Test_application_admin_console_session_idle_timeout_15min: + def test_pass_at_threshold(self): + client = build_application_client( + admin_console_settings=admin_console_settings(idle_timeout=15) + ) + findings = _run_check(client) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "15 minutes" in findings[0].status_extended + + def test_pass_below_threshold(self): + client = build_application_client( + admin_console_settings=admin_console_settings(idle_timeout=10) + ) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "10 minutes" in findings[0].status_extended + + def test_fail_above_threshold(self): + client = build_application_client( + admin_console_settings=admin_console_settings(idle_timeout=60) + ) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "exceeding the configured threshold" in findings[0].status_extended + + def test_fail_when_idle_timeout_missing(self): + client = build_application_client( + admin_console_settings=admin_console_settings(idle_timeout=None) + ) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "does not define" in findings[0].status_extended + + def test_manual_when_settings_unavailable(self): + client = build_application_client(admin_console_settings=None) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "Could not retrieve" in findings[0].status_extended + + def test_manual_when_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": "okta.apps.read", + "built_in_apps": None, + "access_policies": None, + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.apps.read" in findings[0].status_extended + + def test_threshold_overridden_via_audit_config(self): + client = build_application_client( + admin_console_settings=admin_console_settings(idle_timeout=30), + audit_config={"okta_admin_console_idle_timeout_max_minutes": 60}, + ) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "threshold of 60 minutes" in findings[0].status_extended diff --git a/tests/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced_test.py b/tests/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced_test.py new file mode 100644 index 0000000000..92e75a6e17 --- /dev/null +++ b/tests/providers/okta/services/application/application_authentication_policy_network_zone_enforced/application_authentication_policy_network_zone_enforced_test.py @@ -0,0 +1,192 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.application.application_fixtures import ( + auth_policy_rule, + build_application_client, + catch_all_rule, + integrated_app, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.application." + "application_authentication_policy_network_zone_enforced." + "application_authentication_policy_network_zone_enforced.application_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.application.application_authentication_policy_network_zone_enforced.application_authentication_policy_network_zone_enforced import ( + application_authentication_policy_network_zone_enforced, + ) + + return application_authentication_policy_network_zone_enforced().execute() + + +class Test_application_authentication_policy_network_zone_enforced: + def test_pass_when_all_active_nondefault_rules_are_zoned_and_catch_all_denies(self): + app = integrated_app( + "0oa-google", + "google_workspace", + label="Google Workspace", + rules=[ + auth_policy_rule( + name="Allow Corp", + priority=1, + network_connection="ZONE", + network_zones_include=["zone-corp"], + ), + auth_policy_rule( + name="Block Risky", + priority=2, + network_connection="ZONE", + network_zones_exclude=["zone-risky"], + ), + catch_all_rule(priority=3, access="DENY"), + ], + ) + findings = _run_check(build_application_client(integrated_apps={app.id: app})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Google Workspace" in findings[0].status_extended + assert "Catch-all Rule" in findings[0].status_extended + + def test_fail_when_nondefault_rule_has_no_network_zone(self): + app = integrated_app( + "0oa-salesforce", + "salesforce", + label="Salesforce", + rules=[ + auth_policy_rule(name="Allow Users", priority=1), + catch_all_rule(priority=2, access="DENY"), + ], + ) + findings = _run_check(build_application_client(integrated_apps={app.id: app})) + assert findings[0].status == "FAIL" + assert "without Network Zones" in findings[0].status_extended + assert "Allow Users" in findings[0].status_extended + + def test_fail_when_catch_all_rule_does_not_deny(self): + app = integrated_app( + "0oa-box", + "box", + label="Box", + rules=[ + auth_policy_rule( + name="Allow Corp", + priority=1, + network_connection="ZONE", + network_zones_include=["zone-corp"], + ), + catch_all_rule(priority=2, access="ALLOW"), + ], + ) + findings = _run_check(build_application_client(integrated_apps={app.id: app})) + assert findings[0].status == "FAIL" + assert "`Access is` to `DENY`" in findings[0].status_extended + + def test_fail_when_no_active_nondefault_rules_exist(self): + app = integrated_app( + "0oa-slack", + "slack", + label="Slack", + rules=[catch_all_rule(priority=1, access="DENY")], + ) + findings = _run_check(build_application_client(integrated_apps={app.id: app})) + assert findings[0].status == "FAIL" + assert "no active non-default rules" in findings[0].status_extended + + def test_fail_when_no_access_policy_is_bound(self): + app = integrated_app( + "0oa-zoom", + "zoom", + label="Zoom", + rules=[], + access_policy_id=None, + ) + findings = _run_check(build_application_client(integrated_apps={app.id: app})) + assert findings[0].status == "FAIL" + assert "no Authentication Policy bound" in findings[0].status_extended + + def test_inactive_apps_are_skipped(self): + inactive = integrated_app( + "0oa-inactive", + "dropbox", + label="Dropbox", + status="INACTIVE", + rules=[ + auth_policy_rule( + name="Allow Corp", + priority=1, + network_connection="ZONE", + network_zones_include=["zone-corp"], + ), + catch_all_rule(priority=2, access="DENY"), + ], + ) + active = integrated_app( + "0oa-active", + "github", + label="GitHub", + rules=[ + auth_policy_rule( + name="Allow Corp", + priority=1, + network_connection="ZONE", + network_zones_include=["zone-corp"], + ), + catch_all_rule(priority=2, access="DENY"), + ], + ) + findings = _run_check( + build_application_client( + integrated_apps={inactive.id: inactive, active.id: active} + ) + ) + assert len(findings) == 1 + assert findings[0].resource_name == "GitHub" + assert findings[0].status == "PASS" + + def test_manual_when_apps_scope_missing(self): + findings = _run_check( + build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": None, + "integrated_apps": "okta.apps.read", + "access_policies": None, + } + ) + ) + assert findings[0].status == "MANUAL" + assert "okta.apps.read" in findings[0].status_extended + assert findings[0].resource_name == "Okta integrated applications" + assert findings[0].resource_id == "okta-integrated-apps-scope-missing" + + def test_manual_when_policy_scope_missing(self): + findings = _run_check( + build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": None, + "integrated_apps": None, + "access_policies": "okta.policies.read", + } + ) + ) + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + assert findings[0].resource_name == "Okta integrated applications" + assert findings[0].resource_id == "okta-integrated-apps-scope-missing" + + def test_manual_when_no_active_apps_returned(self): + findings = _run_check(build_application_client(integrated_apps={})) + assert findings[0].status == "MANUAL" + assert "No active Okta applications" in findings[0].status_extended diff --git a/tests/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required_test.py b/tests/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required_test.py new file mode 100644 index 0000000000..2a07c82ea8 --- /dev/null +++ b/tests/providers/okta/services/application/application_dashboard_mfa_required/application_dashboard_mfa_required_test.py @@ -0,0 +1,106 @@ +from unittest import mock + +from prowler.providers.okta.services.application.application_service import ( + DASHBOARD_APP_NAME, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.application.application_fixtures import ( + auth_policy_rule, + build_application_client, + catch_all_rule, + dashboard_app, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.application." + "application_dashboard_mfa_required." + "application_dashboard_mfa_required.application_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.application.application_dashboard_mfa_required.application_dashboard_mfa_required import ( + application_dashboard_mfa_required, + ) + + return application_dashboard_mfa_required().execute() + + +class Test_application_dashboard_mfa_required: + def test_pass_when_top_rule_enforces_2fa(self): + app = dashboard_app( + rules=[ + auth_policy_rule(name="MFA Required", priority=1, factor_mode="2FA"), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={DASHBOARD_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "MFA Required" in findings[0].status_extended + + def test_fail_when_top_rule_is_1fa(self): + app = dashboard_app( + rules=[ + auth_policy_rule(name="Password Only", priority=1, factor_mode="1FA"), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={DASHBOARD_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "Password Only" in findings[0].status_extended + + def test_pass_when_top_rule_is_default_and_enforces_2fa(self): + app = dashboard_app(rules=[catch_all_rule(priority=1, factor_mode="2FA")]) + client = build_application_client(built_in_apps={DASHBOARD_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Catch-all Rule" in findings[0].status_extended + assert "built-in Catch-all Rule" in findings[0].status_extended + + def test_fail_when_no_access_policy_bound(self): + app = dashboard_app(rules=[], access_policy_id=None) + client = build_application_client(built_in_apps={DASHBOARD_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "no Authentication Policy bound" in findings[0].status_extended + + def test_manual_when_app_not_returned(self): + client = build_application_client(built_in_apps={}) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "not returned by the Okta API" in findings[0].status_extended + + def test_manual_when_apps_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": "okta.apps.read", + "integrated_apps": None, + "access_policies": None, + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.apps.read" in findings[0].status_extended + + def test_manual_when_policies_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": None, + "integrated_apps": None, + "access_policies": "okta.policies.read", + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended diff --git a/tests/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication_test.py b/tests/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication_test.py new file mode 100644 index 0000000000..10b6a977e6 --- /dev/null +++ b/tests/providers/okta/services/application/application_dashboard_phishing_resistant_authentication/application_dashboard_phishing_resistant_authentication_test.py @@ -0,0 +1,110 @@ +from unittest import mock + +from prowler.providers.okta.services.application.application_service import ( + DASHBOARD_APP_NAME, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.application.application_fixtures import ( + auth_policy_rule, + build_application_client, + catch_all_rule, + dashboard_app, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.application." + "application_dashboard_phishing_resistant_authentication." + "application_dashboard_phishing_resistant_authentication.application_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.application.application_dashboard_phishing_resistant_authentication.application_dashboard_phishing_resistant_authentication import ( + application_dashboard_phishing_resistant_authentication, + ) + + return application_dashboard_phishing_resistant_authentication().execute() + + +class Test_application_dashboard_phishing_resistant_authentication: + def test_pass_when_top_rule_requires_phishing_resistant(self): + app = dashboard_app( + rules=[ + auth_policy_rule( + name="Phishing Resistant", priority=1, phishing_resistant=True + ), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={DASHBOARD_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Phishing Resistant" in findings[0].status_extended + + def test_fail_when_top_rule_does_not_require(self): + app = dashboard_app( + rules=[ + auth_policy_rule( + name="Loose Rule", priority=1, phishing_resistant=False + ), + catch_all_rule(priority=2), + ] + ) + client = build_application_client(built_in_apps={DASHBOARD_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "does not enforce phishing-resistant" in findings[0].status_extended + + def test_pass_when_top_rule_is_default_and_requires_phishing_resistant(self): + app = dashboard_app(rules=[catch_all_rule(priority=1, phishing_resistant=True)]) + client = build_application_client(built_in_apps={DASHBOARD_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Catch-all Rule" in findings[0].status_extended + assert "built-in Catch-all Rule" in findings[0].status_extended + + def test_fail_when_no_access_policy_bound(self): + app = dashboard_app(rules=[], access_policy_id=None) + client = build_application_client(built_in_apps={DASHBOARD_APP_NAME: app}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "no Authentication Policy bound" in findings[0].status_extended + + def test_manual_when_app_not_returned(self): + client = build_application_client(built_in_apps={}) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "not returned by the Okta API" in findings[0].status_extended + + def test_manual_when_apps_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": "okta.apps.read", + "integrated_apps": None, + "access_policies": None, + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.apps.read" in findings[0].status_extended + + def test_manual_when_policies_scope_missing(self): + client = build_application_client( + missing_scope={ + "admin_console_app_settings": None, + "built_in_apps": None, + "integrated_apps": None, + "access_policies": "okta.policies.read", + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended diff --git a/tests/providers/okta/services/application/application_fixtures.py b/tests/providers/okta/services/application/application_fixtures.py new file mode 100644 index 0000000000..4c157a1255 --- /dev/null +++ b/tests/providers/okta/services/application/application_fixtures.py @@ -0,0 +1,175 @@ +"""Shared helpers for `application` service check tests. + +Mirrors `signon_fixtures.py`. The four authentication-policy checks +(MFA + phishing-resistant for Okta Admin Console and Okta Dashboard) +and the Admin Console idle-timeout check all consume the same client +shape, so the helpers stay close to the signon equivalents. +""" + +from unittest import mock + +from prowler.providers.okta.services.application.application_service import ( + ADMIN_CONSOLE_APP_NAME, + DASHBOARD_APP_NAME, + AdminConsoleAppSettings, + AuthenticationPolicy, + AuthenticationPolicyRule, + OktaBuiltInApp, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_application_client( + admin_console_settings: AdminConsoleAppSettings = None, + built_in_apps: dict = None, + integrated_apps: dict = None, + audit_config: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.admin_console_app_settings = admin_console_settings + client.built_in_apps = built_in_apps or {} + client.integrated_apps = integrated_apps or {} + client.provider = set_mocked_okta_provider() + client.audit_config = audit_config or {} + # Default to "all scopes granted" so existing tests keep working. + client.missing_scope = missing_scope or { + "admin_console_app_settings": None, + "built_in_apps": None, + "integrated_apps": None, + "access_policies": None, + } + return client + + +def admin_console_settings( + idle_timeout: int = None, + max_lifetime: int = None, +) -> AdminConsoleAppSettings: + return AdminConsoleAppSettings( + session_idle_timeout_minutes=idle_timeout, + session_max_lifetime_minutes=max_lifetime, + ) + + +def auth_policy_rule( + name: str = "Catch-all Rule", + *, + priority: int = 1, + status: str = "ACTIVE", + is_default: bool = False, + factor_mode: str = None, + phishing_resistant: bool = False, + constraints_count: int = 0, + verification_method_type: str = "ASSURANCE", + access: str = "ALLOW", + network_connection: str = None, + network_zones_include: list[str] = None, + network_zones_exclude: list[str] = None, +): + return AuthenticationPolicyRule( + id=f"rule-{name.lower().replace(' ', '-')}", + name=name, + priority=priority, + status=status, + is_default=is_default, + factor_mode=factor_mode, + possession_phishing_resistant_required=phishing_resistant, + constraints_count=constraints_count, + verification_method_type=verification_method_type, + access=access, + network_connection=network_connection, + network_zones_include=network_zones_include or [], + network_zones_exclude=network_zones_exclude or [], + ) + + +def catch_all_rule( + priority: int = 2, + *, + factor_mode: str = None, + phishing_resistant: bool = False, + access: str = "ALLOW", + network_connection: str = None, + network_zones_include: list[str] = None, + network_zones_exclude: list[str] = None, +): + return auth_policy_rule( + name="Catch-all Rule", + priority=priority, + is_default=True, + factor_mode=factor_mode, + phishing_resistant=phishing_resistant, + access=access, + network_connection=network_connection, + network_zones_include=network_zones_include, + network_zones_exclude=network_zones_exclude, + ) + + +def admin_console_app( + rules: list = None, + *, + access_policy_id: str = "rstadminconsole", + label: str = "Okta Admin Console", + status: str = "ACTIVE", +): + policy = ( + AuthenticationPolicy(id=access_policy_id, rules=rules or []) + if access_policy_id is not None + else None + ) + return OktaBuiltInApp( + id="0oaadminconsole", + name=ADMIN_CONSOLE_APP_NAME, + label=label, + status=status, + access_policy_id=access_policy_id, + access_policy=policy, + ) + + +def dashboard_app( + rules: list = None, + *, + access_policy_id: str = "rstdashboard", + label: str = "Okta Dashboard", + status: str = "ACTIVE", +): + policy = ( + AuthenticationPolicy(id=access_policy_id, rules=rules or []) + if access_policy_id is not None + else None + ) + return OktaBuiltInApp( + id="0oadashboard", + name=DASHBOARD_APP_NAME, + label=label, + status=status, + access_policy_id=access_policy_id, + access_policy=policy, + ) + + +def integrated_app( + app_id: str, + name: str, + *, + rules: list = None, + access_policy_id: str = "rstapp", + label: str = "", + status: str = "ACTIVE", +): + policy = ( + AuthenticationPolicy(id=access_policy_id, rules=rules or []) + if access_policy_id is not None + else None + ) + return OktaBuiltInApp( + id=app_id, + name=name, + label=label or name, + status=status, + access_policy_id=access_policy_id, + access_policy=policy, + ) diff --git a/tests/providers/okta/services/application/application_service_test.py b/tests/providers/okta/services/application/application_service_test.py new file mode 100644 index 0000000000..042882f285 --- /dev/null +++ b/tests/providers/okta/services/application/application_service_test.py @@ -0,0 +1,750 @@ +import json +from unittest import mock + +from prowler.providers.okta.models import OktaIdentityInfo +from prowler.providers.okta.services.application.application_service import ( + Application, + AuthenticationPolicy, + OktaBuiltInApp, + _policy_id_from_href, +) +from tests.providers.okta.okta_fixtures import ( + OKTA_CLIENT_ID, + OKTA_ORG_DOMAIN, + set_mocked_okta_provider, +) + + +def _resp(headers: dict = None): + r = mock.MagicMock() + r.headers = headers or {} + return r + + +def _fake_app( + app_id: str, + name: str, + *, + access_policy_href: str = None, + label: str = "", + status: str = "ACTIVE", +): + a = mock.MagicMock() + a.id = app_id + a.name = name + a.label = label + a.status = status + if access_policy_href is None: + a.links = None + else: + a.links.access_policy.href = access_policy_href + return a + + +def _fake_rule( + *, + rule_id: str = "rule-1", + name: str = "Catch-all Rule", + priority: int = 1, + status: str = "ACTIVE", + system: bool = False, + factor_mode: str = None, + phishing_resistant: str = None, + access: str = "ALLOW", + network_connection: str = None, + network_include: list[str] = None, + network_exclude: list[str] = None, +): + r = mock.MagicMock() + r.id = rule_id + r.name = name + r.priority = priority + r.status = status + r.system = system + r.actions.app_sign_on.verification_method.factor_mode = factor_mode + r.actions.app_sign_on.verification_method.type = "ASSURANCE" + r.actions.app_sign_on.access = access + r.conditions.network.connection = network_connection + r.conditions.network.include = network_include or [] + r.conditions.network.exclude = network_exclude or [] + if phishing_resistant is None: + r.actions.app_sign_on.verification_method.constraints = [] + else: + constraint = mock.MagicMock() + constraint.possession.phishing_resistant = phishing_resistant + r.actions.app_sign_on.verification_method.constraints = [constraint] + return r + + +def _fake_admin_console_settings(idle: int = 15, lifetime: int = 720): + s = mock.MagicMock() + s.session_idle_timeout_minutes = idle + s.session_max_lifetime_minutes = lifetime + return s + + +class Test_policy_id_from_href: + def test_returns_trailing_segment(self): + href = "https://acme.okta.com/api/v1/policies/rst123" + assert _policy_id_from_href(href) == "rst123" + + def test_strips_trailing_slash(self): + assert ( + _policy_id_from_href("https://acme.okta.com/api/v1/policies/rst123/") + == "rst123" + ) + + def test_handles_relative_path(self): + assert _policy_id_from_href("/api/v1/policies/rst-abc") == "rst-abc" + + def test_none_returns_none(self): + assert _policy_id_from_href(None) is None + + def test_empty_returns_none(self): + assert _policy_id_from_href("") is None + + +def _patch_sdk(**methods): + """Returns a context manager that patches OktaSDKClient with the given async methods.""" + return mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=mock.MagicMock(**methods), + ) + + +class Test_Application_service: + def test_fetches_admin_console_settings_and_built_in_apps(self): + provider = set_mocked_okta_provider() + + admin_console_app = _fake_app( + "0oaadminconsole", + "saasure", + access_policy_href="https://acme.okta.com/api/v1/policies/rstadminconsole", + label="Okta Admin Console", + ) + dashboard_app = _fake_app( + "0oadashboard", + "okta_enduser", + access_policy_href="https://acme.okta.com/api/v1/policies/rstdashboard", + label="Okta Dashboard", + ) + + async def fake_get_first_party(_app_name): + return ( + _fake_admin_console_settings(idle=15, lifetime=720), + _resp({}), + None, + ) + + async def fake_list_applications(*_a, **kwargs): + name_filter = kwargs.get("filter", "") + if "saasure" in name_filter: + return ([admin_console_app], _resp({}), None) + if "okta_enduser" in name_filter: + return ([dashboard_app], _resp({}), None) + return ([], _resp({}), None) + + async def fake_list_policy_rules(_policy_id, **_k): + rule = _fake_rule(name="Top", priority=1, factor_mode="2FA") + return ([rule], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_get_first_party, + list_applications=fake_list_applications, + list_policy_rules=fake_list_policy_rules, + ): + service = Application(provider) + + assert service.admin_console_app_settings.session_idle_timeout_minutes == 15 + assert service.admin_console_app_settings.session_max_lifetime_minutes == 720 + assert set(service.built_in_apps.keys()) == {"saasure", "okta_enduser"} + admin = service.built_in_apps["saasure"] + assert isinstance(admin, OktaBuiltInApp) + assert admin.access_policy_id == "rstadminconsole" + assert isinstance(admin.access_policy, AuthenticationPolicy) + assert admin.access_policy.rules[0].factor_mode == "2FA" + + def test_missing_admin_console_settings_endpoint_returns_none(self): + provider = set_mocked_okta_provider() + + async def failing_settings(_app_name): + return (None, _resp({}), Exception("404 Not Found")) + + async def fake_list_applications(*_a, **_k): + return ([], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=failing_settings, + list_applications=fake_list_applications, + list_policy_rules=mock.AsyncMock(), + ): + service = Application(provider) + + assert service.admin_console_app_settings is None + assert service.built_in_apps == {} + + def test_built_in_app_without_access_policy_link(self): + provider = set_mocked_okta_provider() + admin_console_app = _fake_app( + "0oaadminconsole", + "saasure", + access_policy_href=None, + label="Okta Admin Console", + ) + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_list_applications(*_a, **kwargs): + if "saasure" in kwargs.get("filter", ""): + return ([admin_console_app], _resp({}), None) + return ([], _resp({}), None) + + async def fake_list_policy_rules(*_a, **_k): + return ([], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_list_applications, + list_policy_rules=fake_list_policy_rules, + ): + service = Application(provider) + + admin = service.built_in_apps["saasure"] + assert admin.access_policy_id is None + assert admin.access_policy is None + + def test_paginates_list_applications_via_link_header(self): + provider = set_mocked_okta_provider() + page1 = _fake_app("0oa-page-1", "saasure") + page2 = _fake_app( + "0oa-page-2", + "saasure", + access_policy_href="https://acme.okta.com/api/v1/policies/rst1", + ) + next_link = '; rel="next"' + + calls = [] + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_list_applications(*_a, **kwargs): + name_filter = kwargs.get("filter", "") + if "saasure" in name_filter: + if kwargs.get("after") is None: + calls.append("page1") + return ([page1], _resp({"link": next_link}), None) + calls.append("page2") + return ([page2], _resp({}), None) + return ([], _resp({}), None) + + async def fake_list_policy_rules(*_a, **_k): + return ([], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_list_applications, + list_policy_rules=fake_list_policy_rules, + ): + service = Application(provider) + + assert calls == ["page1", "page2"] + # Pagination returns both, but we only keep the first match per + # canonical name. Make sure that path doesn't break. + assert "saasure" in service.built_in_apps + + def test_returns_empty_on_apps_api_error(self): + provider = set_mocked_okta_provider() + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def failing_apps(*_a, **_k): + return ([], _resp({}), Exception("E0000007: scope not found")) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=failing_apps, + list_policy_rules=mock.AsyncMock(), + ): + service = Application(provider) + + assert service.built_in_apps == {} + + def test_skips_fetch_when_apps_scope_missing(self): + identity = OktaIdentityInfo( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + granted_scopes=["okta.policies.read"], + ) + provider = set_mocked_okta_provider(identity=identity) + + list_apps_called = False + get_settings_called = False + + async def fake_settings(_app_name): + nonlocal get_settings_called + get_settings_called = True + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **_k): + nonlocal list_apps_called + list_apps_called = True + return ([], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_apps, + list_policy_rules=mock.AsyncMock(), + ): + service = Application(provider) + + assert list_apps_called is False + assert get_settings_called is False + assert service.built_in_apps == {} + assert service.admin_console_app_settings is None + assert service.missing_scope["admin_console_app_settings"] == "okta.apps.read" + assert service.missing_scope["built_in_apps"] == "okta.apps.read" + assert service.missing_scope["integrated_apps"] == "okta.apps.read" + assert service.missing_scope["access_policies"] is None + + def test_skips_policy_fetch_when_policies_scope_missing(self): + identity = OktaIdentityInfo( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + granted_scopes=["okta.apps.read"], + ) + provider = set_mocked_okta_provider(identity=identity) + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **_k): + return ([], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_apps, + list_policy_rules=mock.AsyncMock(), + ): + service = Application(provider) + + # When only one scope is missing, we still expose + # admin_console_app_settings (uses okta.apps.read which IS granted) + # but skip the joint built_in_apps+policies path. + assert service.admin_console_app_settings is not None + assert service.built_in_apps == {} + assert service.missing_scope["admin_console_app_settings"] is None + assert service.missing_scope["built_in_apps"] is None + assert service.missing_scope["integrated_apps"] is None + assert service.missing_scope["access_policies"] == "okta.policies.read" + + def test_unknown_granted_scopes_falls_back_to_attempting_fetch(self): + identity = OktaIdentityInfo( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + granted_scopes=[], + ) + provider = set_mocked_okta_provider(identity=identity) + + called = {"settings": False, "apps": False} + + async def fake_settings(_app_name): + called["settings"] = True + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **_k): + called["apps"] = True + return ([], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_apps, + list_policy_rules=mock.AsyncMock(), + ): + Application(provider) + + assert called["settings"] is True + assert called["apps"] is True + + def test_phishing_resistant_constraint_picked_up_from_rule(self): + provider = set_mocked_okta_provider() + app = _fake_app( + "0oaadminconsole", + "saasure", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-pr", + ) + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **kwargs): + if "saasure" in kwargs.get("filter", ""): + return ([app], _resp({}), None) + return ([], _resp({}), None) + + async def fake_rules(*_a, **_k): + rule = _fake_rule( + factor_mode="2FA", phishing_resistant="REQUIRED", priority=1 + ) + return ([rule], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_apps, + list_policy_rules=fake_rules, + ): + service = Application(provider) + + admin = service.built_in_apps["saasure"] + assert ( + admin.access_policy.rules[0].possession_phishing_resistant_required is True + ) + assert admin.access_policy.rules[0].factor_mode == "2FA" + + def test_network_zone_condition_picked_up_from_rule(self): + provider = set_mocked_okta_provider() + app = _fake_app( + "0oaadminconsole", + "saasure", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-net", + ) + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **kwargs): + if "saasure" in kwargs.get("filter", ""): + return ([app], _resp({}), None) + return ([], _resp({}), None) + + async def fake_rules(*_a, **_k): + rule = _fake_rule( + priority=1, + access="DENY", + network_connection="ZONE", + network_exclude=["zone-blocked"], + ) + return ([rule], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_apps, + list_policy_rules=fake_rules, + ): + service = Application(provider) + + admin = service.built_in_apps["saasure"] + assert admin.access_policy.rules[0].access == "DENY" + assert admin.access_policy.rules[0].network_connection == "ZONE" + assert admin.access_policy.rules[0].network_zones_exclude == ["zone-blocked"] + + def test_optional_phishing_resistant_not_treated_as_required(self): + provider = set_mocked_okta_provider() + app = _fake_app( + "0oaadminconsole", + "saasure", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-opt", + ) + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **kwargs): + if "saasure" in kwargs.get("filter", ""): + return ([app], _resp({}), None) + return ([], _resp({}), None) + + async def fake_rules(*_a, **_k): + rule = _fake_rule( + factor_mode="2FA", phishing_resistant="OPTIONAL", priority=1 + ) + return ([rule], _resp({}), None) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_apps, + list_policy_rules=fake_rules, + ): + service = Application(provider) + + admin = service.built_in_apps["saasure"] + assert ( + admin.access_policy.rules[0].possession_phishing_resistant_required is False + ) + + def test_lists_integrated_apps_on_demand(self): + provider = set_mocked_okta_provider() + built_in_admin = _fake_app( + "0oaadminconsole", + "saasure", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-admin", + label="Okta Admin Console", + ) + custom_app = _fake_app( + "0oacustom", + "google_workspace", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-custom", + label="Google Workspace", + ) + next_link = '; rel="next"' + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_list_applications(*_a, **kwargs): + name_filter = kwargs.get("filter", "") + if name_filter: + if "saasure" in name_filter: + return ([built_in_admin], _resp({}), None) + if "okta_enduser" in name_filter: + return ([], _resp({}), None) + if kwargs.get("after") is None: + return ([built_in_admin], _resp({"link": next_link}), None) + return ([custom_app], _resp({}), None) + + async def fake_list_policy_rules(_policy_id, **_k): + return ( + [_fake_rule(priority=1, network_include=["zone-corp"])], + _resp({}), + None, + ) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_list_applications, + list_policy_rules=fake_list_policy_rules, + ): + service = Application(provider) + apps = service.integrated_apps + + assert set(apps.keys()) == {"0oaadminconsole", "0oacustom"} + assert apps["0oacustom"].label == "Google Workspace" + assert apps["0oacustom"].access_policy_id == "rst-custom" + + +class Test_Application_service_sdk_validation_fallback: + """Verifies the raw-JSON fallback for the Okta SDK enum-validator bug. + + The Okta Management API returns values (e.g. lowercase `"password"` + in `KnowledgeConstraint.types`) that the SDK's pydantic field + validators reject as ValidationError. Without a fallback the entire + policy fetch crashes; with the fallback we evaluate the rules + correctly via raw JSON. + """ + + def _build_service_with_validation_error_then_raw_success( + self, raw_rules_payload, app_filter_match="saasure" + ): + from pydantic import ValidationError + + provider = set_mocked_okta_provider() + admin = _fake_app( + "0oaadminconsole", + "saasure", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-admin", + label="Okta Admin Console", + ) + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **kwargs): + if app_filter_match in (kwargs.get("filter") or ""): + return ([admin], _resp({}), None) + return ([], _resp({}), None) + + async def failing_list_policy_rules(*_a, **_k): + try: + # Trigger a real pydantic ValidationError so we exercise + # the exact exception type the SDK raises in production. + from okta.models.knowledge_constraint import KnowledgeConstraint + + KnowledgeConstraint(types=["password"]) + except ValidationError as ve: + raise ve + return ([], _resp({}), None) + + async def fake_raw_create(*_a, **_k): + return ({"url": "/api/v1/policies/rst-admin/rules"}, None) + + async def fake_raw_execute(_request): + return (None, json.dumps(raw_rules_payload), None) + + sdk_mock = mock.MagicMock() + sdk_mock.get_first_party_app_settings = fake_settings + sdk_mock.list_applications = fake_apps + sdk_mock.list_policy_rules = failing_list_policy_rules + sdk_mock._request_executor.create_request = fake_raw_create + sdk_mock._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk_mock, + ): + from prowler.providers.okta.services.application.application_service import ( + Application as _Application, + ) + + return _Application(provider) + + def test_raw_fallback_projects_factor_mode_and_phishing_resistant(self): + rules_payload = [ + { + "id": "rul-1", + "name": "Top Rule", + "priority": 1, + "status": "ACTIVE", + "system": False, + "actions": { + "appSignOn": { + "access": "ALLOW", + "verificationMethod": { + "type": "ASSURANCE", + "factorMode": "2FA", + "constraints": [ + { + "knowledge": {"types": ["password"]}, + "possession": {"phishingResistant": "REQUIRED"}, + } + ], + }, + } + }, + "conditions": { + "network": { + "connection": "ZONE", + "include": ["nzo-corp"], + "exclude": [], + } + }, + } + ] + service = self._build_service_with_validation_error_then_raw_success( + rules_payload + ) + + admin = service.built_in_apps["saasure"] + assert admin.access_policy is not None + assert len(admin.access_policy.rules) == 1 + rule = admin.access_policy.rules[0] + assert rule.factor_mode == "2FA" + assert rule.possession_phishing_resistant_required is True + assert rule.network_connection == "ZONE" + assert rule.network_zones_include == ["nzo-corp"] + assert rule.is_default is False + assert rule.priority == 1 + + def test_raw_fallback_handles_empty_rules(self): + service = self._build_service_with_validation_error_then_raw_success([]) + admin = service.built_in_apps["saasure"] + assert admin.access_policy is not None + assert admin.access_policy.rules == [] + + +class Test_Application_service_per_app_isolation: + """One app's fetch failure must not erase the other app's findings.""" + + def test_dashboard_still_returned_when_admin_console_policy_fetch_fails(self): + provider = set_mocked_okta_provider() + admin = _fake_app( + "0oaadminconsole", + "saasure", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-broken", + label="Okta Admin Console", + ) + dashboard = _fake_app( + "0oadashboard", + "okta_enduser", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-dash", + label="Okta Dashboard", + ) + + async def fake_settings(_app_name): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **kwargs): + f = kwargs.get("filter") or "" + if "saasure" in f: + return ([admin], _resp({}), None) + if "okta_enduser" in f: + return ([dashboard], _resp({}), None) + return ([], _resp({}), None) + + async def fake_policy_rules(policy_id, **_k): + if policy_id == "rst-broken": + raise RuntimeError("simulated unexpected SDK failure") + return ( + [ + _fake_rule( + name="Top", + priority=1, + factor_mode="2FA", + phishing_resistant="REQUIRED", + ) + ], + _resp({}), + None, + ) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_apps, + list_policy_rules=fake_policy_rules, + ): + service = Application(provider) + + # Admin Console: app captured, access_policy set to None due to + # isolated failure during rule fetch. + admin_model = service.built_in_apps["saasure"] + assert admin_model.access_policy is None + # Dashboard: succeeded — its rule is fully resolved. + dashboard_model = service.built_in_apps["okta_enduser"] + assert dashboard_model.access_policy is not None + assert dashboard_model.access_policy.rules[0].factor_mode == "2FA" + + def test_integrated_apps_one_app_failure_does_not_drop_others(self): + provider = set_mocked_okta_provider() + good = _fake_app( + "0oa-good", + "custom_good", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-good", + label="Good App", + ) + bad = _fake_app( + "0oa-bad", + "custom_bad", + access_policy_href="https://acme.okta.com/api/v1/policies/rst-bad", + label="Bad App", + ) + + async def fake_settings(_): + return (_fake_admin_console_settings(), _resp({}), None) + + async def fake_apps(*_a, **kwargs): + f = kwargs.get("filter") or "" + if f: + return ([], _resp({}), None) + return ([good, bad], _resp({}), None) + + async def fake_policy_rules(policy_id, **_k): + if policy_id == "rst-bad": + raise RuntimeError("simulated failure") + return ( + [_fake_rule(name="Top", priority=1, factor_mode="1FA")], + _resp({}), + None, + ) + + with _patch_sdk( + get_first_party_app_settings=fake_settings, + list_applications=fake_apps, + list_policy_rules=fake_policy_rules, + ): + service = Application(provider) + apps = service.integrated_apps + + assert set(apps.keys()) == {"0oa-good", "0oa-bad"} + assert apps["0oa-good"].access_policy is not None + assert apps["0oa-bad"].access_policy is None