Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79c93e0ede | |||
| 690e98443b | |||
| 50a808273f | |||
| a30b6623ed | |||
| 15bc317ec4 | |||
| 1536102784 | |||
| 1b99550572 | |||
| 6dfa135755 | |||
| 80482da1cb | |||
| 9cedbd3582 | |||
| c3d1c5c5f7 | |||
| 1fd6c51af6 | |||
| adbfc0bcd1 | |||
| 8f041f6f52 | |||
| 1b0e12ec51 | |||
| 759f7b84d6 | |||
| 0b26c1a39c | |||
| fc7fbddfe7 | |||
| 500b395125 | |||
| a1961d6d5f | |||
| a7e988c361 |
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
|
||||
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.26.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.27.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -44,7 +44,9 @@ repos:
|
||||
rev: v1.24.1
|
||||
hooks:
|
||||
- id: zizmor
|
||||
files: ^\.github/
|
||||
# zizmor only audits workflows, composite actions and dependabot
|
||||
# config; broader paths trip exit 3 ("no audit was performed").
|
||||
files: ^\.github/(workflows|actions)/.+\.ya?ml$|^\.github/dependabot\.ya?ml$
|
||||
priority: 30
|
||||
|
||||
## BASH
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
# Do you want to learn on how to...
|
||||
|
||||
- Contribute with your code or fixes to Prowler
|
||||
- Create a new check for a provider
|
||||
- Create a new security compliance framework
|
||||
- Add a custom output format
|
||||
- Add a new integration
|
||||
- Contribute with documentation
|
||||
- [Contribute with your code or fixes to Prowler](https://docs.prowler.com/developer-guide/introduction)
|
||||
- [Create a new provider](https://docs.prowler.com/developer-guide/provider)
|
||||
- [Create a new service](https://docs.prowler.com/developer-guide/services)
|
||||
- [Create a new check for a provider](https://docs.prowler.com/developer-guide/checks)
|
||||
- [Create a new security compliance framework](https://docs.prowler.com/developer-guide/security-compliance-framework)
|
||||
- [Add a custom output format](https://docs.prowler.com/developer-guide/outputs)
|
||||
- [Add a new integration](https://docs.prowler.com/developer-guide/integrations)
|
||||
- [Contribute with documentation](https://docs.prowler.com/developer-guide/documentation)
|
||||
- [Write unit tests](https://docs.prowler.com/developer-guide/unit-testing)
|
||||
- [Write integration tests](https://docs.prowler.com/developer-guide/integration-testing)
|
||||
- [Write end-to-end tests](https://docs.prowler.com/developer-guide/end2end-testing)
|
||||
- [Debug Prowler](https://docs.prowler.com/developer-guide/debugging)
|
||||
- [Configure checks](https://docs.prowler.com/developer-guide/configurable-checks)
|
||||
- [Rename checks](https://docs.prowler.com/developer-guide/renaming-checks)
|
||||
- [Follow the check metadata guidelines](https://docs.prowler.com/developer-guide/check-metadata-guidelines)
|
||||
- [Extend the MCP server](https://docs.prowler.com/developer-guide/mcp-server)
|
||||
- [Extend Lighthouse AI](https://docs.prowler.com/developer-guide/lighthouse-architecture)
|
||||
- [Add AI skills](https://docs.prowler.com/developer-guide/ai-skills)
|
||||
|
||||
Provider-specific developer notes:
|
||||
|
||||
- [AWS](https://docs.prowler.com/developer-guide/aws-details)
|
||||
- [Azure](https://docs.prowler.com/developer-guide/azure-details)
|
||||
- [Google Cloud](https://docs.prowler.com/developer-guide/gcp-details)
|
||||
- [Alibaba Cloud](https://docs.prowler.com/developer-guide/alibabacloud-details)
|
||||
- [Kubernetes](https://docs.prowler.com/developer-guide/kubernetes-details)
|
||||
- [Microsoft 365](https://docs.prowler.com/developer-guide/m365-details)
|
||||
- [GitHub](https://docs.prowler.com/developer-guide/github-details)
|
||||
- [LLM](https://docs.prowler.com/developer-guide/llm-details)
|
||||
|
||||
Want some swag as appreciation for your contribution?
|
||||
|
||||
|
||||
@@ -2,11 +2,34 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.28.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- GIN index on `findings(categories, resource_services, resource_regions, resource_types)` to speed up `/api/v1/finding-groups` array filters [(#11001)](https://github.com/prowler-cloud/prowler/pull/11001)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Remove orphaned `gin_resources_search_idx` declaration from `Resource.Meta.indexes` (DB index dropped in `0072_drop_unused_indexes`) [(#11001)](https://github.com/prowler-cloud/prowler/pull/11001)
|
||||
|
||||
---
|
||||
|
||||
## [1.27.1] (Prowler v5.26.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `POST /api/v1/scans` was intermittently failing with `Scan matching query does not exist` in the `scan-perform` worker; the Celery task is now published via `transaction.on_commit` so the worker cannot read the Scan before the dispatch-wide transaction commits [(#11122)](https://github.com/prowler-cloud/prowler/pull/11122)
|
||||
|
||||
---
|
||||
|
||||
## [1.27.0] (Prowler v5.26.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `scan-reset-ephemeral-resources` post-scan task zeroes `failed_findings_count` for resources missing from the latest full-scope scan, keeping ephemeral resources from polluting the Resources page sort [(#10929)](https://github.com/prowler-cloud/prowler/pull/10929)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- ASD Essential Eight (AWS) compliance framework support [(#10982)](https://github.com/prowler-cloud/prowler/pull/10982)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
@@ -50,7 +50,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.27.0"
|
||||
version = "1.28.0"
|
||||
|
||||
[project.scripts]
|
||||
celery = "src.backend.config.settings.celery"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from functools import partial
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
from api.db_utils import create_index_on_partitions, drop_index_on_partitions
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
("api", "0090_attack_paths_cleanup_priority"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
partial(
|
||||
create_index_on_partitions,
|
||||
parent_table="findings",
|
||||
index_name="gin_find_arrays_idx",
|
||||
columns="categories, resource_services, resource_regions, resource_types",
|
||||
method="GIN",
|
||||
all_partitions=True,
|
||||
),
|
||||
reverse_code=partial(
|
||||
drop_index_on_partitions,
|
||||
parent_table="findings",
|
||||
index_name="gin_find_arrays_idx",
|
||||
),
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
import django.contrib.postgres.indexes
|
||||
from django.db import migrations
|
||||
|
||||
INDEX_NAME = "gin_find_arrays_idx"
|
||||
PARENT_TABLE = "findings"
|
||||
|
||||
|
||||
def create_parent_and_attach(apps, schema_editor):
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
# Idempotent: the parent index may already exist if it was created
|
||||
# manually on an environment before this migration ran.
|
||||
cursor.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS {INDEX_NAME} ON ONLY {PARENT_TABLE} "
|
||||
f"USING gin (categories, resource_services, resource_regions, resource_types)"
|
||||
)
|
||||
cursor.execute(
|
||||
"SELECT inhrelid::regclass::text "
|
||||
"FROM pg_inherits "
|
||||
"WHERE inhparent = %s::regclass",
|
||||
[PARENT_TABLE],
|
||||
)
|
||||
for (partition,) in cursor.fetchall():
|
||||
child_idx = f"{partition.replace('.', '_')}_{INDEX_NAME}"
|
||||
# ALTER INDEX ... ATTACH PARTITION has no IF NOT ATTACHED clause,
|
||||
# so check pg_inherits first to keep the migration re-runnable.
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM pg_inherits i
|
||||
JOIN pg_class p ON p.oid = i.inhparent
|
||||
JOIN pg_class c ON c.oid = i.inhrelid
|
||||
WHERE p.relname = %s AND c.relname = %s
|
||||
""",
|
||||
[INDEX_NAME, child_idx],
|
||||
)
|
||||
if cursor.fetchone() is None:
|
||||
cursor.execute(f"ALTER INDEX {INDEX_NAME} ATTACH PARTITION {child_idx}")
|
||||
|
||||
|
||||
def drop_parent_index(apps, schema_editor):
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
cursor.execute(f"DROP INDEX IF EXISTS {INDEX_NAME}")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0091_findings_arrays_gin_index_partitions"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(
|
||||
state_operations=[
|
||||
migrations.AddIndex(
|
||||
model_name="finding",
|
||||
index=django.contrib.postgres.indexes.GinIndex(
|
||||
fields=[
|
||||
"categories",
|
||||
"resource_services",
|
||||
"resource_regions",
|
||||
"resource_types",
|
||||
],
|
||||
name=INDEX_NAME,
|
||||
),
|
||||
),
|
||||
],
|
||||
database_operations=[
|
||||
migrations.RunPython(
|
||||
create_parent_and_attach,
|
||||
reverse_code=drop_parent_index,
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -946,7 +946,6 @@ class Resource(RowLevelSecurityProtectedModel):
|
||||
OpClass(Upper("name"), name="gin_trgm_ops"),
|
||||
name="res_name_trgm_idx",
|
||||
),
|
||||
GinIndex(fields=["text_search"], name="gin_resources_search_idx"),
|
||||
models.Index(fields=["tenant_id", "id"], name="resources_tenant_id_idx"),
|
||||
models.Index(
|
||||
fields=["tenant_id", "provider_id"],
|
||||
@@ -1152,6 +1151,15 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel):
|
||||
fields=["tenant_id", "scan_id", "check_id"],
|
||||
name="find_tenant_scan_check_idx",
|
||||
),
|
||||
GinIndex(
|
||||
fields=[
|
||||
"categories",
|
||||
"resource_services",
|
||||
"resource_regions",
|
||||
"resource_types",
|
||||
],
|
||||
name="gin_find_arrays_idx",
|
||||
),
|
||||
]
|
||||
|
||||
class JSONAPIMeta:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.27.0
|
||||
version: 1.28.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -16,7 +17,7 @@ from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
|
||||
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
|
||||
from allauth.socialaccount.providers.saml.views import FinishACSView, LoginView
|
||||
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
|
||||
from celery import chain
|
||||
from celery import chain, states
|
||||
from celery.result import AsyncResult
|
||||
from config.custom_logging import BackendLogger
|
||||
from config.env import env
|
||||
@@ -60,6 +61,7 @@ from django.utils.dateparse import parse_date
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from django_celery_results.models import TaskResult
|
||||
from drf_spectacular.settings import spectacular_settings
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import (
|
||||
@@ -422,7 +424,7 @@ class SchemaView(SpectacularAPIView):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
spectacular_settings.TITLE = "Prowler API"
|
||||
spectacular_settings.VERSION = "1.27.0"
|
||||
spectacular_settings.VERSION = "1.28.0"
|
||||
spectacular_settings.DESCRIPTION = (
|
||||
"Prowler API specification.\n\nThis file is auto-generated."
|
||||
)
|
||||
@@ -2534,28 +2536,45 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
def create(self, request, *args, **kwargs):
|
||||
input_serializer = self.get_serializer(data=request.data)
|
||||
input_serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Broker publish is deferred to on_commit so the worker cannot read
|
||||
# Scan before BaseRLSViewSet's dispatch-wide atomic commits.
|
||||
pre_task_id = str(uuid.uuid4())
|
||||
|
||||
with transaction.atomic():
|
||||
scan = input_serializer.save()
|
||||
with transaction.atomic():
|
||||
task = perform_scan_task.apply_async(
|
||||
kwargs={
|
||||
"tenant_id": self.request.tenant_id,
|
||||
"scan_id": str(scan.id),
|
||||
"provider_id": str(scan.provider_id),
|
||||
# Disabled for now
|
||||
# checks_to_execute=scan.scanner_args.get("checks_to_execute")
|
||||
},
|
||||
scan.task_id = pre_task_id
|
||||
scan.save(update_fields=["task_id"])
|
||||
|
||||
attack_paths_db_utils.create_attack_paths_scan(
|
||||
tenant_id=self.request.tenant_id,
|
||||
scan_id=str(scan.id),
|
||||
provider_id=str(scan.provider_id),
|
||||
)
|
||||
|
||||
attack_paths_db_utils.create_attack_paths_scan(
|
||||
tenant_id=self.request.tenant_id,
|
||||
scan_id=str(scan.id),
|
||||
provider_id=str(scan.provider_id),
|
||||
)
|
||||
task_result, _ = TaskResult.objects.get_or_create(
|
||||
task_id=pre_task_id,
|
||||
defaults={"status": states.PENDING, "task_name": "scan-perform"},
|
||||
)
|
||||
prowler_task, _ = Task.objects.update_or_create(
|
||||
id=pre_task_id,
|
||||
tenant_id=self.request.tenant_id,
|
||||
defaults={"task_runner_task": task_result},
|
||||
)
|
||||
|
||||
prowler_task = Task.objects.get(id=task.id)
|
||||
scan.task_id = task.id
|
||||
scan.save(update_fields=["task_id"])
|
||||
scan_kwargs = {
|
||||
"tenant_id": self.request.tenant_id,
|
||||
"scan_id": str(scan.id),
|
||||
"provider_id": str(scan.provider_id),
|
||||
# Disabled for now
|
||||
# checks_to_execute=scan.scanner_args.get("checks_to_execute")
|
||||
}
|
||||
|
||||
transaction.on_commit(
|
||||
lambda: perform_scan_task.apply_async(
|
||||
kwargs=scan_kwargs, task_id=pre_task_id
|
||||
)
|
||||
)
|
||||
|
||||
self.response_serializer_class = TaskSerializer
|
||||
output_serializer = self.get_serializer(prowler_task)
|
||||
|
||||
@@ -2,47 +2,378 @@
|
||||
title: 'Creating a New Security Compliance Framework in Prowler'
|
||||
---
|
||||
|
||||
This guide explains how to add a new security compliance framework to Prowler, end to end. It covers directory layout, the JSON schema, check mapping conventions, the Pydantic models that validate each framework, the CSV output formatter, local validation, testing, and the pull request process.
|
||||
|
||||
## Introduction
|
||||
|
||||
To create or contribute a custom security framework for Prowler—or to integrate a public framework—you must ensure the necessary checks are available. If they are missing, they must be implemented before proceeding.
|
||||
A compliance framework in Prowler maps a public or custom control catalog (for example CIS, NIST 800-53, PCI DSS, HIPAA, ENS, CCC) to the security checks that Prowler already runs. Each requirement links to zero, one or more Prowler checks. When a scan executes, findings are aggregated per requirement to produce the compliance report rendered by Prowler CLI and Prowler Cloud.
|
||||
|
||||
Each framework is defined in a compliance file per provider. The file should follow the structure used in `prowler/compliance/<provider>/` and be named `<framework>_<version>_<provider>.json`. Follow the format below to create your own.
|
||||
Prowler ships with 85+ compliance frameworks across All Providers. The catalog lives under `prowler/compliance/<provider>/` (or `prowler/compliance/` for universal compliance frameworks)
|
||||
|
||||
## Compliance Framework
|
||||
<Warning>
|
||||
A compliance framework must represent the **complete state** of the source catalog. Every requirement defined by the framework has to be present in the JSON file, even when none of the existing Prowler checks can automate it. In that case, leave `Checks` as an empty array, but do not omit the requirement.
|
||||
|
||||
### Compliance Framework Structure
|
||||
Requirement coverage feeds the compliance percentage calculations and the metadata surfaces (dashboards, widgets, exports). Missing requirements skew those metrics and break the report as a faithful snapshot of the framework.
|
||||
</Warning>
|
||||
|
||||
Each compliance framework file consists of structured metadata that identifies the framework and maps security checks to requirements or controls. Please note that a single requirement can be linked to multiple Prowler checks:
|
||||
### Prerequisites
|
||||
|
||||
- `Framework`: string – The distinguished name of the framework (e.g., CIS).
|
||||
- `Provider`: string – The cloud provider where the framework applies (AWS, Azure, OCI).
|
||||
- `Version`: string – The framework version (e.g., 1.4 for CIS).
|
||||
- `Requirements`: array of objects. – Defines security requirements and their mapping to Prowler checks. All requirements or controls are to be included with the mapping to Prowler.
|
||||
- `Requirements_Id`: string – A unique identifier for each requirement within the framework
|
||||
- `Requirements_Description`: string – The requirement description as specified in the framework.
|
||||
- `Requirements_Attributes`: array of objects. – Contains relevant metadata such as security levels, sections, and any additional data needed for reporting with the result of the findings. Attributes should be derived directly from the framework’s own terminology, ensuring consistency with its established definitions.
|
||||
- `Requirements_Checks`: array. The Prowler checks that are needed to prove this requirement. It can be one or multiple checks. In case automation is not feasible, this can be empty.
|
||||
Before adding a new framework, complete the following checks:
|
||||
|
||||
- **Verify the framework is not already supported.** Inspect `prowler/compliance/<provider>/` for an existing JSON file matching the name and version.
|
||||
- **Confirm the required checks exist.** Every requirement that can be automated must point to one or more existing Prowler checks. For each missing check, implement it first by following the [Prowler Checks](/developer-guide/checks) guide.
|
||||
- **Review a reference framework.** Use an existing framework with a similar structure as your template. `cis_2.0_aws.json` is the canonical reference for CIS-style frameworks. `ccc_aws.json`, `ens_rd2022_aws.json`, and `nist_800_53_revision_5_aws.json` illustrate other attribute shapes.
|
||||
|
||||
## Four-Layer Architecture
|
||||
|
||||
A compliance framework spans four layers. A complete contribution must touch each layer that applies.
|
||||
|
||||
- **Layer 1 – Schema validation:** The Pydantic models in `prowler/lib/check/compliance_models.py` define the canonical schema for each attribute shape (CIS, ENS, Mitre, CCC, C5, CSA CCM, ISO 27001, KISA ISMS-P, AWS Well-Architected, Prowler ThreatScore, and a generic fallback).
|
||||
- **Layer 2 – JSON catalog:** The framework JSON file in `prowler/compliance/<provider>/` lists every requirement and maps it to checks.
|
||||
- **Layer 3 – Output formatter:** The Python module in `prowler/lib/outputs/compliance/<framework>/` builds the CSV row model, the per-provider transformer, and the CLI summary table.
|
||||
- **Layer 4 – Output dispatchers:** The dispatchers in `prowler/lib/outputs/compliance/compliance.py` and `prowler/lib/outputs/compliance/compliance_output.py` route findings to the right formatter based on the framework identifier.
|
||||
|
||||
The rest of this guide walks each layer in order.
|
||||
|
||||
## Directory Structure and File Naming
|
||||
|
||||
Compliance frameworks live at:
|
||||
|
||||
```
|
||||
prowler/compliance/<provider>/<framework>_<version>_<provider>.json
|
||||
```
|
||||
|
||||
The filename conventions are:
|
||||
|
||||
- All lowercase, words separated with underscores.
|
||||
- `<provider>` is a supported provider identifier: `aws`, `azure`, `gcp`, `kubernetes`, `m365`, `github`, `googleworkspace`, `alibabacloud`, `oraclecloud`, `cloudflare`, `mongodbatlas`, `nhn`, `openstack`, `iac`, `llm`.
|
||||
- `<version>` is optional. Omit it when the framework has no versioning, as in `ccc_aws.json`.
|
||||
- The file basename (without `.json`) is the framework key that Prowler CLI accepts via `--compliance`.
|
||||
|
||||
Examples:
|
||||
|
||||
- `prowler/compliance/aws/cis_2.0_aws.json`
|
||||
- `prowler/compliance/aws/nist_800_53_revision_5_aws.json`
|
||||
- `prowler/compliance/azure/ens_rd2022_azure.json`
|
||||
- `prowler/compliance/kubernetes/cis_1.10_kubernetes.json`
|
||||
- `prowler/compliance/aws/ccc_aws.json`
|
||||
|
||||
The output formatter directory mirrors the framework name:
|
||||
|
||||
```
|
||||
prowler/lib/outputs/compliance/<framework>/
|
||||
├── <framework>.py # CLI summary-table dispatcher
|
||||
├── <framework>_<provider>.py # Per-provider transformer class
|
||||
├── models.py # Pydantic CSV row model
|
||||
└── __init__.py
|
||||
```
|
||||
|
||||
## JSON Schema Reference
|
||||
|
||||
Every compliance file is a JSON document with the following top-level keys.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `Framework` | string | Yes | Canonical framework identifier, for example `CIS`, `NIST-800-53-Revision-5`, `ENS`, `CCC`. |
|
||||
| `Name` | string | Yes | Human-readable framework name displayed by Prowler App. |
|
||||
| `Version` | string | Yes | Framework version, for example `2.0`. Use an empty string only for frameworks without versioning. See [Version Handling](#version-handling). |
|
||||
| `Provider` | string | Yes | Upper-cased provider identifier: `AWS`, `AZURE`, `GCP`, `KUBERNETES`, `M365`, `GITHUB`, `GOOGLEWORKSPACE`, and so on. |
|
||||
| `Description` | string | Yes | Short description of the framework's scope and purpose. |
|
||||
| `Requirements` | array | Yes | List of [requirement objects](#requirement-object). |
|
||||
|
||||
### Requirement Object
|
||||
|
||||
Each entry in `Requirements` describes one control or requirement.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `Id` | string | Yes | Unique identifier within the framework, for example `1.10` or `CCC.Core.CN01.AR01`. |
|
||||
| `Name` | string | No | Optional human-readable name used by frameworks that distinguish control name from description, such as NIST. |
|
||||
| `Description` | string | Yes | Verbatim description from the source framework. |
|
||||
| `Attributes` | array | Yes | List of [attribute objects](#attribute-objects). The shape depends on the framework. |
|
||||
| `Checks` | array of strings | Yes | Prowler check identifiers that automate the requirement. Leave the list empty when the control cannot be automated. |
|
||||
|
||||
### Attribute Objects
|
||||
|
||||
Attributes carry the metadata that Prowler App and the CSV output display for each requirement. The object shape is framework-specific and is validated by a dedicated Pydantic model in `prowler/lib/check/compliance_models.py`. The most common shapes are summarized below.
|
||||
|
||||
#### CIS_Requirement_Attribute
|
||||
|
||||
Used by every CIS benchmark.
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `Section` | string | Yes | Top-level section, for example `1 Identity and Access Management`. |
|
||||
| `SubSection` | string | No | Optional second-level grouping. |
|
||||
| `Profile` | enum | Yes | One of `Level 1`, `Level 2`, `E3 Level 1`, `E3 Level 2`, `E5 Level 1`, `E5 Level 2`. |
|
||||
| `AssessmentStatus` | enum | Yes | `Manual` or `Automated`. |
|
||||
| `Description` | string | Yes | Control description. |
|
||||
| `RationaleStatement` | string | Yes | Reason the control exists. |
|
||||
| `ImpactStatement` | string | Yes | Impact of non-compliance. |
|
||||
| `RemediationProcedure` | string | Yes | Remediation steps. |
|
||||
| `AuditProcedure` | string | Yes | Audit steps. |
|
||||
| `AdditionalInformation` | string | Yes | Free-form notes. |
|
||||
| `DefaultValue` | string | No | Default configuration value, when relevant. |
|
||||
| `References` | string | Yes | Colon-separated list of reference URLs. |
|
||||
|
||||
#### ENS_Requirement_Attribute
|
||||
|
||||
Used by the Spanish ENS (Esquema Nacional de Seguridad) frameworks.
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `IdGrupoControl` | string | Yes | Control group identifier. |
|
||||
| `Marco` | string | Yes | Framework block (`operacional`, `organizativo`, `proteccion`). |
|
||||
| `Categoria` | string | Yes | Control category. |
|
||||
| `DescripcionControl` | string | Yes | Control description in Spanish. |
|
||||
| `Tipo` | enum | Yes | `refuerzo`, `requisito`, `recomendacion`, `medida`. |
|
||||
| `Nivel` | enum | Yes | `opcional`, `bajo`, `medio`, `alto`. |
|
||||
| `Dimensiones` | array of enum | Yes | Subset of `confidencialidad`, `integridad`, `trazabilidad`, `autenticidad`, `disponibilidad`. |
|
||||
| `ModoEjecucion` | string | Yes | Execution mode (`manual`, `automático`, `híbrido`). |
|
||||
| `Dependencias` | array of strings | Yes | Ids of prerequisite controls. Empty list when none. |
|
||||
|
||||
#### CCC_Requirement_Attribute
|
||||
|
||||
Used by the Common Cloud Controls Catalog.
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `FamilyName` | string | Yes | Control family, for example `Data`. |
|
||||
| `FamilyDescription` | string | Yes | Description of the family. |
|
||||
| `Section` | string | Yes | Section title. |
|
||||
| `SubSection` | string | Yes | Subsection title, or empty string. |
|
||||
| `SubSectionObjective` | string | Yes | Stated objective for the subsection. |
|
||||
| `Applicability` | array of strings | Yes | Applicability tags such as `tlp-green`, `tlp-amber`, `tlp-red`. |
|
||||
| `Recommendation` | string | Yes | Implementation recommendation. |
|
||||
| `SectionThreatMappings` | array of objects | Yes | Each entry has `ReferenceId` and `Identifiers`. |
|
||||
| `SectionGuidelineMappings` | array of objects | Yes | Each entry has `ReferenceId` and `Identifiers`. |
|
||||
|
||||
#### Generic_Compliance_Requirement_Attribute
|
||||
|
||||
The fallback attribute model used when no framework-specific schema applies (for example NIST 800-53, PCI DSS, GDPR, HIPAA).
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `ItemId` | string | No | Item identifier. |
|
||||
| `Section` | string | No | Section name. |
|
||||
| `SubSection` | string | No | Subsection name. |
|
||||
| `SubGroup` | string | No | Subgroup name. |
|
||||
| `Service` | string | No | Affected service, for example `aws`, `iam`. |
|
||||
| `Type` | string | No | Control type. |
|
||||
| `Comment` | string | No | Free-form comment. |
|
||||
|
||||
Additional per-framework attribute models exist for `AWS_Well_Architected_Requirement_Attribute`, `ISO27001_2013_Requirement_Attribute`, `Mitre_Requirement_Attribute_<Provider>`, `KISA_ISMSP_Requirement_Attribute`, `Prowler_ThreatScore_Requirement_Attribute`, `C5Germany_Requirement_Attribute`, and `CSA_CCM_Requirement_Attribute`. Consult `prowler/lib/check/compliance_models.py` for their full field sets.
|
||||
|
||||
<Note>
|
||||
The `Attributes` field is a Pydantic `Union`. The generic attribute model must remain the last element of that Union, otherwise Pydantic v1 silently coerces every framework into the generic shape and your specialized fields are dropped.
|
||||
</Note>
|
||||
|
||||
## Minimal Working Example
|
||||
|
||||
The following snippet is a complete, valid framework file named `my_framework_1.0_aws.json`, saved at `prowler/compliance/aws/my_framework_1.0_aws.json`. It uses the generic attribute shape for simplicity.
|
||||
|
||||
```json title="prowler/compliance/aws/my_framework_1.0_aws.json"
|
||||
{
|
||||
"Framework": "<framework>-<provider>",
|
||||
"Version": "<version>",
|
||||
"Framework": "My-Framework",
|
||||
"Name": "My Framework 1.0 for AWS",
|
||||
"Version": "1.0",
|
||||
"Provider": "AWS",
|
||||
"Description": "Internal baseline for AWS accounts.",
|
||||
"Requirements": [
|
||||
{
|
||||
"Id": "<unique-id>",
|
||||
"Description": "Full description of the requirement",
|
||||
"Checks": [
|
||||
"Here is the prowler check or checks that will be executed"
|
||||
],
|
||||
"Id": "MF-1.1",
|
||||
"Description": "Root account must have multi-factor authentication enabled.",
|
||||
"Attributes": [
|
||||
{
|
||||
<Add here your custom attributes.>
|
||||
"ItemId": "MF-1.1",
|
||||
"Section": "Identity and Access Management",
|
||||
"SubSection": "Root Account",
|
||||
"Service": "iam"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"iam_root_mfa_enabled",
|
||||
"iam_root_hardware_mfa_enabled"
|
||||
]
|
||||
},
|
||||
...
|
||||
{
|
||||
"Id": "MF-2.1",
|
||||
"Description": "S3 buckets must block public access at the account level.",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "MF-2.1",
|
||||
"Section": "Data Protection",
|
||||
"Service": "s3"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"s3_account_level_public_access_blocks"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Finally, to have a proper output file for your reports, your framework data model has to be created in `prowler/lib/outputs/models.py` and also the CLI table output in `prowler/lib/outputs/compliance.py`. Also, you need to add a new conditional in `prowler/lib/outputs/file_descriptors.py` if creating a new CSV model.
|
||||
## Mapping Checks to Requirements
|
||||
|
||||
Each requirement links to the Prowler checks that, together, produce a PASS or FAIL verdict for that control.
|
||||
|
||||
- **Include every requirement from the source catalog.** The framework file must mirror the full control list, one-to-one. Compliance percentages, dashboards, and exported metadata are computed against the total requirement count, so omitting an unmappable control inflates coverage and misrepresents the framework.
|
||||
- List every check by its canonical identifier, the value of `CheckID` inside the check's `.metadata.json` file.
|
||||
- One requirement can reference multiple checks. The requirement is evaluated as FAIL when any referenced check produces a FAIL finding for a resource in scope.
|
||||
- Leave `Checks` as an empty array when the requirement cannot be automated. The requirement still appears in the report, contributes to the total, and resolves to `MANUAL`. An empty mapping is valid; a missing requirement is not.
|
||||
- Reuse checks across requirements when the same control applies in multiple places. Do not duplicate check logic to match framework structure.
|
||||
- Avoid referencing checks from a different provider. A compliance file is bound to one provider, and cross-provider checks will never match findings in the scan.
|
||||
|
||||
To discover available checks, run:
|
||||
|
||||
```bash
|
||||
poetry run python prowler-cli.py <provider> --list-checks
|
||||
```
|
||||
|
||||
## Supporting Multiple Providers
|
||||
|
||||
Each compliance file targets a single provider. To cover several providers with the same framework (for example CIS across AWS, Azure, and GCP), ship one JSON file per provider:
|
||||
|
||||
```
|
||||
prowler/compliance/aws/cis_2.0_aws.json
|
||||
prowler/compliance/azure/cis_2.0_azure.json
|
||||
prowler/compliance/gcp/cis_2.0_gcp.json
|
||||
```
|
||||
|
||||
Keep the `Framework` and `Version` values identical across the files so the dispatcher matches them, and change only the `Provider`, `Checks`, and provider-specific metadata.
|
||||
|
||||
The CIS output formatter already supports every provider listed above. For a brand-new framework that spans several providers, add one transformer per provider in `prowler/lib/outputs/compliance/<framework>/` and extend the summary-table dispatcher accordingly. See [Output Formatter](#output-formatter).
|
||||
|
||||
## Output Formatter
|
||||
|
||||
Prowler renders every compliance framework in two forms: a detailed CSV report written to disk, and a summary table printed in the CLI. Both are produced by the output formatter package for the framework.
|
||||
|
||||
For a new framework named `my_framework`, create:
|
||||
|
||||
```
|
||||
prowler/lib/outputs/compliance/my_framework/
|
||||
├── __init__.py
|
||||
├── my_framework.py # CLI summary table dispatcher
|
||||
├── my_framework_aws.py # Per-provider transformer
|
||||
└── models.py # CSV row Pydantic model
|
||||
```
|
||||
|
||||
### Step 1 – Define the CSV Row Model
|
||||
|
||||
In `models.py`, declare a Pydantic v1 model with one field per CSV column. Use existing models such as `AWSCISModel` in `prowler/lib/outputs/compliance/cis/models.py` as the reference. Fields typically include `Provider`, `Description`, `AccountId`, `Region`, `AssessmentDate`, `Requirements_Id`, `Requirements_Description`, one `Requirements_Attributes_*` field per attribute key, plus the finding fields `Status`, `StatusExtended`, `ResourceId`, `ResourceName`, `CheckId`, `Muted`, `Framework`, `Name`.
|
||||
|
||||
### Step 2 – Implement the Transformer Class
|
||||
|
||||
In `my_framework_aws.py`, subclass `ComplianceOutput` from `prowler.lib.outputs.compliance.compliance_output` and implement `transform(findings, compliance, compliance_name)`. Iterate over `findings`, match each finding to the requirements it satisfies through `finding.compliance.get(compliance_name, [])`, and append one row per attribute to `self._data`.
|
||||
|
||||
### Step 3 – Add the Summary-Table Dispatcher
|
||||
|
||||
In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_metadata, compliance_framework, output_filename, output_directory, compliance_overview)` following the pattern in `prowler/lib/outputs/compliance/cis/cis.py`.
|
||||
|
||||
### Step 4 – Register the Framework in the Dispatchers
|
||||
|
||||
- Add the dispatcher call in `prowler/lib/outputs/compliance/compliance.py`, inside `display_compliance_table`, with a branch such as `elif "my_framework" in compliance_framework:`.
|
||||
- Register the CSV model and transformer in `prowler/lib/outputs/compliance/compliance_output.py` so the CSV file is emitted during the scan.
|
||||
|
||||
<Note>
|
||||
For NIST-style catalogs that use `Generic_Compliance_Requirement_Attribute`, no custom formatter is needed. The generic formatter in `prowler/lib/outputs/compliance/generic/` handles them automatically, provided the JSON validates against the generic attribute schema.
|
||||
</Note>
|
||||
|
||||
## Version Handling
|
||||
|
||||
Prowler matches frameworks by concatenating `Framework` and `Version`. A missing or empty `Version` collapses several frameworks to the same key and breaks CLI filtering with `--compliance`.
|
||||
|
||||
- Always set `Version` to a non-empty string, even for frameworks that rename editions rather than version them. Use the edition identifier (for example `RD2022`, `v2025.10`, `4.0`).
|
||||
- When the source catalog has no version, use the first year of adoption or the release date.
|
||||
- Make sure the version substring embedded in the filename matches `Version`, because the CLI dispatcher reads `compliance_framework.split("_")[1]` to select the correct version.
|
||||
|
||||
## Validating the Framework Locally
|
||||
|
||||
Follow the steps below before opening a pull request.
|
||||
|
||||
### 1. Run the Compliance Model Validator
|
||||
|
||||
```bash
|
||||
poetry run python prowler-cli.py <provider> --list-compliance
|
||||
```
|
||||
|
||||
The framework must appear in the output. A validation error indicates a schema mismatch between the JSON file and the attribute model.
|
||||
|
||||
### 2. Run a Scan Filtered by the Framework
|
||||
|
||||
```bash
|
||||
poetry run python prowler-cli.py <provider> \
|
||||
--compliance <framework>_<version>_<provider> \
|
||||
--log-level ERROR
|
||||
```
|
||||
|
||||
Verify that:
|
||||
|
||||
- Prowler produces a CSV file under `output/compliance/` with the expected name.
|
||||
- The CLI summary table lists every section in the framework.
|
||||
- Findings roll up under the expected requirements.
|
||||
|
||||
### 3. Inspect the CSV Output
|
||||
|
||||
Open the generated CSV and confirm:
|
||||
|
||||
- All columns defined in `models.py` appear.
|
||||
- Every requirement has at least one row per scanned resource.
|
||||
- Values such as `Requirements_Attributes_Section` reflect the JSON content.
|
||||
|
||||
### 4. Verify the Framework in Prowler App
|
||||
|
||||
Launch Prowler App locally (`docker compose up` from the repository root) and run a scan with the new compliance framework. Confirm the compliance page renders the requirements, sections, and status widgets correctly.
|
||||
|
||||
## Testing
|
||||
|
||||
Compliance contributions require two layers of tests.
|
||||
|
||||
- **Schema tests** exercise the Pydantic models. Extend `tests/lib/check/universal_compliance_models_test.py` with a case that loads the new JSON file and asserts the attribute type matches the expected model.
|
||||
- **Output tests** exercise the transformer. Mirror the structure under `tests/lib/outputs/compliance/<framework>/` with fixtures that feed synthetic findings through the transformer and assert the resulting CSV rows.
|
||||
|
||||
Run the suite with:
|
||||
|
||||
```bash
|
||||
poetry run pytest -n auto tests/lib/check/universal_compliance_models_test.py \
|
||||
tests/lib/outputs/compliance/
|
||||
```
|
||||
|
||||
For guidance on writing Prowler SDK tests, refer to [Unit Testing](/developer-guide/unit-testing).
|
||||
|
||||
## Submitting the Pull Request
|
||||
|
||||
Before opening the pull request:
|
||||
|
||||
1. Run the complete QA pipeline:
|
||||
```bash
|
||||
poetry run pre-commit run --all-files
|
||||
poetry run pytest -n auto
|
||||
```
|
||||
2. Add a changelog entry under the `### 🚀 Added` section of `prowler/CHANGELOG.md`, describing the new framework and the providers it covers.
|
||||
3. Follow the [Pull Request Template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md) and set the PR title using Conventional Commits, for example `feat(compliance): add My Framework 1.0 for AWS`.
|
||||
4. Request review from the compliance codeowners listed in `.github/CODEOWNERS`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
The following issues are the most common when contributing a compliance framework.
|
||||
|
||||
- **`ValidationError: field required` during scan.** The JSON is missing a required attribute field. Re-check the matching Pydantic model in `prowler/lib/check/compliance_models.py`.
|
||||
- **All attributes collapse to `Generic_Compliance_Requirement_Attribute` values.** The Pydantic `Union` is ordered incorrectly, or the JSON matches only the generic shape. Move the generic model to the last Union position and ensure every required field is present in the JSON.
|
||||
- **`--compliance` filter does not find the framework.** The filename does not match the expected pattern `<framework>_<version>_<provider>.json`, the version is empty, or the file lives outside `prowler/compliance/<provider>/`.
|
||||
- **CLI summary table is empty but the CSV is populated.** The dispatcher branch in `prowler/lib/outputs/compliance/compliance.py` is missing or its substring match does not catch the framework key.
|
||||
- **CSV file is missing after the scan.** The transformer class is not registered in `prowler/lib/outputs/compliance/compliance_output.py`, or `transform()` raises silently. Run the scan with `--log-level DEBUG`.
|
||||
- **Findings do not roll up under a requirement.** A check listed in `Checks` either does not exist for that provider or is spelled incorrectly. Run `--list-checks | grep <check_name>` to confirm.
|
||||
|
||||
## Reference Examples
|
||||
|
||||
Use the following files as templates when modeling a new contribution.
|
||||
|
||||
- `prowler/compliance/aws/cis_2.0_aws.json` – CIS attribute shape.
|
||||
- `prowler/compliance/aws/nist_800_53_revision_5_aws.json` – Generic attribute shape.
|
||||
- `prowler/compliance/aws/ccc_aws.json` – CCC attribute shape.
|
||||
- `prowler/compliance/azure/ens_rd2022_azure.json` – ENS attribute shape.
|
||||
- `prowler/lib/check/compliance_models.py` – Canonical Pydantic schemas.
|
||||
- `prowler/lib/outputs/compliance/cis/` – Reference implementation of a multi-provider output formatter.
|
||||
- `prowler/lib/outputs/compliance/generic/` – Reference implementation of a generic output formatter.
|
||||
|
||||
@@ -177,7 +177,6 @@
|
||||
"pages": [
|
||||
"user-guide/cli/tutorials/misc",
|
||||
"user-guide/cli/tutorials/reporting",
|
||||
"user-guide/cli/tutorials/compliance",
|
||||
"user-guide/cli/tutorials/dashboard",
|
||||
"user-guide/cli/tutorials/configuration_file",
|
||||
"user-guide/cli/tutorials/logging",
|
||||
@@ -339,6 +338,7 @@
|
||||
{
|
||||
"group": "Compliance",
|
||||
"pages": [
|
||||
"user-guide/compliance/tutorials/compliance",
|
||||
"user-guide/compliance/tutorials/threatscore"
|
||||
]
|
||||
},
|
||||
@@ -504,6 +504,10 @@
|
||||
}
|
||||
},
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/user-guide/cli/tutorials/compliance",
|
||||
"destination": "/user-guide/compliance/tutorials/compliance"
|
||||
},
|
||||
{
|
||||
"source": "/projects/prowler-open-source/en/latest/tutorials/prowler-app-lighthouse",
|
||||
"destination": "/user-guide/tutorials/prowler-app-lighthouse"
|
||||
|
||||
@@ -121,8 +121,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.25.3"
|
||||
PROWLER_API_VERSION="5.25.3"
|
||||
PROWLER_UI_VERSION="5.26.0"
|
||||
PROWLER_API_VERSION="5.26.0"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 534 KiB |
|
After Width: | Height: | Size: 659 KiB |
|
After Width: | Height: | Size: 759 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 534 KiB |
@@ -1,80 +0,0 @@
|
||||
---
|
||||
title: 'Compliance'
|
||||
---
|
||||
|
||||
Prowler allows you to execute checks based on requirements defined in compliance frameworks. By default, it will execute and give you an overview of the status of each compliance framework:
|
||||
|
||||
<img src="/images/cli/compliance/compliance.png" />
|
||||
|
||||
You can find CSVs containing detailed compliance results in the compliance folder within Prowler's output folder.
|
||||
|
||||
## Execute Prowler based on Compliance Frameworks
|
||||
|
||||
Prowler can analyze your environment based on a specific compliance framework and get more details, to do it, you can use option `--compliance`:
|
||||
|
||||
```sh
|
||||
prowler <provider> --compliance <compliance_framework>
|
||||
```
|
||||
|
||||
Standard results will be shown and additionally the framework information as the sample below for CIS AWS 2.0. For details a CSV file has been generated as well.
|
||||
|
||||
<img src="/images/cli/compliance/compliance-cis-sample1.png" />
|
||||
|
||||
<Note>
|
||||
**If Prowler can't find a resource related with a check from a compliance requirement, this requirement won't appear on the output**
|
||||
</Note>
|
||||
|
||||
## List Available Compliance Frameworks
|
||||
|
||||
To see which compliance frameworks are covered by Prowler, use the `--list-compliance` option:
|
||||
|
||||
```sh
|
||||
prowler <provider> --list-compliance
|
||||
```
|
||||
|
||||
Or you can visit [Prowler Hub](https://hub.prowler.com/compliance).
|
||||
|
||||
## List Requirements of Compliance Frameworks
|
||||
To list requirements for a compliance framework, use the `--list-compliance-requirements` option:
|
||||
|
||||
```sh
|
||||
prowler <provider> --list-compliance-requirements <compliance_framework(s)>
|
||||
```
|
||||
|
||||
Example for the first requirements of CIS 1.5 for AWS:
|
||||
|
||||
```
|
||||
Listing CIS 1.5 AWS Compliance Requirements:
|
||||
|
||||
Requirement Id: 1.1
|
||||
- Description: Maintain current contact details
|
||||
- Checks:
|
||||
account_maintain_current_contact_details
|
||||
|
||||
Requirement Id: 1.2
|
||||
- Description: Ensure security contact information is registered
|
||||
- Checks:
|
||||
account_security_contact_information_is_registered
|
||||
|
||||
Requirement Id: 1.3
|
||||
- Description: Ensure security questions are registered in the AWS account
|
||||
- Checks:
|
||||
account_security_questions_are_registered_in_the_aws_account
|
||||
|
||||
Requirement Id: 1.4
|
||||
- Description: Ensure no 'root' user account access key exists
|
||||
- Checks:
|
||||
iam_no_root_access_key
|
||||
|
||||
Requirement Id: 1.5
|
||||
- Description: Ensure MFA is enabled for the 'root' user account
|
||||
- Checks:
|
||||
iam_root_mfa_enabled
|
||||
|
||||
[redacted]
|
||||
|
||||
```
|
||||
|
||||
## Create and contribute adding other Security Frameworks
|
||||
|
||||
This information is part of the Developer Guide and can be found [here](/developer-guide/security-compliance-framework).
|
||||
@@ -56,6 +56,7 @@ The following list includes all the AWS checks with configurable variables that
|
||||
| `elb_is_in_multiple_az` | `elb_min_azs` | Integer |
|
||||
| `elbv2_is_in_multiple_az` | `elbv2_min_azs` | Integer |
|
||||
| `guardduty_is_enabled` | `mute_non_default_regions` | Boolean |
|
||||
| `iam_user_access_not_stale_to_sagemaker` | `max_unused_sagemaker_access_days` | Integer |
|
||||
| `iam_user_accesskey_unused` | `max_unused_access_keys_days` | Integer |
|
||||
| `iam_user_console_access_unused` | `max_console_access_days` | Integer |
|
||||
| `organizations_delegated_administrators` | `organizations_trusted_delegated_administrators` | List of Strings |
|
||||
@@ -186,6 +187,8 @@ aws:
|
||||
max_unused_access_keys_days: 45
|
||||
# aws.iam_user_console_access_unused --> CIS recommends 45 days
|
||||
max_console_access_days: 45
|
||||
# aws.iam_user_access_not_stale_to_sagemaker --> default 90 days
|
||||
max_unused_sagemaker_access_days: 90
|
||||
|
||||
# AWS EC2 Configuration
|
||||
# aws.ec2_elastic_ip_shodan
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
---
|
||||
title: 'Compliance'
|
||||
description: 'Run security checks against compliance frameworks, review posture across providers, and download CSV or PDF reports from Prowler Cloud, Prowler App, and Prowler CLI.'
|
||||
---
|
||||
|
||||
Prowler maps every security check to one or more industry-standard compliance frameworks, so a single scan produces both technical findings and framework-aligned evidence. The same evaluation runs identically whether scans are launched from Prowler Cloud, Prowler App, or Prowler CLI.
|
||||
|
||||
Out of the box, Prowler covers frameworks such as CIS Benchmarks, NIST 800-53, NIST CSF, NIS2, ENS RD2022, ISO 27001, PCI-DSS, SOC 2, GDPR, HIPAA, AWS Well-Architected, BSI C5, CSA CCM, MITRE ATT&CK, KISA ISMS-P, FedRAMP, and Prowler ThreatScore. The full catalog is available at [Prowler Hub](https://hub.prowler.com/compliance).
|
||||
|
||||
<Note>
|
||||
For the unified compliance score methodology used across frameworks, see [Prowler ThreatScore Documentation](/user-guide/compliance/tutorials/threatscore).
|
||||
</Note>
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Prowler Cloud" icon="cloud" href="#prowler-cloud">
|
||||
Review compliance posture using Prowler Cloud
|
||||
</Card>
|
||||
<Card title="Prowler CLI" icon="terminal" href="#prowler-cli">
|
||||
Run compliance scans using Prowler CLI
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prowler Cloud
|
||||
|
||||
The Compliance section in Prowler Cloud and Prowler App centralizes compliance posture across every connected provider. It aggregates scan results, surfaces Prowler ThreatScore, and exposes detailed requirement-level evidence for each supported framework.
|
||||
|
||||
### Accessing the Compliance Section
|
||||
|
||||
To open the compliance overview, follow these steps:
|
||||
|
||||
1. Sign in to Prowler Cloud at [cloud.prowler.com](https://cloud.prowler.com/sign-in) or to a self-hosted Prowler App instance.
|
||||
2. Select **Compliance** from the left navigation.
|
||||
|
||||
The page lists every framework evaluated by the most recent completed scan of the selected provider.
|
||||
|
||||
<img src="/images/compliance/prowler-app-compliance-overview.png" alt="Compliance overview page in Prowler Cloud and App showing filters, the Prowler ThreatScore card, and the framework grid" width="900" />
|
||||
|
||||
<Note>
|
||||
Compliance results require at least one completed scan. If no scan has finished yet, Prowler Cloud and App display a notice prompting to launch or wait for a scan to complete.
|
||||
</Note>
|
||||
|
||||
### Filtering Compliance Results
|
||||
|
||||
The filters bar at the top of the overview controls which scan and which regions feed every card on the page.
|
||||
|
||||
#### Scan Selector
|
||||
|
||||
The scan selector lists completed scans across all connected providers. Each entry includes the provider type, alias, and completion timestamp. Selecting a scan updates the entire page, including ThreatScore and every framework card.
|
||||
|
||||
#### Region Filter
|
||||
|
||||
The region multi-select narrows results to one or more regions detected in the selected scan. Use it to evaluate compliance posture for a specific geography or account boundary. The filter applies to:
|
||||
|
||||
* The framework grid scores and pass/fail counts.
|
||||
* The detailed requirement view inside each framework.
|
||||
|
||||
<Note>
|
||||
Region filters apply only to providers that report a region attribute (for example, AWS, Azure, and Google Cloud). Providers without regions ignore the filter.
|
||||
</Note>
|
||||
|
||||
#### Clearing Filters
|
||||
|
||||
Select **Clear filters** to reset both the region filter and any other applied filter to its default state. The scan selector is preserved.
|
||||
|
||||
### Reviewing the Prowler ThreatScore Card
|
||||
|
||||
When the selected scan includes Prowler ThreatScore data, a dedicated card appears at the top of the overview, showing:
|
||||
|
||||
* The overall ThreatScore (0–100) with a color-coded indicator.
|
||||
* A progress bar reflecting current posture.
|
||||
* Per-pillar bars for IAM, Attack Surface, and Logging and Monitoring.
|
||||
|
||||
<img src="/images/compliance/prowler-app-compliance-threatscore-card.png" alt="Prowler ThreatScore badge on the Compliance overview showing the overall score and per-pillar bars" width="900" />
|
||||
|
||||
Selecting the card opens the ThreatScore framework detail page, covered in [Working With the Framework Detail Page](#working-with-the-framework-detail-page).
|
||||
|
||||
For a complete explanation of the methodology, formula, and weighting, see [Prowler ThreatScore Documentation](/user-guide/compliance/tutorials/threatscore).
|
||||
|
||||
### Exploring the Framework Grid
|
||||
|
||||
Below ThreatScore, the framework grid shows one card per supported compliance framework. Each card includes:
|
||||
|
||||
* **Framework logo and name:** Identifies the standard (CIS, NIST, ENS, ISO 27001, PCI-DSS, SOC 2, NIS2, CSA CCM, MITRE ATT&CK, and more).
|
||||
* **Version:** Indicates the framework version applied to the scan.
|
||||
* **Score:** The percentage of passing requirements over the total evaluated.
|
||||
* **Passing Requirements:** A `passed / total` counter for additional context.
|
||||
* **Download dropdown:** Quick access to the CSV report and, when supported, the PDF report.
|
||||
|
||||
<img src="/images/compliance/prowler-app-compliance-card-download.png" alt="Download dropdown on a framework card showing CSV and PDF report options" width="500" />
|
||||
|
||||
Select any card to open the framework detail page.
|
||||
|
||||
<Note>
|
||||
Score color coding follows three thresholds: red for severely low compliance, amber for partial compliance, and green for healthy posture. Hover over the score for the exact percentage.
|
||||
</Note>
|
||||
|
||||
### Working With the Framework Detail Page
|
||||
|
||||
The detail page provides everything needed to evaluate a single framework: aggregate metrics, top failure sections, and a requirement-by-requirement view.
|
||||
|
||||
#### Header, Summary Cards, and Download Actions
|
||||
|
||||
The header shows the framework name, version, the provider scan being reviewed, and CSV / PDF download buttons. Below the header, summary cards condense the framework state at a glance:
|
||||
|
||||
* **Requirements Status:** Donut chart with `Pass`, `Fail`, and `Manual` counts plus the total number of requirements.
|
||||
* **Top Failed Sections:** Ranks the sections or pillars with the highest number of failing requirements.
|
||||
* **ThreatScore Breakdown:** Appears only on the ThreatScore framework. It shows the overall score and per-pillar scores aligned with the ThreatScore pillars (IAM, Attack Surface, Logging and Monitoring, Encryption).
|
||||
|
||||
The same layout applies to every compliance framework. ThreatScore is the only framework that includes the extra Breakdown card on the left; for any other framework, the Requirements Status and Top Failed Sections cards span the full row.
|
||||
|
||||
<img src="/images/compliance/prowler-app-compliance-threatscore-detail.png" alt="Prowler ThreatScore detail page including the extra Breakdown card alongside Requirements Status and Top Failed Sections" width="900" />
|
||||
|
||||
<img src="/images/compliance/prowler-app-compliance-detail-header.png" alt="CIS framework detail page showing only the Requirements Status donut and the Top Failed Sections card, without the ThreatScore Breakdown" width="900" />
|
||||
|
||||
#### Requirements Accordion
|
||||
|
||||
Below the summary cards, an accordion organizes every requirement of the framework. Expand a section to see:
|
||||
|
||||
* **Requirement ID and title:** Reflect the official identifier from the framework.
|
||||
* **Pass / Fail / Manual badges:** Indicate the status of each requirement based on the underlying checks.
|
||||
* **Custom details panel:** Opens additional context tailored to the framework. For frameworks with custom layouts, the panel surfaces fields such as control objectives, severity, attack tactics, regulatory references, or required evidence.
|
||||
|
||||
Select a requirement to open the detail panel and review the failing checks, the resources affected, and remediation guidance.
|
||||
|
||||
<img src="/images/compliance/prowler-app-compliance-requirements-accordion.png" alt="Expanded CIS requirement showing description, rationale, remediation procedure, audit procedure, profile and assessment tags, references, and the underlying check" width="900" />
|
||||
|
||||
##### Frameworks With Custom Detail Layouts
|
||||
|
||||
Several frameworks include enriched detail panels that highlight fields specific to the standard:
|
||||
|
||||
* ASD Essential Eight
|
||||
* AWS Well-Architected Framework
|
||||
* BSI C5
|
||||
* Cloud Controls Matrix (CSA CCM)
|
||||
* CIS Benchmarks
|
||||
* CCC (Common Cloud Controls)
|
||||
* ENS RD2022
|
||||
* ISO 27001
|
||||
* KISA ISMS-P
|
||||
* MITRE ATT&CK
|
||||
* Prowler ThreatScore
|
||||
|
||||
Frameworks without a custom layout fall back to the generic details panel, which still exposes the official requirement metadata captured by Prowler.
|
||||
|
||||
### Downloading Compliance Reports
|
||||
|
||||
Prowler Cloud and App expose two formats:
|
||||
|
||||
* **CSV report:** Every requirement, every check, and every finding for the selected scan and filters. Available for all supported frameworks.
|
||||
* **PDF report:** Curated executive-style report. Currently supported for Prowler ThreatScore, ENS RD2022, NIS2, and CSA CCM. Additional PDF reports are added in subsequent Prowler releases.
|
||||
|
||||
#### Downloading From the Detail Page
|
||||
|
||||
Inside any framework detail page, the **CSV** and **PDF** buttons in the header trigger the same downloads as the overview dropdown. The PDF button only appears for frameworks that support it.
|
||||
|
||||
<img src="/images/compliance/prowler-app-compliance-detail-download.png" alt="Top of a framework detail page showing the CSV and PDF download buttons in the header" width="900" />
|
||||
|
||||
<Note>
|
||||
Region filters disable the per-card download dropdown to avoid generating partial reports. Open the framework detail page when downloads scoped to a region are required, or remove the region filter to download the full report.
|
||||
</Note>
|
||||
|
||||
#### Downloading the Full Scan Output
|
||||
|
||||
To export every framework, finding, and resource at once, use the **Scan Jobs** section instead. The ZIP archive contains the CSV, JSON-OCSF, and HTML reports plus a `compliance/` subfolder with one CSV per framework. See [Prowler App — Getting Started](/user-guide/tutorials/prowler-app) for details.
|
||||
|
||||
### API Access
|
||||
|
||||
Every report available in the UI is also reachable through the Prowler API. The following endpoints are the most relevant:
|
||||
|
||||
* [Retrieve a scan compliance report as CSV](https://api.prowler.com/api/v1/docs#tag/Scan/operation/scans_compliance_retrieve)
|
||||
* [Download a complete scan output (ZIP)](https://api.prowler.com/api/v1/docs#tag/Scan/operation/scans_report_retrieve)
|
||||
|
||||
Use the API to integrate compliance evidence into ticketing systems, executive dashboards, or downstream pipelines.
|
||||
|
||||
## Prowler CLI
|
||||
|
||||
Prowler CLI evaluates the same compliance frameworks as Prowler Cloud and App, and produces detailed CSV outputs alongside the standard scan results. By default, it runs every supported framework and prints a status summary at the end of the scan:
|
||||
|
||||
<img src="/images/cli/compliance/compliance.png" />
|
||||
|
||||
Detailed compliance results are stored as CSV files under the `compliance/` subfolder of Prowler's output directory.
|
||||
|
||||
### Scan a Specific Compliance Framework
|
||||
|
||||
To scope a scan to a single framework and get the framework-specific summary, use the `--compliance` option:
|
||||
|
||||
```sh
|
||||
prowler <provider> --compliance <compliance_framework>
|
||||
```
|
||||
|
||||
Standard results plus the framework breakdown are printed to the terminal. A dedicated CSV is also generated under the `compliance/` output folder. Sample output for CIS AWS 2.0:
|
||||
|
||||
<img src="/images/cli/compliance/compliance-cis-sample1.png" />
|
||||
|
||||
<Note>
|
||||
If Prowler cannot find a resource related with a check from a compliance requirement, that requirement is omitted from the output.
|
||||
</Note>
|
||||
|
||||
### List Available Compliance Frameworks
|
||||
|
||||
To see which compliance frameworks are covered by a given provider, use the `--list-compliance` option:
|
||||
|
||||
```sh
|
||||
prowler <provider> --list-compliance
|
||||
```
|
||||
|
||||
The full catalog is also browsable at [Prowler Hub](https://hub.prowler.com/compliance).
|
||||
|
||||
### List Requirements of a Compliance Framework
|
||||
|
||||
To inspect the requirements that compose a specific framework, use the `--list-compliance-requirements` option:
|
||||
|
||||
```sh
|
||||
prowler <provider> --list-compliance-requirements <compliance_framework(s)>
|
||||
```
|
||||
|
||||
Sample output for the first requirements of CIS 1.5 for AWS:
|
||||
|
||||
```
|
||||
Listing CIS 1.5 AWS Compliance Requirements:
|
||||
|
||||
Requirement Id: 1.1
|
||||
- Description: Maintain current contact details
|
||||
- Checks:
|
||||
account_maintain_current_contact_details
|
||||
|
||||
Requirement Id: 1.2
|
||||
- Description: Ensure security contact information is registered
|
||||
- Checks:
|
||||
account_security_contact_information_is_registered
|
||||
|
||||
Requirement Id: 1.3
|
||||
- Description: Ensure security questions are registered in the AWS account
|
||||
- Checks:
|
||||
account_security_questions_are_registered_in_the_aws_account
|
||||
|
||||
Requirement Id: 1.4
|
||||
- Description: Ensure no 'root' user account access key exists
|
||||
- Checks:
|
||||
iam_no_root_access_key
|
||||
|
||||
Requirement Id: 1.5
|
||||
- Description: Ensure MFA is enabled for the 'root' user account
|
||||
- Checks:
|
||||
iam_root_mfa_enabled
|
||||
|
||||
[redacted]
|
||||
|
||||
```
|
||||
|
||||
## Contributing New Compliance Frameworks
|
||||
|
||||
To request a new framework or contribute one, see [Creating a New Security Compliance Framework in Prowler](/developer-guide/security-compliance-framework). The developer guide covers the Pydantic schema, JSON catalog, output formatter, and PR submission steps required to ship a new framework end to end.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
* [Prowler ThreatScore Documentation](/user-guide/compliance/tutorials/threatscore)
|
||||
* [Creating a New Security Compliance Framework in Prowler](/developer-guide/security-compliance-framework)
|
||||
* [Prowler App — Getting Started](/user-guide/tutorials/prowler-app)
|
||||
@@ -4,7 +4,7 @@ title: 'Check Mapping Prowler v4/v3 to v2'
|
||||
|
||||
Prowler v3 and v4 introduce distinct identifiers while preserving the checks originally implemented in v2. This change was made because, in previous versions, check names were primarily derived from the CIS Benchmark for AWS. Starting with v3 and v4, all checks are independent of any security framework and have unique names and IDs.
|
||||
|
||||
For more details on the updated compliance implementation in Prowler v4 and v3, refer to the [Compliance](/user-guide/cli/tutorials/compliance) section.
|
||||
For more details on the updated compliance implementation in Prowler v4 and v3, refer to the [Compliance](/user-guide/compliance/tutorials/compliance) section.
|
||||
|
||||
```
|
||||
checks_v4_v3_to_v2_mapping = {
|
||||
|
||||
@@ -398,7 +398,7 @@ prowler oci --severity critical high
|
||||
|
||||
### Next Steps
|
||||
|
||||
- Learn about [Compliance Frameworks](/user-guide/cli/tutorials/compliance) in Prowler
|
||||
- Learn about [Compliance Frameworks](/user-guide/compliance/tutorials/compliance) in Prowler
|
||||
- Review [Prowler Output Formats](/user-guide/cli/tutorials/reporting)
|
||||
- Explore [Integrations](/user-guide/cli/tutorials/integrations) with SIEM and ticketing systems
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Prowler v3 and v4 introduce distinct identifiers while preserving the checks originally implemented in v2. This change was made because, in previous versions, check names were primarily derived from the CIS Benchmark for AWS. Starting with v3 and v4, all checks are independent of any security framework and have unique names and IDs.
|
||||
|
||||
For more details on the updated compliance implementation in Prowler v4 and v3, refer to the [Compliance](/user-guide/cli/tutorials/compliance) section.
|
||||
For more details on the updated compliance implementation in Prowler v4 and v3, refer to the [Compliance](/user-guide/compliance/tutorials/compliance) section.
|
||||
|
||||
```
|
||||
checks_v4_v3_to_v2_mapping = {
|
||||
|
||||
@@ -2,6 +2,28 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [5.27.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `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)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- `entra_emergency_access_exclusion` check for M365 provider now scopes the exclusion requirement to enabled Conditional Access policies with a `Block` grant control instead of every enabled policy, focusing on the lockout-relevant policy set [(#10849)](https://github.com/prowler-cloud/prowler/pull/10849)
|
||||
|
||||
---
|
||||
|
||||
## [5.26.1] (Prowler v5.26.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `entra_users_mfa_capable` no longer flags disabled guest users by requesting `accountEnabled` and `userType` from Microsoft Graph via `$select` and using Graph as the source of truth for `account_enabled` (EXO `Get-User` does not return guest users) [(#11002)](https://github.com/prowler-cloud/prowler/pull/11002)
|
||||
|
||||
---
|
||||
|
||||
## [5.26.0] (Prowler v5.26.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -550,6 +550,7 @@
|
||||
"apigatewayv2_api_access_logging_enabled",
|
||||
"awslambda_function_invoke_api_operations_cloudtrail_logging_enabled",
|
||||
"cloudfront_distributions_logging_enabled",
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_cloudwatch_logging_enabled",
|
||||
"cloudtrail_logs_s3_bucket_access_logging_enabled",
|
||||
"directoryservice_directory_log_forwarding_enabled",
|
||||
|
||||
@@ -3461,6 +3461,7 @@
|
||||
],
|
||||
"Checks": [
|
||||
"kinesis_stream_data_retention_period",
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_multi_region_enabled_logging_management_events"
|
||||
]
|
||||
},
|
||||
@@ -3669,6 +3670,7 @@
|
||||
"awslambda_function_invoke_api_operations_cloudtrail_logging_enabled",
|
||||
"bedrock_model_invocation_logging_enabled",
|
||||
"cloudfront_distributions_logging_enabled",
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_cloudwatch_logging_enabled",
|
||||
"cloudtrail_logs_s3_bucket_access_logging_enabled",
|
||||
"cloudtrail_multi_region_enabled_logging_management_events",
|
||||
@@ -5288,6 +5290,7 @@
|
||||
"cognito_user_pool_blocks_compromised_credentials_sign_in_attempts",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"secretsmanager_secret_unused"
|
||||
@@ -6359,6 +6362,7 @@
|
||||
"iam_rotate_access_key_90_days",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_administrator_access_policy",
|
||||
"iam_user_console_access_unused",
|
||||
|
||||
@@ -1958,6 +1958,7 @@
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_multi_region_enabled",
|
||||
"cloudtrail_multi_region_enabled_logging_management_events",
|
||||
"cloudtrail_cloudwatch_logging_enabled",
|
||||
|
||||
@@ -3101,6 +3101,7 @@
|
||||
"Checks": [
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"iam_user_two_active_access_key"
|
||||
@@ -3443,6 +3444,7 @@
|
||||
"Checks": [
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"iam_user_no_setup_initial_access_key"
|
||||
@@ -3552,6 +3554,7 @@
|
||||
"Checks": [
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"iam_rotate_access_key_90_days",
|
||||
@@ -5854,6 +5857,7 @@
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_multi_region_enabled",
|
||||
"cloudtrail_multi_region_enabled_logging_management_events",
|
||||
"cloudtrail_s3_dataevents_read_enabled",
|
||||
|
||||
@@ -544,6 +544,7 @@
|
||||
"Checks": [
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
]
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
"iam_rotate_access_key_90_days",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"iam_user_hardware_mfa_enabled",
|
||||
@@ -325,6 +326,7 @@
|
||||
"iam_rotate_access_key_90_days",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"organizations_delegated_administrators"
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"iam_user_hardware_mfa_enabled",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"rds_instance_integration_cloudwatch_logs",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"iam_user_mfa_enabled_console_access",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"securityhub_enabled"
|
||||
@@ -109,6 +110,7 @@
|
||||
"iam_user_mfa_enabled_console_access",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
]
|
||||
@@ -165,6 +167,7 @@
|
||||
"iam_user_mfa_enabled_console_access",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
]
|
||||
@@ -185,6 +188,7 @@
|
||||
"iam_password_policy_minimum_length_14",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
]
|
||||
@@ -320,6 +324,7 @@
|
||||
"iam_no_root_access_key",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"awslambda_function_not_publicly_accessible",
|
||||
@@ -434,6 +439,7 @@
|
||||
"cloudtrail_s3_dataevents_read_enabled",
|
||||
"cloudtrail_s3_dataevents_write_enabled",
|
||||
"cloudtrail_multi_region_enabled",
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_cloudwatch_logging_enabled",
|
||||
"elbv2_logging_enabled",
|
||||
"elb_logging_enabled",
|
||||
@@ -589,6 +595,7 @@
|
||||
"cloudtrail_s3_dataevents_read_enabled",
|
||||
"cloudtrail_s3_dataevents_write_enabled",
|
||||
"cloudtrail_multi_region_enabled",
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_cloudwatch_logging_enabled",
|
||||
"elbv2_logging_enabled",
|
||||
"elb_logging_enabled",
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
],
|
||||
"Checks": [
|
||||
"apigateway_restapi_logging_enabled",
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_multi_region_enabled",
|
||||
"cloudtrail_s3_dataevents_read_enabled",
|
||||
"cloudtrail_s3_dataevents_write_enabled",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
],
|
||||
"Checks": [
|
||||
"apigateway_restapi_logging_enabled",
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_multi_region_enabled",
|
||||
"cloudtrail_s3_dataevents_read_enabled",
|
||||
"cloudtrail_s3_dataevents_write_enabled",
|
||||
@@ -632,6 +633,7 @@
|
||||
],
|
||||
"Checks": [
|
||||
"apigateway_restapi_logging_enabled",
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_multi_region_enabled",
|
||||
"cloudtrail_s3_dataevents_read_enabled",
|
||||
"cloudtrail_s3_dataevents_write_enabled",
|
||||
|
||||
@@ -869,6 +869,7 @@
|
||||
"Checks": [
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
]
|
||||
|
||||
@@ -247,6 +247,7 @@
|
||||
"iam_root_mfa_enabled",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_rotate_access_key_90_days",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
@@ -1293,6 +1294,7 @@
|
||||
"bedrock_model_invocation_logging_enabled",
|
||||
"bedrock_model_invocation_logs_encryption_enabled",
|
||||
"cloudfront_distributions_logging_enabled",
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_cloudwatch_logging_enabled",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
"cloudtrail_log_file_validation_enabled",
|
||||
|
||||
@@ -2540,6 +2540,7 @@
|
||||
"bedrock_model_invocation_logging_enabled",
|
||||
"bedrock_model_invocation_logs_encryption_enabled",
|
||||
"cloudfront_distributions_logging_enabled",
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_bucket_requires_mfa_delete",
|
||||
"cloudtrail_cloudwatch_logging_enabled",
|
||||
"cloudtrail_insights_exist",
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"iam_no_expired_server_certificates_stored",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"iam_no_root_access_key",
|
||||
|
||||
@@ -1913,6 +1913,7 @@
|
||||
"Checks": [
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
],
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"iam_user_mfa_enabled_console_access",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"awslambda_function_not_publicly_accessible",
|
||||
@@ -76,6 +77,7 @@
|
||||
"iam_user_mfa_enabled_console_access",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"awslambda_function_not_publicly_accessible",
|
||||
@@ -164,6 +166,7 @@
|
||||
"iam_no_root_access_key",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
]
|
||||
@@ -589,6 +592,7 @@
|
||||
"iam_password_policy_expires_passwords_within_90_days_or_less",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
]
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"iam_rotate_access_key_90_days",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"securityhub_enabled"
|
||||
@@ -43,6 +44,7 @@
|
||||
"Checks": [
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
]
|
||||
@@ -116,6 +118,7 @@
|
||||
"iam_rotate_access_key_90_days",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"rds_instance_integration_cloudwatch_logs",
|
||||
@@ -240,6 +243,7 @@
|
||||
"iam_no_root_access_key",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"awslambda_function_url_public",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"iam_user_mfa_enabled_console_access",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"secretsmanager_automatic_rotation_enabled"
|
||||
@@ -53,6 +54,7 @@
|
||||
"iam_password_policy_minimum_length_14",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
]
|
||||
@@ -74,6 +76,7 @@
|
||||
"iam_password_policy_minimum_length_14",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
]
|
||||
@@ -95,6 +98,7 @@
|
||||
"iam_password_policy_minimum_length_14",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
]
|
||||
@@ -116,6 +120,7 @@
|
||||
"iam_password_policy_minimum_length_14",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
]
|
||||
@@ -136,6 +141,7 @@
|
||||
"iam_password_policy_minimum_length_14",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
]
|
||||
@@ -247,6 +253,7 @@
|
||||
"Checks": [
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
]
|
||||
@@ -285,6 +292,7 @@
|
||||
"Checks": [
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
]
|
||||
@@ -861,6 +869,7 @@
|
||||
"iam_user_mfa_enabled_console_access",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"secretsmanager_automatic_rotation_enabled"
|
||||
@@ -1199,6 +1208,7 @@
|
||||
"iam_no_root_access_key",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"awslambda_function_not_publicly_accessible",
|
||||
@@ -1594,6 +1604,7 @@
|
||||
"cloudtrail_s3_dataevents_read_enabled",
|
||||
"cloudtrail_s3_dataevents_write_enabled",
|
||||
"cloudtrail_multi_region_enabled",
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_cloudwatch_logging_enabled",
|
||||
"elbv2_logging_enabled",
|
||||
"elb_logging_enabled",
|
||||
@@ -2152,6 +2163,7 @@
|
||||
"cloudtrail_s3_dataevents_read_enabled",
|
||||
"cloudtrail_s3_dataevents_write_enabled",
|
||||
"cloudtrail_multi_region_enabled",
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_cloudwatch_logging_enabled",
|
||||
"elbv2_logging_enabled",
|
||||
"elb_logging_enabled",
|
||||
@@ -2179,6 +2191,7 @@
|
||||
"cloudtrail_s3_dataevents_read_enabled",
|
||||
"cloudtrail_s3_dataevents_write_enabled",
|
||||
"cloudtrail_multi_region_enabled",
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_cloudwatch_logging_enabled",
|
||||
"elbv2_logging_enabled",
|
||||
"elb_logging_enabled",
|
||||
|
||||
@@ -577,6 +577,7 @@
|
||||
"iam_rotate_access_key_90_days",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"secretsmanager_automatic_rotation_enabled"
|
||||
@@ -638,6 +639,7 @@
|
||||
"iam_no_root_access_key",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
]
|
||||
|
||||
@@ -707,6 +707,7 @@
|
||||
"iam_user_console_access_unused",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_two_active_access_key",
|
||||
"iam_root_credentials_management_enabled",
|
||||
@@ -1311,6 +1312,7 @@
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
"cloudtrail_log_file_validation_enabled",
|
||||
"cloudtrail_logs_s3_bucket_access_logging_enabled",
|
||||
@@ -1474,6 +1476,7 @@
|
||||
"cloudtrail_threat_detection_enumeration",
|
||||
"cloudtrail_threat_detection_privilege_escalation",
|
||||
"cloudtrail_threat_detection_llm_jacking",
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_cloudwatch_logging_enabled",
|
||||
"cloudtrail_multi_region_enabled_logging_management_events"
|
||||
]
|
||||
@@ -1570,6 +1573,7 @@
|
||||
"cloudtrail_threat_detection_llm_jacking",
|
||||
"cloudtrail_threat_detection_enumeration",
|
||||
"cloudtrail_multi_region_enabled_logging_management_events",
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_cloudwatch_logging_enabled",
|
||||
"cloudwatch_log_metric_filter_unauthorized_api_calls",
|
||||
"cloudwatch_log_metric_filter_authentication_failures",
|
||||
|
||||
@@ -1563,6 +1563,7 @@
|
||||
"Checks": [
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_password_policy_reuse_24",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused"
|
||||
|
||||
@@ -295,6 +295,7 @@
|
||||
"Checks": [
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"iam_no_expired_server_certificates_stored"
|
||||
@@ -340,6 +341,7 @@
|
||||
"iam_rotate_access_key_90_days",
|
||||
"iam_role_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_bedrock",
|
||||
"iam_user_access_not_stale_to_sagemaker",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"accessanalyzer_enabled_without_findings"
|
||||
@@ -816,6 +818,7 @@
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_multi_region_enabled",
|
||||
"cloudtrail_multi_region_enabled_logging_management_events",
|
||||
"cloudtrail_s3_dataevents_read_enabled",
|
||||
|
||||
@@ -346,6 +346,7 @@
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"cloudtrail_bedrock_logging_enabled",
|
||||
"cloudtrail_cloudwatch_logging_enabled",
|
||||
"cloudwatch_changes_to_network_acls_alarm_configured",
|
||||
"cloudwatch_changes_to_network_gateways_alarm_configured",
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
"entra_break_glass_account_fido2_security_key_registered",
|
||||
"entra_conditional_access_policy_mfa_enforced_for_guest_users",
|
||||
"entra_default_app_management_policy_enabled",
|
||||
"entra_emergency_access_exclusion",
|
||||
"entra_all_apps_conditional_access_coverage",
|
||||
"entra_conditional_access_policy_device_registration_mfa_required",
|
||||
"entra_intune_enrollment_sign_in_frequency_every_time",
|
||||
@@ -260,6 +261,7 @@
|
||||
"entra_legacy_authentication_blocked",
|
||||
"entra_managed_device_required_for_authentication",
|
||||
"entra_seamless_sso_disabled",
|
||||
"entra_service_principal_no_secrets_for_permanent_tier0_roles",
|
||||
"entra_users_mfa_enabled",
|
||||
"exchange_organization_modern_authentication_enabled",
|
||||
"exchange_transport_config_smtp_auth_disabled",
|
||||
@@ -282,6 +284,7 @@
|
||||
"entra_admin_portals_access_restriction",
|
||||
"entra_app_registration_no_unused_privileged_permissions",
|
||||
"entra_policy_guest_users_access_restrictions",
|
||||
"entra_service_principal_no_secrets_for_permanent_tier0_roles",
|
||||
"sharepoint_external_sharing_managed",
|
||||
"sharepoint_external_sharing_restricted",
|
||||
"sharepoint_guest_sharing_restricted"
|
||||
@@ -671,10 +674,12 @@
|
||||
"entra_admin_users_phishing_resistant_mfa_enabled",
|
||||
"entra_admin_users_sign_in_frequency_enabled",
|
||||
"entra_break_glass_account_fido2_security_key_registered",
|
||||
"entra_emergency_access_exclusion",
|
||||
"entra_app_registration_no_unused_privileged_permissions",
|
||||
"entra_policy_ensure_default_user_cannot_create_tenants",
|
||||
"entra_policy_guest_invite_only_for_admin_roles",
|
||||
"entra_seamless_sso_disabled"
|
||||
"entra_seamless_sso_disabled",
|
||||
"entra_service_principal_no_secrets_for_permanent_tier0_roles"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -727,9 +732,11 @@
|
||||
"entra_conditional_access_policy_device_code_flow_blocked",
|
||||
"entra_conditional_access_policy_directory_sync_account_excluded",
|
||||
"entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced",
|
||||
"entra_emergency_access_exclusion",
|
||||
"entra_identity_protection_sign_in_risk_enabled",
|
||||
"entra_managed_device_required_for_authentication",
|
||||
"entra_seamless_sso_disabled",
|
||||
"entra_service_principal_no_secrets_for_permanent_tier0_roles",
|
||||
"entra_users_mfa_enabled"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -48,7 +48,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.26.0"
|
||||
prowler_version = "5.27.0"
|
||||
html_logo_url = "https://github.com/prowler-cloud/prowler/"
|
||||
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
|
||||
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
|
||||
|
||||
@@ -26,6 +26,8 @@ aws:
|
||||
max_unused_access_keys_days: 45
|
||||
# aws.iam_user_console_access_unused --> CIS recommends 45 days
|
||||
max_console_access_days: 45
|
||||
# aws.iam_user_access_not_stale_to_sagemaker --> default 90 days
|
||||
max_unused_sagemaker_access_days: 90
|
||||
|
||||
# AWS EC2 Configuration
|
||||
# aws.ec2_elastic_ip_shodan
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "cloudtrail_bedrock_logging_enabled",
|
||||
"CheckTitle": "CloudTrail logs Amazon Bedrock API calls for security auditing",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
],
|
||||
"ServiceName": "cloudtrail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsCloudTrailTrail",
|
||||
"ResourceGroup": "monitoring",
|
||||
"Description": "**At least one actively logging CloudTrail trail** records **Amazon Bedrock API activity** through management events or advanced event selectors targeting Bedrock resources.\n\nThis check covers **control-plane** operations such as configuration changes through CloudTrail management events and can also cover **data-plane** Bedrock events when advanced event selectors target Bedrock resource types.",
|
||||
"Risk": "Without CloudTrail logging for Bedrock control-plane operations, changes to prompts, guardrails, agents, flows, or knowledge bases can become invisible, weakening forensics and incident response. Management events do not capture `InvokeModel`; pair this control with `bedrock_model_invocation_logging_enabled` or Bedrock data event selectors for invocation visibility.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/bedrock/latest/userguide/logging-using-cloudtrail.html",
|
||||
"https://docs.aws.amazon.com/awscloudtrail/latest/userguide/logging-data-events-with-cloudtrail.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws cloudtrail put-event-selectors --trail-name <example_resource_name> --advanced-event-selectors '[{\"Name\":\"Bedrock data events\",\"FieldSelectors\":[{\"Field\":\"eventCategory\",\"Equals\":[\"Data\"]},{\"Field\":\"resources.type\",\"Equals\":[\"AWS::Bedrock::Model\",\"AWS::Bedrock::Guardrail\",\"AWS::Bedrock::AgentAlias\",\"AWS::Bedrock::FlowAlias\",\"AWS::Bedrock::InlineAgent\",\"AWS::Bedrock::KnowledgeBase\",\"AWS::Bedrock::Prompt\"]}]}]'",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: enable Bedrock data event logging on an actively logging trail\nResources:\n ExampleTrail:\n Type: AWS::CloudTrail::Trail\n Properties:\n TrailName: <example_resource_name>\n S3BucketName: <example_resource_name>\n IsLogging: true\n AdvancedEventSelectors:\n - Name: Bedrock data events\n FieldSelectors:\n - Field: eventCategory\n Equals:\n - Data\n - Field: resources.type # CRITICAL: target Bedrock resources\n Equals:\n - AWS::Bedrock::Model\n - AWS::Bedrock::Guardrail\n - AWS::Bedrock::AgentAlias\n - AWS::Bedrock::FlowAlias\n - AWS::Bedrock::InlineAgent\n - AWS::Bedrock::KnowledgeBase\n - AWS::Bedrock::Prompt\n```",
|
||||
"Other": "1. In the AWS Console, open CloudTrail and select a trail that is actively logging\n2. Edit the trail and enable Management events to capture Bedrock control-plane operations, or add Bedrock advanced data event selectors for data-plane visibility\n3. If using data events, select the Bedrock resource types you want to log\n4. Save changes and confirm the trail remains in logging state",
|
||||
"Terraform": "```hcl\n# Terraform: enable Bedrock data event logging on an actively logging trail\nresource \"aws_cloudtrail\" \"example_resource\" {\n name = \"example_resource\"\n s3_bucket_name = \"example_resource\"\n\n advanced_event_selector {\n name = \"Bedrock data events\"\n field_selector {\n field = \"eventCategory\"\n equals = [\"Data\"]\n }\n field_selector {\n field = \"resources.type\" # CRITICAL: target Bedrock resources\n equals = [\"AWS::Bedrock::Model\", \"AWS::Bedrock::Guardrail\", \"AWS::Bedrock::AgentAlias\", \"AWS::Bedrock::FlowAlias\", \"AWS::Bedrock::InlineAgent\", \"AWS::Bedrock::KnowledgeBase\", \"AWS::Bedrock::Prompt\"]\n }\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable CloudTrail logging for Amazon Bedrock on **at least one actively logging trail**. At minimum, enable **management events** to capture Bedrock control-plane operations. For invocation-level and other data-plane visibility, add **advanced event selectors** targeting Bedrock resource types or pair this control with `bedrock_model_invocation_logging_enabled`.\n\nFor broader region coverage, pair this control with a separate multi-region CloudTrail check. Centralize logs in an encrypted bucket or CloudWatch Logs to support **defense in depth** and forensic readiness for AI workloads.",
|
||||
"Url": "https://hub.prowler.com/check/cloudtrail_bedrock_logging_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"logging",
|
||||
"forensics-ready",
|
||||
"gen-ai"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"cloudtrail_multi_region_enabled_logging_management_events",
|
||||
"bedrock_model_invocation_logging_enabled"
|
||||
],
|
||||
"Notes": "This check passes when CloudTrail captures Bedrock control-plane activity via management events or Bedrock data events via advanced selectors. It does not require multi-region coverage, and it does not by itself guarantee `InvokeModel` visibility unless Bedrock data events are selected; use `bedrock_model_invocation_logging_enabled` for model invocation logs. Additional advanced selector filters such as `eventName` or `resources.ARN` can further narrow effective coverage and should be reviewed explicitly."
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.cloudtrail.cloudtrail_client import (
|
||||
cloudtrail_client,
|
||||
)
|
||||
from prowler.providers.aws.services.cloudtrail.cloudtrail_service import (
|
||||
Event_Selector,
|
||||
)
|
||||
|
||||
|
||||
class cloudtrail_bedrock_logging_enabled(Check):
|
||||
"""Ensure CloudTrail is configured to log Amazon Bedrock API calls.
|
||||
|
||||
This check verifies whether at least one CloudTrail trail is configured to
|
||||
capture Amazon Bedrock control-plane API calls through management events or
|
||||
Bedrock data events through advanced event selectors.
|
||||
|
||||
- PASS: A trail logs Bedrock control-plane API calls via management events
|
||||
or Bedrock data events via Bedrock-specific advanced event selectors.
|
||||
- FAIL: No CloudTrail trail is configured to log Bedrock API calls.
|
||||
"""
|
||||
|
||||
# Bedrock resource types supported by CloudTrail advanced event selectors.
|
||||
BEDROCK_RESOURCE_TYPES = frozenset(
|
||||
{
|
||||
"AWS::Bedrock::AgentAlias",
|
||||
"AWS::Bedrock::FlowAlias",
|
||||
"AWS::Bedrock::Guardrail",
|
||||
"AWS::Bedrock::InlineAgent",
|
||||
"AWS::Bedrock::KnowledgeBase",
|
||||
"AWS::Bedrock::Model",
|
||||
"AWS::Bedrock::Prompt",
|
||||
}
|
||||
)
|
||||
# Bedrock control-plane event sources, including Bedrock Data Automation.
|
||||
BEDROCK_EVENT_SOURCES = frozenset(
|
||||
{
|
||||
"bedrock.amazonaws.com",
|
||||
"bedrock-agent.amazonaws.com",
|
||||
"bedrock-runtime.amazonaws.com",
|
||||
"bedrock-agent-runtime.amazonaws.com",
|
||||
"bedrock-data-automation.amazonaws.com",
|
||||
"bedrock-data-automation-runtime.amazonaws.com",
|
||||
}
|
||||
)
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the check logic.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
if cloudtrail_client.trails is not None:
|
||||
for trail in cloudtrail_client.trails.values():
|
||||
if trail.is_logging:
|
||||
for data_event in trail.data_events:
|
||||
match_type = self._get_bedrock_match_type(data_event)
|
||||
if match_type:
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=trail
|
||||
)
|
||||
report.region = trail.home_region
|
||||
report.status = "PASS"
|
||||
if match_type == "classic_management":
|
||||
report.status_extended = (
|
||||
f"Trail {trail.name} from home region "
|
||||
f"{trail.home_region} has management events "
|
||||
"enabled to log Amazon Bedrock control-plane "
|
||||
"API calls."
|
||||
)
|
||||
elif match_type == "advanced_management":
|
||||
report.status_extended = (
|
||||
f"Trail {trail.name} from home region "
|
||||
f"{trail.home_region} has an advanced "
|
||||
"management event selector to log Amazon "
|
||||
"Bedrock control-plane API calls."
|
||||
)
|
||||
else:
|
||||
report.status_extended = (
|
||||
f"Trail {trail.name} from home region "
|
||||
f"{trail.home_region} has an advanced data "
|
||||
"event selector to log Amazon Bedrock API "
|
||||
"calls."
|
||||
)
|
||||
findings.append(report)
|
||||
break
|
||||
if not findings:
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=cloudtrail_client.trails
|
||||
)
|
||||
report.region = cloudtrail_client.region
|
||||
report.resource_arn = cloudtrail_client.trail_arn_template
|
||||
report.resource_id = cloudtrail_client.audited_account
|
||||
report.status = "FAIL"
|
||||
report.status_extended = "No CloudTrail trails are configured to log Amazon Bedrock API calls."
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
def _get_bedrock_match_type(self, data_event: Event_Selector) -> str | None:
|
||||
"""Return the Bedrock logging match type for an event selector.
|
||||
|
||||
Args:
|
||||
data_event: An Event_Selector object from the trail.
|
||||
|
||||
Returns:
|
||||
The matching selector type, or None if the selector does not log
|
||||
the Bedrock events covered by this check.
|
||||
"""
|
||||
if not data_event.is_advanced:
|
||||
if self._logs_classic_management_events(data_event.event_selector):
|
||||
return "classic_management"
|
||||
return None
|
||||
|
||||
field_selectors = data_event.event_selector.get("FieldSelectors", [])
|
||||
if self._logs_advanced_management_events(field_selectors):
|
||||
return "advanced_management"
|
||||
if self._logs_advanced_bedrock_data_events(field_selectors):
|
||||
return "advanced_data"
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _logs_classic_management_events(event_selector: dict) -> bool:
|
||||
"""Check whether a classic selector logs Bedrock control-plane events."""
|
||||
return event_selector.get(
|
||||
"IncludeManagementEvents", True
|
||||
) and event_selector.get("ReadWriteType", "All") in ("All", "WriteOnly")
|
||||
|
||||
def _logs_advanced_management_events(self, field_selectors: list[dict]) -> bool:
|
||||
"""Check whether advanced selectors log Bedrock control-plane events."""
|
||||
event_category_selectors = [
|
||||
field for field in field_selectors if field.get("Field") == "eventCategory"
|
||||
]
|
||||
if not self._selectors_match_value("Management", event_category_selectors):
|
||||
return False
|
||||
|
||||
read_only_selectors = [
|
||||
field for field in field_selectors if field.get("Field") == "readOnly"
|
||||
]
|
||||
has_read_only_restriction = bool(read_only_selectors) and not any(
|
||||
self._field_selector_matches_value("false", selector)
|
||||
for selector in read_only_selectors
|
||||
)
|
||||
|
||||
return not has_read_only_restriction and self._logs_bedrock_management_events(
|
||||
field_selectors
|
||||
)
|
||||
|
||||
def _logs_advanced_bedrock_data_events(self, field_selectors: list[dict]) -> bool:
|
||||
"""Check whether advanced selectors log Bedrock data events."""
|
||||
event_category_selectors = [
|
||||
field for field in field_selectors if field.get("Field") == "eventCategory"
|
||||
]
|
||||
if not self._selectors_match_value("Data", event_category_selectors):
|
||||
return False
|
||||
|
||||
resource_type_selectors = [
|
||||
field for field in field_selectors if field.get("Field") == "resources.type"
|
||||
]
|
||||
return any(
|
||||
self._selectors_match_value(resource_type, resource_type_selectors)
|
||||
for resource_type in self.BEDROCK_RESOURCE_TYPES
|
||||
)
|
||||
|
||||
def _logs_bedrock_management_events(self, field_selectors: list[dict]) -> bool:
|
||||
"""Check whether advanced management selectors include Bedrock sources."""
|
||||
event_source_selectors = [
|
||||
field for field in field_selectors if field.get("Field") == "eventSource"
|
||||
]
|
||||
if not event_source_selectors:
|
||||
return True
|
||||
|
||||
return any(
|
||||
self._selectors_match_value(event_source, event_source_selectors)
|
||||
for event_source in self.BEDROCK_EVENT_SOURCES
|
||||
)
|
||||
|
||||
def _selectors_match_value(self, value: str, selectors: list[dict]) -> bool:
|
||||
"""Check whether a candidate value satisfies all selectors for a field."""
|
||||
return bool(selectors) and all(
|
||||
self._field_selector_matches_value(value, selector)
|
||||
for selector in selectors
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _field_selector_matches_value(value: str, selector: dict) -> bool:
|
||||
"""Evaluate a CloudTrail advanced field selector against a candidate value."""
|
||||
conditions = []
|
||||
|
||||
if "Equals" in selector:
|
||||
conditions.append(value in selector["Equals"])
|
||||
if "NotEquals" in selector:
|
||||
conditions.append(value not in selector["NotEquals"])
|
||||
if "StartsWith" in selector:
|
||||
conditions.append(
|
||||
any(value.startswith(prefix) for prefix in selector["StartsWith"])
|
||||
)
|
||||
if "NotStartsWith" in selector:
|
||||
conditions.append(
|
||||
all(
|
||||
not value.startswith(prefix) for prefix in selector["NotStartsWith"]
|
||||
)
|
||||
)
|
||||
if "EndsWith" in selector:
|
||||
conditions.append(
|
||||
any(value.endswith(suffix) for suffix in selector["EndsWith"])
|
||||
)
|
||||
if "NotEndsWith" in selector:
|
||||
conditions.append(
|
||||
all(not value.endswith(suffix) for suffix in selector["NotEndsWith"])
|
||||
)
|
||||
|
||||
return all(conditions) if conditions else True
|
||||
@@ -1,9 +1,10 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from dateutil.parser import parse
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.iam.iam_client import iam_client
|
||||
from prowler.providers.aws.services.iam.lib.policy import (
|
||||
evaluate_bedrock_staleness,
|
||||
find_bedrock_service,
|
||||
)
|
||||
|
||||
|
||||
class iam_role_access_not_stale_to_bedrock(Check):
|
||||
@@ -33,33 +34,73 @@ class iam_role_access_not_stale_to_bedrock(Check):
|
||||
"max_unused_bedrock_access_days", 60
|
||||
)
|
||||
|
||||
for (
|
||||
role_data,
|
||||
last_accessed_services,
|
||||
) in iam_client.role_last_accessed_services.items():
|
||||
role_name = role_data[0]
|
||||
role_arn = role_data[1]
|
||||
if iam_client.roles is None:
|
||||
return findings
|
||||
|
||||
bedrock_service = find_bedrock_service(last_accessed_services)
|
||||
for role in iam_client.roles:
|
||||
last_accessed_services = iam_client.role_last_accessed_services.get(
|
||||
(role.name, role.arn), []
|
||||
)
|
||||
bedrock_service = self._find_bedrock_service(last_accessed_services)
|
||||
if bedrock_service is None:
|
||||
continue
|
||||
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(),
|
||||
resource={"name": role_name, "arn": role_arn},
|
||||
)
|
||||
report.resource_id = role_name
|
||||
report.resource_arn = role_arn
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=role)
|
||||
report.region = iam_client.region
|
||||
if iam_client.roles is not None:
|
||||
for iam_role in iam_client.roles:
|
||||
if iam_role.arn == role_arn:
|
||||
report.resource_tags = iam_role.tags
|
||||
break
|
||||
|
||||
evaluate_bedrock_staleness(
|
||||
report, bedrock_service, max_unused_bedrock_days, role_name, "Role"
|
||||
self._evaluate_bedrock_staleness(
|
||||
report,
|
||||
bedrock_service,
|
||||
max_unused_bedrock_days,
|
||||
role.name,
|
||||
"Role",
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
|
||||
@staticmethod
|
||||
def _find_bedrock_service(
|
||||
last_accessed_services: list[dict],
|
||||
) -> Optional[dict]:
|
||||
"""Return the Bedrock entry from a service last accessed list."""
|
||||
for service in last_accessed_services:
|
||||
if service.get("ServiceNamespace") == "bedrock":
|
||||
return service
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_bedrock_staleness(
|
||||
report: Check_Report_AWS,
|
||||
bedrock_service: dict,
|
||||
max_days: int,
|
||||
identity_name: str,
|
||||
identity_type: str,
|
||||
) -> None:
|
||||
"""Populate a check report based on Bedrock access recency."""
|
||||
last_authenticated = bedrock_service.get("LastAuthenticated")
|
||||
if last_authenticated is None:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"IAM {identity_type} {identity_name} has Bedrock permissions "
|
||||
f"but has never used them."
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(last_authenticated, str):
|
||||
last_authenticated = parse(last_authenticated)
|
||||
|
||||
days_since_access = (datetime.now(timezone.utc) - last_authenticated).days
|
||||
|
||||
if days_since_access > max_days:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"IAM {identity_type} {identity_name} has not accessed Bedrock "
|
||||
f"in {days_since_access} days (threshold: {max_days} days)."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"IAM {identity_type} {identity_name} accessed Bedrock "
|
||||
f"{days_since_access} days ago (threshold: {max_days} days)."
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from dateutil.parser import parse
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.iam.iam_client import iam_client
|
||||
from prowler.providers.aws.services.iam.lib.policy import (
|
||||
evaluate_bedrock_staleness,
|
||||
find_bedrock_service,
|
||||
)
|
||||
|
||||
|
||||
class iam_user_access_not_stale_to_bedrock(Check):
|
||||
@@ -33,32 +34,70 @@ class iam_user_access_not_stale_to_bedrock(Check):
|
||||
"max_unused_bedrock_access_days", 60
|
||||
)
|
||||
|
||||
for (
|
||||
user_data,
|
||||
last_accessed_services,
|
||||
) in iam_client.last_accessed_services.items():
|
||||
user_name = user_data[0]
|
||||
user_arn = user_data[1]
|
||||
|
||||
bedrock_service = find_bedrock_service(last_accessed_services)
|
||||
for user in iam_client.users:
|
||||
last_accessed_services = iam_client.last_accessed_services.get(
|
||||
(user.name, user.arn), []
|
||||
)
|
||||
bedrock_service = self._find_bedrock_service(last_accessed_services)
|
||||
if bedrock_service is None:
|
||||
continue
|
||||
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(),
|
||||
resource={"name": user_name, "arn": user_arn},
|
||||
)
|
||||
report.resource_id = user_name
|
||||
report.resource_arn = user_arn
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=user)
|
||||
report.region = iam_client.region
|
||||
for iam_user in iam_client.users:
|
||||
if iam_user.arn == user_arn:
|
||||
report.resource_tags = iam_user.tags
|
||||
break
|
||||
|
||||
evaluate_bedrock_staleness(
|
||||
report, bedrock_service, max_unused_bedrock_days, user_name, "User"
|
||||
self._evaluate_bedrock_staleness(
|
||||
report,
|
||||
bedrock_service,
|
||||
max_unused_bedrock_days,
|
||||
user.name,
|
||||
"User",
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
|
||||
@staticmethod
|
||||
def _find_bedrock_service(
|
||||
last_accessed_services: list[dict],
|
||||
) -> Optional[dict]:
|
||||
"""Return the Bedrock entry from a service last accessed list."""
|
||||
for service in last_accessed_services:
|
||||
if service.get("ServiceNamespace") == "bedrock":
|
||||
return service
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_bedrock_staleness(
|
||||
report: Check_Report_AWS,
|
||||
bedrock_service: dict,
|
||||
max_days: int,
|
||||
identity_name: str,
|
||||
identity_type: str,
|
||||
) -> None:
|
||||
"""Populate a check report based on Bedrock access recency."""
|
||||
last_authenticated = bedrock_service.get("LastAuthenticated")
|
||||
if last_authenticated is None:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"IAM {identity_type} {identity_name} has Bedrock permissions "
|
||||
f"but has never used them."
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(last_authenticated, str):
|
||||
last_authenticated = parse(last_authenticated)
|
||||
|
||||
days_since_access = (datetime.now(timezone.utc) - last_authenticated).days
|
||||
|
||||
if days_since_access > max_days:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"IAM {identity_type} {identity_name} has not accessed Bedrock "
|
||||
f"in {days_since_access} days (threshold: {max_days} days)."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"IAM {identity_type} {identity_name} accessed Bedrock "
|
||||
f"{days_since_access} days ago (threshold: {max_days} days)."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "iam_user_access_not_stale_to_sagemaker",
|
||||
"CheckTitle": "Regular SageMaker access ensures IAM users retain only actively used permissions",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
],
|
||||
"ServiceName": "iam",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsIamUser",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "IAM users granted **SageMaker** permissions are evaluated for recent service usage.\n\nUsers whose last SageMaker access exceeds the configured threshold (default **90 days**) or that have **never** accessed SageMaker are flagged, indicating stale permissions that should be reviewed.",
|
||||
"Risk": "Stale SageMaker permissions widen the **blast radius** of a credential compromise.\n\nAn attacker who gains access to a user with unused SageMaker permissions can access ML training data, models, endpoints, and notebooks — all without triggering expected usage patterns. Removing or scoping down stale permissions enforces least privilege and limits blast radius.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_access-advisor.html",
|
||||
"https://docs.aws.amazon.com/sagemaker/latest/dg/security-iam.html",
|
||||
"https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#remove-credentials"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Open the IAM console and select the user\n2. Review the **Access Advisor** tab to confirm SageMaker has not been accessed recently\n3. Remove or detach any policies granting SageMaker permissions that are no longer needed\n4. If the user still requires SageMaker access, verify usage and reduce scope to least privilege",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Apply the **principle of least privilege** by regularly reviewing IAM Access Advisor data and revoking SageMaker permissions that are no longer actively used.\n\nEstablish a periodic access review process and automate alerts for stale permissions to maintain a minimal attack surface.",
|
||||
"Url": "https://hub.prowler.com/check/iam_user_access_not_stale_to_sagemaker"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"iam_user_access_not_stale_to_bedrock"
|
||||
],
|
||||
"Notes": "The staleness threshold is configurable via the `max_unused_sagemaker_access_days` audit config key (default: 90 days)."
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from dateutil.parser import parse
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.iam.iam_client import iam_client
|
||||
|
||||
|
||||
class iam_user_access_not_stale_to_sagemaker(Check):
|
||||
"""Detect IAM users with stale SageMaker permissions.
|
||||
|
||||
This check evaluates whether IAM users with SageMaker service permissions
|
||||
have actively used those permissions within the configured threshold
|
||||
(default 90 days).
|
||||
|
||||
- PASS: The user has accessed SageMaker within the allowed period.
|
||||
- FAIL: The user has SageMaker permissions but has not used them within
|
||||
the allowed period or has never used them.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the SageMaker access staleness check for IAM users.
|
||||
|
||||
Iterates over IAM users, inspecting service last accessed data for
|
||||
the ``sagemaker`` namespace. Users whose last SageMaker access exceeds
|
||||
the configured threshold are reported as non-compliant.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
max_unused_sagemaker_days = iam_client.audit_config.get(
|
||||
"max_unused_sagemaker_access_days", 90
|
||||
)
|
||||
|
||||
for user in iam_client.users:
|
||||
last_accessed_services = iam_client.last_accessed_services.get(
|
||||
(user.name, user.arn), []
|
||||
)
|
||||
sagemaker_service = self._find_sagemaker_service(last_accessed_services)
|
||||
if sagemaker_service is None:
|
||||
continue
|
||||
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=user)
|
||||
report.region = iam_client.region
|
||||
|
||||
self._evaluate_sagemaker_staleness(
|
||||
report,
|
||||
sagemaker_service,
|
||||
max_unused_sagemaker_days,
|
||||
user.name,
|
||||
"User",
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
|
||||
@staticmethod
|
||||
def _find_sagemaker_service(
|
||||
last_accessed_services: list[dict],
|
||||
) -> Optional[dict]:
|
||||
"""Return the SageMaker entry from a service last accessed list."""
|
||||
for service in last_accessed_services:
|
||||
if service.get("ServiceNamespace") == "sagemaker":
|
||||
return service
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_sagemaker_staleness(
|
||||
report: Check_Report_AWS,
|
||||
sagemaker_service: dict,
|
||||
max_days: int,
|
||||
identity_name: str,
|
||||
identity_type: str,
|
||||
) -> None:
|
||||
"""Populate a check report based on SageMaker access recency."""
|
||||
last_authenticated = sagemaker_service.get("LastAuthenticated")
|
||||
if last_authenticated is None:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"IAM {identity_type} {identity_name} has SageMaker permissions "
|
||||
f"but has never used them."
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(last_authenticated, str):
|
||||
last_authenticated = parse(last_authenticated)
|
||||
|
||||
days_since_access = (datetime.now(timezone.utc) - last_authenticated).days
|
||||
|
||||
if days_since_access > max_days:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"IAM {identity_type} {identity_name} has not accessed SageMaker "
|
||||
f"in {days_since_access} days (threshold: {max_days} days)."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"IAM {identity_type} {identity_name} accessed SageMaker "
|
||||
f"{days_since_access} days ago (threshold: {max_days} days)."
|
||||
)
|
||||
@@ -1,12 +1,9 @@
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from ipaddress import ip_address, ip_network
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from dateutil.parser import parse
|
||||
from py_iam_expand.actions import InvalidActionHandling, expand_actions
|
||||
|
||||
from prowler.lib.check.models import Check_Report_AWS
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.aws.aws_provider import read_aws_regions_file
|
||||
|
||||
@@ -1121,47 +1118,3 @@ def has_codebuild_trusted_principal(trust_policy: dict) -> bool:
|
||||
)
|
||||
for s in statements
|
||||
)
|
||||
|
||||
|
||||
def find_bedrock_service(last_accessed_services: list[dict]) -> Optional[dict]:
|
||||
"""Return the Bedrock entry from a service last accessed list."""
|
||||
for service in last_accessed_services:
|
||||
if service.get("ServiceNamespace") == "bedrock":
|
||||
return service
|
||||
return None
|
||||
|
||||
|
||||
def evaluate_bedrock_staleness(
|
||||
report: Check_Report_AWS,
|
||||
bedrock_service: dict,
|
||||
max_days: int,
|
||||
identity_name: str,
|
||||
identity_type: str,
|
||||
) -> None:
|
||||
"""Populate a check report based on Bedrock access recency."""
|
||||
last_authenticated = bedrock_service.get("LastAuthenticated")
|
||||
if last_authenticated is None:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"IAM {identity_type} {identity_name} has Bedrock permissions "
|
||||
f"but has never used them."
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(last_authenticated, str):
|
||||
last_authenticated = parse(last_authenticated)
|
||||
|
||||
days_since_access = (datetime.now(timezone.utc) - last_authenticated).days
|
||||
|
||||
if days_since_access > max_days:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"IAM {identity_type} {identity_name} has not accessed Bedrock "
|
||||
f"in {days_since_access} days (threshold: {max_days} days)."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"IAM {identity_type} {identity_name} accessed Bedrock "
|
||||
f"{days_since_access} days ago (threshold: {max_days} days)."
|
||||
)
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"Severity": "high",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "Microsoft Entra **Conditional Access** is verified to have at least one **emergency access** (break glass) account or group excluded from all policies. Emergency access accounts provide a fallback mechanism when normal administrative access is blocked due to misconfigured policies.",
|
||||
"Risk": "Without emergency access accounts excluded from Conditional Access policies, a misconfiguration could lock out all administrators from the tenant. This creates a **critical availability risk** where legitimate administrators cannot access or remediate issues in the environment.",
|
||||
"Description": "Microsoft Entra **Conditional Access** is verified to have at least one **emergency access** (break glass) account or group excluded from every enabled Conditional Access policy with a **Block** grant control. Emergency access accounts provide a fallback mechanism when normal administrative access is blocked due to misconfigured blocking policies.",
|
||||
"Risk": "Without emergency access accounts excluded from every blocking Conditional Access policy, a misconfigured Block policy can lock out all administrators from the tenant. This creates a **critical availability risk** where legitimate administrators cannot access or remediate issues in the environment.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-emergency-access",
|
||||
@@ -20,11 +20,11 @@
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Create dedicated emergency access accounts or a security group in Microsoft Entra admin center.\n2. Navigate to Protection > Conditional Access > Policies.\n3. For each Conditional Access policy, add the emergency access account or group to the exclusion list under Users > Exclude.\n4. Ensure the emergency accounts are protected with strong credentials and limited usage.",
|
||||
"Other": "1. Create dedicated emergency access accounts or a security group in Microsoft Entra admin center.\n2. Navigate to Protection > Conditional Access > Policies.\n3. For every Conditional Access policy whose grant control is **Block**, add the emergency access account or group to the exclusion list under Users > Exclude.\n4. Ensure the emergency accounts are protected with strong credentials and limited usage.",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Create and maintain at least two emergency access accounts that are excluded from all Conditional Access policies. Store credentials securely offline, monitor usage, and test access regularly. Follow **least privilege** principles for these accounts while ensuring they can recover tenant access when needed.",
|
||||
"Text": "Create and maintain at least two emergency access accounts that are excluded from every Conditional Access policy with a **Block** grant control so they can never be denied access by a misconfiguration. Store credentials securely offline, monitor usage, and test access regularly. Follow **least privilege** principles for these accounts while ensuring they can recover tenant access when needed.",
|
||||
"Url": "https://hub.prowler.com/check/entra_emergency_access_exclusion"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,87 +3,38 @@ from collections import Counter
|
||||
from prowler.lib.check.models import Check, CheckReportM365
|
||||
from prowler.providers.m365.services.entra.entra_client import entra_client
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
ConditionalAccessGrantControl,
|
||||
ConditionalAccessPolicyState,
|
||||
)
|
||||
|
||||
|
||||
class entra_emergency_access_exclusion(Check):
|
||||
"""Check if at least one emergency access account or group is excluded from all Conditional Access policies.
|
||||
"""Check that at least one emergency access account or group is excluded
|
||||
from every enabled Conditional Access policy with a `Block` grant control.
|
||||
|
||||
This check ensures that the tenant has at least one emergency/break glass account
|
||||
or account exclusion group that is excluded from all Conditional Access policies.
|
||||
This prevents accidental lockout scenarios where misconfigured CA policies could
|
||||
block all administrative access to the tenant.
|
||||
Emergency access (break glass) accounts are, by definition, accounts that
|
||||
cannot be blocked by Conditional Access. Membership of an account in the
|
||||
exclusion list of every enabled blocking policy is therefore the necessary
|
||||
condition for it to act as a true emergency account: if any enabled
|
||||
blocking policy applies to it, a misconfiguration of that policy can lock
|
||||
out the tenant.
|
||||
|
||||
- PASS: At least one user or group is excluded from all enabled Conditional Access policies,
|
||||
or there are no enabled policies.
|
||||
- FAIL: No user or group is excluded from all enabled Conditional Access policies.
|
||||
- PASS: At least one user or group is excluded from every enabled
|
||||
Conditional Access policy with a `Block` grant control, or no
|
||||
enabled blocking Conditional Access policy exists.
|
||||
- FAIL: One or more enabled blocking Conditional Access policies exist and
|
||||
no user or group is excluded from all of them.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[CheckReportM365]:
|
||||
"""Execute the check for emergency access account exclusions.
|
||||
"""Execute the check for emergency access account exclusions from
|
||||
blocking Conditional Access policies.
|
||||
|
||||
Returns:
|
||||
list[CheckReportM365]: A list containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
# Get all enabled CA policies (excluding disabled ones)
|
||||
enabled_policies = [
|
||||
policy
|
||||
for policy in entra_client.conditional_access_policies.values()
|
||||
if policy.state != ConditionalAccessPolicyState.DISABLED
|
||||
]
|
||||
|
||||
# If there are no enabled policies, there's nothing to exclude from
|
||||
if not enabled_policies:
|
||||
report = CheckReportM365(
|
||||
metadata=self.metadata(),
|
||||
resource={},
|
||||
resource_name="Conditional Access Policies",
|
||||
resource_id="conditionalAccessPolicies",
|
||||
)
|
||||
report.status = "PASS"
|
||||
report.status_extended = "No enabled Conditional Access policies found. Emergency access exclusions are not required."
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
total_policy_count = len(enabled_policies)
|
||||
|
||||
# Count how many policies exclude each user
|
||||
excluded_users_counter = Counter()
|
||||
for policy in enabled_policies:
|
||||
user_conditions = policy.conditions.user_conditions
|
||||
if user_conditions:
|
||||
for user_id in user_conditions.excluded_users:
|
||||
excluded_users_counter[user_id] += 1
|
||||
|
||||
# Count how many policies exclude each group
|
||||
excluded_groups_counter = Counter()
|
||||
for policy in enabled_policies:
|
||||
user_conditions = policy.conditions.user_conditions
|
||||
if user_conditions:
|
||||
for group_id in user_conditions.excluded_groups:
|
||||
excluded_groups_counter[group_id] += 1
|
||||
|
||||
# Find users excluded from ALL policies
|
||||
users_excluded_from_all = [
|
||||
user_id
|
||||
for user_id, count in excluded_users_counter.items()
|
||||
if count == total_policy_count
|
||||
]
|
||||
|
||||
# Find groups excluded from ALL policies
|
||||
groups_excluded_from_all = [
|
||||
group_id
|
||||
for group_id, count in excluded_groups_counter.items()
|
||||
if count == total_policy_count
|
||||
]
|
||||
|
||||
has_emergency_exclusion = bool(
|
||||
users_excluded_from_all or groups_excluded_from_all
|
||||
)
|
||||
|
||||
report = CheckReportM365(
|
||||
metadata=self.metadata(),
|
||||
resource={},
|
||||
@@ -91,27 +42,67 @@ class entra_emergency_access_exclusion(Check):
|
||||
resource_id="conditionalAccessPolicies",
|
||||
)
|
||||
|
||||
if has_emergency_exclusion:
|
||||
report.status = "PASS"
|
||||
exclusion_details = []
|
||||
if users_excluded_from_all:
|
||||
user_names = []
|
||||
for user_id in users_excluded_from_all:
|
||||
user = entra_client.users.get(user_id)
|
||||
user_names.append(user.name if user else user_id)
|
||||
exclusion_details.append(f"user(s): {', '.join(user_names)}")
|
||||
if groups_excluded_from_all:
|
||||
group_names = []
|
||||
groups_by_id = {g.id: g for g in entra_client.groups}
|
||||
for group_id in groups_excluded_from_all:
|
||||
group = groups_by_id.get(group_id)
|
||||
group_names.append(group.name if group else group_id)
|
||||
exclusion_details.append(f"group(s): {', '.join(group_names)}")
|
||||
report.status_extended = f"Emergency access {' and '.join(exclusion_details)} excluded from all {total_policy_count} enabled Conditional Access policies."
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"No user or group is excluded as emergency access from all {total_policy_count} enabled Conditional Access policies."
|
||||
blocking_policies = [
|
||||
policy
|
||||
for policy in entra_client.conditional_access_policies.values()
|
||||
if policy.state != ConditionalAccessPolicyState.DISABLED
|
||||
and ConditionalAccessGrantControl.BLOCK
|
||||
in policy.grant_controls.built_in_controls
|
||||
]
|
||||
|
||||
if not blocking_policies:
|
||||
report.status = "PASS"
|
||||
report.status_extended = "No enabled Conditional Access policies with a Block grant control found. Emergency access exclusions are not required."
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
total_blocking_count = len(blocking_policies)
|
||||
|
||||
excluded_users_counter = Counter()
|
||||
excluded_groups_counter = Counter()
|
||||
for policy in blocking_policies:
|
||||
user_conditions = policy.conditions.user_conditions
|
||||
if not user_conditions:
|
||||
continue
|
||||
for user_id in user_conditions.excluded_users:
|
||||
excluded_users_counter[user_id] += 1
|
||||
for group_id in user_conditions.excluded_groups:
|
||||
excluded_groups_counter[group_id] += 1
|
||||
|
||||
emergency_user_ids = [
|
||||
user_id
|
||||
for user_id, count in excluded_users_counter.items()
|
||||
if count == total_blocking_count
|
||||
]
|
||||
emergency_group_ids = [
|
||||
group_id
|
||||
for group_id, count in excluded_groups_counter.items()
|
||||
if count == total_blocking_count
|
||||
]
|
||||
|
||||
if not (emergency_user_ids or emergency_group_ids):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"No user or group is excluded as emergency access from all {total_blocking_count} enabled Conditional Access policies with a Block grant control."
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
exclusion_details = []
|
||||
if emergency_user_ids:
|
||||
user_names = []
|
||||
for uid in emergency_user_ids:
|
||||
user = entra_client.users.get(uid)
|
||||
user_names.append(user.name if user else uid)
|
||||
exclusion_details.append(f"user(s): {', '.join(user_names)}")
|
||||
if emergency_group_ids:
|
||||
groups_by_id = {g.id: g for g in entra_client.groups}
|
||||
group_names = []
|
||||
for gid in emergency_group_ids:
|
||||
group = groups_by_id.get(gid)
|
||||
group_names.append(group.name if group else gid)
|
||||
exclusion_details.append(f"group(s): {', '.join(group_names)}")
|
||||
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Emergency access {' and '.join(exclusion_details)} excluded from all {total_blocking_count} enabled Conditional Access policies with a Block grant control."
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import asyncio
|
||||
import json
|
||||
from asyncio import gather
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from kiota_abstractions.base_request_configuration import RequestConfiguration
|
||||
from msgraph.generated.models.o_data_errors.o_data_error import ODataError
|
||||
from msgraph.generated.security.microsoft_graph_security_run_hunting_query.run_hunting_query_post_request_body import (
|
||||
RunHuntingQueryPostRequestBody,
|
||||
)
|
||||
from msgraph.generated.users.users_request_builder import UsersRequestBuilder
|
||||
from pydantic.v1 import BaseModel, validator
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
@@ -36,6 +39,7 @@ class Entra(M365Service):
|
||||
user_accounts_status (dict): Dictionary of user account statuses.
|
||||
oauth_apps (dict): Dictionary of OAuth applications from Defender XDR.
|
||||
authentication_method_configurations (dict): Dictionary of authentication method configurations.
|
||||
service_principals (dict): Dictionary of service principals with credentials and role assignments.
|
||||
"""
|
||||
|
||||
def __init__(self, provider: M365Provider):
|
||||
@@ -71,6 +75,7 @@ class Entra(M365Service):
|
||||
)
|
||||
|
||||
self.tenant_domain = provider.identity.tenant_domain
|
||||
self.tenant_id = getattr(provider.identity, "tenant_id", None)
|
||||
attributes = loop.run_until_complete(
|
||||
gather(
|
||||
self._get_authorization_policy(),
|
||||
@@ -83,6 +88,7 @@ class Entra(M365Service):
|
||||
self._get_oauth_apps(),
|
||||
self._get_directory_sync_settings(),
|
||||
self._get_authentication_method_configurations(),
|
||||
self._get_service_principals(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -98,6 +104,7 @@ class Entra(M365Service):
|
||||
self.authentication_method_configurations: Dict[
|
||||
str, AuthenticationMethodConfiguration
|
||||
] = attributes[9]
|
||||
self.service_principals: Dict[str, "ServicePrincipal"] = attributes[10]
|
||||
self.user_accounts_status = {}
|
||||
|
||||
if created_loop:
|
||||
@@ -806,7 +813,29 @@ class Entra(M365Service):
|
||||
logger.info("Entra - Getting users...")
|
||||
users = {}
|
||||
try:
|
||||
users_response = await self.client.users.get()
|
||||
# Microsoft Graph's /users endpoint omits accountEnabled, userType and
|
||||
# onPremisesSyncEnabled from the default property set, so we must request
|
||||
# them explicitly via $select. Without this, disabled guest users surface
|
||||
# as account_enabled=True (Pydantic default) and user_type=None, which
|
||||
# bypasses the guest/disabled filters in checks like
|
||||
# entra_users_mfa_capable (CIS 5.2.3.4). See issue #10921.
|
||||
query_parameters = (
|
||||
UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
|
||||
select=[
|
||||
"id",
|
||||
"displayName",
|
||||
"userType",
|
||||
"accountEnabled",
|
||||
"onPremisesSyncEnabled",
|
||||
],
|
||||
)
|
||||
)
|
||||
request_configuration = RequestConfiguration(
|
||||
query_parameters=query_parameters,
|
||||
)
|
||||
users_response = await self.client.users.get(
|
||||
request_configuration=request_configuration,
|
||||
)
|
||||
directory_roles = await self.client.directory_roles.get()
|
||||
|
||||
async def fetch_role_members(directory_role):
|
||||
@@ -830,6 +859,19 @@ class Entra(M365Service):
|
||||
while users_response:
|
||||
for user in getattr(users_response, "value", []) or []:
|
||||
reg_info = registration_details.get(user.id, {})
|
||||
# Prefer Microsoft Graph as the source of truth for
|
||||
# accountEnabled: it covers every directory user including
|
||||
# guests, whereas EXO's Get-User only returns mail-enabled
|
||||
# accounts and silently drops disabled guests. Fall back to
|
||||
# the EXO PowerShell value only when Graph does not return a
|
||||
# value (e.g. older tenants or permission-restricted reads).
|
||||
graph_account_enabled = getattr(user, "account_enabled", None)
|
||||
if graph_account_enabled is None:
|
||||
account_enabled = not self.user_accounts_status.get(
|
||||
user.id, {}
|
||||
).get("AccountDisabled", False)
|
||||
else:
|
||||
account_enabled = bool(graph_account_enabled)
|
||||
users[user.id] = User(
|
||||
id=user.id,
|
||||
name=user.display_name,
|
||||
@@ -838,9 +880,7 @@ class Entra(M365Service):
|
||||
),
|
||||
directory_roles_ids=user_roles_map.get(user.id, []),
|
||||
is_mfa_capable=reg_info.get("is_mfa_capable", False),
|
||||
account_enabled=not self.user_accounts_status.get(
|
||||
user.id, {}
|
||||
).get("AccountDisabled", False),
|
||||
account_enabled=account_enabled,
|
||||
authentication_methods=reg_info.get(
|
||||
"authentication_methods", []
|
||||
),
|
||||
@@ -1055,6 +1095,154 @@ OAuthAppInfo
|
||||
)
|
||||
return authentication_method_configurations
|
||||
|
||||
async def _get_service_principals(self):
|
||||
"""Retrieve service principals owned by the audited tenant.
|
||||
|
||||
Fetches all service principals from Microsoft Graph and keeps only the
|
||||
ones whose ``appOwnerOrganizationId`` matches the audited tenant. Skips
|
||||
Microsoft first-party service principals and multi-tenant ISV apps
|
||||
consented from other publishers: their credentials live in the
|
||||
publisher's tenant, not this one, so they are out of scope for any
|
||||
check that evaluates secret hygiene or role assignments managed by the
|
||||
customer.
|
||||
|
||||
Returns:
|
||||
Dict[str, ServicePrincipal]: Customer-owned service principals
|
||||
keyed by service principal ID.
|
||||
"""
|
||||
logger.info("Entra - Getting service principals...")
|
||||
service_principals = {}
|
||||
tenant_id_normalized = str(self.tenant_id).lower() if self.tenant_id else None
|
||||
try:
|
||||
sp_response = await self.client.service_principals.get()
|
||||
|
||||
# Build a map of service principal IDs to their data
|
||||
while sp_response:
|
||||
for sp in getattr(sp_response, "value", []) or []:
|
||||
raw_owner = getattr(sp, "app_owner_organization_id", None)
|
||||
app_owner_org_id = str(raw_owner).lower() if raw_owner else None
|
||||
if (
|
||||
tenant_id_normalized
|
||||
and app_owner_org_id != tenant_id_normalized
|
||||
):
|
||||
# Skip Microsoft first-party SPs and consented
|
||||
# multi-tenant ISV apps; the customer cannot manage
|
||||
# their credentials.
|
||||
continue
|
||||
|
||||
password_credentials = []
|
||||
for cred in getattr(sp, "password_credentials", []) or []:
|
||||
password_credentials.append(
|
||||
PasswordCredential(
|
||||
key_id=str(getattr(cred, "key_id", "")),
|
||||
display_name=getattr(cred, "display_name", None),
|
||||
end_date_time=getattr(cred, "end_date_time", None),
|
||||
)
|
||||
)
|
||||
|
||||
key_credentials = []
|
||||
for cred in getattr(sp, "key_credentials", []) or []:
|
||||
key_credentials.append(
|
||||
KeyCredential(
|
||||
key_id=str(getattr(cred, "key_id", "")),
|
||||
display_name=getattr(cred, "display_name", None),
|
||||
)
|
||||
)
|
||||
|
||||
service_principals[sp.id] = ServicePrincipal(
|
||||
id=sp.id,
|
||||
name=getattr(sp, "display_name", "") or "",
|
||||
app_id=getattr(sp, "app_id", "") or "",
|
||||
app_owner_organization_id=app_owner_org_id,
|
||||
password_credentials=password_credentials,
|
||||
key_credentials=key_credentials,
|
||||
)
|
||||
|
||||
next_link = getattr(sp_response, "odata_next_link", None)
|
||||
if not next_link:
|
||||
break
|
||||
sp_response = await self.client.service_principals.with_url(
|
||||
next_link
|
||||
).get()
|
||||
|
||||
# Fold in credentials registered on the parent Application objects.
|
||||
# Microsoft Graph stores secrets and certificates added through
|
||||
# "Certificates & secrets" on /applications, not on the service
|
||||
# principal itself, so /servicePrincipals.passwordCredentials is
|
||||
# almost always empty for normal app registrations. Joining via
|
||||
# appId is required for the check to see those credentials.
|
||||
#
|
||||
# Index service principals by app_id once so the join below is
|
||||
# O(N+M) instead of scanning all SPs for every Application page.
|
||||
service_principals_by_app_id = {
|
||||
sp.app_id: sp for sp in service_principals.values() if sp.app_id
|
||||
}
|
||||
app_response = await self.client.applications.get()
|
||||
while app_response:
|
||||
for app in getattr(app_response, "value", []) or []:
|
||||
app_id = getattr(app, "app_id", None)
|
||||
if not app_id:
|
||||
continue
|
||||
target_sp = service_principals_by_app_id.get(app_id)
|
||||
if target_sp is None:
|
||||
continue
|
||||
|
||||
for cred in getattr(app, "password_credentials", []) or []:
|
||||
target_sp.password_credentials.append(
|
||||
PasswordCredential(
|
||||
key_id=str(getattr(cred, "key_id", "")),
|
||||
display_name=getattr(cred, "display_name", None),
|
||||
end_date_time=getattr(cred, "end_date_time", None),
|
||||
)
|
||||
)
|
||||
for cred in getattr(app, "key_credentials", []) or []:
|
||||
target_sp.key_credentials.append(
|
||||
KeyCredential(
|
||||
key_id=str(getattr(cred, "key_id", "")),
|
||||
display_name=getattr(cred, "display_name", None),
|
||||
)
|
||||
)
|
||||
|
||||
next_link = getattr(app_response, "odata_next_link", None)
|
||||
if not next_link:
|
||||
break
|
||||
app_response = await self.client.applications.with_url(next_link).get()
|
||||
|
||||
# Identify permanent Tier 0 directory role assignments via the unified
|
||||
# role management endpoint. ``directoryRoles/{id}/members`` mixes
|
||||
# permanent direct assignments with PIM-activated temporary ones, so
|
||||
# using it would mark just-in-time elevations as "permanent" and emit
|
||||
# false positives. ``roleManagement/directory/roleAssignments``
|
||||
# exposes only the durable, statically-assigned principals, which is
|
||||
# exactly what the Tier 0 check needs.
|
||||
role_assignments_response = (
|
||||
await self.client.role_management.directory.role_assignments.get()
|
||||
)
|
||||
while role_assignments_response:
|
||||
for assignment in getattr(role_assignments_response, "value", []) or []:
|
||||
principal_id = getattr(assignment, "principal_id", None)
|
||||
role_definition_id = getattr(assignment, "role_definition_id", None)
|
||||
if (
|
||||
principal_id in service_principals
|
||||
and role_definition_id in TIER_0_ROLE_TEMPLATE_IDS
|
||||
):
|
||||
service_principals[
|
||||
principal_id
|
||||
].directory_role_template_ids.append(role_definition_id)
|
||||
|
||||
next_link = getattr(role_assignments_response, "odata_next_link", None)
|
||||
if not next_link:
|
||||
break
|
||||
role_assignments_response = await self.client.role_management.directory.role_assignments.with_url(
|
||||
next_link
|
||||
).get()
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return service_principals
|
||||
|
||||
|
||||
class ConditionalAccessPolicyState(Enum):
|
||||
ENABLED = "enabled"
|
||||
@@ -1486,3 +1674,94 @@ class OAuthApp(BaseModel):
|
||||
is_admin_consented: bool = False
|
||||
last_used_time: Optional[str] = None
|
||||
app_origin: str = ""
|
||||
|
||||
|
||||
class PasswordCredential(BaseModel):
|
||||
"""Model representing a password credential (client secret) on a service principal.
|
||||
|
||||
Attributes:
|
||||
key_id: The unique identifier of the credential.
|
||||
display_name: The optional display name of the credential.
|
||||
end_date_time: The expiration time of the credential. ``None`` indicates
|
||||
the secret has no recorded expiry and is treated as active.
|
||||
"""
|
||||
|
||||
key_id: str
|
||||
display_name: Optional[str] = None
|
||||
end_date_time: Optional[datetime] = None
|
||||
|
||||
def is_active(self, now: Optional[datetime] = None) -> bool:
|
||||
"""Return ``True`` when the credential has not expired.
|
||||
|
||||
A credential with no ``end_date_time`` is assumed to be active, matching
|
||||
the behavior of the Microsoft Graph API when the field is omitted.
|
||||
"""
|
||||
if self.end_date_time is None:
|
||||
return True
|
||||
reference = now or datetime.now(timezone.utc)
|
||||
return self.end_date_time > reference
|
||||
|
||||
|
||||
class KeyCredential(BaseModel):
|
||||
"""Model representing a key credential (certificate) on a service principal.
|
||||
|
||||
Attributes:
|
||||
key_id: The unique identifier of the credential.
|
||||
display_name: The optional display name of the credential.
|
||||
"""
|
||||
|
||||
key_id: str
|
||||
display_name: Optional[str] = None
|
||||
|
||||
|
||||
# Control Plane (Tier 0) role template IDs.
|
||||
#
|
||||
# Roles included grant tenant-wide control over identity, authentication, or the
|
||||
# directory itself, so a credential compromise on any of them is equivalent to a
|
||||
# tenant takeover. References:
|
||||
# https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/privileged-roles-permissions
|
||||
# https://learn.microsoft.com/en-us/security/privileged-access-workstations/privileged-access-access-model
|
||||
TIER_0_ROLE_TEMPLATE_IDS = {
|
||||
"62e90394-69f5-4237-9190-012177145e10", # Global Administrator
|
||||
"e8611ab8-c189-46e8-94e1-60213ab1f814", # Privileged Role Administrator
|
||||
"7be44c8a-adaf-4e2a-84d6-ab2649e08a13", # Privileged Authentication Administrator
|
||||
"9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3", # Application Administrator
|
||||
"158c047a-c907-4556-b7ef-446551a6b5f7", # Cloud Application Administrator
|
||||
"c4e39bd9-1100-46d3-8c65-fb160da0071f", # Authentication Administrator
|
||||
"0526716b-113d-4c15-b2c8-68e3c22b9f80", # Authentication Policy Administrator
|
||||
"b1be1c3e-b65d-4f19-8427-f6fa0d97feb9", # Conditional Access Administrator
|
||||
"8329153b-31d0-4727-b945-745eb3bc5f31", # Domain Name Administrator
|
||||
"be2f45a1-457d-42af-a067-6ec1fa63bc45", # External Identity Provider Administrator
|
||||
"8ac3fc64-6eca-42ea-9e69-59f4c7b60eb2", # Hybrid Identity Administrator
|
||||
"194ae4cb-b126-40b2-bd5b-6091b380977d", # Security Administrator
|
||||
"fe930be7-5e62-47db-91af-98c3a49a38b1", # User Administrator
|
||||
"d29b2b05-8046-44ba-8758-1e26182fcf32", # Directory Synchronization Accounts
|
||||
"e00e864a-17c5-4a4b-9c06-f5b95a8d5bd8", # Partner Tier2 Support
|
||||
}
|
||||
|
||||
|
||||
class ServicePrincipal(BaseModel):
|
||||
"""Model representing a Microsoft Entra ID service principal.
|
||||
|
||||
Attributes:
|
||||
id: The service principal's unique identifier.
|
||||
name: The service principal's display name.
|
||||
app_id: The application ID associated with the service principal.
|
||||
app_owner_organization_id: Tenant ID of the application's publisher.
|
||||
For customer-owned apps this matches the audited tenant; the
|
||||
service-layer fetch uses this to filter out Microsoft first-party
|
||||
and third-party multi-tenant service principals that the customer
|
||||
cannot manage credentials for.
|
||||
password_credentials: List of password credentials (client secrets).
|
||||
key_credentials: List of key credentials (certificates).
|
||||
directory_role_template_ids: List of directory role template IDs permanently
|
||||
assigned to this service principal.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
app_id: str = ""
|
||||
app_owner_organization_id: Optional[str] = None
|
||||
password_credentials: List[PasswordCredential] = []
|
||||
key_credentials: List[KeyCredential] = []
|
||||
directory_role_template_ids: List[str] = []
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Provider": "m365",
|
||||
"CheckID": "entra_service_principal_no_secrets_for_permanent_tier0_roles",
|
||||
"CheckTitle": "Secure credential management prevents client secret usage for service principals with permanent Tier 0 roles",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "critical",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "Microsoft Entra **service principals** with permanent assignments to **Control Plane (Tier 0)** directory roles are evaluated for the use of **client secrets** (password credentials) instead of more secure authentication methods such as certificates or managed identities.",
|
||||
"Risk": "A service principal authenticating with a **client secret** while holding a **Tier 0** role creates a high-impact credential theft path. Leaked or brute-forced secrets grant immediate control-plane access, enabling tenant-wide privilege escalation, security control bypass, data exfiltration, and persistent backdoor creation—impacting **confidentiality**, **integrity**, and **availability**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-planning#prefer-certificate-credentials",
|
||||
"https://learn.microsoft.com/en-us/entra/architecture/security-operations-applications#application-credentials"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Identity > Applications > App registrations > select the application\n3. Under Certificates & secrets, remove all client secrets\n4. Under Certificates & secrets > Certificates, upload a certificate for authentication\n5. Alternatively, migrate to a managed identity where possible",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Replace **client secrets** with **certificates** or **managed identities** for service principals holding Control Plane roles. Apply **least privilege** by removing unnecessary Tier 0 assignments. Use **Privileged Identity Management (PIM)** for just-in-time eligible assignments instead of permanent ones. Rotate credentials regularly and monitor sign-in logs for anomalies.",
|
||||
"Url": "https://hub.prowler.com/check/entra_service_principal_no_secrets_for_permanent_tier0_roles"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access",
|
||||
"secrets"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Tier 0 roles evaluated follow the Microsoft Entra Control Plane classification, including Global Administrator, Privileged Role Administrator, and Privileged Authentication Administrator."
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Check for service principals using client secrets with permanent Tier 0 role assignments."""
|
||||
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportM365
|
||||
from prowler.providers.m365.services.entra.entra_client import entra_client
|
||||
from prowler.providers.m365.services.entra.entra_service import TIER_0_ROLE_TEMPLATE_IDS
|
||||
|
||||
|
||||
class entra_service_principal_no_secrets_for_permanent_tier0_roles(Check):
|
||||
"""
|
||||
Service principal with permanent Control Plane role does not use client secrets.
|
||||
|
||||
This check evaluates whether service principals that hold permanent assignments
|
||||
to Tier 0 (Control Plane) directory roles authenticate using client secrets
|
||||
instead of more secure alternatives such as certificates or managed identities.
|
||||
|
||||
- PASS: The service principal does not use client secrets or does not hold a
|
||||
permanent Tier 0 directory role assignment.
|
||||
- FAIL: The service principal uses client secrets and has a permanent assignment
|
||||
to at least one Tier 0 directory role.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportM365]:
|
||||
"""Execute the service principal secret management check.
|
||||
|
||||
Iterates over service principals and identifies those that combine password
|
||||
credentials (client secrets) with permanent Tier 0 directory role assignments.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check for each service principal.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for sp in entra_client.service_principals.values():
|
||||
report = CheckReportM365(
|
||||
metadata=self.metadata(),
|
||||
resource=sp,
|
||||
resource_name=sp.name,
|
||||
resource_id=sp.id,
|
||||
)
|
||||
|
||||
active_secrets = [
|
||||
credential
|
||||
for credential in sp.password_credentials
|
||||
if credential.is_active()
|
||||
]
|
||||
has_secrets = len(active_secrets) > 0
|
||||
tier0_roles = [
|
||||
role_id
|
||||
for role_id in sp.directory_role_template_ids
|
||||
if role_id in TIER_0_ROLE_TEMPLATE_IDS
|
||||
]
|
||||
|
||||
if has_secrets and tier0_roles:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Service principal '{sp.name}' uses client secrets and has "
|
||||
f"permanent assignment to {len(tier0_roles)} Control Plane "
|
||||
f"(Tier 0) directory role(s)."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
if not has_secrets and not tier0_roles:
|
||||
report.status_extended = (
|
||||
f"Service principal '{sp.name}' does not use client secrets "
|
||||
f"and has no Tier 0 directory role assignments."
|
||||
)
|
||||
elif not has_secrets:
|
||||
report.status_extended = (
|
||||
f"Service principal '{sp.name}' does not use client secrets."
|
||||
)
|
||||
else:
|
||||
report.status_extended = (
|
||||
f"Service principal '{sp.name}' has no permanent Tier 0 "
|
||||
f"directory role assignments."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -95,7 +95,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
|
||||
name = "prowler"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.13"
|
||||
version = "5.26.0"
|
||||
version = "5.27.0"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
|
||||
@@ -17,7 +17,7 @@ MOCK_OLD_PROWLER_VERSION = "0.0.0"
|
||||
MOCK_PROWLER_MASTER_VERSION = "3.4.0"
|
||||
|
||||
|
||||
def mock_prowler_get_latest_release(_, **kwargs):
|
||||
def mock_prowler_get_latest_release(_, **_kwargs):
|
||||
"""Mock requests.get() to get the Prowler latest release"""
|
||||
response = Response()
|
||||
response._content = b'[{"name":"3.3.0"}]'
|
||||
@@ -75,6 +75,7 @@ config_aws = {
|
||||
"mute_non_default_regions": False,
|
||||
"max_unused_access_keys_days": 45,
|
||||
"max_console_access_days": 45,
|
||||
"max_unused_sagemaker_access_days": 90,
|
||||
"shodan_api_key": None,
|
||||
"max_security_group_rules": 50,
|
||||
"max_ec2_instance_age_in_days": 180,
|
||||
|
||||
@@ -20,6 +20,8 @@ aws:
|
||||
max_unused_access_keys_days: 45
|
||||
# aws.iam_user_console_access_unused --> CIS recommends 45 days
|
||||
max_console_access_days: 45
|
||||
# aws.iam_user_access_not_stale_to_sagemaker --> default 90 days
|
||||
max_unused_sagemaker_access_days: 90
|
||||
|
||||
# AWS EC2 Configuration
|
||||
# aws.ec2_elastic_ip_shodan
|
||||
|
||||
@@ -190,6 +190,258 @@ class Test_iam_role_access_not_stale_to_bedrock:
|
||||
assert "has never used them" in result[0].status_extended
|
||||
assert "Role" in result[0].status_extended
|
||||
|
||||
@mock_aws
|
||||
def test_no_roles_listed(self):
|
||||
"""No findings when iam.roles is None (short-circuit)."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
iam.roles = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import (
|
||||
iam_role_access_not_stale_to_bedrock,
|
||||
)
|
||||
|
||||
check = iam_role_access_not_stale_to_bedrock()
|
||||
assert check.execute() == []
|
||||
|
||||
@mock_aws
|
||||
def test_role_without_bedrock_permissions(self):
|
||||
"""Role with non-Bedrock services is skipped."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM, Role
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
|
||||
mock_role = Role(
|
||||
name=IAM_ROLE_NAME,
|
||||
arn=IAM_ROLE_ARN,
|
||||
assume_role_policy={},
|
||||
is_service_role=False,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
)
|
||||
iam.roles = [mock_role]
|
||||
iam.role_last_accessed_services = {
|
||||
ROLE_DATA: [
|
||||
{"ServiceNamespace": "iam", "ServiceName": "IAM"},
|
||||
{"ServiceNamespace": "s3", "ServiceName": "S3"},
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import (
|
||||
iam_role_access_not_stale_to_bedrock,
|
||||
)
|
||||
|
||||
check = iam_role_access_not_stale_to_bedrock()
|
||||
assert len(check.execute()) == 0
|
||||
|
||||
@mock_aws
|
||||
def test_role_bedrock_access_with_string_date(self):
|
||||
"""PASS when LastAuthenticated is an ISO string instead of a datetime object."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM, Role
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
|
||||
mock_role = Role(
|
||||
name=IAM_ROLE_NAME,
|
||||
arn=IAM_ROLE_ARN,
|
||||
assume_role_policy={},
|
||||
is_service_role=False,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
)
|
||||
iam.roles = [mock_role]
|
||||
|
||||
last_access_date = datetime.now(timezone.utc) - timedelta(days=5)
|
||||
iam.role_last_accessed_services = {
|
||||
ROLE_DATA: [
|
||||
{
|
||||
"ServiceNamespace": "bedrock",
|
||||
"ServiceName": "Amazon Bedrock",
|
||||
"LastAuthenticated": last_access_date.isoformat(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import (
|
||||
iam_role_access_not_stale_to_bedrock,
|
||||
)
|
||||
|
||||
check = iam_role_access_not_stale_to_bedrock()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
@mock_aws
|
||||
def test_role_bedrock_access_at_exact_threshold(self):
|
||||
"""PASS when role accessed Bedrock exactly at the 60-day boundary."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM, Role
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
|
||||
mock_role = Role(
|
||||
name=IAM_ROLE_NAME,
|
||||
arn=IAM_ROLE_ARN,
|
||||
assume_role_policy={},
|
||||
is_service_role=False,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
)
|
||||
iam.roles = [mock_role]
|
||||
|
||||
last_authenticated = datetime.now(timezone.utc) - timedelta(days=60)
|
||||
iam.role_last_accessed_services = {
|
||||
ROLE_DATA: [
|
||||
{
|
||||
"ServiceNamespace": "bedrock",
|
||||
"ServiceName": "Amazon Bedrock",
|
||||
"LastAuthenticated": last_authenticated,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import (
|
||||
iam_role_access_not_stale_to_bedrock,
|
||||
)
|
||||
|
||||
check = iam_role_access_not_stale_to_bedrock()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "60 days ago" in result[0].status_extended
|
||||
assert "threshold: 60 days" in result[0].status_extended
|
||||
|
||||
@mock_aws
|
||||
def test_role_bedrock_access_one_day_over_threshold(self):
|
||||
"""FAIL when role accessed Bedrock 61 days ago."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM, Role
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
|
||||
mock_role = Role(
|
||||
name=IAM_ROLE_NAME,
|
||||
arn=IAM_ROLE_ARN,
|
||||
assume_role_policy={},
|
||||
is_service_role=False,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
)
|
||||
iam.roles = [mock_role]
|
||||
|
||||
last_authenticated = datetime.now(timezone.utc) - timedelta(days=61)
|
||||
iam.role_last_accessed_services = {
|
||||
ROLE_DATA: [
|
||||
{
|
||||
"ServiceNamespace": "bedrock",
|
||||
"ServiceName": "Amazon Bedrock",
|
||||
"LastAuthenticated": last_authenticated,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import (
|
||||
iam_role_access_not_stale_to_bedrock,
|
||||
)
|
||||
|
||||
check = iam_role_access_not_stale_to_bedrock()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "61 days" in result[0].status_extended
|
||||
assert "threshold: 60 days" in result[0].status_extended
|
||||
|
||||
@mock_aws
|
||||
def test_custom_threshold_via_audit_config(self):
|
||||
"""Custom threshold from audit_config is respected for roles."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM, Role
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
iam.audit_config = {"max_unused_bedrock_access_days": 30}
|
||||
|
||||
mock_role = Role(
|
||||
name=IAM_ROLE_NAME,
|
||||
arn=IAM_ROLE_ARN,
|
||||
assume_role_policy={},
|
||||
is_service_role=False,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
)
|
||||
iam.roles = [mock_role]
|
||||
|
||||
last_authenticated = datetime.now(timezone.utc) - timedelta(days=45)
|
||||
iam.role_last_accessed_services = {
|
||||
ROLE_DATA: [
|
||||
{
|
||||
"ServiceNamespace": "bedrock",
|
||||
"ServiceName": "Amazon Bedrock",
|
||||
"LastAuthenticated": last_authenticated,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import (
|
||||
iam_role_access_not_stale_to_bedrock,
|
||||
)
|
||||
|
||||
check = iam_role_access_not_stale_to_bedrock()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "45 days" in result[0].status_extended
|
||||
assert "threshold: 30 days" in result[0].status_extended
|
||||
|
||||
@mock_aws
|
||||
def test_role_tags_are_populated(self):
|
||||
"""Verify resource_tags are populated from the role object."""
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from moto import mock_aws
|
||||
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_US_EAST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
IAM_USER_NAME = "test-user"
|
||||
IAM_USER_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:user/{IAM_USER_NAME}"
|
||||
USER_DATA = (IAM_USER_NAME, IAM_USER_ARN)
|
||||
|
||||
CHECK_MODULE = (
|
||||
"prowler.providers.aws.services.iam."
|
||||
"iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker"
|
||||
)
|
||||
|
||||
|
||||
class Test_iam_user_access_not_stale_to_sagemaker:
|
||||
@mock_aws
|
||||
def test_no_users_with_sagemaker_permissions(self):
|
||||
"""No findings when no users have SageMaker in last accessed services."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
iam.last_accessed_services = {}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import (
|
||||
iam_user_access_not_stale_to_sagemaker,
|
||||
)
|
||||
|
||||
check = iam_user_access_not_stale_to_sagemaker()
|
||||
assert len(check.execute()) == 0
|
||||
|
||||
@mock_aws
|
||||
def test_user_without_sagemaker_permissions(self):
|
||||
"""User with non-SageMaker services is skipped."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM, User
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
|
||||
mock_user = User(
|
||||
name=IAM_USER_NAME,
|
||||
arn=IAM_USER_ARN,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
)
|
||||
iam.users = [mock_user]
|
||||
iam.last_accessed_services = {
|
||||
USER_DATA: [
|
||||
{"ServiceNamespace": "iam", "ServiceName": "IAM"},
|
||||
{"ServiceNamespace": "s3", "ServiceName": "S3"},
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import (
|
||||
iam_user_access_not_stale_to_sagemaker,
|
||||
)
|
||||
|
||||
check = iam_user_access_not_stale_to_sagemaker()
|
||||
assert len(check.execute()) == 0
|
||||
|
||||
@mock_aws
|
||||
def test_user_sagemaker_access_recent(self):
|
||||
"""PASS when user accessed SageMaker within the threshold."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM, User
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
|
||||
mock_user = User(
|
||||
name=IAM_USER_NAME,
|
||||
arn=IAM_USER_ARN,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
)
|
||||
iam.users = [mock_user]
|
||||
|
||||
last_authenticated = datetime.now(timezone.utc) - timedelta(days=10)
|
||||
iam.last_accessed_services = {
|
||||
USER_DATA: [
|
||||
{
|
||||
"ServiceNamespace": "sagemaker",
|
||||
"ServiceName": "Amazon SageMaker",
|
||||
"LastAuthenticated": last_authenticated,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import (
|
||||
iam_user_access_not_stale_to_sagemaker,
|
||||
)
|
||||
|
||||
check = iam_user_access_not_stale_to_sagemaker()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "accessed SageMaker" in result[0].status_extended
|
||||
assert IAM_USER_NAME in result[0].status_extended
|
||||
assert result[0].resource_id == IAM_USER_NAME
|
||||
assert result[0].resource_arn == IAM_USER_ARN
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
|
||||
@mock_aws
|
||||
def test_user_sagemaker_access_stale(self):
|
||||
"""FAIL when user last accessed SageMaker more than 90 days ago."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM, User
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
|
||||
mock_user = User(
|
||||
name=IAM_USER_NAME,
|
||||
arn=IAM_USER_ARN,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
)
|
||||
iam.users = [mock_user]
|
||||
|
||||
last_authenticated = datetime.now(timezone.utc) - timedelta(days=120)
|
||||
iam.last_accessed_services = {
|
||||
USER_DATA: [
|
||||
{
|
||||
"ServiceNamespace": "sagemaker",
|
||||
"ServiceName": "Amazon SageMaker",
|
||||
"LastAuthenticated": last_authenticated,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import (
|
||||
iam_user_access_not_stale_to_sagemaker,
|
||||
)
|
||||
|
||||
check = iam_user_access_not_stale_to_sagemaker()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "has not accessed SageMaker" in result[0].status_extended
|
||||
assert "120 days" in result[0].status_extended
|
||||
assert IAM_USER_NAME in result[0].status_extended
|
||||
|
||||
@mock_aws
|
||||
def test_user_sagemaker_never_accessed(self):
|
||||
"""FAIL when user has SageMaker permissions but has never used them."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM, User
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
|
||||
mock_user = User(
|
||||
name=IAM_USER_NAME,
|
||||
arn=IAM_USER_ARN,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
)
|
||||
iam.users = [mock_user]
|
||||
|
||||
iam.last_accessed_services = {
|
||||
USER_DATA: [
|
||||
{
|
||||
"ServiceNamespace": "sagemaker",
|
||||
"ServiceName": "Amazon SageMaker",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import (
|
||||
iam_user_access_not_stale_to_sagemaker,
|
||||
)
|
||||
|
||||
check = iam_user_access_not_stale_to_sagemaker()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "has never used them" in result[0].status_extended
|
||||
|
||||
@mock_aws
|
||||
def test_custom_threshold_via_audit_config(self):
|
||||
"""Custom threshold from audit_config is respected."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM, User
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
iam.audit_config = {"max_unused_sagemaker_access_days": 30}
|
||||
|
||||
mock_user = User(
|
||||
name=IAM_USER_NAME,
|
||||
arn=IAM_USER_ARN,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
)
|
||||
iam.users = [mock_user]
|
||||
|
||||
last_authenticated = datetime.now(timezone.utc) - timedelta(days=45)
|
||||
iam.last_accessed_services = {
|
||||
USER_DATA: [
|
||||
{
|
||||
"ServiceNamespace": "sagemaker",
|
||||
"ServiceName": "Amazon SageMaker",
|
||||
"LastAuthenticated": last_authenticated,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import (
|
||||
iam_user_access_not_stale_to_sagemaker,
|
||||
)
|
||||
|
||||
check = iam_user_access_not_stale_to_sagemaker()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "45 days" in result[0].status_extended
|
||||
assert "threshold: 30 days" in result[0].status_extended
|
||||
|
||||
@mock_aws
|
||||
def test_user_sagemaker_access_at_exact_threshold(self):
|
||||
"""PASS when user accessed SageMaker exactly at the 90-day boundary."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM, User
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
|
||||
mock_user = User(
|
||||
name=IAM_USER_NAME,
|
||||
arn=IAM_USER_ARN,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
)
|
||||
iam.users = [mock_user]
|
||||
|
||||
last_authenticated = datetime.now(timezone.utc) - timedelta(days=90)
|
||||
iam.last_accessed_services = {
|
||||
USER_DATA: [
|
||||
{
|
||||
"ServiceNamespace": "sagemaker",
|
||||
"ServiceName": "Amazon SageMaker",
|
||||
"LastAuthenticated": last_authenticated,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import (
|
||||
iam_user_access_not_stale_to_sagemaker,
|
||||
)
|
||||
|
||||
check = iam_user_access_not_stale_to_sagemaker()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "90 days ago" in result[0].status_extended
|
||||
assert "threshold: 90 days" in result[0].status_extended
|
||||
|
||||
@mock_aws
|
||||
def test_user_sagemaker_access_one_day_over_threshold(self):
|
||||
"""FAIL when user accessed SageMaker 91 days ago."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM, User
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
|
||||
mock_user = User(
|
||||
name=IAM_USER_NAME,
|
||||
arn=IAM_USER_ARN,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
)
|
||||
iam.users = [mock_user]
|
||||
|
||||
last_authenticated = datetime.now(timezone.utc) - timedelta(days=91)
|
||||
iam.last_accessed_services = {
|
||||
USER_DATA: [
|
||||
{
|
||||
"ServiceNamespace": "sagemaker",
|
||||
"ServiceName": "Amazon SageMaker",
|
||||
"LastAuthenticated": last_authenticated,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import (
|
||||
iam_user_access_not_stale_to_sagemaker,
|
||||
)
|
||||
|
||||
check = iam_user_access_not_stale_to_sagemaker()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "91 days" in result[0].status_extended
|
||||
assert "threshold: 90 days" in result[0].status_extended
|
||||
|
||||
@mock_aws
|
||||
def test_user_sagemaker_access_with_string_date(self):
|
||||
"""PASS when LastAuthenticated is an ISO string instead of a datetime object."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM, User
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
|
||||
mock_user = User(
|
||||
name=IAM_USER_NAME,
|
||||
arn=IAM_USER_ARN,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
)
|
||||
iam.users = [mock_user]
|
||||
|
||||
last_access_date = datetime.now(timezone.utc) - timedelta(days=5)
|
||||
iam.last_accessed_services = {
|
||||
USER_DATA: [
|
||||
{
|
||||
"ServiceNamespace": "sagemaker",
|
||||
"ServiceName": "Amazon SageMaker",
|
||||
"LastAuthenticated": last_access_date.isoformat(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import (
|
||||
iam_user_access_not_stale_to_sagemaker,
|
||||
)
|
||||
|
||||
check = iam_user_access_not_stale_to_sagemaker()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
@mock_aws
|
||||
def test_user_tags_are_populated(self):
|
||||
"""Verify resource_tags are populated from the user object."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM, User
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
|
||||
user_tags = [{"Key": "Environment", "Value": "production"}]
|
||||
mock_user = User(
|
||||
name=IAM_USER_NAME,
|
||||
arn=IAM_USER_ARN,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
tags=user_tags,
|
||||
)
|
||||
iam.users = [mock_user]
|
||||
|
||||
last_authenticated = datetime.now(timezone.utc) - timedelta(days=10)
|
||||
iam.last_accessed_services = {
|
||||
USER_DATA: [
|
||||
{
|
||||
"ServiceNamespace": "sagemaker",
|
||||
"ServiceName": "Amazon SageMaker",
|
||||
"LastAuthenticated": last_authenticated,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import (
|
||||
iam_user_access_not_stale_to_sagemaker,
|
||||
)
|
||||
|
||||
check = iam_user_access_not_stale_to_sagemaker()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].resource_tags == user_tags
|
||||
|
||||
@mock_aws
|
||||
def test_multiple_users_mixed_results(self):
|
||||
"""Multiple users: one recent (PASS), one stale (FAIL), one without SageMaker (skipped)."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM, User
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
|
||||
recent_user_name = "recent-user"
|
||||
recent_user_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:user/{recent_user_name}"
|
||||
stale_user_name = "stale-user"
|
||||
stale_user_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:user/{stale_user_name}"
|
||||
no_sagemaker_user_name = "no-sagemaker-user"
|
||||
no_sagemaker_user_arn = (
|
||||
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:user/{no_sagemaker_user_name}"
|
||||
)
|
||||
|
||||
iam.users = [
|
||||
User(
|
||||
name=recent_user_name,
|
||||
arn=recent_user_arn,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
),
|
||||
User(
|
||||
name=stale_user_name,
|
||||
arn=stale_user_arn,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
),
|
||||
User(
|
||||
name=no_sagemaker_user_name,
|
||||
arn=no_sagemaker_user_arn,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
),
|
||||
]
|
||||
|
||||
recent_access = datetime.now(timezone.utc) - timedelta(days=10)
|
||||
stale_access = datetime.now(timezone.utc) - timedelta(days=120)
|
||||
iam.last_accessed_services = {
|
||||
(recent_user_name, recent_user_arn): [
|
||||
{
|
||||
"ServiceNamespace": "sagemaker",
|
||||
"ServiceName": "Amazon SageMaker",
|
||||
"LastAuthenticated": recent_access,
|
||||
},
|
||||
],
|
||||
(stale_user_name, stale_user_arn): [
|
||||
{
|
||||
"ServiceNamespace": "sagemaker",
|
||||
"ServiceName": "Amazon SageMaker",
|
||||
"LastAuthenticated": stale_access,
|
||||
},
|
||||
],
|
||||
(no_sagemaker_user_name, no_sagemaker_user_arn): [
|
||||
{"ServiceNamespace": "s3", "ServiceName": "S3"},
|
||||
],
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import (
|
||||
iam_user_access_not_stale_to_sagemaker,
|
||||
)
|
||||
|
||||
check = iam_user_access_not_stale_to_sagemaker()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 2
|
||||
results_by_id = {r.resource_id: r for r in result}
|
||||
|
||||
assert results_by_id[recent_user_name].status == "PASS"
|
||||
assert (
|
||||
"accessed SageMaker" in results_by_id[recent_user_name].status_extended
|
||||
)
|
||||
|
||||
assert results_by_id[stale_user_name].status == "FAIL"
|
||||
assert (
|
||||
"has not accessed SageMaker"
|
||||
in results_by_id[stale_user_name].status_extended
|
||||
)
|
||||
assert "120 days" in results_by_id[stale_user_name].status_extended
|
||||
|
||||
assert no_sagemaker_user_name not in results_by_id
|
||||
|
||||
@mock_aws
|
||||
def test_user_arn_not_in_users_list(self):
|
||||
"""No findings when last_accessed_services entries do not match any iam.users."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
iam.users = []
|
||||
|
||||
last_authenticated = datetime.now(timezone.utc) - timedelta(days=10)
|
||||
iam.last_accessed_services = {
|
||||
USER_DATA: [
|
||||
{
|
||||
"ServiceNamespace": "sagemaker",
|
||||
"ServiceName": "Amazon SageMaker",
|
||||
"LastAuthenticated": last_authenticated,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import (
|
||||
iam_user_access_not_stale_to_sagemaker,
|
||||
)
|
||||
|
||||
check = iam_user_access_not_stale_to_sagemaker()
|
||||
assert check.execute() == []
|
||||
|
||||
@mock_aws
|
||||
def test_sagemaker_among_multiple_services(self):
|
||||
"""SageMaker entry is correctly found when mixed with other services."""
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM, User
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam = IAM(aws_provider)
|
||||
|
||||
mock_user = User(
|
||||
name=IAM_USER_NAME,
|
||||
arn=IAM_USER_ARN,
|
||||
attached_policies=[],
|
||||
inline_policies=[],
|
||||
)
|
||||
iam.users = [mock_user]
|
||||
|
||||
last_authenticated = datetime.now(timezone.utc) - timedelta(days=15)
|
||||
iam.last_accessed_services = {
|
||||
USER_DATA: [
|
||||
{"ServiceNamespace": "iam", "ServiceName": "IAM"},
|
||||
{"ServiceNamespace": "s3", "ServiceName": "S3"},
|
||||
{
|
||||
"ServiceNamespace": "sagemaker",
|
||||
"ServiceName": "Amazon SageMaker",
|
||||
"LastAuthenticated": last_authenticated,
|
||||
},
|
||||
{"ServiceNamespace": "ec2", "ServiceName": "EC2"},
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.iam_client", new=iam),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_sagemaker.iam_user_access_not_stale_to_sagemaker import (
|
||||
iam_user_access_not_stale_to_sagemaker,
|
||||
)
|
||||
|
||||
check = iam_user_access_not_stale_to_sagemaker()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "accessed SageMaker" in result[0].status_extended
|
||||
assert "15 days ago" in result[0].status_extended
|
||||
@@ -47,7 +47,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "No enabled Conditional Access policies found. Emergency access exclusions are not required."
|
||||
== "No enabled Conditional Access policies with a Block grant control found. Emergency access exclusions are not required."
|
||||
)
|
||||
assert result[0].resource == {}
|
||||
assert result[0].resource_name == "Conditional Access Policies"
|
||||
@@ -98,7 +98,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.MFA],
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
@@ -122,7 +122,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "No enabled Conditional Access policies found. Emergency access exclusions are not required."
|
||||
== "No enabled Conditional Access policies with a Block grant control found. Emergency access exclusions are not required."
|
||||
)
|
||||
|
||||
def test_entra_no_emergency_access_exclusion(self):
|
||||
@@ -172,7 +172,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.MFA],
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
@@ -207,7 +207,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.MFA],
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
@@ -230,7 +230,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
"No user or group is excluded as emergency access from all 2 enabled Conditional Access policies"
|
||||
"No user or group is excluded as emergency access from all 2 enabled Conditional Access policies with a Block grant control"
|
||||
in result[0].status_extended
|
||||
)
|
||||
assert result[0].resource_name == "Conditional Access Policies"
|
||||
@@ -283,7 +283,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.MFA],
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
@@ -318,7 +318,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.MFA],
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
@@ -352,7 +352,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
assert result[0].status == "PASS"
|
||||
assert "BreakGlass1" in result[0].status_extended
|
||||
assert (
|
||||
"excluded from all 2 enabled Conditional Access policies"
|
||||
"excluded from all 2 enabled Conditional Access policies with a Block grant control"
|
||||
in result[0].status_extended
|
||||
)
|
||||
assert result[0].resource_name == "Conditional Access Policies"
|
||||
@@ -405,7 +405,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.MFA],
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
@@ -440,7 +440,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.MFA],
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
@@ -474,7 +474,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
assert result[0].status == "PASS"
|
||||
assert "BreakGlassGroup" in result[0].status_extended
|
||||
assert (
|
||||
"excluded from all 2 enabled Conditional Access policies"
|
||||
"excluded from all 2 enabled Conditional Access policies with a Block grant control"
|
||||
in result[0].status_extended
|
||||
)
|
||||
assert result[0].resource_name == "Conditional Access Policies"
|
||||
@@ -528,7 +528,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.MFA],
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
@@ -563,7 +563,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.MFA],
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
@@ -605,7 +605,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
assert "BreakGlass1" in result[0].status_extended
|
||||
assert "BreakGlassGroup" in result[0].status_extended
|
||||
assert (
|
||||
"excluded from all 2 enabled Conditional Access policies"
|
||||
"excluded from all 2 enabled Conditional Access policies with a Block grant control"
|
||||
in result[0].status_extended
|
||||
)
|
||||
assert result[0].resource_name == "Conditional Access Policies"
|
||||
@@ -658,7 +658,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.MFA],
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
@@ -693,7 +693,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.MFA],
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
@@ -729,7 +729,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
assert result[0].resource_id == "conditionalAccessPolicies"
|
||||
assert "BreakGlass1" in result[0].status_extended
|
||||
assert (
|
||||
"excluded from all 1 enabled Conditional Access policies"
|
||||
"excluded from all 1 enabled Conditional Access policies with a Block grant control"
|
||||
in result[0].status_extended
|
||||
)
|
||||
|
||||
@@ -781,7 +781,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.MFA],
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
@@ -816,7 +816,7 @@ class Test_entra_emergency_access_exclusion:
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.MFA],
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
@@ -850,8 +850,316 @@ class Test_entra_emergency_access_exclusion:
|
||||
assert result[0].status == "PASS"
|
||||
assert "BreakGlass1" in result[0].status_extended
|
||||
assert (
|
||||
"excluded from all 2 enabled Conditional Access policies"
|
||||
"excluded from all 2 enabled Conditional Access policies with a Block grant control"
|
||||
in result[0].status_extended
|
||||
)
|
||||
assert result[0].resource_name == "Conditional Access Policies"
|
||||
assert result[0].resource_id == "conditionalAccessPolicies"
|
||||
|
||||
def test_entra_only_non_blocking_policies(self):
|
||||
"""PASS when the tenant has only non-blocking policies (no Block grant)."""
|
||||
policy_id = str(uuid4())
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_emergency_access_exclusion.entra_emergency_access_exclusion.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_emergency_access_exclusion.entra_emergency_access_exclusion import (
|
||||
entra_emergency_access_exclusion,
|
||||
)
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
ConditionalAccessPolicy,
|
||||
)
|
||||
|
||||
entra_client.conditional_access_policies = {
|
||||
policy_id: ConditionalAccessPolicy(
|
||||
id=policy_id,
|
||||
display_name="MFA for all",
|
||||
conditions=Conditions(
|
||||
application_conditions=ApplicationsConditions(
|
||||
included_applications=["All"],
|
||||
excluded_applications=[],
|
||||
included_user_actions=[],
|
||||
),
|
||||
user_conditions=UsersConditions(
|
||||
included_groups=[],
|
||||
excluded_groups=[],
|
||||
included_users=["All"],
|
||||
excluded_users=[],
|
||||
included_roles=[],
|
||||
excluded_roles=[],
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.MFA],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
persistent_browser=PersistentBrowser(
|
||||
is_enabled=False, mode="always"
|
||||
),
|
||||
sign_in_frequency=SignInFrequency(
|
||||
is_enabled=False,
|
||||
frequency=None,
|
||||
type=None,
|
||||
interval=SignInFrequencyInterval.EVERY_TIME,
|
||||
),
|
||||
),
|
||||
state=ConditionalAccessPolicyState.ENABLED,
|
||||
),
|
||||
}
|
||||
|
||||
check = entra_emergency_access_exclusion()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "No enabled Conditional Access policies with a Block grant control found. Emergency access exclusions are not required."
|
||||
)
|
||||
|
||||
def test_entra_user_excluded_from_blocking_but_included_in_non_blocking(self):
|
||||
"""PASS when only Block-grant exclusions are required (non-blocking inclusion is ignored)."""
|
||||
block_policy_id = str(uuid4())
|
||||
mfa_policy_id = str(uuid4())
|
||||
emergency_user_id = "emergency-access-user"
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_emergency_access_exclusion.entra_emergency_access_exclusion.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_emergency_access_exclusion.entra_emergency_access_exclusion import (
|
||||
entra_emergency_access_exclusion,
|
||||
)
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
ConditionalAccessPolicy,
|
||||
)
|
||||
|
||||
entra_client.conditional_access_policies = {
|
||||
block_policy_id: ConditionalAccessPolicy(
|
||||
id=block_policy_id,
|
||||
display_name="Block legacy auth",
|
||||
conditions=Conditions(
|
||||
application_conditions=ApplicationsConditions(
|
||||
included_applications=["All"],
|
||||
excluded_applications=[],
|
||||
included_user_actions=[],
|
||||
),
|
||||
user_conditions=UsersConditions(
|
||||
included_groups=[],
|
||||
excluded_groups=[],
|
||||
included_users=["All"],
|
||||
excluded_users=[emergency_user_id],
|
||||
included_roles=[],
|
||||
excluded_roles=[],
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
persistent_browser=PersistentBrowser(
|
||||
is_enabled=False, mode="always"
|
||||
),
|
||||
sign_in_frequency=SignInFrequency(
|
||||
is_enabled=False,
|
||||
frequency=None,
|
||||
type=None,
|
||||
interval=SignInFrequencyInterval.EVERY_TIME,
|
||||
),
|
||||
),
|
||||
state=ConditionalAccessPolicyState.ENABLED,
|
||||
),
|
||||
mfa_policy_id: ConditionalAccessPolicy(
|
||||
id=mfa_policy_id,
|
||||
display_name="MFA for all",
|
||||
conditions=Conditions(
|
||||
application_conditions=ApplicationsConditions(
|
||||
included_applications=["All"],
|
||||
excluded_applications=[],
|
||||
included_user_actions=[],
|
||||
),
|
||||
user_conditions=UsersConditions(
|
||||
included_groups=[],
|
||||
excluded_groups=[],
|
||||
included_users=["All"],
|
||||
excluded_users=[],
|
||||
included_roles=[],
|
||||
excluded_roles=[],
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.MFA],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
persistent_browser=PersistentBrowser(
|
||||
is_enabled=False, mode="always"
|
||||
),
|
||||
sign_in_frequency=SignInFrequency(
|
||||
is_enabled=False,
|
||||
frequency=None,
|
||||
type=None,
|
||||
interval=SignInFrequencyInterval.EVERY_TIME,
|
||||
),
|
||||
),
|
||||
state=ConditionalAccessPolicyState.ENABLED,
|
||||
),
|
||||
}
|
||||
|
||||
entra_client.users = {
|
||||
emergency_user_id: User(
|
||||
id=emergency_user_id,
|
||||
name="BreakGlass1",
|
||||
on_premises_sync_enabled=False,
|
||||
authentication_methods=[],
|
||||
),
|
||||
}
|
||||
entra_client.groups = []
|
||||
|
||||
check = entra_emergency_access_exclusion()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "BreakGlass1" in result[0].status_extended
|
||||
assert (
|
||||
"excluded from all 1 enabled Conditional Access policies with a Block grant control"
|
||||
in result[0].status_extended
|
||||
)
|
||||
|
||||
def test_entra_user_excluded_only_from_subset_of_blocking_policies(self):
|
||||
"""FAIL when the user is excluded from one Block policy but not the other."""
|
||||
block_policy_id_1 = str(uuid4())
|
||||
block_policy_id_2 = str(uuid4())
|
||||
emergency_user_id = "emergency-access-user"
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_emergency_access_exclusion.entra_emergency_access_exclusion.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_emergency_access_exclusion.entra_emergency_access_exclusion import (
|
||||
entra_emergency_access_exclusion,
|
||||
)
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
ConditionalAccessPolicy,
|
||||
)
|
||||
|
||||
entra_client.conditional_access_policies = {
|
||||
block_policy_id_1: ConditionalAccessPolicy(
|
||||
id=block_policy_id_1,
|
||||
display_name="Block 1",
|
||||
conditions=Conditions(
|
||||
application_conditions=ApplicationsConditions(
|
||||
included_applications=["All"],
|
||||
excluded_applications=[],
|
||||
included_user_actions=[],
|
||||
),
|
||||
user_conditions=UsersConditions(
|
||||
included_groups=[],
|
||||
excluded_groups=[],
|
||||
included_users=["All"],
|
||||
excluded_users=[emergency_user_id],
|
||||
included_roles=[],
|
||||
excluded_roles=[],
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
persistent_browser=PersistentBrowser(
|
||||
is_enabled=False, mode="always"
|
||||
),
|
||||
sign_in_frequency=SignInFrequency(
|
||||
is_enabled=False,
|
||||
frequency=None,
|
||||
type=None,
|
||||
interval=SignInFrequencyInterval.EVERY_TIME,
|
||||
),
|
||||
),
|
||||
state=ConditionalAccessPolicyState.ENABLED,
|
||||
),
|
||||
block_policy_id_2: ConditionalAccessPolicy(
|
||||
id=block_policy_id_2,
|
||||
display_name="Block 2",
|
||||
conditions=Conditions(
|
||||
application_conditions=ApplicationsConditions(
|
||||
included_applications=["All"],
|
||||
excluded_applications=[],
|
||||
included_user_actions=[],
|
||||
),
|
||||
user_conditions=UsersConditions(
|
||||
included_groups=[],
|
||||
excluded_groups=[],
|
||||
included_users=["All"],
|
||||
excluded_users=[],
|
||||
included_roles=[],
|
||||
excluded_roles=[],
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
|
||||
operator=GrantControlOperator.AND,
|
||||
),
|
||||
session_controls=SessionControls(
|
||||
persistent_browser=PersistentBrowser(
|
||||
is_enabled=False, mode="always"
|
||||
),
|
||||
sign_in_frequency=SignInFrequency(
|
||||
is_enabled=False,
|
||||
frequency=None,
|
||||
type=None,
|
||||
interval=SignInFrequencyInterval.EVERY_TIME,
|
||||
),
|
||||
),
|
||||
state=ConditionalAccessPolicyState.ENABLED,
|
||||
),
|
||||
}
|
||||
|
||||
entra_client.users = {
|
||||
emergency_user_id: User(
|
||||
id=emergency_user_id,
|
||||
name="BreakGlass1",
|
||||
on_premises_sync_enabled=False,
|
||||
authentication_methods=[],
|
||||
),
|
||||
}
|
||||
entra_client.groups = []
|
||||
|
||||
check = entra_emergency_access_exclusion()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "No user or group is excluded as emergency access from all 2 enabled Conditional Access policies with a Block grant control."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
KeyCredential,
|
||||
PasswordCredential,
|
||||
ServicePrincipal,
|
||||
)
|
||||
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
|
||||
|
||||
|
||||
class Test_entra_service_principal_no_secrets_for_permanent_tier0_roles:
|
||||
"""Tests for the entra_service_principal_no_secrets_for_permanent_tier0_roles check."""
|
||||
|
||||
def test_no_service_principals(self):
|
||||
"""No service principals configured: expected no findings."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
def test_service_principal_no_secrets_no_roles(self):
|
||||
"""Service principal without secrets and no Tier 0 roles: expected PASS."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="TestApp",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[],
|
||||
key_credentials=[
|
||||
KeyCredential(key_id=str(uuid4()), display_name="cert1")
|
||||
],
|
||||
directory_role_template_ids=[],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "does not use client secrets" in result[0].status_extended
|
||||
assert result[0].resource_id == sp_id
|
||||
assert result[0].resource_name == "TestApp"
|
||||
|
||||
def test_service_principal_with_secrets_no_tier0_roles(self):
|
||||
"""Service principal with secrets but no Tier 0 roles: expected PASS."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
non_tier0_role = "4a5d8f65-41da-4de4-8968-e035b65339cf" # Reports Reader
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="AppWithSecrets",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[
|
||||
PasswordCredential(key_id=str(uuid4()), display_name="secret1")
|
||||
],
|
||||
key_credentials=[],
|
||||
directory_role_template_ids=[non_tier0_role],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "no permanent Tier 0" in result[0].status_extended
|
||||
assert result[0].resource_id == sp_id
|
||||
|
||||
def test_service_principal_no_secrets_with_tier0_roles(self):
|
||||
"""Service principal without secrets but with Tier 0 roles: expected PASS."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="CertBasedApp",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[],
|
||||
key_credentials=[
|
||||
KeyCredential(key_id=str(uuid4()), display_name="cert1")
|
||||
],
|
||||
directory_role_template_ids=[global_admin_role],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "does not use client secrets" in result[0].status_extended
|
||||
assert result[0].resource_id == sp_id
|
||||
|
||||
def test_service_principal_with_secrets_and_tier0_role(self):
|
||||
"""Service principal with secrets and Tier 0 role: expected FAIL."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="VulnerableApp",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[
|
||||
PasswordCredential(key_id=str(uuid4()), display_name="secret1")
|
||||
],
|
||||
key_credentials=[],
|
||||
directory_role_template_ids=[global_admin_role],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "uses client secrets" in result[0].status_extended
|
||||
assert "Control Plane" in result[0].status_extended
|
||||
assert result[0].resource_id == sp_id
|
||||
assert result[0].resource_name == "VulnerableApp"
|
||||
|
||||
def test_service_principal_with_secrets_and_multiple_tier0_roles(self):
|
||||
"""Service principal with secrets and multiple Tier 0 roles: expected FAIL."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
|
||||
priv_role_admin = "e8611ab8-c189-46e8-94e1-60213ab1f814"
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="HighRiskApp",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[
|
||||
PasswordCredential(key_id=str(uuid4()), display_name="secret1"),
|
||||
PasswordCredential(key_id=str(uuid4()), display_name="secret2"),
|
||||
],
|
||||
key_credentials=[],
|
||||
directory_role_template_ids=[
|
||||
global_admin_role,
|
||||
priv_role_admin,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "2 Control Plane" in result[0].status_extended
|
||||
|
||||
def test_multiple_service_principals_mixed(self):
|
||||
"""Multiple service principals with mixed states: mixed results."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id_pass = str(uuid4())
|
||||
sp_id_fail = str(uuid4())
|
||||
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id_pass: ServicePrincipal(
|
||||
id=sp_id_pass,
|
||||
name="SafeApp",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[],
|
||||
key_credentials=[
|
||||
KeyCredential(key_id=str(uuid4()), display_name="cert1")
|
||||
],
|
||||
directory_role_template_ids=[global_admin_role],
|
||||
),
|
||||
sp_id_fail: ServicePrincipal(
|
||||
id=sp_id_fail,
|
||||
name="UnsafeApp",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[
|
||||
PasswordCredential(key_id=str(uuid4()), display_name="secret1")
|
||||
],
|
||||
key_credentials=[],
|
||||
directory_role_template_ids=[global_admin_role],
|
||||
),
|
||||
}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 2
|
||||
statuses = {r.resource_id: r.status for r in result}
|
||||
assert statuses[sp_id_pass] == "PASS"
|
||||
assert statuses[sp_id_fail] == "FAIL"
|
||||
|
||||
def test_service_principal_only_expired_secrets_with_tier0_role(self):
|
||||
"""Service principal whose only client secrets are expired: expected PASS."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
|
||||
expired = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="LegacyApp",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[
|
||||
PasswordCredential(
|
||||
key_id=str(uuid4()),
|
||||
display_name="expired-secret",
|
||||
end_date_time=expired,
|
||||
)
|
||||
],
|
||||
key_credentials=[],
|
||||
directory_role_template_ids=[global_admin_role],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "does not use client secrets" in result[0].status_extended
|
||||
|
||||
def test_service_principal_active_and_expired_secrets_with_tier0_role(self):
|
||||
"""Service principal with at least one active client secret: expected FAIL."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
|
||||
expired = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
future = datetime.now(timezone.utc) + timedelta(days=180)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="MixedSecretsApp",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[
|
||||
PasswordCredential(
|
||||
key_id=str(uuid4()),
|
||||
display_name="old",
|
||||
end_date_time=expired,
|
||||
),
|
||||
PasswordCredential(
|
||||
key_id=str(uuid4()),
|
||||
display_name="current",
|
||||
end_date_time=future,
|
||||
),
|
||||
],
|
||||
key_credentials=[],
|
||||
directory_role_template_ids=[global_admin_role],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "uses client secrets" in result[0].status_extended
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -521,10 +522,26 @@ class Test_Entra_Service:
|
||||
|
||||
assert len(users) == 6
|
||||
assert users_builder.get.await_count == 1
|
||||
assert users_builder.get.await_args.kwargs == {}
|
||||
# The Graph users.get() call must request accountEnabled, userType and
|
||||
# onPremisesSyncEnabled via $select. They are not part of the default
|
||||
# property set, and omitting them causes disabled guest users to leak
|
||||
# into checks like entra_users_mfa_capable (issue #10921).
|
||||
request_configuration = users_builder.get.await_args.kwargs[
|
||||
"request_configuration"
|
||||
]
|
||||
assert set(request_configuration.query_parameters.select) == {
|
||||
"id",
|
||||
"displayName",
|
||||
"userType",
|
||||
"accountEnabled",
|
||||
"onPremisesSyncEnabled",
|
||||
}
|
||||
with_url_mock.assert_called_once_with("next-link")
|
||||
assert users["user-1"].directory_roles_ids == ["role-template-1"]
|
||||
assert users["user-6"].directory_roles_ids == ["role-template-1"]
|
||||
# When Graph does not return accountEnabled (legacy SimpleNamespace
|
||||
# fixtures) we still honour the EXO PowerShell fallback for backwards
|
||||
# compatibility.
|
||||
assert users["user-6"].account_enabled is False
|
||||
assert users["user-1"].is_mfa_capable is True
|
||||
assert users["user-2"].is_mfa_capable is False
|
||||
@@ -532,6 +549,81 @@ class Test_Entra_Service:
|
||||
assert users["user-6"].authentication_methods == ["mobilePhone"]
|
||||
assert users["user-2"].authentication_methods == []
|
||||
|
||||
def test__get_users_uses_graph_account_enabled_for_disabled_guests(self):
|
||||
"""Regression test for https://github.com/prowler-cloud/prowler/issues/10921.
|
||||
|
||||
Disabled guest users do not appear in EXO's ``Get-User`` output, so the
|
||||
previous code resolved their ``account_enabled`` from the EXO map,
|
||||
defaulted it to ``True`` and surfaced them as failing findings in
|
||||
``entra_users_mfa_capable``. The Graph ``accountEnabled`` value must be
|
||||
used as the source of truth so disabled guests are excluded.
|
||||
"""
|
||||
entra_service = Entra.__new__(Entra)
|
||||
# Empty EXO map mirrors the production scenario where the disabled guest
|
||||
# is absent from Get-User results.
|
||||
entra_service.user_accounts_status = {}
|
||||
|
||||
graph_users = [
|
||||
SimpleNamespace(
|
||||
id="member-1",
|
||||
display_name="Member User",
|
||||
on_premises_sync_enabled=False,
|
||||
account_enabled=True,
|
||||
user_type="Member",
|
||||
),
|
||||
SimpleNamespace(
|
||||
id="guest-1",
|
||||
display_name="Disabled Guest",
|
||||
on_premises_sync_enabled=False,
|
||||
account_enabled=False,
|
||||
user_type="Guest",
|
||||
),
|
||||
SimpleNamespace(
|
||||
id="guest-2",
|
||||
display_name="Enabled Guest",
|
||||
on_premises_sync_enabled=False,
|
||||
account_enabled=True,
|
||||
user_type="Guest",
|
||||
),
|
||||
]
|
||||
users_response = SimpleNamespace(
|
||||
value=graph_users,
|
||||
odata_next_link=None,
|
||||
)
|
||||
users_builder = SimpleNamespace(
|
||||
get=AsyncMock(return_value=users_response),
|
||||
with_url=MagicMock(),
|
||||
)
|
||||
directory_roles_builder = SimpleNamespace(
|
||||
get=AsyncMock(return_value=SimpleNamespace(value=[])),
|
||||
by_directory_role_id=MagicMock(),
|
||||
)
|
||||
registration_details_builder = SimpleNamespace(
|
||||
get=AsyncMock(return_value=SimpleNamespace(value=[], odata_next_link=None)),
|
||||
with_url=MagicMock(),
|
||||
)
|
||||
reports_builder = SimpleNamespace(
|
||||
authentication_methods=SimpleNamespace(
|
||||
user_registration_details=registration_details_builder
|
||||
)
|
||||
)
|
||||
|
||||
entra_service.client = SimpleNamespace(
|
||||
users=users_builder,
|
||||
directory_roles=directory_roles_builder,
|
||||
reports=reports_builder,
|
||||
)
|
||||
|
||||
users = asyncio.run(entra_service._get_users())
|
||||
|
||||
assert len(users) == 3
|
||||
assert users["member-1"].account_enabled is True
|
||||
assert users["member-1"].user_type == "Member"
|
||||
assert users["guest-1"].account_enabled is False
|
||||
assert users["guest-1"].user_type == "Guest"
|
||||
assert users["guest-2"].account_enabled is True
|
||||
assert users["guest-2"].user_type == "Guest"
|
||||
|
||||
def test__get_user_registration_details_handles_pagination(self):
|
||||
entra_service = Entra.__new__(Entra)
|
||||
|
||||
@@ -593,3 +685,159 @@ class Test_Entra_Service:
|
||||
registration_builder.get.assert_awaited()
|
||||
registration_builder.with_url.assert_called_once_with("next-link")
|
||||
registration_builder_next.get.assert_awaited()
|
||||
|
||||
def test__get_service_principals_filters_third_party_owners(self):
|
||||
"""Service principals owned by another tenant must not be returned."""
|
||||
# Mixed-case input to verify the service normalizes both sides before
|
||||
# comparison, so a Graph response that returns the owner id in upper
|
||||
# case still matches the lower-case identity in the provider.
|
||||
tenant_id_in = "AAAAAAAA-1111-1111-1111-111111111111"
|
||||
tenant_id_lower = tenant_id_in.lower()
|
||||
microsoft_tenant_id = "f8cdef31-a31e-4b4a-93e4-5f571e91255a"
|
||||
|
||||
owned_sp = SimpleNamespace(
|
||||
id="sp-owned",
|
||||
display_name="Customer App",
|
||||
app_id="app-owned",
|
||||
app_owner_organization_id=tenant_id_in,
|
||||
password_credentials=[
|
||||
SimpleNamespace(
|
||||
key_id="cred-1",
|
||||
display_name="secret",
|
||||
end_date_time=None,
|
||||
)
|
||||
],
|
||||
key_credentials=[],
|
||||
)
|
||||
first_party_sp = SimpleNamespace(
|
||||
id="sp-first-party",
|
||||
display_name="Microsoft Graph",
|
||||
app_id="app-graph",
|
||||
app_owner_organization_id=microsoft_tenant_id,
|
||||
password_credentials=[
|
||||
SimpleNamespace(
|
||||
key_id="cred-2",
|
||||
display_name="secret",
|
||||
end_date_time=None,
|
||||
)
|
||||
],
|
||||
key_credentials=[],
|
||||
)
|
||||
third_party_sp = SimpleNamespace(
|
||||
id="sp-third-party",
|
||||
display_name="Some Vendor App",
|
||||
app_id="app-vendor",
|
||||
app_owner_organization_id="22222222-2222-2222-2222-222222222222",
|
||||
password_credentials=[],
|
||||
key_credentials=[],
|
||||
)
|
||||
|
||||
sp_response = SimpleNamespace(
|
||||
value=[owned_sp, first_party_sp, third_party_sp],
|
||||
odata_next_link=None,
|
||||
)
|
||||
|
||||
empty_assignments_response = SimpleNamespace(value=[], odata_next_link=None)
|
||||
|
||||
role_assignments_builder = SimpleNamespace(
|
||||
get=AsyncMock(return_value=empty_assignments_response)
|
||||
)
|
||||
role_management_builder = SimpleNamespace(
|
||||
directory=SimpleNamespace(
|
||||
role_assignments=role_assignments_builder,
|
||||
)
|
||||
)
|
||||
|
||||
service_principals_builder = SimpleNamespace(
|
||||
get=AsyncMock(return_value=sp_response),
|
||||
with_url=MagicMock(),
|
||||
)
|
||||
|
||||
# The /applications endpoint returns no entries for this case, so the
|
||||
# service-level test just exercises the customer-owned filter, not the
|
||||
# secret join.
|
||||
applications_response = SimpleNamespace(value=[], odata_next_link=None)
|
||||
applications_builder = SimpleNamespace(
|
||||
get=AsyncMock(return_value=applications_response),
|
||||
with_url=MagicMock(),
|
||||
)
|
||||
|
||||
entra_service = Entra.__new__(Entra)
|
||||
entra_service.tenant_id = tenant_id_lower
|
||||
entra_service.client = SimpleNamespace(
|
||||
service_principals=service_principals_builder,
|
||||
role_management=role_management_builder,
|
||||
applications=applications_builder,
|
||||
)
|
||||
|
||||
result = asyncio.run(entra_service._get_service_principals())
|
||||
|
||||
assert set(result.keys()) == {"sp-owned"}
|
||||
assert result["sp-owned"].app_owner_organization_id == tenant_id_lower
|
||||
|
||||
def test__get_service_principals_merges_application_credentials(self):
|
||||
"""Secrets registered on the parent Application must be attributed to the SP."""
|
||||
tenant_id = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
# SP returned by Graph with NO password_credentials of its own (the
|
||||
# common case in production when the secret was added through "App
|
||||
# registrations > Certificates & secrets").
|
||||
sp_without_sp_level_secret = SimpleNamespace(
|
||||
id="sp-owned",
|
||||
display_name="m365-dev",
|
||||
app_id="app-owned",
|
||||
app_owner_organization_id=tenant_id,
|
||||
password_credentials=[],
|
||||
key_credentials=[],
|
||||
)
|
||||
sp_response = SimpleNamespace(
|
||||
value=[sp_without_sp_level_secret], odata_next_link=None
|
||||
)
|
||||
|
||||
# The corresponding Application carries the actual secret.
|
||||
future = datetime(2099, 1, 1, tzinfo=timezone.utc)
|
||||
application = SimpleNamespace(
|
||||
id="app-object",
|
||||
app_id="app-owned",
|
||||
password_credentials=[
|
||||
SimpleNamespace(
|
||||
key_id="cred-app",
|
||||
display_name="app-level-secret",
|
||||
end_date_time=future,
|
||||
),
|
||||
],
|
||||
key_credentials=[],
|
||||
)
|
||||
applications_response = SimpleNamespace(
|
||||
value=[application], odata_next_link=None
|
||||
)
|
||||
|
||||
empty_assignments_response = SimpleNamespace(value=[], odata_next_link=None)
|
||||
|
||||
entra_service = Entra.__new__(Entra)
|
||||
entra_service.tenant_id = tenant_id
|
||||
entra_service.client = SimpleNamespace(
|
||||
service_principals=SimpleNamespace(
|
||||
get=AsyncMock(return_value=sp_response),
|
||||
with_url=MagicMock(),
|
||||
),
|
||||
applications=SimpleNamespace(
|
||||
get=AsyncMock(return_value=applications_response),
|
||||
with_url=MagicMock(),
|
||||
),
|
||||
role_management=SimpleNamespace(
|
||||
directory=SimpleNamespace(
|
||||
role_assignments=SimpleNamespace(
|
||||
get=AsyncMock(return_value=empty_assignments_response),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
result = asyncio.run(entra_service._get_service_principals())
|
||||
|
||||
merged = result["sp-owned"]
|
||||
assert len(merged.password_credentials) == 1
|
||||
assert merged.password_credentials[0].key_id == "cred-app"
|
||||
assert merged.password_credentials[0].display_name == "app-level-secret"
|
||||
assert merged.password_credentials[0].is_active()
|
||||
|
||||
@@ -2,6 +2,27 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.27.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Trimmed unused npm dependencies [(#11115)](https://github.com/prowler-cloud/prowler/pull/11115)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Launch Scan first-provider wizard continues after provider creation instead of resetting the Scans page [(#11136)](https://github.com/prowler-cloud/prowler/pull/11136)
|
||||
|
||||
---
|
||||
|
||||
## [1.26.1] (Prowler 5.26.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Role form Cancel buttons now return to Roles [(#11125)](https://github.com/prowler-cloud/prowler/pull/11125)
|
||||
- Shared select dropdowns stay constrained and scrollable inside modals [(#11125)](https://github.com/prowler-cloud/prowler/pull/11125)
|
||||
|
||||
---
|
||||
|
||||
## [1.26.0] (Prowler v5.26.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -4,16 +4,11 @@ import { getAllProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { auth } from "@/auth.config";
|
||||
import { MutedFindingsConfigButton } from "@/components/providers";
|
||||
import {
|
||||
NoProvidersAdded,
|
||||
NoProvidersConnected,
|
||||
ScansFilters,
|
||||
} from "@/components/scans";
|
||||
import { LaunchScanWorkflow } from "@/components/scans/launch-workflow";
|
||||
import { ScansFilters } from "@/components/scans";
|
||||
import { ScansLaunchSection } from "@/components/scans/scans-launch-section";
|
||||
import { SkeletonTableScans } from "@/components/scans/table";
|
||||
import { ScansTableWithPolling } from "@/components/scans/table/scans";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { CustomBanner } from "@/components/ui/custom/custom-banner";
|
||||
import {
|
||||
createProviderDetailsMapping,
|
||||
extractProviderUIDs,
|
||||
@@ -81,11 +76,15 @@ export default async function Scans({
|
||||
const thereIsNoProviders =
|
||||
!providersData?.data || providersData.data.length === 0;
|
||||
|
||||
const thereIsNoProvidersConnected = providersData?.data?.every(
|
||||
(provider: ProviderProps) => !provider.attributes.connection.connected,
|
||||
const thereIsNoProvidersConnected = Boolean(
|
||||
providersData?.data?.every(
|
||||
(provider: ProviderProps) => !provider.attributes.connection.connected,
|
||||
),
|
||||
);
|
||||
|
||||
const hasManageScansPermission = session?.user?.permissions?.manage_scans;
|
||||
const hasManageScansPermission = Boolean(
|
||||
session?.user?.permissions?.manage_scans,
|
||||
);
|
||||
|
||||
// Extract provider UIDs and create provider details mapping for filtering
|
||||
const providerUIDs = providersData ? extractProviderUIDs(providersData) : [];
|
||||
@@ -93,44 +92,30 @@ export default async function Scans({
|
||||
? createProviderDetailsMapping(providerUIDs, providersData)
|
||||
: [];
|
||||
|
||||
if (thereIsNoProviders) {
|
||||
return (
|
||||
<ContentLayout title="Scans" icon="lucide:timer">
|
||||
<NoProvidersAdded />
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentLayout title="Scans" icon="lucide:timer">
|
||||
<>
|
||||
<>
|
||||
{!hasManageScansPermission ? (
|
||||
<CustomBanner
|
||||
title={"Access Denied"}
|
||||
message={"You don't have permission to launch the scan."}
|
||||
<ScansLaunchSection
|
||||
providers={providerInfo}
|
||||
hasManageScansPermission={hasManageScansPermission}
|
||||
thereIsNoProviders={thereIsNoProviders}
|
||||
thereIsNoProvidersConnected={thereIsNoProvidersConnected}
|
||||
/>
|
||||
{!thereIsNoProviders && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ScansFilters
|
||||
providerUIDs={providerUIDs}
|
||||
providerDetails={providerDetails}
|
||||
completedScans={completedScans}
|
||||
/>
|
||||
) : thereIsNoProvidersConnected ? (
|
||||
<>
|
||||
<NoProvidersConnected />
|
||||
</>
|
||||
) : (
|
||||
<LaunchScanWorkflow providers={providerInfo} />
|
||||
)}
|
||||
</>
|
||||
<div className="flex flex-col gap-6">
|
||||
<ScansFilters
|
||||
providerUIDs={providerUIDs}
|
||||
providerDetails={providerDetails}
|
||||
completedScans={completedScans}
|
||||
/>
|
||||
<div className="flex items-center justify-end">
|
||||
<MutedFindingsConfigButton />
|
||||
<div className="flex items-center justify-end">
|
||||
<MutedFindingsConfigButton />
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTableScans />}>
|
||||
<SSRDataTableScans searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTableScans />}>
|
||||
<SSRDataTableScans searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AddRoleForm } from "./add-role-form";
|
||||
|
||||
const routerMocks = vi.hoisted(() => ({
|
||||
push: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useRouter: () => routerMocks,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/roles/roles", () => ({
|
||||
@@ -73,6 +78,7 @@ vi.mock("@/components/ui", () => ({
|
||||
|
||||
describe("AddRoleForm", () => {
|
||||
afterEach(() => {
|
||||
routerMocks.push.mockClear();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
@@ -99,4 +105,16 @@ describe("AddRoleForm", () => {
|
||||
expect(screen.queryByText("Manage Alerts")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Manage Billing")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates back to roles when cancel is clicked", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<AddRoleForm groups={[]} />);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
|
||||
// Then
|
||||
expect(routerMocks.push).toHaveBeenCalledWith("/roles");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -252,7 +252,11 @@ export const AddRoleForm = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<FormButtons submitText="Add Role" isDisabled={isLoading} />
|
||||
<FormButtons
|
||||
submitText="Add Role"
|
||||
isDisabled={isLoading}
|
||||
onCancel={() => router.push("/roles")}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -271,7 +271,11 @@ export const EditRoleForm = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<FormButtons submitText="Update Role" isDisabled={isLoading} />
|
||||
<FormButtons
|
||||
submitText="Update Role"
|
||||
isDisabled={isLoading}
|
||||
onCancel={() => router.push("/roles")}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -11,23 +11,15 @@ import { Button } from "@/components/shadcn";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { toast } from "@/components/ui/toast";
|
||||
import { onDemandScanFormSchema } from "@/types";
|
||||
import { onDemandScanFormSchema, ScanProviderInfo } from "@/types";
|
||||
|
||||
import { SCAN_LAUNCHED_EVENT } from "../table/scans/scans-table-with-polling";
|
||||
import { SelectScanProvider } from "./select-scan-provider";
|
||||
|
||||
type ProviderInfo = {
|
||||
providerId: string;
|
||||
alias: string;
|
||||
providerType: string;
|
||||
uid: string;
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
export const LaunchScanWorkflow = ({
|
||||
providers,
|
||||
}: {
|
||||
providers: ProviderInfo[];
|
||||
providers: ScanProviderInfo[];
|
||||
}) => {
|
||||
const formSchema = z.object({
|
||||
...onDemandScanFormSchema().shape,
|
||||
|
||||
@@ -11,18 +11,13 @@ import {
|
||||
} from "@/components/shadcn";
|
||||
import { EntityInfo } from "@/components/ui/entities";
|
||||
import { FormControl, FormField, FormMessage } from "@/components/ui/form";
|
||||
import { ScanProviderInfo } from "@/types";
|
||||
|
||||
interface SelectScanProviderProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> {
|
||||
providers: {
|
||||
providerId: string;
|
||||
alias: string;
|
||||
providerType: string;
|
||||
uid: string;
|
||||
connected: boolean;
|
||||
}[];
|
||||
providers: ScanProviderInfo[];
|
||||
control: Control<TFieldValues>;
|
||||
name: TName;
|
||||
}
|
||||
|
||||
@@ -1,45 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
import { Button, Card, CardContent } from "@/components/shadcn";
|
||||
|
||||
import { InfoIcon } from "../icons/Icons";
|
||||
|
||||
export const NoProvidersAdded = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
interface NoProvidersAddedProps {
|
||||
onOpenWizard: () => void;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card variant="base" className="mx-auto w-full max-w-3xl">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
|
||||
No Providers Configured
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
|
||||
No providers have been configured. Start by setting up a
|
||||
provider.
|
||||
</p>
|
||||
</div>
|
||||
export const NoProvidersAdded = ({ onOpenWizard }: NoProvidersAddedProps) => (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card variant="base" className="mx-auto w-full max-w-3xl">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
|
||||
No Providers Configured
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
|
||||
No providers have been configured. Start by setting up a provider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
aria-label="Open Add Provider modal"
|
||||
className="w-full max-w-xs justify-center"
|
||||
size="lg"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<ProviderWizardModal open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
<Button
|
||||
aria-label="Open Add Provider modal"
|
||||
className="w-full max-w-xs justify-center"
|
||||
size="lg"
|
||||
onClick={onOpenWizard}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ScansLaunchSection } from "./scans-launch-section";
|
||||
|
||||
vi.mock("@/components/providers/wizard", () => ({
|
||||
ProviderWizardModal: ({ open }: { open: boolean }) =>
|
||||
open ? <div role="dialog">Provider wizard</div> : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/scans/launch-workflow", () => ({
|
||||
LaunchScanWorkflow: () => <div>Launch scan workflow</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/scans/no-providers-connected", () => ({
|
||||
NoProvidersConnected: () => <div>No providers connected</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/custom/custom-banner", () => ({
|
||||
CustomBanner: ({ title }: { title: string }) => <div>{title}</div>,
|
||||
}));
|
||||
|
||||
const connectedProvider = {
|
||||
providerId: "provider-1",
|
||||
alias: "Production",
|
||||
providerType: "aws",
|
||||
uid: "123456789012",
|
||||
connected: true,
|
||||
};
|
||||
|
||||
describe("ScansLaunchSection", () => {
|
||||
it("should keep the provider wizard open when providers data refreshes after adding the first provider", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<ScansLaunchSection
|
||||
providers={[]}
|
||||
hasManageScansPermission
|
||||
thereIsNoProviders
|
||||
thereIsNoProvidersConnected
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
rerender(
|
||||
<ScansLaunchSection
|
||||
providers={[connectedProvider]}
|
||||
hasManageScansPermission
|
||||
thereIsNoProviders={false}
|
||||
thereIsNoProvidersConnected={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
expect(screen.getByText("Launch scan workflow")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
import { LaunchScanWorkflow } from "@/components/scans/launch-workflow";
|
||||
import { NoProvidersAdded } from "@/components/scans/no-providers-added";
|
||||
import { NoProvidersConnected } from "@/components/scans/no-providers-connected";
|
||||
import { CustomBanner } from "@/components/ui/custom/custom-banner";
|
||||
import { ScanProviderInfo } from "@/types";
|
||||
|
||||
interface ScansLaunchSectionProps {
|
||||
providers: ScanProviderInfo[];
|
||||
hasManageScansPermission: boolean;
|
||||
thereIsNoProviders: boolean;
|
||||
thereIsNoProvidersConnected: boolean;
|
||||
}
|
||||
|
||||
export function ScansLaunchSection({
|
||||
providers,
|
||||
hasManageScansPermission,
|
||||
thereIsNoProviders,
|
||||
thereIsNoProvidersConnected,
|
||||
}: ScansLaunchSectionProps) {
|
||||
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{thereIsNoProviders ? (
|
||||
<NoProvidersAdded onOpenWizard={() => setIsProviderWizardOpen(true)} />
|
||||
) : !hasManageScansPermission ? (
|
||||
<CustomBanner
|
||||
title={"Access Denied"}
|
||||
message={"You don't have permission to launch the scan."}
|
||||
/>
|
||||
) : thereIsNoProvidersConnected ? (
|
||||
<NoProvidersConnected />
|
||||
) : (
|
||||
<LaunchScanWorkflow providers={providers} />
|
||||
)}
|
||||
<ProviderWizardModal
|
||||
open={isProviderWizardOpen}
|
||||
onOpenChange={setIsProviderWizardOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -239,6 +239,46 @@ describe("MultiSelect", () => {
|
||||
expect(screen.getByRole("dialog")).toHaveClass("max-w-[24rem]");
|
||||
});
|
||||
|
||||
it("keeps long option lists scrollable inside the dropdown", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<MultiSelect values={[]} onValuesChange={() => {}}>
|
||||
<MultiSelectTrigger>
|
||||
<MultiSelectValue placeholder="Select accounts" />
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={false}>
|
||||
{Array.from({ length: 20 }, (_, index) => (
|
||||
<MultiSelectItem key={index} value={`account-${index}`}>
|
||||
Account {index}
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
|
||||
// Then
|
||||
const list = screen
|
||||
.getByRole("dialog")
|
||||
.querySelector('[data-slot="command-list"]');
|
||||
|
||||
expect(screen.getByRole("dialog")).toHaveStyle({
|
||||
maxHeight:
|
||||
"min(360px, var(--radix-popover-content-available-height, 360px))",
|
||||
});
|
||||
expect(list).toHaveClass("minimal-scrollbar");
|
||||
expect(list).toHaveStyle({
|
||||
maxHeight:
|
||||
"min(300px, var(--radix-popover-content-available-height, 300px))",
|
||||
});
|
||||
expect(list).toHaveClass("overflow-y-auto");
|
||||
expect(list).toHaveClass("overscroll-contain");
|
||||
});
|
||||
|
||||
it("keeps the legacy clear-all behavior by default", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValuesChange = vi.fn();
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type WheelEvent,
|
||||
} from "react";
|
||||
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
@@ -49,6 +50,10 @@ type MultiSelectContextType = {
|
||||
};
|
||||
const MultiSelectContext = createContext<MultiSelectContextType | null>(null);
|
||||
|
||||
const stopWheelPropagation = (event: WheelEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
export function MultiSelect({
|
||||
children,
|
||||
values,
|
||||
@@ -335,12 +340,16 @@ export function MultiSelectContent({
|
||||
<PopoverContent
|
||||
align="start"
|
||||
data-slot="multiselect-content"
|
||||
style={{
|
||||
maxHeight:
|
||||
"min(360px, var(--radix-popover-content-available-height, 360px))",
|
||||
}}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-border-input-primary bg-bg-input-primary relative z-50 rounded-lg border p-0",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-border-input-primary bg-bg-input-primary relative z-50 overflow-hidden rounded-lg border p-0",
|
||||
widthClasses,
|
||||
)}
|
||||
>
|
||||
<Command {...props} className="rounded-lg">
|
||||
<Command {...props} className="max-h-[inherit] rounded-lg">
|
||||
{canSearch ? (
|
||||
<CommandInput
|
||||
placeholder={
|
||||
@@ -354,7 +363,12 @@ export function MultiSelectContent({
|
||||
)}
|
||||
<CommandList
|
||||
ref={listRef}
|
||||
className="minimal-scrollbar max-h-[300px] overflow-x-hidden overflow-y-auto p-3"
|
||||
onWheelCapture={stopWheelPropagation}
|
||||
style={{
|
||||
maxHeight:
|
||||
"min(300px, var(--radix-popover-content-available-height, 300px))",
|
||||
}}
|
||||
className="minimal-scrollbar overflow-x-hidden overflow-y-auto overscroll-contain p-3"
|
||||
>
|
||||
{canSearch && (
|
||||
<CommandEmpty className="text-bg-button-secondary py-6 text-center text-sm">
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./select";
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: () => false,
|
||||
});
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: () => {},
|
||||
});
|
||||
|
||||
describe("Select", () => {
|
||||
it("keeps long option lists scrollable inside the dropdown", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Select defaultValue="option-1">
|
||||
<SelectTrigger aria-label="Options">
|
||||
<SelectValue placeholder="Select option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 20 }, (_, index) => (
|
||||
<SelectItem key={index} value={`option-${index}`}>
|
||||
Option {index}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: /options/i }));
|
||||
|
||||
// Then
|
||||
const viewport = screen
|
||||
.getByRole("listbox")
|
||||
.querySelector('[data-slot="select-viewport"]');
|
||||
expect(screen.getByRole("listbox")).toHaveStyle({
|
||||
maxHeight: "var(--radix-select-content-available-height)",
|
||||
});
|
||||
expect(viewport).toHaveClass("minimal-scrollbar");
|
||||
expect(viewport).toHaveStyle({
|
||||
maxHeight:
|
||||
"min(300px, var(--radix-select-content-available-height, 300px))",
|
||||
});
|
||||
expect(viewport).toHaveClass("overflow-y-auto");
|
||||
expect(viewport).toHaveClass("overscroll-contain");
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { ComponentProps } from "react";
|
||||
import { ComponentProps, type WheelEvent } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const stopWheelPropagation = (event: WheelEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
function Select({
|
||||
allowDeselect = false,
|
||||
...props
|
||||
@@ -83,6 +87,7 @@ function SelectContent({
|
||||
position = "popper",
|
||||
align = "start",
|
||||
width = "default",
|
||||
style,
|
||||
...props
|
||||
}: ComponentProps<typeof SelectPrimitive.Content> & {
|
||||
width?: "default" | "wide";
|
||||
@@ -97,22 +102,32 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-border-input-primary bg-bg-input-primary relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-border-input-primary bg-bg-input-primary relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-lg border",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
widthClasses,
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
maxHeight: "var(--radix-select-content-available-height)",
|
||||
...style,
|
||||
}}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-slot="select-viewport"
|
||||
onWheelCapture={stopWheelPropagation}
|
||||
style={{
|
||||
maxHeight:
|
||||
"min(300px, var(--radix-select-content-available-height, 300px))",
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col gap-1 p-3",
|
||||
"minimal-scrollbar flex flex-col gap-1 overflow-x-hidden overflow-y-auto overscroll-contain p-3",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
"w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -15,22 +15,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-02-12T11:04:39.164Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@codemirror/autocomplete",
|
||||
"from": "6.20.1",
|
||||
"to": "6.20.1",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-03-26T08:39:34.728Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@codemirror/commands",
|
||||
"from": "6.10.3",
|
||||
"to": "6.10.3",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-03-26T08:39:34.728Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@codemirror/language",
|
||||
@@ -79,14 +63,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@internationalized/date",
|
||||
"from": "3.10.0",
|
||||
"to": "3.10.0",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-12-10T11:34:11.122Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@langchain/aws",
|
||||
@@ -287,14 +263,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-12-15T08:24:46.195Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@react-aria/i18n",
|
||||
"from": "3.12.13",
|
||||
"to": "3.12.13",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-12-10T11:34:11.122Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@react-aria/ssr",
|
||||
@@ -319,14 +287,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-12-10T11:34:11.122Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@react-types/datepicker",
|
||||
"from": "3.13.2",
|
||||
"to": "3.13.2",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-12-10T11:34:11.122Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@react-types/shared",
|
||||
@@ -399,14 +359,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-12-15T08:24:46.195Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "alert",
|
||||
"from": "6.0.2",
|
||||
"to": "6.0.2",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "class-variance-authority",
|
||||
@@ -431,14 +383,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-11-19T12:28:39.510Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "codemirror",
|
||||
"from": "6.0.2",
|
||||
"to": "6.0.2",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-03-26T08:39:34.728Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "d3",
|
||||
@@ -479,22 +423,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-12-16T08:33:37.278Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "intl-messageformat",
|
||||
"from": "10.7.16",
|
||||
"to": "10.7.16",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "jose",
|
||||
"from": "5.10.0",
|
||||
"to": "5.10.0",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "js-yaml",
|
||||
@@ -527,14 +455,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "marked",
|
||||
"from": "15.0.12",
|
||||
"to": "15.0.12",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "nanoid",
|
||||
@@ -567,14 +487,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "radix-ui",
|
||||
"from": "1.4.2",
|
||||
"to": "1.4.2",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "react",
|
||||
@@ -647,14 +559,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "shiki",
|
||||
"from": "3.20.0",
|
||||
"to": "3.20.0",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-12-16T08:33:37.278Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "streamdown",
|
||||
@@ -687,14 +591,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "tw-animate-css",
|
||||
"from": "1.4.0",
|
||||
"to": "1.4.0",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "use-stick-to-bottom",
|
||||
@@ -703,14 +599,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-12-15T08:24:46.195Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "uuid",
|
||||
"from": "11.1.0",
|
||||
"to": "11.1.0",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "vaul",
|
||||
@@ -743,14 +631,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@eslint/eslintrc",
|
||||
"from": "3.3.3",
|
||||
"to": "3.3.3",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-01-19T13:54:24.770Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@iconify/react",
|
||||
@@ -855,14 +735,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-30T10:22:21.335Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@types/uuid",
|
||||
"from": "10.0.0",
|
||||
"to": "10.0.0",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@typescript-eslint/eslint-plugin",
|
||||
@@ -895,14 +767,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-01-29T16:42:27.795Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "autoprefixer",
|
||||
"from": "10.4.19",
|
||||
"to": "10.4.19",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "babel-plugin-react-compiler",
|
||||
@@ -911,6 +775,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-01-19T13:54:24.770Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "dotenv",
|
||||
"from": "16.6.1",
|
||||
"to": "16.6.1",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-05-11T15:12:01.207Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "dotenv-expand",
|
||||
@@ -927,14 +799,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-01-19T13:54:24.770Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "eslint-config-next",
|
||||
"from": "16.1.6",
|
||||
"to": "16.2.3",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-17T09:52:03.464Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "eslint-config-prettier",
|
||||
@@ -943,14 +807,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "eslint-plugin-import",
|
||||
"from": "2.32.0",
|
||||
"to": "2.32.0",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "eslint-plugin-jsx-a11y",
|
||||
@@ -959,14 +815,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "eslint-plugin-node",
|
||||
"from": "11.1.0",
|
||||
"to": "11.1.0",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "eslint-plugin-prettier",
|
||||
@@ -1045,7 +893,7 @@
|
||||
"from": "8.4.38",
|
||||
"to": "8.4.38",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
"generatedAt": "2026-05-11T15:15:31.276Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
@@ -1063,22 +911,6 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "shadcn",
|
||||
"from": "3.4.1",
|
||||
"to": "3.8.4",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-02-12T11:04:39.164Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "tailwind-variants",
|
||||
"from": "0.1.20",
|
||||
"to": "0.1.20",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "tailwindcss",
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"healthcheck": "pnpm run typecheck && pnpm run lint:check",
|
||||
"lint:check": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint:knip": "knip --max-issues 504",
|
||||
"lint:knip:fix": "knip --fix --max-issues 504",
|
||||
"lint:knip": "knip --max-issues 494",
|
||||
"lint:knip:fix": "knip --fix --max-issues 494",
|
||||
"format:check": "./node_modules/.bin/prettier --check ./app",
|
||||
"format:write": "./node_modules/.bin/prettier --config .prettierrc.json --write ./app",
|
||||
"test": "vitest",
|
||||
@@ -30,15 +30,12 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "2.0.111",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.988.0",
|
||||
"@codemirror/autocomplete": "6.20.1",
|
||||
"@codemirror/commands": "6.10.3",
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.40.0",
|
||||
"@extractus/feed-extractor": "7.1.7",
|
||||
"@heroui/react": "2.8.4",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@internationalized/date": "3.10.0",
|
||||
"@langchain/aws": "1.1.0",
|
||||
"@langchain/core": "1.1.15",
|
||||
"@langchain/mcp-adapters": "1.1.3",
|
||||
@@ -64,11 +61,9 @@
|
||||
"@radix-ui/react-toast": "1.2.14",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@react-aria/i18n": "3.12.13",
|
||||
"@react-aria/ssr": "3.9.4",
|
||||
"@react-aria/visually-hidden": "3.8.12",
|
||||
"@react-stately/utils": "3.10.8",
|
||||
"@react-types/datepicker": "3.13.2",
|
||||
"@react-types/shared": "3.26.0",
|
||||
"@sentry/nextjs": "10.27.0",
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
@@ -81,24 +76,19 @@
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"codemirror": "6.0.2",
|
||||
"d3": "7.9.0",
|
||||
"dagre": "0.8.5",
|
||||
"date-fns": "4.1.0",
|
||||
"framer-motion": "11.18.2",
|
||||
"import-in-the-middle": "2.0.0",
|
||||
"intl-messageformat": "10.7.16",
|
||||
"jose": "5.10.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"jwt-decode": "4.0.0",
|
||||
"langchain": "1.2.10",
|
||||
"lucide-react": "0.543.0",
|
||||
"marked": "15.0.12",
|
||||
"nanoid": "5.1.6",
|
||||
"next": "16.2.3",
|
||||
"next-auth": "5.0.0-beta.30",
|
||||
"next-themes": "0.2.1",
|
||||
"radix-ui": "1.4.2",
|
||||
"react": "19.2.5",
|
||||
"react-day-picker": "9.13.0",
|
||||
"react-dom": "19.2.5",
|
||||
@@ -108,21 +98,17 @@
|
||||
"require-in-the-middle": "8.0.1",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "0.33.5",
|
||||
"shiki": "3.20.0",
|
||||
"streamdown": "1.6.10",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-stick-to-bottom": "1.1.1",
|
||||
"uuid": "11.1.0",
|
||||
"vaul": "1.1.2",
|
||||
"world-atlas": "2.0.2",
|
||||
"zod": "4.1.11",
|
||||
"zustand": "5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "3.3.3",
|
||||
"@iconify/react": "5.2.1",
|
||||
"@next/eslint-plugin-next": "16.2.3",
|
||||
"@playwright/test": "1.56.1",
|
||||
@@ -136,20 +122,16 @@
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/topojson-client": "3.1.5",
|
||||
"@types/topojson-specification": "1.0.5",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
"autoprefixer": "10.4.19",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"dotenv": "16.6.1",
|
||||
"dotenv-expand": "12.0.3",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"eslint-config-prettier": "10.1.5",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-node": "11.1.0",
|
||||
"eslint-plugin-prettier": "5.5.1",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
@@ -162,8 +144,6 @@
|
||||
"postcss": "8.4.38",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-tailwindcss": "0.6.14",
|
||||
"shadcn": "3.8.4",
|
||||
"tailwind-variants": "0.1.20",
|
||||
"tailwindcss": "4.1.18",
|
||||
"typescript": "5.5.4",
|
||||
"vitest": "4.0.18"
|
||||
@@ -172,8 +152,6 @@
|
||||
"overrides": {
|
||||
"@react-types/shared": "3.26.0",
|
||||
"@internationalized/date": "3.10.0",
|
||||
"alert>react": "19.2.5",
|
||||
"alert>react-dom": "19.2.5",
|
||||
"@react-aria/ssr>react": "19.2.5",
|
||||
"@react-aria/ssr>react-dom": "19.2.5",
|
||||
"@react-aria/visually-hidden>react": "19.2.5",
|
||||
|
||||
@@ -45,18 +45,26 @@ export interface ScanRelationships {
|
||||
task: RelationshipWrapper;
|
||||
}
|
||||
|
||||
export interface ScanProviderInfo {
|
||||
export interface ScanResultProviderInfo {
|
||||
provider: ProviderType;
|
||||
uid: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export interface ScanProviderInfo {
|
||||
providerId: string;
|
||||
alias: string;
|
||||
providerType: string;
|
||||
uid: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface ScanProps {
|
||||
type: "scans";
|
||||
id: string;
|
||||
attributes: ScanAttributes;
|
||||
relationships: ScanRelationships;
|
||||
providerInfo?: ScanProviderInfo;
|
||||
providerInfo?: ScanResultProviderInfo;
|
||||
}
|
||||
|
||||
export interface ScanEntityProviderInfo {
|
||||
@@ -77,7 +85,7 @@ export interface ScanEntity {
|
||||
}
|
||||
|
||||
export interface ExpandedScanData extends ScanProps {
|
||||
providerInfo: ScanProviderInfo;
|
||||
providerInfo: ScanResultProviderInfo;
|
||||
}
|
||||
|
||||
export interface IncludedResource {
|
||||
|
||||