Compare commits

..

7 Commits

Author SHA1 Message Date
Hugo P.Brito fc1cc1ccf0 feat(oci): add storage admin delete exclusion check
- Add OCI IAM policy check for CIS 3.1 storage admin scope

- Require explicit delete permission exclusions for storage manage grants

- Cover resource, family, all-resources, and disjunctive condition cases
2026-06-10 13:52:44 +02:00
Aryan Bhaskar ec0bb53839 feat(bedrock): add bedrock_agent_role_least_privilege check (#11335)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-06-10 12:40:54 +02:00
Pedro Martín bfb3fcea4c fix(e2e): use branch SDK changes to create the container (#11522) 2026-06-10 11:34:35 +02:00
Pedro Martín 61cd4aea3f feat(compliance): add Okta IDaaS STIG V1R2 framework (#11428)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-06-10 11:22:42 +02:00
StylusFrost 01b49f0743 feat(dashboard): render dynamic-provider compliance frameworks (#11503)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-06-10 11:16:39 +02:00
Pedro Martín 4a5a49b5bb fix(api): store and refresh Resource.name on every scan (#11476)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-06-10 10:55:31 +02:00
Alan Buscaglia a21cb64a94 fix(ui): extend integration poll timeouts to 60s (#11519) 2026-06-10 10:34:50 +02:00
88 changed files with 3393 additions and 2295 deletions
+11 -1
View File
@@ -134,7 +134,17 @@ jobs:
# docker-compose.yml references prowlercloud/prowler-api:latest from the registry,
# which lags behind PR changes; build locally so E2E exercises the API image
# produced by this PR.
run: docker build -t prowlercloud/prowler-api:latest ./api
#
# The image installs the SDK from git@master (api/uv.lock), so a PR changing BOTH the SDK
# and the API would run against the OLD SDK and crash on startup. Overlay the checkout's
# SDK source so both run together. New SDK dependencies still need an api/uv.lock bump.
run: |
docker build -t prowlercloud/prowler-api:pr-base ./api
docker build -t prowlercloud/prowler-api:latest -f - prowler <<'DOCKERFILE'
FROM prowlercloud/prowler-api:pr-base
RUN rm -rf /home/prowler/.venv/lib/python3.12/site-packages/prowler
COPY --chown=prowler:prowler . /home/prowler/.venv/lib/python3.12/site-packages/prowler
DOCKERFILE
- name: Start API services
run: |
+2
View File
@@ -9,6 +9,7 @@ All notable changes to the **Prowler API** are documented in this file.
- Opt-in automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: when enabled via `DJANGO_TASK_RECOVERY_ENABLED` (off by default), stuck summary and deletion tasks are detected and re-run instead of staying pending forever (scan and Jira tasks are excluded), with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
- Label Postgres connections with `application_name="<component>:<alias>"` (component injected per process via `DJANGO_APP_COMPONENT`) so connections are attributable by component in `pg_stat_activity` [(#11494)](https://github.com/prowler-cloud/prowler/pull/11494)
- DISA Okta IDaaS STIG V1R2 compliance framework export support for the Okta provider [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428)
### 🔄 Changed
@@ -17,6 +18,7 @@ All notable changes to the **Prowler API** are documented in this file.
### 🐞 Fixed
- Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- Resource `name` is now stored and refreshed on every scan, so resources no longer keep an empty name [(#11476)](https://github.com/prowler-cloud/prowler/pull/11476)
### 🔐 Security
+6
View File
@@ -58,6 +58,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import (
AzureMitreAttack,
)
from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack
from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import (
OktaIDaaSSTIG,
)
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import (
ProwlerThreatScoreAlibaba,
)
@@ -152,6 +155,9 @@ COMPLIANCE_CLASS_MAP = {
ProwlerThreatScoreAlibaba,
),
],
"okta": [
(lambda name: name.startswith("okta_idaas_stig"), OktaIDaaSSTIG),
],
}
+9
View File
@@ -269,6 +269,7 @@ def _store_resources(
provider=provider_instance,
uid=finding.resource_uid,
defaults={
"name": finding.resource_name,
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
@@ -276,6 +277,7 @@ def _store_resources(
)
if not created:
resource_instance.name = finding.resource_name
resource_instance.region = finding.region
resource_instance.service = finding.service_name
resource_instance.type = finding.resource_type
@@ -704,6 +706,12 @@ def _process_finding_micro_batch(
if finding.region and resource_instance.region != finding.region:
resource_instance.region = finding.region
updated = True
if (
finding.resource_name
and resource_instance.name != finding.resource_name
):
resource_instance.name = finding.resource_name
updated = True
if resource_instance.service != finding.service_name:
resource_instance.service = finding.service_name
updated = True
@@ -945,6 +953,7 @@ def _process_finding_micro_batch(
Resource.objects.bulk_update(
resources_to_bulk_update,
[
"name",
"metadata",
"details",
"partition",
+73
View File
@@ -315,6 +315,7 @@ class TestPerformScan:
provider=provider_instance,
uid=finding.resource_uid,
defaults={
"name": finding.resource_name,
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
@@ -348,6 +349,7 @@ class TestPerformScan:
resource_instance = MagicMock()
resource_instance.uid = finding.resource_uid
resource_instance.name = "old_name"
resource_instance.region = "us-west-1"
resource_instance.service = "old_service"
resource_instance.type = "old_type"
@@ -366,6 +368,7 @@ class TestPerformScan:
provider=provider_instance,
uid=finding.resource_uid,
defaults={
"name": finding.resource_name,
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
@@ -373,6 +376,7 @@ class TestPerformScan:
)
# Check that resource fields were updated
assert resource_instance.name == finding.resource_name
assert resource_instance.region == finding.region
assert resource_instance.service == finding.service_name
assert resource_instance.type == finding.resource_type
@@ -1565,6 +1569,75 @@ class TestProcessFindingMicroBatch:
assert resource_cache[finding.resource_uid].service == finding.service_name
assert tag_cache.keys() == {("team", "devsec")}
def test_process_finding_micro_batch_refreshes_empty_resource_name(
self, tenants_fixture, scans_fixture
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = scan.provider
# Old resource stored before names were persisted: empty name.
existing_resource = Resource.objects.create(
tenant_id=tenant.id,
provider=provider,
uid="arn:aws:s3:::my-bucket",
name="",
region="us-east-1",
service="s3",
type="bucket",
)
finding = FakeFinding(
uid="finding-empty-name",
status=StatusChoices.PASS,
status_extended="passing",
severity=Severity.low,
check_id="s3_bucket_public_access",
resource_uid=existing_resource.uid,
resource_name="my-bucket",
region="us-east-1",
service_name="s3",
resource_type="bucket",
partition="aws",
raw={"status": "PASS"},
metadata={"source": "prowler"},
)
resource_cache = {existing_resource.uid: existing_resource}
tag_cache = {}
last_status_cache = {}
resource_failed_findings_cache = {existing_resource.uid: 0}
unique_resources: set[tuple[str, str]] = set()
scan_resource_cache: set[tuple[str, str, str, str]] = set()
mute_rules_cache = {}
scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {}
scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {}
group_resources_cache: dict[str, set] = {}
with (
patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction),
patch("api.db_utils.rls_transaction", new=noop_rls_transaction),
):
_process_finding_micro_batch(
str(tenant.id),
[finding],
scan,
provider,
resource_cache,
tag_cache,
last_status_cache,
resource_failed_findings_cache,
unique_resources,
scan_resource_cache,
mute_rules_cache,
scan_categories_cache,
scan_resource_groups_cache,
group_resources_cache,
)
existing_resource.refresh_from_db()
assert existing_resource.name == finding.resource_name
def test_process_finding_micro_batch_skips_long_uid(
self, tenants_fixture, scans_fixture
):
+180
View File
@@ -1538,6 +1538,186 @@ def get_section_container_iso(data, section_1, section_2):
return html.Div(section_containers, className="compliance-data-layout")
def _status_bar(success, failed, classname):
"""Build the stacked PASS/FAIL bar shown next to an accordion title."""
fig = go.Figure(
data=[
go.Bar(
name="Failed",
x=[failed],
y=[""],
orientation="h",
marker=dict(color="#e77676"),
width=[0.8],
),
go.Bar(
name="Success",
x=[success],
y=[""],
orientation="h",
marker=dict(color="#45cc6e"),
width=[0.8],
),
]
)
fig.update_layout(
barmode="stack",
margin=dict(l=10, r=10, t=10, b=10),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
showlegend=False,
width=350,
height=30,
xaxis=dict(showticklabels=False, showgrid=False, zeroline=False),
yaxis=dict(showticklabels=False, showgrid=False, zeroline=False),
annotations=[
dict(
x=success + failed,
y=0,
xref="x",
yref="y",
text=str(success),
showarrow=False,
font=dict(color="#45cc6e", size=14),
xanchor="left",
yanchor="middle",
),
dict(
x=0,
y=0,
xref="x",
yref="y",
text=str(failed),
showarrow=False,
font=dict(color="#e77676", size=14),
xanchor="right",
yanchor="middle",
),
],
)
fig.add_annotation(
x=failed,
y=0.3,
text="|",
showarrow=False,
xanchor="center",
yanchor="middle",
font=dict(size=20),
)
return dcc.Graph(figure=fig, config={"staticPlot": True}, className=classname)
def get_section_containers_generic(data, section_col, id_col):
"""Two-level view: section -> requirement id (+ description) -> checks.
Sorts lexicographically so arbitrary requirement IDs never crash the
version-aware sort used by the CIS renderer.
"""
data["STATUS"] = data["STATUS"].apply(map_status_to_icon)
data[section_col] = data[section_col].astype(str)
data[id_col] = data[id_col].astype(str)
data.sort_values(by=[section_col, id_col], inplace=True)
counts_section = data.groupby([section_col, "STATUS"]).size().unstack(fill_value=0)
counts_id = (
data.groupby([section_col, id_col, "STATUS"]).size().unstack(fill_value=0)
)
def count(counts, key, emoji):
return counts.loc[key, emoji] if emoji in counts.columns else 0
has_description = "REQUIREMENTS_DESCRIPTION" in data.columns
table_cols = ["CHECKID", "STATUS", "REGION", "ACCOUNTID", "RESOURCEID"]
section_containers = []
for section in data[section_col].unique():
graph_div = html.Div(
_status_bar(
count(counts_section, section, pass_emoji),
count(counts_section, section, fail_emoji),
"info-bar",
),
className="graph-section",
)
internal_items = []
for req_id in data[data[section_col] == section][id_col].unique():
specific_data = data[
(data[section_col] == section) & (data[id_col] == req_id)
]
data_table = dash_table.DataTable(
data=specific_data.to_dict("records"),
columns=[
{"name": i, "id": i}
for i in table_cols
if i in specific_data.columns
],
style_table={"overflowX": "auto"},
style_as_list_view=True,
style_cell={"textAlign": "left", "padding": "5px"},
)
graph_div_req = html.Div(
_status_bar(
count(counts_id, (section, req_id), pass_emoji),
count(counts_id, (section, req_id), fail_emoji),
"info-bar-child",
),
className="graph-section-req",
)
title = req_id
if has_description:
title = (
f"{req_id} - {specific_data['REQUIREMENTS_DESCRIPTION'].iloc[0]}"
)
if len(title) > 130:
title = title[:130] + " ..."
internal_items.append(
html.Div(
[
graph_div_req,
dbc.Accordion(
[
dbc.AccordionItem(
title=title,
children=[
html.Div(
[data_table],
className="inner-accordion-content",
)
],
)
],
start_collapsed=True,
flush=True,
),
],
className="accordion-inner--child",
)
)
section_containers.append(
html.Div(
[
graph_div,
dbc.Accordion(
[
dbc.AccordionItem(
title=f"{section}", children=internal_items
)
],
start_collapsed=True,
flush=True,
),
],
className="accordion-inner",
)
)
return html.Div(section_containers, className="compliance-data-layout")
def get_section_containers_format4(data, section_1):
data["STATUS"] = data["STATUS"].apply(map_status_to_icon)
+44
View File
@@ -0,0 +1,44 @@
import warnings
from dashboard.common_methods import (
get_section_containers_format4,
get_section_containers_generic,
)
warnings.filterwarnings("ignore")
def get_table(data):
# Discover REQUIREMENTS_ATTRIBUTES_* columns at runtime.
attr_cols = [c for c in data.columns if c.startswith("REQUIREMENTS_ATTRIBUTES_")]
# Section column (in priority order):
# 1. REQUIREMENTS_ATTRIBUTES_SECTION — most common convention
# 2. First discovered attribute column — covers novel schemas
# 3. None — no section, group flat by requirement id
if "REQUIREMENTS_ATTRIBUTES_SECTION" in attr_cols:
section_col = "REQUIREMENTS_ATTRIBUTES_SECTION"
elif attr_cols:
section_col = attr_cols[0]
else:
section_col = None
base_cols = [
"REQUIREMENTS_ID",
"REQUIREMENTS_DESCRIPTION",
"STATUS",
"CHECKID",
"REGION",
"ACCOUNTID",
"RESOURCEID",
]
# Two levels (section -> requirement id) when a section distinct from the
# id exists; otherwise group flat by requirement id.
if section_col and section_col != "REQUIREMENTS_ID":
needed = [section_col] + base_cols
aux = data[[c for c in needed if c in data.columns]].copy()
return get_section_containers_generic(aux, section_col, "REQUIREMENTS_ID")
aux = data[[c for c in base_cols if c in data.columns]].copy()
return get_section_containers_format4(aux, "REQUIREMENTS_ID")
+1 -1
View File
@@ -156,7 +156,7 @@ def create_layout_compliance(
html.Img(src="assets/favicon.ico", className="w-5 mr-3"),
html.Span("Subscribe to Prowler Cloud"),
],
href="https://prowler.pro/",
href="https://cloud.prowler.com/",
target="_blank",
className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10",
),
+57 -31
View File
@@ -215,6 +215,58 @@ else:
)
def _ensure_scope_columns(data):
"""Guarantee ACCOUNTID and REGION exist.
Scope columns always sit between DESCRIPTION and ASSESSMENTDATE, so derive
them positionally for any provider (e.g. Okta's ORGANIZATIONDOMAIN) and
fall back to "-" to avoid a KeyError.
"""
cols = list(data.columns)
scope = []
if "DESCRIPTION" in cols and "ASSESSMENTDATE" in cols:
start, end = cols.index("DESCRIPTION") + 1, cols.index("ASSESSMENTDATE")
scope = [c for c in cols[start:end] if c not in ("ACCOUNTID", "REGION")]
if "ACCOUNTID" not in data.columns:
if scope:
data.rename(columns={scope.pop(0): "ACCOUNTID"}, inplace=True)
else:
data["ACCOUNTID"] = "-"
if "REGION" not in data.columns:
if scope:
data.rename(columns={scope.pop(0): "REGION"}, inplace=True)
else:
data["REGION"] = "-"
return data
def _dispatch_compliance_renderer(data, analytics_input):
"""Resolve the compliance renderer module and return (table, deduped_data).
Tries to import the framework-specific builtin module. On
ModuleNotFoundError (dynamic/external provider with no dedicated module),
falls back to the generic renderer. Any other ImportError is re-raised.
get_table() is called OUTSIDE the try block so errors inside the renderer
surface as real exceptions rather than being swallowed.
"""
current = analytics_input.replace(".", "_")
target = f"dashboard.compliance.{current}"
try:
module = importlib.import_module(target)
except ModuleNotFoundError as exc:
if exc.name != target:
raise
from dashboard.compliance import generic as module
dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"]
if "MUTED" in data.columns:
dedup_columns.insert(2, "MUTED")
data = data.drop_duplicates(subset=dedup_columns)
if "threatscore" in analytics_input:
data = get_threatscore_mean_by_pillar(data)
return module.get_table(data), data
@callback(
[
Output("output", "children"),
@@ -292,7 +344,7 @@ def display_data(
data.rename(columns={"TENANCYID": "ACCOUNTID"}, inplace=True)
# Filter the chosen level of the CIS
if is_level_1:
if is_level_1 and "REQUIREMENTS_ATTRIBUTES_PROFILE" in data.columns:
data = data[data["REQUIREMENTS_ATTRIBUTES_PROFILE"].str.contains("Level 1")]
# Rename the column PROJECTID to ACCOUNTID for GCP
@@ -314,6 +366,9 @@ def display_data(
data.rename(columns={"SUBSCRIPTION": "ACCOUNTID"}, inplace=True)
data["REGION"] = "-"
# Normalize scope columns for any remaining (e.g. dynamic) provider.
data = _ensure_scope_columns(data)
# Filter ACCOUNT
if account_filter == ["All"]:
updated_cloud_account_values = data["ACCOUNTID"].unique()
@@ -409,36 +464,7 @@ def display_data(
# Check cases where the compliance start with AWS_
if "aws_" in analytics_input:
analytics_input = analytics_input + "_aws"
try:
current = analytics_input.replace(".", "_")
compliance_module = importlib.import_module(
f"dashboard.compliance.{current}"
)
# Build subset list based on available columns
dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"]
if "MUTED" in data.columns:
dedup_columns.insert(2, "MUTED")
data = data.drop_duplicates(subset=dedup_columns)
if "threatscore" in analytics_input:
data = get_threatscore_mean_by_pillar(data)
table = compliance_module.get_table(data)
except ModuleNotFoundError:
table = html.Div(
[
html.H5(
"No data found for this compliance",
className="card-title",
style={"text-align": "left", "color": "black"},
)
],
style={
"width": "99%",
"margin-right": "0.8%",
"margin-bottom": "10px",
},
)
table, data = _dispatch_compliance_renderer(data, analytics_input)
df = data.copy()
# Remove Muted rows
+1 -1
View File
@@ -1538,7 +1538,7 @@ def filter_data(
html.Img(src="assets/favicon.ico", className="w-5 mr-3"),
html.Span("Subscribe to Prowler Cloud"),
],
href="https://prowler.pro/",
href="https://cloud.prowler.com/",
target="_blank",
className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10",
),
@@ -40,76 +40,9 @@ When adding a new configurable check to Prowler, update the following files:
# aws.awslambda_function_vpc_multi_az
lambda_min_azs: 2
```
- **Provider Schema:** Add the typed field to the provider's Pydantic schema in `prowler/config/schema/<provider>.py`. This is required: the loader validates user configs against these schemas and the shipped `config.yaml` must round-trip with zero warnings. See [Adding a parameter to the provider schema](#adding-a-parameter-to-the-provider-schema) below.
- **Test Fixtures:** If tests depend on this configuration, add the variable to `tests/config/fixtures/config.yaml`.
- **Documentation:** Document the new variable in the list of configurable checks in `docs/tutorials/configuration_file.md`.
For a complete list of checks that already support configuration, see the [Configuration File Tutorial](/user-guide/cli/tutorials/configuration_file).
## Adding a parameter to the provider schema
Every provider has a typed Pydantic schema in `prowler/config/schema/`. When a config is loaded, `validate_provider_config` checks each user-supplied key against the schema, logs a warning, and drops any field that fails validation. The consumer's `.get(key, default)` then falls back to the built-in default.
This catches typos in a value (for example, `0.2` typed as `20`, or `"medium"` for an enum that expects `"MEDIUM"`). It does NOT catch typos in a key name: `disalowed_regions` (one `l` missing) is treated as an unknown key and passes through untouched, because third-party check plugins legitimately rely on unknown keys being preserved. Reviewers should still check that any new key the YAML adds is named exactly the same as the field on the schema.
### Where to add the field
1. Open `prowler/config/schema/<provider>.py` (for example, `aws.py`).
2. Add a field on the provider's schema class. Always make it `Optional[...] = None` so the absence of the key is valid.
3. Apply the tightest type the value allows. Examples below.
If you are introducing an entirely new provider rather than a new parameter, also add an entry mapping the provider name to its schema class in `prowler/config/schema/registry.py`. The loader uses that registry to find the schema for the provider it is loading.
### Choosing the right type
| Value kind | Field declaration |
|---|---|
| Boolean toggle | `Optional[bool] = None` |
| Strictly positive integer (days, counts) | `Optional[int] = Field(default=None, gt=0)` |
| Fraction in 0..1 (threshold) | `Optional[float] = Field(default=None, ge=0.0, le=1.0)` |
| Closed set of strings | `Optional[Literal["A", "B", "C"]] = None` |
| Free-form string | `Optional[str] = None` |
| List of strings or ints | `Optional[list[str]] = None` |
Prefer `Literal[...]` over `str` whenever the value is one of a known set. Prefer `Field(gt=0)` over `int` whenever zero or negative would be nonsensical. The point of the schema is to catch real-world mistakes that previously passed silently.
### Custom validators (only when needed)
If the value has structural rules beyond type and range, add a `field_validator`. Examples already in `aws.py`:
- `_validate_port_range` rejects ports outside `0..65535`.
- `_validate_account_ids` rejects anything that isn't a 12-digit AWS account ID.
- `_validate_trusted_ips` rejects entries that aren't a valid IP or CIDR.
Raise `ValueError` from the validator. The framework converts the error into a warning and drops the offending key.
### Example: adding a new parameter
Say a new check needs `max_iam_role_session_hours`, a strictly positive integer that defaults to 12 in code.
1. **Schema** (`prowler/config/schema/aws.py`):
```python
# IAM
max_iam_role_session_hours: Optional[int] = Field(default=None, gt=0)
```
2. **Shipped config** (`prowler/config/config.yaml`):
```yaml
# aws.iam_role_session_duration_within_limit
max_iam_role_session_hours: 12
```
3. **Consumer** (the check):
```python
max_hours = iam_client.audit_config.get("max_iam_role_session_hours", 12)
```
4. **Tests** in `tests/config/schema/aws_schema_test.py`:
- one test for a valid value that round-trips,
- one test for an invalid value (zero, negative, wrong type) that is dropped.
### What the loader guarantees
- **Unknown keys pass through.** Third-party check plugins can introduce arbitrary keys without schema edits; they will not be filtered.
- **Invalid values never crash the run.** They produce a single warning per field and the key is dropped.
- **Coerced values are normalized.** A YAML-quoted `"180"` for an `int` field arrives downstream as the integer `180`.
- **The shipped `config.yaml` must round-trip cleanly.** The integration test `test_shipped_default_config_loads_without_warnings` will fail if a key is added to the YAML without a matching schema field, so the two stay in sync.
This approach ensures that checks are easily configurable, making Prowler highly adaptable to different environments and requirements.
+3 -1
View File
@@ -6,6 +6,8 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🚀 Added
- DISA Okta IDaaS STIG V1R2 compliance framework for the Okta provider, with a dedicated CSV output formatter and terminal summary table [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428)
- `identity_storage_service_level_admins_scoped` check for OCI provider CIS 3.1 control 1.15, ensuring storage service-level administrators exclude delete permissions [(#10567)](https://github.com/prowler-cloud/prowler/issues/10567)
- `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)
@@ -19,6 +21,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070)
- `kms_key_rotation_max_90_days` check for GCP provider, verifying KMS customer-managed keys are rotated every 90 days or less in line with the CIS Benchmark [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516)
- `exchange_mailbox_primary_smtp_uses_custom_domain` check for M365 provider [(#11215)](https://github.com/prowler-cloud/prowler/pull/11215)
- `bedrock_agent_role_least_privilege` check for AWS provider, flagging Bedrock Agent execution roles with full-access managed policies, broad `Resource:*` inline statements, or missing permissions boundaries [(#11335)](https://github.com/prowler-cloud/prowler/pull/11335)
### 🐞 Fixed
@@ -128,7 +131,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `entra_service_principal_no_secrets_for_permanent_tier0_roles` check for M365 provider [(#10788)](https://github.com/prowler-cloud/prowler/pull/10788)
- `iam_user_access_not_stale_to_sagemaker` check for AWS provider with configurable `max_unused_sagemaker_access_days` (default 90) [(#11000)](https://github.com/prowler-cloud/prowler/pull/11000)
- `cloudtrail_bedrock_logging_enabled` check for AWS provider [(#10858)](https://github.com/prowler-cloud/prowler/pull/10858)
- Per-provider scan configuration schema with bounds validation that drops out-of-range values with a warning on config load [(#11518)](https://github.com/prowler-cloud/prowler/pull/11518)
- Okta provider with OAuth 2.0 authentication and `signon_global_session_idle_timeout_15min` check [(#11079)](https://github.com/prowler-cloud/prowler/pull/11079)
- `sagemaker_domain_sso_configured` check for AWS provider [(#11094)](https://github.com/prowler-cloud/prowler/pull/11094)
- Scaleway provider with `iam_api_keys_no_root_owned` check [(#11166)](https://github.com/prowler-cloud/prowler/pull/11166)
+30
View File
@@ -102,6 +102,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import (
AzureMitreAttack,
)
from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack
from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import (
OktaIDaaSSTIG,
)
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import (
ProwlerThreatScoreAlibaba,
)
@@ -1314,6 +1317,33 @@ def prowler():
)
generated_outputs["compliance"].append(generic_compliance)
generic_compliance.batch_write_data_to_file()
elif provider == "okta":
for compliance_name in input_compliance_frameworks:
if compliance_name.startswith("okta_idaas_stig"):
# Generate Okta IDaaS STIG Finding Object
filename = (
f"{output_options.output_directory}/compliance/"
f"{output_options.output_filename}_{compliance_name}.csv"
)
okta_idaas_stig = OktaIDaaSSTIG(
findings=finding_outputs,
compliance=bulk_compliance_frameworks[compliance_name],
file_path=filename,
)
generated_outputs["compliance"].append(okta_idaas_stig)
okta_idaas_stig.batch_write_data_to_file()
else:
filename = (
f"{output_options.output_directory}/compliance/"
f"{output_options.output_filename}_{compliance_name}.csv"
)
generic_compliance = GenericCompliance(
findings=finding_outputs,
compliance=bulk_compliance_frameworks[compliance_name],
file_path=filename,
)
generated_outputs["compliance"].append(generic_compliance)
generic_compliance.batch_write_data_to_file()
else:
# Dynamic fallback: any external/custom provider
try:
@@ -0,0 +1,638 @@
{
"Framework": "Okta-IDaaS-STIG",
"Name": "DISA Okta Identity as a Service (IDaaS) STIG V1R2",
"Version": "1R2",
"Provider": "Okta",
"Description": "Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS), Version 1 Release 2 (Benchmark Date: 05 Jan 2026).",
"Requirements": [
{
"Id": "OKTA-APP-000020",
"Name": "Okta must log out a session after a 15-minute period of inactivity.",
"Description": "A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate physical vicinity of the information system but does not log out because of the temporary nature of the absence. Rather than relying on the user to manually lock their application session prior to vacating the vicinity, applications must be able to identify when a user's application session has idled and take action to initiate the session lock. The session lock is implemented at the point where session activity can be determined and/or controlled. This is typically at the operating system level and results in a system lock. However, it may be at the application level where the application interface window is secured instead. Satisfies: SRG-APP-000003, SRG-APP-000190",
"Checks": [
"signon_global_session_idle_timeout_15min"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273186r1098825_rule",
"StigID": "OKTA-APP-000020",
"CCI": [
"CCI-000057",
"CCI-001133"
],
"CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the edit icon next to the Priority 1 rule. 4. Verify the \"Maximum Okta global session idle time\" is set to 15 minutes. If \"Maximum Okta global session idle time\" is not set to 15 minutes, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the Rules table, make these updates: - Click \"Add rule\". - Set \"Maximum Okta global session idle time\" to 15 minutes."
}
]
},
{
"Id": "OKTA-APP-000025",
"Name": "The Okta Admin Console must log out a session after a 15-minute period of inactivity.",
"Description": "A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate physical vicinity of the information system but does not log out because of the temporary nature of the absence. Rather than relying on the user to manually lock their application session prior to vacating the vicinity, applications must be able to identify when a user's application session has idled and take action to initiate the session lock. The session lock is implemented at the point where session activity can be determined and/or controlled. This is typically at the operating system level and results in a system lock. However, it may be at the application level where the application interface window is secured instead.",
"Checks": [
"application_admin_console_session_idle_timeout_15min"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273187r1098828_rule",
"StigID": "OKTA-APP-000025",
"CCI": [
"CCI-000057"
],
"CheckText": "From the Admin Console: 1. Select Applications >> Applications >> Okta Admin Console. 2. In the Sign On tab, under \"Okta Admin Console session\", verify the \"Maximum app session idle time\" is set to 15 minutes. If the \"Maximum app session idle time\" is not set to 15 minutes, this is a finding.",
"FixText": "From the Admin Console: 1. Select Applications >> Applications >> Okta Admin Console. 2. In the Sign On tab, under \"Okta Admin Console session\", set the \"Maximum app session idle time\" to 15 minutes."
}
]
},
{
"Id": "OKTA-APP-000090",
"Name": "Okta must automatically disable accounts after a 35-day period of account inactivity.",
"Description": "Attackers that are able to exploit an inactive account can potentially obtain and maintain undetected access to an application. Owners of inactive accounts will not notice if unauthorized access to their user account has been obtained. Applications must track periods of user inactivity and disable accounts after 35 days of inactivity. Such a process greatly reduces the risk that accounts will be hijacked, leading to a data compromise. To address access requirements, many application developers choose to integrate their applications with enterprise-level authentication/access mechanisms that meet or exceed access control policy requirements. Such integration allows the application developer to off-load those access control functions and focus on core application features and functionality. This policy does not apply to emergency accounts or infrequently used accounts. Infrequently used accounts are local login administrator accounts used by system administrators when network or normal login/access is not available. Emergency accounts are administrator accounts created in response to crisis situations. Satisfies: SRG-APP-000025, SRG-APP-000163, SRG-APP-000700",
"Checks": [
"user_inactivity_automation_35d_enabled"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273188r1098831_rule",
"StigID": "OKTA-APP-000090",
"CCI": [
"CCI-000017",
"CCI-000795",
"CCI-003627"
],
"CheckText": "If Okta Services rely on external directory services for user sourcing, this is not applicable, and the connected directory services must perform this function. Go to Workflows >> Automations and verify that an Automation has been created to disable accounts after 35 days of inactivity. If the Okta configuration does not automatically disable accounts after a 35-day period of account inactivity, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Workflow >> Automations and select \"Add Automation\". 2. Create a name for the Automation (e.g., \"User Inactivity\"). 3. Click \"Add Condition\" and select \"User Inactivity in Okta\". 4. In the duration field, enter 35 days and click \"Save\". 5 Click the edit button next to \"Select Schedule\". 6. Configure the \"Schedule\" field for \"Run Daily\" and set the \"Time\" field to an organizationally defined time to run this automation. Click \"Save\". 7. Click the edit button next to \"Select group membership\". 8. In the \"Applies to\" field, select the group \"Everyone\" by typing it into the field. Click \"Save\". 9. Click \"Add Action\" and select \"Change User lifecycle state in Okta\". 10. In the \"Change user state to\" field, select \"Suspended\" and click \"Save\". 11. Click the \"Inactive\" button near the top of the section screen and select \"Activate\"."
}
]
},
{
"Id": "OKTA-APP-000170",
"Name": "Okta must enforce the limit of three consecutive invalid login attempts by a user during a 15-minute time period.",
"Description": "By limiting the number of failed login attempts, the risk of unauthorized system access via user password guessing, otherwise known as brute forcing, is reduced. Limits are imposed by locking the account. Satisfies: SRG-APP-000065, SRG-APP-000345",
"Checks": [
"authenticator_password_lockout_threshold_3"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273189r1098834_rule",
"StigID": "OKTA-APP-000170",
"CCI": [
"CCI-000044",
"CCI-002238"
],
"CheckText": "If Okta Services rely on external directory services for user sourcing, this check is not applicable, and the connected directory services must perform this function. From the Admin Console: 1. Go to Security >> Authenticators. 2. Click the \"Actions\" button next to \"Password\" and select \"Edit\". 3. For each Password Policy, verify the \"Lock Out\" section has the following values: - \"Lock out after 3 unsuccessful attempts\" is checked. - The value is set to \"3\". If Okta Services are not configured to automatically lock user accounts after three consecutive invalid login attempts, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. Click the \"Actions\" button next to \"Password\" and select \"Edit\". 3. For each Password Policy, ensure the \"Lock Out\" section has the following values: - \"Lock out after 3 unsuccessful attempts\" is checked. - The value is set to \"3\"."
}
]
},
{
"Id": "OKTA-APP-000180",
"Name": "The Okta Dashboard application must be configured to allow authentication only via non-phishable authenticators.",
"Description": "Requiring the use of non-phishable authenticators protects against brute force/password dictionary attacks. This provides a better level of security while removing the need to lock out accounts after three attempts in 15 minutes.",
"Checks": [
"application_dashboard_phishing_resistant_authentication"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273190r1099763_rule",
"StigID": "OKTA-APP-000180",
"CCI": [
"CCI-000044"
],
"CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, verify the \"Phishing resistant\" box is checked. This will ensure that only phishing-resistant factors are used to access the Okta Dashboard. If in the \"Possession factor constraints are\" section the \"Phishing resistant\" box is not checked, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, ensure the \"Phishing resistant\" box is checked."
}
]
},
{
"Id": "OKTA-APP-000190",
"Name": "The Okta Admin Console application must be configured to allow authentication only via non-phishable authenticators.",
"Description": "Requiring the use of non-phishable authenticators protects against brute force/password dictionary attacks. This provides a better level of security while removing the need to lock out accounts after three attempts in 15 minutes.",
"Checks": [
"application_admin_console_phishing_resistant_authentication"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273191r1099764_rule",
"StigID": "OKTA-APP-000190",
"CCI": [
"CCI-000044"
],
"CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, verify the \"Phishing resistant\" box is checked. This will ensure that only phishing-resistant factors are used to access the Okta Dashboard. If in the \"Possession factor constraints are\" section the \"Phishing resistant\" box is not checked, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, ensure the \"Phishing resistant\" box is checked."
}
]
},
{
"Id": "OKTA-APP-000200",
"Name": "Okta must display the Standard Mandatory DOD Notice and Consent Banner before granting access to the application.",
"Description": "Display of the DOD-approved use notification before granting access to the application ensures that privacy and security notification verbiage used is consistent with applicable federal laws, Executive Orders, directives, policies, regulations, standards, and guidance. System use notifications are required only for access via login interfaces with human users and are not required when such human interfaces do not exist. The banner must be formatted in accordance with DTM-08-060. Use the following verbiage for applications that can accommodate banners of 1300 characters: \"You are accessing a U.S. Government (USG) Information System (IS) that is provided for USG-authorized use only. By using this IS (which includes any device attached to this IS), you consent to the following conditions: -The USG routinely intercepts and monitors communications on this IS for purposes including, but not limited to, penetration testing, COMSEC monitoring, network operations and defense, personnel misconduct (PM), law enforcement (LE), and counterintelligence (CI) investigations. -At any time, the USG may inspect and seize data stored on this IS. -Communications using, or data stored on, this IS are not private, are subject to routine monitoring, interception, and search, and may be disclosed or used for any USG-authorized purpose. -This IS includes security measures (e.g., authentication and access controls) to protect USG interests--not for your personal benefit or privacy. -Notwithstanding the above, using this IS does not constitute consent to PM, LE or CI investigative searching or monitoring of the content of privileged communications, or work product, related to personal representation or services by attorneys, psychotherapists, or clergy, and their assistants. Such communications and work product are private and confidential. See User Agreement for details.\" Use the following verbiage for operating systems that have severe limitations on the number of characters that can be displayed in the banner: \"I've read & consent to terms in IS user agreem't.\" Satisfies: SRG-APP-000068, SRG-APP-000069, SRG-APP-000070",
"Checks": [
"signon_dod_warning_banner_configured"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273192r1098843_rule",
"StigID": "OKTA-APP-000200",
"CCI": [
"CCI-000048",
"CCI-000050",
"CCI-001384",
"CCI-001385",
"CCI-001386",
"CCI-001387",
"CCI-001388"
],
"CheckText": "Attempt to log in to the Okta tenant and verify the DOD-approved warning banner is in place. If the required warning banner is not present and complete, this is a finding.",
"FixText": "Follow the supplemental instructions in the \"Okta DOD Warning Banner Configuration Guide\" provided with this STIG package."
}
]
},
{
"Id": "OKTA-APP-000560",
"Name": "The Okta Admin Console application must be configured to use multifactor authentication.",
"Description": "Without the use of multifactor authentication, the ease of access to privileged functions is greatly increased. Multifactor authentication requires using two or more factors to achieve authentication. Factors include: (i) something a user knows (e.g., password/PIN); (ii) something a user has (e.g., cryptographic identification device, token); or (iii) something a user is (e.g., biometric). A privileged account is defined as an information system account with authorizations of a privileged user. Network access is defined as access to an information system by a user (or a process acting on behalf of a user) communicating through a network (e.g., local area network, wide area network, or the internet). Satisfies: SRG-APP-000149, SRG-APP-000154",
"Checks": [
"application_admin_console_mfa_required"
],
"Attributes": [
{
"Section": "CAT I (High)",
"Severity": "high",
"RuleID": "SV-273193r1098846_rule",
"StigID": "OKTA-APP-000560",
"CCI": [
"CCI-000765",
"CCI-004046"
],
"CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, verify that either \"Password/IdP + Another factor\" or \"Any 2 factor types\" is selected. If either of these settings is incorrect, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, select either \"Password/IdP + Another factor\" or \"Any 2 factor types\"."
}
]
},
{
"Id": "OKTA-APP-000570",
"Name": "The Okta Dashboard application must be configured to use multifactor authentication.",
"Description": "To ensure accountability and prevent unauthenticated access, nonprivileged users must use multifactor authentication to prevent potential misuse and compromise of the system. Multifactor authentication uses two or more factors to achieve authentication. Factors include: (i) Something you know (e.g., password/PIN); (ii) Something you have (e.g., cryptographic identification device, token); or (iii) Something you are (e.g., biometric). A nonprivileged account is any information system account with authorizations of a nonprivileged user. Network access is any access to an application by a user (or process acting on behalf of a user) where the access is obtained through a network connection. Applications integrating with the DOD Active Directory and using the DOD CAC are examples of compliant multifactor authentication solutions. Satisfies: SRG-APP-000150, SRG-APP-000155",
"Checks": [
"application_dashboard_mfa_required"
],
"Attributes": [
{
"Section": "CAT I (High)",
"Severity": "high",
"RuleID": "SV-273194r1098849_rule",
"StigID": "OKTA-APP-000570",
"CCI": [
"CCI-000766",
"CCI-004046"
],
"CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, verify that either \"Password/IdP + Another factor\" or \"Any 2 factor types\" is selected. If either of these settings is incorrect, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, select either \"Password/IdP + Another factor\" or \"Any 2 factor types\"."
}
]
},
{
"Id": "OKTA-APP-000650",
"Name": "Okta must enforce a minimum 15-character password length.",
"Description": "Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password length is one factor of several that helps to determine strength and how long it takes to crack a password. The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised. Use of more characters in a password helps to exponentially increase the time and/or resources required to compromise the password.",
"Checks": [
"authenticator_password_minimum_length_15"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273195r1098852_rule",
"StigID": "OKTA-APP-000650",
"CCI": [
"CCI-004066"
],
"CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify the \"Minimum Length\" field is set to at least \"15\" characters. If any policy is not set to at least \"15\", this is a finding.",
"FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set the \"Minimum Length\" field to at least \"15\" characters."
}
]
},
{
"Id": "OKTA-APP-000670",
"Name": "Okta must enforce password complexity by requiring that at least one uppercase character be used.",
"Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password is, the greater the number of possible combinations that need to be tested before the password is compromised.",
"Checks": [
"authenticator_password_complexity_uppercase"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273196r1098855_rule",
"StigID": "OKTA-APP-000670",
"CCI": [
"CCI-004066"
],
"CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Upper case letter\" is checked. For each policy, if \"Upper case letter\" is not checked, this is a finding.",
"FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Upper case letter\" to checked."
}
]
},
{
"Id": "OKTA-APP-000680",
"Name": "Okta must enforce password complexity by requiring that at least one lowercase character be used.",
"Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised.",
"Checks": [
"authenticator_password_complexity_lowercase"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273197r1098858_rule",
"StigID": "OKTA-APP-000680",
"CCI": [
"CCI-004066"
],
"CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Lower case letter\" is checked. For each policy, if \"Lower case letter\" is not checked, this is a finding.",
"FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Lower case letter\" to checked."
}
]
},
{
"Id": "OKTA-APP-000690",
"Name": "Okta must enforce password complexity by requiring that at least one numeric character be used.",
"Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised.",
"Checks": [
"authenticator_password_complexity_number"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273198r1098861_rule",
"StigID": "OKTA-APP-000690",
"CCI": [
"CCI-004066"
],
"CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Number (0-9)\" is checked. For each policy, if \"Number (0-9)\" is not checked, this is a finding.",
"FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Number (0-9)\" to checked."
}
]
},
{
"Id": "OKTA-APP-000700",
"Name": "Okta must enforce password complexity by requiring that at least one special character be used.",
"Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor in determining how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised. Special characters are not alphanumeric. Examples include: ~ ! @ # $ % ^ *.",
"Checks": [
"authenticator_password_complexity_symbol"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273199r1098864_rule",
"StigID": "OKTA-APP-000700",
"CCI": [
"CCI-004066"
],
"CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Symbol (e.g., !@#$%^&*)\" is checked. For each policy, if \"Symbol (e.g., !@#$%^&*)\" is not checked, this is a finding.",
"FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Symbol (e.g., !@#$%^&*)\" to checked."
}
]
},
{
"Id": "OKTA-APP-000740",
"Name": "Okta must enforce 24 hours/one day as the minimum password lifetime.",
"Description": "Enforcing a minimum password lifetime helps prevent repeated password changes to defeat the password reuse or history enforcement requirement. Restricting this setting limits the user's ability to change their password. Passwords must be changed at specific policy-based intervals; however, if the application allows the user to immediately and continually change their password, it could be changed repeatedly in a short period of time to defeat the organization's policy regarding password reuse. Satisfies: SRG-APP-000173, SRG-APP-000870",
"Checks": [
"authenticator_password_minimum_age_24h"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273200r1098867_rule",
"StigID": "OKTA-APP-000740",
"CCI": [
"CCI-004066"
],
"CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Minimum password age is XX hours\" is set to at least \"24\". For each policy, if \"Minimum password age is XX hours\" is not set to at least \"24\", this is a finding.",
"FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Minimum password age is XX hours\" to at least \"24\"."
}
]
},
{
"Id": "OKTA-APP-000745",
"Name": "Okta must enforce a 60-day maximum password lifetime restriction.",
"Description": "Any password, no matter how complex, can eventually be cracked. Therefore, passwords must be changed at specific intervals. One method of minimizing this risk is to use complex passwords and periodically change them. If the application does not limit the lifetime of passwords and force users to change their passwords, there is the risk that the system and/or application passwords could be compromised. This requirement does not include emergency administration accounts, which are meant for access to the application in case of failure. These accounts are not required to have maximum password lifetime restrictions.",
"Checks": [
"authenticator_password_maximum_age_60d"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273201r1098870_rule",
"StigID": "OKTA-APP-000745",
"CCI": [
"CCI-004066"
],
"CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Password expires after XX days\" is set to \"60\". For each policy, if \"Password expires after XX days\" is not set to \"60\", this is a finding.",
"FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Password expires after XX days\" to \"60\"."
}
]
},
{
"Id": "OKTA-APP-001430",
"Name": "Okta must off-load audit records onto a central log server.",
"Description": "Information stored in one location is vulnerable to accidental or incidental deletion or alteration. Off-loading is a common process in information systems with limited audit storage capacity. Satisfies: SRG-APP-000358, SRG-APP-000080, SRG-APP-000125",
"Checks": [
"systemlog_streaming_enabled"
],
"Attributes": [
{
"Section": "CAT I (High)",
"Severity": "high",
"RuleID": "SV-273202r1099766_rule",
"StigID": "OKTA-APP-001430",
"CCI": [
"CCI-001851",
"CCI-000166",
"CCI-001348"
],
"CheckText": "From the Admin Console: 1. Go to Reports >> Log Streaming. 2. Verify that a Log Stream connection is configured and active. Alternately, interview the information system security manager (ISSM) and verify that an external Security Information and Event Management (SIEM) system is pulling Okta logs via an Application Programming Interface (API). If either of these is not configured, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Reports >> Log Streaming. 2. Select either \"AWS EventBridge\" or \"Splunk Cloud\" and click \"Next\". 3. Complete the necessary fields and click \"Save\". If Log Streaming is not an option because the SIEM required is not an option, customers can use the Okta Log API to export system logs in real time."
}
]
},
{
"Id": "OKTA-APP-001665",
"Name": "Okta must be configured to limit the global session lifetime to 18 hours.",
"Description": "Without reauthentication, users may access resources or perform tasks for which they do not have authorization. When applications provide the capability to change security roles or escalate the functional capability of the application, it is critical the user reauthenticate. In addition to the reauthentication requirements associated with session locks, organizations may require reauthentication of individuals and/or devices in other situations, including (but not limited to) the following circumstances. (i) When authenticators change; (ii) When roles change; (iii) When security categories of information systems change; (iv) When the execution of privileged functions occurs; (v) After a fixed period of time; or (vi) Periodically. Within the DOD, the minimum circumstances requiring reauthentication are privilege escalation and role changes.",
"Checks": [
"signon_global_session_lifetime_18h"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273203r1099958_rule",
"StigID": "OKTA-APP-001665",
"CCI": [
"CCI-002038"
],
"CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the \"Edit\" icon next to the Priority 1 rule. 4. Verify \"Maximum Okta global session lifetime\" is set to 18 hours. If the above is not set, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the Rules table, make these updates: - Click \"Add rule\". - Set \"Maximum Okta global session lifetime\" to 18 hours."
}
]
},
{
"Id": "OKTA-APP-001670",
"Name": "Okta must be configured to accept Personal Identity Verification (PIV) credentials.",
"Description": "The use of PIV credentials facilitates standardization and reduces the risk of unauthorized access. DOD has mandated the use of the common access card (CAC) to support identity management and personal authentication for systems covered under HSPD 12, as well as a primary component of layered protection for national security systems. Satisfies: SRG-APP-000391, SRG-APP-000402, SRG-APP-000403",
"Checks": [
"authenticator_smart_card_active"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273204r1098879_rule",
"StigID": "OKTA-APP-001670",
"CCI": [
"CCI-001953",
"CCI-002009",
"CCI-002010"
],
"CheckText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. Verify that \"Smart Card Authenticator\" is listed and has \"Status\" listed as \"Active\". If \"Smart Card Authenticator\" is not listed or is not listed as \"Active\", this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. In the \"Setup\" tab, click \"Add authenticator\". 3. Select the configured Smart Card Identity Provider and finish configuration."
}
]
},
{
"Id": "OKTA-APP-001700",
"Name": "The Okta Verify application must be configured to connect only to FIPS-compliant devices.",
"Description": "Without device-to-device authentication, communications with malicious devices may be established. Bidirectional authentication provides stronger safeguards to validate the identity of other devices for connections that are of greater risk. Currently, DOD requires the use of AES for bidirectional authentication because it is the only FIPS-validated AES cipher block algorithm. For distributed architectures (e.g., service-oriented architectures), the decisions regarding the validation of authentication claims may be made by services separate from the services acting on those decisions. In such situations, it is necessary to provide authentication decisions (as opposed to the actual authenticators) to the services that need to act on those decisions. A local connection is any connection with a device communicating without the use of a network. A network connection is any connection with a device that communicates through a network (e.g., local area or wide area network; the internet). A remote connection is any connection with a device communicating through an external network (e.g., the internet). Because of the challenges of applying this requirement on a large scale, organizations are encouraged to apply the requirement only to those limited number (and type) of devices that truly need to support this capability.",
"Checks": [
"authenticator_okta_verify_fips_compliant"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273205r1098882_rule",
"StigID": "OKTA-APP-001700",
"CCI": [
"CCI-001967"
],
"CheckText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. From the \"Setup\" tab, select \"Edit Okta Verify\". 3. Review the \"FIPS Compliance\" field. If FIPS-compliant authentication is not enabled, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. From the \"Setup\" tab, select \"Edit Okta Verify\". 3. In the \"FIPS Compliance\" field, choose whether users enrolling in Okta Verify can use FIPS-compliant devices only or any device. 4. Click \"Save\" after making any changes."
}
]
},
{
"Id": "OKTA-APP-001710",
"Name": "Okta must be configured to disable persistent global session cookies.",
"Description": "If cached authentication information is out of date, the validity of the authentication information may be questionable. Satisfies: SRG-APP-000400, SRG-APP-000157",
"Checks": [
"signon_global_session_cookies_not_persistent"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273206r1098885_rule",
"StigID": "OKTA-APP-001710",
"CCI": [
"CCI-002007",
"CCI-001942"
],
"CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the \"Edit\" icon next to the Priority 1 rule. 4. Verify \"Okta global session cookies persist across browser sessions\" is set to \"Disabled\". If the above it not set, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the \"Rules\" table, make these updates: - Click \"Add rule\". - Set \"Okta global session cookies persist across browser sessions\" to Disable."
}
]
},
{
"Id": "OKTA-APP-001920",
"Name": "Okta must be configured to use only DOD-approved certificate authorities.",
"Description": "Untrusted Certificate Authorities (CA) can issue certificates, but they may be issued by organizations or individuals that seek to compromise DOD systems or by organizations with insufficient security controls. If the CA used for verifying the certificate is not DOD approved, trust of this CA has not been established. The DOD will accept only PKI certificates obtained from a DOD-approved internal or external CA. Reliance on CAs for the establishment of secure sessions includes, for example, the use of Transport Layer Security (TLS) certificates. This requirement focuses on communications protection for the application session rather than for the network packet. This requirement applies to applications that use communications sessions. This includes, but is not limited to, web-based applications and Service-Oriented Architectures (SOA). Satisfies: SRG-APP-000427, SRG-APP-000910",
"Checks": [
"idp_smart_card_dod_approved_ca"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273207r1098888_rule",
"StigID": "OKTA-APP-001920",
"CCI": [
"CCI-002470",
"CCI-004909"
],
"CheckText": "From the Admin Console: 1. Select Security >> Identity Providers (IdPs). 2. Review the list of IdPs with \"Type\" as \"Smart Card\". If the IdP is not listed as \"Active\", this is a finding. 3. Select Actions >> Configure. 4. Under \"Certificate chain\", verify the certificate is from a DOD-approved CA. If the certificate is not from a DOD-approved CA, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Identity Providers. 2. Click \"Add identity provider.\" 3. Click \"Smart Card IdP\". Click \"Next\". 4. Enter the name of the identity provider. 5. Build a certificate chain: - Click \"Browse\" to open a file explorer. Select the certificate file to add and click \"Open\". - To add another certificate, click \"Add Another\" and repeat step 1. - Click \"Build certificate chain\". On success, the chain and its certificates are shown. If the build failed, correct any issues and try again. - Click \"Reset certificate chain\" if replacing the current chain with a new one. 6. In \"IdP username\", select the \"idpuser.subjectAltNameUpn\" attribute. This is the attribute that stores the Electronic Data Interchange Personnel Identifier (EDIPI) on the CAC. 7. In the \"Match Against\" field, select the Okta Profile Attribute in which the EDIPI is to be stored."
}
]
},
{
"Id": "OKTA-APP-002980",
"Name": "Okta must validate passwords against a list of commonly used, expected, or compromised passwords.",
"Description": "Password-based authentication applies to passwords regardless of whether they are used in single-factor or multifactor authentication. Long passwords or passphrases are preferable over shorter passwords. Enforced composition rules provide marginal security benefits while decreasing usability. However, organizations may choose to establish certain rules for password generation (e.g., minimum character length for long passwords) under certain circumstances and can enforce this requirement in IA-5(1)(h). Account recovery can occur, for example, in situations when a password is forgotten. Cryptographically protected passwords include salted one-way cryptographic hashes of passwords. The list of commonly used, compromised, or expected passwords includes passwords obtained from previous breach corpuses, dictionary words, and repetitive or sequential characters. The list includes context-specific words, such as the name of the service, username, and derivatives thereof.",
"Checks": [
"authenticator_password_common_password_check"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273208r1099769_rule",
"StigID": "OKTA-APP-002980",
"CCI": [
"CCI-004058"
],
"CheckText": "From the Admin Console: 1. Navigate to Security >> Authenticators. 2. Click the \"Actions\" button next to the Password authenticator and select \"Edit\". 3. Under the \"Password Settings\" section, verify the \"Common Password Check\" box is checked. If \"Common Password Check\" is not selected, this is a finding.",
"FixText": "From the Admin Console: 1. Navigate to Security >> Authenticators. 2. Click the \"Actions\" button next to the Password authenticator and select \"Edit\". 3. Under the \"Password Settings\" section, check the \"Common Password Check\" box."
}
]
},
{
"Id": "OKTA-APP-003010",
"Name": "Okta must prohibit password reuse for a minimum of five generations.",
"Description": "Password-based authentication applies to passwords regardless of whether they are used in single-factor or multifactor authentication. Long passwords or passphrases are preferable over shorter passwords. Enforced composition rules provide marginal security benefits while decreasing usability. However, organizations may choose to establish certain rules for password generation (e.g., minimum character length for long passwords) under certain circumstances and can enforce this requirement in IA-5(1)(h). Account recovery can occur, for example, in situations when a password is forgotten. Cryptographically protected passwords include salted one-way cryptographic hashes of passwords. The list of commonly used, compromised, or expected passwords includes passwords obtained from previous breach corpuses, dictionary words, and repetitive or sequential characters. The list includes context-specific words, such as the name of the service, username, and derivatives thereof.",
"Checks": [
"authenticator_password_history_5"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273209r1098894_rule",
"StigID": "OKTA-APP-003010",
"CCI": [
"CCI-004061"
],
"CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password row\" and select \"Edit\". 3. For each listed policy, verify \"Enforce password history for last XX passwords\" is set to \"5\". If any policy is not set to at least \"5\", this is a finding.",
"FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Enforce password history for last XX passwords\" to \"5\"."
}
]
},
{
"Id": "OKTA-APP-003240",
"Name": "Okta API tokens must be configured with Network Zones to restrict authorization from known networks.",
"Description": "An access token is a piece of data that represents the authorization granted to a user or NPE to access specific systems or information resources. Access tokens enable controlled access to services and resources. Properly managing the lifecycle of access tokens, including their issuance, validation, and revocation, is crucial to maintaining confidentiality of data and systems. Restricting token validity to a specific audience, e.g., an application or security domain, and restricting token validity lifetimes are important practices. Access tokens are revoked or invalidated if they are compromised, lost, or are no longer needed to mitigate the risks associated with stolen or misused tokens. API tokens have the potential to be replicated or stolen (just like a password). Because of this, it is important to only allow API tokens to authenticate from known IP ranges as this limits an adversary's ability to use a token to gain access.",
"Checks": [
"apitoken_restricted_to_network_zone"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-279689r1155066_rule",
"StigID": "OKTA-APP-003240",
"CCI": [
"CCI-005165",
"CCI-000366"
],
"CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, click the token name link. 4. In the \"Security\" section, verify the \"Token can be used from\" setting is mapped to a known network zone for the application calling the API. If a network zone for each API access token is not defined, this is a finding.",
"FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, click the token name link. 4. In the \"Security\" section, click \"Edit\". 5. Set the \"Token can be used from\" setting to the known network zone for the application calling the API. 6. Click \"Save\"."
}
]
},
{
"Id": "OKTA-APP-003241",
"Name": "Okta API tokens must be created under new dedicated user accounts.",
"Description": "An access token is a piece of data that represents the authorization granted to a user or NPE to access specific systems or information resources. Access tokens enable controlled access to services and resources. Properly managing the lifecycle of access tokens, including their issuance, validation, and revocation, is crucial to maintaining confidentiality of data and systems. Restricting token validity to a specific audience, e.g., an application or security domain, and restricting token validity lifetimes are important practices. Access tokens are revoked or invalidated if they are compromised, lost, or are no longer needed to mitigate the risks associated with stolen or misused tokens. When API tokens are created, they inherit the permissions of the user that created them. Therefore, API tokens should only be created from dedicated accounts and permissions must be constrained to least privilege for that dedicated user account and token. No API tokens should be created using a Super Admin account.",
"Checks": [
"apitoken_not_super_admin"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-279690r1155069_rule",
"StigID": "OKTA-APP-003241",
"CCI": [
"CCI-005165",
"CCI-000366"
],
"CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, verify that the Role listed is not \"Super Admin\", and that the account has been specifically created for that token. 4. Click the account name to be token to the user profile for that user. 5. Verify the user only has an administrator role (standard or customer) applied that is correctly scoped as required and documented in the Okta Access Control policy. If the token is using a Super Administrator account, or one that is not properly scoped per the Access Control policy, this is a finding. Note: If a Super Admin token is required for system operation, then this permanent finding.",
"FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed that has \"Super Admin\" or an improperly scoped Admin account, delete the token and create a new one with the appropriately scoped permissions. 4. Verify the application performing the API calls with the new token has been updated."
}
]
},
{
"Id": "OKTA-APP-003242",
"Name": "The Okta Global Session policy must be configured to allow or deny IP based access in accordance with the Access Control policy for Okta.",
"Description": "To mitigate the risk of unauthorized access to sensitive information by entities that have been issued certificates by DOD-approved PKIs, all DOD systems (e.g., networks, web servers, and web portals) must be properly configured to incorporate access control methods that do not rely solely on the possession of a certificate for access. Successful authentication must not automatically give an entity access to an asset or security boundary. Authorization procedures and controls must be implemented to ensure each authenticated entity also has a validated and current authorization. Authorization is the process of determining whether an entity, once authenticated, is permitted to access a specific asset. Information systems use access control policies and enforcement mechanisms to implement this requirement. Access Control policies include identity-based policies, role-based policies, and attribute-based policies. Access enforcement mechanisms include access control lists, access control matrices, and cryptography. These policies and mechanisms must be employed by the application to control access between users (or processes acting on behalf of users) and objects (e.g., devices, files, records, processes, programs, and domains) in the information system. The Okta Global Session Policy is applied at the organization level and before any application-specific authentication policies are processed. The Okta authorization package should contain an access control policy that defines IP ranges from which to either allow or deny access. This list (either as an explicit allow or explicit deny) can be implemented in the Global Session Policy.",
"Checks": [
"signon_global_session_policy_network_zone_enforced"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-279691r1155072_rule",
"StigID": "OKTA-APP-003242",
"CCI": [
"CCI-000213"
],
"CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Global Session Policy\" item. 2. In the \"Policy Settings\" section, verify the \"IF User's IP is\" setting is correctly set to either allow or deny based on the organization defined policy. If the Okta Global Session Policy is not configured to restrict access to specific IP ranges, this is a finding.",
"FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Global Session Policy\" item. 2. In the Policy Settings section, configure the \"IF User's IP is\" setting to correctly set the appropriate network to either allow or deny based on the Access Control Policy."
}
]
},
{
"Id": "OKTA-APP-003243",
"Name": "Okta must be configured with Network Zones defined to block anonymized proxies according to organizationally defined policy.",
"Description": "A mechanism to detect and prevent unauthorized communication flow must be configured or provided as part of the system design. If information flow is not enforced based on approved authorizations, the system may become compromised. Information flow control regulates where information is allowed to travel within a system and between interconnected systems. The flow of all application information must be monitored and controlled so it does not introduce any unacceptable risk to the systems or data. Application-specific examples of enforcement occurs in systems that employ rule sets or establish configuration settings that restrict information system services, or provide a message filtering capability based on message content (e.g., implementing key word searches or using document characteristics). Applications providing information flow control must be able to enforce approved authorizations for controlling the flow of information between interconnected systems in accordance with applicable policy. Working with the organizational CSSP, the ISSM should obtain a list of known anonymizer proxies that exist on the commercial internet. If this is not available from the CSSP, then the Okta-provided \"Enhanced dynamic zone blocklist\" should be activated.",
"Checks": [
"network_zone_block_anonymized_proxies"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-279692r1155075_rule",
"StigID": "OKTA-APP-003243",
"CCI": [
"CCI-001414"
],
"CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Networks' item. 2. If the CSSP has provided a list of anonymizers to block, verify the \"IP Block list\" is configured with them. a. Click the pencil icon next to IP Block list. b. Verify the \"Gateway IPs\" section contains all of the IP ranges in the provided list. 3. If the CSSP is not able to provide a list, then implement the Okta managed list. a. Verify the \"Enhanced dynamic zone blocklist\" is set to \"Active\". If Network Zones are not configured to block anonymous proxies, this is a finding.",
"FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Networks\" item. 2. If the CSSP has provided a list of anonymizers to block, add the IP ranges to the \"IP Block list\". a. Click the pencil icon next to IP Block list. b. Add the IP ranges to the \"Gateway IPs\" section and click \"Save\". 3. If the CSSP is not able to provide a list, then implement the Okta managed list. a. Set the \"Enhanced dynamic zone blocklist\" to \"Active\"."
}
]
},
{
"Id": "OKTA-APP-003244",
"Name": "For each application integrated with Okta, network zones must be defined in its authentication policy.",
"Description": "A mechanism to detect and prevent unauthorized communication flow must be configured or provided as part of the system design. If information flow is not enforced based on approved authorizations, the system may become compromised. Information flow control regulates where information is allowed to travel within a system and between interconnected systems. The flow of all application information must be monitored and controlled so it does not introduce any unacceptable risk to the systems or data. Application-specific examples of enforcement occurs in systems that employ rule sets or establish configuration settings that restrict information system services, or provide a message filtering capability based on message content (e.g., implementing key word searches or using document characteristics). Applications providing information flow control must be able to enforce approved authorizations for controlling the flow of information between interconnected systems in accordance with applicable policy. Each application in Okta should have a well defined access control policy that takes into account the end user network. This should be documented in the Access Control policy for each application. As an example, access to an application may be restricted to a specific location by policy. In this case, a network defining that specific location should be created.",
"Checks": [
"application_authentication_policy_network_zone_enforced"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-279693r1155078_rule",
"StigID": "OKTA-APP-003244",
"CCI": [
"CCI-001414"
],
"CheckText": "For each application integrated into Okta: 1. From the Admin console, open the \"Security\" menu, and then select \"Networks\". 2. Verify the list of networks includes all necessary allow or block lists. If any application is not configured with network zones, this is a finding.",
"FixText": "For each application, starting at the admin console: 1. Open the \"Applications\" group from the Menu, and then click the \"Applications\" menu item. 2. Click the application name. 3. Click the \"Sign On\" tab. 4. Scroll to the \"User Authentication\" section, and then click \"Edit\". 5. Select the appropriate Authentication policy from the pull down, and then click \"Save\". 6. Click \"View Policy Details\". 7. For each nondefault rule: a. Select \"Edit\" from the Actions menu. b. In the \"IF\" section, verify the \"User is\" setting has the appropriate allow or deny range has been selected based on the Access Control policy for the application. c. Scroll down to the bottom and click \"Save\". 8. For the Catch-All rule: a. Select \"Edit\" from the Actions menu. b. Scroll down to the \"Then\" section. c. For the \"Access is\" setting, select \"Denied\", and then click \"Save\"."
}
]
}
]
}
@@ -302,7 +302,9 @@
{
"Id": "1.15",
"Description": "Ensure storage service-level admins cannot delete resources they manage",
"Checks": [],
"Checks": [
"identity_storage_service_level_admins_scoped"
],
"Attributes": [
{
"Section": "1. Identity and Access Management",
+1 -10
View File
@@ -288,11 +288,6 @@ def load_and_validate_config_file(provider: str, config_file_path: str) -> dict:
Returns:
dict: The configuration dictionary for the specified provider.
"""
# Imported lazily to avoid an import cycle: schemas may eventually want to
# import from prowler.config.config (e.g. for shared constants).
from prowler.config.schema.registry import SCHEMAS
from prowler.config.schema.validator import validate_provider_config
try:
with open(config_file_path, "r", encoding=encoding_format_utf_8) as f:
config_file = yaml.safe_load(f)
@@ -318,11 +313,7 @@ def load_and_validate_config_file(provider: str, config_file_path: str) -> dict:
else:
config = {}
return validate_provider_config(
provider=provider,
raw=config,
schema_cls=SCHEMAS.get(provider),
)
return config
except FileNotFoundError as error:
logger.error(
-106
View File
@@ -1,106 +0,0 @@
"""Bridge between the Pydantic-based provider schemas in
`prowler.config.schema` and the Prowler App backend (Django) + UI.
The SDK runtime is intentionally LENIENT: invalid keys are dropped with a
warning and downstream checks fall back to their defaults
(`prowler.config.schema.validator.validate_provider_config`).
The Prowler App, however, needs to surface those errors to the user when
they save a Scan Config from the UI, and to expose the schema as JSON so
the UI can validate live with `ajv`. This module provides:
- `validate_scan_config(payload)` STRICT: returns a list of
`{path, message}` errors without silently dropping anything. The DRF
serializer (`api/.../v1/serializers.py:validate_scan_config_payload`)
turns each entry into a `ValidationError`.
- `SCAN_CONFIG_SCHEMA` aggregated JSON Schema derived from the Pydantic
models via `model_json_schema()`. Served by the `/scan-configs/schema`
endpoint and consumed by the UI editor for in-editor live validation.
"""
from typing import Any
from pydantic import ValidationError
from prowler.config.schema.registry import SCHEMAS
def _format_loc(loc: tuple) -> str:
"""Render a Pydantic error location as `key[idx].nested`."""
parts: list[str] = []
for piece in loc:
if isinstance(piece, int):
if parts:
parts[-1] = f"{parts[-1]}[{piece}]"
else:
parts.append(f"[{piece}]")
else:
parts.append(str(piece))
return ".".join(parts) if parts else "<root>"
def validate_scan_config(payload: Any) -> list[dict]:
"""Validate a scan config payload against the registered provider schemas.
Strict by design: every Pydantic violation surfaces as a `{path, message}`
entry so the caller can decide how to present it. Unknown provider
sections are accepted (consistent with `additionalProperties: True` at
the top level the SDK simply has no opinion on them).
"""
if not isinstance(payload, dict):
return [
{
"path": "<root>",
"message": "Scan config must be a mapping with provider sections.",
}
]
errors: list[dict] = []
for provider, section in payload.items():
schema_cls = SCHEMAS.get(provider)
if schema_cls is None:
# Unknown provider type: tolerated. The SDK will simply ignore it.
continue
if not isinstance(section, dict):
errors.append(
{
"path": str(provider),
"message": "section must be a mapping.",
}
)
continue
try:
schema_cls.model_validate(section)
except ValidationError as exc:
for err in exc.errors():
loc = err.get("loc") or ()
path = _format_loc((str(provider), *loc))
errors.append(
{
"path": path,
"message": err.get("msg", "validation error"),
}
)
return errors
def _build_aggregated_schema() -> dict:
"""Compose one JSON Schema per provider into a single top-level schema.
The output mirrors the layout of `prowler/config/config.yaml` (a mapping
keyed by provider type) and is what the UI consumes via `ajv`.
"""
properties: dict[str, dict] = {}
for provider, schema_cls in SCHEMAS.items():
properties[provider] = schema_cls.model_json_schema()
return {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Prowler Scan Config",
"type": "object",
"additionalProperties": True,
"properties": properties,
}
SCAN_CONFIG_SCHEMA: dict = _build_aggregated_schema()
-449
View File
@@ -1,449 +0,0 @@
"""AWS provider config schema.
Bounds on every field are intentionally conservative: they are not the
absolute service maxima but the values that produce a useful security
check. A user is free to keep the built-in default by omitting the key
out-of-range values are dropped with a warning at SDK runtime, and
rejected at the Prowler App backend.
Whenever an upper bound is uncertain, the cap is set to a value that
still keeps the check meaningful (e.g. a 10-year window for date-based
thresholds) and avoids ints that obviously break downstream maths
(`min_kinesis_stream_retention_hours = 99999`).
"""
from ipaddress import ip_network
from typing import Annotated, Literal, Optional
from pydantic import AfterValidator, Field
from prowler.config.schema.base import ProviderConfigBase
# ---- Reusable constants -----------------------------------------------------
# CloudWatch Logs only accepts these retention values (in days). Anything else
# is silently coerced to the next valid value by the API — we reject upfront.
_CLOUDWATCH_RETENTION_DAYS = (
1,
3,
5,
7,
14,
30,
60,
90,
120,
150,
180,
365,
400,
545,
731,
1827,
2192,
2557,
2922,
3288,
3653,
)
_VALID_CW_RETENTION_LITERAL = Literal[
1,
3,
5,
7,
14,
30,
60,
90,
120,
150,
180,
365,
400,
545,
731,
1827,
2192,
2557,
2922,
3288,
3653,
]
# ---- Custom validators ------------------------------------------------------
def _validate_port_range(v: Optional[list[int]]) -> Optional[list[int]]:
if v is None:
return v
for port in v:
if not 1 <= port <= 65535:
raise ValueError(f"port {port} is outside the valid range 1..65535")
return v
def _validate_account_ids(v: Optional[list[str]]) -> Optional[list[str]]:
if v is None:
return v
for account_id in v:
if not (account_id.isdigit() and len(account_id) == 12):
raise ValueError(
f"trusted_account_ids entry {account_id!r} is not a 12-digit AWS account id"
)
return v
def _validate_trusted_ips(v: Optional[list[str]]) -> Optional[list[str]]:
if v is None:
return v
for entry in v:
try:
ip_network(entry, strict=False)
except ValueError as exc:
raise ValueError(
f"trusted_ips entry {entry!r} is not a valid IP or CIDR ({exc})"
) from exc
return v
def _validate_semver(v: Optional[str]) -> Optional[str]:
"""Accept "1.4.0" style strings (used by Fargate platform versions)."""
if v is None:
return v
parts = v.split(".")
if len(parts) != 3 or not all(p.isdigit() for p in parts):
raise ValueError(f"{v!r} is not a valid semantic version (expected X.Y.Z)")
return v
def _validate_eks_minor(v: Optional[str]) -> Optional[str]:
"""Accept "1.28" style strings (EKS minor versions)."""
if v is None:
return v
parts = v.split(".")
if len(parts) != 2 or not all(p.isdigit() for p in parts):
raise ValueError(f"{v!r} is not a valid EKS version (expected X.Y)")
return v
# ---- Nested models ----------------------------------------------------------
class _DetectSecretsPlugin(ProviderConfigBase):
"""One entry inside ``detect_secrets_plugins``.
Only ``name`` is required by the upstream library. ``limit`` is used by
the entropy detectors. Any other plugin-specific kwarg is preserved by
the ``extra="allow"`` policy inherited from ProviderConfigBase.
"""
name: str
limit: Optional[float] = Field(
default=None,
ge=0.0,
le=10.0,
description=(
"Entropy threshold for detect-secrets entropy plugins. Range: 0..10 "
"(Shannon entropy is bounded by log2(256)=8; >10 is meaningless)."
),
)
# ---- Main schema ------------------------------------------------------------
class AWSProviderConfig(ProviderConfigBase):
# --- IAM ---------------------------------------------------------------
mute_non_default_regions: Optional[bool] = None
disallowed_regions: Optional[list[str]] = None
max_unused_access_keys_days: Optional[int] = Field(
default=None,
ge=30,
le=180,
description=(
"Days an IAM user access key can stay unused before being flagged. "
"Range: 30..180 days (CIS AWS 1.13 recommends 45; NIST IA-5 ≤90)."
),
)
max_console_access_days: Optional[int] = Field(
default=None,
ge=30,
le=180,
description=(
"Days an IAM console password can stay unused before being flagged. "
"Range: 30..180 days (CIS AWS 1.12 recommends 45)."
),
)
max_unused_sagemaker_access_days: Optional[int] = Field(
default=None,
ge=7,
le=180,
description=(
"Days a SageMaker user access key can stay unused. Range: 7..180 "
"(SageMaker tokens are usually high-privilege over S3/KMS)."
),
)
# --- EC2 ---------------------------------------------------------------
shodan_api_key: Optional[str] = Field(
default=None,
max_length=512,
description="API key for Shodan lookups on EC2 public IPs.",
)
max_security_group_rules: Optional[int] = Field(
default=None,
ge=1,
le=1000,
description="Max ingress+egress rules per security group. AWS hard limit is 1000.",
)
max_ec2_instance_age_in_days: Optional[int] = Field(
default=None,
ge=1,
le=1095,
description=(
"Days an EC2 instance can run before being flagged as old. "
"Range: 1..1095 (3 years; instances should be refreshed for patching "
"per NIST CM-3 — anything older is a security smell)."
),
)
ec2_allowed_interface_types: Optional[list[str]] = None
ec2_allowed_instance_owners: Optional[list[str]] = None
ec2_high_risk_ports: Annotated[
Optional[list[int]], AfterValidator(_validate_port_range)
] = Field(
default=None,
description="TCP/UDP ports considered high-risk when reachable from the Internet (1..65535; port 0 is reserved).",
)
# --- ECS ---------------------------------------------------------------
fargate_linux_latest_version: Annotated[
Optional[str], AfterValidator(_validate_semver)
] = Field(default=None, description="Fargate Linux platform version (X.Y.Z).")
fargate_windows_latest_version: Annotated[
Optional[str], AfterValidator(_validate_semver)
] = Field(default=None, description="Fargate Windows platform version (X.Y.Z).")
# --- Cross-account trust ----------------------------------------------
trusted_account_ids: Annotated[
Optional[list[str]], AfterValidator(_validate_account_ids)
] = Field(
default=None,
description="Additional 12-digit AWS account IDs trusted by cross-account checks.",
)
trusted_ips: Annotated[
Optional[list[str]], AfterValidator(_validate_trusted_ips)
] = Field(
default=None,
description="IPv4/IPv6 addresses or CIDR ranges that are NOT considered public.",
)
# --- CloudWatch / CloudFormation --------------------------------------
log_group_retention_days: Optional[_VALID_CW_RETENTION_LITERAL] = Field(
default=None,
description=(
"Required CloudWatch Logs retention in days. Must match one of the "
f"values accepted by the AWS API: {list(_CLOUDWATCH_RETENTION_DAYS)}."
),
)
recommended_cdk_bootstrap_version: Optional[int] = Field(
default=None,
ge=1,
le=100,
description="Min CDK bootstrap version expected on the account.",
)
# --- AppStream --------------------------------------------------------
max_idle_disconnect_timeout_in_seconds: Optional[int] = Field(
default=None,
ge=60,
le=1800,
description=(
"AppStream idle disconnect timeout (seconds). Range: 60..1800 "
"(NIST AC-12: sensitive sessions ≤15 min — cap at 30 min)."
),
)
max_disconnect_timeout_in_seconds: Optional[int] = Field(
default=None,
ge=60,
le=3600,
description="AppStream disconnect timeout (seconds). Range: 60..3600.",
)
max_session_duration_seconds: Optional[int] = Field(
default=None,
ge=600,
le=86400,
description=(
"AppStream max session duration (seconds). Range: 600..86400 "
"(10 min .. 24 h — AWS AppStream hard limit per session)."
),
)
# --- Lambda -----------------------------------------------------------
obsolete_lambda_runtimes: Optional[list[str]] = None
lambda_min_azs: Optional[int] = Field(
default=None,
ge=1,
le=6,
description="Min number of AZs a VPC-bound Lambda must span. Range: 1..6.",
)
# --- Organizations ----------------------------------------------------
organizations_enabled_regions: Optional[list[str]] = None
organizations_trusted_delegated_administrators: Annotated[
Optional[list[str]], AfterValidator(_validate_account_ids)
] = None
organizations_trusted_ids: Optional[list[str]] = None
# --- ECR --------------------------------------------------------------
ecr_repository_vulnerability_minimum_severity: Optional[
Literal["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"]
] = Field(
default=None,
description="Highest severity tolerated for ECR images.",
)
# --- Trusted Advisor --------------------------------------------------
verify_premium_support_plans: Optional[bool] = None
# --- CloudTrail threat detection: privilege escalation ----------------
threat_detection_privilege_escalation_threshold: Optional[float] = Field(
default=None,
ge=0.0,
le=1.0,
description="Fraction of suspicious actions that triggers the priv-esc detection.",
)
threat_detection_privilege_escalation_minutes: Optional[int] = Field(
default=None,
ge=5,
le=43200,
description=(
"Lookback window (minutes) for priv-esc detection. Range: 5..43200 "
"(under 5 min the signal is dominated by false positives)."
),
)
threat_detection_privilege_escalation_actions: Optional[list[str]] = None
# --- CloudTrail threat detection: enumeration -------------------------
threat_detection_enumeration_threshold: Optional[float] = Field(
default=None,
ge=0.0,
le=1.0,
description="Fraction of suspicious actions that triggers the enumeration detection.",
)
threat_detection_enumeration_minutes: Optional[int] = Field(
default=None,
ge=5,
le=43200,
description="Lookback window (minutes) for enumeration detection. Range: 5..43200.",
)
threat_detection_enumeration_actions: Optional[list[str]] = None
# --- CloudTrail threat detection: LLM jacking -------------------------
threat_detection_llm_jacking_threshold: Optional[float] = Field(
default=None,
ge=0.0,
le=1.0,
description="Fraction of suspicious actions that triggers the LLM-jacking detection.",
)
threat_detection_llm_jacking_minutes: Optional[int] = Field(
default=None,
ge=5,
le=43200,
description="Lookback window (minutes) for LLM-jacking detection. Range: 5..43200.",
)
threat_detection_llm_jacking_actions: Optional[list[str]] = None
# --- RDS --------------------------------------------------------------
check_rds_instance_replicas: Optional[bool] = None
# --- ACM --------------------------------------------------------------
days_to_expire_threshold: Optional[int] = Field(
default=None,
ge=7,
le=365,
description=(
"Days before certificate expiration to flag. Range: 7..365 "
"(PCI-DSS 4.2.1.1: alert ≥30 days before expiry; <7 days is too "
"tight to actually act on)."
),
)
insecure_key_algorithms: Optional[list[str]] = None
# --- EKS --------------------------------------------------------------
eks_required_log_types: Optional[
list[
Literal[
"api",
"audit",
"authenticator",
"controllerManager",
"scheduler",
]
]
] = Field(
default=None,
description="EKS control plane log types that must be enabled.",
)
eks_cluster_oldest_version_supported: Annotated[
Optional[str], AfterValidator(_validate_eks_minor)
] = Field(
default=None,
description='Minimum supported EKS minor version, expected as "X.Y".',
)
# --- CodeBuild --------------------------------------------------------
excluded_sensitive_environment_variables: Optional[list[str]] = None
codebuild_github_allowed_organizations: Optional[list[str]] = None
# --- ELB / ELBv2 ------------------------------------------------------
elb_min_azs: Optional[int] = Field(
default=None,
ge=1,
le=6,
description="Min AZs a Classic ELB must span. Range: 1..6.",
)
elbv2_min_azs: Optional[int] = Field(
default=None,
ge=1,
le=6,
description="Min AZs an Application/Network LB must span. Range: 1..6.",
)
# --- ElastiCache -----------------------------------------------------
minimum_snapshot_retention_period: Optional[int] = Field(
default=None,
ge=1,
le=35,
description="Days an ElastiCache backup must be retained. Range: 1..35 (service hard limit).",
)
# --- Secrets ---------------------------------------------------------
secrets_ignore_patterns: Optional[list[str]] = None
max_days_secret_unused: Optional[int] = Field(
default=None,
ge=7,
le=365,
description="Days a Secrets Manager secret can stay unused. Range: 7..365.",
)
max_days_secret_unrotated: Optional[int] = Field(
default=None,
ge=1,
le=180,
description=(
"Days a Secrets Manager secret can go without rotation. Range: 1..180 "
"(NIST IA-5: rotate quarterly; CIS recommends ≤90)."
),
)
# --- Kinesis ---------------------------------------------------------
min_kinesis_stream_retention_hours: Optional[int] = Field(
default=None,
ge=24,
le=8760,
description="Hours of Kinesis stream retention. Range: 24..8760 (1 day .. 1 year).",
)
# --- detect-secrets plugin list -------------------------------------
detect_secrets_plugins: Optional[list[_DetectSecretsPlugin]] = None
-91
View File
@@ -1,91 +0,0 @@
"""Azure provider config schema with safety bounds.
Bounds aim for values that produce a meaningful security check; out-of-range
values are dropped (SDK runtime) or rejected (Prowler App backend).
"""
from typing import Annotated, Literal, Optional
from pydantic import AfterValidator, Field
from prowler.config.schema.base import ProviderConfigBase
def _validate_dotted_version(v: Optional[str]) -> Optional[str]:
"""Accept ``"8.2"``, ``"3.12"``, ``"17"`` style version strings.
Used by App Service language version fields where the upstream APIs
accept either ``MAJOR`` or ``MAJOR.MINOR`` notation.
"""
if v is None:
return v
parts = v.split(".")
if not (1 <= len(parts) <= 2) or not all(p.isdigit() for p in parts):
raise ValueError(f"{v!r} is not a valid version (expected 'X' or 'X.Y')")
return v
class AzureProviderConfig(ProviderConfigBase):
# --- Network ---------------------------------------------------------
shodan_api_key: Optional[str] = Field(
default=None,
max_length=512,
description="API key for Shodan lookups on Azure public IPs.",
)
# --- Defender --------------------------------------------------------
defender_attack_path_minimal_risk_level: Optional[
Literal["Low", "Medium", "High", "Critical"]
] = Field(
default=None,
description="Minimum attack-path risk level worth a notification.",
)
# --- App Service ----------------------------------------------------
php_latest_version: Annotated[
Optional[str], AfterValidator(_validate_dotted_version)
] = Field(default=None, description='PHP minimum acceptable version, e.g. "8.2".')
python_latest_version: Annotated[
Optional[str], AfterValidator(_validate_dotted_version)
] = Field(
default=None, description='Python minimum acceptable version, e.g. "3.12".'
)
java_latest_version: Annotated[
Optional[str], AfterValidator(_validate_dotted_version)
] = Field(default=None, description='Java minimum acceptable version, e.g. "17".')
# --- SQL ------------------------------------------------------------
recommended_minimal_tls_versions: Optional[list[Literal["1.2", "1.3"]]] = Field(
default=None,
description="TLS versions accepted on Azure SQL Server.",
)
# --- Virtual Machines -----------------------------------------------
desired_vm_sku_sizes: Optional[list[str]] = None
vm_backup_min_daily_retention_days: Optional[int] = Field(
default=None,
ge=7,
le=9999,
description=(
"Min daily backup retention days. Range: 7..9999 "
"(Azure Backup hard limit; <7 days defeats DR/ransomware recovery)."
),
)
# --- API Management threat detection (LLM jacking) -----------------
apim_threat_detection_llm_jacking_threshold: Optional[float] = Field(
default=None,
ge=0.0,
le=1.0,
description="Fraction of suspicious actions that triggers the detection.",
)
apim_threat_detection_llm_jacking_minutes: Optional[int] = Field(
default=None,
ge=5,
le=43200,
description=(
"Lookback window (minutes) for LLM-jacking detection. Range: 5..43200 "
"(under 5 min the signal is dominated by false positives)."
),
)
apim_threat_detection_llm_jacking_actions: Optional[list[str]] = None
-17
View File
@@ -1,17 +0,0 @@
from pydantic import BaseModel, ConfigDict
class ProviderConfigBase(BaseModel):
"""Base for every provider config schema.
``extra="allow"`` is REQUIRED for backwards compatibility: third-party
check plugins frequently introduce config keys we do not know about,
and pre-existing user configs may carry deprecated keys. Validation
must never reject these.
"""
model_config = ConfigDict(
extra="allow",
str_strip_whitespace=True,
validate_assignment=False,
)
-18
View File
@@ -1,18 +0,0 @@
"""Cloudflare provider config schema with safety bounds."""
from typing import Optional
from pydantic import Field
from prowler.config.schema.base import ProviderConfigBase
class CloudflareProviderConfig(ProviderConfigBase):
max_retries: Optional[int] = Field(
default=None,
ge=0,
le=10,
description=(
"Max retries for Cloudflare API requests. Range: 0..10 (0 disables retries)."
),
)
-45
View File
@@ -1,45 +0,0 @@
"""GCP provider config schema with safety bounds."""
from typing import Optional
from pydantic import Field
from prowler.config.schema.base import ProviderConfigBase
class GCPProviderConfig(ProviderConfigBase):
shodan_api_key: Optional[str] = Field(
default=None,
max_length=512,
description="API key for Shodan lookups on GCP public IPs.",
)
mig_min_zones: Optional[int] = Field(
default=None,
ge=1,
le=5,
description="Min zones a Managed Instance Group must span. Range: 1..5.",
)
max_snapshot_age_days: Optional[int] = Field(
default=None,
ge=1,
le=1095,
description=(
"Days a disk snapshot can age before being flagged. Range: 1..1095 "
"(3 years; older snapshots typically miss data-class compliance)."
),
)
max_unused_account_days: Optional[int] = Field(
default=None,
ge=30,
le=365,
description=(
"Days a service account or user-managed key can stay unused. "
"Range: 30..365."
),
)
storage_min_retention_days: Optional[int] = Field(
default=None,
ge=1,
le=3650,
description="Min retention period on Cloud Storage buckets. Range: 1..3650.",
)
-20
View File
@@ -1,20 +0,0 @@
"""GitHub provider config schema with safety bounds."""
from typing import Optional
from pydantic import Field
from prowler.config.schema.base import ProviderConfigBase
class GitHubProviderConfig(ProviderConfigBase):
inactive_not_archived_days_threshold: Optional[int] = Field(
default=None,
ge=30,
le=3650,
description=(
"Days a repository can stay inactive without being archived before "
"being flagged. Range: 30..3650 (CIS GitHub recommends 180; "
"<30 days produces false positives on seasonal projects)."
),
)
-45
View File
@@ -1,45 +0,0 @@
"""Kubernetes provider config schema with safety bounds."""
from typing import Optional
from pydantic import Field
from prowler.config.schema.base import ProviderConfigBase
class KubernetesProviderConfig(ProviderConfigBase):
audit_log_maxbackup: Optional[int] = Field(
default=None,
ge=2,
le=1000,
description=(
"API server audit log file rotations to keep. Range: 2..1000 "
"(CIS Kubernetes 1.2.18 recommends ≥10)."
),
)
audit_log_maxsize: Optional[int] = Field(
default=None,
ge=10,
le=10000,
description=(
"Max MB per audit log file before rotation. Range: 10..10000 MB "
"(CIS Kubernetes 1.2.19 recommends ≥100 MB)."
),
)
audit_log_maxage: Optional[int] = Field(
default=None,
ge=7,
le=3650,
description=(
"Days an audit log file is retained. Range: 7..3650 "
"(CIS Kubernetes 1.2.17 recommends ≥30 days)."
),
)
apiserver_strong_ciphers: Optional[list[str]] = Field(
default=None,
description="Whitelist of strong TLS cipher suites required on the API server.",
)
kubelet_strong_ciphers: Optional[list[str]] = Field(
default=None,
description="Whitelist of strong TLS cipher suites required on kubelet.",
)
-54
View File
@@ -1,54 +0,0 @@
"""M365 provider config schema with safety bounds."""
from typing import Optional
from pydantic import Field
from prowler.config.schema.base import ProviderConfigBase
class M365ProviderConfig(ProviderConfigBase):
# --- Entra (sign-in policy) ----------------------------------------
sign_in_frequency: Optional[int] = Field(
default=None,
ge=1,
le=168,
description=(
"Hours between forced sign-ins for admin users. Range: 1..168 (1 h .. 7 days). "
"Microsoft Conditional Access baseline for admin roles is ≤24 h."
),
)
# --- Teams ---------------------------------------------------------
allowed_cloud_storage_services: Optional[list[str]] = Field(
default=None,
description="External cloud storage services allowed in Teams.",
)
# --- Exchange ------------------------------------------------------
recommended_mailtips_large_audience_threshold: Optional[int] = Field(
default=None,
ge=5,
le=10000,
description=(
"Recipient count that should trigger a 'large audience' MailTip. "
"Range: 5..10000 (Microsoft default 25)."
),
)
# --- Defender malware policy --------------------------------------
default_recommended_extensions: Optional[list[str]] = Field(
default=None,
description="File extensions blocked by the malware policy.",
)
# --- Mailbox auditing ---------------------------------------------
audit_log_age: Optional[int] = Field(
default=None,
ge=30,
le=3650,
description=(
"Days mailbox audit logs must be retained. Range: 30..3650 "
"(M365 E3 default is 90 days; SEC/FINRA require ≥7 years)."
),
)
-19
View File
@@ -1,19 +0,0 @@
"""MongoDB Atlas provider config schema with safety bounds."""
from typing import Optional
from pydantic import Field
from prowler.config.schema.base import ProviderConfigBase
class MongoDBAtlasProviderConfig(ProviderConfigBase):
max_service_account_secret_validity_hours: Optional[int] = Field(
default=None,
ge=1,
le=720,
description=(
"Max hours a service account secret can stay valid. "
"Range: 1..720 (1 h .. 30 days)."
),
)
-28
View File
@@ -1,28 +0,0 @@
"""Mapping of provider name to its Pydantic schema class.
Kept in its own module so the validator stays free of provider-schema imports
and callers pay the import cost only when they actually need the registry.
"""
from prowler.config.schema.aws import AWSProviderConfig
from prowler.config.schema.azure import AzureProviderConfig
from prowler.config.schema.base import ProviderConfigBase
from prowler.config.schema.cloudflare import CloudflareProviderConfig
from prowler.config.schema.gcp import GCPProviderConfig
from prowler.config.schema.github import GitHubProviderConfig
from prowler.config.schema.kubernetes import KubernetesProviderConfig
from prowler.config.schema.m365 import M365ProviderConfig
from prowler.config.schema.mongodbatlas import MongoDBAtlasProviderConfig
from prowler.config.schema.vercel import VercelProviderConfig
SCHEMAS: dict[str, type[ProviderConfigBase]] = {
"aws": AWSProviderConfig,
"azure": AzureProviderConfig,
"gcp": GCPProviderConfig,
"kubernetes": KubernetesProviderConfig,
"m365": M365ProviderConfig,
"github": GitHubProviderConfig,
"mongodbatlas": MongoDBAtlasProviderConfig,
"cloudflare": CloudflareProviderConfig,
"vercel": VercelProviderConfig,
}
-61
View File
@@ -1,61 +0,0 @@
from typing import Any
from pydantic import ValidationError
from prowler.config.schema.base import ProviderConfigBase
from prowler.lib.logger import logger
def validate_provider_config(
provider: str,
raw: Any,
schema_cls: type[ProviderConfigBase] | None,
) -> dict:
"""Validate a provider's config dict against its Pydantic schema.
Behavior is intentionally lenient to preserve backwards compatibility:
- If ``raw`` is not a dict, return an empty dict (mirrors prior loader).
- If no schema is registered for ``provider``, return ``raw`` untouched.
- On validation errors, log one WARNING per offending field, DROP those
keys from the result, and continue. Consumers fall back to their own
hard-coded defaults via ``audit_config.get(key, default)``.
- Coerced values (e.g. ``"180"`` -> ``180``) replace the user's input
so that downstream checks never receive a wrongly-typed value.
"""
if not isinstance(raw, dict):
return {}
if schema_cls is None:
return raw
try:
model = schema_cls.model_validate(raw)
return model.model_dump(exclude_unset=True)
except ValidationError as exc:
bad_keys: set[str] = set()
for err in exc.errors():
loc = err.get("loc") or ()
if not loc:
continue
key = loc[0]
if not isinstance(key, str):
continue
bad_keys.add(key)
logger.warning(
f"prowler.config[{provider}.{key}] = {raw.get(key)!r} is invalid "
f"({err.get('msg', 'validation error')}); the value will be ignored "
f"and the built-in default will be used."
)
cleaned = {k: v for k, v in raw.items() if k not in bad_keys}
try:
model = schema_cls.model_validate(cleaned)
return model.model_dump(exclude_unset=True)
except ValidationError as exc2:
logger.error(
f"prowler.config[{provider}] could not be revalidated after dropping "
f"invalid keys ({bad_keys}); passing through the cleaned dict as-is. "
f"Underlying errors: {exc2.errors()}"
)
return cleaned
-61
View File
@@ -1,61 +0,0 @@
"""Vercel provider config schema with safety bounds."""
from typing import Optional
from pydantic import Field
from prowler.config.schema.base import ProviderConfigBase
class VercelProviderConfig(ProviderConfigBase):
stable_branches: Optional[list[str]] = Field(
default=None,
description="Branches considered stable for production deployments.",
)
days_to_expire_threshold: Optional[int] = Field(
default=None,
ge=7,
le=365,
description=(
"Days before token/certificate expiration to flag. Range: 7..365 "
"(PCI-DSS 4.2.1.1: alert ≥30 days before expiry)."
),
)
stale_token_threshold_days: Optional[int] = Field(
default=None,
ge=30,
le=3650,
description=(
"Days of inactivity before a token is considered stale. Range: 30..3650 "
"(NIST AC-2(3) typical window 30..90 days)."
),
)
stale_invitation_threshold_days: Optional[int] = Field(
default=None,
ge=7,
le=365,
description=(
"Days a pending invitation can stay open. Range: 7..365 "
"(OWASP ASVS 2.7.1 recommends short-lived invitations)."
),
)
max_owner_percentage: Optional[int] = Field(
default=None,
ge=1,
le=50,
description=(
"Max percentage of team members that can have the OWNER role. "
"Range: 1..50 (PoLP — having >50% of a team as OWNER defeats RBAC; "
"industry guidance recommends ≤25%)."
),
)
max_owners: Optional[int] = Field(
default=None,
ge=1,
le=1000,
description="Absolute max owners (overrides percentage for large teams). Range: 1..1000.",
)
secret_suffixes: Optional[list[str]] = Field(
default=None,
description="Suffixes that mark a project env var as secret-like.",
)
+21
View File
@@ -283,6 +283,26 @@ class CSA_CCM_Requirement_Attribute(BaseModel):
ScopeApplicability: list[dict]
class STIG_Requirement_Attribute_Severity(str, Enum):
"""DISA STIG Requirement Attribute Severity (maps to CAT I/II/III)"""
high = "high"
medium = "medium"
low = "low"
class STIG_Requirement_Attribute(BaseModel):
"""DISA STIG Requirement Attribute"""
Section: str
Severity: STIG_Requirement_Attribute_Severity
RuleID: str
StigID: str
CCI: Optional[list[str]] = None
CheckText: Optional[str] = None
FixText: Optional[str] = None
# Base Compliance Model
# TODO: move this to compliance folder
class Compliance_Requirement(BaseModel):
@@ -303,6 +323,7 @@ class Compliance_Requirement(BaseModel):
CCC_Requirement_Attribute,
C5Germany_Requirement_Attribute,
CSA_CCM_Requirement_Attribute,
STIG_Requirement_Attribute,
# Generic_Compliance_Requirement_Attribute must be the last one since it is the fallback for generic compliance framework
Generic_Compliance_Requirement_Attribute,
]
@@ -18,6 +18,9 @@ from prowler.lib.outputs.compliance.kisa_ismsp.kisa_ismsp import get_kisa_ismsp_
from prowler.lib.outputs.compliance.mitre_attack.mitre_attack import (
get_mitre_attack_table,
)
from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig import (
get_okta_idaas_stig_table,
)
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore import (
get_prowler_threatscore_table,
)
@@ -252,6 +255,15 @@ def display_compliance_table(
output_directory,
compliance_overview,
)
elif compliance_framework.startswith("okta_idaas_stig"):
get_okta_idaas_stig_table(
findings,
bulk_checks_metadata,
compliance_framework,
output_filename,
output_directory,
compliance_overview,
)
else:
# Try provider-specific table first, fall back to generic
from prowler.providers.common.provider import Provider
@@ -0,0 +1,32 @@
from typing import Optional
from pydantic.v1 import BaseModel
class OktaIDaaSSTIGModel(BaseModel):
"""
OktaIDaaSSTIGModel generates a finding's output in DISA Okta IDaaS STIG Compliance format.
"""
Provider: str
Description: str
OrganizationDomain: str
AssessmentDate: str
Requirements_Id: str
Requirements_Name: str
Requirements_Description: str
Requirements_Attributes_Section: str
Requirements_Attributes_Severity: str
Requirements_Attributes_RuleID: str
Requirements_Attributes_StigID: str
Requirements_Attributes_CCI: Optional[list[str]] = None
Requirements_Attributes_CheckText: Optional[str] = None
Requirements_Attributes_FixText: Optional[str] = None
Status: str
StatusExtended: str
ResourceId: str
ResourceName: str
CheckId: str
Muted: bool
Framework: str
Name: str
@@ -0,0 +1,98 @@
from colorama import Fore, Style
from tabulate import tabulate
from prowler.config.config import orange_color
def get_okta_idaas_stig_table(
findings: list,
bulk_checks_metadata: dict,
compliance_framework: str,
output_filename: str,
output_directory: str,
compliance_overview: bool,
):
section_table = {
"Provider": [],
"Section": [],
"Status": [],
"Muted": [],
}
pass_count = []
fail_count = []
muted_count = []
sections = {}
for index, finding in enumerate(findings):
check = bulk_checks_metadata[finding.check_metadata.CheckID]
check_compliances = check.Compliance
for compliance in check_compliances:
if compliance.Framework == "Okta-IDaaS-STIG":
for requirement in compliance.Requirements:
for attribute in requirement.Attributes:
section = attribute.Section
if section not in sections:
sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0}
if finding.muted:
if index not in muted_count:
muted_count.append(index)
sections[section]["Muted"] += 1
else:
if finding.status == "FAIL" and index not in fail_count:
fail_count.append(index)
sections[section]["FAIL"] += 1
elif finding.status == "PASS" and index not in pass_count:
pass_count.append(index)
sections[section]["PASS"] += 1
sections = dict(sorted(sections.items()))
for section in sections:
section_table["Provider"].append(compliance.Provider)
section_table["Section"].append(section)
if sections[section]["FAIL"] > 0:
section_table["Status"].append(
f"{Fore.RED}FAIL({sections[section]['FAIL']}){Style.RESET_ALL}"
)
else:
if sections[section]["PASS"] > 0:
section_table["Status"].append(
f"{Fore.GREEN}PASS({sections[section]['PASS']}){Style.RESET_ALL}"
)
else:
section_table["Status"].append(f"{Fore.GREEN}PASS{Style.RESET_ALL}")
section_table["Muted"].append(
f"{orange_color}{sections[section]['Muted']}{Style.RESET_ALL}"
)
if (
len(fail_count) + len(pass_count) + len(muted_count) > 1
): # If there are no resources, don't print the compliance table
print(
f"\nCompliance Status of {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Framework:"
)
total_findings_count = len(fail_count) + len(pass_count) + len(muted_count)
overview_table = [
[
f"{Fore.RED}{round(len(fail_count) / total_findings_count * 100, 2)}% ({len(fail_count)}) FAIL{Style.RESET_ALL}",
f"{Fore.GREEN}{round(len(pass_count) / total_findings_count * 100, 2)}% ({len(pass_count)}) PASS{Style.RESET_ALL}",
f"{orange_color}{round(len(muted_count) / total_findings_count * 100, 2)}% ({len(muted_count)}) MUTED{Style.RESET_ALL}",
]
]
print(tabulate(overview_table, tablefmt="rounded_grid"))
if not compliance_overview:
if len(fail_count) > 0 and len(section_table["Section"]) > 0:
print(
f"\nFramework {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Results:"
)
print(
tabulate(
section_table,
tablefmt="rounded_grid",
headers="keys",
)
)
print(f"\nDetailed results of {compliance_framework.upper()} are in:")
print(
f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework}.csv\n"
)
@@ -0,0 +1,95 @@
from prowler.config.config import timestamp
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput
from prowler.lib.outputs.compliance.okta_idaas_stig.models import OktaIDaaSSTIGModel
from prowler.lib.outputs.finding import Finding
class OktaIDaaSSTIG(ComplianceOutput):
"""
This class represents the Okta IDaaS STIG compliance output.
Attributes:
- _data (list): A list to store transformed data from findings.
- _file_descriptor (TextIOWrapper): A file descriptor to write data to a file.
Methods:
- transform: Transforms findings into Okta IDaaS STIG compliance format.
"""
def transform(
self,
findings: list[Finding],
compliance: Compliance,
_compliance_name: str,
) -> None:
"""
Transforms a list of findings into Okta IDaaS STIG compliance format.
Parameters:
- findings (list): A list of findings.
- compliance (Compliance): A compliance model.
- _compliance_name (str): The name of the compliance model (unused).
Returns:
- None
"""
for finding in findings:
for requirement in compliance.Requirements:
# Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift).
if finding.check_id in requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = OktaIDaaSSTIGModel(
Provider=finding.provider,
Description=compliance.Description,
OrganizationDomain=finding.account_name,
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Name=requirement.Name,
Requirements_Description=requirement.Description,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_Severity=attribute.Severity.value,
Requirements_Attributes_RuleID=attribute.RuleID,
Requirements_Attributes_StigID=attribute.StigID,
Requirements_Attributes_CCI=attribute.CCI,
Requirements_Attributes_CheckText=attribute.CheckText,
Requirements_Attributes_FixText=attribute.FixText,
Status=finding.status,
StatusExtended=finding.status_extended,
ResourceId=finding.resource_uid,
ResourceName=finding.resource_name,
CheckId=finding.check_id,
Muted=finding.muted,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
# Add manual requirements to the compliance output
for requirement in compliance.Requirements:
if not requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = OktaIDaaSSTIGModel(
Provider=compliance.Provider.lower(),
Description=compliance.Description,
OrganizationDomain="",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Name=requirement.Name,
Requirements_Description=requirement.Description,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_Severity=attribute.Severity.value,
Requirements_Attributes_RuleID=attribute.RuleID,
Requirements_Attributes_StigID=attribute.StigID,
Requirements_Attributes_CCI=attribute.CCI,
Requirements_Attributes_CheckText=attribute.CheckText,
Requirements_Attributes_FixText=attribute.FixText,
Status="MANUAL",
StatusExtended="Manual check",
ResourceId="manual_check",
ResourceName="Manual check",
CheckId="manual",
Muted=False,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
@@ -0,0 +1,41 @@
{
"Provider": "aws",
"CheckID": "bedrock_agent_role_least_privilege",
"CheckTitle": "Amazon Bedrock agent execution role follows least privilege",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis",
"TTPs/Privilege Escalation"
],
"ServiceName": "bedrock",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "Other",
"ResourceGroup": "ai_ml",
"Description": "**Bedrock Agent** execution roles (`agentResourceRoleArn`) should grant only the minimum permissions the agent needs. The evaluation FAILs when the role has an AWS-managed `*FullAccess` policy attached, has an inline statement allowing broad actions on `Resource: \"*\"`, or has no permissions boundary configured.",
"Risk": "An overly permissive **Bedrock Agent** execution role turns a successful **prompt injection** into AWS privilege escalation. A model coerced into calling tools can invoke any API the role allows — reading secrets, modifying IAM, exfiltrating data from S3, or pivoting laterally. **Least privilege** plus a **permissions boundary** keeps the blast radius bounded even when guardrails fail.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/bedrock/latest/userguide/agents-permissions.html",
"https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html",
"https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege"
],
"Remediation": {
"Code": {
"CLI": "aws iam put-role-permissions-boundary --role-name <execution_role_name> --permissions-boundary <boundary_policy_arn>",
"NativeIaC": "",
"Other": "1. Identify the Bedrock Agent's execution role (agentResourceRoleArn) in the IAM console\n2. Detach any AWS-managed *FullAccess policies (e.g. AmazonBedrockFullAccess, AdministratorAccess)\n3. Replace inline policies that use Resource: \"*\" with statements scoped to specific resource ARNs and minimal action sets\n4. Attach a permissions boundary that caps what the role can ever do, even if a future policy is added\n5. Re-run Prowler to confirm the check passes",
"Terraform": "```hcl\nresource \"aws_iam_role\" \"bedrock_agent\" {\n name = \"<execution_role_name>\"\n assume_role_policy = data.aws_iam_policy_document.trust.json\n permissions_boundary = aws_iam_policy.bedrock_agent_boundary.arn # CRITICAL: caps maximum privileges\n}\n\nresource \"aws_iam_role_policy\" \"bedrock_agent_inline\" {\n role = aws_iam_role.bedrock_agent.name\n policy = jsonencode({\n Version = \"2012-10-17\",\n Statement = [{\n Effect = \"Allow\",\n Action = [\"s3:GetObject\"], # CRITICAL: narrow action\n Resource = [\"arn:aws:s3:::my-rag-bucket/*\"] # CRITICAL: narrow resource\n }]\n })\n}\n```"
},
"Recommendation": {
"Text": "Apply **least privilege** to every Bedrock Agent execution role: scope `Action` and `Resource` to exactly what the agent needs, avoid AWS-managed `*FullAccess` policies, and always attach a **permissions boundary** so that future policy edits cannot exceed an approved ceiling. Treat agent roles as high-risk because prompt injection can weaponize any granted permission.",
"Url": "https://hub.prowler.com/check/bedrock_agent_role_least_privilege"
}
},
"Categories": [
"gen-ai"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,101 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.bedrock.bedrock_agent_client import (
bedrock_agent_client,
)
from prowler.providers.aws.services.iam.iam_client import iam_client
from prowler.providers.aws.services.iam.lib.policy import check_admin_access
from prowler.providers.aws.services.iam.lib.privilege_escalation import (
check_privilege_escalation,
)
class bedrock_agent_role_least_privilege(Check):
"""Ensure Bedrock Agent execution roles follow least privilege.
A Bedrock Agent's execution role is evaluated against three criteria:
- No AWS-managed ``*FullAccess`` policy attached.
- No attached or inline policy granting administrative access or known
privilege escalation combinations.
- A permissions boundary is configured on the role.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Run the least-privilege evaluation across all Bedrock Agents.
Returns:
A list of ``Check_Report_AWS`` with one entry per agent. The
status is ``FAIL`` when any of the criteria above is violated,
or when the execution role cannot be resolved in IAM.
"""
findings = []
roles_by_arn = {role.arn: role for role in (iam_client.roles or [])}
for agent in bedrock_agent_client.agents.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=agent)
report.status = "PASS"
report.status_extended = (
f"Bedrock Agent {agent.name} execution role follows least privilege."
)
role = roles_by_arn.get(agent.role_arn) if agent.role_arn else None
if role is None:
report.status = "FAIL"
report.status_extended = (
f"Bedrock Agent {agent.name} execution role could not be "
f"resolved in IAM and cannot be evaluated for least privilege."
)
findings.append(report)
continue
violations = []
for policy in role.attached_policies:
policy_arn = policy.get("PolicyArn", "")
policy_name = policy.get("PolicyName") or policy_arn
if policy_arn.startswith(
"arn:aws:iam::aws:policy/"
) and policy_arn.endswith("FullAccess"):
violations.append(
f"managed policy {policy_name} grants full access"
)
continue
policy_obj = iam_client.policies.get(policy_arn)
if policy_obj is None or not policy_obj.document:
continue
document = policy_obj.document
if check_admin_access(document):
violations.append(
f"managed policy {policy_name} grants administrative access"
)
elif check_privilege_escalation(document):
violations.append(
f"managed policy {policy_name} allows privilege escalation"
)
for inline_name in role.inline_policies:
policy_obj = iam_client.policies.get(f"{role.arn}:policy/{inline_name}")
if policy_obj is None or not policy_obj.document:
continue
document = policy_obj.document
if check_admin_access(document):
violations.append(
f"inline policy {inline_name} grants administrative access"
)
elif check_privilege_escalation(document):
violations.append(
f"inline policy {inline_name} allows privilege escalation"
)
if not role.permissions_boundary:
violations.append("no permissions boundary configured")
if violations:
report.status = "FAIL"
report.status_extended = (
f"Bedrock Agent {agent.name} execution role violates least "
f"privilege: {'; '.join(violations)}."
)
findings.append(report)
return findings
@@ -146,6 +146,7 @@ class BedrockAgent(AWSService):
self.prompts = {}
self.prompt_scanned_regions: set = set()
self.__threading_call__(self._list_agents)
self.__threading_call__(self._get_agent, self.agents.values())
self.__threading_call__(self._list_prompts)
self.__threading_call__(self._get_prompt, self.prompts.values())
self.__threading_call__(self._list_tags_for_resource, self.agents.values())
@@ -174,6 +175,22 @@ class BedrockAgent(AWSService):
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_agent(self, agent):
"""Fetch full agent details to capture the execution role ARN.
list_agents only returns summaries (no agentResourceRoleArn), so we
need a per-agent GetAgent call. Stored on the Agent model for use by
checks like bedrock_agent_role_least_privilege.
"""
logger.info("Bedrock Agent - Getting Agent...")
try:
agent_info = self.regional_clients[agent.region].get_agent(agentId=agent.id)
agent.role_arn = agent_info.get("agent", {}).get("agentResourceRoleArn")
except Exception as error:
logger.error(
f"{agent.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_prompts(self, regional_client):
"""List all prompts in a region."""
logger.info("Bedrock Agent - Listing Prompts...")
@@ -236,6 +253,7 @@ class Agent(BaseModel):
name: str
arn: str
guardrail_id: Optional[str] = None
role_arn: Optional[str] = None
region: str
tags: Optional[list] = []
@@ -103,6 +103,9 @@ class IAM(AWSService):
self._get_user_temporary_credentials_usage()
self.organization_features = []
self._list_organizations_features()
# ListRoles does not echo PermissionsBoundary; backfill via GetRole.
if self.roles:
self.__threading_call__(self._get_role_permissions_boundary, self.roles)
# List missing tags
self.__threading_call__(self._list_tags, self.users)
self.__threading_call__(self._list_tags, self.roles)
@@ -133,6 +136,7 @@ class IAM(AWSService):
arn=role["Arn"],
assume_role_policy=role["AssumeRolePolicyDocument"],
is_service_role=is_service_role(role),
permissions_boundary=role.get("PermissionsBoundary"),
)
)
except ClientError as error:
@@ -460,6 +464,34 @@ class IAM(AWSService):
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_role_permissions_boundary(self, role):
"""Backfill ``role.permissions_boundary`` via ``GetRole``.
``ListRoles`` does not return ``PermissionsBoundary`` in practice, so
the value is fetched per role and stored on the ``Role`` model.
Args:
role: The ``Role`` instance to enrich.
"""
try:
response = self.client.get_role(RoleName=role.name)
role.permissions_boundary = response.get("Role", {}).get(
"PermissionsBoundary"
)
except ClientError as error:
if error.response["Error"]["Code"] == "NoSuchEntity":
logger.warning(
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
else:
logger.error(
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
logger.error(
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_attached_role_policies(self):
logger.info("IAM - List Attached Role Policies...")
try:
@@ -1139,6 +1171,7 @@ class Role(BaseModel):
is_service_role: bool
attached_policies: list[dict] = []
inline_policies: list[str] = []
permissions_boundary: Optional[dict] = None
tags: Optional[list]
@@ -0,0 +1,39 @@
{
"Provider": "oraclecloud",
"CheckID": "identity_storage_service_level_admins_scoped",
"CheckTitle": "OCI IAM storage service-level admin policies exclude delete permissions",
"CheckType": [],
"ServiceName": "identity",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Policy",
"ResourceGroup": "IAM",
"Description": "**OCI IAM policies** are reviewed to ensure storage service-level administrator statements that grant `manage` permissions exclude the relevant storage delete permissions with `request.permission`. This supports CIS OCI 3.1 control 1.15 separation of duties for Block Volume, File Storage, and Object Storage administrators.",
"Risk": "Storage service-level administrators with unrestricted `manage` permissions can delete the resources they administer, including volumes, backups, file systems, mount targets, export sets, objects, or buckets. This weakens separation of duties and can lead to data loss, service disruption, or destructive insider activity.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.oracle.com/en-us/iaas/Content/Identity/policyreference/policyreference.htm",
"https://docs.oracle.com/en-us/iaas/Content/Block/home.htm",
"https://docs.oracle.com/en-us/iaas/Content/File/home.htm",
"https://docs.oracle.com/en-us/iaas/Content/Object/home.htm"
],
"Remediation": {
"Code": {
"CLI": "oci iam policy update --policy-id <policy-ocid> --statements \"[\\\"Allow group VolumeUsers to manage volumes in tenancy where request.permission!='VOLUME_DELETE'\\\"]\"",
"NativeIaC": "",
"Other": "1. In OCI Console, go to Identity & Security > Policies\n2. Open each active policy that grants storage service-level administrators `manage` permissions\n3. Edit storage manage statements to exclude the relevant delete permission with `request.permission`\n4. Example: Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE'\n5. Save changes",
"Terraform": "```hcl\nresource \"oci_identity_policy\" \"storage_admins\" {\n compartment_id = var.compartment_id\n name = \"storage-admins\"\n description = \"Storage administrators without delete permissions\"\n\n statements = [\n \"Allow group VolumeUsers to manage volumes in tenancy where request.permission!='VOLUME_DELETE'\",\n \"Allow group VolumeUsers to manage volume-backups in tenancy where request.permission!='VOLUME_BACKUP_DELETE'\",\n \"Allow group FileUsers to manage file-systems in tenancy where request.permission!='FILE_SYSTEM_DELETE'\",\n \"Allow group FileUsers to manage mount-targets in tenancy where request.permission!='MOUNT_TARGET_DELETE'\",\n \"Allow group FileUsers to manage export-sets in tenancy where request.permission!='EXPORT_SET_DELETE'\",\n \"Allow group BucketUsers to manage objects in tenancy where request.permission!='OBJECT_DELETE'\",\n \"Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE'\"\n ]\n}\n```"
},
"Recommendation": {
"Text": "Exclude delete permissions from storage service-level administrator policies. Use `request.permission!='VOLUME_DELETE'`, `request.permission!='VOLUME_BACKUP_DELETE'`, `request.permission!='FILE_SYSTEM_DELETE'`, `request.permission!='MOUNT_TARGET_DELETE'`, `request.permission!='EXPORT_SET_DELETE'`, `request.permission!='OBJECT_DELETE'`, and `request.permission!='BUCKET_DELETE'` as appropriate for each storage manage statement.",
"Url": "https://hub.prowler.com/check/identity_storage_service_level_admins_scoped"
}
},
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,154 @@
"""Check storage service-level administrators cannot delete managed resources."""
import re
from prowler.lib.check.models import Check, Check_Report_OCI
from prowler.providers.oraclecloud.services.identity.identity_client import (
identity_client,
)
STORAGE_DELETE_PERMISSIONS_BY_RESOURCE = {
"volumes": {"VOLUME_DELETE"},
"volume-backups": {"VOLUME_BACKUP_DELETE"},
"file-systems": {"FILE_SYSTEM_DELETE"},
"mount-targets": {"MOUNT_TARGET_DELETE"},
"export-sets": {"EXPORT_SET_DELETE"},
"objects": {"OBJECT_DELETE"},
"buckets": {"BUCKET_DELETE"},
"volume-family": {"VOLUME_DELETE", "VOLUME_BACKUP_DELETE"},
"file-family": {"FILE_SYSTEM_DELETE", "MOUNT_TARGET_DELETE", "EXPORT_SET_DELETE"},
"object-family": {"OBJECT_DELETE", "BUCKET_DELETE"},
}
ALL_STORAGE_DELETE_PERMISSIONS = set().union(
*STORAGE_DELETE_PERMISSIONS_BY_RESOURCE.values()
)
STORAGE_DELETE_PERMISSIONS_BY_RESOURCE["all-resources"] = ALL_STORAGE_DELETE_PERMISSIONS
MANAGE_STATEMENT_PATTERN = re.compile(
r"\ballow\s+group\b.+?\bto\s+manage\s+(?P<resource>[a-z-]+)\b",
re.IGNORECASE,
)
def _normalize_statement(statement: str) -> str:
"""Collapse whitespace in an OCI policy statement."""
return " ".join(statement.strip().split())
def _has_disjunctive_condition(statement: str) -> bool:
"""Return True when the WHERE condition can allow alternate branches."""
condition = re.split(r"\bwhere\b", statement, flags=re.IGNORECASE, maxsplit=1)
if len(condition) != 2:
return False
return bool(re.search(r"\b(any|or)\b|\|\|", condition[1], re.IGNORECASE))
def _excluded_permissions(statement: str) -> set[str]:
"""Return delete permissions explicitly excluded with request.permission != value."""
if _has_disjunctive_condition(statement):
return set()
exclusions = set()
for permission in ALL_STORAGE_DELETE_PERMISSIONS:
pattern = re.compile(
rf"\brequest\.permission\s*!=\s*['\"]?{re.escape(permission)}['\"]?\b",
re.IGNORECASE,
)
if pattern.search(statement):
exclusions.add(permission)
return exclusions
def _missing_delete_exclusions(statement: str) -> tuple[str, set[str]] | None:
"""Return the storage resource and missing delete exclusions for a statement."""
normalized_statement = _normalize_statement(statement)
match = MANAGE_STATEMENT_PATTERN.search(normalized_statement)
if not match:
return None
resource = match.group("resource").lower()
required_permissions = STORAGE_DELETE_PERMISSIONS_BY_RESOURCE.get(resource)
if not required_permissions:
return None
excluded_permissions = _excluded_permissions(normalized_statement)
missing_permissions = required_permissions - excluded_permissions
if not missing_permissions:
return None
return resource, missing_permissions
class identity_storage_service_level_admins_scoped(Check):
"""Ensure storage service-level admins cannot delete resources they manage."""
def execute(self) -> list[Check_Report_OCI]:
"""Execute the storage service-level administrators scoped check.
Returns:
A list of OCI check reports for active non-tenant-admin policies.
"""
findings = []
for policy in identity_client.policies:
if policy.lifecycle_state != "ACTIVE":
continue
if policy.name.upper() == "TENANT ADMIN POLICY":
continue
region = policy.region if hasattr(policy, "region") else "global"
violations = []
for statement in policy.statements:
missing_result = _missing_delete_exclusions(statement)
if not missing_result:
continue
resource, missing_permissions = missing_result
violations.append(
f"statement `{_normalize_statement(statement)}` manages {resource} without excluding: {', '.join(sorted(missing_permissions))}"
)
report = Check_Report_OCI(
metadata=self.metadata(),
resource=policy,
region=region,
resource_id=policy.id,
resource_name=policy.name,
compartment_id=policy.compartment_id,
)
if violations:
report.status = "FAIL"
report.status_extended = (
f"Policy '{policy.name}' allows storage service-level administrators to manage storage resources without explicitly excluding required delete permissions: "
+ "; ".join(violations)
+ "."
)
else:
report.status = "PASS"
report.status_extended = f"Policy '{policy.name}' excludes required storage delete permissions from storage manage statements."
findings.append(report)
if not findings:
region = (
identity_client.audited_regions[0].key
if identity_client.audited_regions
else "global"
)
report = Check_Report_OCI(
metadata=self.metadata(),
resource={},
region=region,
resource_id=identity_client.audited_tenancy,
resource_name="Tenancy",
compartment_id=identity_client.audited_tenancy,
)
report.status = "PASS"
report.status_extended = "No active storage service-level administrator policies grant manage permissions without excluding delete permissions."
findings.append(report)
return findings
-222
View File
@@ -1,222 +0,0 @@
"""AWS-specific schema coverage — the biggest provider, with the richest
constraint surface (CIDRs, account IDs, port ranges, enums, thresholds)."""
import pytest
from prowler.config.schema.aws import AWSProviderConfig
from prowler.config.schema.validator import validate_provider_config
def _validate(raw):
return validate_provider_config("aws", raw, AWSProviderConfig)
class Test_AWS_Threat_Detection_Thresholds:
"""All threat detection thresholds are documented as fractions in 0..1.
The biggest risk of mistyping them is silently disabling the check."""
@pytest.mark.parametrize(
"key",
[
"threat_detection_privilege_escalation_threshold",
"threat_detection_enumeration_threshold",
"threat_detection_llm_jacking_threshold",
],
)
def test_valid_boundary_values(self, key):
assert _validate({key: 0.0}) == {key: 0.0}
assert _validate({key: 1.0}) == {key: 1.0}
assert _validate({key: 0.5}) == {key: 0.5}
@pytest.mark.parametrize(
"key",
[
"threat_detection_privilege_escalation_threshold",
"threat_detection_enumeration_threshold",
"threat_detection_llm_jacking_threshold",
],
)
def test_invalid_values_are_dropped(self, key):
# 20 instead of 0.2 — would never trigger
assert _validate({key: 20}) == {}
# negative
assert _validate({key: -0.1}) == {}
# string
assert _validate({key: "high"}) == {}
class Test_AWS_Trusted_Account_Ids:
def test_valid_twelve_digit_ids(self):
ids = ["123456789012", "098765432109"]
assert _validate({"trusted_account_ids": ids}) == {"trusted_account_ids": ids}
def test_empty_list_is_valid(self):
assert _validate({"trusted_account_ids": []}) == {"trusted_account_ids": []}
def test_short_id_is_dropped(self):
assert _validate({"trusted_account_ids": ["12345"]}) == {}
def test_non_numeric_id_is_dropped(self):
assert _validate({"trusted_account_ids": ["1234abcd5678"]}) == {}
def test_id_with_dashes_is_dropped(self):
# Some users format account IDs as "1234-5678-9012"
assert _validate({"trusted_account_ids": ["1234-5678-9012"]}) == {}
class Test_AWS_Trusted_Ips:
def test_single_ipv4_address(self):
assert _validate({"trusted_ips": ["1.2.3.4"]}) == {"trusted_ips": ["1.2.3.4"]}
def test_ipv4_cidr(self):
assert _validate({"trusted_ips": ["10.0.0.0/8"]}) == {
"trusted_ips": ["10.0.0.0/8"]
}
def test_ipv6_address(self):
assert _validate({"trusted_ips": ["2001:db8::1"]}) == {
"trusted_ips": ["2001:db8::1"]
}
def test_ipv6_cidr(self):
assert _validate({"trusted_ips": ["2001:db8::/32"]}) == {
"trusted_ips": ["2001:db8::/32"]
}
def test_mixed_list(self):
ips = ["1.2.3.4", "10.0.0.0/8", "2001:db8::1"]
assert _validate({"trusted_ips": ips}) == {"trusted_ips": ips}
def test_garbage_entry_is_dropped(self):
assert _validate({"trusted_ips": ["definitely-not-an-ip"]}) == {}
def test_cidr_with_host_bits_is_accepted(self):
# We use strict=False so "10.0.0.5/8" is accepted. This matches the
# behaviour of most security tools and avoids surprising users who
# paste real-world allowlists with non-canonical CIDR notation.
assert _validate({"trusted_ips": ["10.0.0.5/8"]}) == {
"trusted_ips": ["10.0.0.5/8"]
}
class Test_AWS_Ports:
def test_valid_ports_in_range(self):
ports = [25, 80, 443, 65535, 1]
assert _validate({"ec2_high_risk_ports": ports}) == {
"ec2_high_risk_ports": ports
}
def test_port_zero_is_dropped(self):
# Port 0 is reserved and not a valid security signal.
assert _validate({"ec2_high_risk_ports": [0]}) == {}
def test_out_of_range_port_is_dropped(self):
assert _validate({"ec2_high_risk_ports": [70000]}) == {}
def test_negative_port_is_dropped(self):
assert _validate({"ec2_high_risk_ports": [-1]}) == {}
class Test_AWS_Enums:
@pytest.mark.parametrize("level", ["CRITICAL", "HIGH", "MEDIUM", "LOW"])
def test_valid_severity_levels(self, level):
assert _validate({"ecr_repository_vulnerability_minimum_severity": level}) == {
"ecr_repository_vulnerability_minimum_severity": level
}
@pytest.mark.parametrize("level", ["critical", "Medium", "ANY", "", "X"])
def test_invalid_severity_levels_are_dropped(self, level):
assert _validate({"ecr_repository_vulnerability_minimum_severity": level}) == {}
class Test_AWS_Detect_Secrets_Plugins:
def test_plugin_without_limit(self):
out = _validate({"detect_secrets_plugins": [{"name": "AWSKeyDetector"}]})
assert out == {"detect_secrets_plugins": [{"name": "AWSKeyDetector"}]}
def test_plugin_with_limit(self):
out = _validate(
{
"detect_secrets_plugins": [
{"name": "Base64HighEntropyString", "limit": 6.0}
]
}
)
assert out == {
"detect_secrets_plugins": [
{"name": "Base64HighEntropyString", "limit": 6.0}
]
}
def test_plugin_missing_name_drops_whole_field(self):
# ``name`` is required by the upstream library.
out = _validate({"detect_secrets_plugins": [{"limit": 6.0}]})
assert out == {}
def test_extra_plugin_kwargs_pass_through(self):
# Plugins can have arbitrary extra params (extra="allow" on the
# nested model). They must round-trip.
out = _validate(
{
"detect_secrets_plugins": [
{"name": "Custom", "my_param": "abc", "other": 42}
]
}
)
assert out == {
"detect_secrets_plugins": [
{"name": "Custom", "my_param": "abc", "other": 42}
]
}
class Test_AWS_Booleans:
@pytest.mark.parametrize(
"key",
[
"mute_non_default_regions",
"verify_premium_support_plans",
"check_rds_instance_replicas",
],
)
def test_true_and_false_round_trip(self, key):
assert _validate({key: True}) == {key: True}
assert _validate({key: False}) == {key: False}
def test_yaml_style_boolean_coercion(self):
# YAML can produce Python str "true"/"yes" if the user quoted it.
# Pydantic v2 will refuse string booleans by default. Verify it is
# dropped, not silently treated as True (which would be dangerous
# for verify_premium_support_plans).
out = _validate({"verify_premium_support_plans": "yes"})
# Pydantic actually DOES coerce "yes"/"no"/"true"/"false" in lax mode.
# We accept either outcome but require it to be a real bool.
if "verify_premium_support_plans" in out:
assert isinstance(out["verify_premium_support_plans"], bool)
class Test_AWS_Full_Default_Config_Round_Trips:
"""Loading the real shipped defaults through the schema must produce
exactly the same dict. This is the regression sentinel for backwards
compatibility."""
def test_full_default_config_round_trip(self):
# Subset that mirrors the shipped config.yaml semantics.
raw = {
"mute_non_default_regions": False,
"disallowed_regions": ["me-south-1", "me-central-1"],
"max_unused_access_keys_days": 45,
"max_ec2_instance_age_in_days": 180,
"trusted_account_ids": [],
"trusted_ips": [],
"ecr_repository_vulnerability_minimum_severity": "MEDIUM",
"threat_detection_privilege_escalation_threshold": 0.2,
"threat_detection_enumeration_threshold": 0.3,
"threat_detection_llm_jacking_threshold": 0.4,
"ec2_high_risk_ports": [25, 110, 8088],
"detect_secrets_plugins": [
{"name": "AWSKeyDetector"},
{"name": "Base64HighEntropyString", "limit": 6.0},
],
}
assert _validate(raw) == raw
-398
View File
@@ -1,398 +0,0 @@
"""Boundary tests for the safety bounds added on top of the upstream schemas.
Each parametrised case checks (a) the min and max values are accepted and
(b) one step outside the range is rejected. Custom validators (semver,
EKS minor, dotted version, port range, account IDs, IPs) get focused
positive/negative tests.
Tests use the public adapter ``prowler.config.scan_config_schema``: a
schema violation surfaces as a list of ``{"path", "message"}`` entries.
This keeps the contract the Prowler App backend depends on under test.
"""
import pytest
from prowler.config.scan_config_schema import validate_scan_config
def _has_error_for(errors, path_substr: str) -> bool:
return any(path_substr in e["path"] for e in errors)
# Each tuple: (provider, key, min_allowed, max_allowed)
INT_BOUND_CASES = [
# AWS
("aws", "max_unused_access_keys_days", 30, 180),
("aws", "max_console_access_days", 30, 180),
("aws", "max_unused_sagemaker_access_days", 7, 180),
("aws", "max_security_group_rules", 1, 1000),
("aws", "max_ec2_instance_age_in_days", 1, 1095),
("aws", "recommended_cdk_bootstrap_version", 1, 100),
("aws", "max_idle_disconnect_timeout_in_seconds", 60, 1800),
("aws", "max_disconnect_timeout_in_seconds", 60, 3600),
("aws", "max_session_duration_seconds", 600, 86400),
("aws", "lambda_min_azs", 1, 6),
("aws", "threat_detection_privilege_escalation_minutes", 5, 43200),
("aws", "threat_detection_enumeration_minutes", 5, 43200),
("aws", "threat_detection_llm_jacking_minutes", 5, 43200),
("aws", "days_to_expire_threshold", 7, 365),
("aws", "elb_min_azs", 1, 6),
("aws", "elbv2_min_azs", 1, 6),
("aws", "minimum_snapshot_retention_period", 1, 35),
("aws", "max_days_secret_unused", 7, 365),
("aws", "max_days_secret_unrotated", 1, 180),
("aws", "min_kinesis_stream_retention_hours", 24, 8760),
# Azure
("azure", "vm_backup_min_daily_retention_days", 7, 9999),
("azure", "apim_threat_detection_llm_jacking_minutes", 5, 43200),
# GCP
("gcp", "mig_min_zones", 1, 5),
("gcp", "max_snapshot_age_days", 1, 1095),
("gcp", "max_unused_account_days", 30, 365),
("gcp", "storage_min_retention_days", 1, 3650),
# Kubernetes
("kubernetes", "audit_log_maxbackup", 2, 1000),
("kubernetes", "audit_log_maxsize", 10, 10000),
("kubernetes", "audit_log_maxage", 7, 3650),
# M365
("m365", "sign_in_frequency", 1, 168),
("m365", "recommended_mailtips_large_audience_threshold", 5, 10000),
("m365", "audit_log_age", 30, 3650),
# GitHub
("github", "inactive_not_archived_days_threshold", 30, 3650),
# MongoDB Atlas
("mongodbatlas", "max_service_account_secret_validity_hours", 1, 720),
# Cloudflare
("cloudflare", "max_retries", 0, 10),
# Vercel
("vercel", "days_to_expire_threshold", 7, 365),
("vercel", "stale_token_threshold_days", 30, 3650),
("vercel", "stale_invitation_threshold_days", 7, 365),
("vercel", "max_owner_percentage", 1, 50),
("vercel", "max_owners", 1, 1000),
]
FLOAT_THRESHOLD_FIELDS = [
("aws", "threat_detection_privilege_escalation_threshold"),
("aws", "threat_detection_enumeration_threshold"),
("aws", "threat_detection_llm_jacking_threshold"),
("azure", "apim_threat_detection_llm_jacking_threshold"),
]
class TestIntegerBounds:
"""Each int field accepts both ends of its range and rejects ±1 outside."""
@pytest.mark.parametrize("provider, key, lo, hi", INT_BOUND_CASES)
def test_min_accepted(self, provider, key, lo, hi):
assert validate_scan_config({provider: {key: lo}}) == []
@pytest.mark.parametrize("provider, key, lo, hi", INT_BOUND_CASES)
def test_max_accepted(self, provider, key, lo, hi):
assert validate_scan_config({provider: {key: hi}}) == []
@pytest.mark.parametrize("provider, key, lo, hi", INT_BOUND_CASES)
def test_below_min_rejected(self, provider, key, lo, hi):
errors = validate_scan_config({provider: {key: lo - 1}})
assert _has_error_for(errors, f"{provider}.{key}"), errors
@pytest.mark.parametrize("provider, key, lo, hi", INT_BOUND_CASES)
def test_above_max_rejected(self, provider, key, lo, hi):
errors = validate_scan_config({provider: {key: hi + 1}})
assert _has_error_for(errors, f"{provider}.{key}"), errors
class TestFloatThresholds:
"""Threshold floats must stay within 0..1 inclusive."""
@pytest.mark.parametrize("provider, key", FLOAT_THRESHOLD_FIELDS)
def test_zero_and_one_accepted(self, provider, key):
assert validate_scan_config({provider: {key: 0.0}}) == []
assert validate_scan_config({provider: {key: 1.0}}) == []
assert validate_scan_config({provider: {key: 0.5}}) == []
@pytest.mark.parametrize("provider, key", FLOAT_THRESHOLD_FIELDS)
def test_negative_rejected(self, provider, key):
errors = validate_scan_config({provider: {key: -0.01}})
assert _has_error_for(errors, f"{provider}.{key}")
@pytest.mark.parametrize("provider, key", FLOAT_THRESHOLD_FIELDS)
def test_above_one_rejected(self, provider, key):
errors = validate_scan_config({provider: {key: 1.01}})
assert _has_error_for(errors, f"{provider}.{key}")
class TestCloudWatchRetention:
"""`log_group_retention_days` only accepts the AWS-approved enum values."""
@pytest.mark.parametrize("value", [1, 7, 30, 365, 731, 3653])
def test_valid_values_accepted(self, value):
assert validate_scan_config({"aws": {"log_group_retention_days": value}}) == []
@pytest.mark.parametrize("value", [0, 2, 42, 500, 999, 4000])
def test_invalid_values_rejected(self, value):
errors = validate_scan_config({"aws": {"log_group_retention_days": value}})
assert _has_error_for(errors, "aws.log_group_retention_days")
class TestSemverValidator:
"""AWS Fargate platform versions: X.Y.Z."""
@pytest.mark.parametrize("value", ["1.4.0", "1.0.0", "0.0.1", "10.20.30"])
def test_accepts_semver(self, value):
assert (
validate_scan_config({"aws": {"fargate_linux_latest_version": value}}) == []
)
@pytest.mark.parametrize("value", ["1.4", "1", "v1.4.0", "1.4.0-beta", "a.b.c", ""])
def test_rejects_non_semver(self, value):
errors = validate_scan_config({"aws": {"fargate_linux_latest_version": value}})
assert _has_error_for(errors, "aws.fargate_linux_latest_version")
class TestEksVersionValidator:
"""`eks_cluster_oldest_version_supported` expects MAJOR.MINOR."""
@pytest.mark.parametrize("value", ["1.28", "1.29", "1.30", "2.0"])
def test_accepts_minor(self, value):
assert (
validate_scan_config(
{"aws": {"eks_cluster_oldest_version_supported": value}}
)
== []
)
@pytest.mark.parametrize("value", ["1.28.0", "v1.28", "1", "1.x", ""])
def test_rejects_invalid(self, value):
errors = validate_scan_config(
{"aws": {"eks_cluster_oldest_version_supported": value}}
)
assert _has_error_for(errors, "aws.eks_cluster_oldest_version_supported")
class TestEksLogTypesEnum:
"""Only the documented log types are accepted."""
def test_full_enum_accepted(self):
assert (
validate_scan_config(
{
"aws": {
"eks_required_log_types": [
"api",
"audit",
"authenticator",
"controllerManager",
"scheduler",
]
}
}
)
== []
)
def test_unknown_type_rejected(self):
errors = validate_scan_config(
{"aws": {"eks_required_log_types": ["api", "telemetry"]}}
)
assert _has_error_for(errors, "aws.eks_required_log_types")
class TestAzureDottedVersion:
"""App Service versions accept 'X' and 'X.Y' but not 'X.Y.Z' or junk."""
@pytest.mark.parametrize("value", ["8.2", "3.12", "17"])
def test_accepts(self, value):
assert validate_scan_config({"azure": {"php_latest_version": value}}) == []
assert validate_scan_config({"azure": {"python_latest_version": value}}) == []
assert validate_scan_config({"azure": {"java_latest_version": value}}) == []
@pytest.mark.parametrize("value", ["8.2.0", "v8", "8.x", ""])
def test_rejects(self, value):
errors = validate_scan_config({"azure": {"php_latest_version": value}})
assert _has_error_for(errors, "azure.php_latest_version")
class TestAzureTlsLiteralEnum:
"""Only TLS 1.2 and 1.3 are tolerated by the recommended list."""
def test_accepted_versions(self):
assert (
validate_scan_config(
{"azure": {"recommended_minimal_tls_versions": ["1.2", "1.3"]}}
)
== []
)
@pytest.mark.parametrize("value", ["1.0", "1.1", "2.0", ""])
def test_unknown_version_rejected(self, value):
errors = validate_scan_config(
{"azure": {"recommended_minimal_tls_versions": [value]}}
)
assert _has_error_for(errors, "azure.recommended_minimal_tls_versions")
class TestAzureRiskLevelLiteral:
"""Defender attack-path risk level is a closed enum."""
@pytest.mark.parametrize("value", ["Low", "Medium", "High", "Critical"])
def test_accepted(self, value):
assert (
validate_scan_config(
{"azure": {"defender_attack_path_minimal_risk_level": value}}
)
== []
)
@pytest.mark.parametrize("value", ["low", "CRITICAL", "Severe", ""])
def test_rejected(self, value):
errors = validate_scan_config(
{"azure": {"defender_attack_path_minimal_risk_level": value}}
)
assert _has_error_for(errors, "azure.defender_attack_path_minimal_risk_level")
class TestECRSeverityLiteral:
"""ECR severity is a closed enum (with INFORMATIONAL allowed)."""
@pytest.mark.parametrize(
"value",
["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"],
)
def test_accepted(self, value):
assert (
validate_scan_config(
{"aws": {"ecr_repository_vulnerability_minimum_severity": value}}
)
== []
)
@pytest.mark.parametrize("value", ["URGENT", "low", "Crit", ""])
def test_rejected(self, value):
errors = validate_scan_config(
{"aws": {"ecr_repository_vulnerability_minimum_severity": value}}
)
assert _has_error_for(
errors, "aws.ecr_repository_vulnerability_minimum_severity"
)
class TestPortRangeValidator:
"""Each entry of `ec2_high_risk_ports` must be 1..65535 (0 is reserved)."""
def test_valid_ports(self):
assert (
validate_scan_config({"aws": {"ec2_high_risk_ports": [1, 22, 8080, 65535]}})
== []
)
@pytest.mark.parametrize("value", [-1, 0, 65536, 99999])
def test_invalid_port_rejected(self, value):
errors = validate_scan_config({"aws": {"ec2_high_risk_ports": [80, value]}})
assert _has_error_for(errors, "aws.ec2_high_risk_ports")
class TestAccountIdsValidator:
"""AWS account IDs are 12-digit strings."""
def test_valid(self):
assert (
validate_scan_config(
{"aws": {"trusted_account_ids": ["123456789012", "098765432109"]}}
)
== []
)
@pytest.mark.parametrize(
"value", ["12345", "12345678901", "1234567890123", "12345678901a"]
)
def test_invalid_rejected(self, value):
errors = validate_scan_config({"aws": {"trusted_account_ids": [value]}})
assert _has_error_for(errors, "aws.trusted_account_ids")
class TestTrustedIpsValidator:
"""Trusted IPs accept IPv4, IPv6, and CIDR; reject junk."""
@pytest.mark.parametrize(
"value",
["1.2.3.4", "10.0.0.0/8", "2001:db8::1", "2001:db8::/32"],
)
def test_valid(self, value):
assert validate_scan_config({"aws": {"trusted_ips": [value]}}) == []
@pytest.mark.parametrize(
"value", ["not.an.ip", "1.2.3.300", "10.0.0.0/40", "::ffff:::"]
)
def test_invalid_rejected(self, value):
errors = validate_scan_config({"aws": {"trusted_ips": [value]}})
assert _has_error_for(errors, "aws.trusted_ips")
class TestDetectSecretsEntropyBound:
"""`detect_secrets_plugins[].limit` is Shannon entropy: 0..10."""
@pytest.mark.parametrize("value", [0.0, 3.5, 4.5, 8.0, 10.0])
def test_valid(self, value):
assert (
validate_scan_config(
{
"aws": {
"detect_secrets_plugins": [
{"name": "Base64HighEntropyString", "limit": value}
]
}
}
)
== []
)
@pytest.mark.parametrize("value", [-0.1, 10.01, 50])
def test_invalid(self, value):
errors = validate_scan_config(
{
"aws": {
"detect_secrets_plugins": [
{"name": "Base64HighEntropyString", "limit": value}
]
}
}
)
assert _has_error_for(errors, "aws.detect_secrets_plugins")
class TestAdapterRobustness:
"""Top-level adapter behaviour the Prowler App backend depends on."""
def test_non_dict_payload(self):
errors = validate_scan_config([1, 2, 3])
assert len(errors) == 1
assert errors[0]["path"] == "<root>"
def test_unknown_provider_section_tolerated(self):
# additionalProperties: True at the root level by design.
assert validate_scan_config({"newprovider": {"foo": "bar"}}) == []
def test_unknown_key_tolerated_by_pydantic_extra_allow(self):
# ProviderConfigBase has extra="allow" for forward compatibility.
assert validate_scan_config({"aws": {"completely_new_knob": 1}}) == []
def test_provider_section_must_be_mapping(self):
errors = validate_scan_config({"aws": "not a mapping"})
assert _has_error_for(errors, "aws")
def test_multiple_errors_surfaced(self):
errors = validate_scan_config(
{
"aws": {
"max_unused_access_keys_days": 5, # below min 30
"max_security_group_rules": 99999, # above max 1000
"ec2_high_risk_ports": [80, 70000], # port out of range
}
}
)
# All three should surface independently.
assert _has_error_for(errors, "aws.max_unused_access_keys_days")
assert _has_error_for(errors, "aws.max_security_group_rules")
assert _has_error_for(errors, "aws.ec2_high_risk_ports")
@@ -1,123 +0,0 @@
"""End-to-end tests that exercise the real ``load_and_validate_config_file``
through a temp YAML file. Anything that breaks here would break the actual
``prowler aws -c `` code path."""
import logging
import os
import pathlib
import pytest
from prowler.config.config import load_and_validate_config_file
@pytest.fixture
def write_config(tmp_path):
def _write(content: str) -> str:
path = tmp_path / "config.yaml"
path.write_text(content)
return str(path)
return _write
class Test_Loader_With_Schema_Integration:
def test_shipped_default_config_loads_without_warnings(self, caplog):
"""The default ``prowler/config/config.yaml`` must round-trip every
provider WITHOUT emitting any schema warnings. If this fails,
someone added a key to the YAML without updating the schema."""
repo_root = pathlib.Path(os.path.dirname(os.path.realpath(__file__))).parents[2]
shipped = repo_root / "prowler" / "config" / "config.yaml"
with caplog.at_level(logging.WARNING, logger="prowler"):
for provider in [
"aws",
"azure",
"gcp",
"kubernetes",
"m365",
"github",
"mongodbatlas",
"cloudflare",
"vercel",
]:
cfg = load_and_validate_config_file(provider, str(shipped))
# Provider always exists in the shipped file → non-empty.
assert cfg, f"{provider} returned an empty config"
offending = [
r.getMessage()
for r in caplog.records
if "prowler.config[" in r.getMessage()
]
assert not offending, (
"Shipped config.yaml triggered schema warnings — schema or YAML out of sync:\n"
+ "\n".join(offending)
)
def test_user_config_with_bad_threshold_falls_back(self, write_config, caplog):
path = write_config(
"aws:\n"
" threat_detection_privilege_escalation_threshold: 5.0\n"
" lambda_min_azs: 2\n"
)
with caplog.at_level(logging.WARNING, logger="prowler"):
cfg = load_and_validate_config_file("aws", path)
assert cfg == {"lambda_min_azs": 2}
assert any(
"threat_detection_privilege_escalation_threshold" in r.getMessage()
for r in caplog.records
)
def test_old_format_config_still_works(self, write_config):
# Old format = flat keys, no provider header.
path = write_config(
"max_ec2_instance_age_in_days: 90\n"
"ecr_repository_vulnerability_minimum_severity: HIGH\n"
)
cfg = load_and_validate_config_file("aws", path)
assert cfg == {
"max_ec2_instance_age_in_days": 90,
"ecr_repository_vulnerability_minimum_severity": "HIGH",
}
def test_unknown_keys_pass_through_via_loader(self, write_config):
path = write_config(
"aws:\n" " third_party_plugin_setting: hello\n" " lambda_min_azs: 2\n"
)
cfg = load_and_validate_config_file("aws", path)
assert cfg == {
"third_party_plugin_setting": "hello",
"lambda_min_azs": 2,
}
def test_quoted_numeric_is_coerced_via_loader(self, write_config):
# YAML quotes the number: ``"180"`` arrives as a Python str.
# The schema must coerce it to int so downstream comparisons work.
path = write_config('aws:\n max_ec2_instance_age_in_days: "180"\n')
cfg = load_and_validate_config_file("aws", path)
assert cfg == {"max_ec2_instance_age_in_days": 180}
assert isinstance(cfg["max_ec2_instance_age_in_days"], int)
def test_invalid_yaml_shape_list_as_string_drops_key(self, write_config, caplog):
path = write_config(
"aws:\n"
" disallowed_regions: me-south-1\n" # forgot list dashes
" lambda_min_azs: 2\n"
)
with caplog.at_level(logging.WARNING, logger="prowler"):
cfg = load_and_validate_config_file("aws", path)
assert cfg == {"lambda_min_azs": 2}
assert any("disallowed_regions" in r.getMessage() for r in caplog.records)
def test_other_providers_unaffected_by_aws_block(self, write_config):
path = write_config(
"aws:\n max_ec2_instance_age_in_days: 90\n" "gcp:\n mig_min_zones: 5\n"
)
assert load_and_validate_config_file("aws", path) == {
"max_ec2_instance_age_in_days": 90
}
assert load_and_validate_config_file("gcp", path) == {"mig_min_zones": 5}
def test_missing_provider_block_returns_empty(self, write_config):
path = write_config("aws:\n max_ec2_instance_age_in_days: 90\n")
assert load_and_validate_config_file("azure", path) == {}
@@ -1,152 +0,0 @@
"""Smaller-provider schema coverage. One happy path + one invalid path
per field is enough to lock in the contract; the validator behaviour
itself is covered exhaustively in validator_test.py."""
import pytest
from prowler.config.schema.registry import SCHEMAS
from prowler.config.schema.validator import validate_provider_config
def _validate(provider, raw):
return validate_provider_config(provider, raw, SCHEMAS[provider])
class Test_Azure_Schema:
@pytest.mark.parametrize("level", ["Low", "Medium", "High", "Critical"])
def test_defender_risk_level_valid_values(self, level):
assert _validate(
"azure", {"defender_attack_path_minimal_risk_level": level}
) == {"defender_attack_path_minimal_risk_level": level}
def test_defender_risk_level_lowercase_dropped(self):
# Case matters: the matching check uses Title-case comparison.
assert (
_validate("azure", {"defender_attack_path_minimal_risk_level": "high"})
== {}
)
def test_apim_threshold_in_range(self):
out = _validate("azure", {"apim_threat_detection_llm_jacking_threshold": 0.1})
assert out == {"apim_threat_detection_llm_jacking_threshold": 0.1}
def test_apim_threshold_out_of_range(self):
out = _validate("azure", {"apim_threat_detection_llm_jacking_threshold": 1.5})
assert out == {}
def test_vm_backup_retention_must_be_positive(self):
assert _validate("azure", {"vm_backup_min_daily_retention_days": 7}) == {
"vm_backup_min_daily_retention_days": 7
}
assert _validate("azure", {"vm_backup_min_daily_retention_days": 0}) == {}
assert _validate("azure", {"vm_backup_min_daily_retention_days": -1}) == {}
class Test_GCP_Schema:
def test_valid_values_round_trip(self):
raw = {
"mig_min_zones": 2,
"max_snapshot_age_days": 90,
"max_unused_account_days": 180,
"storage_min_retention_days": 90,
}
assert _validate("gcp", raw) == raw
def test_zero_zone_count_dropped(self):
assert _validate("gcp", {"mig_min_zones": 0}) == {}
class Test_Kubernetes_Schema:
def test_valid_values_round_trip(self):
raw = {
"audit_log_maxbackup": 10,
"audit_log_maxsize": 100,
"audit_log_maxage": 30,
}
assert _validate("kubernetes", raw) == raw
def test_negative_audit_log_dropped(self):
assert _validate("kubernetes", {"audit_log_maxage": -1}) == {}
class Test_M365_Schema:
def test_valid_values_round_trip(self):
raw = {
"sign_in_frequency": 4,
"recommended_mailtips_large_audience_threshold": 25,
"audit_log_age": 90,
}
assert _validate("m365", raw) == raw
def test_negative_audit_log_age_dropped(self):
assert _validate("m365", {"audit_log_age": -10}) == {}
class Test_GitHub_Schema:
def test_valid_threshold(self):
assert _validate("github", {"inactive_not_archived_days_threshold": 180}) == {
"inactive_not_archived_days_threshold": 180
}
def test_zero_threshold_dropped(self):
assert _validate("github", {"inactive_not_archived_days_threshold": 0}) == {}
class Test_MongoDBAtlas_Schema:
def test_valid(self):
assert _validate(
"mongodbatlas", {"max_service_account_secret_validity_hours": 8}
) == {"max_service_account_secret_validity_hours": 8}
def test_invalid_negative(self):
assert (
_validate("mongodbatlas", {"max_service_account_secret_validity_hours": -1})
== {}
)
class Test_Cloudflare_Schema:
def test_zero_retries_allowed(self):
# 0 is explicitly documented as "disable retries" in config.yaml.
assert _validate("cloudflare", {"max_retries": 0}) == {"max_retries": 0}
def test_positive_retries_allowed(self):
assert _validate("cloudflare", {"max_retries": 3}) == {"max_retries": 3}
def test_negative_retries_dropped(self):
assert _validate("cloudflare", {"max_retries": -1}) == {}
class Test_Vercel_Schema:
def test_owner_percentage_in_range(self):
assert _validate("vercel", {"max_owner_percentage": 20}) == {
"max_owner_percentage": 20
}
assert _validate("vercel", {"max_owner_percentage": 1}) == {
"max_owner_percentage": 1
}
assert _validate("vercel", {"max_owner_percentage": 50}) == {
"max_owner_percentage": 50
}
def test_owner_percentage_over_max_dropped(self):
# Tightened to 1..50 — anything above (incl. previous 100) is dropped.
assert _validate("vercel", {"max_owner_percentage": 51}) == {}
assert _validate("vercel", {"max_owner_percentage": 150}) == {}
def test_owner_percentage_zero_or_negative_dropped(self):
# 0 is no longer a valid configuration (defeats PoLP signal).
assert _validate("vercel", {"max_owner_percentage": 0}) == {}
assert _validate("vercel", {"max_owner_percentage": -1}) == {}
def test_full_default_config_round_trip(self):
raw = {
"stable_branches": ["main", "master"],
"days_to_expire_threshold": 7,
"stale_token_threshold_days": 90,
"stale_invitation_threshold_days": 30,
"max_owner_percentage": 20,
"max_owners": 3,
"secret_suffixes": ["_KEY", "_SECRET", "_TOKEN"],
}
assert _validate("vercel", raw) == raw
-175
View File
@@ -1,175 +0,0 @@
"""Behavioural tests for ``validate_provider_config``.
The validator is the gatekeeper for every provider schema: its job is to
keep backwards-compatible behaviour (no exceptions, drop only the bad
keys) while loudly logging type mistakes.
"""
import logging
import pytest
from prowler.config.schema.aws import AWSProviderConfig
from prowler.config.schema.registry import SCHEMAS
from prowler.config.schema.validator import validate_provider_config
class Test_Validate_Provider_Config_Contract:
"""Generic invariants that must hold for any schema."""
def test_returns_empty_dict_when_raw_is_not_a_dict(self):
assert validate_provider_config("aws", None, AWSProviderConfig) == {}
assert validate_provider_config("aws", "string", AWSProviderConfig) == {}
assert validate_provider_config("aws", 42, AWSProviderConfig) == {}
assert validate_provider_config("aws", [], AWSProviderConfig) == {}
def test_returns_raw_unchanged_when_no_schema_registered(self):
raw = {"anything": "goes", "even": [1, 2, 3]}
assert validate_provider_config("mystery_provider", raw, None) == raw
def test_unknown_keys_pass_through_for_plugin_compatibility(self):
# Third-party plugins inject arbitrary keys; the schema must NOT
# filter them. This is the contract that lets the plugin ecosystem
# keep working when we add validation.
raw = {"plugin_custom_key": "foo", "lambda_min_azs": 2}
assert validate_provider_config("aws", raw, AWSProviderConfig) == {
"plugin_custom_key": "foo",
"lambda_min_azs": 2,
}
def test_empty_dict_returns_empty_dict(self):
assert validate_provider_config("aws", {}, AWSProviderConfig) == {}
def test_known_valid_value_passes_through_unchanged(self):
raw = {"max_ec2_instance_age_in_days": 180}
assert validate_provider_config("aws", raw, AWSProviderConfig) == {
"max_ec2_instance_age_in_days": 180
}
class Test_Validate_Provider_Config_Coercion:
"""Pydantic v2 coerces common type-mistakes automatically. We want to
keep that behaviour so quoted numerics in user configs ``Just Work``."""
def test_string_numeric_is_coerced_to_int(self):
out = validate_provider_config(
"aws", {"max_ec2_instance_age_in_days": "180"}, AWSProviderConfig
)
assert out == {"max_ec2_instance_age_in_days": 180}
assert isinstance(out["max_ec2_instance_age_in_days"], int)
def test_string_numeric_is_coerced_to_float(self):
out = validate_provider_config(
"aws",
{"threat_detection_privilege_escalation_threshold": "0.4"},
AWSProviderConfig,
)
assert out == {"threat_detection_privilege_escalation_threshold": 0.4}
class Test_Validate_Provider_Config_Drops_Invalid_Keys:
"""When a field fails validation, only that key is dropped from the
returned dict. The rest of the user's config is preserved so the
consumer's ``audit_config.get(key, default)`` falls back to its own
built-in default for the offending field and uses user values for
everything else."""
def test_out_of_range_threshold_is_dropped(self, caplog):
with caplog.at_level(logging.WARNING):
out = validate_provider_config(
"aws",
{
"threat_detection_privilege_escalation_threshold": 2.0,
"lambda_min_azs": 2,
},
AWSProviderConfig,
)
assert out == {"lambda_min_azs": 2}
assert any(
"threat_detection_privilege_escalation_threshold" in r.getMessage()
for r in caplog.records
)
def test_invalid_enum_is_dropped(self):
out = validate_provider_config(
"aws",
{"ecr_repository_vulnerability_minimum_severity": "medum"},
AWSProviderConfig,
)
assert out == {}
def test_wrong_shape_list_as_string_is_dropped(self):
# Classic YAML mistake: ``disallowed_regions: me-south-1`` without dashes.
# Pydantic refuses to silently treat a str as a single-element list,
# which is exactly the safety guarantee we want.
out = validate_provider_config(
"aws",
{"disallowed_regions": "me-south-1", "lambda_min_azs": 2},
AWSProviderConfig,
)
assert out == {"lambda_min_azs": 2}
def test_negative_positive_int_is_dropped(self):
out = validate_provider_config(
"aws", {"max_ec2_instance_age_in_days": -1}, AWSProviderConfig
)
assert out == {}
def test_zero_is_dropped_for_strictly_positive_field(self):
# max_ec2_instance_age_in_days is gt=0. Zero would silently cause every
# instance to FAIL the age check.
out = validate_provider_config(
"aws", {"max_ec2_instance_age_in_days": 0}, AWSProviderConfig
)
assert out == {}
def test_multiple_invalid_keys_yield_multiple_warnings(self, caplog):
with caplog.at_level(logging.WARNING):
out = validate_provider_config(
"aws",
{
"max_ec2_instance_age_in_days": "nope",
"ecr_repository_vulnerability_minimum_severity": "medum",
"valid_extra_key": "kept",
},
AWSProviderConfig,
)
assert out == {"valid_extra_key": "kept"}
messages = " ".join(r.getMessage() for r in caplog.records)
assert "max_ec2_instance_age_in_days" in messages
assert "ecr_repository_vulnerability_minimum_severity" in messages
def test_warning_message_includes_provider_and_field(self, caplog):
with caplog.at_level(logging.WARNING):
validate_provider_config(
"aws",
{"threat_detection_privilege_escalation_threshold": 5.0},
AWSProviderConfig,
)
assert any(
"prowler.config[aws.threat_detection_privilege_escalation_threshold]"
in r.getMessage()
for r in caplog.records
)
class Test_Schemas_Registry:
"""Every provider mentioned in the YAML config must have a schema."""
@pytest.mark.parametrize(
"provider",
[
"aws",
"azure",
"gcp",
"kubernetes",
"m365",
"github",
"mongodbatlas",
"cloudflare",
"vercel",
],
)
def test_schema_registered_for_provider(self, provider):
assert provider in SCHEMAS
assert SCHEMAS[provider] is not None
View File
+81
View File
@@ -0,0 +1,81 @@
import pandas as pd
from dash import dash_table
from dashboard.common_methods import get_section_containers_generic
def _datatable_column_ids(component):
"""Collect the column ids of every DataTable in a Dash component tree."""
if isinstance(component, dash_table.DataTable):
return [[c["id"] for c in component.columns]]
children = getattr(component, "children", None)
if children is None:
return []
if not isinstance(children, (list, tuple)):
children = [children]
return [cols for child in children for cols in _datatable_column_ids(child)]
def _df(**extra):
data = {
"REQUIREMENTS_ID": ["req1"],
"STATUS": ["PASS"],
"CHECKID": ["check1"],
"REGION": ["us-east-1"],
"ACCOUNTID": ["123"],
"RESOURCEID": ["res1"],
}
data.update(extra)
return pd.DataFrame(data)
class TestGetSectionContainersGeneric:
def test_one_container_per_section(self):
"""One outer container per distinct section value."""
df = pd.DataFrame(
{
"REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A", "Sec B"],
"REQUIREMENTS_ID": ["req1", "req2", "req3"],
"STATUS": ["PASS", "FAIL", "PASS"],
"CHECKID": ["c1", "c2", "c3"],
"REGION": ["-"] * 3,
"ACCOUNTID": ["123"] * 3,
"RESOURCEID": ["r1", "r2", "r3"],
}
)
result = get_section_containers_generic(
df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID"
)
assert len(result.children) == 2
def test_inner_title_includes_id_and_description(self):
"""Inner accordion title is '<id> - <description>'."""
df = _df(
REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"],
REQUIREMENTS_DESCRIPTION=["Ensure MFA"],
)
rendered = str(
get_section_containers_generic(
df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID"
)
)
assert "req1 - Ensure MFA" in rendered
def test_arbitrary_ids_do_not_crash(self):
"""Non-numeric ids are sorted lexicographically without raising."""
df = pd.DataFrame(
{
"REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A"] * 3,
"REQUIREMENTS_ID": ["AC-2(1)", "foo-bar", "step.1.2"],
"STATUS": ["PASS", "FAIL", "PASS"],
"CHECKID": ["c1", "c2", "c3"],
"REGION": ["-"] * 3,
"ACCOUNTID": ["123"] * 3,
"RESOURCEID": ["r1", "r2", "r3"],
}
)
result = get_section_containers_generic(
df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID"
)
tables = _datatable_column_ids(result)
assert tables and all("CHECKID" in cols for cols in tables)
+204
View File
@@ -0,0 +1,204 @@
import pandas as pd
from dash import dash_table, html
from dashboard.compliance.generic import get_table
def _make_minimal_df(**extra_cols):
"""Create a minimal valid DataFrame for get_table tests."""
data = {
"REQUIREMENTS_ID": ["req1"],
"STATUS": ["PASS"],
"CHECKID": ["check1"],
"REGION": ["us-east-1"],
"ACCOUNTID": ["123456789"],
"RESOURCEID": ["res1"],
}
data.update(extra_cols)
return pd.DataFrame(data)
def _datatable_column_ids(component):
"""Collect the column ids of every DataTable in a Dash component tree."""
if isinstance(component, dash_table.DataTable):
return [[c["id"] for c in component.columns]]
children = getattr(component, "children", None)
if children is None:
return []
if not isinstance(children, (list, tuple)):
children = [children]
return [cols for child in children for cols in _datatable_column_ids(child)]
class TestGetTable:
def test_groups_by_section(self):
"""SC-001a: df with REQUIREMENTS_ATTRIBUTES_SECTION returns Div grouped by section."""
data = pd.DataFrame(
{
"REQUIREMENTS_ATTRIBUTES_SECTION": [
"Section A",
"Section A",
"Section A",
"Section B",
"Section B",
],
"REQUIREMENTS_ID": [
"ctrl-alpha",
"ctrl-alpha",
"ctrl-alpha",
"ctrl-beta",
"ctrl-beta",
],
"STATUS": ["PASS", "FAIL", "PASS", "FAIL", "FAIL"],
"CHECKID": ["check1", "check2", "check3", "check4", "check5"],
"REGION": ["us-east-1"] * 5,
"ACCOUNTID": ["123"] * 5,
"RESOURCEID": ["res1", "res2", "res3", "res4", "res5"],
}
)
result = get_table(data)
assert isinstance(result, html.Div)
assert result.className == "compliance-data-layout"
assert len(result.children) == 2 # one container per distinct section
def test_flat_fallback_no_attributes(self):
"""SC-001b: No REQUIREMENTS_ATTRIBUTES_* cols → grouped by REQUIREMENTS_ID."""
data = pd.DataFrame(
{
"REQUIREMENTS_ID": ["req1", "req1", "req2"],
"STATUS": ["PASS", "FAIL", "FAIL"],
"CHECKID": ["check1", "check2", "check3"],
"REGION": ["us-east-1"] * 3,
"ACCOUNTID": ["123"] * 3,
"RESOURCEID": ["res1", "res2", "res3"],
}
)
result = get_table(data)
assert isinstance(result, html.Div)
assert result.className == "compliance-data-layout"
# 2 distinct REQUIREMENTS_ID values → 2 group containers
assert len(result.children) == 2
def test_arbitrary_ids_no_crash(self):
"""ADR-2 / R1 regression guard: non-numeric REQUIREMENTS_IDs must not raise ValueError.
get_section_containers_cis sorts by version_tuple which calls int() on each
dotted/dashed segment and crashes on IDs like 'AC-2(1)'. Selecting format4
(no version sort) is the fix. This test is a permanent guard against regression.
"""
data = pd.DataFrame(
{
"REQUIREMENTS_ID": ["AC-2(1)", "foo-bar", "step.1.2"],
"STATUS": ["PASS", "FAIL", "PASS"],
"CHECKID": ["check1", "check2", "check3"],
"REGION": ["us-east-1"] * 3,
"ACCOUNTID": ["123"] * 3,
"RESOURCEID": ["res1", "res2", "res3"],
}
)
# Must not raise ValueError
result = get_table(data)
assert isinstance(result, html.Div)
def test_discovers_multiple_attribute_columns(self):
"""SC-005a: Multiple REQUIREMENTS_ATTRIBUTES_* cols present → no AttributeError;
component tree is non-empty."""
data = pd.DataFrame(
{
"REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec B"],
"REQUIREMENTS_ATTRIBUTES_CATEGORY": ["Cat 1", "Cat 2"],
"REQUIREMENTS_ATTRIBUTES_CONTROL_ID": ["C1", "C2"],
"REQUIREMENTS_ID": ["req1", "req2"],
"STATUS": ["PASS", "FAIL"],
"CHECKID": ["check1", "check2"],
"REGION": ["us-east-1"] * 2,
"ACCOUNTID": ["123"] * 2,
"RESOURCEID": ["res1", "res2"],
}
)
result = get_table(data)
assert isinstance(result, html.Div)
assert result.children # non-empty component tree
def test_novel_attribute_column_names(self):
"""SC-005b: Novel attr col names without a SECTION col → first attr col used as
grouping; returns a valid html.Div without any code change required."""
data = pd.DataFrame(
{
"REQUIREMENTS_ATTRIBUTES_DOMAIN": ["Domain A", "Domain B"],
"REQUIREMENTS_ATTRIBUTES_SUBDOMAIN": ["Sub 1", "Sub 2"],
"REQUIREMENTS_ID": ["req1", "req2"],
"STATUS": ["PASS", "FAIL"],
"CHECKID": ["check1", "check2"],
"REGION": ["us-east-1"] * 2,
"ACCOUNTID": ["123"] * 2,
"RESOURCEID": ["res1", "res2"],
}
)
result = get_table(data)
assert isinstance(result, html.Div)
assert len(result.children) > 0
def test_manual_only_requirements(self):
"""SC-008a: All rows have STATUS='MANUAL' → returns html.Div with non-empty
children; result is not the 'No data found' string."""
data = pd.DataFrame(
{
"REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec B"],
"REQUIREMENTS_ID": ["req1", "req2"],
"STATUS": ["MANUAL", "MANUAL"],
"CHECKID": ["check1", "check2"],
"REGION": ["us-east-1"] * 2,
"ACCOUNTID": ["123"] * 2,
"RESOURCEID": ["res1", "res2"],
}
)
result = get_table(data)
assert isinstance(result, html.Div)
assert not isinstance(result, str)
assert result.children # non-empty
def test_empty_dataframe(self):
"""SC-009a: Zero rows with correct column schema → valid html.Div; no exception."""
data = pd.DataFrame(
{
"REQUIREMENTS_ATTRIBUTES_SECTION": pd.Series([], dtype=str),
"REQUIREMENTS_ID": pd.Series([], dtype=str),
"STATUS": pd.Series([], dtype=str),
"CHECKID": pd.Series([], dtype=str),
"REGION": pd.Series([], dtype=str),
"ACCOUNTID": pd.Series([], dtype=str),
"RESOURCEID": pd.Series([], dtype=str),
}
)
result = get_table(data)
assert isinstance(result, html.Div)
def test_get_table_returns_html_div(self):
"""SC-012a: Smoke test — isinstance(get_table(df), html.Div) is True."""
data = _make_minimal_df(
REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"],
)
result = get_table(data)
assert isinstance(result, html.Div)
class TestNestedRendering:
def test_section_and_requirement_id_are_separate_levels(self):
"""Section is the outer level; requirement id + description the inner."""
data = _make_minimal_df(
REQUIREMENTS_ATTRIBUTES_SECTION=["3 Compute Services"],
REQUIREMENTS_DESCRIPTION=["Ensure only MFA enabled identities"],
)
rendered = str(get_table(data))
assert "3 Compute Services" in rendered
assert "req1 - Ensure only MFA enabled identities" in rendered
def test_checks_table_is_nested_under_requirement(self):
"""The checks table sits at the innermost level."""
data = _make_minimal_df(
REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"],
REQUIREMENTS_DESCRIPTION=["Some requirement"],
)
tables = _datatable_column_ids(get_table(data))
assert tables and all("CHECKID" in cols for cols in tables)
View File
@@ -0,0 +1,179 @@
from unittest.mock import MagicMock, patch
import pandas as pd
import pytest
from dash import html
from dashboard.pages.compliance import _dispatch_compliance_renderer
def _make_dispatch_df(**extra_cols):
"""Minimal DataFrame with the columns required by the dedup step."""
data = {
"REQUIREMENTS_ID": ["req1", "req2"],
"REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A"],
"STATUS": ["PASS", "FAIL"],
"CHECKID": ["check1", "check2"],
"RESOURCEID": ["res1", "res2"],
"STATUSEXTENDED": ["", ""],
"REGION": ["us-east-1", "us-east-1"],
"ACCOUNTID": ["123456789", "123456789"],
}
data.update(extra_cols)
return pd.DataFrame(data)
class TestDispatchComplianceRenderer:
def test_builtin_name_uses_builtin_module(self):
"""SC-002a: analytics_input='cis_4_0_aws' resolves real builtin module;
returns (html.Div, DataFrame) 2-tuple."""
data = pd.DataFrame(
{
"REQUIREMENTS_ID": ["1.1", "1.2"],
"REQUIREMENTS_DESCRIPTION": ["Description 1", "Description 2"],
"REQUIREMENTS_ATTRIBUTES_SECTION": ["Section A", "Section A"],
"CHECKID": ["check1", "check2"],
"STATUS": ["PASS", "FAIL"],
"REGION": ["us-east-1", "us-east-1"],
"ACCOUNTID": ["123456789", "123456789"],
"RESOURCEID": ["res1", "res2"],
"STATUSEXTENDED": ["Pass", "Fail"],
}
)
table, result_data = _dispatch_compliance_renderer(data, "cis_4_0_aws")
assert isinstance(table, html.Div)
assert isinstance(result_data, pd.DataFrame)
def test_unknown_name_falls_back_to_generic(self):
"""SC-003a: Unknown analytics_input raises ModuleNotFoundError → generic
fallback is called with the deduped dataframe."""
data = _make_dispatch_df()
sentinel = MagicMock(
return_value=html.Div([], className="compliance-data-layout")
)
with patch("dashboard.compliance.generic.get_table", sentinel):
table, result_data = _dispatch_compliance_renderer(data, "myfw_dynprovider")
sentinel.assert_called_once()
assert isinstance(table, html.Div)
assert isinstance(result_data, pd.DataFrame)
def test_import_error_is_not_swallowed(self):
"""SC-003b: ImportError (NOT ModuleNotFoundError) is re-raised; except clause
is exact only ModuleNotFoundError routes to generic."""
data = _make_dispatch_df()
with patch(
"dashboard.pages.compliance.importlib.import_module",
side_effect=ImportError("custom error"),
):
with pytest.raises(ImportError, match="custom error"):
_dispatch_compliance_renderer(data, "anything")
def test_get_table_error_in_generic_surfaces(self):
"""SC-004a: ValueError from generic.get_table propagates (not swallowed);
get_table is called OUTSIDE the try block."""
data = _make_dispatch_df()
with patch(
"dashboard.compliance.generic.get_table",
side_effect=ValueError("boom"),
):
with pytest.raises(ValueError, match="boom"):
_dispatch_compliance_renderer(data, "myfw_dynprovider")
def test_get_table_error_in_builtin_surfaces(self):
"""REQ-004 / ADR-1: RuntimeError from a builtin get_table propagates;
proving get_table is called outside the try block."""
data = _make_dispatch_df()
mock_module = MagicMock()
mock_module.get_table.side_effect = RuntimeError("table error")
with patch(
"dashboard.pages.compliance.importlib.import_module",
return_value=mock_module,
):
with pytest.raises(RuntimeError, match="table error"):
_dispatch_compliance_renderer(data, "some_builtin_fw")
def test_dedup_applied_before_get_table(self):
"""ADR-1: Duplicate rows (identical CHECKID/STATUS/RESOURCEID/STATUSEXTENDED)
are dropped; returned data has the deduplicated row count."""
# Row 0 and row 1 are identical in all dedup-key columns; row 2 is unique.
data = pd.DataFrame(
{
"REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A", "Sec B"],
"REQUIREMENTS_ID": ["req1", "req1", "req2"],
"STATUS": ["PASS", "PASS", "FAIL"],
"CHECKID": ["check1", "check1", "check2"],
"RESOURCEID": ["res1", "res1", "res2"],
"STATUSEXTENDED": ["", "", ""],
"REGION": ["us-east-1"] * 3,
"ACCOUNTID": ["123"] * 3,
}
)
mock_module = MagicMock()
mock_module.get_table.return_value = html.Div([])
with patch(
"dashboard.pages.compliance.importlib.import_module",
return_value=mock_module,
):
table, result_data = _dispatch_compliance_renderer(data, "some_fw")
assert len(result_data) == 2 # one duplicate removed
def test_muted_column_added_to_dedup_when_present(self):
"""ADR-1 edge case: When MUTED column is present, it is included in the dedup
subset at index 2; rows differing only in MUTED are kept as distinct rows."""
# Both rows share CHECKID/STATUS/RESOURCEID/STATUSEXTENDED but differ in MUTED.
# With MUTED in dedup_columns, both rows are kept (2 rows after dedup).
# Without MUTED in dedup_columns, they would be collapsed to 1 row.
data = pd.DataFrame(
{
"REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A"],
"REQUIREMENTS_ID": ["req1", "req1"],
"STATUS": ["PASS", "PASS"],
"CHECKID": ["check1", "check1"],
"RESOURCEID": ["res1", "res1"],
"STATUSEXTENDED": ["", ""],
"MUTED": ["True", "False"],
"REGION": ["us-east-1", "us-east-1"],
"ACCOUNTID": ["123", "123"],
}
)
mock_module = MagicMock()
mock_module.get_table.return_value = html.Div([])
with patch(
"dashboard.pages.compliance.importlib.import_module",
return_value=mock_module,
):
table, result_data = _dispatch_compliance_renderer(data, "some_fw")
# MUTED at idx 2 means these two rows have different dedup keys → both kept
assert len(result_data) == 2
def test_returns_table_and_data_tuple(self):
"""ADR-1 interface contract: _dispatch_compliance_renderer returns a
2-tuple (table, deduped_data)."""
data = pd.DataFrame(
{
"REQUIREMENTS_ID": ["1.1", "1.2"],
"REQUIREMENTS_DESCRIPTION": ["Desc 1", "Desc 2"],
"REQUIREMENTS_ATTRIBUTES_SECTION": ["Section A", "Section A"],
"CHECKID": ["check1", "check2"],
"STATUS": ["PASS", "FAIL"],
"REGION": ["us-east-1", "us-east-1"],
"ACCOUNTID": ["123456789", "123456789"],
"RESOURCEID": ["res1", "res2"],
"STATUSEXTENDED": ["", ""],
}
)
result = _dispatch_compliance_renderer(data, "cis_4_0_aws")
assert isinstance(result, tuple)
assert len(result) == 2
table, deduped_data = result
assert isinstance(table, html.Div)
assert isinstance(deduped_data, pd.DataFrame)
+7
View File
@@ -0,0 +1,7 @@
import dash
# Initialize a minimal Dash app so that dashboard page modules can call
# dash.register_page() during import without raising PageError.
# This module-level initialization runs during pytest collection, before
# any test file in this directory is imported.
_test_app = dash.Dash("prowler_test_app", use_pages=True, pages_folder="")
@@ -0,0 +1,60 @@
import pandas as pd
from dashboard.pages.compliance import _ensure_scope_columns
def _df(columns):
"""Build a one-row DataFrame preserving the given column order."""
return pd.DataFrame({col: ["x"] for col in columns})
class TestEnsureScopeColumns:
def test_aws_account_and_region_preserved(self):
"""A provider that already emits ACCOUNTID and REGION is left untouched."""
df = _df(["PROVIDER", "DESCRIPTION", "ACCOUNTID", "REGION", "ASSESSMENTDATE"])
result = _ensure_scope_columns(df)
assert "ACCOUNTID" in result.columns
assert "REGION" in result.columns
assert result["ACCOUNTID"].iloc[0] == "x"
def test_okta_single_scope_column_becomes_accountid(self):
"""Okta's ORGANIZATIONDOMAIN becomes ACCOUNTID; REGION falls back."""
df = _df(["PROVIDER", "DESCRIPTION", "ORGANIZATIONDOMAIN", "ASSESSMENTDATE"])
df["ORGANIZATIONDOMAIN"] = ["trial-123.okta.com"]
result = _ensure_scope_columns(df)
assert "ACCOUNTID" in result.columns
assert "ORGANIZATIONDOMAIN" not in result.columns
assert result["ACCOUNTID"].iloc[0] == "trial-123.okta.com"
assert result["REGION"].iloc[0] == "-"
def test_two_unknown_scope_columns_map_to_account_and_region(self):
"""Two scope columns map positionally to ACCOUNTID and REGION."""
df = _df(["PROVIDER", "DESCRIPTION", "TENANCYID", "LOCATION", "ASSESSMENTDATE"])
df["TENANCYID"] = ["tenant-1"]
df["LOCATION"] = ["eu-west-1"]
result = _ensure_scope_columns(df)
assert result["ACCOUNTID"].iloc[0] == "tenant-1"
assert result["REGION"].iloc[0] == "eu-west-1"
def test_no_scope_columns_fall_back_to_dash(self):
"""No scope columns → both ACCOUNTID and REGION fall back to '-'."""
df = _df(["PROVIDER", "DESCRIPTION", "ASSESSMENTDATE"])
result = _ensure_scope_columns(df)
assert result["ACCOUNTID"].iloc[0] == "-"
assert result["REGION"].iloc[0] == "-"
def test_missing_anchors_still_fall_back_to_dash(self):
"""Without DESCRIPTION/ASSESSMENTDATE anchors, both fall back to '-'."""
df = _df(["PROVIDER", "FOO", "BAR"])
result = _ensure_scope_columns(df)
assert result["ACCOUNTID"].iloc[0] == "-"
assert result["REGION"].iloc[0] == "-"
def test_existing_accountid_does_not_consume_region_scope(self):
"""An existing ACCOUNTID is kept; the leftover scope becomes REGION."""
df = _df(["PROVIDER", "DESCRIPTION", "ACCOUNTID", "LOCATION", "ASSESSMENTDATE"])
df["ACCOUNTID"] = ["acc-1"]
df["LOCATION"] = ["us-east-2"]
result = _ensure_scope_columns(df)
assert result["ACCOUNTID"].iloc[0] == "acc-1"
assert result["REGION"].iloc[0] == "us-east-2"
@@ -103,6 +103,15 @@ class TestDispatchStartswith:
display_compliance_table(compliance_framework=framework_name, **_COMMON)
mock_fn.assert_called_once()
@pytest.mark.parametrize(
"framework_name",
["okta_idaas_stig_v1r2_okta"],
)
@patch(f"{MODULE}.get_okta_idaas_stig_table")
def test_okta_idaas_stig_dispatch(self, mock_fn, framework_name):
display_compliance_table(compliance_framework=framework_name, **_COMMON)
mock_fn.assert_called_once()
@pytest.mark.parametrize(
"framework_name",
[
+45
View File
@@ -16,6 +16,7 @@ from prowler.lib.check.compliance_models import (
Mitre_Requirement_Attribute_Azure,
Mitre_Requirement_Attribute_GCP,
Prowler_ThreatScore_Requirement_Attribute,
STIG_Requirement_Attribute,
)
CIS_1_4_AWS = Compliance(
@@ -1258,3 +1259,47 @@ ASD_ESSENTIAL_EIGHT_AWS = Compliance(
),
],
)
OKTA_IDAAS_STIG_OKTA = Compliance(
Framework="Okta-IDaaS-STIG",
Name="DISA Okta Identity as a Service (IDaaS) STIG V1R2",
Version="1R2",
Provider="Okta",
Description="Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).",
Requirements=[
Compliance_Requirement(
Id="OKTA-APP-000020",
Name="Okta must log out a session after a 15-minute period of inactivity.",
Description="A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate vicinity of the information system.",
Attributes=[
STIG_Requirement_Attribute(
Section="CAT II (Medium)",
Severity="medium",
RuleID="SV-273186r1098825_rule",
StigID="OKTA-APP-000020",
CCI=["CCI-000057", "CCI-001133"],
CheckText="Verify the Global Session Policy logs out a session after 15 minutes of inactivity.",
FixText="From the Admin Console configure the Global Session Policy idle timeout to 15 minutes.",
)
],
Checks=["signon_global_session_idle_timeout_15min"],
),
Compliance_Requirement(
Id="OKTA-APP-000650",
Name="Okta must enforce a minimum 15-character password length.",
Description="The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised.",
Attributes=[
STIG_Requirement_Attribute(
Section="CAT II (Medium)",
Severity="medium",
RuleID="SV-273209r1098894_rule",
StigID="OKTA-APP-000650",
CCI=["CCI-000205"],
CheckText="Verify the password policy enforces a minimum length of 15 characters.",
FixText="From the Admin Console set the minimum password length to 15 characters.",
)
],
Checks=[],
),
],
)
@@ -0,0 +1,139 @@
from datetime import datetime
from io import StringIO
from unittest import mock
from freezegun import freeze_time
from mock import patch
from prowler.lib.outputs.compliance.okta_idaas_stig.models import OktaIDaaSSTIGModel
from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import (
OktaIDaaSSTIG,
)
from tests.lib.outputs.compliance.fixtures import OKTA_IDAAS_STIG_OKTA
from tests.lib.outputs.fixtures.fixtures import generate_finding_output
OKTA_ORG_DOMAIN = "dev-12345.okta.com"
class TestOktaIDaaSSTIG:
def test_output_transform(self):
findings = [
generate_finding_output(
provider="okta",
account_uid=OKTA_ORG_DOMAIN,
account_name=OKTA_ORG_DOMAIN,
region="global",
service_name="signon",
check_id="signon_global_session_idle_timeout_15min",
resource_uid="okta-global-session-policy",
resource_name="Default Policy",
compliance={"Okta-IDaaS-STIG-1R2": ["OKTA-APP-000020"]},
)
]
output = OktaIDaaSSTIG(findings, OKTA_IDAAS_STIG_OKTA)
output_data = output.data[0]
assert isinstance(output_data, OktaIDaaSSTIGModel)
assert output_data.Provider == "okta"
assert output_data.Framework == OKTA_IDAAS_STIG_OKTA.Framework
assert output_data.Name == OKTA_IDAAS_STIG_OKTA.Name
assert output_data.OrganizationDomain == OKTA_ORG_DOMAIN
assert output_data.Description == OKTA_IDAAS_STIG_OKTA.Description
assert output_data.Requirements_Id == OKTA_IDAAS_STIG_OKTA.Requirements[0].Id
assert (
output_data.Requirements_Name == OKTA_IDAAS_STIG_OKTA.Requirements[0].Name
)
assert (
output_data.Requirements_Description
== OKTA_IDAAS_STIG_OKTA.Requirements[0].Description
)
assert (
output_data.Requirements_Attributes_Section
== OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].Section
)
assert (
output_data.Requirements_Attributes_Severity
== OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].Severity.value
)
assert (
output_data.Requirements_Attributes_RuleID
== OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].RuleID
)
assert (
output_data.Requirements_Attributes_StigID
== OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].StigID
)
assert (
output_data.Requirements_Attributes_CCI
== OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].CCI
)
assert (
output_data.Requirements_Attributes_CheckText
== OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].CheckText
)
assert (
output_data.Requirements_Attributes_FixText
== OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].FixText
)
assert output_data.Status == "PASS"
assert output_data.StatusExtended == ""
assert output_data.ResourceId == "okta-global-session-policy"
assert output_data.ResourceName == "Default Policy"
assert output_data.CheckId == "signon_global_session_idle_timeout_15min"
assert output_data.Muted is False
# Test manual check
output_data_manual = output.data[1]
assert output_data_manual.Provider == "okta"
assert output_data_manual.Framework == OKTA_IDAAS_STIG_OKTA.Framework
assert output_data_manual.Name == OKTA_IDAAS_STIG_OKTA.Name
assert output_data_manual.OrganizationDomain == ""
assert (
output_data_manual.Requirements_Id
== OKTA_IDAAS_STIG_OKTA.Requirements[1].Id
)
assert (
output_data_manual.Requirements_Attributes_Severity
== OKTA_IDAAS_STIG_OKTA.Requirements[1].Attributes[0].Severity.value
)
assert (
output_data_manual.Requirements_Attributes_StigID
== OKTA_IDAAS_STIG_OKTA.Requirements[1].Attributes[0].StigID
)
assert output_data_manual.Status == "MANUAL"
assert output_data_manual.StatusExtended == "Manual check"
assert output_data_manual.ResourceId == "manual_check"
assert output_data_manual.ResourceName == "Manual check"
assert output_data_manual.CheckId == "manual"
assert output_data_manual.Muted is False
@freeze_time("2025-01-01 00:00:00")
@mock.patch(
"prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta.timestamp",
"2025-01-01 00:00:00",
)
def test_batch_write_data_to_file(self):
mock_file = StringIO()
findings = [
generate_finding_output(
provider="okta",
account_uid=OKTA_ORG_DOMAIN,
account_name=OKTA_ORG_DOMAIN,
region="global",
service_name="signon",
check_id="signon_global_session_idle_timeout_15min",
resource_uid="okta-global-session-policy",
resource_name="Default Policy",
compliance={"Okta-IDaaS-STIG-1R2": ["OKTA-APP-000020"]},
)
]
output = OktaIDaaSSTIG(findings, OKTA_IDAAS_STIG_OKTA)
output._file_descriptor = mock_file
with patch.object(mock_file, "close", return_value=None):
output.batch_write_data_to_file()
mock_file.seek(0)
content = mock_file.read()
expected_csv = f"PROVIDER;DESCRIPTION;ORGANIZATIONDOMAIN;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_NAME;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SEVERITY;REQUIREMENTS_ATTRIBUTES_RULEID;REQUIREMENTS_ATTRIBUTES_STIGID;REQUIREMENTS_ATTRIBUTES_CCI;REQUIREMENTS_ATTRIBUTES_CHECKTEXT;REQUIREMENTS_ATTRIBUTES_FIXTEXT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED;FRAMEWORK;NAME\r\nokta;Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).;{OKTA_ORG_DOMAIN};{datetime.now()};OKTA-APP-000020;Okta must log out a session after a 15-minute period of inactivity.;A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate vicinity of the information system.;CAT II (Medium);medium;SV-273186r1098825_rule;OKTA-APP-000020;['CCI-000057', 'CCI-001133'];Verify the Global Session Policy logs out a session after 15 minutes of inactivity.;From the Admin Console configure the Global Session Policy idle timeout to 15 minutes.;PASS;;okta-global-session-policy;Default Policy;signon_global_session_idle_timeout_15min;False;Okta-IDaaS-STIG;DISA Okta Identity as a Service (IDaaS) STIG V1R2\r\nokta;Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).;;{datetime.now()};OKTA-APP-000650;Okta must enforce a minimum 15-character password length.;The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised.;CAT II (Medium);medium;SV-273209r1098894_rule;OKTA-APP-000650;['CCI-000205'];Verify the password policy enforces a minimum length of 15 characters.;From the Admin Console set the minimum password length to 15 characters.;MANUAL;Manual check;manual_check;Manual check;manual;False;Okta-IDaaS-STIG;DISA Okta Identity as a Service (IDaaS) STIG V1R2\r\n"
assert content == expected_csv
@@ -0,0 +1,275 @@
from json import dumps
from unittest import mock
import botocore
from boto3 import client
from moto import mock_aws
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
AGENT_ID = "test-agent-id"
AGENT_NAME = "test-agent-name"
AGENT_ARN = (
f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:agent/{AGENT_ID}"
)
ROLE_NAME = "AmazonBedrockExecutionRoleForAgents_test"
ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/{ROLE_NAME}"
BOUNDARY_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/AgentBoundary"
ASSUME_ROLE_POLICY_DOCUMENT = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "bedrock.amazonaws.com"},
"Action": "sts:AssumeRole",
}
],
}
BOUNDARY_POLICY_DOCUMENT = {
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": "bedrock:*", "Resource": "*"}],
}
NARROW_INLINE_POLICY = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::my-rag-bucket/*"],
}
],
}
BROAD_INLINE_POLICY = {
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"}],
}
# Mock both ListAgents and GetAgent at the botocore level. moto's bedrock-agent
# support is incomplete for our needs (GetAgent often doesn't echo back the
# role ARN we set), so we control the responses directly. We also need to keep
# IAM calls going to moto.
make_api_call = botocore.client.BaseClient._make_api_call
def _mock_bedrock_agent_factory(role_arn):
"""Return a mock_make_api_call function that returns role_arn from GetAgent.
Pass role_arn=None to simulate an agent whose role can't be resolved.
"""
def _mock_make_api_call(self, operation_name, kwarg):
if operation_name == "ListAgents":
return {
"agentSummaries": [
{"agentId": AGENT_ID, "agentName": AGENT_NAME},
]
}
if operation_name == "GetAgent":
return {
"agent": {
"agentId": AGENT_ID,
"agentName": AGENT_NAME,
"agentResourceRoleArn": role_arn,
}
}
if operation_name == "ListTagsForResource":
return {"tags": {}}
if operation_name == "ListPrompts":
return {"promptSummaries": []}
return make_api_call(self, operation_name, kwarg)
return _mock_make_api_call
def _setup_role(
*,
attached_policy_arns=(),
inline_policies=None,
permissions_boundary=None,
):
"""Create an IAM role in moto with the given configuration. Returns the role ARN."""
iam = client("iam", region_name=AWS_REGION_US_EAST_1)
if permissions_boundary:
iam.create_policy(
PolicyName="AgentBoundary",
PolicyDocument=dumps(BOUNDARY_POLICY_DOCUMENT),
)
create_kwargs = {
"RoleName": ROLE_NAME,
"AssumeRolePolicyDocument": dumps(ASSUME_ROLE_POLICY_DOCUMENT),
}
if permissions_boundary:
create_kwargs["PermissionsBoundary"] = permissions_boundary
iam.create_role(**create_kwargs)
for policy_arn in attached_policy_arns:
iam.attach_role_policy(RoleName=ROLE_NAME, PolicyArn=policy_arn)
for policy_name, policy_document in (inline_policies or {}).items():
iam.put_role_policy(
RoleName=ROLE_NAME,
PolicyName=policy_name,
PolicyDocument=dumps(policy_document),
)
return ROLE_ARN
def _run_check(role_arn_for_get_agent):
"""Build the IAM + BedrockAgent services, patch them in, run the check."""
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
from prowler.providers.aws.services.iam.iam_service import IAM
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with mock.patch(
"botocore.client.BaseClient._make_api_call",
new=_mock_bedrock_agent_factory(role_arn_for_get_agent),
):
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.bedrock_agent_client",
new=BedrockAgent(aws_provider),
),
mock.patch(
"prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.iam_client",
new=IAM(aws_provider),
),
):
from prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege import (
bedrock_agent_role_least_privilege,
)
return bedrock_agent_role_least_privilege().execute()
class Test_bedrock_agent_role_least_privilege:
@mock_aws(config={"iam": {"load_aws_managed_policies": True}})
def test_no_agents(self):
"""No agents in the account -> zero findings."""
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
from prowler.providers.aws.services.iam.iam_service import IAM
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.bedrock_agent_client",
new=BedrockAgent(aws_provider),
),
mock.patch(
"prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.iam_client",
new=IAM(aws_provider),
),
):
from prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege import (
bedrock_agent_role_least_privilege,
)
assert bedrock_agent_role_least_privilege().execute() == []
@mock_aws(config={"iam": {"load_aws_managed_policies": True}})
def test_agent_role_compliant(self):
"""Narrow inline policy + boundary + no *FullAccess attached -> PASS."""
role_arn = _setup_role(
inline_policies={"NarrowAccess": NARROW_INLINE_POLICY},
permissions_boundary=BOUNDARY_ARN,
)
result = _run_check(role_arn_for_get_agent=role_arn)
assert len(result) == 1
assert result[0].status == "PASS"
assert "follows least privilege" in result[0].status_extended
assert result[0].resource_id == AGENT_ID
assert result[0].resource_arn == AGENT_ARN
@mock_aws(config={"iam": {"load_aws_managed_policies": True}})
def test_agent_role_full_access_attached(self):
"""AmazonBedrockFullAccess attached -> FAIL."""
role_arn = _setup_role(
attached_policy_arns=("arn:aws:iam::aws:policy/AmazonBedrockFullAccess",),
inline_policies={"NarrowAccess": NARROW_INLINE_POLICY},
permissions_boundary=BOUNDARY_ARN,
)
result = _run_check(role_arn_for_get_agent=role_arn)
assert len(result) == 1
assert result[0].status == "FAIL"
assert "grants full access" in result[0].status_extended
@mock_aws(config={"iam": {"load_aws_managed_policies": True}})
def test_agent_role_administrator_access_attached(self):
"""AdministratorAccess attached (no FullAccess suffix) -> FAIL via doc-based admin check."""
role_arn = _setup_role(
attached_policy_arns=("arn:aws:iam::aws:policy/AdministratorAccess",),
inline_policies={"NarrowAccess": NARROW_INLINE_POLICY},
permissions_boundary=BOUNDARY_ARN,
)
result = _run_check(role_arn_for_get_agent=role_arn)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
"managed policy AdministratorAccess grants administrative access"
in result[0].status_extended
)
@mock_aws(config={"iam": {"load_aws_managed_policies": True}})
def test_agent_role_resource_star_broad_action(self):
"""Inline statement with Action:* on Resource:* -> FAIL."""
role_arn = _setup_role(
inline_policies={"BroadAccess": BROAD_INLINE_POLICY},
permissions_boundary=BOUNDARY_ARN,
)
result = _run_check(role_arn_for_get_agent=role_arn)
assert len(result) == 1
assert result[0].status == "FAIL"
assert "grants administrative access" in result[0].status_extended
@mock_aws(config={"iam": {"load_aws_managed_policies": True}})
def test_agent_role_no_permissions_boundary(self):
"""Otherwise clean role but missing permissions boundary -> FAIL."""
role_arn = _setup_role(
inline_policies={"NarrowAccess": NARROW_INLINE_POLICY},
permissions_boundary=None,
)
result = _run_check(role_arn_for_get_agent=role_arn)
assert len(result) == 1
assert result[0].status == "FAIL"
assert "no permissions boundary configured" in result[0].status_extended
@mock_aws(config={"iam": {"load_aws_managed_policies": True}})
def test_agent_role_not_resolvable(self):
"""role_arn returned by GetAgent doesn't match any IAM role -> FAIL."""
result = _run_check(
role_arn_for_get_agent=f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/does-not-exist"
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert "could not be resolved" in result[0].status_extended
@@ -0,0 +1,294 @@
from datetime import datetime
from unittest import mock
import pytest
from prowler.providers.oraclecloud.services.identity.identity_service import Policy
from tests.providers.oraclecloud.oci_fixtures import (
OCI_COMPARTMENT_ID,
OCI_REGION,
OCI_TENANCY_ID,
set_mocked_oraclecloud_provider,
)
CHECK_PATH = "prowler.providers.oraclecloud.services.identity.identity_storage_service_level_admins_scoped.identity_storage_service_level_admins_scoped"
def _policy(name, statements, lifecycle_state="ACTIVE"):
return Policy(
id=f"ocid1.policy.oc1..{name.lower().replace(' ', '-')}",
name=name,
description="Test policy",
compartment_id=OCI_COMPARTMENT_ID,
statements=statements,
time_created=datetime.now(),
lifecycle_state=lifecycle_state,
region=OCI_REGION,
)
def _identity_client(policies):
identity_client = mock.MagicMock()
identity_client.policies = policies
identity_client.audited_tenancy = OCI_TENANCY_ID
identity_client.audited_regions = [mock.MagicMock(key=OCI_REGION)]
return identity_client
def _run_check(policies):
identity_client = _identity_client(policies)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_oraclecloud_provider(),
),
mock.patch(f"{CHECK_PATH}.identity_client", new=identity_client),
):
from prowler.providers.oraclecloud.services.identity.identity_storage_service_level_admins_scoped.identity_storage_service_level_admins_scoped import (
identity_storage_service_level_admins_scoped,
)
return identity_storage_service_level_admins_scoped().execute()
class Test_identity_storage_service_level_admins_scoped:
def test_no_policies_passes_with_tenancy_finding(self):
result = _run_check([])
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_id == OCI_TENANCY_ID
assert result[0].resource_name == "Tenancy"
assert (
result[0].status_extended
== "No active storage service-level administrator policies grant manage permissions without excluding delete permissions."
)
def test_manage_volumes_without_delete_exclusion_fails(self):
result = _run_check(
[
_policy(
"Volume Admins",
["Allow group VolumeUsers to manage volumes in tenancy"],
)
]
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].resource_name == "Volume Admins"
assert "VOLUME_DELETE" in result[0].status_extended
assert (
"Allow group VolumeUsers to manage volumes in tenancy"
in result[0].status_extended
)
def test_manage_volumes_with_delete_exclusion_passes(self):
result = _run_check(
[
_policy(
"Volume Admins",
[
"Allow group VolumeUsers to manage volumes in tenancy where request.permission!='VOLUME_DELETE'"
],
)
]
)
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Policy 'Volume Admins' excludes required storage delete permissions from storage manage statements."
)
def test_delete_exclusion_parser_is_case_and_whitespace_insensitive(self):
result = _run_check(
[
_policy(
"Volume Admins",
[
" allow group VolumeUsers TO manage volumes in tenancy WHERE request.permission != 'volume_delete' "
],
)
]
)
assert len(result) == 1
assert result[0].status == "PASS"
def test_generic_where_clause_does_not_pass(self):
result = _run_check(
[
_policy(
"Bucket Admins",
[
"Allow group BucketUsers to manage buckets in tenancy where request.region='iad'"
],
)
]
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert "BUCKET_DELETE" in result[0].status_extended
assert "request.region='iad'" in result[0].status_extended
@pytest.mark.parametrize(
"statement",
[
"Allow group BucketUsers to manage buckets in tenancy where ANY {request.permission!='BUCKET_DELETE', request.region='iad'}",
"Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE' OR request.region='iad'",
],
)
def test_disjunctive_delete_exclusion_does_not_pass(self, statement):
result = _run_check([_policy("Bucket Admins", [statement])])
assert len(result) == 1
assert result[0].status == "FAIL"
assert "BUCKET_DELETE" in result[0].status_extended
@pytest.mark.parametrize(
"resource,permission",
[
("file-systems", "FILE_SYSTEM_DELETE"),
("mount-targets", "MOUNT_TARGET_DELETE"),
("export-sets", "EXPORT_SET_DELETE"),
("volumes", "VOLUME_DELETE"),
("volume-backups", "VOLUME_BACKUP_DELETE"),
("objects", "OBJECT_DELETE"),
("buckets", "BUCKET_DELETE"),
],
)
def test_storage_resources_require_matching_delete_exclusion(
self, resource, permission
):
fail_result = _run_check(
[
_policy(
"Storage Admins",
[f"Allow group StorageUsers to manage {resource} in tenancy"],
)
]
)
pass_result = _run_check(
[
_policy(
"Storage Admins",
[
f"Allow group StorageUsers to manage {resource} in tenancy where request.permission != '{permission}'"
],
)
]
)
assert len(fail_result) == 1
assert fail_result[0].status == "FAIL"
assert permission in fail_result[0].status_extended
assert len(pass_result) == 1
assert pass_result[0].status == "PASS"
def test_file_family_fails_until_all_delete_permissions_are_excluded(self):
partial_result = _run_check(
[
_policy(
"File Admins",
[
"Allow group FileUsers to manage file-family in tenancy where ALL {request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE'}"
],
)
]
)
complete_result = _run_check(
[
_policy(
"File Admins",
[
"Allow group FileUsers to manage file-family in tenancy where ALL {request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE', request.permission!='EXPORT_SET_DELETE'}"
],
)
]
)
assert len(partial_result) == 1
assert partial_result[0].status == "FAIL"
assert "EXPORT_SET_DELETE" in partial_result[0].status_extended
assert len(complete_result) == 1
assert complete_result[0].status == "PASS"
@pytest.mark.parametrize(
"family,missing_permission,statement",
[
(
"volume-family",
"VOLUME_BACKUP_DELETE",
"Allow group VolumeUsers to manage volume-family in tenancy where request.permission!='VOLUME_DELETE'",
),
(
"object-family",
"BUCKET_DELETE",
"Allow group BucketUsers to manage object-family in tenancy where request.permission!='OBJECT_DELETE'",
),
(
"all-resources",
"BUCKET_DELETE",
"Allow group StorageUsers to manage all-resources in tenancy where ALL {request.permission!='VOLUME_DELETE', request.permission!='VOLUME_BACKUP_DELETE', request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE', request.permission!='EXPORT_SET_DELETE', request.permission!='OBJECT_DELETE'}",
),
],
)
def test_families_and_all_resources_fail_unless_all_delete_permissions_are_excluded(
self, family, missing_permission, statement
):
result = _run_check([_policy("Storage Admins", [statement])])
assert len(result) == 1
assert result[0].status == "FAIL"
assert family in result[0].status_extended
assert missing_permission in result[0].status_extended
def test_all_resources_passes_when_all_storage_delete_permissions_are_excluded(
self,
):
result = _run_check(
[
_policy(
"Storage Admins",
[
"Allow group StorageUsers to manage all-resources in tenancy where ALL {request.permission!='VOLUME_DELETE', request.permission!='VOLUME_BACKUP_DELETE', request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE', request.permission!='EXPORT_SET_DELETE', request.permission!='OBJECT_DELETE', request.permission!='BUCKET_DELETE'}"
],
)
]
)
assert len(result) == 1
assert result[0].status == "PASS"
def test_inactive_policies_are_ignored(self):
result = _run_check(
[
_policy(
"Inactive Volume Admins",
["Allow group VolumeUsers to manage volumes in tenancy"],
lifecycle_state="INACTIVE",
)
]
)
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_name == "Tenancy"
def test_tenant_admin_policy_is_ignored(self):
result = _run_check(
[
_policy(
"Tenant Admin Policy",
["Allow group Administrators to manage all-resources in tenancy"],
)
]
)
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_name == "Tenancy"
+2
View File
@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
- DISA Okta IDaaS STIG V1R2 compliance framework support with its dedicated mapper, details panel, and icon [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428)
- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
### 🔄 Changed
@@ -54,6 +55,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Compliance page now loads the most recent scan when opened from the sidebar instead of showing the "no compliance data available" alert [(#11374)](https://github.com/prowler-cloud/prowler/pull/11374)
- Invitation links now show specific expired, no-longer-valid, and invalid-token messages based on API error responses [(#11376)](https://github.com/prowler-cloud/prowler/pull/11376)
- Jira dispatch and provider connection-test polling no longer show a false timeout for longer-running tasks; both poll windows now extend to 60 seconds [(#11519)](https://github.com/prowler-cloud/prowler/pull/11519)
### 🔐 Security
+1 -1
View File
@@ -286,7 +286,7 @@ const pollTaskUntilComplete = async (
taskId: string,
): Promise<PollConnectionResult> => {
const settled = await pollTaskUntilSettled<ConnectionTaskResult>(taskId, {
maxAttempts: 10,
maxAttempts: 20,
delayMs: 3000,
});
+1 -1
View File
@@ -148,7 +148,7 @@ export const pollJiraDispatchTask = async (
{ success: true; message: string } | { success: false; error: string }
> => {
const res = await pollTaskUntilSettled(taskId, {
maxAttempts: 10,
maxAttempts: 30,
delayMs: 2000,
});
if (!res.ok) {
@@ -85,7 +85,7 @@ export const ASDEssentialEightCustomDetails = ({
<ComplianceBadge
label="Maturity Level"
value={maturityLevel}
color="purple"
variant="secondary"
/>
)}
@@ -93,7 +93,7 @@ export const ASDEssentialEightCustomDetails = ({
<ComplianceBadge
label="Assessment"
value={assessmentStatus}
color="blue"
variant="info"
/>
)}
@@ -101,7 +101,7 @@ export const ASDEssentialEightCustomDetails = ({
<ComplianceBadge
label="Cloud Applicability"
value={cloudApplicability}
color="orange"
variant="secondary"
/>
)}
</ComplianceBadgeContainer>
@@ -52,7 +52,7 @@ export const AWSWellArchitectedCustomDetails = ({
<ComplianceBadge
label="Question ID"
value={requirement.well_architected_question_id as string}
color="indigo"
variant="tag"
/>
)}
@@ -60,7 +60,7 @@ export const AWSWellArchitectedCustomDetails = ({
<ComplianceBadge
label="Practice ID"
value={requirement.well_architected_practice_id as string}
color="indigo"
variant="tag"
/>
)}
@@ -68,7 +68,7 @@ export const AWSWellArchitectedCustomDetails = ({
<ComplianceBadge
label="Assessment"
value={requirement.assessment_method as string}
color="blue"
variant="info"
/>
)}
</ComplianceBadgeContainer>
@@ -1,4 +1,4 @@
import { cn } from "@/lib";
import { Badge } from "@/components/shadcn/badge/badge";
import { CCC_MAPPING_SECTIONS, CCC_TEXT_SECTIONS } from "@/lib/compliance/ccc";
import { Requirement } from "@/types/compliance";
@@ -46,7 +46,7 @@ export const CCCCustomDetails = ({ requirement }: CCCDetailsProps) => {
<ComplianceBadge
label="Family"
value={requirement.family_name as string}
color="purple"
variant="secondary"
/>
</ComplianceBadgeContainer>
)}
@@ -68,15 +68,9 @@ export const CCCCustomDetails = ({ requirement }: CCCDetailsProps) => {
</span>
<div className="flex flex-wrap gap-2">
{mapping.Identifiers.map((identifier, idx) => (
<span
key={idx}
className={cn(
"inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset",
section.colorClasses,
)}
>
<Badge key={idx} variant={section.variant}>
{identifier}
</span>
</Badge>
))}
</div>
</div>
@@ -41,7 +41,7 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => {
<ComplianceBadge
label="Profile"
value={requirement.profile as string}
color="purple"
variant="secondary"
/>
)}
@@ -49,7 +49,7 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => {
<ComplianceBadge
label="Assessment"
value={requirement.assessment_status as string}
color="blue"
variant="info"
/>
)}
</ComplianceBadgeContainer>
@@ -1,4 +1,4 @@
import { cn } from "@/lib";
import { Badge } from "@/components/shadcn/badge/badge";
import { CSA_MAPPING_SECTIONS } from "@/lib/compliance/csa";
import { Requirement } from "@/types/compliance";
@@ -36,28 +36,28 @@ export const CSACustomDetails = ({ requirement }: CSADetailsProps) => {
<ComplianceBadge
label="CCM Lite"
value={requirement.ccm_lite as string}
color={requirement.ccm_lite === "Yes" ? "green" : "gray"}
variant={requirement.ccm_lite === "Yes" ? "success" : "secondary"}
/>
)}
{requirement.iaas && (
<ComplianceBadge
label="IaaS"
value={requirement.iaas as string}
color="blue"
variant="info"
/>
)}
{requirement.paas && (
<ComplianceBadge
label="PaaS"
value={requirement.paas as string}
color="blue"
variant="info"
/>
)}
{requirement.saas && (
<ComplianceBadge
label="SaaS"
value={requirement.saas as string}
color="blue"
variant="info"
/>
)}
</ComplianceBadgeContainer>
@@ -72,15 +72,9 @@ export const CSACustomDetails = ({ requirement }: CSADetailsProps) => {
</span>
<div className="flex flex-wrap gap-2">
{mapping.Identifiers.map((identifier, idx) => (
<span
key={idx}
className={cn(
"inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset",
section.colorClasses,
)}
>
<Badge key={idx} variant={section.variant}>
{identifier}
</span>
</Badge>
))}
</div>
</div>
@@ -26,21 +26,21 @@ export const DORACustomDetails = ({ requirement }: DORADetailsProps) => {
<ComplianceBadge
label="Pillar"
value={requirement.pillar as string}
color="blue"
variant="tag"
/>
)}
{requirement.article && (
<ComplianceBadge
label="Article"
value={requirement.article as string}
color="indigo"
variant="tag"
/>
)}
{requirement.article_title && (
<ComplianceBadge
label="Article Title"
value={requirement.article_title as string}
color="gray"
variant="tag"
/>
)}
</ComplianceBadgeContainer>
@@ -28,7 +28,7 @@ export const ENSCustomDetails = ({
<ComplianceBadge
label="Type"
value={translateType(requirement.type as string)}
color="orange"
variant="secondary"
/>
)}
@@ -36,7 +36,7 @@ export const ENSCustomDetails = ({
<ComplianceBadge
label="Level"
value={requirement.nivel as string}
color="red"
variant="error"
/>
)}
</ComplianceBadgeContainer>
@@ -26,7 +26,7 @@ export const GenericCustomDetails = ({
<ComplianceBadge
label="Item ID"
value={requirement.item_id as string}
color="indigo"
variant="tag"
/>
)}
@@ -34,7 +34,7 @@ export const GenericCustomDetails = ({
<ComplianceBadge
label="Service"
value={requirement.service as string}
color="blue"
variant="info"
/>
)}
@@ -42,7 +42,7 @@ export const GenericCustomDetails = ({
<ComplianceBadge
label="Type"
value={requirement.type as string}
color="orange"
variant="secondary"
/>
)}
</ComplianceBadgeContainer>
@@ -37,7 +37,7 @@ export const MITRECustomDetails = ({
<ComplianceBadge
label="Technique ID"
value={requirement.technique_id as string}
color="indigo"
variant="tag"
/>
)}
</ComplianceBadgeContainer>
@@ -81,17 +81,17 @@ export const MITRECustomDetails = ({
<ComplianceBadge
label="Service"
value={service.service}
color="blue"
variant="info"
/>
<ComplianceBadge
label="Category"
value={service.category}
color="indigo"
variant="tag"
/>
<ComplianceBadge
label="Coverage"
value={service.value}
color="orange"
variant="secondary"
/>
</div>
{service.comment && (
@@ -0,0 +1,65 @@
import { Severity, SeverityBadge } from "@/components/ui/table";
import { Requirement } from "@/types/compliance";
import {
ComplianceBadge,
ComplianceBadgeContainer,
ComplianceChipContainer,
ComplianceDetailContainer,
ComplianceDetailSection,
ComplianceDetailText,
} from "./shared-components";
export const OktaIDaaSStigCustomDetails = ({
requirement,
}: {
requirement: Requirement;
}) => {
const severity = requirement.severity as string | undefined;
const stigId = requirement.stig_id as string | undefined;
const ruleId = requirement.rule_id as string | undefined;
const cci = requirement.cci as string[] | undefined;
const checkText = requirement.check_text as string | undefined;
const fixText = requirement.fix_text as string | undefined;
return (
<ComplianceDetailContainer>
<ComplianceBadgeContainer>
{severity && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm font-medium">
Severity:
</span>
<SeverityBadge severity={severity.toLowerCase() as Severity} />
</div>
)}
{stigId && (
<ComplianceBadge label="STIG ID" value={stigId} variant="tag" />
)}
{ruleId && (
<ComplianceBadge label="Rule ID" value={ruleId} variant="tag" />
)}
</ComplianceBadgeContainer>
{requirement.description && (
<ComplianceDetailSection title="Description">
<ComplianceDetailText>{requirement.description}</ComplianceDetailText>
</ComplianceDetailSection>
)}
<ComplianceChipContainer title="CCI" items={cci || []} />
{checkText && (
<ComplianceDetailSection title="Check">
<ComplianceDetailText>{checkText}</ComplianceDetailText>
</ComplianceDetailSection>
)}
{fixText && (
<ComplianceDetailSection title="Fix">
<ComplianceDetailText>{fixText}</ComplianceDetailText>
</ComplianceDetailSection>
)}
</ComplianceDetailContainer>
);
};
@@ -1,4 +1,12 @@
import { cn } from "@/lib/utils";
import { VariantProps } from "class-variance-authority";
import { Badge, badgeVariants } from "@/components/shadcn/badge/badge";
// Variants come straight from the canonical shadcn Badge so compliance panels
// share the same badge vocabulary (and tokens) as the rest of the app.
export type ComplianceBadgeVariant = NonNullable<
VariantProps<typeof badgeVariants>["variant"]
>;
export const ComplianceDetailContainer = ({
children,
@@ -43,55 +51,28 @@ export const ComplianceBadgeContainer = ({
return <div className="flex flex-wrap items-center gap-3">{children}</div>;
};
type BadgeColor =
| "red" // Risk/Level/Severity
| "blue" // Assessment/Method
| "orange" // Type/Category
| "green" // Weight/Score (positive)
| "purple" // Profile
| "indigo" // IDs/References
| "gray"; // Additional Info/Neutral
export const ComplianceBadge = ({
label,
value,
color,
variant,
conditional = false,
}: {
label: string;
value: string | number;
color: BadgeColor;
variant: ComplianceBadgeVariant;
conditional?: boolean;
}) => {
const actualColor = conditional && Number(value) === 0 ? "gray" : color;
const colorClasses = {
red: "bg-red-50 text-red-700 ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/20",
blue: "bg-blue-50 text-blue-700 ring-blue-600/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/20",
orange:
"bg-orange-50 text-orange-700 ring-orange-600/10 dark:bg-orange-400/10 dark:text-orange-400 dark:ring-orange-400/20",
green:
"bg-green-50 text-green-700 ring-green-600/10 dark:bg-green-400/10 dark:text-green-400 dark:ring-green-400/20",
purple:
"bg-purple-50 text-purple-700 ring-purple-600/10 dark:bg-purple-400/10 dark:text-purple-400 dark:ring-purple-400/20",
indigo:
"bg-indigo-50 text-indigo-700 ring-indigo-600/10 dark:bg-indigo-400/10 dark:text-indigo-400 dark:ring-indigo-400/20",
gray: "bg-gray-50 text-gray-600 ring-gray-500/10 dark:bg-gray-400/10 dark:text-gray-400 dark:ring-gray-400/20",
};
// A "conditional" metric badge with a zero value drops to a neutral variant
// so empty scores don't read as a meaningful (e.g. positive) result.
const actualVariant: ComplianceBadgeVariant =
conditional && Number(value) === 0 ? "secondary" : variant;
return (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm font-medium">
{label}:
</span>
<span
className={cn(
"inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset",
colorClasses[actualColor],
)}
>
{value}
</span>
<Badge variant={actualVariant}>{value}</Badge>
</div>
);
};
@@ -132,12 +113,9 @@ export const ComplianceChipContainer = ({
<ComplianceDetailSection title={title}>
<div className="flex flex-wrap gap-2">
{items.map((item: string, index: number) => (
<span
key={index}
className="inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-gray-500/10 ring-inset dark:bg-gray-400/10 dark:text-gray-400 dark:ring-gray-400/20"
>
<Badge key={index} variant="tag">
{item}
</span>
</Badge>
))}
</div>
</ComplianceDetailSection>
@@ -34,7 +34,7 @@ export const ThreatCustomDetails = ({
<ComplianceBadge
label="Risk Level"
value={requirement.levelOfRisk}
color="red"
variant="error"
/>
)}
@@ -42,7 +42,7 @@ export const ThreatCustomDetails = ({
<ComplianceBadge
label="Weight"
value={requirement.weight}
color="green"
variant="success"
/>
)}
@@ -50,7 +50,7 @@ export const ThreatCustomDetails = ({
<ComplianceBadge
label="Score"
value={requirement.score}
color="green"
variant="success"
conditional={true}
/>
)}
@@ -61,13 +61,13 @@ export const ThreatCustomDetails = ({
<ComplianceBadge
label="Findings"
value={`${requirement.passedFindings}/${requirement.totalFindings}`}
color="blue"
variant="info"
/>
{requirement.totalFindings > 0 && (
<ComplianceBadge
label="Pass Rate"
value={`${Math.round((requirement.passedFindings / requirement.totalFindings) * 100)}%`}
color="green"
variant="success"
conditional={true}
/>
)}
@@ -58,6 +58,12 @@ describe("getComplianceIcon", () => {
expect(getComplianceIcon("prowler_threatscore_aws")).toBe(threatLogo);
});
it("resolves the Okta IDaaS STIG via the `okta` keyword", () => {
const oktaLogo = getComplianceIcon("Okta-IDaaS-STIG");
expect(oktaLogo).toBeDefined();
expect(getComplianceIcon("okta_idaas_stig_v1r2_okta")).toBe(oktaLogo);
});
it("resolves ASD Essential Eight by the framework keyword, not by `aws`", () => {
const essentialLogo = getComplianceIcon("ASD-Essential-Eight");
expect(essentialLogo).toBeDefined();
@@ -18,6 +18,7 @@ import KISALogo from "./kisa.svg";
import MITRELogo from "./mitre-attack.svg";
import NIS2Logo from "./nis2.svg";
import NISTLogo from "./nist.svg";
import OktaLogo from "./okta.svg";
import PCILogo from "./pci-dss.svg";
import PROWLERTHREATLogo from "./prowlerThreat.svg";
import RBILogo from "./rbi.svg";
@@ -72,6 +73,7 @@ const COMPLIANCE_LOGOS = [
// compliance_id is just `dora`, no provider suffix.
["dora", DORALogo],
["secnumcloud", ANSSILogo],
["okta", OktaLogo],
["aws", AWSLogo],
] as const;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 63 63" width="40" height="40"><path fill="#00297A" d="M34.6,0.4l-1.3,16c-0.6-0.1-1.2-0.1-1.9-0.1c-0.8,0-1.6,0.1-2.3,0.2l-0.7-7.7c0-0.2,0.2-0.5,0.4-0.5h1.3l-0.6-7.8c0-0.2,0.2-0.5,0.4-0.5h4.3C34.5,0,34.7,0.2,34.6,0.4L34.6,0.4L34.6,0.4z M23.8,1.2c-0.1-0.2-0.3-0.4-0.5-0.3l-4,1.5C19,2.5,18.9,2.8,19,3l3.3,7.1l-1.2,0.5c-0.2,0.1-0.3,0.3-0.2,0.6l3.3,7c1.2-0.7,2.5-1.2,3.9-1.5L23.8,1.2L23.8,1.2z M14,5.7l9.3,13.1c-1.2,0.8-2.2,1.7-3.1,2.7L14.5,16c-0.2-0.2-0.2-0.5,0-0.6l1-0.8L10,9c-0.2-0.2-0.2-0.5,0-0.6l3.3-2.7C13.5,5.4,13.8,5.5,14,5.7L14,5.7z M6.2,13.2c-0.2-0.1-0.5-0.1-0.6,0.1l-2.1,3.7c-0.1,0.2,0,0.5,0.2,0.6l7.1,3.4l-0.7,1.1c-0.1,0.2,0,0.5,0.2,0.6l7.1,3.2c0.5-1.3,1.2-2.5,2-3.6L6.2,13.2z M0.9,23.3c0-0.2,0.3-0.4,0.5-0.3l15.5,4c-0.4,1.3-0.6,2.7-0.7,4.1l-7.8-0.6c-0.2,0-0.4-0.2-0.4-0.5l0.2-1.3L0.6,28c-0.2,0-0.4-0.2-0.4-0.5L0.9,23.3L0.9,23.3L0.9,23.3z M0.4,33.8C0.1,33.8,0,34,0,34.3l0.8,4.2c0,0.2,0.3,0.4,0.5,0.3l7.6-2l0.2,1.3c0,0.2,0.3,0.4,0.5,0.3l7.5-2.1c-0.4-1.3-0.7-2.7-0.8-4.1L0.4,33.8L0.4,33.8z M2.9,44.9c-0.1-0.2,0-0.5,0.2-0.6l14.5-6.9c0.5,1.3,1.3,2.5,2.2,3.6l-6.3,4.5c-0.2,0.1-0.5,0.1-0.6-0.1L12,44.3l-6.5,4.5c-0.2,0.1-0.5,0.1-0.6-0.1L2.9,44.9L2.9,44.9z M20.4,41.9L9.1,53.3c-0.2,0.2-0.2,0.5,0,0.6l3.3,2.7c0.2,0.2,0.5,0.1,0.6-0.1l4.6-6.4l1,0.9c0.2,0.2,0.5,0.1,0.6-0.1l4.4-6.4C22.4,43.8,21.3,42.9,20.4,41.9L20.4,41.9z M18.2,60.1c-0.2-0.1-0.3-0.3-0.2-0.6L24.6,45c1.2,0.6,2.6,1.1,3.9,1.4l-2,7.5c-0.1,0.2-0.3,0.4-0.5,0.3l-1.2-0.5l-2.1,7.6c-0.1,0.2-0.3,0.4-0.5,0.3L18.2,60.1L18.2,60.1L18.2,60.1z M29.6,46.6l-1.3,16c0,0.2,0.2,0.5,0.4,0.5H33c0.2,0,0.4-0.2,0.4-0.5l-0.6-7.8h1.3c0.2,0,0.4-0.2,0.4-0.5l-0.7-7.7c-0.8,0.1-1.5,0.2-2.3,0.2C30.9,46.7,30.2,46.7,29.6,46.6L29.6,46.6z M45.1,3.4c0.1-0.2,0-0.5-0.2-0.6l-4-1.5c-0.2-0.1-0.5,0.1-0.5,0.3l-2.1,7.6l-1.2-0.5c-0.2-0.1-0.5,0.1-0.5,0.3l-2,7.5c1.4,0.3,2.7,0.8,3.9,1.4L45.1,3.4L45.1,3.4z M53.9,9.7L42.6,21.1c-0.9-1-2-1.9-3.2-2.6l4.4-6.4c0.1-0.2,0.4-0.2,0.6-0.1l1,0.9l4.6-6.4c0.1-0.2,0.4-0.2,0.6-0.1l3.3,2.7C54,9.3,54,9.6,53.9,9.7L53.9,9.7z M59.9,18.7c0.2-0.1,0.3-0.4,0.2-0.6L58,14.4c-0.1-0.2-0.4-0.3-0.6-0.1l-6.5,4.5l-0.7-1.1c-0.1-0.2-0.4-0.3-0.6-0.1L43.3,22c0.9,1.1,1.6,2.3,2.2,3.6L59.9,18.7L59.9,18.7z M62.2,24.5l0.7,4.2c0,0.2-0.1,0.5-0.4,0.5l-15.9,1.5c-0.1-1.4-0.4-2.8-0.8-4.1l7.5-2.1c0.2-0.1,0.5,0.1,0.5,0.3l0.2,1.3l7.6-2C61.9,24.1,62.1,24.3,62.2,24.5L62.2,24.5L62.2,24.5z M61.5,40c0.2,0.1,0.5-0.1,0.5-0.3l0.7-4.2c0-0.2-0.1-0.5-0.4-0.5l-7.8-0.7l0.2-1.3c0-0.2-0.1-0.5-0.4-0.5l-7.8-0.6c0,1.4-0.3,2.8-0.7,4.1L61.5,40L61.5,40L61.5,40z M57.4,49.6c-0.1,0.2-0.4,0.3-0.6,0.1l-13.2-9.1c0.8-1.1,1.5-2.3,2-3.6l7.1,3.2c0.2,0.1,0.3,0.4,0.2,0.6L52.2,42l7.1,3.4c0.2,0.1,0.3,0.4,0.2,0.6L57.4,49.6C57.4,49.6,57.4,49.6,57.4,49.6z M39.7,44.2L49,57.3c0.1,0.2,0.4,0.2,0.6,0.1l3.3-2.7c0.2-0.2,0.2-0.4,0-0.6l-5.5-5.6l1-0.8c0.2-0.2,0.2-0.4,0-0.6l-5.5-5.5C42,42.6,40.9,43.5,39.7,44.2L39.7,44.2L39.7,44.2z M39.7,62c-0.2,0.1-0.5-0.1-0.5-0.3l-4.2-15.4c1.4-0.3,2.7-0.8,3.9-1.5l3.3,7c0.1,0.2,0,0.5-0.2,0.6l-1.2,0.5l3.3,7.1c0.1,0.2,0,0.5-0.2,0.6L39.7,62L39.7,62L39.7,62z"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+30
View File
@@ -0,0 +1,30 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Badge } from "./badge";
describe("Badge", () => {
it("renders its children", () => {
render(<Badge variant="info">Assessment</Badge>);
expect(screen.getByText("Assessment")).toBeInTheDocument();
});
it("applies the info variant token classes", () => {
const { container } = render(<Badge variant="info">Info</Badge>);
const badge = container.querySelector("[data-slot='badge']");
// The info variant is built from the existing design-system blue token
// (bg-data-info) rather than a bespoke palette.
expect(badge?.className).toContain("bg-bg-data-info/15");
expect(badge?.className).toContain("text-bg-data-info");
});
it("merges a custom className", () => {
const { container } = render(
<Badge variant="tag" className="extra-class">
Tag
</Badge>,
);
const badge = container.querySelector("[data-slot='badge']");
expect(badge?.className).toContain("extra-class");
});
});
+1
View File
@@ -24,6 +24,7 @@ const badgeVariants = cva(
"border-bg-warning/30 bg-bg-warning-secondary/20 text-text-warning-primary",
error:
"border-transparent bg-bg-fail-secondary text-text-error-primary",
info: "border-transparent bg-bg-data-info/15 text-bg-data-info",
},
},
defaultVariants: {
+4 -5
View File
@@ -1,6 +1,7 @@
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
import { ComplianceBadgeVariant } from "@/components/compliance/compliance-custom-details/shared-components";
import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
import { FindingStatus } from "@/components/ui/table/status-finding-badge";
import {
@@ -39,7 +40,7 @@ export interface CCCTextSection {
export interface CCCMappingSection {
title: string;
key: keyof Requirement;
colorClasses: string;
variant: ComplianceBadgeVariant;
}
export const CCC_TEXT_SECTIONS: CCCTextSection[] = [
@@ -71,14 +72,12 @@ export const CCC_MAPPING_SECTIONS: CCCMappingSection[] = [
{
title: "Threat Mappings",
key: "section_threat_mappings",
colorClasses:
"bg-red-50 text-red-700 ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/20",
variant: "error",
},
{
title: "Guideline Mappings",
key: "section_guideline_mappings",
colorClasses:
"bg-blue-50 text-blue-700 ring-blue-600/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/20",
variant: "info",
},
];
@@ -62,6 +62,10 @@ vi.mock(
"@/components/compliance/compliance-custom-details/mitre-details",
() => ({ MITRECustomDetails: stubFactory("MITREStub") }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/okta-idaas-stig-details",
() => ({ OktaIDaaSStigCustomDetails: stubFactory("OktaIDaaSStigStub") }),
);
vi.mock(
"@/components/compliance/compliance-custom-details/threat-details",
() => ({ ThreatCustomDetails: stubFactory("ThreatStub") }),
@@ -144,6 +148,7 @@ describe("getComplianceMapper", () => {
{ framework: "ProwlerThreatScore", expected: "ThreatStub" },
{ framework: "CCC", expected: "CCCStub" },
{ framework: "CSA-CCM", expected: "CSAStub" },
{ framework: "Okta-IDaaS-STIG", expected: "OktaIDaaSStigStub" },
];
for (const { framework, expected } of wiring) {
@@ -188,6 +193,7 @@ describe("getComplianceMapper", () => {
"ProwlerThreatScore",
"CCC",
"CSA-CCM",
"Okta-IDaaS-STIG",
]) {
const mapper = getComplianceMapper(framework);
expect(Object.keys(mapper).sort(), framework).toEqual(expectedKeys);
+14
View File
@@ -12,6 +12,7 @@ import { GenericCustomDetails } from "@/components/compliance/compliance-custom-
import { ISOCustomDetails } from "@/components/compliance/compliance-custom-details/iso-details";
import { KISACustomDetails } from "@/components/compliance/compliance-custom-details/kisa-details";
import { MITRECustomDetails } from "@/components/compliance/compliance-custom-details/mitre-details";
import { OktaIDaaSStigCustomDetails } from "@/components/compliance/compliance-custom-details/okta-idaas-stig-details";
import { ThreatCustomDetails } from "@/components/compliance/compliance-custom-details/threat-details";
import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
import {
@@ -74,6 +75,10 @@ import {
mapComplianceData as mapMITREComplianceData,
toAccordionItems as toMITREAccordionItems,
} from "./mitre";
import {
mapComplianceData as mapOktaIDaaSStigComplianceData,
toAccordionItems as toOktaIDaaSStigAccordionItems,
} from "./okta-idaas-stig";
import {
getTopFailedSections as getThreatScoreTopFailedSections,
mapComplianceData as mapThetaComplianceData,
@@ -213,6 +218,15 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
getDetailsComponent: (requirement: Requirement) =>
createElement(CSACustomDetails, { requirement }),
},
"Okta-IDaaS-STIG": {
mapComplianceData: mapOktaIDaaSStigComplianceData,
toAccordionItems: toOktaIDaaSStigAccordionItems,
getTopFailedSections,
calculateCategoryHeatmapData: (data: Framework[]) =>
calculateCategoryHeatmapData(data),
getDetailsComponent: (requirement: Requirement) =>
createElement(OktaIDaaSStigCustomDetails, { requirement }),
},
// DORA (Regulation (EU) 2022/2554) — universal framework keyed by the
// `framework` field of `prowler/compliance/dora.json` ("DORA"). Groups by
// Pillar (5 enum values) and surfaces Pillar / Article / ArticleTitle in
+3 -3
View File
@@ -1,6 +1,7 @@
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
import { ComplianceBadgeVariant } from "@/components/compliance/compliance-custom-details/shared-components";
import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
import { FindingStatus } from "@/components/ui/table/status-finding-badge";
import {
@@ -24,15 +25,14 @@ import {
export interface CSAMappingSection {
title: string;
key: keyof Requirement;
colorClasses: string;
variant: ComplianceBadgeVariant;
}
export const CSA_MAPPING_SECTIONS: CSAMappingSection[] = [
{
title: "Scope Applicability",
key: "scope_applicability",
colorClasses:
"bg-blue-50 text-blue-700 ring-blue-600/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/20",
variant: "info",
},
];
+163
View File
@@ -0,0 +1,163 @@
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
import { FindingStatus } from "@/components/ui/table/status-finding-badge";
import {
AttributesData,
Control,
Framework,
isOktaIDaaSStigAttributesMetadata,
OktaIDaaSStigRequirement,
Requirement,
REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
import {
calculateFrameworkCounters,
createRequirementsMap,
findOrCreateCategory,
findOrCreateControl,
findOrCreateFramework,
} from "./commons";
const getStatusCounters = (status: RequirementStatus) => ({
pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0,
fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0,
manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
});
export const mapComplianceData = (
attributesData: AttributesData,
requirementsData: RequirementsData,
): Framework[] => {
const attributes = attributesData?.data || [];
const requirementsMap = createRequirementsMap(requirementsData);
const frameworks: Framework[] = [];
for (const attributeItem of attributes) {
const id = attributeItem.id;
const metadataArray = attributeItem.attributes?.attributes?.metadata;
const attrs = metadataArray?.[0];
if (!isOktaIDaaSStigAttributesMetadata(attrs)) continue;
const requirementData = requirementsMap.get(id);
if (!requirementData) continue;
const frameworkName = attributeItem.attributes.framework;
// Level 1: Section maps to the STIG severity category (e.g. "CAT II (Medium)")
const categoryName = attrs.Section;
// Level 2: each requirement is its own control, labelled by its STIG ID
const controlLabel = id;
const description = attributeItem.attributes.description;
// Human-readable STIG title (e.g. "Okta must log out a session after a
// 15-minute period of inactivity."). Surface it next to the STIG ID and
// fall back to the bare ID when missing, mirroring DORA/CSA.
const requirementName = attributeItem.attributes.name || "";
const status = requirementData.attributes.status || "";
const checks = attributeItem.attributes.attributes.check_ids || [];
const framework = findOrCreateFramework(frameworks, frameworkName);
const category = findOrCreateCategory(framework.categories, categoryName);
const control = findOrCreateControl(category.controls, controlLabel);
const finalStatus: RequirementStatus = status as RequirementStatus;
const requirement = {
name: requirementName ? `${id} - ${requirementName}` : id,
description,
status: finalStatus,
check_ids: checks,
...getStatusCounters(finalStatus),
severity: attrs.Severity,
rule_id: attrs.RuleID,
stig_id: attrs.StigID,
cci: attrs.CCI,
check_text: attrs.CheckText,
fix_text: attrs.FixText,
} satisfies OktaIDaaSStigRequirement;
control.requirements.push(requirement);
}
calculateFrameworkCounters(frameworks);
return frameworks;
};
const createRequirementItem = (
requirement: Requirement,
frameworkName: string,
categoryName: string,
controlIndex: number,
scanId: string,
): AccordionItemProps => ({
key: `${frameworkName}-${categoryName}-control-${controlIndex}`,
title: (
<ComplianceAccordionRequirementTitle
type=""
name={requirement.name}
status={requirement.status as FindingStatus}
/>
),
content: (
<ClientAccordionContent
key={`content-${frameworkName}-${categoryName}-control-${controlIndex}`}
requirement={requirement}
scanId={scanId}
framework={frameworkName}
disableFindings={
requirement.check_ids.length === 0 && requirement.manual === 0
}
/>
),
items: [],
});
const createControlItem = (
control: Control,
frameworkName: string,
categoryName: string,
controlIndex: number,
scanId: string,
): AccordionItemProps =>
createRequirementItem(
control.requirements[0],
frameworkName,
categoryName,
controlIndex,
scanId,
);
export const toAccordionItems = (
data: Framework[],
scanId: string | undefined,
): AccordionItemProps[] => {
const safeId = scanId || "";
return data.flatMap((framework) =>
framework.categories.map((category) => ({
key: `${framework.name}-${category.name}`,
title: (
<ComplianceAccordionTitle
label={category.name}
pass={category.pass}
fail={category.fail}
manual={category.manual}
isParentLevel={true}
/>
),
content: "",
items: category.controls.map((control, controlIndex) =>
createControlItem(
control,
framework.name,
category.name,
controlIndex,
safeId,
),
),
})),
);
};
+44
View File
@@ -279,6 +279,12 @@ const isOneOf = <T extends string>(
const isStringArray = (value: unknown): value is string[] =>
Array.isArray(value) && value.every((item) => typeof item === "string");
const isOptionalString = (value: unknown): value is string | undefined =>
value === undefined || typeof value === "string";
const isOptionalStringArray = (value: unknown): value is string[] | undefined =>
value === undefined || isStringArray(value);
const ASD_METADATA_STRING_FIELDS = [
"Section",
"Description",
@@ -327,6 +333,43 @@ export interface ASDEssentialEightRequirement extends Requirement {
references: ASDEssentialEightAttributesMetadata["References"];
}
export interface OktaIDaaSStigAttributesMetadata {
Section: string;
Severity: string;
RuleID: string;
StigID: string;
CCI?: string[];
CheckText?: string;
FixText?: string;
}
const OKTA_IDAAS_STIG_REQUIRED_STRING_FIELDS = [
"Section",
"Severity",
"RuleID",
"StigID",
] as const satisfies readonly (keyof OktaIDaaSStigAttributesMetadata)[];
export const isOktaIDaaSStigAttributesMetadata = (
value: unknown,
): value is OktaIDaaSStigAttributesMetadata =>
isRecord(value) &&
OKTA_IDAAS_STIG_REQUIRED_STRING_FIELDS.every(
(field) => typeof value[field] === "string",
) &&
isOptionalStringArray(value.CCI) &&
isOptionalString(value.CheckText) &&
isOptionalString(value.FixText);
export interface OktaIDaaSStigRequirement extends Requirement {
severity: OktaIDaaSStigAttributesMetadata["Severity"];
rule_id: OktaIDaaSStigAttributesMetadata["RuleID"];
stig_id: OktaIDaaSStigAttributesMetadata["StigID"];
cci: OktaIDaaSStigAttributesMetadata["CCI"];
check_text: OktaIDaaSStigAttributesMetadata["CheckText"];
fix_text: OktaIDaaSStigAttributesMetadata["FixText"];
}
// DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554).
// Universal framework — flat attributes dict with Pillar/Article/ArticleTitle.
// `Pillar` is the canonical grouping key for tables and PDF; the enum mirrors
@@ -374,6 +417,7 @@ export interface AttributesItemData {
| CCCAttributesMetadata[]
| CSAAttributesMetadata[]
| ASDEssentialEightAttributesMetadata[]
| OktaIDaaSStigAttributesMetadata[]
| DORAAttributesMetadata[]
| GenericAttributesMetadata[];
check_ids: string[];