feat(sdk): add signon_dod_warning_banner_configured Okta check

- Enforce DISA STIG V-273192 / OKTA-APP-000200 on Okta brand sign-in pages
- Inspect customized sign-in page HTML for DTM-08-060 banner markers per brand
- Extend Signon service to fetch brands and customized sign-in pages
- Add okta.brands.read to default scopes, CLI args, provider, docs and fixtures
- Return MANUAL when a brand has no customization or the API rejects access
This commit is contained in:
Daniel Barranquero
2026-05-19 12:58:24 +02:00
parent 327a601a67
commit 427e8e1600
11 changed files with 548 additions and 18 deletions
@@ -30,15 +30,17 @@ If a different authentication method is needed (SSWS API token, OAuth with user
### Required OAuth Scopes
For the initial check (`signon_global_session_idle_timeout_15min`) only one scope is required:
The bundled signon checks require the following read-only scopes:
- `okta.policies.read`
- `okta.brands.read`
Additional scopes will be needed as more services and checks are added, this are the current ones needed:
Additional scopes will be needed as more services and checks are added. These are the current ones needed:
| Scope | Used by |
|---|---|
| `okta.policies.read` | Sign-on / password / authentication policies |
| `okta.brands.read` | Sign-in page customizations (DOD Notice and Consent Banner check) |
### Required Admin Role
@@ -96,7 +98,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. For the initial release, granting only `okta.policies.read` is sufficient.
On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled signon checks require `okta.policies.read` and `okta.brands.read`.
![Okta — grant OAuth scopes](/user-guide/providers/okta/images/grant-permissions.png)
@@ -130,8 +132,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"
export OKTA_SCOPES="okta.policies.read"
# Optional — defaults to "okta.policies.read,okta.brands.read"
export OKTA_SCOPES="okta.policies.read,okta.brands.read"
uv run python prowler-cli.py okta
```
@@ -172,7 +174,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`** — the `okta.policies.read` scope is not granted on the service app. Grant it from **Okta API Scopes**.
- **`invalid_scope`** — one of the requested scopes (`okta.policies.read` or `okta.brands.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
- **`Forbidden` / `not authorized`** — the **Read-Only Administrator** role is not assigned to the service app. Assign it from **Admin roles**.
### `invalid_dpop_proof`
@@ -12,7 +12,7 @@ Set up authentication for Okta with the [Okta Authentication](/user-guide/provid
- An Okta organization. The UI examples below use **Identity Engine** terminology such as **Global Session Policy**; Classic Engine exposes the equivalent sign-on policy concepts under older names.
- A **Super Administrator** account on that organization for the one-time service-app setup.
- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read` scope granted and the **Read-Only Administrator** role assigned.
- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read` and `okta.brands.read` scopes granted and the **Read-Only Administrator** role assigned.
- Python 3.10+ and Prowler 5.27.0 or later installed locally.
<CardGroup cols={2}>
@@ -44,8 +44,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"
export OKTA_SCOPES="okta.policies.read"
# Optional — defaults to "okta.policies.read,okta.brands.read"
export OKTA_SCOPES="okta.policies.read,okta.brands.read"
```
The private key file may contain either a PEM-encoded RSA key or a JWK JSON document.
@@ -113,20 +113,21 @@ 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 default is a single scope that covers the bundled initial check:
Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover the bundled signon checks:
- `okta.policies.read`
- `okta.brands.read`
The service app must have that scope granted in the **Okta API Scopes** tab. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization.
The service app must have these scopes granted in the **Okta API Scopes** tab. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization.
When additional checks are enabled — or when running against a service app that exposes a different scope set — override the default with `OKTA_SCOPES` (comma-separated string for the env var) or `--okta-scopes` (space-separated list for the CLI):
```bash
# Environment variable — comma-separated
export OKTA_SCOPES="okta.policies.read,okta.apps.read,okta.users.read"
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.users.read"
# CLI flag — space-separated
prowler okta --okta-scopes okta.policies.read okta.apps.read okta.users.read
prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.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/).
@@ -35,8 +35,8 @@ def init_parser(self):
nargs="+",
help=(
"OAuth scopes to request, space-separated "
"(e.g. okta.policies.read okta.users.read). Defaults to the "
"read scopes required by the bundled checks."
"(e.g. okta.policies.read okta.brands.read okta.users.read). "
"Defaults to the read scopes required by the bundled checks."
),
default=None,
metavar="OKTA_SCOPES",
+1 -1
View File
@@ -29,7 +29,7 @@ from prowler.providers.okta.exceptions.exceptions import (
from prowler.providers.okta.lib.mutelist.mutelist import OktaMutelist
from prowler.providers.okta.models import OktaIdentityInfo, OktaSession
DEFAULT_SCOPES = ["okta.policies.read"]
DEFAULT_SCOPES = ["okta.policies.read", "okta.brands.read"]
# Accept only Okta-managed domains. Custom (vanity) domains are rejected on
# purpose — they're a recurring source of typos and silent misconfig and
# Prowler's audience overwhelmingly uses Okta-managed hosts. The TLDs below
@@ -0,0 +1,38 @@
{
"Provider": "okta",
"CheckID": "signon_dod_warning_banner_configured",
"CheckTitle": "Okta sign-in page displays the Standard Mandatory DOD Notice and Consent Banner",
"CheckType": [],
"ServiceName": "signon",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "governance",
"Description": "Each Okta brand's sign-in page must present the Standard Mandatory DOD Notice and Consent Banner (DTM-08-060) before granting access (DISA STIG V-273192 / OKTA-APP-000200). The check inspects the customized sign-in page HTML for distinctive banner phrases. Brands without a customized sign-in page fall through to MANUAL: the default Okta page is not retrievable via the Management API.",
"Risk": "Without the DOD Notice and Consent Banner, users are not informed that the system is a U.S. Government information system subject to monitoring. The absence of the banner weakens the legal basis for incident response and prosecution and breaks alignment with federal laws, Executive Orders, directives, policies, regulations, standards, and guidance.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://help.okta.com/oie/en-us/content/topics/settings/settings-customization.htm",
"https://developer.okta.com/docs/api/openapi/okta-management/management/tag/CustomPages/",
"https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Brands/"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Follow the supplemental 'Okta DOD Warning Banner Configuration Guide' shipped with the DISA Okta IDaaS STIG package\n2. Sign in to the Okta Admin Console as a Super Admin\n3. Go to Customizations > Brands and select the brand\n4. Edit the Sign-in page customization\n5. Insert the full DTM-08-060 Standard Mandatory DOD Notice and Consent Banner text in the page content\n6. Publish the customization and verify the banner is presented before sign-in",
"Terraform": ""
},
"Recommendation": {
"Text": "Customize the Okta sign-in page for each brand to display the Standard Mandatory DOD Notice and Consent Banner (DTM-08-060) before users authenticate.",
"Url": "https://hub.prowler.com/check/signon_dod_warning_banner_configured"
}
},
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,105 @@
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 SignInPage
# Distinctive substrings drawn from the DTM-08-060 Standard Mandatory DOD
# Notice and Consent Banner. The full banner is ~1300 characters and is
# highly variable across HTML encodings, so the check matches on a
# tolerant marker set: a customized sign-in page is considered to carry
# the banner when at least two of these markers appear (case-insensitive).
BANNER_MARKERS = (
"u.s. government",
"usg information system",
"only authorized use",
"may be intercepted",
)
MIN_MARKER_MATCHES = 2
class signon_dod_warning_banner_configured(Check):
"""STIG V-273192 / OKTA-APP-000200.
Okta must display the Standard Mandatory DOD Notice and Consent
Banner (DTM-08-060) before granting access to the application. The
check inspects each brand's customized sign-in page; tenants without
a customized page fall through to MANUAL because the default Okta
page content is not retrievable via the Management API.
"""
def execute(self) -> list[CheckReportOkta]:
org_domain = signon_client.provider.identity.org_domain
findings: list[CheckReportOkta] = []
if not signon_client.sign_in_pages:
placeholder = SignInPage(
brand_id="no-brands-detected",
brand_name="(no brands detected)",
is_customized=False,
)
report = CheckReportOkta(
metadata=self.metadata(),
resource=placeholder,
org_domain=org_domain,
resource_name=placeholder.brand_name,
resource_id=placeholder.brand_id,
)
report.status = "MANUAL"
report.status_extended = (
"No Okta brands were retrieved from the Brands API. Verify "
"the sign-in page for the organization displays the DOD "
"Notice and Consent Banner (DTM-08-060) in the Admin Console."
)
findings.append(report)
return findings
for page in signon_client.sign_in_pages.values():
report = CheckReportOkta(
metadata=self.metadata(),
resource=page,
org_domain=org_domain,
resource_name=page.brand_name or page.brand_id,
resource_id=page.brand_id,
)
if page.fetch_error:
report.status = "MANUAL"
report.status_extended = (
f"Could not retrieve the customized sign-in page for "
f"brand '{page.brand_name or page.brand_id}' ({page.fetch_error}). "
"Inspect the brand customization manually to confirm the "
"DOD Notice and Consent Banner (DTM-08-060) is displayed."
)
findings.append(report)
continue
if not page.is_customized or not page.page_content:
report.status = "MANUAL"
report.status_extended = (
f"No customized sign-in page is configured for brand "
f"'{page.brand_name or page.brand_id}'. The DOD Notice "
"and Consent Banner cannot be audited via API — verify "
"the default sign-in page in the Admin Console."
)
findings.append(report)
continue
content_lower = page.page_content.lower()
matches = sum(1 for marker in BANNER_MARKERS if marker in content_lower)
if matches >= MIN_MARKER_MATCHES:
report.status = "PASS"
report.status_extended = (
f"DOD Notice and Consent Banner detected on the customized "
f"sign-in page for brand '{page.brand_name or page.brand_id}' "
f"({matches} of {len(BANNER_MARKERS)} marker phrases matched)."
)
else:
report.status = "FAIL"
report.status_extended = (
f"Customized sign-in page for brand "
f"'{page.brand_name or page.brand_id}' does not contain "
"the DOD Notice and Consent Banner (DTM-08-060)."
)
findings.append(report)
return findings
@@ -30,11 +30,16 @@ def _next_after_cursor(resp) -> Optional[str]:
class Signon(OktaService):
"""Fetches OKTA_SIGN_ON policies and their rules.
"""Fetches OKTA_SIGN_ON policies, rules, and brand sign-in pages.
Populates `self.global_session_policies` keyed by policy id. Each
policy carries its rules; downstream checks read directly from this
structure.
Also populates `self.sign_in_pages` keyed by brand id with the
customized sign-in page HTML, used by the DOD warning-banner check.
Brands without a customized page are tracked with `is_customized=False`
so the check can render a MANUAL finding.
"""
def __init__(self, provider):
@@ -42,6 +47,7 @@ class Signon(OktaService):
self.global_session_policies: dict[str, GlobalSessionPolicy] = (
self._list_global_session_policies()
)
self.sign_in_pages: dict[str, SignInPage] = self._list_sign_in_pages()
def _list_global_session_policies(self) -> dict:
logger.info("Signon - Listing OKTA_SIGN_ON policies and rules...")
@@ -125,6 +131,63 @@ class Signon(OktaService):
)
return rules_out
def _list_sign_in_pages(self) -> dict:
logger.info("Signon - Listing brand sign-in pages...")
try:
return self._run(self._fetch_brands_and_pages())
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return {}
async def _fetch_brands_and_pages(self) -> dict:
result: dict[str, SignInPage] = {}
all_brands, err = await self._paginate(
lambda after: self.client.list_brands(after=after)
)
if err is not None:
logger.error(f"Error listing brands: {err}")
return result
for brand in all_brands:
brand_id = getattr(brand, "id", "") or ""
brand_name = getattr(brand, "name", "") or ""
page_result = await self.client.get_customized_sign_in_page(brand_id)
page_err = page_result[-1]
page_data = page_result[0]
if page_err is not None:
err_text = str(page_err).lower()
# 404 is the documented response when a brand has no
# customized sign-in page yet. The default Okta page still
# shows, but its content is not retrievable via API — flag
# the brand for MANUAL review downstream.
if (
"404" in err_text
or "not found" in err_text
or "e0000007" in err_text
):
result[brand_id] = SignInPage(
brand_id=brand_id,
brand_name=brand_name,
is_customized=False,
)
else:
result[brand_id] = SignInPage(
brand_id=brand_id,
brand_name=brand_name,
is_customized=False,
fetch_error=str(page_err),
)
continue
result[brand_id] = SignInPage(
brand_id=brand_id,
brand_name=brand_name,
is_customized=True,
page_content=getattr(page_data, "page_content", None),
)
return result
@staticmethod
async def _paginate(fetch):
"""Drain all pages of an SDK list call.
@@ -176,3 +239,11 @@ class GlobalSessionPolicy(BaseModel):
status: str = ""
is_default: bool = False
rules: list[GlobalSessionPolicyRule] = []
class SignInPage(BaseModel):
brand_id: str
brand_name: str = ""
is_customized: bool = False
page_content: Optional[str] = None
fetch_error: Optional[str] = None
+1 -1
View File
@@ -16,7 +16,7 @@ def set_mocked_okta_provider(
session = OktaSession(
org_domain=OKTA_ORG_DOMAIN,
client_id=OKTA_CLIENT_ID,
scopes=["okta.policies.read"],
scopes=["okta.policies.read", "okta.brands.read"],
private_key=OKTA_PRIVATE_KEY,
)
if identity is None:
@@ -0,0 +1,178 @@
from unittest import mock
from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
from tests.providers.okta.services.signon.signon_fixtures import (
DOD_BANNER_HTML_SNIPPET,
build_signon_client,
sign_in_page,
)
CHECK_PATH = (
"prowler.providers.okta.services.signon."
"signon_dod_warning_banner_configured."
"signon_dod_warning_banner_configured.signon_client"
)
class Test_signon_dod_warning_banner_configured:
def test_manual_when_no_brands_detected(self):
signon_client = build_signon_client(sign_in_pages={})
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_dod_warning_banner_configured.signon_dod_warning_banner_configured import (
signon_dod_warning_banner_configured,
)
findings = signon_dod_warning_banner_configured().execute()
assert len(findings) == 1
assert findings[0].status == "MANUAL"
assert "No Okta brands were retrieved" in findings[0].status_extended
def test_pass_when_customized_page_contains_banner(self):
page = sign_in_page(
brand_id="brand-1",
brand_name="Primary",
is_customized=True,
page_content=f"<html><body>{DOD_BANNER_HTML_SNIPPET}</body></html>",
)
signon_client = build_signon_client(sign_in_pages={"brand-1": page})
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_dod_warning_banner_configured.signon_dod_warning_banner_configured import (
signon_dod_warning_banner_configured,
)
findings = signon_dod_warning_banner_configured().execute()
assert len(findings) == 1
assert findings[0].status == "PASS"
assert "DOD Notice and Consent Banner detected" in (
findings[0].status_extended
)
def test_fail_when_customized_page_missing_banner(self):
page = sign_in_page(
brand_id="brand-1",
brand_name="Primary",
is_customized=True,
page_content="<html><body><h1>Welcome to ACME</h1></body></html>",
)
signon_client = build_signon_client(sign_in_pages={"brand-1": page})
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_dod_warning_banner_configured.signon_dod_warning_banner_configured import (
signon_dod_warning_banner_configured,
)
findings = signon_dod_warning_banner_configured().execute()
assert len(findings) == 1
assert findings[0].status == "FAIL"
assert "does not contain" in findings[0].status_extended
def test_manual_when_no_customization(self):
page = sign_in_page(
brand_id="brand-1",
brand_name="Primary",
is_customized=False,
page_content=None,
)
signon_client = build_signon_client(sign_in_pages={"brand-1": page})
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_dod_warning_banner_configured.signon_dod_warning_banner_configured import (
signon_dod_warning_banner_configured,
)
findings = signon_dod_warning_banner_configured().execute()
assert len(findings) == 1
assert findings[0].status == "MANUAL"
assert "No customized sign-in page" in findings[0].status_extended
def test_manual_when_fetch_error(self):
page = sign_in_page(
brand_id="brand-1",
brand_name="Primary",
is_customized=False,
fetch_error="403 Forbidden: invalid_scope",
)
signon_client = build_signon_client(sign_in_pages={"brand-1": page})
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_dod_warning_banner_configured.signon_dod_warning_banner_configured import (
signon_dod_warning_banner_configured,
)
findings = signon_dod_warning_banner_configured().execute()
assert len(findings) == 1
assert findings[0].status == "MANUAL"
assert "Could not retrieve" in findings[0].status_extended
assert "403" in findings[0].status_extended
def test_emits_one_finding_per_brand(self):
compliant = sign_in_page(
brand_id="brand-prod",
brand_name="Prod",
is_customized=True,
page_content=f"<html>{DOD_BANNER_HTML_SNIPPET}</html>",
)
missing = sign_in_page(
brand_id="brand-sandbox",
brand_name="Sandbox",
is_customized=True,
page_content="<html><body>No banner here</body></html>",
)
no_custom = sign_in_page(
brand_id="brand-legacy",
brand_name="Legacy",
is_customized=False,
)
signon_client = build_signon_client(
sign_in_pages={
"brand-prod": compliant,
"brand-sandbox": missing,
"brand-legacy": no_custom,
}
)
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_dod_warning_banner_configured.signon_dod_warning_banner_configured import (
signon_dod_warning_banner_configured,
)
findings = signon_dod_warning_banner_configured().execute()
assert len(findings) == 3
by_brand = {f.resource_id: f.status for f in findings}
assert by_brand == {
"brand-prod": "PASS",
"brand-sandbox": "FAIL",
"brand-legacy": "MANUAL",
}
@@ -3,6 +3,7 @@ from unittest import mock
from prowler.providers.okta.services.signon.signon_service import (
GlobalSessionPolicy,
GlobalSessionPolicyRule,
SignInPage,
Signon,
_next_after_cursor,
)
@@ -48,12 +49,29 @@ def _fake_rule(
return r
def _fake_brand(brand_id: str, name: str):
b = mock.MagicMock()
b.id = brand_id
b.name = name
return b
def _fake_sign_in_page(page_content: str):
p = mock.MagicMock()
p.page_content = page_content
return p
def _resp(headers: dict = None):
r = mock.MagicMock()
r.headers = headers or {}
return r
async def _empty_brands(*_a, **_k):
return ([], _resp({}), None)
class Test_next_after_cursor:
def test_no_resp_returns_none(self):
assert _next_after_cursor(None) is None
@@ -97,6 +115,7 @@ class Test_Signon_service:
mocked = mock.MagicMock()
mocked.list_policies = fake_list_policies
mocked.list_policy_rules = fake_list_rules
mocked.list_brands = _empty_brands
mocked_client_cls.return_value = mocked
service = Signon(provider)
@@ -140,6 +159,7 @@ class Test_Signon_service:
mocked = mock.MagicMock()
mocked.list_policies = fake_list_policies
mocked.list_policy_rules = fake_list_rules
mocked.list_brands = _empty_brands
mocked_client_cls.return_value = mocked
service = Signon(provider)
@@ -157,7 +177,122 @@ class Test_Signon_service:
) as mocked_client_cls:
mocked = mock.MagicMock()
mocked.list_policies = failing
mocked.list_brands = _empty_brands
mocked_client_cls.return_value = mocked
service = Signon(provider)
assert service.global_session_policies == {}
class Test_Signon_service_brands:
"""Brand sign-in page fetching for the DOD banner check."""
def _build_with_brands(
self,
provider,
brands_response,
sign_in_page_responses: dict,
):
async def fake_list_policies(*_a, **_k):
return ([], _resp({}), None)
async def fake_list_brands(*_a, **_k):
return brands_response
async def fake_get_sign_in_page(brand_id, *_a, **_k):
return sign_in_page_responses[brand_id]
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_brands = fake_list_brands
mocked.get_customized_sign_in_page = fake_get_sign_in_page
mocked_client_cls.return_value = mocked
return Signon(provider)
def test_fetches_brand_with_customized_page(self):
provider = set_mocked_okta_provider()
brand = _fake_brand("brand-1", "Primary")
page = _fake_sign_in_page("<html>banner here</html>")
service = self._build_with_brands(
provider,
brands_response=([brand], _resp({}), None),
sign_in_page_responses={"brand-1": (page, _resp({}), None)},
)
assert "brand-1" in service.sign_in_pages
result = service.sign_in_pages["brand-1"]
assert isinstance(result, SignInPage)
assert result.is_customized is True
assert result.page_content == "<html>banner here</html>"
assert result.fetch_error is None
def test_404_marks_brand_as_not_customized(self):
provider = set_mocked_okta_provider()
brand = _fake_brand("brand-1", "Primary")
service = self._build_with_brands(
provider,
brands_response=([brand], _resp({}), None),
sign_in_page_responses={
"brand-1": (None, _resp({}), Exception("404 Not Found"))
},
)
assert service.sign_in_pages["brand-1"].is_customized is False
assert service.sign_in_pages["brand-1"].fetch_error is None
def test_403_captured_into_fetch_error(self):
provider = set_mocked_okta_provider()
brand = _fake_brand("brand-1", "Primary")
service = self._build_with_brands(
provider,
brands_response=([brand], _resp({}), None),
sign_in_page_responses={
"brand-1": (None, _resp({}), Exception("403 Forbidden: invalid_scope"))
},
)
result = service.sign_in_pages["brand-1"]
assert result.is_customized is False
assert "403" in result.fetch_error
def test_returns_empty_on_brands_api_error(self):
provider = set_mocked_okta_provider()
async def fake_list_policies(*_a, **_k):
return ([], _resp({}), None)
async def failing_brands(*_a, **_k):
return ([], _resp({}), Exception("Brands API unavailable"))
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_brands = failing_brands
mocked_client_cls.return_value = mocked
service = Signon(provider)
assert service.sign_in_pages == {}
def test_handles_multiple_brands(self):
provider = set_mocked_okta_provider()
brand_a = _fake_brand("brand-a", "Brand A")
brand_b = _fake_brand("brand-b", "Brand B")
page_a = _fake_sign_in_page("<html>A</html>")
service = self._build_with_brands(
provider,
brands_response=([brand_a, brand_b], _resp({}), None),
sign_in_page_responses={
"brand-a": (page_a, _resp({}), None),
"brand-b": (None, _resp({}), Exception("404 not found")),
},
)
assert set(service.sign_in_pages.keys()) == {"brand-a", "brand-b"}
assert service.sign_in_pages["brand-a"].page_content == "<html>A</html>"
assert service.sign_in_pages["brand-b"].is_customized is False