feat(okta): add application service with 6 new checks (#11358)

This commit is contained in:
Daniel Barranquero
2026-05-27 11:16:18 +02:00
committed by GitHub
parent 48c071297f
commit 2678c6bc9f
40 changed files with 3391 additions and 23 deletions
@@ -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
@@ -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.
<Note>
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.
</Note>
## Step-by-Step Setup
@@ -98,19 +122,21 @@ Okta displays the private key **only once**. If you close the modal without copy
### 5. Grant the required OAuth scopes
On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled signon checks require `okta.policies.read` and `okta.brands.read`.
On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, and `okta.apps.read`.
![Okta — grant OAuth scopes](/user-guide/providers/okta/images/grant-permissions.png)
### 6. Assign the Read-Only Administrator role
### 6. Assign an admin role
On the app, open the **Admin roles** tab and click **Edit assignments → Add assignment**:
- **Role:** Read-Only Administrator
- **Role:** Read-Only Administrator (default) — covers every `signon` check and runs the per-app network-zone check against the apps the service app can see (typically only the service app's own row).
- **Resources:** All resources
Save the changes.
To additionally evaluate the first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, and phishing-resistant authentication) and to widen the per-app network-zone check to the full org-wide app inventory, assign **Super Administrator** instead. Without Super Administrator, the five first-party checks return MANUAL and the network-zone check is limited to the service app's own visibility — the rest of the scan still runs. See [Required Admin Role](#required-admin-role) for the full breakdown.
![Okta — grant Read-Only role](/user-guide/providers/okta/images/grant-roles.png)
### 7. [Optional] Verify DPoP setting
@@ -132,8 +158,8 @@ export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem"
# or
export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)"
# Optional — defaults to "okta.policies.read,okta.brands.read"
export OKTA_SCOPES="okta.policies.read,okta.brands.read"
# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read"
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read"
uv run python prowler-cli.py okta
```
@@ -174,8 +200,12 @@ Prowler validates credentials at startup by listing one sign-on policy. This err
Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role:
- **`invalid_scope`** — one of the requested scopes (`okta.policies.read` or `okta.brands.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
- **`Forbidden` / `not authorized`** — the **Read-Only Administrator** role is not assigned to the service app. Assign it from **Admin roles**.
- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, or `okta.apps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
- **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**.
### Application-service checks return MANUAL on first-party apps
When the service app runs with Read-Only Administrator, the five application-service checks targeting the Okta Admin Console and Okta Dashboard return MANUAL. This is by design — Okta restricts the underlying endpoints (`/api/v1/first-party-app-settings/{appName}` and `/api/v1/apps` for first-party app `name` values `saasure` / `okta_enduser`) to **Super Administrator**. Assign the Super Administrator role to the service app to evaluate those checks. See [Required Admin Role](#required-admin-role) for the full list.
### `invalid_dpop_proof`
@@ -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.
<CardGroup cols={2}>
@@ -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.
+1
View File
@@ -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)
+6
View File
@@ -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
@@ -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,
+1 -1
View File
@@ -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
@@ -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."
}
@@ -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
@@ -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."
}
@@ -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
@@ -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."
}
@@ -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
@@ -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."
}
@@ -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.",
)
@@ -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())
@@ -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."
}
@@ -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
@@ -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."
}
@@ -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
@@ -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),
)
@@ -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
+3
View File
@@ -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
+6 -2
View File
@@ -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()
@@ -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
)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
)
@@ -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 = '<https://acme.okta.com/api/v1/apps?after=cursor-2>; 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 = '<https://acme.okta.com/api/v1/apps?after=cursor-2>; 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