mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
feat(okta): add user, systemlog, and idp services with DISA STIG checks (#11496)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com> Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
This commit is contained in:
co-authored by
Hugo P.Brito
Hugo Pereira Brito
parent
061fbaa7bb
commit
466f1a3d73
@@ -35,14 +35,18 @@ The bundled checks require the following read-only scopes:
|
||||
- `okta.policies.read`
|
||||
- `okta.brands.read`
|
||||
- `okta.apps.read`
|
||||
- `okta.logStreams.read`
|
||||
- `okta.idps.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, and authentication policies |
|
||||
| `okta.policies.read` | Sign-on, password, authentication, and `USER_LIFECYCLE` (Workflow > Automations) 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 |
|
||||
| `okta.logStreams.read` | Log Stream configuration (`/api/v1/logStreams`) |
|
||||
| `okta.idps.read` | Identity Providers, including Smart Card (X509) IdPs (`/api/v1/idps`) |
|
||||
|
||||
### Required Admin Role
|
||||
|
||||
@@ -68,7 +72,9 @@ Okta filters the first-party apps (`saasure`, `okta_enduser`) out of `/api/v1/ap
|
||||
|
||||
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.
|
||||
`user_inactivity_automation_35d_enabled` (STIG V-273188) reads `USER_LIFECYCLE` policies (`list_policies(type='USER_LIFECYCLE')`) using the `okta.policies.read` scope. The Read-Only Administrator role is enough to list them; no Super Administrator requirement.
|
||||
|
||||
When the service app runs with Read-Only Administrator, the 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.
|
||||
@@ -158,8 +164,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,okta.apps.read"
|
||||
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read"
|
||||
# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read"
|
||||
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read"
|
||||
|
||||
uv run python prowler-cli.py okta
|
||||
```
|
||||
|
||||
@@ -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,okta.apps.read"
|
||||
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read"
|
||||
# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read"
|
||||
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read"
|
||||
```
|
||||
|
||||
The private key file may contain either a PEM-encoded RSA key or a JWK JSON document.
|
||||
@@ -143,10 +143,13 @@ 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) |
|
||||
| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) |
|
||||
| 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) |
|
||||
| **User** | User lifecycle automations (inactivity-based deprovisioning) |
|
||||
| **System Log** | Log Stream configuration that off-loads audit records to a central SIEM |
|
||||
| **Identity Provider** | Identity Providers, including Smart Card (X509) IdP status and certificate-chain visibility |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -158,11 +161,13 @@ 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 every bundled check across the Sign-On and Application services:
|
||||
Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, User, System Log, and Identity Provider services:
|
||||
|
||||
- `okta.policies.read`
|
||||
- `okta.brands.read`
|
||||
- `okta.apps.read`
|
||||
- `okta.logStreams.read`
|
||||
- `okta.idps.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.
|
||||
|
||||
@@ -170,10 +175,10 @@ When additional checks are enabled — or when running against a service app tha
|
||||
|
||||
```bash
|
||||
# Environment variable — comma-separated
|
||||
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.users.read"
|
||||
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read,okta.users.read"
|
||||
|
||||
# CLI flag — space-separated
|
||||
prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.users.read
|
||||
prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.logStreams.read okta.idps.read okta.users.read
|
||||
```
|
||||
|
||||
For the full catalog of OAuth scopes exposed by the Okta Management API, refer to the [Okta OAuth 2.0 scopes documentation](https://developer.okta.com/docs/api/oauth2/).
|
||||
|
||||
@@ -9,6 +9,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278)
|
||||
- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
|
||||
- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
|
||||
- `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ def init_parser(self):
|
||||
nargs="+",
|
||||
help=(
|
||||
"OAuth scopes to request, space-separated "
|
||||
"(e.g. okta.policies.read okta.brands.read okta.apps.read). "
|
||||
"(e.g. okta.policies.read okta.brands.read okta.apps.read "
|
||||
"okta.logStreams.read okta.idps.read). "
|
||||
"Defaults to the read scopes required by the bundled checks."
|
||||
),
|
||||
default=None,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Shared pagination helpers for Okta SDK list calls.
|
||||
|
||||
The Okta SDK exposes paginated list endpoints (`list_applications`,
|
||||
`list_policies`, `list_log_streams`, `list_identity_providers`, …) that
|
||||
return a tuple `(items, response, error)`. The next page is signalled
|
||||
through an RFC 5988 `Link: <…>; rel="next"` header carrying an opaque
|
||||
`after` cursor.
|
||||
|
||||
These helpers are used by every Okta service that needs to drain a
|
||||
paginated endpoint. They live here so we don't keep copy-pasting them
|
||||
into each service module.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
async def paginate(fetch):
|
||||
"""Drain all pages of an SDK list call.
|
||||
|
||||
`fetch` is a callable that accepts the `after` cursor (or None for
|
||||
the first page) and returns the SDK's standard `(items, resp, err)`
|
||||
tuple — or the 2-tuple early-error shape `(items, err)`. Follows the
|
||||
`Link: rel="next"` header until exhausted. The returned tuple is
|
||||
`(all_items, error)` — error is non-None only when a page fails
|
||||
to fetch.
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Raw-JSON HTTP fetch via the Okta SDK's request executor.
|
||||
|
||||
Some Okta Management API endpoints are not yet exposed as typed methods
|
||||
on the SDK client (e.g. `/api/v1/automations`), or the typed path's
|
||||
pydantic deserialization rejects values the API actually returns (e.g.
|
||||
the `KnowledgeConstraint.types` lowercase issue we hit on
|
||||
`list_policy_rules`). In both cases we go around the typed layer:
|
||||
construct the request via `client._request_executor.create_request`,
|
||||
execute without a response type, and parse the body ourselves.
|
||||
|
||||
`get_json` returns the parsed JSON payload (typically a list or dict)
|
||||
or raises with a descriptive log line on any of the failure modes —
|
||||
request build, transport, decode, parse. `get_json_paginated` drains
|
||||
list endpoints by following the `Link: rel="next"` cursor — without it,
|
||||
the raw fallback would silently truncate at the per-request `limit`.
|
||||
Callers are expected to project the JSON onto their own pydantic snapshot.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.okta.lib.service.pagination import next_after_cursor
|
||||
|
||||
|
||||
async def get_json(
|
||||
client,
|
||||
path: str,
|
||||
*,
|
||||
accept: str = "application/json",
|
||||
context: Optional[str] = None,
|
||||
) -> Optional[Any]:
|
||||
"""GET `path` via the SDK's request executor and return parsed JSON.
|
||||
|
||||
Returns the decoded JSON payload on success, or None when the
|
||||
request, transport, or decode steps fail. Each failure path emits a
|
||||
`logger.error` line tagged with `context` so the caller can grep
|
||||
for it.
|
||||
"""
|
||||
label = context or path
|
||||
request, error = await client._request_executor.create_request(
|
||||
method="GET",
|
||||
url=path,
|
||||
body=None,
|
||||
headers={"Accept": accept},
|
||||
)
|
||||
if error is not None:
|
||||
logger.error(f"Raw fetch (create_request) failed for {label}: {error}")
|
||||
return None
|
||||
|
||||
_response, response_body, error = await client._request_executor.execute(request)
|
||||
if error is not None:
|
||||
logger.error(f"Raw fetch (execute) failed for {label}: {error}")
|
||||
return None
|
||||
|
||||
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 response for {label}: {decode_err}")
|
||||
return None
|
||||
try:
|
||||
return json.loads(response_body) if response_body else None
|
||||
except json.JSONDecodeError as decode_err:
|
||||
logger.error(f"Could not parse JSON for {label}: {decode_err}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_json_paginated(
|
||||
client,
|
||||
path: str,
|
||||
*,
|
||||
page_size: int = 200,
|
||||
accept: str = "application/json",
|
||||
context: Optional[str] = None,
|
||||
) -> Optional[list]:
|
||||
"""Drain all pages of a raw-JSON list endpoint.
|
||||
|
||||
Mirrors the typed `pagination.paginate` shape but operates on the
|
||||
SDK's request executor directly. Follows the `Link: rel="next"`
|
||||
header until exhausted, accumulating items across pages. Returns
|
||||
the concatenated list, or None if any page fails to fetch or the
|
||||
response is not a JSON array.
|
||||
|
||||
`page_size` is appended as `limit=N` to the first request; subsequent
|
||||
requests use the URL Okta returns via the cursor.
|
||||
"""
|
||||
label = context or path
|
||||
all_items: list = []
|
||||
current_path = _set_query(path, {"limit": str(page_size)})
|
||||
while True:
|
||||
request, error = await client._request_executor.create_request(
|
||||
method="GET",
|
||||
url=current_path,
|
||||
body=None,
|
||||
headers={"Accept": accept},
|
||||
)
|
||||
if error is not None:
|
||||
logger.error(f"Raw fetch (create_request) failed for {label}: {error}")
|
||||
return None
|
||||
|
||||
response, response_body, error = await client._request_executor.execute(request)
|
||||
if error is not None:
|
||||
logger.error(f"Raw fetch (execute) failed for {label}: {error}")
|
||||
return None
|
||||
|
||||
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 response for {label}: {decode_err}")
|
||||
return None
|
||||
if not response_body:
|
||||
break
|
||||
try:
|
||||
page = json.loads(response_body)
|
||||
except json.JSONDecodeError as decode_err:
|
||||
logger.error(f"Could not parse JSON for {label}: {decode_err}")
|
||||
return None
|
||||
if not isinstance(page, list):
|
||||
logger.error(
|
||||
f"Unexpected raw payload shape for {label}: "
|
||||
f"{type(page).__name__}; expected list"
|
||||
)
|
||||
return None
|
||||
all_items.extend(page)
|
||||
|
||||
cursor = next_after_cursor(response)
|
||||
if not cursor:
|
||||
break
|
||||
current_path = _set_query(path, {"limit": str(page_size), "after": cursor})
|
||||
return all_items
|
||||
|
||||
|
||||
def _set_query(path: str, params: dict) -> str:
|
||||
"""Return `path` with the given query params merged in (overriding existing)."""
|
||||
parsed = urlparse(path)
|
||||
qs = dict(parse_qsl(parsed.query))
|
||||
qs.update({k: v for k, v in params.items() if v is not None})
|
||||
return urlunparse(parsed._replace(query=urlencode(qs)))
|
||||
@@ -32,7 +32,13 @@ 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", "okta.apps.read"]
|
||||
DEFAULT_SCOPES = [
|
||||
"okta.policies.read",
|
||||
"okta.brands.read",
|
||||
"okta.apps.read",
|
||||
"okta.logStreams.read",
|
||||
"okta.idps.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
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import json
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared
|
||||
from prowler.providers.okta.lib.service.raw_fetch import (
|
||||
get_json_paginated as _raw_get_json_paginated,
|
||||
)
|
||||
from prowler.providers.okta.lib.service.service import OktaService
|
||||
|
||||
# These three keys are Okta-platform constants, not tenant-configurable:
|
||||
@@ -28,29 +31,6 @@ 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",
|
||||
@@ -321,69 +301,24 @@ class Application(OktaService):
|
||||
"""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 executor directly via the shared `get_json_paginated`
|
||||
helper, which follows `Link: rel=next` so policies with more
|
||||
rules than `rule_fetch_limit` are not silently truncated.
|
||||
Projects the response 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"},
|
||||
rules_data = await _raw_get_json_paginated(
|
||||
self.client,
|
||||
f"/api/v1/policies/{policy_id}/rules",
|
||||
page_size=rule_fetch_limit,
|
||||
context=f"access policy {policy_id} rules",
|
||||
)
|
||||
if error is not None:
|
||||
logger.error(
|
||||
f"Raw rules fetch (create_request) failed for {policy_id}: {error}"
|
||||
)
|
||||
if rules_data is None:
|
||||
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
|
||||
@@ -391,33 +326,7 @@ class Application(OktaService):
|
||||
|
||||
@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
|
||||
return await _paginate_shared(fetch)
|
||||
|
||||
|
||||
def _policy_id_from_href(href: Optional[str]) -> Optional[str]:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.okta.services.idp.idp_service import Idp
|
||||
|
||||
idp_client = Idp(Provider.get_global_provider())
|
||||
@@ -0,0 +1,118 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.okta.lib.service.pagination import paginate
|
||||
from prowler.providers.okta.lib.service.service import OktaService
|
||||
|
||||
# Okta's API value for the "Smart Card" IdP shown in the Admin Console.
|
||||
# The UI label is "Smart Card IdP" but the `type` field on the API response
|
||||
# is `X509` (Mutual TLS) — that is the value we filter on.
|
||||
SMART_CARD_IDP_TYPE = "X509"
|
||||
|
||||
REQUIRED_SCOPES: dict[str, str] = {
|
||||
"identity_providers": "okta.idps.read",
|
||||
}
|
||||
|
||||
|
||||
class Idp(OktaService):
|
||||
"""Fetches Okta Identity Providers.
|
||||
|
||||
Populates `self.identity_providers` keyed by IdP id. Each entry
|
||||
captures the minimum fields the bundled checks read: identity
|
||||
(`id`, `name`), `type`, `status`, and — for `X509` Smart Card IdPs
|
||||
— the certificate-chain `issuer` and `kid` exposed by Okta's
|
||||
`protocol.credentials.trust` structure. Reading the issuer DN lets
|
||||
the check surface it for out-of-band verification against the
|
||||
DOD-approved CA list.
|
||||
|
||||
Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the
|
||||
access token's granted scopes (`provider.identity.granted_scopes`).
|
||||
Missing scopes are recorded in `self.missing_scope` so the check
|
||||
can emit an explicit MANUAL finding.
|
||||
"""
|
||||
|
||||
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.identity_providers: dict[str, OktaIdentityProvider] = (
|
||||
{}
|
||||
if self.missing_scope["identity_providers"]
|
||||
else self._list_identity_providers()
|
||||
)
|
||||
|
||||
def _list_identity_providers(self) -> dict:
|
||||
logger.info("Idp - Listing Okta Identity Providers...")
|
||||
try:
|
||||
return self._run(self._fetch_identity_providers())
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return {}
|
||||
|
||||
async def _fetch_identity_providers(self) -> dict:
|
||||
result: dict[str, OktaIdentityProvider] = {}
|
||||
all_idps, err = await paginate(
|
||||
lambda after: self.client.list_identity_providers(after=after)
|
||||
)
|
||||
if err is not None:
|
||||
logger.error(f"Error listing identity providers: {err}")
|
||||
return result
|
||||
|
||||
for idp in all_idps:
|
||||
idp_id = getattr(idp, "id", "") or ""
|
||||
if not idp_id:
|
||||
continue
|
||||
issuer, kid = _trust_fields(idp)
|
||||
result[idp_id] = OktaIdentityProvider(
|
||||
id=idp_id,
|
||||
name=getattr(idp, "name", "") or "",
|
||||
type=_stringify_enum(getattr(idp, "type", None)) or "",
|
||||
status=_stringify_enum(getattr(idp, "status", None)) or "",
|
||||
trust_issuer=issuer,
|
||||
trust_kid=kid,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _trust_fields(idp) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Extract `issuer` and `kid` from an `X509` IdP's protocol.credentials.trust.
|
||||
|
||||
The SDK exposes `IdentityProvider.protocol` as `IdentityProviderProtocol`,
|
||||
a Pydantic v2 oneOf wrapper that holds the concrete protocol (ProtocolMtls
|
||||
for X509 IdPs) on `actual_instance`. `credentials` is not proxied on the
|
||||
wrapper, so reading it directly returns None — we have to unwrap first.
|
||||
"""
|
||||
protocol = getattr(idp, "protocol", None)
|
||||
if protocol is None:
|
||||
return None, None
|
||||
actual_protocol = getattr(protocol, "actual_instance", None) or protocol
|
||||
credentials = getattr(actual_protocol, "credentials", None)
|
||||
if credentials is None:
|
||||
return None, None
|
||||
trust = getattr(credentials, "trust", None)
|
||||
if trust is None:
|
||||
return None, None
|
||||
return getattr(trust, "issuer", None), getattr(trust, "kid", None)
|
||||
|
||||
|
||||
def _stringify_enum(value) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
return getattr(value, "value", None) or str(value)
|
||||
|
||||
|
||||
class OktaIdentityProvider(BaseModel):
|
||||
id: str
|
||||
name: str = ""
|
||||
type: str = ""
|
||||
status: str = ""
|
||||
trust_issuer: Optional[str] = None
|
||||
trust_kid: Optional[str] = None
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"Provider": "okta",
|
||||
"CheckID": "idp_smart_card_dod_approved_ca",
|
||||
"CheckTitle": "Okta Smart Card (X509) Identity Provider uses a DOD-approved certificate authority",
|
||||
"CheckType": [],
|
||||
"ServiceName": "idp",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "Every Okta Smart Card (X509) Identity Provider must be `ACTIVE` and its certificate chain must be issued by a DOD-approved CA. The check ships default issuer-DN patterns covering DOD PKI and ECA, and matches them against the chain's `issuer`. Override or extend via `okta_dod_approved_ca_issuer_patterns` in the audit config to recognise tenant-specific DOD CAs.",
|
||||
"Risk": "An Okta Smart Card IdP whose certificate chain is not issued by a DOD-approved CA can be used to authenticate non-vetted identities.\n\n- **Trust on an unverified CA** allows impersonation of CAC/PIV holders\n- **Bypass of the federal PKI** required for DOD-grade identity assurance\n- **Acceptance of certificates** from a private or unaccredited issuer",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://help.okta.com/en-us/content/topics/security/idp-enable-smart-card.htm",
|
||||
"https://developer.okta.com/docs/api/openapi/okta-management/management/tag/IdentityProvider/"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Identity Providers**.\n3. For each IdP whose **Type** is **Smart Card**, click **Actions** > **Configure**.\n4. Under **Certificate chain**, verify the certificate is from a DOD-approved Certificate Authority (DOD PKI, ECA, JITC, or equivalent).\n5. If the IdP is not **Active**, activate it once the chain is validated.",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Verify each Okta Smart Card (X509) Identity Provider is ACTIVE and its certificate chain is issued by a DOD-approved Certificate Authority. Document the issuer for audit evidence.",
|
||||
"Url": "https://hub.prowler.com/check/idp_smart_card_dod_approved_ca"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Aligns with DISA STIG V-273207 / OKTA-APP-001920."
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import re
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportOkta
|
||||
from prowler.providers.okta.services.idp.idp_client import idp_client
|
||||
from prowler.providers.okta.services.idp.idp_service import (
|
||||
SMART_CARD_IDP_TYPE,
|
||||
OktaIdentityProvider,
|
||||
)
|
||||
from prowler.providers.okta.services.idp.lib.idp_helpers import (
|
||||
missing_idps_scope_finding,
|
||||
)
|
||||
|
||||
# Default issuer-DN substring patterns recognised as DOD-approved Certificate
|
||||
# Authorities. The DOD PKI publishes canonical DN forms that include
|
||||
# `O=U.S. Government, OU=DoD` (for DoD Root, DoD ID, DoD EMAIL, DoD SW, DoD
|
||||
# JITC CAs) and `O=U.S. Government, OU=ECA` for the External Certificate
|
||||
# Authorities. Customers running an internal CA outside these patterns can
|
||||
# extend the list via the `okta_dod_approved_ca_issuer_patterns` audit-config
|
||||
# entry — see the per-check Notes in metadata.json.
|
||||
DEFAULT_DOD_CA_ISSUER_PATTERNS = (
|
||||
# `OU=DoD` is the distinctive DISA DN component for every CA in the DoD
|
||||
# PKI (Root, ID, EMAIL, SW, JITC). `OU=ECA` is the equivalent for the
|
||||
# External Certificate Authorities. The trailing `\b` prevents accidental
|
||||
# matches against superstrings like `OU=DoDExtra`.
|
||||
r"\bOU=DoD\b",
|
||||
r"\bOU=ECA\b",
|
||||
)
|
||||
|
||||
|
||||
class idp_smart_card_dod_approved_ca(Check):
|
||||
"""Verifies that Okta Smart Card (X509) IdPs are configured and use a DOD-approved CA.
|
||||
|
||||
PASS when the IdP is `ACTIVE` and its certificate chain's `issuer`
|
||||
DN matches one of the configured DOD-approved CA patterns. MANUAL
|
||||
when active but the issuer doesn't match (operator can verify
|
||||
out-of-band or extend the pattern list). FAIL when no Smart Card
|
||||
IdP is configured or when the configured IdP is inactive.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[CheckReportOkta]:
|
||||
findings: list[CheckReportOkta] = []
|
||||
org_domain = idp_client.provider.identity.org_domain
|
||||
audit_config = idp_client.audit_config or {}
|
||||
configured_patterns = audit_config.get("okta_dod_approved_ca_issuer_patterns")
|
||||
patterns = (
|
||||
tuple(configured_patterns)
|
||||
if configured_patterns
|
||||
else DEFAULT_DOD_CA_ISSUER_PATTERNS
|
||||
)
|
||||
|
||||
missing_scope = idp_client.missing_scope.get("identity_providers")
|
||||
if missing_scope:
|
||||
findings.append(
|
||||
missing_idps_scope_finding(self.metadata(), org_domain, missing_scope)
|
||||
)
|
||||
return findings
|
||||
|
||||
smart_card_idps = [
|
||||
idp
|
||||
for idp in idp_client.identity_providers.values()
|
||||
if (idp.type or "").upper() == SMART_CARD_IDP_TYPE
|
||||
]
|
||||
|
||||
if not smart_card_idps:
|
||||
placeholder = OktaIdentityProvider(
|
||||
id="okta-smart-card-idp-missing",
|
||||
name="(no Smart Card IdP configured)",
|
||||
type=SMART_CARD_IDP_TYPE,
|
||||
status="MISSING",
|
||||
)
|
||||
report = CheckReportOkta(
|
||||
metadata=self.metadata(), resource=placeholder, org_domain=org_domain
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
"No Smart Card (X509) Identity Providers are configured. "
|
||||
"Configure a Smart Card IdP in the Admin Console "
|
||||
"(Security > Identity Providers) with a certificate chain "
|
||||
"issued by a DOD-approved CA. If CAC/PIV authentication is "
|
||||
"not required for this tenant, mutelist this check with "
|
||||
"that documented exception."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
for idp in smart_card_idps:
|
||||
report = CheckReportOkta(
|
||||
metadata=self.metadata(), resource=idp, org_domain=org_domain
|
||||
)
|
||||
label = f"Okta Smart Card IdP '{idp.name}' (id={idp.id}, type={idp.type})"
|
||||
chain_detail = _format_chain_detail(idp)
|
||||
|
||||
if (idp.status or "").upper() != "ACTIVE":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"{label} is not ACTIVE (status={idp.status or 'unset'}). "
|
||||
"Activate the IdP from Security > Identity Providers, then "
|
||||
f"verify the certificate chain. {chain_detail}"
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
matched_pattern = _matched_issuer_pattern(idp.trust_issuer, patterns)
|
||||
if matched_pattern is not None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"{label} is ACTIVE and its chain issuer matches a "
|
||||
f"DOD-approved CA pattern (`{matched_pattern}`). "
|
||||
f"{chain_detail}"
|
||||
)
|
||||
else:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"{label} is ACTIVE but its chain issuer does not match any "
|
||||
"configured DOD-approved CA pattern. Verify out-of-band "
|
||||
"that the certificate chain belongs to a DOD-approved "
|
||||
"Certificate Authority, or extend "
|
||||
"`okta_dod_approved_ca_issuer_patterns` in the audit "
|
||||
f"config. {chain_detail}"
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
|
||||
def _matched_issuer_pattern(issuer, patterns):
|
||||
if not issuer:
|
||||
return None
|
||||
for pattern in patterns:
|
||||
try:
|
||||
if re.search(pattern, issuer):
|
||||
return pattern
|
||||
except re.error:
|
||||
# Skip malformed operator-supplied patterns rather than crashing
|
||||
# the whole check.
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _format_chain_detail(idp: OktaIdentityProvider) -> str:
|
||||
if idp.trust_issuer or idp.trust_kid:
|
||||
return (
|
||||
f"Chain issuer: {idp.trust_issuer or 'unset'}; "
|
||||
f"kid: {idp.trust_kid or 'unset'}."
|
||||
)
|
||||
return (
|
||||
"Chain issuer and kid were not exposed by the API; inspect the IdP in "
|
||||
"the Admin Console under Security > Identity Providers > Configure."
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Shared helpers for the OKTA idp STIG checks."""
|
||||
|
||||
from prowler.lib.check.models import CheckReportOkta
|
||||
from prowler.providers.okta.services.idp.idp_service import OktaIdentityProvider
|
||||
|
||||
|
||||
def missing_idps_scope_finding(
|
||||
metadata, org_domain: str, scope: str
|
||||
) -> CheckReportOkta:
|
||||
"""Build the MANUAL finding when the IdPs scope is not granted."""
|
||||
placeholder = OktaIdentityProvider(
|
||||
id="okta-idps-scope-missing",
|
||||
name="(scope not granted)",
|
||||
status="MISSING",
|
||||
)
|
||||
report = CheckReportOkta(
|
||||
metadata=metadata, resource=placeholder, org_domain=org_domain
|
||||
)
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
"Could not retrieve Okta Identity Providers: the Okta service app is "
|
||||
f"missing the required `{scope}` API scope. Grant it on the service "
|
||||
"app's Okta API Scopes tab in the Okta Admin Console, then re-run the "
|
||||
"check."
|
||||
)
|
||||
return report
|
||||
@@ -1,34 +1,11 @@
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared
|
||||
from prowler.providers.okta.lib.service.service import OktaService
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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] = {
|
||||
"global_session_policies": "okta.policies.read",
|
||||
"sign_in_pages": "okta.brands.read",
|
||||
@@ -228,33 +205,7 @@ class Signon(OktaService):
|
||||
|
||||
@staticmethod
|
||||
async def _paginate(fetch):
|
||||
"""Drain all pages of an SDK list call.
|
||||
|
||||
`fetch` is a callable that takes the `after` cursor (or None for
|
||||
the first page) and returns the SDK's standard `(items, resp, err)`
|
||||
tuple. We follow `Link: rel="next"` headers until exhausted.
|
||||
"""
|
||||
all_items = []
|
||||
result = await fetch(None)
|
||||
# Defensive against the SDK's 2-tuple early-error path: error is last.
|
||||
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
|
||||
return await _paginate_shared(fetch)
|
||||
|
||||
|
||||
class GlobalSessionPolicyRule(BaseModel):
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Shared helpers for the OKTA systemlog STIG checks."""
|
||||
|
||||
from prowler.lib.check.models import CheckReportOkta
|
||||
from prowler.providers.okta.services.systemlog.systemlog_service import LogStream
|
||||
|
||||
|
||||
def missing_log_streams_scope_finding(
|
||||
metadata, org_domain: str, scope: str
|
||||
) -> CheckReportOkta:
|
||||
"""Build the MANUAL finding when the log-streams scope is not granted."""
|
||||
placeholder = LogStream(
|
||||
id="okta-log-streams-scope-missing",
|
||||
name="(scope not granted)",
|
||||
status="MISSING",
|
||||
type="",
|
||||
)
|
||||
report = CheckReportOkta(
|
||||
metadata=metadata, resource=placeholder, org_domain=org_domain
|
||||
)
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
"Could not retrieve Okta Log Streams: the Okta service app is missing "
|
||||
f"the required `{scope}` API scope. Grant it on the service app's "
|
||||
"Okta API Scopes tab in the Okta Admin Console, then re-run the check."
|
||||
)
|
||||
return report
|
||||
@@ -0,0 +1,4 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.okta.services.systemlog.systemlog_service import SystemLog
|
||||
|
||||
systemlog_client = SystemLog(Provider.get_global_provider())
|
||||
@@ -0,0 +1,136 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.okta.lib.service.pagination import paginate
|
||||
from prowler.providers.okta.lib.service.raw_fetch import (
|
||||
get_json_paginated as raw_get_json_paginated,
|
||||
)
|
||||
from prowler.providers.okta.lib.service.service import OktaService
|
||||
|
||||
REQUIRED_SCOPES: dict[str, str] = {
|
||||
"log_streams": "okta.logStreams.read",
|
||||
}
|
||||
|
||||
|
||||
class SystemLog(OktaService):
|
||||
"""Fetches Okta Log Stream configurations.
|
||||
|
||||
Populates `self.log_streams` keyed by Log Stream id. Each entry
|
||||
carries `name`, `status`, `type` — enough for the streaming-enabled
|
||||
check to evaluate whether the tenant has off-loaded audit records
|
||||
to an external SIEM/event bus.
|
||||
|
||||
Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the
|
||||
access token's granted scopes (`provider.identity.granted_scopes`).
|
||||
Missing scopes are recorded in `self.missing_scope` so the check
|
||||
can emit an explicit MANUAL finding instead of a misleading
|
||||
"no resources returned".
|
||||
"""
|
||||
|
||||
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.log_streams: dict[str, LogStream] = (
|
||||
{} if self.missing_scope["log_streams"] else self._list_log_streams()
|
||||
)
|
||||
|
||||
def _list_log_streams(self) -> dict:
|
||||
logger.info("SystemLog - Listing Okta Log Streams...")
|
||||
try:
|
||||
return self._run(self._fetch_log_streams())
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return {}
|
||||
|
||||
async def _fetch_log_streams(self) -> dict:
|
||||
result: dict[str, LogStream] = {}
|
||||
try:
|
||||
all_streams, err = await paginate(
|
||||
lambda after: self.client.list_log_streams(after=after)
|
||||
)
|
||||
except ValidationError as ve:
|
||||
# Upstream okta-sdk-python bug: e.g. `LogStreamSettingsAws`'s
|
||||
# `eventSourceName` validator regex is `^[a-zA-Z0-9.\-_]$` —
|
||||
# missing the `+` quantifier, so it rejects every
|
||||
# multi-character name. Fall back to raw JSON so the check
|
||||
# can still evaluate the tenant's actual log-stream state.
|
||||
# Remove this workaround once okta-sdk-python fixes the
|
||||
# validator (issue to be filed upstream).
|
||||
logger.warning(
|
||||
f"Okta SDK raised ValidationError parsing log streams "
|
||||
f"({ve.error_count()} error(s)) — falling back to raw-JSON "
|
||||
"parse. This is an okta-sdk-python deserialization bug."
|
||||
)
|
||||
return await self._fetch_log_streams_raw()
|
||||
|
||||
if err is not None:
|
||||
logger.error(f"Error listing log streams: {err}")
|
||||
return result
|
||||
|
||||
for stream in all_streams:
|
||||
stream_id = getattr(stream, "id", "") or ""
|
||||
if not stream_id:
|
||||
continue
|
||||
result[stream_id] = LogStream(
|
||||
id=stream_id,
|
||||
name=getattr(stream, "name", "") or "",
|
||||
status=getattr(stream, "status", "") or "",
|
||||
type=_stringify_enum(getattr(stream, "type", None)) or "",
|
||||
)
|
||||
return result
|
||||
|
||||
async def _fetch_log_streams_raw(self) -> dict:
|
||||
"""Raw-JSON fallback for `list_log_streams`.
|
||||
|
||||
Bypasses the SDK's typed deserialization via the shared
|
||||
`get_json_paginated` helper (which follows the `Link: rel=next`
|
||||
cursor so tenants with >200 streams are not silently truncated),
|
||||
and projects the response onto our own pydantic snapshot which
|
||||
only validates the four fields the check reads. Keeps the check
|
||||
evaluable on tenants whose Log Stream settings happen to trip
|
||||
an SDK enum/regex validator.
|
||||
"""
|
||||
result: dict[str, LogStream] = {}
|
||||
data = await raw_get_json_paginated(
|
||||
self.client,
|
||||
"/api/v1/logStreams",
|
||||
page_size=200,
|
||||
context="log streams",
|
||||
)
|
||||
if data is None:
|
||||
return result
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
stream_id = item.get("id")
|
||||
if not stream_id:
|
||||
continue
|
||||
result[stream_id] = LogStream(
|
||||
id=stream_id,
|
||||
name=item.get("name") or "",
|
||||
status=(item.get("status") or "").upper(),
|
||||
type=item.get("type") or "",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _stringify_enum(value) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
return getattr(value, "value", None) or str(value)
|
||||
|
||||
|
||||
class LogStream(BaseModel):
|
||||
id: str
|
||||
name: str = ""
|
||||
status: str = ""
|
||||
type: str = ""
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"Provider": "okta",
|
||||
"CheckID": "systemlog_streaming_enabled",
|
||||
"CheckTitle": "Okta off-loads audit records to a central log server via Log Streaming",
|
||||
"CheckType": [],
|
||||
"ServiceName": "systemlog",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "monitoring",
|
||||
"Description": "Okta must off-load audit records to a central log server. At least one **Log Stream** (AWS EventBridge, Splunk Cloud, etc.) must be configured and `ACTIVE` in the tenant. Alternatively, an external SIEM pulling the System Log API can satisfy the requirement, but that pull-based path is verified manually.",
|
||||
"Risk": "Audit records stored only inside the Okta tenant are exposed to accidental or incidental deletion or alteration.\n\n- **No central retention** of authentication events for incident investigations\n- **Single point of failure** for the audit trail\n- **No correlation** with other identity, network, and endpoint telemetry in the SIEM",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://help.okta.com/en-us/content/topics/reports/log-streaming/about-log-streams.htm",
|
||||
"https://developer.okta.com/docs/api/openapi/okta-management/management/tag/LogStream/"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Reports** > **Log Streaming**.\n3. Click **Add Log Stream** and select **AWS EventBridge**, **Splunk Cloud**, or another supported destination.\n4. Complete the connection fields and save.\n5. Activate the stream and verify the destination receives events.\n6. If the destination SIEM is not natively supported, document the pull-based ingestion that uses the System Log API.",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Configure at least one ACTIVE Okta Log Stream that off-loads audit records to a central SIEM (AWS EventBridge, Splunk Cloud, or another supported destination). Document any alternative pull-based ingestion via the System Log API.",
|
||||
"Url": "https://hub.prowler.com/check/systemlog_streaming_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"logging"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Aligns with DISA STIG V-273202 / OKTA-APP-001430."
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
from prowler.lib.check.models import Check, CheckReportOkta
|
||||
from prowler.providers.okta.services.systemlog.lib.systemlog_helpers import (
|
||||
missing_log_streams_scope_finding,
|
||||
)
|
||||
from prowler.providers.okta.services.systemlog.systemlog_client import systemlog_client
|
||||
from prowler.providers.okta.services.systemlog.systemlog_service import LogStream
|
||||
|
||||
|
||||
class systemlog_streaming_enabled(Check):
|
||||
"""Verifies that at least one Okta Log Stream is configured and active.
|
||||
|
||||
Off-loading audit records to a central SIEM (AWS EventBridge, Splunk
|
||||
Cloud, etc.) is the standard mechanism for centralised retention.
|
||||
An alternative path — pulling the System Log API into an external
|
||||
SIEM — is allowed by the requirement, but cannot be verified
|
||||
automatically; this check emits a MANUAL note in that case.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[CheckReportOkta]:
|
||||
findings: list[CheckReportOkta] = []
|
||||
org_domain = systemlog_client.provider.identity.org_domain
|
||||
|
||||
missing_scope = systemlog_client.missing_scope.get("log_streams")
|
||||
if missing_scope:
|
||||
findings.append(
|
||||
missing_log_streams_scope_finding(
|
||||
self.metadata(), org_domain, missing_scope
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
active_streams = [
|
||||
stream
|
||||
for stream in systemlog_client.log_streams.values()
|
||||
if not stream.status or stream.status.upper() == "ACTIVE"
|
||||
]
|
||||
|
||||
if not systemlog_client.log_streams:
|
||||
placeholder = LogStream(
|
||||
id="okta-log-streams-missing",
|
||||
name="(no Log Streams configured)",
|
||||
status="MISSING",
|
||||
type="",
|
||||
)
|
||||
report = CheckReportOkta(
|
||||
metadata=self.metadata(), resource=placeholder, org_domain=org_domain
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
"No Okta Log Streams are configured. Configure a Log Stream "
|
||||
"(Reports > Log Streaming) to off-load audit records to a "
|
||||
"central SIEM. If an external SIEM is already pulling logs "
|
||||
"via the System Log API, mutelist this check with that "
|
||||
"evidence."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
if not active_streams:
|
||||
placeholder = LogStream(
|
||||
id="okta-log-streams-inactive",
|
||||
name="(no active Log Streams)",
|
||||
status="INACTIVE",
|
||||
type="",
|
||||
)
|
||||
report = CheckReportOkta(
|
||||
metadata=self.metadata(), resource=placeholder, org_domain=org_domain
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"{len(systemlog_client.log_streams)} Okta Log Stream(s) are "
|
||||
"configured but none are ACTIVE. Activate a Log Stream to "
|
||||
"off-load audit records to a central SIEM."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
for stream in active_streams:
|
||||
report = CheckReportOkta(
|
||||
metadata=self.metadata(), resource=stream, org_domain=org_domain
|
||||
)
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Okta Log Stream '{stream.name}' (type={stream.type or 'unset'}) "
|
||||
"is ACTIVE and off-loads audit records to a central SIEM."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Shared helpers for the OKTA user STIG checks."""
|
||||
|
||||
from prowler.lib.check.models import CheckReportOkta
|
||||
from prowler.providers.okta.services.user.user_service import UserAutomation
|
||||
|
||||
|
||||
def missing_user_scope_finding(
|
||||
metadata, org_domain: str, scope: str
|
||||
) -> CheckReportOkta:
|
||||
"""Build the MANUAL finding when an OAuth scope is not granted."""
|
||||
placeholder = UserAutomation(
|
||||
id="okta-user-scope-missing",
|
||||
name="(scope not granted)",
|
||||
status="MISSING",
|
||||
)
|
||||
report = CheckReportOkta(
|
||||
metadata=metadata, resource=placeholder, org_domain=org_domain
|
||||
)
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not retrieve Okta user lifecycle automations: the Okta service "
|
||||
f"app is missing the required `{scope}` API scope. Grant it on the "
|
||||
"service app's Okta API Scopes tab in the Okta Admin Console, then "
|
||||
"re-run the check."
|
||||
)
|
||||
return report
|
||||
@@ -0,0 +1,4 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.okta.services.user.user_service import User
|
||||
|
||||
user_client = User(Provider.get_global_provider())
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"Provider": "okta",
|
||||
"CheckID": "user_inactivity_automation_35d_enabled",
|
||||
"CheckTitle": "Okta automation suspends or deactivates users after 35 days of inactivity",
|
||||
"CheckType": [],
|
||||
"ServiceName": "user",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "An Okta **Workflows Automation** must disable inactive user accounts. The automation must be `ACTIVE`, on an `ACTIVE` schedule, evaluate `User Inactivity = 35 days` (or less), apply to a group covering every user, and trigger `Suspended` / `Deactivated` / `Deprovisioned`. Threshold override: `okta_user_inactivity_max_days`. N/A when user sourcing is delegated to Active Directory or LDAP.",
|
||||
"Risk": "Inactive Okta accounts retained indefinitely give an attacker who exploits one undetected access to downstream applications.\n\n- **Account takeover via dormant identities** that no one is monitoring\n- **Lateral movement** through SSO sessions of forgotten users\n- **Stale entitlements** that survive role and policy reorganisations",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://help.okta.com/en-us/content/topics/automation-hooks/automations-main.htm"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Workflow** > **Automations** and click **Add Automation**.\n3. Name the automation (e.g., `User Inactivity`).\n4. Add a condition: select **User Inactivity in Okta** and enter `35` days.\n5. Configure the schedule to run daily and activate it.\n6. Apply the automation to a group that covers every user — typically `Everyone`.\n7. Add an action: **Change User lifecycle state in Okta** and choose `Suspended` (or `Deactivated`/`Deprovisioned`).\n8. Activate the automation.",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Create an active Okta Workflows automation that runs daily, evaluates `User Inactivity in Okta = 35 days`, applies to a group covering every user, and changes the user lifecycle state to Suspended/Deactivated. If user sourcing is delegated to Active Directory or LDAP, document that the connected directory enforces this requirement instead.",
|
||||
"Url": "https://hub.prowler.com/check/user_inactivity_automation_35d_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Aligns with DISA STIG V-273188 / OKTA-APP-000090."
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
from prowler.lib.check.models import Check, CheckReportOkta
|
||||
from prowler.providers.okta.services.user.lib.user_helpers import (
|
||||
missing_user_scope_finding,
|
||||
)
|
||||
from prowler.providers.okta.services.user.user_client import user_client
|
||||
from prowler.providers.okta.services.user.user_service import UserAutomation
|
||||
|
||||
DEFAULT_INACTIVITY_DAYS = 35
|
||||
SUSPENSION_LIFECYCLE_ACTIONS = {"SUSPENDED", "DEACTIVATED", "DEPROVISIONED"}
|
||||
|
||||
|
||||
class user_inactivity_automation_35d_enabled(Check):
|
||||
"""Verifies that Okta suspends/deactivates users after 35 days of inactivity.
|
||||
|
||||
A Workflows Automation must exist with:
|
||||
- status ACTIVE,
|
||||
- schedule active,
|
||||
- condition `User Inactivity in Okta = 35 days`,
|
||||
- action that changes the user state to Suspended / Deactivated,
|
||||
- applied to a group covering every user (typically `Everyone`).
|
||||
|
||||
When user sourcing is delegated to an external directory (Active
|
||||
Directory or LDAP), the requirement is N/A on the Okta side — the
|
||||
connected directory is expected to enforce inactivity-based
|
||||
deactivation instead. Threshold override:
|
||||
`okta_user_inactivity_max_days` in the audit config.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[CheckReportOkta]:
|
||||
findings: list[CheckReportOkta] = []
|
||||
audit_config = user_client.audit_config or {}
|
||||
threshold_days = audit_config.get(
|
||||
"okta_user_inactivity_max_days", DEFAULT_INACTIVITY_DAYS
|
||||
)
|
||||
org_domain = user_client.provider.identity.org_domain
|
||||
|
||||
for scope_key in ("automations", "identity_providers"):
|
||||
missing_scope = user_client.missing_scope.get(scope_key)
|
||||
if missing_scope:
|
||||
findings.append(
|
||||
missing_user_scope_finding(
|
||||
self.metadata(), org_domain, missing_scope
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
# External-directory N/A path.
|
||||
if user_client.external_directory_idps:
|
||||
idp_names = ", ".join(
|
||||
f"'{idp.name}' (type={idp.type})"
|
||||
for idp in user_client.external_directory_idps.values()
|
||||
)
|
||||
placeholder = UserAutomation(
|
||||
id="okta-user-inactivity-na-external-directory",
|
||||
name="(external directory enforces inactivity)",
|
||||
status="N/A",
|
||||
)
|
||||
report = CheckReportOkta(
|
||||
metadata=self.metadata(),
|
||||
resource=placeholder,
|
||||
org_domain=org_domain,
|
||||
)
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
"User sourcing is delegated to an external directory "
|
||||
f"({idp_names}). The 35-day inactivity disable requirement is "
|
||||
"expected to be enforced by the connected directory rather "
|
||||
"than by an Okta automation. Confirm out-of-band that the "
|
||||
"external directory disables accounts after "
|
||||
f"{threshold_days} days of inactivity."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
compliant_automations = [
|
||||
automation
|
||||
for automation in user_client.automations.values()
|
||||
if _is_compliant(automation, threshold_days)
|
||||
]
|
||||
|
||||
if not user_client.automations:
|
||||
placeholder = UserAutomation(
|
||||
id="okta-user-inactivity-no-automations",
|
||||
name="(no automations configured)",
|
||||
status="MISSING",
|
||||
)
|
||||
report = CheckReportOkta(
|
||||
metadata=self.metadata(),
|
||||
resource=placeholder,
|
||||
org_domain=org_domain,
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
"No Okta Workflows automations are configured. Create an "
|
||||
"automation that suspends or deactivates users after "
|
||||
f"{threshold_days} days of inactivity, scoped to a group "
|
||||
"covering every user (typically 'Everyone'), with an active "
|
||||
"schedule."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
if compliant_automations:
|
||||
for automation in compliant_automations:
|
||||
report = CheckReportOkta(
|
||||
metadata=self.metadata(),
|
||||
resource=automation,
|
||||
org_domain=org_domain,
|
||||
)
|
||||
report.status = "PASS"
|
||||
groups_label = ", ".join(automation.applies_to_groups)
|
||||
report.status_extended = (
|
||||
f"Okta automation '{automation.name}' is ACTIVE with an "
|
||||
f"active schedule, triggers after "
|
||||
f"{automation.inactivity_days} days of inactivity, and "
|
||||
f"changes the user state to "
|
||||
f"{automation.lifecycle_action or 'unset'}. "
|
||||
f"Applied to group(s): {groups_label}. Verify that these "
|
||||
"group(s) cover every user. Okta has no built-in "
|
||||
"'Everyone' group ID, so tenant-wide coverage cannot be "
|
||||
"asserted automatically."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
# Automations exist but none satisfy the predicate — surface the
|
||||
# closest candidate for the auditor.
|
||||
candidate = _closest_candidate(user_client.automations.values())
|
||||
report = CheckReportOkta(
|
||||
metadata=self.metadata(),
|
||||
resource=candidate
|
||||
or UserAutomation(
|
||||
id="okta-user-inactivity-noncompliant",
|
||||
name="(no compliant automation)",
|
||||
status="MISSING",
|
||||
),
|
||||
org_domain=org_domain,
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = _failure_message(candidate, threshold_days)
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
|
||||
def _is_compliant(automation: UserAutomation, threshold_days: int) -> bool:
|
||||
# `applies_to_groups` must be non-empty — Okta USER_LIFECYCLE policies
|
||||
# do not implicitly cover every user; the scope is whatever group IDs
|
||||
# the operator put in `people.groups.include`. An empty scope means
|
||||
# the automation runs against nobody. Operator must still verify those
|
||||
# group(s) cover the intended user population (surfaced in the PASS
|
||||
# status_extended).
|
||||
return bool(
|
||||
automation.status.upper() == "ACTIVE"
|
||||
and automation.schedule_status.upper() == "ACTIVE"
|
||||
and automation.inactivity_days is not None
|
||||
and automation.inactivity_days <= threshold_days
|
||||
and (automation.lifecycle_action or "").upper() in SUSPENSION_LIFECYCLE_ACTIONS
|
||||
and bool(automation.applies_to_groups)
|
||||
)
|
||||
|
||||
|
||||
def _closest_candidate(automations):
|
||||
automations = list(automations)
|
||||
if not automations:
|
||||
return None
|
||||
automations.sort(
|
||||
key=lambda a: (
|
||||
0 if a.status.upper() == "ACTIVE" else 1,
|
||||
0 if a.schedule_status.upper() == "ACTIVE" else 1,
|
||||
(
|
||||
abs(a.inactivity_days - DEFAULT_INACTIVITY_DAYS)
|
||||
if a.inactivity_days is not None
|
||||
else 10_000
|
||||
),
|
||||
a.name,
|
||||
)
|
||||
)
|
||||
return automations[0]
|
||||
|
||||
|
||||
def _failure_message(automation, threshold_days):
|
||||
if automation is None:
|
||||
return f"No Okta automation enforces {threshold_days}-day inactivity disable."
|
||||
issues = []
|
||||
if automation.status.upper() != "ACTIVE":
|
||||
issues.append(f"status {automation.status or 'unset'}")
|
||||
if automation.schedule_status.upper() != "ACTIVE":
|
||||
issues.append(f"schedule {automation.schedule_status or 'unset'}")
|
||||
if automation.inactivity_days is None:
|
||||
issues.append("no inactivity condition")
|
||||
elif automation.inactivity_days > threshold_days:
|
||||
issues.append(
|
||||
f"inactivity {automation.inactivity_days}d (max {threshold_days}d)"
|
||||
)
|
||||
action = (automation.lifecycle_action or "").upper()
|
||||
if action not in SUSPENSION_LIFECYCLE_ACTIONS:
|
||||
issues.append(f"action {automation.lifecycle_action or 'unset'}")
|
||||
if not automation.applies_to_groups:
|
||||
issues.append("no group scope")
|
||||
detail = ", ".join(issues) if issues else "incomplete"
|
||||
return (
|
||||
f"Okta automation '{automation.name}' fails {threshold_days}d "
|
||||
f"inactivity: {detail}."
|
||||
)
|
||||
@@ -0,0 +1,455 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.okta.lib.service.pagination import paginate
|
||||
from prowler.providers.okta.lib.service.raw_fetch import (
|
||||
get_json_paginated as raw_get_json_paginated,
|
||||
)
|
||||
from prowler.providers.okta.lib.service.service import OktaService
|
||||
|
||||
# External-directory IdP `type` values that delegate user sourcing to a
|
||||
# separate identity store. When any of these is present and ACTIVE, the
|
||||
# STIG's 35-day inactivity disable requirement is N/A on the Okta side —
|
||||
# the connected directory is expected to enforce it instead.
|
||||
EXTERNAL_DIRECTORY_IDP_TYPES = {"ACTIVE_DIRECTORY", "LDAP"}
|
||||
|
||||
# Okta exposes "Workflow > Automations" as USER_LIFECYCLE policies with
|
||||
# inactivity rule conditions, not as a standalone `/api/v1/automations`
|
||||
# resource. The SDK's `UserPolicyRuleCondition.inactivity` and
|
||||
# `ScheduledUserLifecycleAction` models confirm this; the API rejects
|
||||
# every other `type` candidate.
|
||||
USER_LIFECYCLE_POLICY_TYPE = "USER_LIFECYCLE"
|
||||
|
||||
REQUIRED_SCOPES: dict[str, str] = {
|
||||
"automations": "okta.policies.read",
|
||||
"identity_providers": "okta.idps.read",
|
||||
}
|
||||
|
||||
|
||||
class User(OktaService):
|
||||
"""Fetches Okta User Lifecycle Automations and external-directory IdPs.
|
||||
|
||||
Populates:
|
||||
- `self.automations` — keyed by USER_LIFECYCLE policy rule id. Each
|
||||
entry projects the fields the 35-day inactivity check evaluates:
|
||||
identity (`id`, `name` — taken from the rule), `status`,
|
||||
`schedule_status` (inherited from the parent policy), the
|
||||
`inactivity_days` condition and `applies_to_groups` scope from the
|
||||
parent policy, and the `lifecycle_action` from the rule.
|
||||
- `self.external_directory_idps` — keyed by IdP id. Used to short
|
||||
circuit the STIG to N/A when user sourcing is delegated to an
|
||||
external directory (Active Directory, LDAP).
|
||||
|
||||
The Okta Admin Console's "Workflow > Automations" page is rendered
|
||||
on top of `USER_LIFECYCLE` policies in the Management API
|
||||
(`list_policies(type='USER_LIFECYCLE')` + `list_policy_rules(...)`).
|
||||
There is no standalone `/api/v1/automations` GET endpoint; the SDK's
|
||||
`InactivityPolicyRuleCondition`, `UserPolicyRuleCondition`, and
|
||||
`ScheduledUserLifecycleAction` models all hang off the policy API.
|
||||
|
||||
Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the
|
||||
access token's granted scopes (`provider.identity.granted_scopes`).
|
||||
Missing scopes are recorded in `self.missing_scope` so the check
|
||||
can emit an explicit MANUAL finding.
|
||||
"""
|
||||
|
||||
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.automations: dict[str, UserAutomation] = (
|
||||
{} if self.missing_scope["automations"] else self._list_automations()
|
||||
)
|
||||
self.external_directory_idps: dict[str, ExternalDirectoryIdp] = (
|
||||
{}
|
||||
if self.missing_scope["identity_providers"]
|
||||
else self._list_external_directory_idps()
|
||||
)
|
||||
|
||||
def _list_automations(self) -> dict:
|
||||
logger.info("User - Listing USER_LIFECYCLE policies and rules...")
|
||||
try:
|
||||
return self._run(self._fetch_automations())
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return {}
|
||||
|
||||
async def _fetch_automations(self) -> dict:
|
||||
result: dict[str, UserAutomation] = {}
|
||||
try:
|
||||
all_policies, err = await paginate(
|
||||
lambda after: self.client.list_policies(
|
||||
type=USER_LIFECYCLE_POLICY_TYPE, after=after
|
||||
)
|
||||
)
|
||||
except (ValueError, ValidationError) as ex:
|
||||
# Upstream okta-sdk-python bug: `Policy.from_dict` uses a
|
||||
# discriminator dispatch that maps `type` → concrete Policy
|
||||
# subclass, and `USER_LIFECYCLE` is not in the map. The SDK
|
||||
# raises ValueError ("failed to lookup discriminator value")
|
||||
# even though the API returns a valid policy. Fall back to
|
||||
# raw JSON. Remove once okta-sdk-python adds
|
||||
# USER_LIFECYCLE → UserLifecyclePolicy to the mapping.
|
||||
logger.warning(
|
||||
f"Okta SDK raised {type(ex).__name__} parsing USER_LIFECYCLE "
|
||||
"policies — falling back to raw-JSON parse. This is an "
|
||||
"okta-sdk-python deserialization bug "
|
||||
"(missing discriminator mapping)."
|
||||
)
|
||||
return await self._fetch_automations_raw()
|
||||
|
||||
if err is not None:
|
||||
logger.error(f"Error listing USER_LIFECYCLE policies: {err}")
|
||||
return result
|
||||
|
||||
for policy in all_policies:
|
||||
policy_id = getattr(policy, "id", "") or ""
|
||||
if not policy_id:
|
||||
continue
|
||||
policy_status = _stringify_enum(getattr(policy, "status", None)) or ""
|
||||
policy_name = getattr(policy, "name", "") or ""
|
||||
rules = await self._fetch_rules(policy_id)
|
||||
if rules is None:
|
||||
# Rule typed parsing tripped an SDK validator. Re-run the
|
||||
# whole automation discovery via raw JSON so we don't lose
|
||||
# the rule data for this — or any other — policy. Cheaper
|
||||
# than mixing typed and raw projections.
|
||||
logger.warning(
|
||||
f"Rule typed parsing failed for USER_LIFECYCLE policy "
|
||||
f"{policy_id} — re-running all automations via raw-JSON."
|
||||
)
|
||||
return await self._fetch_automations_raw()
|
||||
if not rules:
|
||||
# A policy with no rules exists in the Admin Console UI as
|
||||
# an "Automation" the operator hasn't finished configuring
|
||||
# (no conditions, no actions). Emit a placeholder so the
|
||||
# check FAILs with a specific message naming every missing
|
||||
# piece, instead of pretending the policy doesn't exist.
|
||||
result[policy_id] = _shell_automation(
|
||||
policy_id, policy_name, policy_status
|
||||
)
|
||||
continue
|
||||
for rule in rules:
|
||||
automation = _rule_to_automation(rule, policy)
|
||||
if automation is None:
|
||||
continue
|
||||
result[automation.id] = automation
|
||||
return result
|
||||
|
||||
async def _fetch_rules(self, policy_id: str) -> Optional[list]:
|
||||
"""Return the policy's typed rules, or None to signal raw fallback.
|
||||
|
||||
The Okta SDK's `list_policy_rules` shares the same brittle typed
|
||||
deserialization as `list_policies` (strict pydantic validators
|
||||
rejecting values the API actually returns). When that happens the
|
||||
caller can't reuse any of the typed projection for this policy —
|
||||
we return None as a sentinel and the caller re-runs the whole
|
||||
discovery via `_fetch_automations_raw`. Returning `[]` would
|
||||
otherwise misclassify the policy as an "unfinished automation"
|
||||
and FAIL it.
|
||||
"""
|
||||
rule_fetch_limit = 100
|
||||
try:
|
||||
result = await self.client.list_policy_rules(
|
||||
policy_id, limit=str(rule_fetch_limit)
|
||||
)
|
||||
except (ValueError, ValidationError) as ex:
|
||||
logger.warning(
|
||||
f"Okta SDK raised {type(ex).__name__} parsing rules for "
|
||||
f"USER_LIFECYCLE policy {policy_id} — signaling raw fallback."
|
||||
)
|
||||
return None
|
||||
err = result[-1]
|
||||
if err is not None:
|
||||
logger.error(
|
||||
f"Error listing rules for USER_LIFECYCLE policy {policy_id}: {err}"
|
||||
)
|
||||
return []
|
||||
rules = list(result[0] or [])
|
||||
if len(rules) >= rule_fetch_limit:
|
||||
logger.warning(
|
||||
f"USER_LIFECYCLE policy {policy_id} returned {len(rules)} rules — "
|
||||
f"the per-policy fetch limit ({rule_fetch_limit}) was hit; any "
|
||||
"rules beyond this limit are not evaluated."
|
||||
)
|
||||
return rules
|
||||
|
||||
async def _fetch_automations_raw(self) -> dict:
|
||||
"""Raw-JSON fallback for `list_policies(type='USER_LIFECYCLE')`.
|
||||
|
||||
Bypasses the SDK's typed deserialization via the shared
|
||||
`get_json_paginated` helper, then drains each policy's rules
|
||||
via the same path. Projects everything onto our `UserAutomation`
|
||||
snapshot which only validates the fields the check reads.
|
||||
"""
|
||||
result: dict[str, UserAutomation] = {}
|
||||
policies_data = await raw_get_json_paginated(
|
||||
self.client,
|
||||
f"/api/v1/policies?type={USER_LIFECYCLE_POLICY_TYPE}",
|
||||
page_size=200,
|
||||
context="USER_LIFECYCLE policies",
|
||||
)
|
||||
if policies_data is None:
|
||||
return result
|
||||
|
||||
for policy_dict in policies_data:
|
||||
if not isinstance(policy_dict, dict):
|
||||
continue
|
||||
policy_id = policy_dict.get("id")
|
||||
if not policy_id:
|
||||
continue
|
||||
policy_status = (policy_dict.get("status") or "").upper()
|
||||
policy_name = policy_dict.get("name") or ""
|
||||
|
||||
rules_data = await raw_get_json_paginated(
|
||||
self.client,
|
||||
f"/api/v1/policies/{policy_id}/rules",
|
||||
page_size=100,
|
||||
context=f"USER_LIFECYCLE policy {policy_id} rules",
|
||||
)
|
||||
if not rules_data:
|
||||
# No rules under the policy → emit placeholder. Same
|
||||
# rationale as the typed path: surface the unfinished
|
||||
# automation so the check can name what's missing.
|
||||
result[policy_id] = _shell_automation(
|
||||
policy_id, policy_name, policy_status
|
||||
)
|
||||
continue
|
||||
for rule_dict in rules_data:
|
||||
automation = _raw_rule_to_automation(
|
||||
rule_dict, policy_dict, policy_id, policy_name, policy_status
|
||||
)
|
||||
if automation is None:
|
||||
continue
|
||||
result[automation.id] = automation
|
||||
return result
|
||||
|
||||
def _list_external_directory_idps(self) -> dict:
|
||||
logger.info("User - Listing Okta IdPs for external-directory detection...")
|
||||
try:
|
||||
return self._run(self._fetch_external_directory_idps())
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return {}
|
||||
|
||||
async def _fetch_external_directory_idps(self) -> dict:
|
||||
result: dict[str, ExternalDirectoryIdp] = {}
|
||||
all_idps, err = await paginate(
|
||||
lambda after: self.client.list_identity_providers(after=after)
|
||||
)
|
||||
if err is not None:
|
||||
logger.error(f"Error listing identity providers: {err}")
|
||||
return result
|
||||
|
||||
for idp in all_idps:
|
||||
idp_type = _stringify_enum(getattr(idp, "type", None)) or ""
|
||||
if idp_type.upper() not in EXTERNAL_DIRECTORY_IDP_TYPES:
|
||||
continue
|
||||
idp_status = _stringify_enum(getattr(idp, "status", None)) or ""
|
||||
if idp_status.upper() != "ACTIVE":
|
||||
continue
|
||||
idp_id = getattr(idp, "id", "") or ""
|
||||
if not idp_id:
|
||||
continue
|
||||
result[idp_id] = ExternalDirectoryIdp(
|
||||
id=idp_id,
|
||||
name=getattr(idp, "name", "") or "",
|
||||
type=idp_type,
|
||||
status=idp_status,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _rule_to_automation(rule, policy) -> Optional["UserAutomation"]:
|
||||
"""Project a typed USER_LIFECYCLE policy + rule pair onto our snapshot.
|
||||
|
||||
Important: in the actual API response, an Okta "Automation" is split
|
||||
across two resources — the **inactivity condition + group scope**
|
||||
live on the *policy* (`policy.conditions.people.users.inactivity`,
|
||||
`policy.conditions.people.groups.include`), and the **lifecycle
|
||||
action** lives on the *rule* (`rule.actions.user_lifecycle.action`
|
||||
on the typed model; `updateUserLifecycle.targetStatus` on raw JSON).
|
||||
The rule's own `conditions` is typically empty. Projecting requires
|
||||
both — kept aligned with `_raw_rule_to_automation` so the two paths
|
||||
yield identical snapshots.
|
||||
"""
|
||||
rule_id = getattr(rule, "id", "") or ""
|
||||
if not rule_id:
|
||||
return None
|
||||
|
||||
policy_id = getattr(policy, "id", "") or ""
|
||||
policy_name = getattr(policy, "name", "") or ""
|
||||
policy_status = (_stringify_enum(getattr(policy, "status", None)) or "").upper()
|
||||
|
||||
# Inactivity + groups live on the POLICY in the API response.
|
||||
inactivity_days: Optional[int] = None
|
||||
applies_to_groups: list[str] = []
|
||||
conditions = getattr(policy, "conditions", None)
|
||||
people = getattr(conditions, "people", None) if conditions else None
|
||||
users = getattr(people, "users", None) if people else None
|
||||
inactivity = getattr(users, "inactivity", None) if users else None
|
||||
if inactivity is not None:
|
||||
number = getattr(inactivity, "number", None)
|
||||
unit = (_stringify_enum(getattr(inactivity, "unit", None)) or "").upper()
|
||||
if isinstance(number, int) and unit in {"DAYS", "DAY"}:
|
||||
inactivity_days = number
|
||||
groups = getattr(people, "groups", None) if people else None
|
||||
include_groups = getattr(groups, "include", None) if groups else None
|
||||
if include_groups:
|
||||
applies_to_groups = [str(g) for g in include_groups if g]
|
||||
|
||||
# Lifecycle action lives on the RULE.
|
||||
actions = getattr(rule, "actions", None)
|
||||
user_lifecycle = (
|
||||
getattr(actions, "user_lifecycle", None) if actions else None
|
||||
) or (getattr(actions, "userLifecycle", None) if actions else None)
|
||||
lifecycle_action: Optional[str] = None
|
||||
if user_lifecycle is not None:
|
||||
for attr in ("action", "status"):
|
||||
value = _stringify_enum(getattr(user_lifecycle, attr, None))
|
||||
if value:
|
||||
lifecycle_action = value.upper()
|
||||
break
|
||||
|
||||
rule_name = getattr(rule, "name", "") or policy_name or "(unnamed)"
|
||||
rule_status = _stringify_enum(getattr(rule, "status", None)) or ""
|
||||
|
||||
return UserAutomation(
|
||||
id=rule_id,
|
||||
name=rule_name,
|
||||
status=rule_status.upper(),
|
||||
schedule_status=policy_status,
|
||||
inactivity_days=inactivity_days,
|
||||
lifecycle_action=lifecycle_action,
|
||||
applies_to_groups=applies_to_groups,
|
||||
policy_id=policy_id,
|
||||
policy_name=policy_name,
|
||||
)
|
||||
|
||||
|
||||
def _raw_rule_to_automation(
|
||||
rule_dict,
|
||||
policy_dict,
|
||||
policy_id: str,
|
||||
policy_name: str,
|
||||
policy_status: str,
|
||||
) -> Optional["UserAutomation"]:
|
||||
"""Project a raw USER_LIFECYCLE policy+rule pair onto our snapshot.
|
||||
|
||||
Important: in the actual API response, an Okta "Automation" is split
|
||||
across two resources — the **inactivity condition + group scope**
|
||||
live on the *policy* (`policy.conditions.people.users.inactivity`,
|
||||
`policy.conditions.people.groups.include`), and the **lifecycle
|
||||
action** lives on the *rule*
|
||||
(`rule.actions.updateUserLifecycle.targetStatus`). The rule's own
|
||||
`conditions` is typically empty `{}`. Projecting requires both.
|
||||
|
||||
Schedule isn't exposed by the API on either resource. Okta runs an
|
||||
automation on its UI-configured schedule iff the policy is ACTIVE,
|
||||
so we treat `policy.status` as the schedule proxy.
|
||||
"""
|
||||
if not isinstance(rule_dict, dict):
|
||||
return None
|
||||
rule_id = rule_dict.get("id")
|
||||
if not rule_id:
|
||||
return None
|
||||
|
||||
# Inactivity + groups live on the POLICY in the API response.
|
||||
inactivity_days: Optional[int] = None
|
||||
applies_to_groups: list[str] = []
|
||||
if isinstance(policy_dict, dict):
|
||||
policy_conditions = policy_dict.get("conditions") or {}
|
||||
people = policy_conditions.get("people") or {}
|
||||
users = people.get("users") or {}
|
||||
inactivity = users.get("inactivity")
|
||||
if isinstance(inactivity, dict):
|
||||
number = inactivity.get("number")
|
||||
unit = (inactivity.get("unit") or "").upper()
|
||||
if isinstance(number, int) and unit in {"DAYS", "DAY"}:
|
||||
inactivity_days = number
|
||||
groups = people.get("groups") or {}
|
||||
include_groups = groups.get("include")
|
||||
if isinstance(include_groups, list):
|
||||
applies_to_groups = [str(g) for g in include_groups if g]
|
||||
|
||||
# Lifecycle action lives on the RULE under
|
||||
# `actions.updateUserLifecycle.targetStatus` (the API uses
|
||||
# "updateUserLifecycle" rather than the SDK's `user_lifecycle`).
|
||||
rule_actions = rule_dict.get("actions") or {}
|
||||
update_user_lifecycle = rule_actions.get("updateUserLifecycle") or {}
|
||||
lifecycle_action: Optional[str] = None
|
||||
if isinstance(update_user_lifecycle, dict):
|
||||
target = update_user_lifecycle.get("targetStatus")
|
||||
if isinstance(target, str) and target:
|
||||
lifecycle_action = target.upper()
|
||||
|
||||
return UserAutomation(
|
||||
id=rule_id,
|
||||
name=(rule_dict.get("name") or policy_name or "(unnamed)"),
|
||||
status=(rule_dict.get("status") or "").upper(),
|
||||
schedule_status=policy_status,
|
||||
inactivity_days=inactivity_days,
|
||||
lifecycle_action=lifecycle_action,
|
||||
applies_to_groups=applies_to_groups,
|
||||
policy_id=policy_id,
|
||||
policy_name=policy_name,
|
||||
)
|
||||
|
||||
|
||||
def _shell_automation(
|
||||
policy_id: str, policy_name: str, policy_status: str
|
||||
) -> "UserAutomation":
|
||||
"""Placeholder UserAutomation for a USER_LIFECYCLE policy with no rules.
|
||||
|
||||
Surfaces the unfinished automation in `self.automations` so the check
|
||||
can list every missing piece in its FAIL message (no inactivity
|
||||
condition, no lifecycle action, status inactive, etc.) instead of
|
||||
silently dropping the policy.
|
||||
"""
|
||||
upper_status = (policy_status or "").upper()
|
||||
return UserAutomation(
|
||||
id=policy_id,
|
||||
name=policy_name or "(unnamed automation)",
|
||||
status=upper_status,
|
||||
schedule_status=upper_status,
|
||||
inactivity_days=None,
|
||||
lifecycle_action=None,
|
||||
applies_to_groups=[],
|
||||
policy_id=policy_id,
|
||||
policy_name=policy_name,
|
||||
)
|
||||
|
||||
|
||||
def _stringify_enum(value) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
return getattr(value, "value", None) or str(value)
|
||||
|
||||
|
||||
class UserAutomation(BaseModel):
|
||||
id: str
|
||||
name: str = ""
|
||||
status: str = ""
|
||||
schedule_status: str = ""
|
||||
inactivity_days: Optional[int] = None
|
||||
lifecycle_action: Optional[str] = None
|
||||
applies_to_groups: list[str] = []
|
||||
policy_id: str = ""
|
||||
policy_name: str = ""
|
||||
|
||||
|
||||
class ExternalDirectoryIdp(BaseModel):
|
||||
id: str
|
||||
name: str = ""
|
||||
type: str = ""
|
||||
status: str = ""
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Tests for the raw-JSON HTTP helpers in
|
||||
`prowler.providers.okta.lib.service.raw_fetch`.
|
||||
|
||||
Covers `get_json` (single-shot) and `get_json_paginated`
|
||||
(drains list endpoints via the `Link: rel="next"` cursor).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.okta.lib.service.raw_fetch import (
|
||||
get_json,
|
||||
get_json_paginated,
|
||||
)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _mock_response(headers: dict = None):
|
||||
r = mock.MagicMock()
|
||||
r.headers = headers or {}
|
||||
return r
|
||||
|
||||
|
||||
class Test_get_json:
|
||||
def test_returns_parsed_json_on_success(self):
|
||||
client = mock.MagicMock()
|
||||
|
||||
async def create(*_a, **_k):
|
||||
return ({"url": "/x"}, None)
|
||||
|
||||
async def execute(_req):
|
||||
return (_mock_response(), json.dumps({"hello": "world"}), None)
|
||||
|
||||
client._request_executor.create_request = create
|
||||
client._request_executor.execute = execute
|
||||
|
||||
assert _run(get_json(client, "/x")) == {"hello": "world"}
|
||||
|
||||
def test_returns_none_on_create_request_error(self):
|
||||
client = mock.MagicMock()
|
||||
|
||||
async def create(*_a, **_k):
|
||||
return (None, Exception("boom"))
|
||||
|
||||
client._request_executor.create_request = create
|
||||
assert _run(get_json(client, "/x")) is None
|
||||
|
||||
def test_returns_none_on_execute_error(self):
|
||||
client = mock.MagicMock()
|
||||
|
||||
async def create(*_a, **_k):
|
||||
return ({"url": "/x"}, None)
|
||||
|
||||
async def execute(_req):
|
||||
return (_mock_response(), None, Exception("boom"))
|
||||
|
||||
client._request_executor.create_request = create
|
||||
client._request_executor.execute = execute
|
||||
assert _run(get_json(client, "/x")) is None
|
||||
|
||||
|
||||
class Test_get_json_paginated:
|
||||
def test_drains_all_pages_following_link_rel_next(self):
|
||||
# Two pages: first carries `Link: <…?after=cur1>; rel="next"`,
|
||||
# second has no `next`, so iteration stops.
|
||||
client = mock.MagicMock()
|
||||
|
||||
page1 = [{"id": "a"}, {"id": "b"}]
|
||||
page2 = [{"id": "c"}]
|
||||
page1_headers = {
|
||||
"link": '<https://acme.okta.com/api/v1/items?after=cur1>; rel="next"'
|
||||
}
|
||||
|
||||
seen_urls = []
|
||||
|
||||
async def create(**kwargs):
|
||||
seen_urls.append(kwargs["url"])
|
||||
return ({"url": kwargs["url"]}, None)
|
||||
|
||||
async def execute(request):
|
||||
if "after=cur1" in request["url"]:
|
||||
return (_mock_response({}), json.dumps(page2), None)
|
||||
return (_mock_response(page1_headers), json.dumps(page1), None)
|
||||
|
||||
client._request_executor.create_request = create
|
||||
client._request_executor.execute = execute
|
||||
|
||||
items = _run(get_json_paginated(client, "/api/v1/items", page_size=2))
|
||||
|
||||
assert items == [{"id": "a"}, {"id": "b"}, {"id": "c"}]
|
||||
assert len(seen_urls) == 2
|
||||
assert "limit=2" in seen_urls[0]
|
||||
# The cursor was carried into the second request.
|
||||
assert "after=cur1" in seen_urls[1]
|
||||
assert "limit=2" in seen_urls[1]
|
||||
|
||||
def test_single_page_terminates_immediately(self):
|
||||
client = mock.MagicMock()
|
||||
|
||||
async def create(**kwargs):
|
||||
return ({"url": kwargs["url"]}, None)
|
||||
|
||||
async def execute(_req):
|
||||
return (_mock_response({}), json.dumps([{"id": "only"}]), None)
|
||||
|
||||
client._request_executor.create_request = create
|
||||
client._request_executor.execute = execute
|
||||
|
||||
assert _run(get_json_paginated(client, "/api/v1/items")) == [{"id": "only"}]
|
||||
|
||||
def test_returns_none_when_response_is_not_a_list(self):
|
||||
client = mock.MagicMock()
|
||||
|
||||
async def create(**kwargs):
|
||||
return ({"url": kwargs["url"]}, None)
|
||||
|
||||
async def execute(_req):
|
||||
return (_mock_response({}), json.dumps({"error": "nope"}), None)
|
||||
|
||||
client._request_executor.create_request = create
|
||||
client._request_executor.execute = execute
|
||||
|
||||
assert _run(get_json_paginated(client, "/api/v1/items")) is None
|
||||
|
||||
def test_preserves_existing_query_string_and_overrides_limit(self):
|
||||
# Caller already passes `type=USER_LIFECYCLE` — pagination must
|
||||
# merge `limit` without clobbering existing params.
|
||||
client = mock.MagicMock()
|
||||
seen = []
|
||||
|
||||
async def create(**kwargs):
|
||||
seen.append(kwargs["url"])
|
||||
return ({"url": kwargs["url"]}, None)
|
||||
|
||||
async def execute(_req):
|
||||
return (_mock_response({}), "[]", None)
|
||||
|
||||
client._request_executor.create_request = create
|
||||
client._request_executor.execute = execute
|
||||
|
||||
_run(
|
||||
get_json_paginated(
|
||||
client, "/api/v1/policies?type=USER_LIFECYCLE", page_size=50
|
||||
)
|
||||
)
|
||||
|
||||
assert "type=USER_LIFECYCLE" in seen[0]
|
||||
assert "limit=50" in seen[0]
|
||||
@@ -16,7 +16,13 @@ def set_mocked_okta_provider(
|
||||
session = OktaSession(
|
||||
org_domain=OKTA_ORG_DOMAIN,
|
||||
client_id=OKTA_CLIENT_ID,
|
||||
scopes=["okta.policies.read", "okta.brands.read", "okta.apps.read"],
|
||||
scopes=[
|
||||
"okta.policies.read",
|
||||
"okta.brands.read",
|
||||
"okta.apps.read",
|
||||
"okta.logStreams.read",
|
||||
"okta.idps.read",
|
||||
],
|
||||
private_key=OKTA_PRIVATE_KEY,
|
||||
)
|
||||
if identity is None:
|
||||
@@ -27,6 +33,8 @@ def set_mocked_okta_provider(
|
||||
"okta.policies.read",
|
||||
"okta.brands.read",
|
||||
"okta.apps.read",
|
||||
"okta.logStreams.read",
|
||||
"okta.idps.read",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Shared helpers for `idp` service check tests."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.okta.services.idp.idp_service import OktaIdentityProvider
|
||||
from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
|
||||
|
||||
|
||||
def build_idp_client(
|
||||
identity_providers: dict = None,
|
||||
missing_scope: dict = None,
|
||||
):
|
||||
client = mock.MagicMock()
|
||||
client.identity_providers = identity_providers or {}
|
||||
client.provider = set_mocked_okta_provider()
|
||||
client.audit_config = {}
|
||||
client.missing_scope = missing_scope or {"identity_providers": None}
|
||||
return client
|
||||
|
||||
|
||||
def smart_card_idp(
|
||||
idp_id: str = "0oa-x509",
|
||||
name: str = "CAC IdP",
|
||||
status: str = "ACTIVE",
|
||||
issuer: str = "CN=DOD ROOT CA 6",
|
||||
kid: str = "kid-abc-123",
|
||||
):
|
||||
return OktaIdentityProvider(
|
||||
id=idp_id,
|
||||
name=name,
|
||||
type="X509",
|
||||
status=status,
|
||||
trust_issuer=issuer,
|
||||
trust_kid=kid,
|
||||
)
|
||||
|
||||
|
||||
def non_smart_card_idp(
|
||||
idp_id: str = "0oa-saml",
|
||||
name: str = "Corporate SAML",
|
||||
type: str = "SAML2",
|
||||
status: str = "ACTIVE",
|
||||
):
|
||||
return OktaIdentityProvider(id=idp_id, name=name, type=type, status=status)
|
||||
@@ -0,0 +1,80 @@
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from okta.models.identity_provider_protocol import IdentityProviderProtocol
|
||||
|
||||
from prowler.providers.okta.services.idp.idp_service import Idp, OktaIdentityProvider
|
||||
from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
|
||||
|
||||
|
||||
def _resp(headers: dict = None):
|
||||
r = mock.MagicMock()
|
||||
r.headers = headers or {}
|
||||
return r
|
||||
|
||||
|
||||
def _fake_idp(idp_id, name, type_, status="ACTIVE", issuer=None, kid=None):
|
||||
# Build a real `IdentityProviderProtocol` when issuer/kid are provided
|
||||
# so the test exercises the SDK's Pydantic v2 oneOf wrapper — credentials
|
||||
# live on `actual_instance`, not directly on the wrapper. MagicMock
|
||||
# auto-attribute-creation would otherwise hide a missed unwrap.
|
||||
idp = mock.MagicMock()
|
||||
idp.id = idp_id
|
||||
idp.name = name
|
||||
idp.type = type_
|
||||
idp.status = status
|
||||
if issuer is None and kid is None:
|
||||
idp.protocol = None
|
||||
else:
|
||||
idp.protocol = IdentityProviderProtocol.from_json(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "MTLS",
|
||||
"credentials": {"trust": {"issuer": issuer, "kid": kid}},
|
||||
}
|
||||
)
|
||||
)
|
||||
return idp
|
||||
|
||||
|
||||
def _patch_sdk(**methods):
|
||||
return mock.patch(
|
||||
"prowler.providers.okta.lib.service.service.OktaSDKClient",
|
||||
return_value=mock.MagicMock(**methods),
|
||||
)
|
||||
|
||||
|
||||
class Test_Idp_service:
|
||||
def test_fetches_idps_with_trust_fields(self):
|
||||
provider = set_mocked_okta_provider()
|
||||
x509 = _fake_idp(
|
||||
"0oa1",
|
||||
"CAC",
|
||||
"X509",
|
||||
issuer="CN=DOD ROOT CA 6",
|
||||
kid="kid-1",
|
||||
)
|
||||
saml = _fake_idp("0oa2", "Corp", "SAML2")
|
||||
|
||||
async def fake_list(*_a, **_k):
|
||||
return ([x509, saml], _resp({}), None)
|
||||
|
||||
with _patch_sdk(list_identity_providers=fake_list):
|
||||
service = Idp(provider)
|
||||
|
||||
assert set(service.identity_providers.keys()) == {"0oa1", "0oa2"}
|
||||
assert isinstance(service.identity_providers["0oa1"], OktaIdentityProvider)
|
||||
assert service.identity_providers["0oa1"].trust_issuer == "CN=DOD ROOT CA 6"
|
||||
assert service.identity_providers["0oa1"].trust_kid == "kid-1"
|
||||
assert service.identity_providers["0oa2"].trust_issuer is None
|
||||
|
||||
def test_returns_empty_on_api_error(self):
|
||||
provider = set_mocked_okta_provider()
|
||||
|
||||
async def failing(*_a, **_k):
|
||||
return ([], _resp({}), Exception("API failure"))
|
||||
|
||||
with _patch_sdk(list_identity_providers=failing):
|
||||
service = Idp(provider)
|
||||
|
||||
assert service.identity_providers == {}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
from unittest import mock
|
||||
|
||||
from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
|
||||
from tests.providers.okta.services.idp.idp_fixtures import (
|
||||
build_idp_client,
|
||||
non_smart_card_idp,
|
||||
smart_card_idp,
|
||||
)
|
||||
|
||||
CHECK_PATH = (
|
||||
"prowler.providers.okta.services.idp."
|
||||
"idp_smart_card_dod_approved_ca.idp_smart_card_dod_approved_ca.idp_client"
|
||||
)
|
||||
|
||||
DOD_PKI_ISSUER = "CN=DoD ID CA-59, OU=PKI, OU=DoD, O=U.S. Government, C=US"
|
||||
ECA_ISSUER = "CN=ECA Root CA 4, OU=ECA, O=U.S. Government, C=US"
|
||||
NON_DOD_ISSUER = "CN=ACME Internal Root, O=Acme Corp, C=US"
|
||||
|
||||
|
||||
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.idp.idp_smart_card_dod_approved_ca.idp_smart_card_dod_approved_ca import (
|
||||
idp_smart_card_dod_approved_ca,
|
||||
)
|
||||
|
||||
return idp_smart_card_dod_approved_ca().execute()
|
||||
|
||||
|
||||
class Test_idp_smart_card_dod_approved_ca:
|
||||
def test_pass_when_active_idp_chain_matches_dod_pki_pattern(self):
|
||||
idp = smart_card_idp(name="CAC", issuer=DOD_PKI_ISSUER, kid="kid-x")
|
||||
client = build_idp_client(identity_providers={idp.id: idp})
|
||||
findings = _run_check(client)
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "OU=DoD" in findings[0].status_extended
|
||||
assert DOD_PKI_ISSUER in findings[0].status_extended
|
||||
|
||||
def test_pass_when_active_idp_chain_matches_eca_pattern(self):
|
||||
idp = smart_card_idp(name="ECA Partner", issuer=ECA_ISSUER, kid="kid-e")
|
||||
client = build_idp_client(identity_providers={idp.id: idp})
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "PASS"
|
||||
assert "OU=ECA" in findings[0].status_extended
|
||||
|
||||
def test_manual_when_active_but_issuer_does_not_match_any_pattern(self):
|
||||
idp = smart_card_idp(name="Custom", issuer=NON_DOD_ISSUER, kid="kid-c")
|
||||
client = build_idp_client(identity_providers={idp.id: idp})
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "MANUAL"
|
||||
assert NON_DOD_ISSUER in findings[0].status_extended
|
||||
assert "okta_dod_approved_ca_issuer_patterns" in findings[0].status_extended
|
||||
|
||||
def test_pass_when_audit_config_pattern_matches(self):
|
||||
idp = smart_card_idp(name="Custom DOD", issuer=NON_DOD_ISSUER, kid="kid-c")
|
||||
client = build_idp_client(identity_providers={idp.id: idp})
|
||||
client.audit_config = {
|
||||
"okta_dod_approved_ca_issuer_patterns": [r"CN=ACME Internal Root"]
|
||||
}
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "PASS"
|
||||
|
||||
def test_audit_config_overrides_bundled_defaults(self):
|
||||
# When the operator supplies a list, the bundled DEFAULT patterns
|
||||
# are replaced (not merged) so customers can carve out a strict set.
|
||||
idp = smart_card_idp(name="DoD", issuer=DOD_PKI_ISSUER, kid="kid-x")
|
||||
client = build_idp_client(identity_providers={idp.id: idp})
|
||||
client.audit_config = {
|
||||
"okta_dod_approved_ca_issuer_patterns": [r"CN=YourTenantCustomDodCA"]
|
||||
}
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "MANUAL"
|
||||
|
||||
def test_malformed_audit_config_pattern_skipped(self):
|
||||
# An invalid regex from the operator must not crash the whole check.
|
||||
idp = smart_card_idp(name="CAC", issuer=DOD_PKI_ISSUER, kid="kid-x")
|
||||
client = build_idp_client(identity_providers={idp.id: idp})
|
||||
client.audit_config = {
|
||||
"okta_dod_approved_ca_issuer_patterns": [r"[invalid(regex", r"OU=DoD"]
|
||||
}
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "PASS"
|
||||
|
||||
def test_fail_when_x509_idp_is_inactive(self):
|
||||
idp = smart_card_idp(status="INACTIVE", issuer=DOD_PKI_ISSUER)
|
||||
client = build_idp_client(identity_providers={idp.id: idp})
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "INACTIVE" in findings[0].status_extended
|
||||
|
||||
def test_fail_when_no_smart_card_idp_configured(self):
|
||||
client = build_idp_client(identity_providers={"saml": non_smart_card_idp()})
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "FAIL"
|
||||
assert (
|
||||
"No Smart Card (X509) Identity Providers are configured"
|
||||
in findings[0].status_extended
|
||||
)
|
||||
assert "mutelist" in findings[0].status_extended
|
||||
|
||||
def test_manual_when_idps_scope_missing(self):
|
||||
client = build_idp_client(
|
||||
missing_scope={"identity_providers": "okta.idps.read"}
|
||||
)
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "MANUAL"
|
||||
assert "okta.idps.read" in findings[0].status_extended
|
||||
|
||||
def test_multiple_x509_idps_yield_one_finding_each(self):
|
||||
idp_a = smart_card_idp(idp_id="0oa-a", name="A", issuer=DOD_PKI_ISSUER)
|
||||
idp_b = smart_card_idp(
|
||||
idp_id="0oa-b", name="B", status="INACTIVE", issuer=DOD_PKI_ISSUER
|
||||
)
|
||||
client = build_idp_client(identity_providers={idp_a.id: idp_a, idp_b.id: idp_b})
|
||||
findings = _run_check(client)
|
||||
assert len(findings) == 2
|
||||
# We don't strictly assert ordering — just that both are covered.
|
||||
statuses = sorted(f.status for f in findings)
|
||||
assert statuses == ["FAIL", "PASS"]
|
||||
@@ -1,12 +1,14 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.okta.lib.service.pagination import (
|
||||
next_after_cursor as _next_after_cursor,
|
||||
)
|
||||
from prowler.providers.okta.models import OktaIdentityInfo
|
||||
from prowler.providers.okta.services.signon.signon_service import (
|
||||
GlobalSessionPolicy,
|
||||
GlobalSessionPolicyRule,
|
||||
SignInPage,
|
||||
Signon,
|
||||
_next_after_cursor,
|
||||
)
|
||||
from tests.providers.okta.okta_fixtures import (
|
||||
OKTA_CLIENT_ID,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Shared helpers for `systemlog` service check tests."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.okta.services.systemlog.systemlog_service import LogStream
|
||||
from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
|
||||
|
||||
|
||||
def build_systemlog_client(
|
||||
log_streams: dict = None,
|
||||
missing_scope: dict = None,
|
||||
):
|
||||
client = mock.MagicMock()
|
||||
client.log_streams = log_streams or {}
|
||||
client.provider = set_mocked_okta_provider()
|
||||
client.audit_config = {}
|
||||
client.missing_scope = missing_scope or {"log_streams": None}
|
||||
return client
|
||||
|
||||
|
||||
def log_stream(
|
||||
stream_id: str = "log-1",
|
||||
name: str = "EventBridge stream",
|
||||
status: str = "ACTIVE",
|
||||
type: str = "AWS_EVENTBRIDGE",
|
||||
):
|
||||
return LogStream(id=stream_id, name=name, status=status, type=type)
|
||||
@@ -0,0 +1,185 @@
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.okta.models import OktaIdentityInfo
|
||||
from prowler.providers.okta.services.systemlog.systemlog_service import (
|
||||
LogStream,
|
||||
SystemLog,
|
||||
)
|
||||
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_stream(
|
||||
stream_id: str, name: str, status: str = "ACTIVE", type_: str = "AWS_EVENTBRIDGE"
|
||||
):
|
||||
s = mock.MagicMock()
|
||||
s.id = stream_id
|
||||
s.name = name
|
||||
s.status = status
|
||||
s.type = type_
|
||||
return s
|
||||
|
||||
|
||||
def _patch_sdk(**methods):
|
||||
return mock.patch(
|
||||
"prowler.providers.okta.lib.service.service.OktaSDKClient",
|
||||
return_value=mock.MagicMock(**methods),
|
||||
)
|
||||
|
||||
|
||||
class Test_SystemLog_service:
|
||||
def test_fetches_active_streams(self):
|
||||
provider = set_mocked_okta_provider()
|
||||
s1 = _fake_stream("log-1", "EventBridge")
|
||||
s2 = _fake_stream("log-2", "Splunk", type_="SPLUNK_CLOUD_LOGSTREAMING")
|
||||
|
||||
async def fake_list(*_a, **_k):
|
||||
return ([s1, s2], _resp({}), None)
|
||||
|
||||
with _patch_sdk(list_log_streams=fake_list):
|
||||
service = SystemLog(provider)
|
||||
|
||||
assert set(service.log_streams.keys()) == {"log-1", "log-2"}
|
||||
assert isinstance(service.log_streams["log-1"], LogStream)
|
||||
assert service.log_streams["log-2"].type == "SPLUNK_CLOUD_LOGSTREAMING"
|
||||
|
||||
def test_returns_empty_on_api_error(self):
|
||||
provider = set_mocked_okta_provider()
|
||||
|
||||
async def failing(*_a, **_k):
|
||||
return ([], _resp({}), Exception("E0000007"))
|
||||
|
||||
with _patch_sdk(list_log_streams=failing):
|
||||
service = SystemLog(provider)
|
||||
|
||||
assert service.log_streams == {}
|
||||
|
||||
def test_skips_fetch_when_scope_missing(self):
|
||||
identity = OktaIdentityInfo(
|
||||
org_domain=OKTA_ORG_DOMAIN,
|
||||
client_id=OKTA_CLIENT_ID,
|
||||
granted_scopes=["okta.policies.read"], # no logStreams scope
|
||||
)
|
||||
provider = set_mocked_okta_provider(identity=identity)
|
||||
|
||||
called = False
|
||||
|
||||
async def fake_list(*_a, **_k):
|
||||
nonlocal called
|
||||
called = True
|
||||
return ([], _resp({}), None)
|
||||
|
||||
with _patch_sdk(list_log_streams=fake_list):
|
||||
service = SystemLog(provider)
|
||||
|
||||
assert called is False
|
||||
assert service.log_streams == {}
|
||||
assert service.missing_scope["log_streams"] == "okta.logStreams.read"
|
||||
|
||||
|
||||
class Test_SystemLog_service_sdk_validation_fallback:
|
||||
"""Verifies the raw-JSON fallback when the Okta SDK rejects API values.
|
||||
|
||||
The SDK's `LogStreamSettingsAws.eventSourceName` validator uses the
|
||||
regex `^[a-zA-Z0-9.\\-_]$` — missing the `+` quantifier, so every
|
||||
multi-character name raises pydantic `ValidationError`. Without the
|
||||
fallback the whole stream list is lost; with it, the raw JSON path
|
||||
still surfaces each stream's id/name/status/type.
|
||||
"""
|
||||
|
||||
def test_raw_fallback_projects_streams_when_sdk_raises(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
provider = set_mocked_okta_provider()
|
||||
|
||||
raw_payload = [
|
||||
{
|
||||
"id": "log-1",
|
||||
"name": "EventBridge prod",
|
||||
"status": "ACTIVE",
|
||||
"type": "AWS_EVENTBRIDGE",
|
||||
},
|
||||
{
|
||||
"id": "log-2",
|
||||
"name": "Splunk staging",
|
||||
"status": "INACTIVE",
|
||||
"type": "SPLUNK_CLOUD_LOGSTREAMING",
|
||||
},
|
||||
]
|
||||
|
||||
async def failing_list_log_streams(*_a, **_k):
|
||||
try:
|
||||
# Trigger a real pydantic ValidationError so we exercise
|
||||
# the exact exception type the SDK raises in production.
|
||||
from okta.models.log_stream_settings_aws import LogStreamSettingsAws
|
||||
|
||||
LogStreamSettingsAws(
|
||||
accountId="123456789012",
|
||||
eventSourceName="MultiCharacter",
|
||||
region="us-east-1",
|
||||
)
|
||||
except ValidationError as ve:
|
||||
raise ve
|
||||
return ([], _resp({}), None)
|
||||
|
||||
async def fake_raw_create(*_a, **_k):
|
||||
return ({"url": "/api/v1/logStreams"}, None)
|
||||
|
||||
async def fake_raw_execute(_request):
|
||||
return (None, json.dumps(raw_payload), None)
|
||||
|
||||
sdk = mock.MagicMock()
|
||||
sdk.list_log_streams = failing_list_log_streams
|
||||
sdk._request_executor.create_request = fake_raw_create
|
||||
sdk._request_executor.execute = fake_raw_execute
|
||||
|
||||
with mock.patch(
|
||||
"prowler.providers.okta.lib.service.service.OktaSDKClient",
|
||||
return_value=sdk,
|
||||
):
|
||||
service = SystemLog(provider)
|
||||
|
||||
assert set(service.log_streams.keys()) == {"log-1", "log-2"}
|
||||
assert service.log_streams["log-1"].status == "ACTIVE"
|
||||
assert service.log_streams["log-2"].status == "INACTIVE"
|
||||
assert service.log_streams["log-2"].type == "SPLUNK_CLOUD_LOGSTREAMING"
|
||||
|
||||
def test_raw_fallback_handles_empty_list(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
provider = set_mocked_okta_provider()
|
||||
|
||||
async def failing(*_a, **_k):
|
||||
raise ValidationError.from_exception_data(
|
||||
title="LogStreamSettingsAws",
|
||||
line_errors=[],
|
||||
)
|
||||
|
||||
async def fake_create(*_a, **_k):
|
||||
return ({"url": "/api/v1/logStreams"}, None)
|
||||
|
||||
async def fake_execute(_req):
|
||||
return (None, "[]", None)
|
||||
|
||||
sdk = mock.MagicMock()
|
||||
sdk.list_log_streams = failing
|
||||
sdk._request_executor.create_request = fake_create
|
||||
sdk._request_executor.execute = fake_execute
|
||||
|
||||
with mock.patch(
|
||||
"prowler.providers.okta.lib.service.service.OktaSDKClient",
|
||||
return_value=sdk,
|
||||
):
|
||||
service = SystemLog(provider)
|
||||
|
||||
assert service.log_streams == {}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
from unittest import mock
|
||||
|
||||
from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
|
||||
from tests.providers.okta.services.systemlog.systemlog_fixtures import (
|
||||
build_systemlog_client,
|
||||
log_stream,
|
||||
)
|
||||
|
||||
CHECK_PATH = (
|
||||
"prowler.providers.okta.services.systemlog."
|
||||
"systemlog_streaming_enabled.systemlog_streaming_enabled.systemlog_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.systemlog.systemlog_streaming_enabled.systemlog_streaming_enabled import (
|
||||
systemlog_streaming_enabled,
|
||||
)
|
||||
|
||||
return systemlog_streaming_enabled().execute()
|
||||
|
||||
|
||||
class Test_systemlog_streaming_enabled:
|
||||
def test_pass_when_active_stream_exists(self):
|
||||
client = build_systemlog_client(
|
||||
log_streams={"log-1": log_stream(name="EventBridge prod")}
|
||||
)
|
||||
findings = _run_check(client)
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
assert "EventBridge prod" in findings[0].status_extended
|
||||
|
||||
def test_pass_when_multiple_active_streams(self):
|
||||
client = build_systemlog_client(
|
||||
log_streams={
|
||||
"log-1": log_stream(stream_id="log-1", name="A"),
|
||||
"log-2": log_stream(stream_id="log-2", name="B"),
|
||||
}
|
||||
)
|
||||
findings = _run_check(client)
|
||||
assert len(findings) == 2
|
||||
assert all(f.status == "PASS" for f in findings)
|
||||
|
||||
def test_fail_when_all_streams_inactive(self):
|
||||
client = build_systemlog_client(
|
||||
log_streams={"log-1": log_stream(name="A", status="INACTIVE")}
|
||||
)
|
||||
findings = _run_check(client)
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "none are ACTIVE" in findings[0].status_extended
|
||||
|
||||
def test_fail_when_no_streams_configured(self):
|
||||
client = build_systemlog_client(log_streams={})
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "No Okta Log Streams are configured" in findings[0].status_extended
|
||||
assert "mutelist" in findings[0].status_extended
|
||||
|
||||
def test_manual_when_scope_missing(self):
|
||||
client = build_systemlog_client(
|
||||
missing_scope={"log_streams": "okta.logStreams.read"}
|
||||
)
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "MANUAL"
|
||||
assert "okta.logStreams.read" in findings[0].status_extended
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Shared helpers for `user` service check tests."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.okta.services.user.user_service import (
|
||||
ExternalDirectoryIdp,
|
||||
UserAutomation,
|
||||
)
|
||||
from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
|
||||
|
||||
|
||||
def build_user_client(
|
||||
automations: dict = None,
|
||||
external_directory_idps: dict = None,
|
||||
audit_config: dict = None,
|
||||
missing_scope: dict = None,
|
||||
):
|
||||
client = mock.MagicMock()
|
||||
client.automations = automations or {}
|
||||
client.external_directory_idps = external_directory_idps or {}
|
||||
client.provider = set_mocked_okta_provider()
|
||||
client.audit_config = audit_config or {}
|
||||
client.missing_scope = missing_scope or {
|
||||
"automations": None,
|
||||
"identity_providers": None,
|
||||
}
|
||||
return client
|
||||
|
||||
|
||||
def automation(
|
||||
automation_id: str = "auto-1",
|
||||
name: str = "User Inactivity",
|
||||
status: str = "ACTIVE",
|
||||
schedule_status: str = "ACTIVE",
|
||||
inactivity_days: int = 35,
|
||||
lifecycle_action: str = "SUSPENDED",
|
||||
groups: list = None,
|
||||
):
|
||||
# `groups is None` keeps the "Everyone-equivalent" default; passing
|
||||
# `groups=[]` lets a test exercise the empty-scope FAIL path.
|
||||
return UserAutomation(
|
||||
id=automation_id,
|
||||
name=name,
|
||||
status=status,
|
||||
schedule_status=schedule_status,
|
||||
inactivity_days=inactivity_days,
|
||||
lifecycle_action=lifecycle_action,
|
||||
applies_to_groups=["everyone"] if groups is None else groups,
|
||||
)
|
||||
|
||||
|
||||
def ad_idp(idp_id: str = "0oa-ad", name: str = "Corp AD"):
|
||||
return ExternalDirectoryIdp(
|
||||
id=idp_id, name=name, type="ACTIVE_DIRECTORY", status="ACTIVE"
|
||||
)
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
from unittest import mock
|
||||
|
||||
from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
|
||||
from tests.providers.okta.services.user.user_fixtures import (
|
||||
ad_idp,
|
||||
automation,
|
||||
build_user_client,
|
||||
)
|
||||
|
||||
CHECK_PATH = (
|
||||
"prowler.providers.okta.services.user."
|
||||
"user_inactivity_automation_35d_enabled."
|
||||
"user_inactivity_automation_35d_enabled.user_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.user.user_inactivity_automation_35d_enabled.user_inactivity_automation_35d_enabled import (
|
||||
user_inactivity_automation_35d_enabled,
|
||||
)
|
||||
|
||||
return user_inactivity_automation_35d_enabled().execute()
|
||||
|
||||
|
||||
class Test_user_inactivity_automation_35d_enabled:
|
||||
def test_pass_when_compliant_automation_present(self):
|
||||
client = build_user_client(
|
||||
automations={"auto-1": automation(name="Inactivity 35d")}
|
||||
)
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "PASS"
|
||||
assert "Inactivity 35d" in findings[0].status_extended
|
||||
assert "SUSPENDED" in findings[0].status_extended
|
||||
|
||||
def test_pass_message_names_groups_and_asks_for_coverage_verification(self):
|
||||
# Okta has no built-in Everyone group ID and group names vary by
|
||||
# tenant (e.g. "pepito"), so we can't assert tenant-wide coverage
|
||||
# automatically — surface the group IDs and let the operator verify.
|
||||
client = build_user_client(
|
||||
automations={"auto-1": automation(groups=["grp-A", "grp-B"])}
|
||||
)
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "PASS"
|
||||
assert "grp-A, grp-B" in findings[0].status_extended
|
||||
assert "cover every user" in findings[0].status_extended
|
||||
|
||||
def test_fail_when_applies_to_no_group(self):
|
||||
# An automation with empty `people.groups.include` runs against
|
||||
# nobody — Okta does not implicitly cover every user.
|
||||
client = build_user_client(automations={"auto-1": automation(groups=[])})
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "no group scope" in findings[0].status_extended
|
||||
|
||||
def test_pass_when_lower_threshold(self):
|
||||
# Inactivity threshold lower than the default is still compliant.
|
||||
client = build_user_client(
|
||||
automations={"auto-1": automation(inactivity_days=14)}
|
||||
)
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "PASS"
|
||||
|
||||
def test_fail_when_threshold_too_high(self):
|
||||
client = build_user_client(
|
||||
automations={"auto-1": automation(inactivity_days=90)}
|
||||
)
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "inactivity 90d (max 35d)" in findings[0].status_extended
|
||||
|
||||
def test_fail_when_status_inactive(self):
|
||||
client = build_user_client(
|
||||
automations={"auto-1": automation(status="INACTIVE")}
|
||||
)
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "status INACTIVE" in findings[0].status_extended
|
||||
|
||||
def test_fail_when_schedule_inactive(self):
|
||||
client = build_user_client(
|
||||
automations={"auto-1": automation(schedule_status="INACTIVE")}
|
||||
)
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "schedule INACTIVE" in findings[0].status_extended
|
||||
|
||||
def test_fail_when_wrong_lifecycle_action(self):
|
||||
client = build_user_client(
|
||||
automations={"auto-1": automation(lifecycle_action="ACTIVE")}
|
||||
)
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "action ACTIVE" in findings[0].status_extended
|
||||
|
||||
def test_fail_when_no_automations(self):
|
||||
client = build_user_client(automations={})
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "No Okta Workflows automations" in findings[0].status_extended
|
||||
|
||||
def test_fail_lists_every_missing_piece_for_unfinished_automation(self):
|
||||
# Mirrors the real-world case where an admin clicks "Add Automation"
|
||||
# in the UI but never configures conditions or actions. The service
|
||||
# emits a placeholder UserAutomation so the check FAILs with a
|
||||
# specific message instead of pretending the policy doesn't exist.
|
||||
from prowler.providers.okta.services.user.user_service import UserAutomation
|
||||
|
||||
shell = UserAutomation(
|
||||
id="pol-1",
|
||||
name="TestCheck",
|
||||
status="INACTIVE",
|
||||
schedule_status="INACTIVE",
|
||||
inactivity_days=None,
|
||||
lifecycle_action=None,
|
||||
applies_to_groups=[],
|
||||
policy_id="pol-1",
|
||||
policy_name="TestCheck",
|
||||
)
|
||||
client = build_user_client(automations={"pol-1": shell})
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "FAIL"
|
||||
msg = findings[0].status_extended
|
||||
assert "TestCheck" in msg
|
||||
assert "status INACTIVE" in msg
|
||||
assert "schedule INACTIVE" in msg
|
||||
assert "no inactivity condition" in msg
|
||||
assert "action unset" in msg
|
||||
|
||||
def test_manual_na_when_external_directory_idp_present(self):
|
||||
client = build_user_client(
|
||||
automations={"auto-1": automation(inactivity_days=90)}, # non-compliant
|
||||
external_directory_idps={"0oa-ad": ad_idp(name="Corp AD")},
|
||||
)
|
||||
findings = _run_check(client)
|
||||
# External directory short-circuits to MANUAL N/A regardless of
|
||||
# the automations state.
|
||||
assert findings[0].status == "MANUAL"
|
||||
assert "ACTIVE_DIRECTORY" in findings[0].status_extended
|
||||
assert "Corp AD" in findings[0].status_extended
|
||||
|
||||
def test_manual_when_scope_missing(self):
|
||||
client = build_user_client(
|
||||
missing_scope={
|
||||
"automations": "okta.policies.read",
|
||||
"identity_providers": None,
|
||||
}
|
||||
)
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "MANUAL"
|
||||
assert "okta.policies.read" in findings[0].status_extended
|
||||
|
||||
def test_threshold_overridden_via_audit_config(self):
|
||||
client = build_user_client(
|
||||
automations={"auto-1": automation(inactivity_days=60)},
|
||||
audit_config={"okta_user_inactivity_max_days": 90},
|
||||
)
|
||||
findings = _run_check(client)
|
||||
assert findings[0].status == "PASS"
|
||||
@@ -0,0 +1,477 @@
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.okta.services.user.user_service import (
|
||||
ExternalDirectoryIdp,
|
||||
User,
|
||||
UserAutomation,
|
||||
_raw_rule_to_automation,
|
||||
_rule_to_automation,
|
||||
)
|
||||
from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
|
||||
|
||||
|
||||
def _resp(headers: dict = None):
|
||||
r = mock.MagicMock()
|
||||
r.headers = headers or {}
|
||||
return r
|
||||
|
||||
|
||||
def _fake_policy(
|
||||
policy_id,
|
||||
name="Inactivity Policy",
|
||||
status="ACTIVE",
|
||||
inactivity_days=35,
|
||||
inactivity_unit="DAYS",
|
||||
groups=None,
|
||||
):
|
||||
# In the actual API response, the inactivity condition and the
|
||||
# group scope live on the *policy*, not on its rules — keep the
|
||||
# typed fixture aligned with that shape so it mirrors raw JSON.
|
||||
p = mock.MagicMock()
|
||||
p.id = policy_id
|
||||
p.name = name
|
||||
p.status = status
|
||||
if inactivity_days is None:
|
||||
p.conditions.people.users.inactivity = None
|
||||
else:
|
||||
p.conditions.people.users.inactivity.number = inactivity_days
|
||||
p.conditions.people.users.inactivity.unit = inactivity_unit
|
||||
p.conditions.people.groups.include = ["everyone"] if groups is None else groups
|
||||
return p
|
||||
|
||||
|
||||
def _fake_rule(
|
||||
rule_id="rule-1",
|
||||
name="Inactivity",
|
||||
status="ACTIVE",
|
||||
lifecycle_action="SUSPENDED",
|
||||
):
|
||||
# A USER_LIFECYCLE policy rule carries only the lifecycle action;
|
||||
# its `conditions` is typically empty.
|
||||
r = mock.MagicMock()
|
||||
r.id = rule_id
|
||||
r.name = name
|
||||
r.status = status
|
||||
r.actions.user_lifecycle.action = lifecycle_action
|
||||
return r
|
||||
|
||||
|
||||
def _fake_idp(idp_type, status="ACTIVE", idp_id="0oa-1", name="x"):
|
||||
idp = mock.MagicMock()
|
||||
idp.id = idp_id
|
||||
idp.name = name
|
||||
idp.type = idp_type
|
||||
idp.status = status
|
||||
return idp
|
||||
|
||||
|
||||
def _patch_sdk(**methods):
|
||||
return mock.patch(
|
||||
"prowler.providers.okta.lib.service.service.OktaSDKClient",
|
||||
return_value=mock.MagicMock(**methods),
|
||||
)
|
||||
|
||||
|
||||
class Test_rule_to_automation:
|
||||
def test_parses_inactivity_and_lifecycle(self):
|
||||
policy = _fake_policy("pol-1", name="Inactivity Policy")
|
||||
rule = _fake_rule(rule_id="rule-1", name="Inactivity")
|
||||
m = _rule_to_automation(rule, policy)
|
||||
assert isinstance(m, UserAutomation)
|
||||
assert m.id == "rule-1"
|
||||
assert m.status == "ACTIVE"
|
||||
assert m.schedule_status == "ACTIVE"
|
||||
assert m.inactivity_days == 35
|
||||
assert m.lifecycle_action == "SUSPENDED"
|
||||
assert m.applies_to_groups == ["everyone"]
|
||||
assert m.policy_id == "pol-1"
|
||||
assert m.policy_name == "Inactivity Policy"
|
||||
|
||||
def test_returns_none_when_id_missing(self):
|
||||
policy = _fake_policy("pol")
|
||||
bad = _fake_rule()
|
||||
bad.id = ""
|
||||
assert _rule_to_automation(bad, policy) is None
|
||||
|
||||
def test_ignores_non_days_unit(self):
|
||||
policy = _fake_policy("pol", inactivity_unit="WEEKS")
|
||||
rule = _fake_rule()
|
||||
m = _rule_to_automation(rule, policy)
|
||||
assert m.inactivity_days is None
|
||||
|
||||
def test_reads_inactivity_and_groups_from_policy_not_rule(self):
|
||||
# The typed path used to read inactivity/groups from the rule;
|
||||
# an SDK update that started populating `policy.conditions`
|
||||
# exposed the mismatch. Locking the policy-shaped projection in.
|
||||
policy = _fake_policy("pol", inactivity_days=21, groups=["grp-x"])
|
||||
rule = _fake_rule()
|
||||
# Sanity: nothing inactivity-ish on the rule.
|
||||
del rule.conditions
|
||||
m = _rule_to_automation(rule, policy)
|
||||
assert m.inactivity_days == 21
|
||||
assert m.applies_to_groups == ["grp-x"]
|
||||
|
||||
|
||||
class Test_User_service:
|
||||
def test_fetches_automations_via_policy_api(self):
|
||||
provider = set_mocked_okta_provider()
|
||||
policy = _fake_policy("pol-1")
|
||||
rule = _fake_rule(rule_id="rule-1")
|
||||
|
||||
async def fake_list_policies(*_a, **_k):
|
||||
return ([policy], _resp({}), None)
|
||||
|
||||
async def fake_list_rules(*_a, **_k):
|
||||
return ([rule], _resp({}), None)
|
||||
|
||||
async def fake_list_idps(*_a, **_k):
|
||||
return ([], _resp({}), None)
|
||||
|
||||
sdk = mock.MagicMock()
|
||||
sdk.list_policies = fake_list_policies
|
||||
sdk.list_policy_rules = fake_list_rules
|
||||
sdk.list_identity_providers = fake_list_idps
|
||||
|
||||
with mock.patch(
|
||||
"prowler.providers.okta.lib.service.service.OktaSDKClient",
|
||||
return_value=sdk,
|
||||
):
|
||||
service = User(provider)
|
||||
|
||||
assert "rule-1" in service.automations
|
||||
assert service.automations["rule-1"].inactivity_days == 35
|
||||
assert service.external_directory_idps == {}
|
||||
|
||||
def test_returns_empty_on_policies_api_error(self):
|
||||
provider = set_mocked_okta_provider()
|
||||
|
||||
async def failing(*_a, **_k):
|
||||
return ([], _resp({}), Exception("E0000007"))
|
||||
|
||||
async def fake_list_idps(*_a, **_k):
|
||||
return ([], _resp({}), None)
|
||||
|
||||
sdk = mock.MagicMock()
|
||||
sdk.list_policies = failing
|
||||
sdk.list_identity_providers = fake_list_idps
|
||||
|
||||
with mock.patch(
|
||||
"prowler.providers.okta.lib.service.service.OktaSDKClient",
|
||||
return_value=sdk,
|
||||
):
|
||||
service = User(provider)
|
||||
|
||||
assert service.automations == {}
|
||||
|
||||
def test_detects_external_directory_idp(self):
|
||||
provider = set_mocked_okta_provider()
|
||||
|
||||
async def empty_policies(*_a, **_k):
|
||||
return ([], _resp({}), None)
|
||||
|
||||
ad = _fake_idp("ACTIVE_DIRECTORY", idp_id="0oa-ad", name="Corp AD")
|
||||
saml = _fake_idp("SAML2", idp_id="0oa-saml")
|
||||
|
||||
async def fake_list_idps(*_a, **_k):
|
||||
return ([ad, saml], _resp({}), None)
|
||||
|
||||
sdk = mock.MagicMock()
|
||||
sdk.list_policies = empty_policies
|
||||
sdk.list_identity_providers = fake_list_idps
|
||||
|
||||
with mock.patch(
|
||||
"prowler.providers.okta.lib.service.service.OktaSDKClient",
|
||||
return_value=sdk,
|
||||
):
|
||||
service = User(provider)
|
||||
|
||||
assert "0oa-ad" in service.external_directory_idps
|
||||
assert "0oa-saml" not in service.external_directory_idps
|
||||
assert isinstance(
|
||||
service.external_directory_idps["0oa-ad"], ExternalDirectoryIdp
|
||||
)
|
||||
|
||||
|
||||
class Test_raw_rule_to_automation:
|
||||
def test_projects_inactivity_and_lifecycle(self):
|
||||
# Real API shape: inactivity + groups live on the POLICY,
|
||||
# lifecycle action lives on the RULE under
|
||||
# `actions.updateUserLifecycle.targetStatus`.
|
||||
policy = {
|
||||
"id": "pol-1",
|
||||
"name": "TestCheck",
|
||||
"status": "ACTIVE",
|
||||
"conditions": {
|
||||
"people": {
|
||||
"users": {"inactivity": {"number": 35, "unit": "DAYS"}},
|
||||
"groups": {"include": ["everyone"]},
|
||||
}
|
||||
},
|
||||
"type": "USER_LIFECYCLE",
|
||||
}
|
||||
rule = {
|
||||
"id": "rule-1",
|
||||
"name": "lifecycle-rule-1",
|
||||
"status": "ACTIVE",
|
||||
"conditions": {},
|
||||
"actions": {
|
||||
"updateUserLifecycle": {
|
||||
"targetStatus": "SUSPENDED",
|
||||
"quietPeriod": {"number": 0, "unit": "DAYS"},
|
||||
}
|
||||
},
|
||||
}
|
||||
m = _raw_rule_to_automation(rule, policy, "pol-1", "TestCheck", "ACTIVE")
|
||||
assert isinstance(m, UserAutomation)
|
||||
assert m.id == "rule-1"
|
||||
assert m.status == "ACTIVE"
|
||||
assert m.schedule_status == "ACTIVE"
|
||||
assert m.inactivity_days == 35
|
||||
assert m.lifecycle_action == "SUSPENDED"
|
||||
assert m.applies_to_groups == ["everyone"]
|
||||
assert m.policy_id == "pol-1"
|
||||
assert m.policy_name == "TestCheck"
|
||||
|
||||
def test_returns_none_when_id_missing(self):
|
||||
assert _raw_rule_to_automation({"name": "x"}, {}, "pol", "P", "ACTIVE") is None
|
||||
|
||||
def test_ignores_non_days_unit(self):
|
||||
policy = {
|
||||
"id": "pol",
|
||||
"conditions": {
|
||||
"people": {"users": {"inactivity": {"number": 5, "unit": "WEEKS"}}}
|
||||
},
|
||||
}
|
||||
rule = {"id": "rule-2", "actions": {}}
|
||||
m = _raw_rule_to_automation(rule, policy, "pol", "P", "ACTIVE")
|
||||
assert m.inactivity_days is None
|
||||
|
||||
def test_missing_policy_dict_gives_empty_inactivity_and_groups(self):
|
||||
rule = {
|
||||
"id": "rule-3",
|
||||
"actions": {"updateUserLifecycle": {"targetStatus": "SUSPENDED"}},
|
||||
}
|
||||
m = _raw_rule_to_automation(rule, None, "pol", "P", "ACTIVE")
|
||||
assert m.inactivity_days is None
|
||||
assert m.applies_to_groups == []
|
||||
assert m.lifecycle_action == "SUSPENDED"
|
||||
|
||||
|
||||
class Test_User_service_sdk_discriminator_fallback:
|
||||
"""Verifies the raw-JSON fallback when the SDK can't deserialize USER_LIFECYCLE.
|
||||
|
||||
Okta SDK 3.4.2 ships a `Policy.from_dict` discriminator mapping that
|
||||
omits `USER_LIFECYCLE`, so the typed call raises ValueError. Without
|
||||
the fallback the whole automations list is lost; with it the raw
|
||||
JSON path projects each rule onto a `UserAutomation` snapshot.
|
||||
"""
|
||||
|
||||
def test_raw_fallback_projects_user_lifecycle_policy_rules(self):
|
||||
provider = set_mocked_okta_provider()
|
||||
|
||||
# Real API shape: inactivity + groups on POLICY, lifecycle
|
||||
# action on RULE under `actions.updateUserLifecycle.targetStatus`.
|
||||
policy_payload = [
|
||||
{
|
||||
"id": "pol-1",
|
||||
"name": "TestCheck",
|
||||
"status": "ACTIVE",
|
||||
"type": "USER_LIFECYCLE",
|
||||
"conditions": {
|
||||
"people": {
|
||||
"users": {"inactivity": {"number": 35, "unit": "DAYS"}},
|
||||
"groups": {"include": ["everyone"]},
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
rules_payload = [
|
||||
{
|
||||
"id": "rule-1",
|
||||
"name": "lifecycle-rule-1",
|
||||
"status": "ACTIVE",
|
||||
"conditions": {},
|
||||
"actions": {
|
||||
"updateUserLifecycle": {
|
||||
"targetStatus": "SUSPENDED",
|
||||
"quietPeriod": {"number": 0, "unit": "DAYS"},
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
async def failing_list_policies(*_a, **_k):
|
||||
raise ValueError(
|
||||
"Policy failed to lookup discriminator value from {...}. "
|
||||
"Discriminator property name: type, mapping: {...}"
|
||||
)
|
||||
|
||||
async def fake_list_idps(*_a, **_k):
|
||||
return ([], _resp({}), None)
|
||||
|
||||
async def fake_raw_create(*_a, **kwargs):
|
||||
url = kwargs.get("url", "") or ""
|
||||
return ({"url": url}, None)
|
||||
|
||||
async def fake_raw_execute(request):
|
||||
url = request.get("url", "")
|
||||
if "/api/v1/policies/pol-1/rules" in url:
|
||||
return (None, json.dumps(rules_payload), None)
|
||||
if "/api/v1/policies" in url:
|
||||
return (None, json.dumps(policy_payload), None)
|
||||
return (None, "[]", None)
|
||||
|
||||
sdk = mock.MagicMock()
|
||||
sdk.list_policies = failing_list_policies
|
||||
sdk.list_identity_providers = fake_list_idps
|
||||
sdk._request_executor.create_request = fake_raw_create
|
||||
sdk._request_executor.execute = fake_raw_execute
|
||||
|
||||
with mock.patch(
|
||||
"prowler.providers.okta.lib.service.service.OktaSDKClient",
|
||||
return_value=sdk,
|
||||
):
|
||||
service = User(provider)
|
||||
|
||||
assert "rule-1" in service.automations
|
||||
a = service.automations["rule-1"]
|
||||
assert a.inactivity_days == 35
|
||||
assert a.lifecycle_action == "SUSPENDED"
|
||||
assert a.schedule_status == "ACTIVE"
|
||||
assert a.policy_id == "pol-1"
|
||||
assert a.policy_name == "TestCheck"
|
||||
|
||||
def test_raw_fallback_emits_shell_for_policy_with_no_rules(self):
|
||||
# Mirrors the real-world tenant state where an admin clicked
|
||||
# "Add Automation" in the UI but never configured conditions or
|
||||
# actions. The policy exists; it has zero rules. The raw fallback
|
||||
# must surface the policy as a shell UserAutomation so the check
|
||||
# FAILs with a specific message instead of dropping it.
|
||||
provider = set_mocked_okta_provider()
|
||||
|
||||
async def failing_list_policies(*_a, **_k):
|
||||
raise ValueError("missing discriminator mapping")
|
||||
|
||||
async def fake_list_idps(*_a, **_k):
|
||||
return ([], _resp({}), None)
|
||||
|
||||
async def fake_raw_create(*_a, **kwargs):
|
||||
return ({"url": kwargs.get("url", "") or ""}, None)
|
||||
|
||||
async def fake_raw_execute(request):
|
||||
url = request.get("url", "")
|
||||
if "/api/v1/policies/pol-empty/rules" in url:
|
||||
return (None, "[]", None)
|
||||
if "/api/v1/policies" in url:
|
||||
return (
|
||||
None,
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"id": "pol-empty",
|
||||
"name": "TestCheck",
|
||||
"status": "INACTIVE",
|
||||
"type": "USER_LIFECYCLE",
|
||||
}
|
||||
]
|
||||
),
|
||||
None,
|
||||
)
|
||||
return (None, "[]", None)
|
||||
|
||||
sdk = mock.MagicMock()
|
||||
sdk.list_policies = failing_list_policies
|
||||
sdk.list_identity_providers = fake_list_idps
|
||||
sdk._request_executor.create_request = fake_raw_create
|
||||
sdk._request_executor.execute = fake_raw_execute
|
||||
|
||||
with mock.patch(
|
||||
"prowler.providers.okta.lib.service.service.OktaSDKClient",
|
||||
return_value=sdk,
|
||||
):
|
||||
service = User(provider)
|
||||
|
||||
assert "pol-empty" in service.automations
|
||||
shell = service.automations["pol-empty"]
|
||||
assert shell.name == "TestCheck"
|
||||
assert shell.status == "INACTIVE"
|
||||
assert shell.schedule_status == "INACTIVE"
|
||||
assert shell.inactivity_days is None
|
||||
assert shell.lifecycle_action is None
|
||||
assert shell.applies_to_groups == []
|
||||
assert shell.policy_id == "pol-empty"
|
||||
|
||||
def test_rule_typed_failure_triggers_raw_fallback_for_all_policies(self):
|
||||
# When the typed `list_policies` succeeds but the typed
|
||||
# `list_policy_rules` fails for a policy, the previous behavior
|
||||
# was to emit a shell automation — silently misclassifying a
|
||||
# valid automation as "unfinished". Now `_fetch_rules` returns
|
||||
# None as a sentinel and the caller re-runs the entire
|
||||
# discovery via raw JSON so no rule data is lost.
|
||||
provider = set_mocked_okta_provider()
|
||||
|
||||
typed_policy = _fake_policy(
|
||||
"pol-1", name="TestCheck", inactivity_days=35, groups=["everyone"]
|
||||
)
|
||||
|
||||
async def fake_list_policies(*_a, **_k):
|
||||
return ([typed_policy], _resp({}), None)
|
||||
|
||||
async def failing_list_policy_rules(*_a, **_k):
|
||||
raise ValueError("KnowledgeConstraint.types expected uppercase")
|
||||
|
||||
async def fake_list_idps(*_a, **_k):
|
||||
return ([], _resp({}), None)
|
||||
|
||||
raw_policy_payload = [
|
||||
{
|
||||
"id": "pol-1",
|
||||
"name": "TestCheck",
|
||||
"status": "ACTIVE",
|
||||
"type": "USER_LIFECYCLE",
|
||||
"conditions": {
|
||||
"people": {
|
||||
"users": {"inactivity": {"number": 35, "unit": "DAYS"}},
|
||||
"groups": {"include": ["everyone"]},
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
raw_rules_payload = [
|
||||
{
|
||||
"id": "rule-1",
|
||||
"name": "lifecycle-rule-1",
|
||||
"status": "ACTIVE",
|
||||
"actions": {"updateUserLifecycle": {"targetStatus": "SUSPENDED"}},
|
||||
}
|
||||
]
|
||||
|
||||
async def fake_raw_create(*_a, **kwargs):
|
||||
return ({"url": kwargs.get("url", "") or ""}, None)
|
||||
|
||||
async def fake_raw_execute(request):
|
||||
url = request.get("url", "")
|
||||
if "/api/v1/policies/pol-1/rules" in url:
|
||||
return (None, json.dumps(raw_rules_payload), None)
|
||||
if "/api/v1/policies" in url:
|
||||
return (None, json.dumps(raw_policy_payload), None)
|
||||
return (None, "[]", None)
|
||||
|
||||
sdk = mock.MagicMock()
|
||||
sdk.list_policies = fake_list_policies
|
||||
sdk.list_policy_rules = failing_list_policy_rules
|
||||
sdk.list_identity_providers = fake_list_idps
|
||||
sdk._request_executor.create_request = fake_raw_create
|
||||
sdk._request_executor.execute = fake_raw_execute
|
||||
|
||||
with mock.patch(
|
||||
"prowler.providers.okta.lib.service.service.OktaSDKClient",
|
||||
return_value=sdk,
|
||||
):
|
||||
service = User(provider)
|
||||
|
||||
# Raw-projected automation, not a shell.
|
||||
assert "rule-1" in service.automations
|
||||
assert service.automations["rule-1"].inactivity_days == 35
|
||||
assert service.automations["rule-1"].lifecycle_action == "SUSPENDED"
|
||||
Reference in New Issue
Block a user