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`.

-### 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.
+

### 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