diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx index 74e8ef7805..2e08cac8af 100644 --- a/docs/user-guide/providers/okta/authentication.mdx +++ b/docs/user-guide/providers/okta/authentication.mdx @@ -35,6 +35,7 @@ The bundled checks require the following read-only scopes: - `okta.policies.read` - `okta.brands.read` - `okta.apps.read` +- `okta.authenticators.read` - `okta.networkZones.read` - `okta.apiTokens.read` - `okta.roles.read` @@ -49,6 +50,7 @@ Additional scopes will be needed as more services and checks are added. These ar | `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.authenticators.read` | Okta authenticator configuration, including Okta Verify and Smart Card IdP | | `okta.networkZones.read` | Network Zone inventory, anonymized-proxy blocklist checks, and API token Network Zone validation | | `okta.apiTokens.read` | API token metadata and token network conditions | | `okta.roles.read` | Admin role assignments for API token owners (both direct and group-inherited) | @@ -136,7 +138,7 @@ Okta displays the private key **only once**. If you close the modal without copy ### 5. Grant the required OAuth scopes -On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`. +On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`. ![Okta — grant OAuth scopes](/user-guide/providers/okta/images/grant-permissions.png) @@ -172,8 +174,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,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" +# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" uv run python prowler-cli.py okta ``` @@ -214,7 +216,7 @@ Prowler validates credentials at startup by listing one sign-on policy. This err Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role: -- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**. +- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**. - **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**. ### Application-service checks return MANUAL on first-party apps diff --git a/docs/user-guide/providers/okta/getting-started-okta.mdx b/docs/user-guide/providers/okta/getting-started-okta.mdx index 32c08433b7..e04e0d4a13 100644 --- a/docs/user-guide/providers/okta/getting-started-okta.mdx +++ b/docs/user-guide/providers/okta/getting-started-okta.mdx @@ -12,7 +12,7 @@ Set up authentication for Okta with the [Okta Authentication](/user-guide/provid - An Okta organization. The UI examples below use **Identity Engine** terminology such as **Global Session Policy**; Classic Engine exposes the equivalent sign-on policy concepts under older names. - A **Super Administrator** account on that organization for the one-time service-app setup. -- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, API Token, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown. +- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, API Token, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown. - Python 3.10+ and Prowler 5.27.0 or later installed locally. @@ -85,8 +85,8 @@ Follow the [Okta Authentication](/user-guide/providers/okta/authentication) guid export OKTA_ORG_DOMAIN="acme.okta.com" export OKTA_CLIENT_ID="0oa1234567890abcdef" export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem" -# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" +# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" ``` The private key file may contain either a PEM-encoded RSA key or a JWK JSON document. @@ -147,6 +147,7 @@ Prowler for Okta includes security checks across the following services: | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | **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) | +| **Authenticator** | Password Policy controls plus Okta Verify FIPS and Smart Card IdP authenticator status | | **Network** | Network Zone blocklists for anonymized proxy sources | | **API Token** | API token owner-role validation and Network Zone restrictions | | **User** | User lifecycle automations (inactivity-based deprovisioning) | @@ -163,11 +164,12 @@ 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, Application, Network, API Token, User, System Log, and Identity Provider services: +Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, Authenticator, Network, API Token, User, System Log, and Identity Provider services: - `okta.policies.read` - `okta.brands.read` - `okta.apps.read` +- `okta.authenticators.read` - `okta.networkZones.read` - `okta.apiTokens.read` - `okta.roles.read` @@ -181,10 +183,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.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read,okta.users.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.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.networkZones.read okta.apiTokens.read okta.roles.read okta.groups.read okta.logStreams.read okta.idps.read okta.users.read +prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.authenticators.read okta.networkZones.read okta.apiTokens.read okta.roles.read okta.groups.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/). diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index d28520a220..af0b2f6496 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -8,6 +8,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) +- Okta authenticator and password policy checks for STIG-aligned hardening requirements [(#11465)](https://github.com/prowler-cloud/prowler/pull/11465) - Okta network zone check to detect whether anonymized proxy traffic is blocked [(#11463)](https://github.com/prowler-cloud/prowler/pull/11463) - Okta API token checks for super admin ownership and network zone restrictions [(#11464)](https://github.com/prowler-cloud/prowler/pull/11464) - Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700) diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py index e5c968e947..8859507ec7 100644 --- a/prowler/providers/okta/okta_provider.py +++ b/prowler/providers/okta/okta_provider.py @@ -36,6 +36,7 @@ DEFAULT_SCOPES = [ "okta.policies.read", "okta.brands.read", "okta.apps.read", + "okta.authenticators.read", "okta.networkZones.read", "okta.apiTokens.read", "okta.roles.read", diff --git a/prowler/providers/okta/services/authenticator/__init__.py b/prowler/providers/okta/services/authenticator/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_client.py b/prowler/providers/okta/services/authenticator/authenticator_client.py new file mode 100644 index 0000000000..3657a2821e --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.authenticator.authenticator_service import ( + Authenticator, +) + +authenticator_client = Authenticator(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json new file mode 100644 index 0000000000..363b95f84a --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_okta_verify_fips_compliant", + "CheckTitle": "Okta Verify authenticator is active and restricts enrollment to FIPS-compliant devices", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The **Okta Verify authenticator** (`okta_verify`) must be present, in the `ACTIVE` status, and configured to require FIPS-compliant devices for enrollment (`settings.compliance.fips == REQUIRED`).\n\nMissing, inactive, and active-but-non-FIPS authenticators surface as distinct FAIL findings so the operator can act on the specific gap.", + "Risk": "Without FIPS-required enrollment, users can authenticate with devices whose cryptographic modules are not FIPS-validated.\n\n- **Regulatory exposure** under frameworks that mandate FIPS-validated cryptography\n- **Inconsistent assurance** across the user population\n- **Weak baseline** if the authenticator is active but the FIPS flag is `OPTIONAL` or unset", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/authenticator", + "https://help.okta.com/en-us/content/topics/mobile/ov-admin-config.htm", + "https://help.okta.com/oie/en-us/content/topics/identity-engine/authenticators/configure-okta-verify-options.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authenticators**.\n3. Activate the **Okta Verify** authenticator if it is not already `ACTIVE`.\n4. Open the authenticator's settings and set *FIPS Compliance* to **Users enrolling in Okta Verify can use FIPS compliant devices only**.\n5. Save the authenticator.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Ensure the **Okta Verify** authenticator is:\n- Present in the org (`key = okta_verify`)\n- In the `ACTIVE` status\n- Configured so *FIPS Compliance* is **Required** (`settings.compliance.fips == REQUIRED`)\n\nIf the organization does not require FIPS-validated authenticators, mute the check rather than disabling the FIPS toggle on a partial population.", + "Url": "https://hub.prowler.com/check/authenticator_okta_verify_fips_compliant" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273205 / OKTA-APP-001700." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py new file mode 100644 index 0000000000..bf3c64b4b8 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py @@ -0,0 +1,65 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.authenticator_helpers import ( + find_authenticator_by_key, + missing_authenticator_resource, + missing_authenticators_scope_finding, +) + + +class authenticator_okta_verify_fips_compliant(Check): + """STIG V-273205 / OKTA-APP-001700. + + The check requires Okta to restrict Okta Verify enrollment to FIPS-compliant devices. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate Okta Verify FIPS compliance settings.""" + org_domain = authenticator_client.provider.identity.org_domain + missing_scope = authenticator_client.missing_scope.get("authenticators") + if missing_scope: + return [ + missing_authenticators_scope_finding( + self.metadata(), + org_domain, + "okta_verify", + "Okta Verify authenticator", + missing_scope, + ) + ] + + authenticator = find_authenticator_by_key( + authenticator_client.authenticators, "okta_verify" + ) + resource = authenticator or missing_authenticator_resource( + "okta_verify", "Okta Verify authenticator" + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=resource, org_domain=org_domain + ) + if not authenticator: + report.status = "FAIL" + report.status_extended = "Okta Verify authenticator is missing." + elif authenticator.status.upper() != "ACTIVE": + report.status = "FAIL" + report.status_extended = ( + f"Okta Verify authenticator is not active; current status is " + f"{authenticator.status}." + ) + elif authenticator.fips.upper() == "REQUIRED": + report.status = "PASS" + report.status_extended = ( + "Okta Verify authenticator requires FIPS-compliant devices " + "for enrollment." + ) + else: + current_fips = authenticator.fips or "unset" + report.status = "FAIL" + report.status_extended = ( + "Okta Verify authenticator is active but does not require " + "FIPS-compliant devices for enrollment (current value: " + f"{current_fips})." + ) + return [report] diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json new file mode 100644 index 0000000000..f61ccef8a1 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_common_password_check", + "CheckTitle": "Every active Okta Password Policy rejects common passwords", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must reject passwords found in Okta's common-password dictionary (the *Restrict use of common passwords* setting).\n\nOkta evaluates Password Policies by group assignment; a custom policy with the check disabled can govern users. The check emits one finding per active policy.", + "Risk": "Without dictionary checking, users can pick passwords known to attackers from public breaches.\n\n- **Credential stuffing** succeeds with the same passwords compromised elsewhere\n- **Trivial guessing** stays viable for top-N lists (`123456`, `password`, …)\n- **Inconsistent baselines** leave users on legacy policies that allow common passwords", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Restrict use of common passwords**.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_dictionary_lookup = true # Critical: enables the common-password dictionary check\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Restrict use of common passwords* is **enabled**\n- Group assignments do not route users to legacy policies that leave the check disabled\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_common_password_check" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273208 / OKTA-APP-002980." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py new file mode 100644 index 0000000000..d90d88afeb --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_common_password_check(Check): + """STIG V-273208 / OKTA-APP-002980. + + Every active Okta Password Policy must reject passwords found in the common-password dictionary. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "common-password dictionary checks" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.common_password_check is True: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(common password check enabled: {policy.common_password_check})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(common password check enabled: {policy.common_password_check})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json new file mode 100644 index 0000000000..42a74b6e71 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_complexity_lowercase", + "CheckTitle": "Every active Okta Password Policy requires at least one lowercase character", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least one **lowercase** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops lowercase complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Without lowercase complexity, the effective alphabet shrinks and passwords become easier to enumerate.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match more candidates without case mixing\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Lower case letter** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_lowercase = 1 # Critical: STIG-aligned complexity\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of lowercase characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable lowercase complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_complexity_lowercase" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273197 / OKTA-APP-000680." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py new file mode 100644 index 0000000000..e058e27b7b --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_complexity_lowercase(Check): + """STIG V-273197 / OKTA-APP-000680. + + Every active Okta Password Policy must require at least one lowercase character. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "at least one lowercase character" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_lower_case is not None and policy.min_lower_case >= 1: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum lowercase characters: {policy.min_lower_case})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum lowercase characters: {policy.min_lower_case})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json new file mode 100644 index 0000000000..fb3d4a920b --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_complexity_number", + "CheckTitle": "Every active Okta Password Policy requires at least one numeric character", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least one **numeric** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops numeric complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Without numeric complexity, the effective alphabet shrinks and dictionary words remain viable as passwords.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match plain dictionary words without numeric padding\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Number** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_number = 1 # Critical: STIG-aligned complexity\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of numeric characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable numeric complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_complexity_number" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273198 / OKTA-APP-000690." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py new file mode 100644 index 0000000000..78ffe6878e --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_complexity_number(Check): + """STIG V-273198 / OKTA-APP-000690. + + Every active Okta Password Policy must require at least one numeric character. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "at least one numeric character" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_number is not None and policy.min_number >= 1: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum numeric characters: {policy.min_number})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum numeric characters: {policy.min_number})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json new file mode 100644 index 0000000000..1268780551 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_complexity_symbol", + "CheckTitle": "Every active Okta Password Policy requires at least one symbol character", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least one **symbol** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops symbol complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Without symbol complexity, the effective alphabet shrinks and passwords stay closer to natural-language phrases.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match dictionary words without symbol padding\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Symbol** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_symbol = 1 # Critical: STIG-aligned complexity\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of symbol characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable symbol complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_complexity_symbol" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273199 / OKTA-APP-000700." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py new file mode 100644 index 0000000000..208a0f51d3 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_complexity_symbol(Check): + """STIG V-273199 / OKTA-APP-000700. + + Every active Okta Password Policy must require at least one symbol character. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "at least one symbol character" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_symbol is not None and policy.min_symbol >= 1: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum symbol characters: {policy.min_symbol})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum symbol characters: {policy.min_symbol})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json new file mode 100644 index 0000000000..7e885364ae --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_complexity_uppercase", + "CheckTitle": "Every active Okta Password Policy requires at least one uppercase character", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least one **uppercase** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops uppercase complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Without uppercase complexity, the effective alphabet shrinks and passwords become easier to enumerate.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match more candidates without case mixing\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Upper case letter** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_uppercase = 1 # Critical: STIG-aligned complexity\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of uppercase characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable uppercase complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_complexity_uppercase" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273196 / OKTA-APP-000670." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py new file mode 100644 index 0000000000..1419027980 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_complexity_uppercase(Check): + """STIG V-273196 / OKTA-APP-000670. + + Every active Okta Password Policy must require at least one uppercase character. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "at least one uppercase character" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_upper_case is not None and policy.min_upper_case >= 1: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum uppercase characters: {policy.min_upper_case})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum uppercase characters: {policy.min_upper_case})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_history_5/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json new file mode 100644 index 0000000000..a7191b0364 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_history_5", + "CheckTitle": "Every active Okta Password Policy remembers at least 5 previous passwords", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must keep at least the last `5` passwords in history so users cannot immediately recycle a recently-used password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that lowers the history depth can govern users even when the default policy is compliant. The check emits one finding per active policy.", + "Risk": "A short password history lets users cycle back to compromised or trivially-guessable values shortly after a forced rotation.\n\n- **Reuse of breached passwords** within the same account\n- **Defeats forced rotation** by letting users return to a previous password\n- **Inconsistent baselines** leave users on legacy policies with shorter history than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Enforce password history for last* to `5` passwords or more.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_history_count = 5 # Critical: STIG-aligned history depth\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Enforce password history for last* is `5` passwords or more\n- Group assignments do not route users to legacy policies with shorter history\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_history_5" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273209 / OKTA-APP-003010." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py new file mode 100644 index 0000000000..b2eb6d16ba --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_history_5(Check): + """STIG V-273209 / OKTA-APP-003010. + + Every active Okta Password Policy must remember at least the last 5 previous passwords. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "password history of at least 5 previous passwords" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.history_count is not None and policy.history_count >= 5: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(password history count: {policy.history_count})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(password history count: {policy.history_count})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json new file mode 100644 index 0000000000..e287ed38f3 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_lockout_threshold_3", + "CheckTitle": "Every active Okta Password Policy locks accounts after 3 or fewer failed attempts", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must lock the account after at most `3` consecutive failed sign-in attempts.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy with a higher threshold (or no lockout) can govern users even when the default is compliant. The check emits one finding per active policy.", + "Risk": "A high lockout threshold (or no threshold) leaves accounts exposed to online password guessing.\n\n- **Online brute force** retains enough attempts to enumerate common passwords\n- **Credential stuffing** can iterate through breached credentials at scale\n- **Inconsistent baselines** leave users on legacy policies with weaker thresholds than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Lock out user after X unsuccessful attempts* to `3` or fewer.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_max_lockout_attempts = 3 # Critical: STIG-aligned lockout threshold\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Lock out user after X unsuccessful attempts* is `3` or fewer\n- Group assignments do not route users to legacy policies with higher thresholds or no lockout\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_lockout_threshold_3" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273189 / OKTA-APP-000170. Not applicable when Okta delegates password sourcing to an external directory (AD/LDAP) — mute the check in that case; the directory enforces lockout instead." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py new file mode 100644 index 0000000000..8026725c8a --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_lockout_threshold_3(Check): + """STIG V-273189 / OKTA-APP-000170. + + Every active Okta Password Policy must lock accounts after no more than 3 consecutive failed login attempts. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "password lockout after 3 or fewer failed attempts" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.max_attempts is not None and policy.max_attempts <= 3: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(maximum failed attempts: {policy.max_attempts})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(maximum failed attempts: {policy.max_attempts})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json new file mode 100644 index 0000000000..449904303e --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_maximum_age_60d", + "CheckTitle": "Every active Okta Password Policy enforces a maximum password age of 60 days or less", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require users to change their password at least every `60` days. The check rejects both unlimited expiration (`0`, which Okta treats as *never expires*) and any value greater than `60`.\n\nOkta evaluates Password Policies by group assignment; a permissive custom policy with a longer window can govern users. The check emits one finding per active policy.", + "Risk": "Long-lived passwords give a compromised credential indefinite usefulness.\n\n- **Stolen passwords** stay valid until the user happens to change them\n- **Breach detection lag** means a leaked password can be in use for months unnoticed\n- **No expiration** is the same risk as an infinite expiration window", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Enforce password expiration* to `60` days or less. Do **not** leave it disabled or set to `0` — that disables expiration entirely.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_max_age_days = 60 # Critical: STIG-aligned maximum age; do not use 0 (disables expiration)\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Enforce password expiration* is `60` days or less\n- Never set the value to `0`, which disables expiration\n- Group assignments do not route users to legacy policies with longer windows or disabled expiration\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_maximum_age_60d" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273201 / OKTA-APP-000745." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py new file mode 100644 index 0000000000..2dbc5d9c07 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_maximum_age_60d(Check): + """STIG V-273201 / OKTA-APP-000745. + + Every active Okta Password Policy must enforce a 60-day maximum password age. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "maximum password age of 60 days or less" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.max_age_days is not None and 0 < policy.max_age_days <= 60: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(maximum age days: {policy.max_age_days})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(maximum age days: {policy.max_age_days})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json new file mode 100644 index 0000000000..8adbf3b5a0 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_minimum_age_24h", + "CheckTitle": "Every active Okta Password Policy enforces a minimum password age of 24 hours", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must prevent users from changing their password again for at least `24` hours (`1440` minutes) after the previous change.\n\nA minimum age stops users from cycling through their entire history in one session to land back on a previously-known password. The check emits one finding per active policy.", + "Risk": "Without a minimum password age, users can sidestep history and rotation requirements in minutes.\n\n- **Defeats password history** by walking through new passwords back to a previous one\n- **Defeats forced rotation** by returning to the prior password after the mandatory change\n- **Inconsistent baselines** leave users on legacy policies with shorter minimums than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Minimum password age* to `24` hours (`1440` minutes) or more.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_age_minutes = 1440 # Critical: STIG-aligned 24h minimum age\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum password age* is `1440` minutes (`24` hours) or more\n- Group assignments do not route users to legacy policies with shorter minimums or no minimum age\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_minimum_age_24h" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273200 / OKTA-APP-000740." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py new file mode 100644 index 0000000000..93ce511458 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_minimum_age_24h(Check): + """STIG V-273200 / OKTA-APP-000740. + + Every active Okta Password Policy must enforce a 24-hour minimum password age. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "minimum password age of at least 24 hours" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_age_minutes is not None and policy.min_age_minutes >= 1440: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum age minutes: {policy.min_age_minutes})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum age minutes: {policy.min_age_minutes})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json new file mode 100644 index 0000000000..d7409c6bb1 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_minimum_length_15", + "CheckTitle": "Every active Okta Password Policy enforces a minimum length of 15 characters", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least `15` characters in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy with a shorter minimum length can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Short password minimums weaken brute-force and credential-stuffing resistance for every user assigned to the affected policy.\n\n- **Brute force** completes in feasible time against short passwords\n- **Credential stuffing** succeeds with reused passwords from public breaches\n- **Inconsistent baselines** leave users on legacy policies with weaker assurance than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Minimum password length* to `15` or more.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_length = 15 # Critical: STIG-aligned minimum\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum password length* is `15` characters or more\n- Group assignments do not route users to legacy policies with shorter minimums\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_minimum_length_15" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273195 / OKTA-APP-000650." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py new file mode 100644 index 0000000000..35e0a02b8b --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_minimum_length_15(Check): + """STIG V-273195 / OKTA-APP-000650. + + Every active Okta Password Policy must enforce a minimum password length of 15 characters. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "minimum password length of at least 15 characters" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_length is not None and policy.min_length >= 15: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum length: {policy.min_length})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum length: {policy.min_length})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_service.py b/prowler/providers/okta/services/authenticator/authenticator_service.py new file mode 100644 index 0000000000..86b4084dfb --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_service.py @@ -0,0 +1,236 @@ +from typing import Optional + +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 + +REQUIRED_SCOPES: dict[str, str] = { + "password_policies": "okta.policies.read", + "authenticators": "okta.authenticators.read", +} + + +def _value(value) -> str: + """Return plain string values from Okta SDK enums and raw strings.""" + if value is None: + return "" + enum_value = getattr(value, "value", None) + if enum_value is not None: + return str(enum_value) + return str(value) + + +def _int_or_none(value) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _bool_or_none(value) -> Optional[bool]: + """Coerce common Okta boolean shapes into a real `Optional[bool]`. + + The Okta SDK typed `bool` fields are already real booleans, but the + raw-JSON fallback paths in sibling services have surfaced both + JSON-style booleans (`true`/`false` as Python `bool` after `json.loads`) + and string-flavored ones (`"true"`/`"false"`). `bool("false")` is + `True` — so naive coercion silently flips the meaning. Reject that + explicitly. + """ + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes"}: + return True + if normalized in {"false", "0", "no", ""}: + return False + return None + return bool(value) + + +class Authenticator(OktaService): + """Fetches Okta Password Policies and Authenticators for STIG checks. + + Populates: + - `self.password_policies` — keyed by policy id. Each `PasswordPolicy` + carries the projected fields the 10 password-policy checks read + (length, complexity, age, history, lockout, common-password + dictionary). The complete typed SDK response is collapsed into a + flat dataclass so the checks never reach back into the SDK shape. + - `self.authenticators` — keyed by authenticator id. Used by the + two non-password checks (Smart Card IdP, Okta Verify FIPS). + + Before each fetch the service compares its required OAuth scope + (see `REQUIRED_SCOPES`) against the access token's granted scopes + (`provider.identity.granted_scopes`). When a scope is known to be + missing, the fetch is skipped and recorded in `self.missing_scope` + so each check can emit an explicit MANUAL finding instead of a + misleading "no resources returned". Empty granted_scopes means + "unknown" — the service attempts the fetch and lets the SDK fail + loudly. + """ + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + self.password_policies: dict[str, PasswordPolicy] = ( + {} + if self.missing_scope["password_policies"] + else self._list_password_policies() + ) + self.authenticators: dict[str, OktaAuthenticator] = ( + {} if self.missing_scope["authenticators"] else self._list_authenticators() + ) + + def _list_password_policies(self) -> dict[str, "PasswordPolicy"]: + """List PASSWORD policies with normalized password settings.""" + logger.info("Authenticator - Listing Okta PASSWORD policies...") + try: + return self._run(self._fetch_password_policies()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_password_policies(self) -> dict[str, "PasswordPolicy"]: + result: dict[str, PasswordPolicy] = {} + items, err = await _paginate_shared( + lambda after: self.client.list_policies( + type="PASSWORD", after=after, limit="200" + ) + ) + if err is not None: + logger.error(f"Error listing PASSWORD policies: {err}") + return result + + for policy in items: + policy_obj = self._build_password_policy(policy) + result[policy_obj.id] = policy_obj + return result + + def _list_authenticators(self) -> dict[str, "OktaAuthenticator"]: + """List org authenticators with normalized settings.""" + logger.info("Authenticator - Listing Okta authenticators...") + try: + return self._run(self._fetch_authenticators()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_authenticators(self) -> dict[str, "OktaAuthenticator"]: + # `list_authenticators` is non-paginated in the SDK (no `after` + # parameter); inline the tuple unwrap rather than going through + # `paginate`. Same shape `application_service` uses for + # `get_first_party_app_settings`. + result: dict[str, OktaAuthenticator] = {} + sdk_result = await self.client.list_authenticators() + err = sdk_result[-1] + if err is not None: + logger.error(f"Error listing authenticators: {err}") + return result + items = sdk_result[0] or [] + + for authenticator in items: + auth_obj = self._build_authenticator(authenticator) + result[auth_obj.id] = auth_obj + return result + + @staticmethod + def _build_password_policy(policy) -> "PasswordPolicy": + settings = getattr(policy, "settings", None) + password_settings = getattr(settings, "password", None) if settings else None + lockout = ( + getattr(password_settings, "lockout", None) if password_settings else None + ) + complexity = ( + getattr(password_settings, "complexity", None) + if password_settings + else None + ) + dictionary = getattr(complexity, "dictionary", None) if complexity else None + common = getattr(dictionary, "common", None) if dictionary else None + age = getattr(password_settings, "age", None) if password_settings else None + policy_id = _value(getattr(policy, "id", None)) + return PasswordPolicy( + id=policy_id, + name=_value(getattr(policy, "name", None)) or policy_id, + status=_value(getattr(policy, "status", None)), + priority=_int_or_none(getattr(policy, "priority", None)), + is_default=bool(getattr(policy, "system", False)), + max_attempts=_int_or_none(getattr(lockout, "max_attempts", None)), + min_length=_int_or_none(getattr(complexity, "min_length", None)), + min_upper_case=_int_or_none(getattr(complexity, "min_upper_case", None)), + min_lower_case=_int_or_none(getattr(complexity, "min_lower_case", None)), + min_number=_int_or_none(getattr(complexity, "min_number", None)), + min_symbol=_int_or_none(getattr(complexity, "min_symbol", None)), + min_age_minutes=_int_or_none(getattr(age, "min_age_minutes", None)), + max_age_days=_int_or_none(getattr(age, "max_age_days", None)), + history_count=_int_or_none(getattr(age, "history_count", None)), + common_password_check=_bool_or_none(getattr(common, "exclude", None)), + ) + + @staticmethod + def _build_authenticator(authenticator) -> "OktaAuthenticator": + settings = getattr(authenticator, "settings", None) + compliance = getattr(settings, "compliance", None) if settings else None + auth_id = _value(getattr(authenticator, "id", None)) + return OktaAuthenticator( + id=auth_id, + key=_value(getattr(authenticator, "key", None)), + name=_value(getattr(authenticator, "name", None)) or auth_id, + status=_value(getattr(authenticator, "status", None)), + type=_value(getattr(authenticator, "type", None)), + fips=_value(getattr(compliance, "fips", None)), + ) + + +class PasswordPolicy(BaseModel): + """Normalized Okta Password Policy settings used by checks.""" + + id: str + name: str + status: str = "" + priority: Optional[int] = None + is_default: bool = False + max_attempts: Optional[int] = None + min_length: Optional[int] = None + min_upper_case: Optional[int] = None + min_lower_case: Optional[int] = None + min_number: Optional[int] = None + min_symbol: Optional[int] = None + min_age_minutes: Optional[int] = None + max_age_days: Optional[int] = None + history_count: Optional[int] = None + common_password_check: Optional[bool] = None + + +class OktaAuthenticator(BaseModel): + """Normalized Okta Authenticator settings used by checks.""" + + id: str + key: str + name: str + status: str = "" + type: str = "" + fips: str = "" + + +class AuthenticatorSummary(BaseModel): + """Synthetic resource for org-level authenticator findings.""" + + id: str + name: str diff --git a/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json new file mode 100644 index 0000000000..b8e93ae548 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_smart_card_active", + "CheckTitle": "Okta Smart Card IdP authenticator is configured and active", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The **Smart Card IdP authenticator** (`smart_card_idp`) must exist in the org and be in the `ACTIVE` status so certificate-based authentication is available to users and apps that require it.\n\nThe check resolves the authenticator by its built-in `key`. Missing and inactive authenticators surface as distinct FAIL findings.", + "Risk": "Without an active Smart Card authenticator, users cannot satisfy mandated certificate-based authentication and may be forced onto weaker fallback paths.\n\n- **Mandated CAC/PIV use** cannot be enforced\n- **Compliance gaps** for environments that require X.509 certificate authentication\n- **Fallback to password-only** sign-in for affected groups", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/authenticator", + "https://help.okta.com/oie/en-us/Content/Topics/identity-engine/authenticators/smart-card-authenticator.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authenticators**.\n3. If the **Smart Card IdP** authenticator is not listed, click **Add Authenticator** and add it.\n4. Open the authenticator and switch its status to **ACTIVE**.\n5. Bind it to the Authentication Policies that require certificate-based auth.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Ensure the **Smart Card IdP** authenticator is:\n- Present in the org (`key = smart_card_idp`)\n- In the `ACTIVE` status\n- Referenced by every Authentication Policy that requires certificate-based authentication\n\nIf certificate-based authentication is not in scope for the organization, mute the check rather than disabling the authenticator.", + "Url": "https://hub.prowler.com/check/authenticator_smart_card_active" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273204 / OKTA-APP-001670." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py new file mode 100644 index 0000000000..4341db8612 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py @@ -0,0 +1,56 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.authenticator_helpers import ( + find_authenticator_by_key, + missing_authenticator_resource, + missing_authenticators_scope_finding, +) + + +class authenticator_smart_card_active(Check): + """STIG V-273204 / OKTA-APP-001670. + + The check requires Okta to configure and activate the Smart Card (PIV) authenticator. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate the Smart Card IdP authenticator status.""" + org_domain = authenticator_client.provider.identity.org_domain + missing_scope = authenticator_client.missing_scope.get("authenticators") + if missing_scope: + return [ + missing_authenticators_scope_finding( + self.metadata(), + org_domain, + "smart_card_idp", + "Smart Card IdP authenticator", + missing_scope, + ) + ] + + authenticator = find_authenticator_by_key( + authenticator_client.authenticators, "smart_card_idp" + ) + resource = authenticator or missing_authenticator_resource( + "smart_card_idp", "Smart Card IdP authenticator" + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=resource, org_domain=org_domain + ) + if authenticator and authenticator.status.upper() == "ACTIVE": + report.status = "PASS" + report.status_extended = "Smart Card IdP authenticator is ACTIVE." + elif authenticator: + report.status = "FAIL" + report.status_extended = ( + f"Smart Card IdP authenticator is not active; current status is " + f"{authenticator.status}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "Smart Card IdP authenticator is not active or missing." + ) + return [report] diff --git a/prowler/providers/okta/services/authenticator/lib/__init__.py b/prowler/providers/okta/services/authenticator/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py b/prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py new file mode 100644 index 0000000000..851daec5a8 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py @@ -0,0 +1,49 @@ +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_service import ( + AuthenticatorSummary, + OktaAuthenticator, +) + +_SCOPE_ADVICE = ( + "Grant it on the Okta API Scopes tab of the service app in the Okta Admin " + "Console, then re-run the check." +) + + +def find_authenticator_by_key( + authenticators: dict[str, OktaAuthenticator], key: str +) -> OktaAuthenticator | None: + """Return the first authenticator with the requested key. + + Okta enforces unique authenticator `key` values per org for built-in + types (`okta_verify`, `smart_card_idp`, etc.), so the "first match" + is the only match in practice. If a future Okta release relaxes + that and returns duplicates, only the first is evaluated — STIG + semantics need refining at that point. + """ + for authenticator in authenticators.values(): + if authenticator.key == key: + return authenticator + return None + + +def missing_authenticator_resource(key: str, name: str) -> AuthenticatorSummary: + """Build a synthetic resource for a missing authenticator.""" + return AuthenticatorSummary(id=f"{key}-missing", name=name) + + +def missing_authenticators_scope_finding( + metadata, org_domain: str, key: str, name: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when authenticators cannot be listed.""" + resource = AuthenticatorSummary(id=f"{key}-scope-missing", name=name) + report = CheckReportOkta( + metadata=metadata, resource=resource, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Okta authenticators to evaluate {name}: the Okta " + f"service app is missing the required `{scope}` API scope. " + f"{_SCOPE_ADVICE}" + ) + return report diff --git a/prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py b/prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py new file mode 100644 index 0000000000..08aa64a3a6 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py @@ -0,0 +1,80 @@ +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_service import ( + PasswordPolicy, +) + +_SCOPE_ADVICE = ( + "Grant it on the Okta API Scopes tab of the service app in the Okta Admin " + "Console, then re-run the check." +) + + +def active_password_policies( + password_policies: dict[str, PasswordPolicy], +) -> list[PasswordPolicy]: + """Return active password policies sorted by priority. + + Treats `policy.status == ""` as ACTIVE: the typed Okta SDK + occasionally returns policies without a `status` field populated + (the SDK enum doesn't cover every server-side value Okta has + shipped). Dropping those would silently hide real policies — we + 'd rather evaluate them and let the per-field comparator decide. + """ + return sorted( + [ + policy + for policy in password_policies.values() + if not policy.status or policy.status.upper() == "ACTIVE" + ], + key=lambda policy: ( + policy.priority if policy.priority is not None else float("inf"), + policy.name, + ), + ) + + +def password_policy_label(policy: PasswordPolicy) -> str: + kind = "default" if policy.is_default else "custom" + priority = policy.priority if policy.priority is not None else "unset" + return f"Password Policy {policy.name} (priority {priority}, {kind})" + + +def no_active_password_policies_finding( + metadata, org_domain: str, requirement: str +) -> CheckReportOkta: + """Build the FAIL finding emitted when no active password policies exist.""" + placeholder = PasswordPolicy( + id="password-policies-missing", + name="(no active password policies)", + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "FAIL" + report.status_extended = ( + "No active Okta Password Policies were returned by the API. " + f"The organization must enforce: {requirement}." + ) + return report + + +def missing_password_policies_scope_finding( + metadata, org_domain: str, scope: str, requirement: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when Password Policies cannot be listed.""" + placeholder = PasswordPolicy( + id="password-policies-scope-missing", + name="(scope not granted)", + status="UNKNOWN", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Okta Password Policies to evaluate {requirement}: " + f"the Okta service app is missing the required `{scope}` API scope. " + f"{_SCOPE_ADVICE}" + ) + return report diff --git a/tests/providers/okta/okta_fixtures.py b/tests/providers/okta/okta_fixtures.py index d1018a65eb..5c3d0e43c3 100644 --- a/tests/providers/okta/okta_fixtures.py +++ b/tests/providers/okta/okta_fixtures.py @@ -20,6 +20,7 @@ def set_mocked_okta_provider( "okta.policies.read", "okta.brands.read", "okta.apps.read", + "okta.authenticators.read", "okta.networkZones.read", "okta.apiTokens.read", "okta.roles.read", @@ -37,6 +38,7 @@ def set_mocked_okta_provider( "okta.policies.read", "okta.brands.read", "okta.apps.read", + "okta.authenticators.read", "okta.networkZones.read", "okta.apiTokens.read", "okta.roles.read", diff --git a/tests/providers/okta/services/authenticator/authenticator_fixtures.py b/tests/providers/okta/services/authenticator/authenticator_fixtures.py new file mode 100644 index 0000000000..f15603221b --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_fixtures.py @@ -0,0 +1,76 @@ +from unittest import mock + +from prowler.providers.okta.services.authenticator.authenticator_service import ( + OktaAuthenticator, + PasswordPolicy, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_authenticator_client( + password_policies: dict = None, + authenticators: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.password_policies = password_policies or {} + client.authenticators = authenticators or {} + client.missing_scope = missing_scope or { + "password_policies": None, + "authenticators": None, + } + client.provider = set_mocked_okta_provider() + return client + + +def password_policy( + policy_id: str = "pol-password", + name: str = "Default Password Policy", + *, + status: str = "ACTIVE", + priority: int = 1, + max_attempts: int = 3, + min_length: int = 15, + min_upper_case: int = 1, + min_lower_case: int = 1, + min_number: int = 1, + min_symbol: int = 1, + min_age_minutes: int = 1440, + max_age_days: int = 60, + history_count: int = 5, + common_password_check: bool = True, +): + return PasswordPolicy( + id=policy_id, + name=name, + status=status, + priority=priority, + max_attempts=max_attempts, + min_length=min_length, + min_upper_case=min_upper_case, + min_lower_case=min_lower_case, + min_number=min_number, + min_symbol=min_symbol, + min_age_minutes=min_age_minutes, + max_age_days=max_age_days, + history_count=history_count, + common_password_check=common_password_check, + ) + + +def authenticator( + auth_id: str = "aut-okta-verify", + key: str = "okta_verify", + name: str = "Okta Verify", + *, + status: str = "ACTIVE", + fips: str = "REQUIRED", +): + return OktaAuthenticator( + id=auth_id, + key=key, + name=name, + status=status, + type="app", + fips=fips, + ) diff --git a/tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py b/tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py new file mode 100644 index 0000000000..467dc44611 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py @@ -0,0 +1,74 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + authenticator, + build_authenticator_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_okta_verify_fips_compliant." + "authenticator_okta_verify_fips_compliant.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_okta_verify_fips_compliant.authenticator_okta_verify_fips_compliant import ( + authenticator_okta_verify_fips_compliant, + ) + + return authenticator_okta_verify_fips_compliant().execute() + + +class Test_authenticator_okta_verify_fips_compliant: + def test_okta_verify_fips_required_passes(self): + okta_verify = authenticator(key="okta_verify", fips="REQUIRED") + findings = _run_check( + build_authenticator_client(authenticators={okta_verify.id: okta_verify}) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == okta_verify.id + + def test_okta_verify_without_fips_required_fails(self): + okta_verify = authenticator(key="okta_verify", fips="OPTIONAL") + findings = _run_check( + build_authenticator_client(authenticators={okta_verify.id: okta_verify}) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "FIPS" in findings[0].status_extended + + def test_missing_okta_verify_fails(self): + findings = _run_check(build_authenticator_client(authenticators={})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "Okta Verify authenticator is missing." in findings[0].status_extended + + def test_inactive_okta_verify_surfaces_current_status(self): + okta_verify = authenticator(key="okta_verify", status="INACTIVE") + findings = _run_check( + build_authenticator_client(authenticators={okta_verify.id: okta_verify}) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "INACTIVE" in findings[0].status_extended + + def test_missing_authenticators_scope_is_manual(self): + findings = _run_check( + build_authenticator_client( + authenticators={}, + missing_scope={"authenticators": "okta.authenticators.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.authenticators.read" in findings[0].status_extended diff --git a/tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py b/tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py new file mode 100644 index 0000000000..c0e49deb8c --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py @@ -0,0 +1,60 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_common_password_check.authenticator_password_common_password_check.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_common_password_check.authenticator_password_common_password_check import ( + authenticator_password_common_password_check, + ) + + return authenticator_password_common_password_check().execute() + + +class Test_authenticator_password_common_password_check: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_missing_password_policies_scope_is_manual(self): + findings = _run_check( + build_authenticator_client( + {}, + missing_scope={"password_policies": "okta.policies.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(common_password_check=True) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(common_password_check=False) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py new file mode 100644 index 0000000000..8483f46552 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_complexity_lowercase.authenticator_password_complexity_lowercase.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_complexity_lowercase.authenticator_password_complexity_lowercase import ( + authenticator_password_complexity_lowercase, + ) + + return authenticator_password_complexity_lowercase().execute() + + +class Test_authenticator_password_complexity_lowercase: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_lower_case=1) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_lower_case=0) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py new file mode 100644 index 0000000000..cac8f8b444 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_complexity_number.authenticator_password_complexity_number.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_complexity_number.authenticator_password_complexity_number import ( + authenticator_password_complexity_number, + ) + + return authenticator_password_complexity_number().execute() + + +class Test_authenticator_password_complexity_number: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_number=1) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_number=0) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py new file mode 100644 index 0000000000..93ed18a36d --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_complexity_symbol.authenticator_password_complexity_symbol.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_complexity_symbol.authenticator_password_complexity_symbol import ( + authenticator_password_complexity_symbol, + ) + + return authenticator_password_complexity_symbol().execute() + + +class Test_authenticator_password_complexity_symbol: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_symbol=1) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_symbol=0) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py new file mode 100644 index 0000000000..74ab626e52 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_complexity_uppercase.authenticator_password_complexity_uppercase.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_complexity_uppercase.authenticator_password_complexity_uppercase import ( + authenticator_password_complexity_uppercase, + ) + + return authenticator_password_complexity_uppercase().execute() + + +class Test_authenticator_password_complexity_uppercase: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_upper_case=1) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_upper_case=0) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py b/tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py new file mode 100644 index 0000000000..e6741c53c2 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_history_5.authenticator_password_history_5.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_history_5.authenticator_password_history_5 import ( + authenticator_password_history_5, + ) + + return authenticator_password_history_5().execute() + + +class Test_authenticator_password_history_5: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(history_count=5) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(history_count=4) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py b/tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py new file mode 100644 index 0000000000..35c7026e11 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_lockout_threshold_3.authenticator_password_lockout_threshold_3.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_lockout_threshold_3.authenticator_password_lockout_threshold_3 import ( + authenticator_password_lockout_threshold_3, + ) + + return authenticator_password_lockout_threshold_3().execute() + + +class Test_authenticator_password_lockout_threshold_3: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(max_attempts=3) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(max_attempts=4) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py b/tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py new file mode 100644 index 0000000000..938b29a290 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_maximum_age_60d.authenticator_password_maximum_age_60d.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_maximum_age_60d.authenticator_password_maximum_age_60d import ( + authenticator_password_maximum_age_60d, + ) + + return authenticator_password_maximum_age_60d().execute() + + +class Test_authenticator_password_maximum_age_60d: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(max_age_days=60) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(max_age_days=61) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py b/tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py new file mode 100644 index 0000000000..0fbe562330 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_minimum_age_24h.authenticator_password_minimum_age_24h.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_minimum_age_24h.authenticator_password_minimum_age_24h import ( + authenticator_password_minimum_age_24h, + ) + + return authenticator_password_minimum_age_24h().execute() + + +class Test_authenticator_password_minimum_age_24h: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_age_minutes=1440) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_age_minutes=1439) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py b/tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py new file mode 100644 index 0000000000..2608c42998 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py @@ -0,0 +1,62 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_minimum_length_15.authenticator_password_minimum_length_15.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_minimum_length_15.authenticator_password_minimum_length_15 import ( + authenticator_password_minimum_length_15, + ) + + return authenticator_password_minimum_length_15().execute() + + +class Test_authenticator_password_minimum_length_15: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_length=15) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_length=14) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id + + def test_multiple_active_policies_emit_one_finding_each(self): + compliant = password_policy(policy_id="pol-good", name="Strict", min_length=15) + weak = password_policy( + policy_id="pol-weak", name="Weak", min_length=8, priority=2 + ) + findings = _run_check( + build_authenticator_client({compliant.id: compliant, weak.id: weak}) + ) + assert len(findings) == 2 + by_name = {finding.resource_name: finding for finding in findings} + assert by_name["Strict"].status == "PASS" + assert by_name["Weak"].status == "FAIL" diff --git a/tests/providers/okta/services/authenticator/authenticator_service_test.py b/tests/providers/okta/services/authenticator/authenticator_service_test.py new file mode 100644 index 0000000000..d66af92ca2 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_service_test.py @@ -0,0 +1,188 @@ +from types import SimpleNamespace +from unittest import mock + +from prowler.providers.okta.models import OktaIdentityInfo +from prowler.providers.okta.services.authenticator.authenticator_service import ( + Authenticator, + OktaAuthenticator, + PasswordPolicy, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _resp(headers: dict = None): + return SimpleNamespace(headers=headers or {}) + + +def _sdk_password_policy( + policy_id: str = "pol-password", name: str = "Default", common_exclude=True +): + return SimpleNamespace( + id=policy_id, + name=name, + priority=1, + status="ACTIVE", + system=True, + settings=SimpleNamespace( + password=SimpleNamespace( + lockout=SimpleNamespace(max_attempts=3), + complexity=SimpleNamespace( + min_length=15, + min_upper_case=1, + min_lower_case=1, + min_number=1, + min_symbol=1, + dictionary=SimpleNamespace( + common=SimpleNamespace(exclude=common_exclude) + ), + ), + age=SimpleNamespace( + min_age_minutes=1440, + max_age_days=60, + history_count=5, + ), + ) + ), + ) + + +def _sdk_authenticator( + auth_id: str = "aut-okta-verify", + key: str = "okta_verify", + status: str = "ACTIVE", + fips: str = "REQUIRED", +): + return SimpleNamespace( + id=auth_id, + key=key, + name="Okta Verify" if key == "okta_verify" else "Smart Card IdP", + status=status, + type="app", + settings=SimpleNamespace(compliance=SimpleNamespace(fips=fips)), + ) + + +class Test_Authenticator_service: + def test_fetches_password_policies_and_authenticators(self): + provider = set_mocked_okta_provider() + policy = _sdk_password_policy() + okta_verify = _sdk_authenticator() + + async def fake_list_policies(*_a, **_k): + return ([policy], _resp({}), None) + + async def fake_list_authenticators(*_a, **_k): + return ([okta_verify], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_authenticators = fake_list_authenticators + mocked_client_cls.return_value = mocked + + service = Authenticator(provider) + + assert isinstance(service.password_policies[policy.id], PasswordPolicy) + assert service.password_policies[policy.id].min_length == 15 + assert service.password_policies[policy.id].common_password_check is True + assert isinstance(service.authenticators[okta_verify.id], OktaAuthenticator) + assert service.authenticators[okta_verify.id].fips == "REQUIRED" + + def test_common_password_exclude_false_is_not_compliant(self): + provider = set_mocked_okta_provider() + policy = _sdk_password_policy(common_exclude=False) + + async def fake_list_policies(*_a, **_k): + return ([policy], _resp({}), None) + + async def fake_list_authenticators(*_a, **_k): + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_authenticators = fake_list_authenticators + mocked_client_cls.return_value = mocked + service = Authenticator(provider) + + assert service.password_policies[policy.id].common_password_check is False + + def test_paginates_password_policies(self): + provider = set_mocked_okta_provider() + page_1 = _sdk_password_policy("pol-1", "First") + page_2 = _sdk_password_policy("pol-2", "Second") + quote = chr(34) + next_link = ( + "; " + f"rel={quote}next{quote}" + ) + calls = [] + + async def fake_list_policies(*_a, **kwargs): + calls.append(kwargs.get("after")) + if kwargs.get("after") is None: + return ([page_1], _resp({"link": next_link}), None) + return ([page_2], _resp({}), None) + + async def fake_list_authenticators(*_a, **_k): + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_authenticators = fake_list_authenticators + mocked_client_cls.return_value = mocked + service = Authenticator(provider) + + assert calls == [None, "cursor-2"] + assert set(service.password_policies.keys()) == {"pol-1", "pol-2"} + + def test_missing_scopes_skip_dependent_api_calls(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=["okta.apps.read"], + ) + ) + + async def fail_if_called(*_a, **_k): + raise AssertionError("Authenticator API calls should not run") + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fail_if_called + mocked.list_authenticators = fail_if_called + mocked_client_cls.return_value = mocked + service = Authenticator(provider) + + assert service.missing_scope["password_policies"] == "okta.policies.read" + assert service.missing_scope["authenticators"] == "okta.authenticators.read" + assert service.password_policies == {} + assert service.authenticators == {} + + def test_returns_empty_collections_on_api_errors(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("forbidden")) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = failing + mocked.list_authenticators = failing + mocked_client_cls.return_value = mocked + service = Authenticator(provider) + + assert service.password_policies == {} + assert service.authenticators == {} diff --git a/tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py b/tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py new file mode 100644 index 0000000000..c85527f0be --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py @@ -0,0 +1,74 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + authenticator, + build_authenticator_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_smart_card_active.authenticator_smart_card_active.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_smart_card_active.authenticator_smart_card_active import ( + authenticator_smart_card_active, + ) + + return authenticator_smart_card_active().execute() + + +class Test_authenticator_smart_card_active: + def test_smart_card_active_passes(self): + smart_card = authenticator( + auth_id="aut-smart-card", + key="smart_card_idp", + name="Smart Card IdP", + status="ACTIVE", + ) + findings = _run_check( + build_authenticator_client(authenticators={smart_card.id: smart_card}) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == smart_card.id + + def test_missing_smart_card_fails(self): + findings = _run_check(build_authenticator_client(authenticators={})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not active" in findings[0].status_extended + + def test_missing_authenticators_scope_is_manual(self): + findings = _run_check( + build_authenticator_client( + authenticators={}, + missing_scope={"authenticators": "okta.authenticators.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.authenticators.read" in findings[0].status_extended + + def test_inactive_smart_card_fails(self): + smart_card = authenticator( + auth_id="aut-smart-card", + key="smart_card_idp", + name="Smart Card IdP", + status="INACTIVE", + ) + findings = _run_check( + build_authenticator_client(authenticators={smart_card.id: smart_card}) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "INACTIVE" in findings[0].status_extended