mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
feat(okta): add application service with 6 new checks (#11358)
This commit is contained in:
@@ -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`.
|
||||
|
||||

|
||||
|
||||
### 6. Assign the Read-Only Administrator role
|
||||
### 6. Assign an admin role
|
||||
|
||||
On the app, open the **Admin roles** tab and click **Edit assignments → Add assignment**:
|
||||
|
||||
- **Role:** Read-Only Administrator
|
||||
- **Role:** Read-Only Administrator (default) — covers every `signon` check and runs the per-app network-zone check against the apps the service app can see (typically only the service app's own row).
|
||||
- **Resources:** All resources
|
||||
|
||||
Save the changes.
|
||||
|
||||
To additionally evaluate the first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, and phishing-resistant authentication) and to widen the per-app network-zone check to the full org-wide app inventory, assign **Super Administrator** instead. Without Super Administrator, the five first-party checks return MANUAL and the network-zone check is limited to the service app's own visibility — the rest of the scan still runs. See [Required Admin Role](#required-admin-role) for the full breakdown.
|
||||
|
||||

|
||||
|
||||
### 7. [Optional] Verify DPoP setting
|
||||
@@ -132,8 +158,8 @@ export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem"
|
||||
# or
|
||||
export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)"
|
||||
|
||||
# Optional — defaults to "okta.policies.read,okta.brands.read"
|
||||
export OKTA_SCOPES="okta.policies.read,okta.brands.read"
|
||||
# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read"
|
||||
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read"
|
||||
|
||||
uv run python prowler-cli.py okta
|
||||
```
|
||||
@@ -174,8 +200,12 @@ Prowler validates credentials at startup by listing one sign-on policy. This err
|
||||
|
||||
Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role:
|
||||
|
||||
- **`invalid_scope`** — one of the requested scopes (`okta.policies.read` or `okta.brands.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
|
||||
- **`Forbidden` / `not authorized`** — the **Read-Only Administrator** role is not assigned to the service app. Assign it from **Admin roles**.
|
||||
- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, or `okta.apps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
|
||||
- **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**.
|
||||
|
||||
### Application-service checks return MANUAL on first-party apps
|
||||
|
||||
When the service app runs with Read-Only Administrator, the five application-service checks targeting the Okta Admin Console and Okta Dashboard return MANUAL. This is by design — Okta restricts the underlying endpoints (`/api/v1/first-party-app-settings/{appName}` and `/api/v1/apps` for first-party app `name` values `saasure` / `okta_enduser`) to **Super Administrator**. Assign the Super Administrator role to the service app to evaluate those checks. See [Required Admin Role](#required-admin-role) for the full list.
|
||||
|
||||
### `invalid_dpop_proof`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+37
@@ -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."
|
||||
}
|
||||
+89
@@ -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
|
||||
+37
@@ -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."
|
||||
}
|
||||
+88
@@ -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
|
||||
+37
@@ -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."
|
||||
}
|
||||
+89
@@ -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
|
||||
+38
@@ -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."
|
||||
}
|
||||
+151
@@ -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())
|
||||
+37
@@ -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."
|
||||
}
|
||||
+85
@@ -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
|
||||
+37
@@ -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."
|
||||
}
|
||||
+84
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+149
@@ -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
|
||||
)
|
||||
+129
@@ -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
|
||||
+90
@@ -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
|
||||
+192
@@ -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
|
||||
+106
@@ -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
|
||||
+110
@@ -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
|
||||
Reference in New Issue
Block a user