From 42b19038e0f29802b800b95a5aebfac104330c17 Mon Sep 17 00:00:00 2001 From: Daniel Barranquero Date: Tue, 19 May 2026 12:57:55 +0200 Subject: [PATCH] feat(sdk): add signon_global_session_lifetime_18h Okta check - Enforce DISA STIG V-273203 / OKTA-APP-001665 on the Default Global Session Policy - Require Priority 1 non-default rule with max session lifetime <= 18 hours - Threshold overridable via okta_max_session_lifetime_minutes audit config key --- .../__init__.py | 0 ..._global_session_lifetime_18h.metadata.json | 37 +++ .../signon_global_session_lifetime_18h.py | 126 ++++++++++ ...signon_global_session_lifetime_18h_test.py | 232 ++++++++++++++++++ 4 files changed, 395 insertions(+) create mode 100644 prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/__init__.py create mode 100644 prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.metadata.json create mode 100644 prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.py create mode 100644 tests/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h_test.py diff --git a/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/__init__.py b/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.metadata.json b/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.metadata.json new file mode 100644 index 0000000000..a5460c4ca9 --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "signon_global_session_lifetime_18h", + "CheckTitle": "Default Global Session Policy has a Priority 1 non-default rule limiting session lifetime to 18 hours", + "CheckType": [], + "ServiceName": "signon", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "The **Default Global Session Policy** must have a **Priority 1** rule that is **not** the built-in `Default Rule`, and that rule must set **Maximum Okta global session lifetime** to 18 hours or less (DISA STIG V-273203 / OKTA-APP-001665). The threshold defaults to 1080 minutes and is overridable via the `okta_max_session_lifetime_minutes` key in the audit config.", + "Risk": "Without an enforced session lifetime, an authenticated Okta session can be reused indefinitely without reauthentication. Stolen session material continues to grant access long after the original sign-in event, and authorization changes (role revocation, group removal) take effect only on the next forced reauth.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/oie/en-us/content/topics/identity-engine/policies/about-okta-sign-on-policies.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Policy/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Okta Admin Console as a Super Admin\n2. Go to Security > Global Session Policy\n3. Open the Default Policy\n4. Add or edit a non-default rule\n5. Move that rule to Priority 1 so it is evaluated before the built-in Default Rule\n6. Set 'Maximum Okta global session lifetime' to 18 hours or less\n7. Save the rule", + "Terraform": "resource \"okta_policy_rule_signon\" \"prowler_session_lifetime_18h\" {\n policy_id = okta_policy_signon.default.id\n name = \"Prowler-enforced session lifetime\"\n status = \"ACTIVE\"\n session_lifetime = 1080\n session_persistent = false\n}\n" + }, + "Recommendation": { + "Text": "Configure the Default Global Session Policy so its Priority 1 non-default rule sets the Maximum Okta global session lifetime to 18 hours or less.", + "Url": "https://hub.prowler.com/check/signon_global_session_lifetime_18h" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.py b/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.py new file mode 100644 index 0000000000..6e574e7327 --- /dev/null +++ b/prowler/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h.py @@ -0,0 +1,126 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.signon.signon_client import signon_client +from prowler.providers.okta.services.signon.signon_service import GlobalSessionPolicy + +DEFAULT_THRESHOLD_MINUTES = 18 * 60 + + +class signon_global_session_lifetime_18h(Check): + """STIG V-273203 / OKTA-APP-001665. + + The DISA STIG requires the Okta Default Policy to have an active + Priority 1 rule that is not the built-in Default Rule, and that + rule must set the maximum Okta global session lifetime to the + configured threshold or lower (defaults to 18 hours per STIG; + override via `okta_max_session_lifetime_minutes` in the audit config). + """ + + def execute(self) -> list[CheckReportOkta]: + audit_config = signon_client.audit_config or {} + threshold = audit_config.get( + "okta_max_session_lifetime_minutes", DEFAULT_THRESHOLD_MINUTES + ) + org_domain = signon_client.provider.identity.org_domain + policy = self._get_default_policy() + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + + if policy.id == "default-policy-missing": + report.status = "FAIL" + report.status_extended = ( + "Default Global Session Policy was not found. STIG V-273203 " + "requires the Default Policy to contain an active Priority 1 " + f"non-default rule with session lifetime <= {threshold} minutes." + ) + return [report] + + if policy.status and policy.status.upper() != "ACTIVE": + report.status = "FAIL" + report.status_extended = ( + f"Default Global Session Policy '{policy.name}' is in " + f"status '{policy.status}'. STIG V-273203 requires an active " + "Default Policy with an active Priority 1 non-default rule." + ) + return [report] + + active_rules = sorted( + [ + rule + for rule in policy.rules + if not rule.status or rule.status.upper() == "ACTIVE" + ], + key=lambda rule: ( + rule.priority if rule.priority is not None else float("inf"), + rule.name, + ), + ) + if not active_rules: + report.status = "FAIL" + report.status_extended = ( + f"Default Global Session Policy '{policy.name}' has no active " + "rules. STIG V-273203 requires an active Priority 1 non-default " + f"rule with session lifetime <= {threshold} minutes." + ) + return [report] + + priority_one_rule = active_rules[0] + if priority_one_rule.priority != 1: + report.status = "FAIL" + report.status_extended = ( + f"Default Global Session Policy '{policy.name}' has no active " + f"Priority 1 rule. The first active rule is '{priority_one_rule.name}' " + f"at priority {priority_one_rule.priority}." + ) + return [report] + + if priority_one_rule.is_default or priority_one_rule.name == "Default Rule": + report.status = "FAIL" + report.status_extended = ( + f"Default Global Session Policy '{policy.name}' uses " + f"'{priority_one_rule.name}' as its active Priority 1 rule. " + "The STIG requires a non-default Priority 1 rule." + ) + return [report] + + lifetime = priority_one_rule.max_session_lifetime_minutes + if lifetime is None: + report.status = "FAIL" + report.status_extended = ( + f"Priority 1 non-default rule '{priority_one_rule.name}' in " + f"Default Global Session Policy '{policy.name}' does not define " + "a maximum Okta global session lifetime." + ) + return [report] + + if lifetime <= threshold: + report.status = "PASS" + report.status_extended = ( + f"Priority 1 non-default rule '{priority_one_rule.name}' in " + f"Default Global Session Policy '{policy.name}' sets the " + f"maximum Okta global session lifetime to {lifetime} minutes, " + f"meeting the configured threshold of {threshold} minutes." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Priority 1 non-default rule '{priority_one_rule.name}' in " + f"Default Global Session Policy '{policy.name}' sets the " + f"maximum Okta global session lifetime to {lifetime} minutes, " + f"exceeding the configured threshold of {threshold} minutes." + ) + return [report] + + @staticmethod + def _get_default_policy() -> GlobalSessionPolicy: + for policy in signon_client.global_session_policies.values(): + if policy.is_default or policy.name == "Default Policy": + return policy + return GlobalSessionPolicy( + id="default-policy-missing", + name="Default Policy", + priority=1, + status="MISSING", + is_default=True, + rules=[], + ) diff --git a/tests/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h_test.py b/tests/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h_test.py new file mode 100644 index 0000000000..ee1e6cca31 --- /dev/null +++ b/tests/providers/okta/services/signon/signon_global_session_lifetime_18h/signon_global_session_lifetime_18h_test.py @@ -0,0 +1,232 @@ +from unittest import mock + +from prowler.providers.okta.services.signon.signon_service import ( + GlobalSessionPolicy, + GlobalSessionPolicyRule, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.signon.signon_fixtures import ( + build_signon_client, + custom_policy, + default_policy, + default_rule, + non_default_rule, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.signon." + "signon_global_session_lifetime_18h." + "signon_global_session_lifetime_18h.signon_client" +) + + +class Test_signon_global_session_lifetime_18h: + def test_no_policies(self): + signon_client = build_signon_client({}) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_global_session_lifetime_18h.signon_global_session_lifetime_18h import ( + signon_global_session_lifetime_18h, + ) + + findings = signon_global_session_lifetime_18h().execute() + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "was not found" in findings[0].status_extended + + def test_pass_when_priority_one_non_default_rule_is_compliant(self): + policy = default_policy( + [ + non_default_rule("18h rule", lifetime_min=1080, priority=1), + default_rule(priority=2), + ] + ) + signon_client = build_signon_client({"pol-default": policy}) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_global_session_lifetime_18h.signon_global_session_lifetime_18h import ( + signon_global_session_lifetime_18h, + ) + + findings = signon_global_session_lifetime_18h().execute() + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "18h rule" in findings[0].status_extended + assert "1080 minutes" in findings[0].status_extended + + def test_fail_when_lifetime_exceeds_threshold(self): + policy = default_policy( + [ + non_default_rule("Loose 24h rule", lifetime_min=1440, priority=1), + default_rule(priority=2), + ] + ) + signon_client = build_signon_client({"pol-default": policy}) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_global_session_lifetime_18h.signon_global_session_lifetime_18h import ( + signon_global_session_lifetime_18h, + ) + + findings = signon_global_session_lifetime_18h().execute() + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "1440 minutes" in findings[0].status_extended + assert "exceeding the configured threshold" in findings[0].status_extended + + def test_fail_when_priority_one_rule_has_no_lifetime(self): + policy = default_policy( + [ + GlobalSessionPolicyRule( + id="rule-no-session", + name="No Session Block", + priority=1, + status="ACTIVE", + is_default=False, + max_session_lifetime_minutes=None, + ), + default_rule(priority=2), + ] + ) + signon_client = build_signon_client({"pol-default": policy}) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_global_session_lifetime_18h.signon_global_session_lifetime_18h import ( + signon_global_session_lifetime_18h, + ) + + findings = signon_global_session_lifetime_18h().execute() + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "does not define" in findings[0].status_extended + + def test_fail_when_default_rule_is_priority_one(self): + policy = default_policy( + [ + default_rule(priority=1, lifetime_min=1080), + non_default_rule("Compliant", lifetime_min=1080, priority=2), + ] + ) + signon_client = build_signon_client({"pol-default": policy}) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_global_session_lifetime_18h.signon_global_session_lifetime_18h import ( + signon_global_session_lifetime_18h, + ) + + findings = signon_global_session_lifetime_18h().execute() + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "uses 'Default Rule' as its active Priority 1 rule" in ( + findings[0].status_extended + ) + + def test_fail_when_default_policy_is_inactive(self): + policy = GlobalSessionPolicy( + id="pol-default", + name="Default Policy", + priority=99, + status="INACTIVE", + is_default=True, + rules=[non_default_rule("18h rule", lifetime_min=1080, priority=1)], + ) + signon_client = build_signon_client({"pol-default": policy}) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_global_session_lifetime_18h.signon_global_session_lifetime_18h import ( + signon_global_session_lifetime_18h, + ) + + findings = signon_global_session_lifetime_18h().execute() + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "status 'INACTIVE'" in findings[0].status_extended + + def test_ignores_other_custom_policies(self): + compliant_default = default_policy( + [ + non_default_rule("18h rule", lifetime_min=1080, priority=1), + default_rule(priority=2), + ] + ) + loose_custom = custom_policy( + [ + non_default_rule("Long-lived admin", lifetime_min=4320, priority=1), + default_rule(priority=2), + ] + ) + signon_client = build_signon_client( + {"pol-custom": loose_custom, "pol-default": compliant_default} + ) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_global_session_lifetime_18h.signon_global_session_lifetime_18h import ( + signon_global_session_lifetime_18h, + ) + + findings = signon_global_session_lifetime_18h().execute() + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_name == "Default Policy" + + def test_threshold_overridden_via_audit_config(self): + policy = default_policy( + [ + non_default_rule("Relaxed 24h", lifetime_min=1440, priority=1), + default_rule(priority=2), + ] + ) + signon_client = build_signon_client( + {"pol-default": policy}, + audit_config={"okta_max_session_lifetime_minutes": 1440}, + ) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=signon_client), + ): + from prowler.providers.okta.services.signon.signon_global_session_lifetime_18h.signon_global_session_lifetime_18h import ( + signon_global_session_lifetime_18h, + ) + + findings = signon_global_session_lifetime_18h().execute() + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "threshold of 1440 minutes" in findings[0].status_extended