mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-05 03:12:14 +00:00
Merge branch 'master' into DEVREL-98-include-open-api-specification-in-mintlify
This commit is contained in:
@@ -14,6 +14,15 @@ UI_PORT=3000
|
||||
AUTH_SECRET="N/c6mnaS5+SWq81+819OrzQZlmx1Vxtp/orjttJSmw8="
|
||||
# Google Tag Manager ID
|
||||
NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=""
|
||||
# Sentry
|
||||
SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
SENTRY_ORG=
|
||||
SENTRY_PROJECT=
|
||||
SENTRY_AUTH_TOKEN=
|
||||
SENTRY_ENVIRONMENT=production
|
||||
NEXT_PUBLIC_SENTRY_ENVIRONMENT=production
|
||||
|
||||
#### Code Review Configuration ####
|
||||
# Enable Claude Code standards validation on pre-push hook
|
||||
# Set to 'true' to validate changes against AGENTS.md standards via Claude Code
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
name: Community PR labelling
|
||||
|
||||
on:
|
||||
# We need "write" permissions on the PR to be able to add a label.
|
||||
pull_request_target: # We need this to have labelling permissions. There are no user inputs here, so we should be fine.
|
||||
types:
|
||||
- opened
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
label-if-community:
|
||||
name: Add 'community' label if the PR is from a community contributor
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Check if author is org member
|
||||
id: check_membership
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
ORG: ${{ github.repository_owner }}
|
||||
run: |
|
||||
echo "Checking if $AUTHOR is a member of $ORG"
|
||||
if gh api --method GET "orgs/$ORG/members/$AUTHOR" >/dev/null 2>&1; then
|
||||
echo "is_member=true" >> $GITHUB_OUTPUT
|
||||
echo "$AUTHOR is an organization member"
|
||||
else
|
||||
echo "is_member=false" >> $GITHUB_OUTPUT
|
||||
echo "$AUTHOR is not an organization member"
|
||||
fi
|
||||
|
||||
- name: Add community label
|
||||
if: steps.check_membership.outputs.is_member == 'false'
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "Adding 'community' label to PR #$PR_NUMBER"
|
||||
gh api /repos/${{ github.repository }}/issues/${{ github.event.number }}/labels \
|
||||
-X POST \
|
||||
-f labels[]='community'
|
||||
@@ -27,3 +27,66 @@ jobs:
|
||||
uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
label-community:
|
||||
name: Add 'community' label if the PR is from a community contributor
|
||||
needs: labeler
|
||||
if: github.repository == 'prowler-cloud/prowler' && github.event.action == 'opened'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Check if author is org member
|
||||
id: check_membership
|
||||
env:
|
||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
# Hardcoded list of prowler-cloud organization members
|
||||
# This list includes members who have set their organization membership as private
|
||||
ORG_MEMBERS=(
|
||||
"AdriiiPRodri"
|
||||
"Alan-TheGentleman"
|
||||
"alejandrobailo"
|
||||
"amitsharm"
|
||||
"andoniaf"
|
||||
"cesararroba"
|
||||
"Chan9390"
|
||||
"danibarranqueroo"
|
||||
"HugoPBrito"
|
||||
"jfagoagas"
|
||||
"josemazo"
|
||||
"lydiavilchez"
|
||||
"mmuller88"
|
||||
"MrCloudSec"
|
||||
"pedrooot"
|
||||
"prowler-bot"
|
||||
"puchy22"
|
||||
"rakan-pro"
|
||||
"RosaRivasProwler"
|
||||
"StylusFrost"
|
||||
"toniblyx"
|
||||
"vicferpoy"
|
||||
)
|
||||
|
||||
echo "Checking if $AUTHOR is a member of prowler-cloud organization"
|
||||
|
||||
# Check if author is in the org members list
|
||||
if printf '%s\n' "${ORG_MEMBERS[@]}" | grep -q "^${AUTHOR}$"; then
|
||||
echo "is_member=true" >> $GITHUB_OUTPUT
|
||||
echo "$AUTHOR is an organization member"
|
||||
else
|
||||
echo "is_member=false" >> $GITHUB_OUTPUT
|
||||
echo "$AUTHOR is not an organization member"
|
||||
fi
|
||||
|
||||
- name: Add community label
|
||||
if: steps.check_membership.outputs.is_member == 'false'
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "Adding 'community' label to PR #$PR_NUMBER"
|
||||
gh api /repos/${{ github.repository }}/issues/${{ github.event.number }}/labels \
|
||||
-X POST \
|
||||
-f labels[]='community'
|
||||
|
||||
@@ -82,9 +82,9 @@ prowler dashboard
|
||||
|
||||
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) | Support | Interface |
|
||||
|---|---|---|---|---|---|---|
|
||||
| AWS | 576 | 82 | 38 | 10 | Official | UI, API, CLI |
|
||||
| GCP | 79 | 13 | 12 | 3 | Official | UI, API, CLI |
|
||||
| Azure | 162 | 19 | 12 | 4 | Official | UI, API, CLI |
|
||||
| AWS | 576 | 82 | 39 | 10 | Official | UI, API, CLI |
|
||||
| GCP | 79 | 13 | 13 | 3 | Official | UI, API, CLI |
|
||||
| Azure | 162 | 19 | 13 | 4 | Official | UI, API, CLI |
|
||||
| Kubernetes | 83 | 7 | 5 | 7 | Official | UI, API, CLI |
|
||||
| GitHub | 17 | 2 | 1 | 0 | Official | Stable | UI, API, CLI |
|
||||
| M365 | 70 | 7 | 3 | 2 | Official | UI, API, CLI |
|
||||
|
||||
+7
-2
@@ -15,14 +15,19 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
- Support C5 compliance framework for the GCP provider [(#9097)](https://github.com/prowler-cloud/prowler/pull/9097)
|
||||
- Support for Amazon Bedrock and OpenAI compatible providers in Lighthouse AI [(#8957)](https://github.com/prowler-cloud/prowler/pull/8957)
|
||||
- OpenAPI schema integration with Mintlify documentation including compatibility fixes and dynamic server URL configuration [(#9168)](https://github.com/prowler-cloud/prowler/pull/9168)
|
||||
- Tenant-wide ThreatScore overview aggregation and snapshot persistence with backfill support [(#9148)](https://github.com/prowler-cloud/prowler/pull/9148)
|
||||
- Support for MongoDB Atlas provider [(#9167)](https://github.com/prowler-cloud/prowler/pull/9167)
|
||||
|
||||
### Security
|
||||
- Django updated to the latest 5.1 security release, 5.1.14, due to problems with potential [SQL injection](https://github.com/prowler-cloud/prowler/security/dependabot/113) and [denial-of-service vulnerability](https://github.com/prowler-cloud/prowler/security/dependabot/114) [(#9176)](https://github.com/prowler-cloud/prowler/pull/9176)
|
||||
|
||||
---
|
||||
|
||||
## [1.14.2] (Prowler 5.13.2)
|
||||
## [1.14.2] (Prowler UNRELEASED)
|
||||
|
||||
### Fixed
|
||||
- Update unique constraint for `Provider` model to exclude soft-deleted entries, resolving duplicate errors when re-deleting providers.
|
||||
- Update unique constraint for `Provider` model to exclude soft-deleted entries, resolving duplicate errors when re-deleting providers.[(#9054)](https://github.com/prowler-cloud/prowler/pull/9054)
|
||||
- Remove compliance generation for providers without compliance frameworks [(#9208)](https://github.com/prowler-cloud/prowler/pull/9208)
|
||||
|
||||
## [1.14.1] (Prowler 5.13.1)
|
||||
|
||||
|
||||
Generated
+5
-5
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "about-time"
|
||||
@@ -1671,14 +1671,14 @@ with-social = ["django-allauth[socialaccount] (>=64.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.1.13"
|
||||
version = "5.1.14"
|
||||
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "django-5.1.13-py3-none-any.whl", hash = "sha256:06f257f79dc4c17f3f9e23b106a4c5ed1335abecbe731e83c598c941d14fbeed"},
|
||||
{file = "django-5.1.13.tar.gz", hash = "sha256:543ff21679f15e80edfc01fe7ea35f8291b6d4ea589433882913626a7c1cf929"},
|
||||
{file = "django-5.1.14-py3-none-any.whl", hash = "sha256:2a4b9c20404fd1bf50aaaa5542a19d860594cba1354f688f642feb271b91df27"},
|
||||
{file = "django-5.1.14.tar.gz", hash = "sha256:b98409fb31fdd6e8c3a6ba2eef3415cc5c0020057b43b21ba7af6eff5f014831"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6786,4 +6786,4 @@ type = ["pytest-mypy"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11,<3.13"
|
||||
content-hash = "3c9164d668d37d6373eb5200bbe768232ead934d9312b9c68046b1df922789f3"
|
||||
content-hash = "943e2cd6b87229704550d4e140b36509fb9f58896ebb5834b9fbabe28a9ee92f"
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}]
|
||||
dependencies = [
|
||||
"celery[pytest] (>=5.4.0,<6.0.0)",
|
||||
"dj-rest-auth[with_social,jwt] (==7.0.1)",
|
||||
"django (==5.1.13)",
|
||||
"django (==5.1.14)",
|
||||
"django-allauth[saml] (>=65.8.0,<66.0.0)",
|
||||
"django-celery-beat (>=2.7.0,<3.0.0)",
|
||||
"django-celery-results (>=2.5.1,<3.0.0)",
|
||||
|
||||
@@ -144,6 +144,7 @@ def generate_scan_compliance(
|
||||
Returns:
|
||||
None: This function modifies the compliance_overview in place.
|
||||
"""
|
||||
|
||||
for compliance_id in PROWLER_CHECKS[provider_type][check_id]:
|
||||
for requirement in compliance_overview[compliance_id]["requirements"].values():
|
||||
if check_id in requirement["checks"]:
|
||||
|
||||
@@ -47,6 +47,7 @@ from api.models import (
|
||||
StatusChoices,
|
||||
Task,
|
||||
TenantAPIKey,
|
||||
ThreatScoreSnapshot,
|
||||
User,
|
||||
)
|
||||
from api.rls import Tenant
|
||||
@@ -998,3 +999,36 @@ class MuteRuleFilter(FilterSet):
|
||||
"inserted_at": ["gte", "lte"],
|
||||
"updated_at": ["gte", "lte"],
|
||||
}
|
||||
|
||||
|
||||
class ThreatScoreSnapshotFilter(FilterSet):
|
||||
"""
|
||||
Filter for ThreatScore snapshots.
|
||||
Allows filtering by scan, provider, compliance_id, and date ranges.
|
||||
"""
|
||||
|
||||
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
|
||||
scan_id = UUIDFilter(field_name="scan__id", lookup_expr="exact")
|
||||
scan_id__in = UUIDInFilter(field_name="scan__id", lookup_expr="in")
|
||||
provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact")
|
||||
provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in")
|
||||
provider_type = ChoiceFilter(
|
||||
field_name="provider__provider", choices=Provider.ProviderChoices.choices
|
||||
)
|
||||
provider_type__in = ChoiceInFilter(
|
||||
field_name="provider__provider",
|
||||
choices=Provider.ProviderChoices.choices,
|
||||
lookup_expr="in",
|
||||
)
|
||||
compliance_id = CharFilter(field_name="compliance_id", lookup_expr="exact")
|
||||
compliance_id__in = CharInFilter(field_name="compliance_id", lookup_expr="in")
|
||||
|
||||
class Meta:
|
||||
model = ThreatScoreSnapshot
|
||||
fields = {
|
||||
"scan": ["exact", "in"],
|
||||
"provider": ["exact", "in"],
|
||||
"compliance_id": ["exact", "in"],
|
||||
"inserted_at": ["date", "gte", "lte"],
|
||||
"overall_score": ["exact", "gte", "lte"],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# Generated by Django 5.1.13 on 2025-10-31 09:04
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import api.rls
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0056_remove_provider_unique_provider_uids_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ThreatScoreSnapshot",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("inserted_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"compliance_id",
|
||||
models.CharField(
|
||||
help_text="Compliance framework ID (e.g., 'prowler_threatscore_aws')",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
(
|
||||
"overall_score",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
help_text="Overall ThreatScore percentage (0-100)",
|
||||
max_digits=5,
|
||||
),
|
||||
),
|
||||
(
|
||||
"score_delta",
|
||||
models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=2,
|
||||
help_text="Score change compared to previous snapshot (positive = improvement)",
|
||||
max_digits=5,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"section_scores",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="ThreatScore breakdown by section",
|
||||
),
|
||||
),
|
||||
(
|
||||
"critical_requirements",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="List of critical failed requirements (risk >= 4)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"total_requirements",
|
||||
models.IntegerField(
|
||||
default=0, help_text="Total number of requirements evaluated"
|
||||
),
|
||||
),
|
||||
(
|
||||
"passed_requirements",
|
||||
models.IntegerField(
|
||||
default=0, help_text="Number of requirements with PASS status"
|
||||
),
|
||||
),
|
||||
(
|
||||
"failed_requirements",
|
||||
models.IntegerField(
|
||||
default=0, help_text="Number of requirements with FAIL status"
|
||||
),
|
||||
),
|
||||
(
|
||||
"manual_requirements",
|
||||
models.IntegerField(
|
||||
default=0, help_text="Number of requirements with MANUAL status"
|
||||
),
|
||||
),
|
||||
(
|
||||
"total_findings",
|
||||
models.IntegerField(
|
||||
default=0,
|
||||
help_text="Total number of findings across all requirements",
|
||||
),
|
||||
),
|
||||
(
|
||||
"passed_findings",
|
||||
models.IntegerField(
|
||||
default=0, help_text="Number of findings with PASS status"
|
||||
),
|
||||
),
|
||||
(
|
||||
"failed_findings",
|
||||
models.IntegerField(
|
||||
default=0, help_text="Number of findings with FAIL status"
|
||||
),
|
||||
),
|
||||
(
|
||||
"provider",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="threatscore_snapshots",
|
||||
related_query_name="threatscore_snapshot",
|
||||
to="api.provider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"scan",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="threatscore_snapshots",
|
||||
related_query_name="threatscore_snapshot",
|
||||
to="api.scan",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "threatscore_snapshots",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="threatscoresnapshot",
|
||||
index=models.Index(
|
||||
fields=["tenant_id", "scan_id"], name="threatscore_snap_t_scan_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="threatscoresnapshot",
|
||||
index=models.Index(
|
||||
fields=["tenant_id", "provider_id"], name="threatscore_snap_t_prov_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="threatscoresnapshot",
|
||||
index=models.Index(
|
||||
fields=["tenant_id", "inserted_at"], name="threatscore_snap_t_time_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="threatscoresnapshot",
|
||||
constraint=api.rls.RowLevelSecurityConstraint(
|
||||
"tenant_id",
|
||||
name="rls_on_threatscoresnapshot",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -2239,3 +2239,137 @@ class LighthouseProviderModels(RowLevelSecurityProtectedModel):
|
||||
|
||||
class JSONAPIMeta:
|
||||
resource_name = "lighthouse-models"
|
||||
|
||||
|
||||
class ThreatScoreSnapshot(RowLevelSecurityProtectedModel):
|
||||
"""
|
||||
Stores historical ThreatScore metrics for a given scan.
|
||||
Snapshots are created automatically after each ThreatScore report generation.
|
||||
"""
|
||||
|
||||
objects = models.Manager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
|
||||
scan = models.ForeignKey(
|
||||
Scan,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="threatscore_snapshots",
|
||||
related_query_name="threatscore_snapshot",
|
||||
)
|
||||
|
||||
provider = models.ForeignKey(
|
||||
Provider,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="threatscore_snapshots",
|
||||
related_query_name="threatscore_snapshot",
|
||||
)
|
||||
|
||||
compliance_id = models.CharField(
|
||||
max_length=100,
|
||||
blank=False,
|
||||
null=False,
|
||||
help_text="Compliance framework ID (e.g., 'prowler_threatscore_aws')",
|
||||
)
|
||||
|
||||
# Overall ThreatScore metrics
|
||||
overall_score = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
help_text="Overall ThreatScore percentage (0-100)",
|
||||
)
|
||||
|
||||
# Score improvement/degradation compared to previous snapshot
|
||||
score_delta = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Score change compared to previous snapshot (positive = improvement)",
|
||||
)
|
||||
|
||||
# Section breakdown stored as JSON
|
||||
# Format: {"1. IAM": 85.5, "2. Attack Surface": 92.3, ...}
|
||||
section_scores = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="ThreatScore breakdown by section",
|
||||
)
|
||||
|
||||
# Critical requirements metadata stored as JSON
|
||||
# Format: [{"requirement_id": "...", "risk_level": 5, "weight": 150, ...}, ...]
|
||||
critical_requirements = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text="List of critical failed requirements (risk >= 4)",
|
||||
)
|
||||
|
||||
# Summary statistics
|
||||
total_requirements = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Total number of requirements evaluated",
|
||||
)
|
||||
|
||||
passed_requirements = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Number of requirements with PASS status",
|
||||
)
|
||||
|
||||
failed_requirements = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Number of requirements with FAIL status",
|
||||
)
|
||||
|
||||
manual_requirements = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Number of requirements with MANUAL status",
|
||||
)
|
||||
|
||||
total_findings = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Total number of findings across all requirements",
|
||||
)
|
||||
|
||||
passed_findings = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Number of findings with PASS status",
|
||||
)
|
||||
|
||||
failed_findings = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Number of findings with FAIL status",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"ThreatScore {self.overall_score}% for scan {self.scan_id} ({self.inserted_at})"
|
||||
|
||||
class Meta(RowLevelSecurityProtectedModel.Meta):
|
||||
db_table = "threatscore_snapshots"
|
||||
|
||||
constraints = [
|
||||
RowLevelSecurityConstraint(
|
||||
field="tenant_id",
|
||||
name="rls_on_%(class)s",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
]
|
||||
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["tenant_id", "scan_id"],
|
||||
name="threatscore_snap_t_scan_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["tenant_id", "provider_id"],
|
||||
name="threatscore_snap_t_prov_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["tenant_id", "inserted_at"],
|
||||
name="threatscore_snap_t_time_idx",
|
||||
),
|
||||
]
|
||||
|
||||
class JSONAPIMeta:
|
||||
resource_name = "threatscore-snapshots"
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, MagicMock, Mock, patch
|
||||
@@ -56,6 +57,7 @@ from api.models import (
|
||||
StateChoices,
|
||||
Task,
|
||||
TenantAPIKey,
|
||||
ThreatScoreSnapshot,
|
||||
User,
|
||||
UserRoleRelationship,
|
||||
)
|
||||
@@ -6221,6 +6223,407 @@ class TestOverviewViewSet:
|
||||
for entry in grouped_data:
|
||||
assert "findings" not in entry["attributes"]
|
||||
|
||||
def _create_scan(self, tenant, provider, name, started_at=None):
|
||||
scan_started = started_at or datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
return Scan.objects.create(
|
||||
tenant=tenant,
|
||||
provider=provider,
|
||||
name=name,
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
started_at=scan_started,
|
||||
completed_at=scan_started + timedelta(minutes=30),
|
||||
)
|
||||
|
||||
def _create_threatscore_snapshot(
|
||||
self,
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
*,
|
||||
compliance_id,
|
||||
overall_score,
|
||||
score_delta,
|
||||
section_scores,
|
||||
critical_requirements,
|
||||
total_requirements,
|
||||
passed_requirements,
|
||||
failed_requirements,
|
||||
manual_requirements,
|
||||
total_findings,
|
||||
passed_findings,
|
||||
failed_findings,
|
||||
):
|
||||
return ThreatScoreSnapshot.objects.create(
|
||||
tenant=tenant,
|
||||
scan=scan,
|
||||
provider=provider,
|
||||
compliance_id=compliance_id,
|
||||
overall_score=Decimal(overall_score),
|
||||
score_delta=Decimal(score_delta) if score_delta is not None else None,
|
||||
section_scores=section_scores,
|
||||
critical_requirements=critical_requirements,
|
||||
total_requirements=total_requirements,
|
||||
passed_requirements=passed_requirements,
|
||||
failed_requirements=failed_requirements,
|
||||
manual_requirements=manual_requirements,
|
||||
total_findings=total_findings,
|
||||
passed_findings=passed_findings,
|
||||
failed_findings=failed_findings,
|
||||
)
|
||||
|
||||
def test_overview_threatscore_returns_weighted_aggregate_snapshot(
|
||||
self, authenticated_client, tenants_fixture, providers_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider1, provider2, *_ = providers_fixture
|
||||
|
||||
scan1 = self._create_scan(tenant, provider1, "agg-scan-one")
|
||||
scan2 = self._create_scan(tenant, provider2, "agg-scan-two")
|
||||
|
||||
snapshot1 = self._create_threatscore_snapshot(
|
||||
tenant,
|
||||
scan1,
|
||||
provider1,
|
||||
compliance_id="prowler_threatscore_aws",
|
||||
overall_score="80.00",
|
||||
score_delta="5.00",
|
||||
section_scores={"1. IAM": "70.00", "2. Attack Surface": "60.00"},
|
||||
critical_requirements=[
|
||||
{
|
||||
"requirement_id": "req_shared",
|
||||
"title": "Shared requirement (preferred)",
|
||||
"section": "1. IAM",
|
||||
"subsection": "Sub IAM",
|
||||
"risk_level": 5,
|
||||
"weight": 150,
|
||||
"passed_findings": 14,
|
||||
"total_findings": 20,
|
||||
"description": "Higher risk duplicate",
|
||||
},
|
||||
{
|
||||
"requirement_id": "req_unique_one",
|
||||
"title": "Unique provider one",
|
||||
"section": "2. Attack Surface",
|
||||
"subsection": "Sub Attack",
|
||||
"risk_level": 4,
|
||||
"weight": 90,
|
||||
"passed_findings": 20,
|
||||
"total_findings": 30,
|
||||
"description": "Lower risk",
|
||||
},
|
||||
],
|
||||
total_requirements=120,
|
||||
passed_requirements=90,
|
||||
failed_requirements=30,
|
||||
manual_requirements=0,
|
||||
total_findings=100,
|
||||
passed_findings=70,
|
||||
failed_findings=30,
|
||||
)
|
||||
|
||||
snapshot2 = self._create_threatscore_snapshot(
|
||||
tenant,
|
||||
scan2,
|
||||
provider2,
|
||||
compliance_id="prowler_threatscore_aws",
|
||||
overall_score="20.00",
|
||||
score_delta="-2.00",
|
||||
section_scores={
|
||||
"1. IAM": "10.00",
|
||||
"2. Attack Surface": "40.00",
|
||||
"3. Logging": "30.00",
|
||||
},
|
||||
critical_requirements=[
|
||||
{
|
||||
"requirement_id": "req_shared",
|
||||
"title": "Shared requirement (secondary)",
|
||||
"section": "1. IAM",
|
||||
"subsection": "Sub IAM",
|
||||
"risk_level": 4,
|
||||
"weight": 120,
|
||||
"passed_findings": 8,
|
||||
"total_findings": 12,
|
||||
"description": "Lower risk duplicate",
|
||||
},
|
||||
{
|
||||
"requirement_id": "req_unique_two",
|
||||
"title": "Unique provider two",
|
||||
"section": "3. Logging",
|
||||
"subsection": "Sub Logging",
|
||||
"risk_level": 5,
|
||||
"weight": 110,
|
||||
"passed_findings": 6,
|
||||
"total_findings": 10,
|
||||
"description": "Another critical requirement",
|
||||
},
|
||||
],
|
||||
total_requirements=80,
|
||||
passed_requirements=30,
|
||||
failed_requirements=50,
|
||||
manual_requirements=0,
|
||||
total_findings=50,
|
||||
passed_findings=15,
|
||||
failed_findings=35,
|
||||
)
|
||||
|
||||
older_inserted = datetime(2025, 1, 1, 12, 0, tzinfo=timezone.utc)
|
||||
newer_inserted = datetime(2025, 1, 2, 12, 0, tzinfo=timezone.utc)
|
||||
ThreatScoreSnapshot.objects.filter(id=snapshot1.id).update(
|
||||
inserted_at=older_inserted
|
||||
)
|
||||
ThreatScoreSnapshot.objects.filter(id=snapshot2.id).update(
|
||||
inserted_at=newer_inserted
|
||||
)
|
||||
snapshot2.refresh_from_db()
|
||||
|
||||
response = authenticated_client.get(reverse("overview-threatscore"))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
body = response.json()
|
||||
assert len(body["data"]) == 1
|
||||
aggregated = body["data"][0]
|
||||
|
||||
assert aggregated["id"] == "n/a"
|
||||
assert aggregated["relationships"]["scan"]["data"] is None
|
||||
assert aggregated["relationships"]["provider"]["data"] is None
|
||||
|
||||
attrs = aggregated["attributes"]
|
||||
assert Decimal(attrs["overall_score"]) == Decimal("60.00")
|
||||
assert Decimal(attrs["score_delta"]) == Decimal("2.67")
|
||||
assert attrs["inserted_at"] == snapshot2.inserted_at.isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
assert attrs["total_findings"] == 150
|
||||
assert attrs["passed_findings"] == 85
|
||||
assert attrs["failed_findings"] == 65
|
||||
assert attrs["total_requirements"] == 200
|
||||
assert attrs["passed_requirements"] == 120
|
||||
assert attrs["failed_requirements"] == 80
|
||||
assert attrs["manual_requirements"] == 0
|
||||
|
||||
assert attrs["section_scores"] == {
|
||||
"1. IAM": "50.00",
|
||||
"2. Attack Surface": "53.33",
|
||||
"3. Logging": "30.00",
|
||||
}
|
||||
|
||||
expected_critical = [
|
||||
{
|
||||
"requirement_id": "req_shared",
|
||||
"title": "Shared requirement (preferred)",
|
||||
"section": "1. IAM",
|
||||
"subsection": "Sub IAM",
|
||||
"risk_level": 5,
|
||||
"weight": 150,
|
||||
"passed_findings": 14,
|
||||
"total_findings": 20,
|
||||
"description": "Higher risk duplicate",
|
||||
},
|
||||
{
|
||||
"requirement_id": "req_unique_two",
|
||||
"title": "Unique provider two",
|
||||
"section": "3. Logging",
|
||||
"subsection": "Sub Logging",
|
||||
"risk_level": 5,
|
||||
"weight": 110,
|
||||
"passed_findings": 6,
|
||||
"total_findings": 10,
|
||||
"description": "Another critical requirement",
|
||||
},
|
||||
{
|
||||
"requirement_id": "req_unique_one",
|
||||
"title": "Unique provider one",
|
||||
"section": "2. Attack Surface",
|
||||
"subsection": "Sub Attack",
|
||||
"risk_level": 4,
|
||||
"weight": 90,
|
||||
"passed_findings": 20,
|
||||
"total_findings": 30,
|
||||
"description": "Lower risk",
|
||||
},
|
||||
]
|
||||
assert attrs["critical_requirements"] == expected_critical
|
||||
|
||||
def test_overview_threatscore_weight_fallback_to_requirements(
|
||||
self, authenticated_client, tenants_fixture, providers_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider1, provider2, *_ = providers_fixture
|
||||
|
||||
scan1 = self._create_scan(tenant, provider1, "fallback-scan-1")
|
||||
scan2 = self._create_scan(tenant, provider2, "fallback-scan-2")
|
||||
|
||||
self._create_threatscore_snapshot(
|
||||
tenant,
|
||||
scan1,
|
||||
provider1,
|
||||
compliance_id="prowler_threatscore_aws",
|
||||
overall_score="90.00",
|
||||
score_delta="4.00",
|
||||
section_scores={"1. IAM": "90.00"},
|
||||
critical_requirements=[],
|
||||
total_requirements=10,
|
||||
passed_requirements=8,
|
||||
failed_requirements=0,
|
||||
manual_requirements=2,
|
||||
total_findings=0,
|
||||
passed_findings=0,
|
||||
failed_findings=0,
|
||||
)
|
||||
self._create_threatscore_snapshot(
|
||||
tenant,
|
||||
scan2,
|
||||
provider2,
|
||||
compliance_id="prowler_threatscore_aws",
|
||||
overall_score="50.00",
|
||||
score_delta="1.00",
|
||||
section_scores={"1. IAM": "40.00"},
|
||||
critical_requirements=[],
|
||||
total_requirements=12,
|
||||
passed_requirements=5,
|
||||
failed_requirements=7,
|
||||
manual_requirements=0,
|
||||
total_findings=10,
|
||||
passed_findings=4,
|
||||
failed_findings=6,
|
||||
)
|
||||
|
||||
response = authenticated_client.get(reverse("overview-threatscore"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
aggregate = response.json()["data"][0]["attributes"]
|
||||
|
||||
assert Decimal(aggregate["overall_score"]) == Decimal("67.78")
|
||||
assert Decimal(aggregate["score_delta"]) == Decimal("2.33")
|
||||
assert aggregate["total_findings"] == 10
|
||||
assert aggregate["total_requirements"] == 22
|
||||
assert aggregate["manual_requirements"] == 2
|
||||
assert aggregate["section_scores"] == {"1. IAM": "62.22"}
|
||||
|
||||
def test_overview_threatscore_filter_by_scan_id_returns_snapshot(
|
||||
self, authenticated_client, tenants_fixture, providers_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider1, *_ = providers_fixture
|
||||
scan = self._create_scan(tenant, provider1, "filter-scan")
|
||||
|
||||
snapshot = self._create_threatscore_snapshot(
|
||||
tenant,
|
||||
scan,
|
||||
provider1,
|
||||
compliance_id="prowler_threatscore_aws",
|
||||
overall_score="75.00",
|
||||
score_delta="3.00",
|
||||
section_scores={"1. IAM": "70.00"},
|
||||
critical_requirements=[],
|
||||
total_requirements=50,
|
||||
passed_requirements=30,
|
||||
failed_requirements=20,
|
||||
manual_requirements=0,
|
||||
total_findings=25,
|
||||
passed_findings=15,
|
||||
failed_findings=10,
|
||||
)
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("overview-threatscore"), {"filter[scan_id]": str(scan.id)}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
body = response.json()
|
||||
assert len(body["data"]) == 1
|
||||
assert body["data"][0]["id"] == str(snapshot.id)
|
||||
assert body["data"][0]["attributes"]["overall_score"] == "75.00"
|
||||
|
||||
def test_overview_threatscore_snapshot_id_returns_specific_snapshot(
|
||||
self, authenticated_client, tenants_fixture, providers_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider1, *_ = providers_fixture
|
||||
scan = self._create_scan(tenant, provider1, "snapshot-id-scan")
|
||||
|
||||
snapshot = self._create_threatscore_snapshot(
|
||||
tenant,
|
||||
scan,
|
||||
provider1,
|
||||
compliance_id="prowler_threatscore_aws",
|
||||
overall_score="88.50",
|
||||
score_delta=None,
|
||||
section_scores={"1. IAM": "80.00"},
|
||||
critical_requirements=[],
|
||||
total_requirements=60,
|
||||
passed_requirements=45,
|
||||
failed_requirements=15,
|
||||
manual_requirements=0,
|
||||
total_findings=30,
|
||||
passed_findings=25,
|
||||
failed_findings=5,
|
||||
)
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("overview-threatscore"), {"snapshot_id": str(snapshot.id)}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["data"]["id"] == str(snapshot.id)
|
||||
assert data["data"]["attributes"]["score_delta"] is None
|
||||
|
||||
def test_overview_threatscore_provider_filter_returns_unaggregated_snapshot(
|
||||
self, authenticated_client, tenants_fixture, providers_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider1, provider2, *_ = providers_fixture
|
||||
|
||||
scan1 = self._create_scan(tenant, provider1, "provider-filter-scan-1")
|
||||
scan2 = self._create_scan(tenant, provider2, "provider-filter-scan-2")
|
||||
|
||||
snapshot1 = self._create_threatscore_snapshot(
|
||||
tenant,
|
||||
scan1,
|
||||
provider1,
|
||||
compliance_id="prowler_threatscore_aws",
|
||||
overall_score="55.55",
|
||||
score_delta="1.10",
|
||||
section_scores={"1. IAM": "50.00"},
|
||||
critical_requirements=[],
|
||||
total_requirements=40,
|
||||
passed_requirements=25,
|
||||
failed_requirements=15,
|
||||
manual_requirements=0,
|
||||
total_findings=12,
|
||||
passed_findings=7,
|
||||
failed_findings=5,
|
||||
)
|
||||
self._create_threatscore_snapshot(
|
||||
tenant,
|
||||
scan2,
|
||||
provider2,
|
||||
compliance_id="prowler_threatscore_aws",
|
||||
overall_score="44.44",
|
||||
score_delta="0.80",
|
||||
section_scores={"1. IAM": "40.00"},
|
||||
critical_requirements=[],
|
||||
total_requirements=30,
|
||||
passed_requirements=18,
|
||||
failed_requirements=12,
|
||||
manual_requirements=0,
|
||||
total_findings=10,
|
||||
passed_findings=6,
|
||||
failed_findings=4,
|
||||
)
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("overview-threatscore"),
|
||||
{"filter[provider_id__in]": str(provider1.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) == 1
|
||||
assert data[0]["id"] == str(snapshot1.id)
|
||||
assert data[0]["attributes"]["overall_score"] == "55.55"
|
||||
|
||||
def test_overview_services_list_no_required_filters(
|
||||
self, authenticated_client, scan_summaries_fixture
|
||||
):
|
||||
|
||||
@@ -47,6 +47,7 @@ from api.models import (
|
||||
StatusChoices,
|
||||
Task,
|
||||
TenantAPIKey,
|
||||
ThreatScoreSnapshot,
|
||||
User,
|
||||
UserRoleRelationship,
|
||||
)
|
||||
@@ -3626,3 +3627,64 @@ class MuteRuleUpdateSerializer(BaseWriteSerializer):
|
||||
):
|
||||
raise ValidationError("A mute rule with this name already exists.")
|
||||
return value
|
||||
|
||||
|
||||
# ThreatScore Snapshots
|
||||
|
||||
|
||||
class ThreatScoreSnapshotSerializer(RLSSerializer):
|
||||
"""
|
||||
Serializer for ThreatScore snapshots.
|
||||
Read-only serializer for retrieving historical ThreatScore metrics.
|
||||
"""
|
||||
|
||||
id = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ThreatScoreSnapshot
|
||||
fields = [
|
||||
"id",
|
||||
"inserted_at",
|
||||
"scan",
|
||||
"provider",
|
||||
"compliance_id",
|
||||
"overall_score",
|
||||
"score_delta",
|
||||
"section_scores",
|
||||
"critical_requirements",
|
||||
"total_requirements",
|
||||
"passed_requirements",
|
||||
"failed_requirements",
|
||||
"manual_requirements",
|
||||
"total_findings",
|
||||
"passed_findings",
|
||||
"failed_findings",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"id": {"read_only": True},
|
||||
"inserted_at": {"read_only": True},
|
||||
"scan": {"read_only": True},
|
||||
"provider": {"read_only": True},
|
||||
"compliance_id": {"read_only": True},
|
||||
"overall_score": {"read_only": True},
|
||||
"score_delta": {"read_only": True},
|
||||
"section_scores": {"read_only": True},
|
||||
"critical_requirements": {"read_only": True},
|
||||
"total_requirements": {"read_only": True},
|
||||
"passed_requirements": {"read_only": True},
|
||||
"failed_requirements": {"read_only": True},
|
||||
"manual_requirements": {"read_only": True},
|
||||
"total_findings": {"read_only": True},
|
||||
"passed_findings": {"read_only": True},
|
||||
"failed_findings": {"read_only": True},
|
||||
}
|
||||
|
||||
included_serializers = {
|
||||
"scan": "api.v1.serializers.ScanIncludeSerializer",
|
||||
"provider": "api.v1.serializers.ProviderIncludeSerializer",
|
||||
}
|
||||
|
||||
def get_id(self, obj):
|
||||
if getattr(obj, "_aggregated", False):
|
||||
return "n/a"
|
||||
return str(obj.id)
|
||||
|
||||
@@ -3,7 +3,10 @@ import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import sentry_sdk
|
||||
@@ -24,9 +27,23 @@ from django.conf import settings as django_settings
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.search import SearchQuery
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, F, Prefetch, Q, Subquery, Sum
|
||||
from django.db.models import (
|
||||
Case,
|
||||
Count,
|
||||
DecimalField,
|
||||
ExpressionWrapper,
|
||||
F,
|
||||
IntegerField,
|
||||
Max,
|
||||
Prefetch,
|
||||
Q,
|
||||
Subquery,
|
||||
Sum,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, QueryDict
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.dateparse import parse_date
|
||||
@@ -105,6 +122,7 @@ from api.filters import (
|
||||
TaskFilter,
|
||||
TenantApiKeyFilter,
|
||||
TenantFilter,
|
||||
ThreatScoreSnapshotFilter,
|
||||
UserFilter,
|
||||
)
|
||||
from api.models import (
|
||||
@@ -138,6 +156,7 @@ from api.models import (
|
||||
StateChoices,
|
||||
Task,
|
||||
TenantAPIKey,
|
||||
ThreatScoreSnapshot,
|
||||
User,
|
||||
UserRoleRelationship,
|
||||
)
|
||||
@@ -218,6 +237,7 @@ from api.v1.serializers import (
|
||||
TenantApiKeySerializer,
|
||||
TenantApiKeyUpdateSerializer,
|
||||
TenantSerializer,
|
||||
ThreatScoreSnapshotSerializer,
|
||||
TokenRefreshSerializer,
|
||||
TokenSerializer,
|
||||
TokenSocialLoginSerializer,
|
||||
@@ -3770,6 +3790,8 @@ class OverviewViewSet(BaseRLSViewSet):
|
||||
return OverviewSeveritySerializer
|
||||
elif self.action == "services":
|
||||
return OverviewServiceSerializer
|
||||
elif self.action == "threatscore":
|
||||
return ThreatScoreSnapshotSerializer
|
||||
return super().get_serializer_class()
|
||||
|
||||
def get_filterset_class(self):
|
||||
@@ -4011,6 +4033,332 @@ class OverviewViewSet(BaseRLSViewSet):
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@extend_schema(
|
||||
summary="Get ThreatScore snapshots",
|
||||
description=(
|
||||
"Retrieve ThreatScore metrics. By default, returns the latest snapshot for each provider. "
|
||||
"Use snapshot_id to retrieve a specific historical snapshot."
|
||||
),
|
||||
tags=["Overviews"],
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="snapshot_id",
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Retrieve a specific snapshot by ID. If not provided, returns latest snapshots.",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="provider_id",
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by specific provider ID",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="provider_id__in",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by multiple provider IDs (comma-separated UUIDs)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="provider_type",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by provider type (aws, azure, gcp, etc.)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="provider_type__in",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by multiple provider types (comma-separated)",
|
||||
),
|
||||
],
|
||||
)
|
||||
@action(detail=False, methods=["get"], url_name="threatscore")
|
||||
def threatscore(self, request):
|
||||
"""
|
||||
Get ThreatScore snapshots.
|
||||
|
||||
Default behavior: Returns the latest snapshot for each provider.
|
||||
With snapshot_id: Returns the specific snapshot requested.
|
||||
"""
|
||||
tenant_id = self.request.tenant_id
|
||||
snapshot_id = request.query_params.get("snapshot_id")
|
||||
|
||||
# Base queryset with RLS
|
||||
base_queryset = ThreatScoreSnapshot.objects.filter(tenant_id=tenant_id)
|
||||
|
||||
# Apply RBAC filtering
|
||||
if hasattr(self, "allowed_providers"):
|
||||
base_queryset = base_queryset.filter(provider__in=self.allowed_providers)
|
||||
|
||||
# Case 1: Specific snapshot requested
|
||||
if snapshot_id:
|
||||
try:
|
||||
snapshot = base_queryset.get(id=snapshot_id)
|
||||
serializer = ThreatScoreSnapshotSerializer(
|
||||
snapshot, context={"request": request}
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except ThreatScoreSnapshot.DoesNotExist:
|
||||
raise NotFound(detail="ThreatScore snapshot not found")
|
||||
|
||||
# Case 2: Latest snapshot per provider (default)
|
||||
# Apply filters manually: this @action is outside the standard list endpoint flow,
|
||||
# so DRF's filter backends don't execute and we must flatten JSON:API params ourselves.
|
||||
normalized_params = QueryDict(mutable=True)
|
||||
for param_key, values in request.query_params.lists():
|
||||
normalized_key = param_key
|
||||
if param_key.startswith("filter[") and param_key.endswith("]"):
|
||||
normalized_key = param_key[7:-1]
|
||||
if normalized_key == "snapshot_id":
|
||||
continue
|
||||
normalized_params.setlist(normalized_key, values)
|
||||
|
||||
filterset = ThreatScoreSnapshotFilter(normalized_params, queryset=base_queryset)
|
||||
filtered_queryset = filterset.qs
|
||||
|
||||
# Get distinct provider IDs from filtered queryset
|
||||
# Pick the latest snapshot per provider using Postgres DISTINCT ON pattern.
|
||||
# This avoids issuing one query per provider (N+1) when the filtered dataset is large.
|
||||
latest_snapshot_ids = list(
|
||||
filtered_queryset.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
latest_snapshot_map = {
|
||||
snapshot.id: snapshot
|
||||
for snapshot in filtered_queryset.filter(id__in=latest_snapshot_ids)
|
||||
}
|
||||
latest_snapshots = [
|
||||
latest_snapshot_map[snapshot_id]
|
||||
for snapshot_id in latest_snapshot_ids
|
||||
if snapshot_id in latest_snapshot_map
|
||||
]
|
||||
|
||||
if len(latest_snapshots) <= 1:
|
||||
serializer = ThreatScoreSnapshotSerializer(
|
||||
latest_snapshots, many=True, context={"request": request}
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
snapshot_ids = [
|
||||
snapshot.id for snapshot in latest_snapshots if snapshot and snapshot.id
|
||||
]
|
||||
aggregated_snapshot = self._build_threatscore_overview_snapshot(
|
||||
snapshot_ids, tenant_id
|
||||
)
|
||||
serializer = ThreatScoreSnapshotSerializer(
|
||||
[aggregated_snapshot], many=True, context={"request": request}
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def _build_threatscore_overview_snapshot(self, snapshot_ids, tenant_id):
|
||||
"""
|
||||
Aggregate the latest snapshots into a single overview snapshot for the tenant.
|
||||
"""
|
||||
if not snapshot_ids:
|
||||
raise ValueError(
|
||||
"Snapshot id list cannot be empty when aggregating threatscore overview"
|
||||
)
|
||||
|
||||
base_queryset = ThreatScoreSnapshot.objects.filter(
|
||||
tenant_id=tenant_id, id__in=snapshot_ids
|
||||
)
|
||||
|
||||
annotated_queryset = (
|
||||
base_queryset.annotate(
|
||||
active_requirements=ExpressionWrapper(
|
||||
F("total_requirements") - F("manual_requirements"),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
weight=Case(
|
||||
When(total_findings__gt=0, then=F("total_findings")),
|
||||
When(
|
||||
active_requirements__gt=0,
|
||||
then=F("active_requirements"),
|
||||
),
|
||||
default=Value(1, output_field=IntegerField()),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
)
|
||||
.order_by()
|
||||
)
|
||||
|
||||
aggregated_metrics = annotated_queryset.aggregate(
|
||||
total_requirements=Sum("total_requirements"),
|
||||
passed_requirements=Sum("passed_requirements"),
|
||||
failed_requirements=Sum("failed_requirements"),
|
||||
manual_requirements=Sum("manual_requirements"),
|
||||
total_findings=Sum("total_findings"),
|
||||
passed_findings=Sum("passed_findings"),
|
||||
failed_findings=Sum("failed_findings"),
|
||||
weighted_overall_sum=Sum(
|
||||
ExpressionWrapper(
|
||||
F("overall_score") * F("weight"),
|
||||
output_field=DecimalField(max_digits=14, decimal_places=4),
|
||||
)
|
||||
),
|
||||
overall_weight=Sum("weight"),
|
||||
unweighted_overall_sum=Sum("overall_score"),
|
||||
weighted_delta_sum=Sum(
|
||||
Case(
|
||||
When(
|
||||
score_delta__isnull=False,
|
||||
then=ExpressionWrapper(
|
||||
F("score_delta") * F("weight"),
|
||||
output_field=DecimalField(max_digits=14, decimal_places=4),
|
||||
),
|
||||
),
|
||||
default=Value(
|
||||
Decimal("0"),
|
||||
output_field=DecimalField(max_digits=14, decimal_places=4),
|
||||
),
|
||||
output_field=DecimalField(max_digits=14, decimal_places=4),
|
||||
)
|
||||
),
|
||||
delta_weight=Sum(
|
||||
Case(
|
||||
When(score_delta__isnull=False, then=F("weight")),
|
||||
default=Value(0, output_field=IntegerField()),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
),
|
||||
provider_count=Count("id"),
|
||||
latest_inserted_at=Max("inserted_at"),
|
||||
)
|
||||
|
||||
total_requirements = aggregated_metrics["total_requirements"] or 0
|
||||
passed_requirements = aggregated_metrics["passed_requirements"] or 0
|
||||
failed_requirements = aggregated_metrics["failed_requirements"] or 0
|
||||
manual_requirements = aggregated_metrics["manual_requirements"] or 0
|
||||
total_findings = aggregated_metrics["total_findings"] or 0
|
||||
passed_findings = aggregated_metrics["passed_findings"] or 0
|
||||
failed_findings = aggregated_metrics["failed_findings"] or 0
|
||||
|
||||
weighted_overall_sum = aggregated_metrics["weighted_overall_sum"]
|
||||
if weighted_overall_sum is None:
|
||||
weighted_overall_sum = Decimal("0")
|
||||
unweighted_overall_sum = aggregated_metrics["unweighted_overall_sum"]
|
||||
if unweighted_overall_sum is None:
|
||||
unweighted_overall_sum = Decimal("0")
|
||||
|
||||
overall_weight = aggregated_metrics["overall_weight"] or 0
|
||||
provider_count = aggregated_metrics["provider_count"] or 0
|
||||
|
||||
weighted_delta_sum = aggregated_metrics["weighted_delta_sum"]
|
||||
if weighted_delta_sum is None:
|
||||
weighted_delta_sum = Decimal("0")
|
||||
delta_weight = aggregated_metrics["delta_weight"] or 0
|
||||
|
||||
if overall_weight > 0:
|
||||
overall_score = (weighted_overall_sum / Decimal(overall_weight)).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
elif provider_count > 0:
|
||||
overall_score = (unweighted_overall_sum / Decimal(provider_count)).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
else:
|
||||
overall_score = Decimal("0.00")
|
||||
|
||||
if delta_weight > 0:
|
||||
score_delta = (weighted_delta_sum / Decimal(delta_weight)).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
else:
|
||||
score_delta = None
|
||||
|
||||
section_weighted_sums = defaultdict(lambda: Decimal("0"))
|
||||
section_weights = defaultdict(lambda: Decimal("0"))
|
||||
|
||||
combined_critical_requirements = {}
|
||||
|
||||
snapshots_with_weight = list(annotated_queryset)
|
||||
|
||||
for snapshot in snapshots_with_weight:
|
||||
weight_value = getattr(snapshot, "weight", None)
|
||||
try:
|
||||
weight_decimal = Decimal(weight_value)
|
||||
except (InvalidOperation, TypeError):
|
||||
weight_decimal = Decimal("1")
|
||||
if weight_decimal <= 0:
|
||||
weight_decimal = Decimal("1")
|
||||
|
||||
section_scores = snapshot.section_scores or {}
|
||||
for section, score in section_scores.items():
|
||||
try:
|
||||
score_decimal = Decimal(str(score))
|
||||
except (InvalidOperation, TypeError):
|
||||
continue
|
||||
section_weighted_sums[section] += score_decimal * weight_decimal
|
||||
section_weights[section] += weight_decimal
|
||||
|
||||
for requirement in snapshot.critical_requirements or []:
|
||||
key = requirement.get("requirement_id") or requirement.get("title")
|
||||
if not key:
|
||||
continue
|
||||
existing = combined_critical_requirements.get(key)
|
||||
|
||||
def requirement_sort_key(item):
|
||||
return (
|
||||
item.get("risk_level") or 0,
|
||||
item.get("weight") or 0,
|
||||
)
|
||||
|
||||
if existing is None or requirement_sort_key(
|
||||
requirement
|
||||
) > requirement_sort_key(existing):
|
||||
combined_critical_requirements[key] = deepcopy(requirement)
|
||||
|
||||
aggregated_section_scores = {}
|
||||
for section, total in section_weighted_sums.items():
|
||||
weight_total = section_weights[section]
|
||||
if weight_total > 0:
|
||||
aggregated_section_scores[section] = str(
|
||||
(total / weight_total).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
|
||||
aggregated_section_scores = dict(sorted(aggregated_section_scores.items()))
|
||||
|
||||
aggregated_critical_requirements = sorted(
|
||||
combined_critical_requirements.values(),
|
||||
key=lambda item: (
|
||||
item.get("risk_level") or 0,
|
||||
item.get("weight") or 0,
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
aggregated_snapshot = ThreatScoreSnapshot(
|
||||
tenant_id=tenant_id,
|
||||
scan=None,
|
||||
provider=None,
|
||||
compliance_id="prowler_threatscore_overview",
|
||||
overall_score=overall_score,
|
||||
score_delta=score_delta,
|
||||
section_scores=aggregated_section_scores,
|
||||
critical_requirements=aggregated_critical_requirements,
|
||||
total_requirements=total_requirements,
|
||||
passed_requirements=passed_requirements,
|
||||
failed_requirements=failed_requirements,
|
||||
manual_requirements=manual_requirements,
|
||||
total_findings=total_findings,
|
||||
passed_findings=passed_findings,
|
||||
failed_findings=failed_findings,
|
||||
)
|
||||
|
||||
latest_inserted_at = aggregated_metrics["latest_inserted_at"]
|
||||
if latest_inserted_at is not None:
|
||||
aggregated_snapshot.inserted_at = latest_inserted_at
|
||||
|
||||
aggregated_snapshot._aggregated = True
|
||||
|
||||
return aggregated_snapshot
|
||||
|
||||
|
||||
@extend_schema(tags=["Schedule"])
|
||||
@extend_schema_view(
|
||||
|
||||
@@ -7,7 +7,6 @@ from shutil import rmtree
|
||||
import matplotlib.pyplot as plt
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE, DJANGO_TMP_OUTPUT_DIRECTORY
|
||||
from django.db.models import Count, Q
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_CENTER
|
||||
from reportlab.lib.pagesizes import letter
|
||||
@@ -26,11 +25,22 @@ from reportlab.platypus import (
|
||||
TableStyle,
|
||||
)
|
||||
from tasks.jobs.export import _generate_output_directory, _upload_to_s3
|
||||
from tasks.jobs.threatscore import compute_threatscore_metrics
|
||||
from tasks.jobs.threatscore_utils import (
|
||||
_aggregate_requirement_statistics_from_database,
|
||||
_calculate_requirements_data_from_statistics,
|
||||
)
|
||||
from tasks.utils import batched
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Finding, Provider, ScanSummary, StatusChoices
|
||||
from api.models import (
|
||||
Finding,
|
||||
Provider,
|
||||
ScanSummary,
|
||||
StatusChoices,
|
||||
ThreatScoreSnapshot,
|
||||
)
|
||||
from api.utils import initialize_prowler_provider
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.outputs.finding import Finding as FindingOutput
|
||||
@@ -434,56 +444,6 @@ def _add_pdf_footer(canvas_obj: canvas.Canvas, doc: SimpleDocTemplate) -> None:
|
||||
canvas_obj.drawString(width - text_width - 30, 20, powered_text)
|
||||
|
||||
|
||||
def _aggregate_requirement_statistics_from_database(
|
||||
tenant_id: str, scan_id: str
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""
|
||||
Aggregate finding statistics by check_id using database aggregation.
|
||||
|
||||
This function uses Django ORM aggregation to calculate pass/fail statistics
|
||||
entirely in the database, avoiding the need to load findings into memory.
|
||||
|
||||
Args:
|
||||
tenant_id (str): The tenant ID for Row-Level Security context.
|
||||
scan_id (str): The ID of the scan to retrieve findings for.
|
||||
|
||||
Returns:
|
||||
dict[str, dict[str, int]]: Dictionary mapping check_id to statistics:
|
||||
- 'passed' (int): Number of passed findings for this check
|
||||
- 'total' (int): Total number of findings for this check
|
||||
|
||||
Example:
|
||||
{
|
||||
'aws_iam_user_mfa_enabled': {'passed': 10, 'total': 15},
|
||||
'aws_s3_bucket_public_access': {'passed': 0, 'total': 5}
|
||||
}
|
||||
"""
|
||||
requirement_statistics_by_check_id = {}
|
||||
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
# Use database aggregation to calculate stats without loading findings into memory
|
||||
aggregated_statistics_queryset = (
|
||||
Finding.all_objects.filter(tenant_id=tenant_id, scan_id=scan_id)
|
||||
.values("check_id")
|
||||
.annotate(
|
||||
total_findings=Count("id"),
|
||||
passed_findings=Count("id", filter=Q(status=StatusChoices.PASS)),
|
||||
)
|
||||
)
|
||||
|
||||
for aggregated_stat in aggregated_statistics_queryset:
|
||||
check_id = aggregated_stat["check_id"]
|
||||
requirement_statistics_by_check_id[check_id] = {
|
||||
"passed": aggregated_stat["passed_findings"],
|
||||
"total": aggregated_stat["total_findings"],
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Aggregated statistics for {len(requirement_statistics_by_check_id)} unique checks"
|
||||
)
|
||||
return requirement_statistics_by_check_id
|
||||
|
||||
|
||||
def _load_findings_for_requirement_checks(
|
||||
tenant_id: str, scan_id: str, check_ids: list[str], prowler_provider
|
||||
) -> dict[str, list[FindingOutput]]:
|
||||
@@ -544,84 +504,6 @@ def _load_findings_for_requirement_checks(
|
||||
return dict(findings_by_check_id)
|
||||
|
||||
|
||||
def _calculate_requirements_data_from_statistics(
|
||||
compliance_obj, requirement_statistics_by_check_id: dict[str, dict[str, int]]
|
||||
) -> tuple[dict[str, dict], list[dict]]:
|
||||
"""
|
||||
Calculate requirement status and statistics using pre-aggregated database statistics.
|
||||
|
||||
This function uses O(n) lookups with pre-aggregated statistics from the database,
|
||||
avoiding the need to iterate over all findings for each requirement.
|
||||
|
||||
Args:
|
||||
compliance_obj: The compliance framework object containing requirements.
|
||||
requirement_statistics_by_check_id (dict[str, dict[str, int]]): Pre-aggregated statistics
|
||||
mapping check_id to {'passed': int, 'total': int} counts.
|
||||
|
||||
Returns:
|
||||
tuple[dict[str, dict], list[dict]]: A tuple containing:
|
||||
- attributes_by_requirement_id: Dictionary mapping requirement IDs to their attributes.
|
||||
- requirements_list: List of requirement dictionaries with status and statistics.
|
||||
"""
|
||||
attributes_by_requirement_id = {}
|
||||
requirements_list = []
|
||||
|
||||
compliance_framework = getattr(compliance_obj, "Framework", "N/A")
|
||||
compliance_version = getattr(compliance_obj, "Version", "N/A")
|
||||
|
||||
for requirement in compliance_obj.Requirements:
|
||||
requirement_id = requirement.Id
|
||||
requirement_description = getattr(requirement, "Description", "")
|
||||
requirement_checks = getattr(requirement, "Checks", [])
|
||||
requirement_attributes = getattr(requirement, "Attributes", [])
|
||||
|
||||
# Store requirement metadata for later use
|
||||
attributes_by_requirement_id[requirement_id] = {
|
||||
"attributes": {
|
||||
"req_attributes": requirement_attributes,
|
||||
"checks": requirement_checks,
|
||||
},
|
||||
"description": requirement_description,
|
||||
}
|
||||
|
||||
# Calculate aggregated passed and total findings for this requirement
|
||||
total_passed_findings = 0
|
||||
total_findings_count = 0
|
||||
|
||||
for check_id in requirement_checks:
|
||||
if check_id in requirement_statistics_by_check_id:
|
||||
check_statistics = requirement_statistics_by_check_id[check_id]
|
||||
total_findings_count += check_statistics["total"]
|
||||
total_passed_findings += check_statistics["passed"]
|
||||
|
||||
# Determine overall requirement status based on findings
|
||||
if total_findings_count > 0:
|
||||
if total_passed_findings == total_findings_count:
|
||||
requirement_status = StatusChoices.PASS
|
||||
else:
|
||||
# Partial pass or complete fail both count as FAIL
|
||||
requirement_status = StatusChoices.FAIL
|
||||
else:
|
||||
# No findings means manual review required
|
||||
requirement_status = StatusChoices.MANUAL
|
||||
|
||||
requirements_list.append(
|
||||
{
|
||||
"id": requirement_id,
|
||||
"attributes": {
|
||||
"framework": compliance_framework,
|
||||
"version": compliance_version,
|
||||
"status": requirement_status,
|
||||
"description": requirement_description,
|
||||
"passed_findings": total_passed_findings,
|
||||
"total_findings": total_findings_count,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return attributes_by_requirement_id, requirements_list
|
||||
|
||||
|
||||
def generate_threatscore_report(
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
@@ -1262,8 +1144,9 @@ def generate_threatscore_report_job(
|
||||
2. Checks provider type compatibility
|
||||
3. Generates the output directory
|
||||
4. Calls generate_threatscore_report to create the PDF
|
||||
5. Uploads the PDF to S3
|
||||
6. Cleans up temporary files
|
||||
5. Computes and stores ThreatScore metrics snapshot
|
||||
6. Uploads the PDF to S3
|
||||
7. Cleans up temporary files
|
||||
|
||||
Args:
|
||||
tenant_id (str): The tenant ID for Row-Level Security context.
|
||||
@@ -1317,6 +1200,66 @@ def generate_threatscore_report_job(
|
||||
min_risk_level=4,
|
||||
)
|
||||
|
||||
# Compute and store ThreatScore metrics snapshot
|
||||
logger.info(f"Computing ThreatScore metrics for scan {scan_id}")
|
||||
try:
|
||||
metrics = compute_threatscore_metrics(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
provider_id=provider_id,
|
||||
compliance_id=compliance_id,
|
||||
min_risk_level=4,
|
||||
)
|
||||
|
||||
# Create snapshot in database
|
||||
with rls_transaction(tenant_id):
|
||||
# Get previous snapshot for the same provider to calculate delta
|
||||
previous_snapshot = (
|
||||
ThreatScoreSnapshot.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_id,
|
||||
compliance_id=compliance_id,
|
||||
)
|
||||
.order_by("-inserted_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
# Calculate score delta (improvement)
|
||||
score_delta = None
|
||||
if previous_snapshot:
|
||||
score_delta = metrics["overall_score"] - float(
|
||||
previous_snapshot.overall_score
|
||||
)
|
||||
|
||||
snapshot = ThreatScoreSnapshot.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
provider_id=provider_id,
|
||||
compliance_id=compliance_id,
|
||||
overall_score=metrics["overall_score"],
|
||||
score_delta=score_delta,
|
||||
section_scores=metrics["section_scores"],
|
||||
critical_requirements=metrics["critical_requirements"],
|
||||
total_requirements=metrics["total_requirements"],
|
||||
passed_requirements=metrics["passed_requirements"],
|
||||
failed_requirements=metrics["failed_requirements"],
|
||||
manual_requirements=metrics["manual_requirements"],
|
||||
total_findings=metrics["total_findings"],
|
||||
passed_findings=metrics["passed_findings"],
|
||||
failed_findings=metrics["failed_findings"],
|
||||
)
|
||||
|
||||
delta_msg = (
|
||||
f" (delta: {score_delta:+.2f}%)" if score_delta is not None else ""
|
||||
)
|
||||
logger.info(
|
||||
f"ThreatScore snapshot created with ID {snapshot.id} "
|
||||
f"(score: {snapshot.overall_score}%{delta_msg})"
|
||||
)
|
||||
except Exception as e:
|
||||
# Log error but don't fail the job if snapshot creation fails
|
||||
logger.error(f"Error creating ThreatScore snapshot: {e}")
|
||||
|
||||
upload_uri = _upload_to_s3(
|
||||
tenant_id,
|
||||
scan_id,
|
||||
|
||||
@@ -762,9 +762,9 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
provider_instance = scan_instance.provider
|
||||
prowler_provider = return_prowler_provider(provider_instance)
|
||||
|
||||
compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[
|
||||
provider_instance.provider
|
||||
]
|
||||
compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE.get(
|
||||
provider_instance.provider, {}
|
||||
)
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
threatscore_requirements_by_check: dict[str, set[str]] = {}
|
||||
threatscore_framework = compliance_template.get(
|
||||
@@ -836,63 +836,68 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
region: deepcopy(compliance_template) for region in regions
|
||||
}
|
||||
|
||||
# Apply check statuses to compliance data
|
||||
for region, check_status in check_status_by_region.items():
|
||||
compliance_data = compliance_overview_by_region.setdefault(
|
||||
region, deepcopy(compliance_template)
|
||||
)
|
||||
for check_name, status in check_status.items():
|
||||
generate_scan_compliance(
|
||||
compliance_data,
|
||||
provider_instance.provider,
|
||||
check_name,
|
||||
status,
|
||||
)
|
||||
|
||||
# Prepare compliance requirement rows
|
||||
compliance_requirement_rows: list[dict[str, Any]] = []
|
||||
utc_datetime_now = datetime.now(tz=timezone.utc)
|
||||
for region, compliance_data in compliance_overview_by_region.items():
|
||||
for compliance_id, compliance in compliance_data.items():
|
||||
modeled_compliance_id = _normalized_compliance_key(
|
||||
compliance["framework"], compliance["version"]
|
||||
|
||||
# Skip if provider has no compliance frameworks
|
||||
if compliance_template:
|
||||
# Apply check statuses to compliance data
|
||||
for region, check_status in check_status_by_region.items():
|
||||
compliance_data = compliance_overview_by_region.setdefault(
|
||||
region, deepcopy(compliance_template)
|
||||
)
|
||||
# Create an overview record for each requirement within each compliance framework
|
||||
for requirement_id, requirement in compliance["requirements"].items():
|
||||
checks_status = requirement["checks_status"]
|
||||
compliance_requirement_rows.append(
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": tenant_id,
|
||||
"inserted_at": utc_datetime_now,
|
||||
"compliance_id": compliance_id,
|
||||
"framework": compliance["framework"],
|
||||
"version": compliance["version"] or "",
|
||||
"description": requirement.get("description") or "",
|
||||
"region": region,
|
||||
"requirement_id": requirement_id,
|
||||
"requirement_status": requirement["status"],
|
||||
"passed_checks": checks_status["pass"],
|
||||
"failed_checks": checks_status["fail"],
|
||||
"total_checks": checks_status["total"],
|
||||
"scan_id": scan_instance.id,
|
||||
"passed_findings": findings_count_by_compliance.get(
|
||||
region, {}
|
||||
)
|
||||
.get(modeled_compliance_id, {})
|
||||
.get(requirement_id, {})
|
||||
.get("pass", 0),
|
||||
"total_findings": findings_count_by_compliance.get(
|
||||
region, {}
|
||||
)
|
||||
.get(modeled_compliance_id, {})
|
||||
.get(requirement_id, {})
|
||||
.get("total", 0),
|
||||
}
|
||||
for check_name, status in check_status.items():
|
||||
generate_scan_compliance(
|
||||
compliance_data,
|
||||
provider_instance.provider,
|
||||
check_name,
|
||||
status,
|
||||
)
|
||||
|
||||
# Bulk create requirement records using PostgreSQL COPY
|
||||
_persist_compliance_requirement_rows(tenant_id, compliance_requirement_rows)
|
||||
# Prepare compliance requirement rows
|
||||
utc_datetime_now = datetime.now(tz=timezone.utc)
|
||||
for region, compliance_data in compliance_overview_by_region.items():
|
||||
for compliance_id, compliance in compliance_data.items():
|
||||
modeled_compliance_id = _normalized_compliance_key(
|
||||
compliance["framework"], compliance["version"]
|
||||
)
|
||||
# Create an overview record for each requirement within each compliance framework
|
||||
for requirement_id, requirement in compliance[
|
||||
"requirements"
|
||||
].items():
|
||||
checks_status = requirement["checks_status"]
|
||||
compliance_requirement_rows.append(
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": tenant_id,
|
||||
"inserted_at": utc_datetime_now,
|
||||
"compliance_id": compliance_id,
|
||||
"framework": compliance["framework"],
|
||||
"version": compliance["version"] or "",
|
||||
"description": requirement.get("description") or "",
|
||||
"region": region,
|
||||
"requirement_id": requirement_id,
|
||||
"requirement_status": requirement["status"],
|
||||
"passed_checks": checks_status["pass"],
|
||||
"failed_checks": checks_status["fail"],
|
||||
"total_checks": checks_status["total"],
|
||||
"scan_id": scan_instance.id,
|
||||
"passed_findings": findings_count_by_compliance.get(
|
||||
region, {}
|
||||
)
|
||||
.get(modeled_compliance_id, {})
|
||||
.get(requirement_id, {})
|
||||
.get("pass", 0),
|
||||
"total_findings": findings_count_by_compliance.get(
|
||||
region, {}
|
||||
)
|
||||
.get(modeled_compliance_id, {})
|
||||
.get(requirement_id, {})
|
||||
.get("total", 0),
|
||||
}
|
||||
)
|
||||
|
||||
# Bulk create requirement records using PostgreSQL COPY
|
||||
_persist_compliance_requirement_rows(tenant_id, compliance_requirement_rows)
|
||||
|
||||
return {
|
||||
"requirements_created": len(compliance_requirement_rows),
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
from celery.utils.log import get_task_logger
|
||||
from tasks.jobs.threatscore_utils import (
|
||||
_aggregate_requirement_statistics_from_database,
|
||||
_calculate_requirements_data_from_statistics,
|
||||
)
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Provider, StatusChoices
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def compute_threatscore_metrics(
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
provider_id: str,
|
||||
compliance_id: str,
|
||||
min_risk_level: int = 4,
|
||||
) -> dict:
|
||||
"""
|
||||
Compute ThreatScore metrics for a given scan.
|
||||
|
||||
This function calculates all the metrics needed for a ThreatScore snapshot:
|
||||
- Overall ThreatScore percentage
|
||||
- Section-by-section scores
|
||||
- Critical failed requirements (risk >= min_risk_level)
|
||||
- Summary statistics (requirements and findings counts)
|
||||
|
||||
Args:
|
||||
tenant_id (str): The tenant ID for Row-Level Security context.
|
||||
scan_id (str): The ID of the scan to analyze.
|
||||
provider_id (str): The ID of the provider used in the scan.
|
||||
compliance_id (str): Compliance framework ID (e.g., "prowler_threatscore_aws").
|
||||
min_risk_level (int): Minimum risk level for critical requirements. Defaults to 4.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing:
|
||||
- overall_score (float): Overall ThreatScore percentage (0-100)
|
||||
- section_scores (dict): Section name -> score percentage mapping
|
||||
- critical_requirements (list): List of critical failed requirement dicts
|
||||
- total_requirements (int): Total number of requirements
|
||||
- passed_requirements (int): Number of PASS requirements
|
||||
- failed_requirements (int): Number of FAIL requirements
|
||||
- manual_requirements (int): Number of MANUAL requirements
|
||||
- total_findings (int): Total findings count
|
||||
- passed_findings (int): Passed findings count
|
||||
- failed_findings (int): Failed findings count
|
||||
|
||||
Example:
|
||||
>>> metrics = compute_threatscore_metrics(
|
||||
... tenant_id="tenant-123",
|
||||
... scan_id="scan-456",
|
||||
... provider_id="provider-789",
|
||||
... compliance_id="prowler_threatscore_aws"
|
||||
... )
|
||||
>>> print(f"Overall ThreatScore: {metrics['overall_score']:.2f}%")
|
||||
"""
|
||||
# Get provider and compliance information
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
provider_obj = Provider.objects.get(id=provider_id)
|
||||
provider_type = provider_obj.provider
|
||||
|
||||
frameworks_bulk = Compliance.get_bulk(provider_type)
|
||||
compliance_obj = frameworks_bulk[compliance_id]
|
||||
|
||||
# Aggregate requirement statistics from database
|
||||
requirement_statistics_by_check_id = (
|
||||
_aggregate_requirement_statistics_from_database(tenant_id, scan_id)
|
||||
)
|
||||
|
||||
# Calculate requirements data using aggregated statistics
|
||||
attributes_by_requirement_id, requirements_list = (
|
||||
_calculate_requirements_data_from_statistics(
|
||||
compliance_obj, requirement_statistics_by_check_id
|
||||
)
|
||||
)
|
||||
|
||||
# Initialize metrics
|
||||
overall_numerator = 0
|
||||
overall_denominator = 0
|
||||
overall_has_findings = False
|
||||
|
||||
sections_data = {}
|
||||
|
||||
total_requirements = len(requirements_list)
|
||||
passed_requirements = 0
|
||||
failed_requirements = 0
|
||||
manual_requirements = 0
|
||||
total_findings = 0
|
||||
passed_findings = 0
|
||||
failed_findings = 0
|
||||
|
||||
critical_requirements_list = []
|
||||
|
||||
# Process each requirement
|
||||
for requirement in requirements_list:
|
||||
requirement_id = requirement["id"]
|
||||
requirement_status = requirement["attributes"]["status"]
|
||||
requirement_attributes = attributes_by_requirement_id.get(requirement_id, {})
|
||||
|
||||
# Count requirements by status
|
||||
if requirement_status == StatusChoices.PASS:
|
||||
passed_requirements += 1
|
||||
elif requirement_status == StatusChoices.FAIL:
|
||||
failed_requirements += 1
|
||||
elif requirement_status == StatusChoices.MANUAL:
|
||||
manual_requirements += 1
|
||||
|
||||
# Get findings data
|
||||
req_passed_findings = requirement["attributes"].get("passed_findings", 0)
|
||||
req_total_findings = requirement["attributes"].get("total_findings", 0)
|
||||
|
||||
# Accumulate findings counts
|
||||
total_findings += req_total_findings
|
||||
passed_findings += req_passed_findings
|
||||
failed_findings += req_total_findings - req_passed_findings
|
||||
|
||||
# Skip requirements with no findings
|
||||
if req_total_findings == 0:
|
||||
continue
|
||||
|
||||
overall_has_findings = True
|
||||
|
||||
# Get requirement metadata
|
||||
metadata = requirement_attributes.get("attributes", {}).get(
|
||||
"req_attributes", []
|
||||
)
|
||||
if not metadata or len(metadata) == 0:
|
||||
continue
|
||||
|
||||
m = metadata[0]
|
||||
risk_level = getattr(m, "LevelOfRisk", 0)
|
||||
weight = getattr(m, "Weight", 0)
|
||||
section = getattr(m, "Section", "Unknown")
|
||||
|
||||
# Calculate ThreatScore components using formula from UI
|
||||
rate_i = req_passed_findings / req_total_findings
|
||||
rfac_i = 1 + 0.25 * risk_level
|
||||
|
||||
# Update overall score
|
||||
overall_numerator += rate_i * req_total_findings * weight * rfac_i
|
||||
overall_denominator += req_total_findings * weight * rfac_i
|
||||
|
||||
# Update section scores
|
||||
if section not in sections_data:
|
||||
sections_data[section] = {
|
||||
"numerator": 0,
|
||||
"denominator": 0,
|
||||
"has_findings": False,
|
||||
}
|
||||
|
||||
sections_data[section]["has_findings"] = True
|
||||
sections_data[section]["numerator"] += (
|
||||
rate_i * req_total_findings * weight * rfac_i
|
||||
)
|
||||
sections_data[section]["denominator"] += req_total_findings * weight * rfac_i
|
||||
|
||||
# Identify critical failed requirements
|
||||
if requirement_status == StatusChoices.FAIL and risk_level >= min_risk_level:
|
||||
critical_requirements_list.append(
|
||||
{
|
||||
"requirement_id": requirement_id,
|
||||
"title": getattr(m, "Title", "N/A"),
|
||||
"section": section,
|
||||
"subsection": getattr(m, "SubSection", "N/A"),
|
||||
"risk_level": risk_level,
|
||||
"weight": weight,
|
||||
"passed_findings": req_passed_findings,
|
||||
"total_findings": req_total_findings,
|
||||
"description": getattr(m, "AttributeDescription", "N/A"),
|
||||
}
|
||||
)
|
||||
|
||||
# Calculate overall ThreatScore
|
||||
if not overall_has_findings:
|
||||
overall_score = 100.0
|
||||
elif overall_denominator > 0:
|
||||
overall_score = (overall_numerator / overall_denominator) * 100
|
||||
else:
|
||||
overall_score = 0.0
|
||||
|
||||
# Calculate section scores
|
||||
section_scores = {}
|
||||
for section, data in sections_data.items():
|
||||
if data["has_findings"] and data["denominator"] > 0:
|
||||
section_scores[section] = (data["numerator"] / data["denominator"]) * 100
|
||||
else:
|
||||
section_scores[section] = 100.0
|
||||
|
||||
# Sort critical requirements by risk level (desc) and weight (desc)
|
||||
critical_requirements_list.sort(
|
||||
key=lambda x: (x["risk_level"], x["weight"]), reverse=True
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"ThreatScore computed: {overall_score:.2f}% "
|
||||
f"({passed_requirements}/{total_requirements} requirements passed, "
|
||||
f"{len(critical_requirements_list)} critical failures)"
|
||||
)
|
||||
|
||||
return {
|
||||
"overall_score": round(overall_score, 2),
|
||||
"section_scores": {k: round(v, 2) for k, v in section_scores.items()},
|
||||
"critical_requirements": critical_requirements_list,
|
||||
"total_requirements": total_requirements,
|
||||
"passed_requirements": passed_requirements,
|
||||
"failed_requirements": failed_requirements,
|
||||
"manual_requirements": manual_requirements,
|
||||
"total_findings": total_findings,
|
||||
"passed_findings": passed_findings,
|
||||
"failed_findings": failed_findings,
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.db.models import Count, Q
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Finding, StatusChoices
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def _aggregate_requirement_statistics_from_database(
|
||||
tenant_id: str, scan_id: str
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""
|
||||
Aggregate finding statistics by check_id using database aggregation.
|
||||
|
||||
This function uses Django ORM aggregation to calculate pass/fail statistics
|
||||
entirely in the database, avoiding the need to load findings into memory.
|
||||
|
||||
Args:
|
||||
tenant_id (str): The tenant ID for Row-Level Security context.
|
||||
scan_id (str): The ID of the scan to retrieve findings for.
|
||||
|
||||
Returns:
|
||||
dict[str, dict[str, int]]: Dictionary mapping check_id to statistics:
|
||||
- 'passed' (int): Number of passed findings for this check
|
||||
- 'total' (int): Total number of findings for this check
|
||||
|
||||
Example:
|
||||
{
|
||||
'aws_iam_user_mfa_enabled': {'passed': 10, 'total': 15},
|
||||
'aws_s3_bucket_public_access': {'passed': 0, 'total': 5}
|
||||
}
|
||||
"""
|
||||
requirement_statistics_by_check_id = {}
|
||||
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
aggregated_statistics_queryset = (
|
||||
Finding.all_objects.filter(tenant_id=tenant_id, scan_id=scan_id)
|
||||
.values("check_id")
|
||||
.annotate(
|
||||
total_findings=Count("id"),
|
||||
passed_findings=Count("id", filter=Q(status=StatusChoices.PASS)),
|
||||
)
|
||||
)
|
||||
|
||||
for aggregated_stat in aggregated_statistics_queryset:
|
||||
check_id = aggregated_stat["check_id"]
|
||||
requirement_statistics_by_check_id[check_id] = {
|
||||
"passed": aggregated_stat["passed_findings"],
|
||||
"total": aggregated_stat["total_findings"],
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Aggregated statistics for {len(requirement_statistics_by_check_id)} unique checks"
|
||||
)
|
||||
return requirement_statistics_by_check_id
|
||||
|
||||
|
||||
def _calculate_requirements_data_from_statistics(
|
||||
compliance_obj, requirement_statistics_by_check_id: dict[str, dict[str, int]]
|
||||
) -> tuple[dict[str, dict], list[dict]]:
|
||||
"""
|
||||
Calculate requirement status and statistics using pre-aggregated database statistics.
|
||||
|
||||
Args:
|
||||
compliance_obj: The compliance framework object containing requirements.
|
||||
requirement_statistics_by_check_id (dict[str, dict[str, int]]): Pre-aggregated statistics
|
||||
mapping check_id to {'passed': int, 'total': int} counts.
|
||||
|
||||
Returns:
|
||||
tuple[dict[str, dict], list[dict]]: A tuple containing:
|
||||
- attributes_by_requirement_id: Dictionary mapping requirement IDs to their attributes.
|
||||
- requirements_list: List of requirement dictionaries with status and statistics.
|
||||
"""
|
||||
attributes_by_requirement_id = {}
|
||||
requirements_list = []
|
||||
|
||||
compliance_framework = getattr(compliance_obj, "Framework", "N/A")
|
||||
compliance_version = getattr(compliance_obj, "Version", "N/A")
|
||||
|
||||
for requirement in compliance_obj.Requirements:
|
||||
requirement_id = requirement.Id
|
||||
requirement_description = getattr(requirement, "Description", "")
|
||||
requirement_checks = getattr(requirement, "Checks", [])
|
||||
requirement_attributes = getattr(requirement, "Attributes", [])
|
||||
|
||||
attributes_by_requirement_id[requirement_id] = {
|
||||
"attributes": {
|
||||
"req_attributes": requirement_attributes,
|
||||
"checks": requirement_checks,
|
||||
},
|
||||
"description": requirement_description,
|
||||
}
|
||||
|
||||
total_passed_findings = 0
|
||||
total_findings_count = 0
|
||||
|
||||
for check_id in requirement_checks:
|
||||
if check_id in requirement_statistics_by_check_id:
|
||||
check_statistics = requirement_statistics_by_check_id[check_id]
|
||||
total_findings_count += check_statistics["total"]
|
||||
total_passed_findings += check_statistics["passed"]
|
||||
|
||||
if total_findings_count > 0:
|
||||
if total_passed_findings == total_findings_count:
|
||||
requirement_status = StatusChoices.PASS
|
||||
else:
|
||||
requirement_status = StatusChoices.FAIL
|
||||
else:
|
||||
requirement_status = StatusChoices.MANUAL
|
||||
|
||||
requirements_list.append(
|
||||
{
|
||||
"id": requirement_id,
|
||||
"attributes": {
|
||||
"framework": compliance_framework,
|
||||
"version": compliance_version,
|
||||
"status": requirement_status,
|
||||
"description": requirement_description,
|
||||
"passed_findings": total_passed_findings,
|
||||
"total_findings": total_findings_count,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return attributes_by_requirement_id, requirements_list
|
||||
@@ -1,19 +1,25 @@
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import matplotlib
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
from freezegun import freeze_time
|
||||
from tasks.jobs.report import (
|
||||
_aggregate_requirement_statistics_from_database,
|
||||
_calculate_requirements_data_from_statistics,
|
||||
_load_findings_for_requirement_checks,
|
||||
generate_threatscore_report,
|
||||
generate_threatscore_report_job,
|
||||
)
|
||||
from tasks.jobs.threatscore_utils import (
|
||||
_aggregate_requirement_statistics_from_database,
|
||||
_calculate_requirements_data_from_statistics,
|
||||
)
|
||||
from tasks.tasks import generate_threatscore_report_task
|
||||
|
||||
from api.models import Finding, StatusChoices
|
||||
from api.models import Finding, Scan, StateChoices, StatusChoices, ThreatScoreSnapshot
|
||||
from prowler.lib.check.models import Severity
|
||||
|
||||
matplotlib.use("Agg") # Use non-interactive backend for tests
|
||||
@@ -39,6 +45,7 @@ class TestGenerateThreatscoreReport:
|
||||
assert result == {"upload": False}
|
||||
mock_filter.assert_called_once_with(scan_id=self.scan_id)
|
||||
|
||||
@patch("tasks.jobs.report.ThreatScoreSnapshot.objects.create")
|
||||
@patch("tasks.jobs.report.rmtree")
|
||||
@patch("tasks.jobs.report._upload_to_s3")
|
||||
@patch("tasks.jobs.report.generate_threatscore_report")
|
||||
@@ -53,6 +60,7 @@ class TestGenerateThreatscoreReport:
|
||||
mock_generate_report,
|
||||
mock_upload,
|
||||
mock_rmtree,
|
||||
mock_snapshot_create,
|
||||
):
|
||||
mock_scan_summary_filter.return_value.exists.return_value = True
|
||||
|
||||
@@ -95,8 +103,10 @@ class TestGenerateThreatscoreReport:
|
||||
Path("/tmp/threatscore_path_threatscore_report.pdf").parent,
|
||||
ignore_errors=True,
|
||||
)
|
||||
mock_snapshot_create.assert_called_once()
|
||||
|
||||
def test_generate_threatscore_report_fails_upload(self):
|
||||
@patch("tasks.jobs.report.ThreatScoreSnapshot.objects.create")
|
||||
def test_generate_threatscore_report_fails_upload(self, mock_snapshot_create):
|
||||
with (
|
||||
patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter,
|
||||
patch("tasks.jobs.report.Provider.objects.get") as mock_provider_get,
|
||||
@@ -125,8 +135,12 @@ class TestGenerateThreatscoreReport:
|
||||
)
|
||||
|
||||
assert result == {"upload": False}
|
||||
mock_snapshot_create.assert_called_once()
|
||||
|
||||
def test_generate_threatscore_report_logs_rmtree_exception(self, caplog):
|
||||
@patch("tasks.jobs.report.ThreatScoreSnapshot.objects.create")
|
||||
def test_generate_threatscore_report_logs_rmtree_exception(
|
||||
self, mock_snapshot_create, caplog
|
||||
):
|
||||
with (
|
||||
patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter,
|
||||
patch("tasks.jobs.report.Provider.objects.get") as mock_provider_get,
|
||||
@@ -160,8 +174,10 @@ class TestGenerateThreatscoreReport:
|
||||
provider_id=self.provider_id,
|
||||
)
|
||||
assert "Error deleting output files" in caplog.text
|
||||
mock_snapshot_create.assert_called_once()
|
||||
|
||||
def test_generate_threatscore_report_azure_provider(self):
|
||||
@patch("tasks.jobs.report.ThreatScoreSnapshot.objects.create")
|
||||
def test_generate_threatscore_report_azure_provider(self, mock_snapshot_create):
|
||||
with (
|
||||
patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter,
|
||||
patch("tasks.jobs.report.Provider.objects.get") as mock_provider_get,
|
||||
@@ -200,6 +216,135 @@ class TestGenerateThreatscoreReport:
|
||||
only_failed=True,
|
||||
min_risk_level=4,
|
||||
)
|
||||
mock_snapshot_create.assert_called_once()
|
||||
|
||||
@patch("tasks.jobs.report.rmtree")
|
||||
@patch(
|
||||
"tasks.jobs.report._upload_to_s3",
|
||||
return_value="s3://bucket/threatscore/threatscore_report.pdf",
|
||||
)
|
||||
@patch("tasks.jobs.report.generate_threatscore_report")
|
||||
@patch("tasks.jobs.report._generate_output_directory")
|
||||
@patch("tasks.jobs.report.ScanSummary.objects.filter")
|
||||
@patch("tasks.jobs.report.compute_threatscore_metrics")
|
||||
@pytest.mark.django_db
|
||||
@freeze_time("2025-01-10T12:00:00Z")
|
||||
def test_generate_threatscore_report_persists_snapshot_and_delta(
|
||||
self,
|
||||
mock_compute_metrics,
|
||||
mock_scan_summary_filter,
|
||||
mock_generate_output_directory,
|
||||
mock_generate_report,
|
||||
mock_upload,
|
||||
mock_rmtree,
|
||||
tenants_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
|
||||
scan_previous = Scan.objects.create(
|
||||
tenant=tenant,
|
||||
provider=provider,
|
||||
name="previous-threatscore-scan",
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
started_at=timezone.now() - timedelta(hours=4),
|
||||
completed_at=timezone.now() - timedelta(hours=3),
|
||||
)
|
||||
ThreatScoreSnapshot.objects.create(
|
||||
tenant=tenant,
|
||||
scan=scan_previous,
|
||||
provider=provider,
|
||||
compliance_id="prowler_threatscore_aws",
|
||||
overall_score=Decimal("70.00"),
|
||||
score_delta=None,
|
||||
section_scores={"1. IAM": "65.00"},
|
||||
critical_requirements=[],
|
||||
total_requirements=50,
|
||||
passed_requirements=35,
|
||||
failed_requirements=15,
|
||||
manual_requirements=0,
|
||||
total_findings=40,
|
||||
passed_findings=25,
|
||||
failed_findings=15,
|
||||
)
|
||||
|
||||
scan_current = Scan.objects.create(
|
||||
tenant=tenant,
|
||||
provider=provider,
|
||||
name="current-threatscore-scan",
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
started_at=timezone.now() - timedelta(hours=2),
|
||||
completed_at=timezone.now() - timedelta(hours=1),
|
||||
)
|
||||
|
||||
mock_scan_summary_filter.return_value.exists.return_value = True
|
||||
mock_generate_output_directory.return_value = (
|
||||
"/tmp/output",
|
||||
"/tmp/compressed",
|
||||
"/tmp/threatscore_path",
|
||||
)
|
||||
|
||||
metrics = {
|
||||
"overall_score": 85.5,
|
||||
"score_delta": 10.0,
|
||||
"section_scores": {"1. IAM": 82.3, "2. Attack Surface": 60.0},
|
||||
"critical_requirements": [
|
||||
{
|
||||
"requirement_id": "req_new",
|
||||
"title": "New high-risk requirement",
|
||||
"section": "1. IAM",
|
||||
"subsection": "Root Account",
|
||||
"risk_level": 5,
|
||||
"weight": 150,
|
||||
"passed_findings": 7,
|
||||
"total_findings": 10,
|
||||
"description": "Critical requirement description",
|
||||
}
|
||||
],
|
||||
"total_requirements": 140,
|
||||
"passed_requirements": 100,
|
||||
"failed_requirements": 40,
|
||||
"manual_requirements": 0,
|
||||
"total_findings": 200,
|
||||
"passed_findings": 150,
|
||||
"failed_findings": 50,
|
||||
}
|
||||
mock_compute_metrics.return_value = metrics
|
||||
|
||||
result = generate_threatscore_report_job(
|
||||
tenant_id=str(tenant.id),
|
||||
scan_id=str(scan_current.id),
|
||||
provider_id=str(provider.id),
|
||||
)
|
||||
|
||||
assert result == {"upload": True}
|
||||
mock_compute_metrics.assert_called_once_with(
|
||||
tenant_id=str(tenant.id),
|
||||
scan_id=str(scan_current.id),
|
||||
provider_id=str(provider.id),
|
||||
compliance_id="prowler_threatscore_aws",
|
||||
min_risk_level=4,
|
||||
)
|
||||
mock_generate_report.assert_called_once()
|
||||
mock_upload.assert_called_once()
|
||||
mock_rmtree.assert_called_once()
|
||||
|
||||
snapshots = ThreatScoreSnapshot.objects.filter(
|
||||
tenant=tenant, provider=provider
|
||||
).order_by("inserted_at")
|
||||
assert snapshots.count() == 2
|
||||
|
||||
new_snapshot = ThreatScoreSnapshot.objects.get(scan=scan_current)
|
||||
assert new_snapshot.compliance_id == "prowler_threatscore_aws"
|
||||
assert Decimal(new_snapshot.overall_score) == Decimal("85.50")
|
||||
assert Decimal(new_snapshot.score_delta) == Decimal("15.50")
|
||||
assert new_snapshot.section_scores == metrics["section_scores"]
|
||||
assert new_snapshot.critical_requirements == metrics["critical_requirements"]
|
||||
assert new_snapshot.total_requirements == metrics["total_requirements"]
|
||||
assert new_snapshot.total_findings == metrics["total_findings"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import warnings
|
||||
|
||||
from dashboard.common_methods import get_section_containers_cis
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
|
||||
def get_table(data):
|
||||
aux = data[
|
||||
[
|
||||
"REQUIREMENTS_ID",
|
||||
"REQUIREMENTS_DESCRIPTION",
|
||||
"REQUIREMENTS_ATTRIBUTES_SECTION",
|
||||
"CHECKID",
|
||||
"STATUS",
|
||||
"REGION",
|
||||
"ACCOUNTID",
|
||||
"RESOURCEID",
|
||||
]
|
||||
].copy()
|
||||
|
||||
# Shorten the long FedRAMP KSI descriptions for better display
|
||||
ksi_short_names = {
|
||||
"A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management",
|
||||
"A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture",
|
||||
"A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management",
|
||||
"A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education",
|
||||
"A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting",
|
||||
"A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing",
|
||||
"A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory",
|
||||
"A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning",
|
||||
"A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration",
|
||||
"A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources",
|
||||
}
|
||||
|
||||
# Replace long descriptions with short names - use contains for partial matching
|
||||
if not aux.empty:
|
||||
for long_desc, short_name in ksi_short_names.items():
|
||||
mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains(
|
||||
long_desc, na=False, regex=False
|
||||
)
|
||||
aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name
|
||||
|
||||
return get_section_containers_cis(
|
||||
aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION"
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
import warnings
|
||||
|
||||
from dashboard.common_methods import get_section_containers_cis
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
|
||||
def get_table(data):
|
||||
aux = data[
|
||||
[
|
||||
"REQUIREMENTS_ID",
|
||||
"REQUIREMENTS_DESCRIPTION",
|
||||
"REQUIREMENTS_ATTRIBUTES_SECTION",
|
||||
"CHECKID",
|
||||
"STATUS",
|
||||
"REGION",
|
||||
"ACCOUNTID",
|
||||
"RESOURCEID",
|
||||
]
|
||||
].copy()
|
||||
|
||||
# Shorten the long FedRAMP KSI descriptions for better display
|
||||
ksi_short_names = {
|
||||
"A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management",
|
||||
"A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture",
|
||||
"A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management",
|
||||
"A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education",
|
||||
"A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting",
|
||||
"A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing",
|
||||
"A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory",
|
||||
"A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning",
|
||||
"A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration",
|
||||
"A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources",
|
||||
}
|
||||
|
||||
# Replace long descriptions with short names - use contains for partial matching
|
||||
if not aux.empty:
|
||||
for long_desc, short_name in ksi_short_names.items():
|
||||
mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains(
|
||||
long_desc, na=False, regex=False
|
||||
)
|
||||
aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name
|
||||
|
||||
return get_section_containers_cis(
|
||||
aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION"
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
import warnings
|
||||
|
||||
from dashboard.common_methods import get_section_containers_cis
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
|
||||
def get_table(data):
|
||||
aux = data[
|
||||
[
|
||||
"REQUIREMENTS_ID",
|
||||
"REQUIREMENTS_DESCRIPTION",
|
||||
"REQUIREMENTS_ATTRIBUTES_SECTION",
|
||||
"CHECKID",
|
||||
"STATUS",
|
||||
"REGION",
|
||||
"ACCOUNTID",
|
||||
"RESOURCEID",
|
||||
]
|
||||
].copy()
|
||||
|
||||
# Shorten the long FedRAMP KSI descriptions for better display
|
||||
ksi_short_names = {
|
||||
"A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management",
|
||||
"A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture",
|
||||
"A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management",
|
||||
"A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education",
|
||||
"A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting",
|
||||
"A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing",
|
||||
"A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory",
|
||||
"A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning",
|
||||
"A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration",
|
||||
"A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources",
|
||||
}
|
||||
|
||||
# Replace long descriptions with short names - use contains for partial matching
|
||||
if not aux.empty:
|
||||
for long_desc, short_name in ksi_short_names.items():
|
||||
mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains(
|
||||
long_desc, na=False, regex=False
|
||||
)
|
||||
aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name
|
||||
|
||||
return get_section_containers_cis(
|
||||
aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION"
|
||||
)
|
||||
@@ -4,7 +4,26 @@ title: 'Contributing to Documentation'
|
||||
|
||||
Prowler documentation is built using [Mintlify](https://www.mintlify.com/docs), allowing contributors to easily add or enhance documentation.
|
||||
|
||||
## Installation and Setup
|
||||
## Documentation Structure
|
||||
|
||||
The Prowler documentation is organized into several sections. The main ones are:
|
||||
|
||||
- **Getting Started**: Provides an overview of the Prowler platform and its different solutions, including Prowler Cloud/App, Prowler CLI, Prowler MCP Server, Prowler Hub, and Prowler Lighthouse AI. This section helps new users understand which Prowler solution best fits their needs and includes product comparisons.
|
||||
|
||||
- **Guides**: Contains practical tutorials and how-to guides organized by product (Prowler Cloud/App, CLI) and provider (AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub, etc.). This section covers authentication, integrations, compliance, and advanced usage scenarios.
|
||||
|
||||
- **Developer Guide**: Documentation for contributors looking to extend Prowler functionality. This includes guides on creating providers, services, checks, output formats, integrations, and compliance frameworks. Provider-specific implementation details and testing strategies are also covered here.
|
||||
|
||||
- **Troubleshooting**: Common issues, error messages, and their solutions. This section helps users resolve problems encountered during installation, configuration, or execution.
|
||||
|
||||
|
||||
## AI-Driven Documentation
|
||||
|
||||
As mentioned in the [Introduction](/developer-guide/introduction#ai-driven-contributions), we have specialized resources to enhance AI-driven development.
|
||||
|
||||
This includes the [AGENTS.md](https://github.com/prowler-cloud/prowler/blob/master/docs/AGENTS.md) file that contains the guidelines and style guide for the AI agents in the Prowler documentation.
|
||||
|
||||
## Local Development
|
||||
|
||||
<Steps>
|
||||
<Step title="Install Mintlify CLI">
|
||||
@@ -33,10 +52,10 @@ Prowler documentation is built using [Mintlify](https://www.mintlify.com/docs),
|
||||
</Step>
|
||||
|
||||
<Step title="Submit Changes">
|
||||
Once documentation updates are complete, submit a pull request for review.
|
||||
Once documentation updates are complete, [submit a pull request for review](/developer-guide/introduction#sending-the-pull-request).
|
||||
|
||||
The Prowler team will assess and merge contributions.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
Your efforts help improve Prowler documentation—thank you for contributing!
|
||||
Your efforts help improve Prowler documentation. Thank you for contributing! 🤘
|
||||
|
||||
@@ -2,19 +2,70 @@
|
||||
title: 'Introduction to developing in Prowler'
|
||||
---
|
||||
|
||||
Extending Prowler
|
||||
Thanks for your interest in contributing to Prowler!
|
||||
|
||||
Prowler can be extended in various ways, with common use cases including:
|
||||
Prowler can be extended in various ways. This guide provides the different ways to contribute and how to get started.
|
||||
|
||||
- New security checks
|
||||
- New compliance frameworks
|
||||
- New output formats
|
||||
- New integrations
|
||||
- New proposed features
|
||||
## Contributing to Prowler
|
||||
|
||||
All the relevant information for these cases is included in this guide.
|
||||
### Review Current Issues
|
||||
Check out our [GitHub Issues](https://github.com/prowler-cloud/prowler/issues) page for ideas to contribute.
|
||||
<Columns cols={2}>
|
||||
<Card title="Good First Issue" icon="github" href="https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20label%3A%22good%20first%20issue%22">
|
||||
We tag issues as `good first issue` for new contributors. These are typically well-defined and manageable in scope.
|
||||
</Card>
|
||||
<Card title="Help Wanted" icon="github" href="https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20label%3A%22help%20wanted%22">
|
||||
We tag issues as `help wanted` for other issues that require more time to complete.
|
||||
</Card>
|
||||
</Columns>
|
||||
|
||||
## Getting the Code and Installing All Dependencies
|
||||
### Expand Prowler's Capabilities
|
||||
Prowler is constantly evolving. Contributions to checks, services, or integrations help improve the tool for everyone. Here is how to get involved:
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="Adding New Checks" icon="shield" href="/developer-guide/checks">
|
||||
Want to improve Prowler's detection capabilities for your favorite cloud provider? You can contribute by writing new checks.
|
||||
</Card>
|
||||
<Card title="Adding New Services" icon="server" href="/developer-guide/services">
|
||||
One key service for your favorite cloud provider is missing? Add it to Prowler! Do not forget to include relevant checks to validate functionality.
|
||||
</Card>
|
||||
<Card title="Adding New Providers" icon="cloud" href="/developer-guide/provider">
|
||||
If you would like to extend Prowler to work with a new cloud provider, this typically involves setting up new services and checks to ensure compatibility.
|
||||
</Card>
|
||||
<Card title="Adding New Output Formats" icon="file" href="/developer-guide/outputs">
|
||||
Want to tailor how results are displayed or exported? You can add custom output formats.
|
||||
</Card>
|
||||
<Card title="Adding New Integrations" icon="link" href="/developer-guide/integrations">
|
||||
Prowler can work with other tools and platforms through integrations.
|
||||
</Card>
|
||||
<Card title="Proposing or Implementing Features" icon="lightbulb" href="https://github.com/prowler-cloud/prowler/issues/new?template=feature-request.yml">
|
||||
Propose brand-new features or enhancements to existing ones, or help implement community-requested improvements.
|
||||
</Card>
|
||||
</Columns>
|
||||
|
||||
### Improve Documentation
|
||||
Help make Prowler more accessible by enhancing our documentation, fixing typos, or adding examples/tutorials.
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="Documentation Guide" icon="book" href="/developer-guide/documentation">
|
||||
Enhance our documentation, fix typos, or add examples/tutorials.
|
||||
</Card>
|
||||
</Columns>
|
||||
|
||||
### Bug Fixes
|
||||
If you find any issues or bugs, you can report them in the [GitHub Issues](https://github.com/prowler-cloud/prowler/issues) page and if you want you can also fix them.
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="Report a Bug" icon="bug" href="https://github.com/prowler-cloud/prowler/issues/new?template=bug_report.yml">
|
||||
Report or fix issues or bugs.
|
||||
</Card>
|
||||
</Columns>
|
||||
|
||||
Remember, our community is here to help! If you need guidance, do not hesitate to ask questions in the issues or join our [<Icon icon="slack" /> Slack workspace](https://goto.prowler.com/slack).
|
||||
|
||||
|
||||
|
||||
## Setting up your development environment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -26,11 +77,11 @@ Before proceeding, ensure the following:
|
||||
|
||||
### Forking the Prowler Repository
|
||||
|
||||
To contribute to Prowler, fork the Prowler GitHub repository. This allows you to propose changes, submit new features, and fix bugs. For guidance on forking, refer to the [official GitHub documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo?tool=webui#forking-a-repository).
|
||||
Fork the Prowler GitHub repository to contribute to Prowler. This allows proposing changes, submitting new features, and fixing bugs. For guidance on forking, refer to the [official GitHub documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo?tool=webui#forking-a-repository).
|
||||
|
||||
### Cloning Your Forked Repository
|
||||
|
||||
Once your fork is created, clone it using the following commands:
|
||||
Once your fork is created, clone it using the following commands (replace `<your-github-user>` with your GitHub username):
|
||||
|
||||
```
|
||||
git clone https://github.com/<your-github-user>/prowler
|
||||
@@ -56,39 +107,7 @@ If your poetry version is below 2.0.0 you must keep using `poetry shell` to acti
|
||||
In case you have any doubts, consult the [Poetry environment activation guide](https://python-poetry.org/docs/managing-environments/#activating-the-environment).
|
||||
|
||||
</Warning>
|
||||
## Contributing to Prowler
|
||||
|
||||
### Ways to Contribute
|
||||
|
||||
Here are some ideas for collaborating with Prowler:
|
||||
|
||||
1. **Review Current Issues**: Check out our [GitHub Issues](https://github.com/prowler-cloud/prowler/issues) page. We often tag issues as `good first issue` - these are perfect for new contributors as they are typically well-defined and manageable in scope.
|
||||
|
||||
2. **Expand Prowler's Capabilities**: Prowler is constantly evolving, and you can be a part of its growth. Whether you are adding checks, supporting new services, or introducing integrations, your contributions help improve the tool for everyone. Here is how you can get involved:
|
||||
|
||||
- **Adding New Checks**
|
||||
Want to improve Prowler's detection capabilities for your favorite cloud provider? You can contribute by writing new checks. To get started, follow the [create a new check guide](/developer-guide/checks).
|
||||
|
||||
- **Adding New Services**
|
||||
One key service for your favorite cloud provider is missing? Add it to Prowler! To add a new service, check out the [create a new service guide](/developer-guide/services). Do not forget to include relevant checks to validate functionality.
|
||||
|
||||
- **Adding New Providers**
|
||||
If you would like to extend Prowler to work with a new cloud provider, follow the [create a new provider guide](/developer-guide/provider). This typically involves setting up new services and checks to ensure compatibility.
|
||||
|
||||
- **Adding New Output Formats**
|
||||
Want to tailor how results are displayed or exported? You can add custom output formats by following the [create a new output format guide](/developer-guide/outputs).
|
||||
|
||||
- **Adding New Integrations**
|
||||
Prowler can work with other tools and platforms through integrations. If you would like to add one, see the [create a new integration guide](/developer-guide/integrations).
|
||||
|
||||
- **Proposing or Implementing Features**
|
||||
Got an idea to make Prowler better? Whether it is a brand-new feature or an enhancement to an existing one, you are welcome to propose it or help implement community-requested improvements.
|
||||
|
||||
3. **Improve Documentation**: Help make Prowler more accessible by enhancing our documentation, fixing typos, or adding examples/tutorials. See the tutorial of how we write our documentation [here](/developer-guide/documentation).
|
||||
|
||||
4. **Bug Fixes**: If you find any issues or bugs, you can report them in the [GitHub Issues](https://github.com/prowler-cloud/prowler/issues) page and if you want you can also fix them.
|
||||
|
||||
Remember, our community is here to help! If you need guidance, do not hesitate to ask questions in the issues or join our [Slack workspace](https://goto.prowler.com/slack).
|
||||
|
||||
### Pre-Commit Hooks
|
||||
|
||||
@@ -121,6 +140,16 @@ These should have been already installed if `poetry install --with dev` was alre
|
||||
|
||||
Additionally, ensure the latest version of [`TruffleHog`](https://github.com/trufflesecurity/trufflehog) is installed to scan for sensitive data in the code. Follow the official [installation guide](https://github.com/trufflesecurity/trufflehog?tab=readme-ov-file#floppy_disk-installation) for setup.
|
||||
|
||||
### AI-Driven Contributions
|
||||
|
||||
If you are using AI assistants to help with your contributions, Prowler provides specialized resources to enhance AI-driven development:
|
||||
|
||||
- **Prowler MCP Server**: The [Prowler MCP Server](/getting-started/products/prowler-mcp) provides AI assistants with access to the entire Prowler ecosystem, including security checks, compliance frameworks, documentation, and more. This enables AI tools to better understand Prowler's architecture and help you create contributions that align with project standards.
|
||||
|
||||
- **AGENTS.md Files**: Each component of the Prowler monorepo includes an `AGENTS.md` file that contains specific guidelines for AI agents working on that component. These files provide context about project structure, coding standards, and best practices. When working on a specific component, refer to the relevant `AGENTS.md` file (e.g., `prowler/AGENTS.md`, `ui/AGENTS.md`, `api/AGENTS.md`) to ensure your AI assistant follows the appropriate guidelines.
|
||||
|
||||
These resources help ensure that AI-assisted contributions maintain consistency with Prowler's codebase and development practices.
|
||||
|
||||
### Dependency Management
|
||||
|
||||
All dependencies are listed in the `pyproject.toml` file.
|
||||
@@ -133,7 +162,7 @@ If you encounter issues when committing to the Prowler repository, use the `--no
|
||||
</Note>
|
||||
### Repository Folder Structure
|
||||
|
||||
Understanding the layout of the Prowler codebase will help you quickly find where to add new features, checks, or integrations. The following is a high-level overview from the root of the repository:
|
||||
The Prowler codebase layout helps quickly locate where to add new features, checks, or integrations. The following is a high-level overview from the root of the repository:
|
||||
|
||||
```
|
||||
prowler/
|
||||
@@ -148,7 +177,7 @@ prowler/
|
||||
├── permissions/ # Permission-related files and policies
|
||||
├── contrib/ # Community-contributed scripts or modules
|
||||
├── kubernetes/ # Kubernetes deployment files
|
||||
├── .github/ # GitHub related files (workflows, issue templates, etc.)
|
||||
├── .github/ # GitHub-related files (workflows, issue templates, etc.)
|
||||
├── pyproject.toml # Python project configuration (Poetry)
|
||||
├── poetry.lock # Poetry lock file
|
||||
├── README.md # Project overview and getting started
|
||||
@@ -158,19 +187,23 @@ prowler/
|
||||
└── ... # Other supporting files
|
||||
```
|
||||
|
||||
## Pull Request Checklist
|
||||
## Sending the Pull Request
|
||||
|
||||
When creating or reviewing a pull request in https://github.com/prowler-cloud/prowler, follow [this checklist](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md#checklist).
|
||||
When creating or reviewing a pull request in <Icon icon="github" /> [Prowler](https://github.com/prowler-cloud/prowler), follow [this template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md) and fill it with the relevant information:
|
||||
|
||||
- **Context** and **Description** of the change: This will help the reviewers to understand the change and the purpose of the pull request.
|
||||
- **Steps to review**: A detailed description of how to review the change.
|
||||
- **Checklist**: A mandatory checklist of the things that should be reviewed before merging the pull request.
|
||||
|
||||
## Contribution Appreciation
|
||||
|
||||
If you enjoy swag, we’d love to thank you for your contribution with laptop stickers or other Prowler merchandise!
|
||||
If you enjoy swag, we'd love to thank you for your contribution with laptop stickers or other Prowler merchandise!
|
||||
|
||||
To request swag: Share your pull request details in our [Slack workspace](https://goto.prowler.com/slack).
|
||||
|
||||
You can also reach out to Toni de la Fuente on [Twitter](https://twitter.com/ToniBlyx)—his DMs are open!
|
||||
|
||||
# Testing a Pull Request from a Specific Branch
|
||||
## Testing a Pull Request from a Specific Branch
|
||||
|
||||
To test Prowler from a specific branch (for example, to try out changes from a pull request before it is merged), you can use `pipx` to install directly from GitHub:
|
||||
|
||||
@@ -179,3 +212,5 @@ pipx install "git+https://github.com/prowler-cloud/prowler.git@branch-name"
|
||||
```
|
||||
|
||||
Replace `branch-name` with the name of the branch you want to test. This will install Prowler in an isolated environment, allowing you to try out the changes safely.
|
||||
|
||||
For more details on testing go to the [Testing section](/developer-guide/unit-testing) of this documentation.
|
||||
@@ -4,7 +4,7 @@ title: 'Kubernetes Provider'
|
||||
|
||||
This page details the [Kubernetes](https://kubernetes.io/) provider implementation in Prowler.
|
||||
|
||||
By default, Prowler will audit all namespaces in the Kubernetes cluster accessible by the configured context. To configure it, see the [In-Cluster Execution](/user-guide/providers/kubernetes/in-cluster) or [Non In-Cluster Execution](/user-guide/providers/kubernetes/outside-cluster) guides.
|
||||
By default, Prowler will audit all namespaces in the Kubernetes cluster accessible by the configured context. To configure it, see the [In-Cluster Execution](/user-guide/providers/kubernetes/getting-started-k8s#in-cluster-execution) or [Non In-Cluster Execution](/user-guide/providers/kubernetes/getting-started-k8s#non-in-cluster-execution) guides.
|
||||
|
||||
## Kubernetes Provider Classes Architecture
|
||||
|
||||
|
||||
+2
-2
@@ -110,6 +110,7 @@
|
||||
]
|
||||
},
|
||||
"user-guide/tutorials/prowler-app-lighthouse",
|
||||
"user-guide/tutorials/prowler-cloud-public-ips",
|
||||
{
|
||||
"group": "Tutorials",
|
||||
"pages": [
|
||||
@@ -194,8 +195,7 @@
|
||||
{
|
||||
"group": "Kubernetes",
|
||||
"pages": [
|
||||
"user-guide/providers/kubernetes/in-cluster",
|
||||
"user-guide/providers/kubernetes/outside-cluster",
|
||||
"user-guide/providers/kubernetes/getting-started-k8s",
|
||||
"user-guide/providers/kubernetes/misc"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ title: "Overview"
|
||||
**Prowler MCP Server** brings the entire Prowler ecosystem to AI assistants through the Model Context Protocol (MCP). It enables seamless integration with AI tools like Claude Desktop, Cursor, and other MCP clients, allowing interaction with Prowler's security capabilities through natural language.
|
||||
|
||||
<Warning>
|
||||
**Preview Feature**: This MCP server is currently in preview and under active development. Features and functionality may change. We welcome your feedback—please report any issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join our [Slack community](https://goto.prowler.com/slack) to discuss and share your thoughts.
|
||||
**Preview Feature**: This MCP server is currently under active development. Features and functionality may change. We welcome your feedback—please report any issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join our [Slack community](https://goto.prowler.com/slack) to discuss and share your thoughts.
|
||||
</Warning>
|
||||
|
||||
## What is the Model Context Protocol?
|
||||
@@ -42,6 +42,15 @@ Search and retrieve official Prowler documentation:
|
||||
- **Contextual Results**: Get relevant documentation pages with highlighted snippets.
|
||||
- **Document Retrieval**: Access complete markdown content of any documentation file.
|
||||
|
||||
## MCP Server Architecture
|
||||
|
||||
The following diagram illustrates the Prowler MCP Server architecture and its integration points:
|
||||
|
||||
<img className="block dark:hidden" src="/images/prowler_mcp_schema_light.png" alt="Prowler MCP Server Schema" />
|
||||
<img className="hidden dark:block" src="/images/prowler_mcp_schema_dark.png" alt="Prowler MCP Server Schema" />
|
||||
|
||||
The architecture shows how AI assistants connect through the MCP protocol to access Prowler's three main components: Prowler Cloud/App for security operations, Prowler Hub for security knowledge, and Prowler Documentation for guidance and reference.
|
||||
|
||||
## Use Cases
|
||||
|
||||
The Prowler MCP Server enables powerful workflows through AI assistants:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 328 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 332 KiB |
@@ -28,7 +28,7 @@ The supported providers right now are:
|
||||
| [AWS](/user-guide/providers/aws/getting-started-aws) | Official | UI, API, CLI |
|
||||
| [Azure](/user-guide/providers/azure/getting-started-azure) | Official | UI, API, CLI |
|
||||
| [Google Cloud](/user-guide/providers/gcp/getting-started-gcp) | Official | UI, API, CLI |
|
||||
| [Kubernetes](/user-guide/providers/kubernetes/in-cluster) | Official | UI, API, CLI |
|
||||
| [Kubernetes](/user-guide/providers/kubernetes/getting-started-k8s) | Official | UI, API, CLI |
|
||||
| [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | UI, API, CLI |
|
||||
| [Github](/user-guide/providers/github/getting-started-github) | Official | UI, API, CLI |
|
||||
| [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | UI, API, CLI |
|
||||
|
||||
@@ -19,9 +19,39 @@ The Mutelist option works in combination with other filtering mechanisms and mod
|
||||
|
||||
## How the Mutelist Works
|
||||
|
||||
The **Mutelist** uses both "AND" and "OR" logic to determine which resources, checks, regions, and tags should be muted. For each check, the Mutelist evaluates whether the account, region, and resource match the specified criteria using "AND" logic. If tags are specified, the Mutelist can apply either "AND" or "OR" logic.
|
||||
The **Mutelist** uses **AND logic** to evaluate whether a finding should be muted. For a finding to be muted, **ALL** of the following conditions must match:
|
||||
|
||||
If any of the criteria do not match, the check is not muted.
|
||||
- **Account** matches (exact match or `*`)
|
||||
- **Check** matches (exact match, regex pattern, or `*`)
|
||||
- **Region** matches (exact match, regex pattern, or `*`)
|
||||
- **Resource** matches (exact match, regex pattern, or `*`)
|
||||
- **Tags** match (if specified)
|
||||
|
||||
If **any** of these criteria do not match, the finding is **not muted**.
|
||||
|
||||
### Tag Matching Logic
|
||||
|
||||
Tags have special matching behavior:
|
||||
|
||||
- **Multiple tags in the list = AND logic**: ALL tags must be present on the resource
|
||||
```yaml
|
||||
Tags:
|
||||
- "environment=dev"
|
||||
- "team=backend" # BOTH tags required
|
||||
```
|
||||
|
||||
- **Regex alternation within a single tag = OR logic**: Use the pipe operator `|` for OR
|
||||
```yaml
|
||||
Tags:
|
||||
- "environment=dev|environment=stg" # Matches EITHER dev OR stg
|
||||
```
|
||||
|
||||
- **Complex tag patterns**: Combine AND and OR using regex
|
||||
```yaml
|
||||
Tags:
|
||||
- "team=backend" # Required
|
||||
- "environment=dev|environment=stg" # AND (dev OR stg)
|
||||
```
|
||||
|
||||
<Note>
|
||||
Remember that mutelist can be used with regular expressions.
|
||||
@@ -40,9 +70,10 @@ The Mutelist file uses the [YAML](https://en.wikipedia.org/wiki/YAML) format wit
|
||||
```yaml
|
||||
### Account, Check and/or Region can be * to apply for all the cases.
|
||||
### Resources and tags are lists that can have either Regex or Keywords.
|
||||
### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together.
|
||||
### Use an alternation Regex to match one of multiple tags with "ORed" logic.
|
||||
### For each check you can except Accounts, Regions, Resources and/or Tags.
|
||||
### Multiple tags in the list are "ANDed" together (ALL must match).
|
||||
### Use regex alternation (|) within a single tag for "OR" logic (e.g., "env=dev|env=stg").
|
||||
### For each check you can use Exceptions to unmute specific Accounts, Regions, Resources and/or Tags.
|
||||
### All conditions (Account, Check, Region, Resource, Tags) are ANDed together.
|
||||
########################### MUTELIST EXAMPLE ###########################
|
||||
Mutelist:
|
||||
Accounts:
|
||||
@@ -148,11 +179,11 @@ Mutelist:
|
||||
|
||||
| Field| Description| Logic
|
||||
|----------|----------|----------
|
||||
| `account_id`| Use `*` to apply the mutelist to all accounts.| `ANDed`
|
||||
| `check_name`| The name of the Prowler check. Use `*` to apply the mutelist to all checks, or `service_*` to apply it to all service's checks.| `ANDed`
|
||||
| `region`| The region identifier. Use `*` to apply the mutelist to all regions.| `ANDed`
|
||||
| `resource`| The resource identifier. Use `*` to apply the mutelist to all resources.| `ANDed`
|
||||
| `tag`| The tag value.| `ORed`
|
||||
| `account_id`| Use `*` to apply the mutelist to all accounts. Supports exact match or wildcard.| `AND` (with other fields)
|
||||
| `check_name`| The name of the Prowler check. Use `*` to apply the mutelist to all checks, or `service_*` to apply it to all service's checks. Supports regex patterns.| `AND` (with other fields)
|
||||
| `region`| The region identifier. Use `*` to apply the mutelist to all regions. Supports regex patterns.| `AND` (with other fields)
|
||||
| `resource`| The resource identifier. Use `*` to apply the mutelist to all resources. Supports regex patterns.| `AND` (with other fields)
|
||||
| `tags`| List of tag patterns in `key=value` format. **Multiple tags = AND** (all must match). **Regex alternation within single tag = OR** (use `tag1\|tag2`).| `AND` between tags, `OR` within regex
|
||||
|
||||
### Description
|
||||
|
||||
@@ -173,6 +204,68 @@ Replace `<provider>` with the appropriate provider name.
|
||||
- The Mutelist can be used in combination with other Prowler options, such as the `--service` or `--checks` option, to further customize the scanning process.
|
||||
- Make sure to review and update the Mutelist regularly to ensure it reflects the desired exclusions and remains up to date with your infrastructure.
|
||||
|
||||
## Current Limitations and Workarounds
|
||||
|
||||
### Limitation: No OR Logic Between Different Rule Sets
|
||||
|
||||
The current Mutelist schema **does not support OR logic** between different condition sets. Each check can have only **one rule object**, and all conditions are **ANDed** together.
|
||||
|
||||
**Example of unsupported scenario:**
|
||||
```yaml
|
||||
# ❌ INVALID: Cannot have multiple rule blocks for the same check
|
||||
Accounts:
|
||||
"*":
|
||||
Checks:
|
||||
"*": # Rule 1
|
||||
Regions: ["eu-west-1", "us-west-2"]
|
||||
Resources: ["*"]
|
||||
"*": # Rule 2 - This will OVERWRITE Rule 1 (YAML duplicate key)
|
||||
Regions: ["us-east-1"]
|
||||
Tags: ["environment=dev"]
|
||||
```
|
||||
|
||||
**Workaround: Use multiple scans with different mutelists**
|
||||
|
||||
For complex scenarios requiring OR logic, run separate scans:
|
||||
|
||||
```bash
|
||||
# Scan 1: Mute findings in non-critical regions
|
||||
prowler aws --mutelist-file mutelist_noncritical.yaml
|
||||
|
||||
# Scan 2: Mute dev/stg in critical regions
|
||||
prowler aws --mutelist-file mutelist_critical.yaml --regions us-east-1,sa-east-1
|
||||
```
|
||||
|
||||
Then merge the outputs in your reporting pipeline.
|
||||
|
||||
### Limitation: Cannot Negate Regions
|
||||
|
||||
You cannot express "all regions **except** X and Y". You must explicitly list all regions you want to mute.
|
||||
|
||||
**Workaround:**
|
||||
```yaml
|
||||
# Must enumerate all unwanted regions
|
||||
Accounts:
|
||||
"*":
|
||||
Checks:
|
||||
"*":
|
||||
Regions:
|
||||
- "af-south-1"
|
||||
- "ap-east-1"
|
||||
# ... list all regions EXCEPT the ones you want to monitor
|
||||
Resources: ["*"]
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Use regex patterns for flexibility**: Instead of listing multiple resources, use regex patterns like `"dev-.*"` or `"test-instance-[0-9]+"`
|
||||
|
||||
2. **Combine tag OR logic with regex**: Use `"environment=dev|environment=stg|environment=test"` instead of multiple tag entries
|
||||
|
||||
3. **Be specific with exceptions**: Use the `Exceptions` field to unmute specific resources within a broader muting rule
|
||||
|
||||
4. **Test your mutelist**: Run Prowler with `--output-modes json` and verify that the expected findings are muted
|
||||
|
||||
## AWS Mutelist
|
||||
|
||||
### Muting specific AWS regions
|
||||
|
||||
@@ -53,7 +53,8 @@ For detailed instructions on how to create the Service Principal and configure p
|
||||
|
||||
### Step 3: Add Credentials to Prowler App
|
||||
|
||||
Having completed the [Service Principal setup from the Authentication guide](/user-guide/providers/azure/authentication#service-principal-application-authentication-recommended):
|
||||
For Azure, Prowler App uses a service principal application to authenticate. For more information about the process of creating and adding permissions to a service principal refer to this [section](/user-guide/providers/azure/authentication). When you finish creating and adding the [Entra](/user-guide/providers/azure/create-prowler-service-principal#assigning-proper-permissions) and [Subscription](/user-guide/providers/azure/subscriptions) scope permissions to the service principal, enter the `Tenant ID`, `Client ID` and `Client Secret` of the service principal application.
|
||||
|
||||
|
||||
1. Go to your App Registration overview and copy the `Client ID` and `Tenant ID`
|
||||
|
||||
|
||||
@@ -32,35 +32,45 @@ title: 'Getting Started With GCP on Prowler'
|
||||
|
||||
### Step 3: Set Up GCP Authentication
|
||||
|
||||
Choose the preferred authentication mode before proceeding:
|
||||
For Google Cloud, first enter your `GCP Project ID` and then select the authentication method you want to use:
|
||||
|
||||
**User Credentials (Application Default Credentials)**
|
||||
- **Service Account Authentication** (**Recommended**)
|
||||
* Authenticates as a service identity
|
||||
* Stable and auditable
|
||||
* Recommended for production
|
||||
- **Application Default Credentials**
|
||||
* Quick scan as current user
|
||||
* Uses Google Cloud CLI authentication
|
||||
* Credentials may time out
|
||||
|
||||
* Quick scan as current user
|
||||
* Uses Google Cloud CLI authentication
|
||||
* Credentials may time out
|
||||
**Service Account Authentication** is the recommended authentication method for automated systems and machine-to-machine interactions, like Prowler. For detailed information about this, refer to the [Google Cloud documentation](https://cloud.google.com/iam/docs/service-account-overview).
|
||||
|
||||
**Service Account Key File**
|
||||
<img src="/images/prowler-app/gcp-auth-methods.png" alt="GCP Authentication Methods" width="700" />
|
||||
|
||||
* Authenticates as a service identity
|
||||
* Stable and auditable
|
||||
* Recommended for production
|
||||
<Tabs>
|
||||
<Tab title="Service Account Authentication">
|
||||
First of all, in the same project that you selected in the previous step, you need to create a service account and then generate a key in JSON format for it. For more information about this, you can follow the next Google Cloud documentation tutorials:
|
||||
|
||||
For detailed instructions on how to set up authentication, see [Authentication](/user-guide/providers/gcp/authentication).
|
||||
- [Create a service account](https://cloud.google.com/iam/docs/creating-managing-service-accounts)
|
||||
- [Generate a key for a service account](https://cloud.google.com/iam/docs/creating-managing-service-account-keys)
|
||||
|
||||
6. Once credentials are configured, return to Prowler App and enter the required values:
|
||||
<img src="/images/prowler-app/gcp-service-account-creds.png" alt="GCP Service Account Credentials" width="700" />
|
||||
</Tab>
|
||||
<Tab title="Application Default Credentials">
|
||||
1. Run the following command in your terminal to authenticate with GCP:
|
||||
|
||||
For "Service Account Key":
|
||||
```bash
|
||||
gcloud auth application-default login
|
||||
```
|
||||
|
||||
- `Service Account Key JSON`
|
||||
2. Once authenticated, get the `Client ID`, `Client Secret` and `Refresh Token` from `~/.config/gcloud/application_default_credentials`.
|
||||
|
||||
For "Application Default Credentials":
|
||||
3. Paste the `Client ID`, `Client Secret` and `Refresh Token` into Prowler App.
|
||||
|
||||
- `client_id`
|
||||
- `client_secret`
|
||||
- `refresh_token`
|
||||
<img src="/images/gcp-credentials.png" alt="GCP Credentials" width="700" />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||

|
||||
|
||||
7. Click "Next", then "Launch Scan"
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: 'Getting Started with Kubernetes'
|
||||
---
|
||||
|
||||
## Prowler App
|
||||
|
||||
### Step 1: Access Prowler Cloud/App
|
||||
|
||||
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
|
||||
2. Go to "Configuration" > "Cloud Providers"
|
||||
|
||||

|
||||
|
||||
3. Click "Add Cloud Provider"
|
||||
|
||||

|
||||
|
||||
4. Select "Kubernetes"
|
||||
|
||||
5. Enter your Kubernetes Cluster context from your kubeconfig file and optionally provide a friendly alias
|
||||
|
||||
### Step 2: Configure Kubernetes Authentication
|
||||
|
||||
For Kubernetes, Prowler App uses a `kubeconfig` file to authenticate. Paste the contents of your `kubeconfig` file into the `Kubeconfig content` field.
|
||||
|
||||
By default, the `kubeconfig` file is located at `~/.kube/config`.
|
||||
|
||||

|
||||
|
||||
### Step 3: Additional Setup for EKS, GKE, AKS, or External Clusters
|
||||
|
||||
If you are adding an **EKS**, **GKE**, **AKS** or external cluster, follow these additional steps to ensure proper authentication:
|
||||
|
||||
**Make sure your cluster allows traffic from the Prowler Cloud IP address `52.48.254.174/32`**
|
||||
|
||||
1. Apply the necessary Kubernetes resources to your EKS, GKE, AKS or external cluster (you can find the files in the [`kubernetes` directory of the Prowler repository](https://github.com/prowler-cloud/prowler/tree/master/kubernetes)):
|
||||
|
||||
```console
|
||||
kubectl apply -f kubernetes/prowler-sa.yaml
|
||||
kubectl apply -f kubernetes/prowler-role.yaml
|
||||
kubectl apply -f kubernetes/prowler-rolebinding.yaml
|
||||
```
|
||||
|
||||
2. Generate a long-lived token for authentication:
|
||||
|
||||
```console
|
||||
kubectl create token prowler-sa -n prowler-ns --duration=0
|
||||
```
|
||||
|
||||
- **Security Note:** The `--duration=0` option generates a non-expiring token, which may pose a security risk if not managed properly. Users should decide on an appropriate expiration time based on their security policies. If a limited-time token is preferred, set `--duration=<TIME>` (e.g., `--duration=24h`).
|
||||
- **Important:** If the token expires, Prowler Cloud will no longer be able to authenticate with the cluster. In this case, you will need to generate a new token and **remove and re-add the provider in Prowler Cloud** with the updated `kubeconfig`.
|
||||
|
||||
3. Update your `kubeconfig` to use the ServiceAccount token:
|
||||
|
||||
```console
|
||||
kubectl config set-credentials prowler-sa --token=<SA_TOKEN>
|
||||
kubectl config set-context <CONTEXT_NAME> --user=prowler-sa
|
||||
```
|
||||
|
||||
Replace `<SA_TOKEN>` with the generated token and `<CONTEXT_NAME>` with your KubeConfig Context Name of your EKS, GKE or AKS cluster.
|
||||
|
||||
4. Add the modified `kubeconfig` in Prowler Cloud and test the connection.
|
||||
|
||||
## Prowler CLI
|
||||
|
||||
### Non In-Cluster Execution
|
||||
|
||||
For execution outside the cluster environment, specify the location of the [kubeconfig](https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/) file using the following argument:
|
||||
|
||||
```console
|
||||
prowler kubernetes --kubeconfig-file /path/to/kubeconfig
|
||||
```
|
||||
|
||||
<Note>
|
||||
If no `--kubeconfig-file` is provided, Prowler will use the default KubeConfig file location (`~/.kube/config`).
|
||||
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
`prowler` will scan the active Kubernetes context by default. Use the [`--context`](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/kubernetes/context/) flag to specify the context to be scanned.
|
||||
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
By default, `prowler` will scan all namespaces in your active Kubernetes context. Use the [`--namespace`](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/kubernetes/namespace/) flag to specify the namespace(s) to be scanned.
|
||||
|
||||
</Note>
|
||||
|
||||
### In-Cluster Execution
|
||||
|
||||
For in-cluster execution, use the supplied yaml files inside `/kubernetes`:
|
||||
|
||||
* [prowler-sa.yaml](https://github.com/prowler-cloud/prowler/blob/master/kubernetes/prowler-sa.yaml)
|
||||
* [job.yaml](https://github.com/prowler-cloud/prowler/blob/master/kubernetes/job.yaml)
|
||||
* [prowler-role.yaml](https://github.com/prowler-cloud/prowler/blob/master/kubernetes/prowler-role.yaml)
|
||||
* [prowler-rolebinding.yaml](https://github.com/prowler-cloud/prowler/blob/master/kubernetes/prowler-rolebinding.yaml)
|
||||
|
||||
They can be used to run Prowler as a job within a new Prowler namespace:
|
||||
|
||||
```console
|
||||
kubectl apply -f kubernetes/prowler-sa.yaml
|
||||
kubectl apply -f kubernetes/job.yaml
|
||||
kubectl apply -f kubernetes/prowler-role.yaml
|
||||
kubectl apply -f kubernetes/prowler-rolebinding.yaml
|
||||
kubectl get pods --namespace prowler-ns --> prowler-XXXXX
|
||||
kubectl logs prowler-XXXXX --namespace prowler-ns
|
||||
```
|
||||
|
||||
<Note>
|
||||
By default, `prowler` will scan all namespaces in your active Kubernetes context. Use the [`--namespace`](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/kubernetes/namespace/) flag to specify the namespace(s) to be scanned.
|
||||
|
||||
</Note>
|
||||
<Tip>
|
||||
**Identifying the cluster in reports**
|
||||
|
||||
When running in in-cluster mode, the Kubernetes API does not expose the actual cluster name by default.
|
||||
|
||||
To uniquely identify the cluster in logs and reports:
|
||||
|
||||
- Use the `--cluster-name` flag to manually set the cluster name:
|
||||
```bash
|
||||
prowler -p kubernetes --cluster-name production-cluster
|
||||
```
|
||||
- Or set the `CLUSTER_NAME` environment variable:
|
||||
```yaml
|
||||
env:
|
||||
- name: CLUSTER_NAME
|
||||
value: production-cluster
|
||||
```
|
||||
|
||||
</Tip>
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
title: 'In-Cluster Execution'
|
||||
---
|
||||
|
||||
For in-cluster execution, use the supplied yaml files inside `/kubernetes`:
|
||||
|
||||
* [prowler-sa.yaml](https://github.com/prowler-cloud/prowler/blob/master/kubernetes/prowler-sa.yaml)
|
||||
* [job.yaml](https://github.com/prowler-cloud/prowler/blob/master/kubernetes/job.yaml)
|
||||
* [prowler-role.yaml](https://github.com/prowler-cloud/prowler/blob/master/kubernetes/prowler-role.yaml)
|
||||
* [prowler-rolebinding.yaml](https://github.com/prowler-cloud/prowler/blob/master/kubernetes/prowler-rolebinding.yaml)
|
||||
|
||||
They can be used to run Prowler as a job within a new Prowler namespace:
|
||||
|
||||
```console
|
||||
kubectl apply -f kubernetes/prowler-sa.yaml
|
||||
kubectl apply -f kubernetes/job.yaml
|
||||
kubectl apply -f kubernetes/prowler-role.yaml
|
||||
kubectl apply -f kubernetes/prowler-rolebinding.yaml
|
||||
kubectl get pods --namespace prowler-ns --> prowler-XXXXX
|
||||
kubectl logs prowler-XXXXX --namespace prowler-ns
|
||||
```
|
||||
|
||||
<Note>
|
||||
By default, `prowler` will scan all namespaces in your active Kubernetes context. Use the [`--namespace`](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/kubernetes/namespace/) flag to specify the namespace(s) to be scanned.
|
||||
|
||||
</Note>
|
||||
<Tip>
|
||||
**Identifying the cluster in reports**
|
||||
|
||||
When running in in-cluster mode, the Kubernetes API does not expose the actual cluster name by default.
|
||||
|
||||
To uniquely identify the cluster in logs and reports, you can:
|
||||
|
||||
- Use the `--cluster-name` flag to manually set the cluster name:
|
||||
```bash
|
||||
prowler -p kubernetes --cluster-name production-cluster
|
||||
```
|
||||
- Or set the `CLUSTER_NAME` environment variable:
|
||||
```yaml
|
||||
env:
|
||||
- name: CLUSTER_NAME
|
||||
value: production-cluster
|
||||
```
|
||||
|
||||
</Tip>
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
title: 'Non In-Cluster Execution'
|
||||
---
|
||||
|
||||
For execution outside the cluster environment, specify the location of the [kubeconfig](https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/) file using the following argument:
|
||||
|
||||
```console
|
||||
prowler kubernetes --kubeconfig-file /path/to/kubeconfig
|
||||
```
|
||||
|
||||
<Note>
|
||||
If no `--kubeconfig-file` is provided, Prowler will use the default KubeConfig file location (`~/.kube/config`).
|
||||
|
||||
</Note>
|
||||
<Note>
|
||||
`prowler` will scan the active Kubernetes context by default. Use the [`--context`](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/kubernetes/context/) flag to specify the context to be scanned.
|
||||
|
||||
</Note>
|
||||
<Note>
|
||||
By default, `prowler` will scan all namespaces in your active Kubernetes context. Use the [`--namespace`](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/kubernetes/namespace/) flag to specify the namespace(s) to be scanned.
|
||||
|
||||
</Note>
|
||||
@@ -50,15 +50,15 @@ Configure authentication for Microsoft 365 by following the [Microsoft 365 Authe
|
||||
|
||||
### Step 3: Select Authentication Method and Provide Credentials
|
||||
|
||||
Prowler App now separates Microsoft 365 authentication into two app-only options. After adding the Domain ID, choose the method that matches your setup:
|
||||
Prowler App now separates Microsoft 365 authentication into two app-only options. After adding the Domain ID (primary tenant domain), choose the method that matches your setup:
|
||||
|
||||
<img src="/images/providers/m365-auth-selection-form.png" alt="M365 authentication method selection" width="700" />
|
||||
|
||||
#### Application Certificate Authentication (Recommended)
|
||||
|
||||
1. Copy the Application (client) ID and Tenant ID from the app registration overview page.
|
||||
2. Paste both values into the Prowler App form.
|
||||
3. Upload the PFX bundle or paste the Base64-encoded certificate (`M365_CERTIFICATE_CONTENT`), then click **Test Connection**.
|
||||
1. Enter your **tenant ID**: This is the unique identifier for your Microsoft Entra ID directory.
|
||||
2. Enter your **application (client) ID**: This is the unique identifier assigned to your app registration in Microsoft Entra ID.
|
||||
3. Upload your **certificate file content**: This is the Base64 encoded certificate content used to authenticate your application.
|
||||
|
||||
<img src="/images/providers/certificate-form.png" alt="M365 certificate authentication form" width="700" />
|
||||
|
||||
@@ -66,9 +66,9 @@ Use this method whenever possible to avoid managing client secrets and to unlock
|
||||
|
||||
#### Application Client Secret Authentication
|
||||
|
||||
1. From the app registration, copy the Application (client) ID and Tenant ID.
|
||||
2. Paste both values plus the client secret into the Prowler App form.
|
||||
3. Click **Test Connection** to validate the credentials.
|
||||
1. Enter your **tenant ID**: This is the unique identifier for your Microsoft Entra ID directory.
|
||||
2. Enter your **application (client) ID**: This is the unique identifier assigned to your app registration in Microsoft Entra ID.
|
||||
3. Enter your **client secret**: This is the secret key used to authenticate your application.
|
||||
|
||||
<img src="/images/providers/secret-form.png" alt="M365 client secret authentication form" width="700" />
|
||||
|
||||
|
||||
@@ -77,208 +77,35 @@ Steps to add a provider:
|
||||
|
||||
## **Step 4: Configure the Provider**
|
||||
|
||||
Select the cloud provider you want to scan.
|
||||
Select the cloud provider to scan and configure authentication credentials. Each provider has specific requirements and authentication methods.
|
||||
|
||||
<img src="/images/select-provider.png" alt="Select a Provider" width="700" />
|
||||
|
||||
Once chosen, enter the Provider UID for authentication:
|
||||
|
||||
- **AWS**: Enter your AWS Account ID.
|
||||
- **GCP**: Enter your GCP Project ID.
|
||||
- **Azure**: Enter your Azure Subscription ID.
|
||||
- **Kubernetes**: Enter your Kubernetes Cluster context of your kubeconfig file.
|
||||
- **M365**: Enter your M365 Domain ID.
|
||||
|
||||
Optionally, provide a **Provider Alias** for easier identification. Follow the instructions provided to add your credentials:
|
||||
|
||||
### **Step 4.1: AWS Credentials**
|
||||
|
||||
For AWS, enter your `AWS Account ID` and choose one of the following methods to connect:
|
||||
|
||||
#### **Step 4.1.1: IAM Access Keys**
|
||||
|
||||
1. Select `Connect via Credentials`.
|
||||
|
||||
<img src="/images/connect-aws-credentials.png" alt="AWS Credentials" width="350" />
|
||||
|
||||
2. Enter your `Access Key ID`, `Secret Access Key` and optionally a `Session Token`:
|
||||
|
||||
<img src="/images/aws-credentials.png" alt="AWS Credentials" width="350" />
|
||||
|
||||
#### **Step 4.1.2: IAM Role**
|
||||
|
||||
1. Select `Connect assuming IAM Role`.
|
||||
|
||||
<img src="/images/connect-aws-role.png" alt="AWS Role" width="350" />
|
||||
|
||||
2. Enter the `Role ARN` and any optional field like the AWS Access Keys to assume the role, the `External ID`, the `Role Session Name` or the `Session Duration`:
|
||||
|
||||
<img src="/images/aws-role.png" alt="AWS Role" width="700" />
|
||||
|
||||
<Note>
|
||||
Check if your AWS Security Token Service (STS) has the EU (Ireland) endpoint active. If not, we will not be able to connect to your AWS account.
|
||||
|
||||
If that is the case your STS configuration may look like this:
|
||||
|
||||
<img src="/images/sts-configuration.png" alt="AWS Role" width="800" />
|
||||
|
||||
To solve this issue, please activate the EU (Ireland) STS endpoint.
|
||||
|
||||
</Note>
|
||||
### **Step 4.2: Azure Credentials**:
|
||||
|
||||
For Azure, Prowler App uses a service principal application to authenticate. For more information about the process of creating and adding permissions to a service principal refer to this [section](/user-guide/providers/azure/authentication). When you finish creating and adding the [Entra](/user-guide/providers/azure/create-prowler-service-principal#assigning-proper-permissions) and [Subscription](/user-guide/providers/azure/subscriptions) scope permissions to the service principal, enter the `Tenant ID`, `Client ID` and `Client Secret` of the service principal application.
|
||||
|
||||
<img src="/images/azure-credentials.png" alt="Azure Credentials" width="700" />
|
||||
|
||||
---
|
||||
### **Step 4.3: GCP Credentials**
|
||||
|
||||
For Google Cloud, first enter your `GCP Project ID` and then select the authentication method you want to use:
|
||||
|
||||
- **Service Account Authentication** (**Recommended**)
|
||||
- **Application Default Credentials**
|
||||
|
||||
**Service Account Authentication** is the recommended authentication method for automated systems and machine-to-machine interactions, like Prowler. For detailed information about this, refer to the [Google Cloud documentation](https://cloud.google.com/iam/docs/service-account-overview).
|
||||
|
||||
<img src="/images/prowler-app/gcp-auth-methods.png" alt="GCP Authentication Methods" width="700" />
|
||||
|
||||
#### **Step 4.3.1: Service Account Authentication**
|
||||
|
||||
First of all, in the same project that you selected in the previous step, you need to create a service account and then generate a key in JSON format for it. For more information about this, you can follow the next Google Cloud documentation tutorials:
|
||||
|
||||
- [Create a service account](https://cloud.google.com/iam/docs/creating-managing-service-accounts)
|
||||
- [Generate a key for a service account](https://cloud.google.com/iam/docs/creating-managing-service-account-keys)
|
||||
|
||||
<img src="/images/prowler-app/gcp-service-account-creds.png" alt="GCP Service Account Credentials" width="700" />
|
||||
|
||||
#### **Step 4.3.2: Application Default Credentials**
|
||||
|
||||
1. Run the following command in your terminal to authenticate with GCP:
|
||||
|
||||
```bash
|
||||
gcloud auth application-default login
|
||||
```
|
||||
|
||||
2. Once authenticated, get the `Client ID`, `Client Secret` and `Refresh Token` from `~/.config/gcloud/application_default_credentials`.
|
||||
|
||||
3. Paste the `Client ID`, `Client Secret` and `Refresh Token` into Prowler App.
|
||||
|
||||
<img src="/images/gcp-credentials.png" alt="GCP Credentials" width="700" />
|
||||
|
||||
### **Step 4.4: Kubernetes Credentials**:
|
||||
|
||||
For Kubernetes, Prowler App uses a `kubeconfig` file to authenticate, paste the contents of your `kubeconfig` file into the `Kubeconfig content` field.
|
||||
|
||||
By default, the `kubeconfig` file is located at `~/.kube/config`.
|
||||
|
||||
<img src="/images/kubernetes-credentials.png" alt="Kubernetes Credentials" width="700" />
|
||||
|
||||
If you are adding an **EKS**, **GKE**, **AKS** or external cluster, follow these additional steps to ensure proper authentication:
|
||||
|
||||
**Make sure your cluster allow traffic from the Prowler Cloud IP address `52.48.254.174/32`**
|
||||
|
||||
1. Apply the necessary Kubernetes resources to your EKS, GKE, AKS or external cluster (you can find the files in the [`kubernetes` directory of the Prowler repository](https://github.com/prowler-cloud/prowler/tree/master/kubernetes)):
|
||||
|
||||
```console
|
||||
kubectl apply -f kubernetes/prowler-sa.yaml
|
||||
kubectl apply -f kubernetes/prowler-role.yaml
|
||||
kubectl apply -f kubernetes/prowler-rolebinding.yaml
|
||||
```
|
||||
|
||||
2. Generate a long-lived token for authentication:
|
||||
|
||||
```console
|
||||
kubectl create token prowler-sa -n prowler-ns --duration=0
|
||||
```
|
||||
|
||||
- **Security Note:** The `--duration=0` option generates a non-expiring token, which may pose a security risk if not managed properly. Users should decide on an appropriate expiration time based on their security policies. If a limited-time token is preferred, set `--duration=<TIME>` (e.g., `--duration=24h`).
|
||||
- **Important:** If the token expires, Prowler Cloud will no longer be able to authenticate with the cluster. In this case, you will need to generate a new token and **remove and re-add the provider in Prowler Cloud** with the updated `kubeconfig`.
|
||||
|
||||
3. Update your `kubeconfig` to use the ServiceAccount token:
|
||||
|
||||
```console
|
||||
kubectl config set-credentials prowler-sa --token=<SA_TOKEN>
|
||||
kubectl config set-context <CONTEXT_NAME> --user=prowler-sa
|
||||
```
|
||||
|
||||
Replace `<SA_TOKEN>` with the generated token and `<CONTEXT_NAME>` with your KubeConfig Context Name of your EKS, GKE or AKS cluster.
|
||||
|
||||
4. Now you can add the modified `kubeconfig` in Prowler Cloud. Then test the connection.
|
||||
|
||||
### **Step 4.5: M365 Credentials**
|
||||
Enter your Microsoft Entra domain (primary tenant domain) and select how the provider should authenticate. Prowler App guides you through the process:
|
||||
|
||||
<img src="/images/providers/m365-auth-selection-form.png" alt="M365 authentication method selection" width="700" />
|
||||
|
||||
- **Application Client Secret Authentication**: Client secret-based authentication.
|
||||
- **Application Certificate Authentication (Recommended)**: Certificate-based authentication. Recommended by Microsoft.
|
||||
|
||||
#### Step 4.5.1: Application Client Secret Authentication
|
||||
1. **Enter your tenant ID**: This is the unique identifier for your Microsoft Entra ID directory.
|
||||
2. **Enter your application (client) ID**: This is the unique identifier assigned to your app registration in Microsoft Entra ID.
|
||||
3. **Enter your client secret**: This is the secret key used to authenticate your application.
|
||||
|
||||
<img src="/images/providers/secret-form.png" alt="M365 client secret authentication form" width="700" />
|
||||
|
||||
For full setup instructions, certificate generation commands, and required permissions, review the [Microsoft 365 provider requirements](/user-guide/providers/microsoft365/getting-started-m365).
|
||||
|
||||
#### Step 4.5.2: Application Certificate Authentication (Recommended)
|
||||
1. **Enter your tenant ID**: This is the unique identifier for your Microsoft Entra ID directory.
|
||||
2. **Enter your application (client) ID**: This is the unique identifier assigned to your app registration in Microsoft Entra ID.
|
||||
3. **Upload your certificate file content**: This is the **Base64** encoded certificate content used to authenticate your application.
|
||||
|
||||
<img src="/images/providers/certificate-form.png" alt="M365 certificate authentication form" width="700" />
|
||||
|
||||
### **Step 4.6: GitHub Credentials**
|
||||
For GitHub, you must enter your Provider ID (username or organization name) and choose the authentication method you want to use:
|
||||
|
||||
- **Personal Access Token** (Recommended for individual users)
|
||||
- **OAuth App Token** (For applications requiring user consent)
|
||||
- **GitHub App** (Recommended for organizations and production use)
|
||||
|
||||
<Note>
|
||||
For full setup instructions and requirements, check the [GitHub provider requirements](/user-guide/providers/github/getting-started-github).
|
||||
|
||||
</Note>
|
||||
<img src="/images/prowler-app/github-auth-methods.png" alt="GitHub Authentication Methods" width="700" />
|
||||
|
||||
#### **Step 4.6.1: Personal Access Token**
|
||||
|
||||
Personal Access Tokens provide the simplest GitHub authentication method and support individual user authentication or testing scenarios.
|
||||
|
||||
- Select `Personal Access Token` and enter your `Personal Access Token`:
|
||||
|
||||
<img src="/images/prowler-app/github-pat-credentials.png" alt="GitHub Personal Access Token Credentials" width="700" />
|
||||
|
||||
<Note>
|
||||
For detailed instructions on creating a Personal Access Token and the exact permissions required, check the [GitHub Personal Access Token tutorial](/user-guide/providers/github/getting-started-github#1-personal-access-token-pat).
|
||||
|
||||
</Note>
|
||||
#### **Step 4.6.2: OAuth App Token**
|
||||
|
||||
OAuth Apps enable applications to act on behalf of users with explicit consent.
|
||||
|
||||
- Select `OAuth App Token` and enter your `OAuth App Token`:
|
||||
|
||||
<img src="/images/prowler-app/github-oauth-credentials.png" alt="GitHub OAuth App Credentials" width="700" />
|
||||
|
||||
<Note>
|
||||
To create an OAuth App, go to GitHub Settings → Developer settings → OAuth Apps → New OAuth App. You'll need to exchange an authorization code for an access token using the OAuth flow.
|
||||
|
||||
</Note>
|
||||
#### **Step 4.6.3: GitHub App**
|
||||
|
||||
GitHub Apps provide the recommended integration method for accessing multiple repositories or organizations.
|
||||
|
||||
- Select `GitHub App` and enter your `GitHub App ID` and `GitHub App Private Key`:
|
||||
|
||||
<img src="/images/prowler-app/github-app-credentials.png" alt="GitHub App Credentials" width="700" />
|
||||
|
||||
<Note>
|
||||
To create a GitHub App, go to GitHub Settings → Developer settings → GitHub Apps → New GitHub App. Configure the necessary permissions and generate a private key. Install the app to your account or organization and provide the App ID and private key content.
|
||||
|
||||
</Note>
|
||||
For detailed instructions on configuring credentials for each provider, refer to the provider-specific getting started guides:
|
||||
|
||||
<Columns cols={3}>
|
||||
<Card title="AWS" icon="aws" href="/user-guide/providers/aws/getting-started-aws">
|
||||
Configure AWS authentication using IAM Access Keys or Assumed Role credentials.
|
||||
</Card>
|
||||
<Card title="Azure" icon="microsoft" href="/user-guide/providers/azure/getting-started-azure">
|
||||
Set up Azure authentication using Service Principal credentials.
|
||||
</Card>
|
||||
<Card title="Google Cloud" icon="google" href="/user-guide/providers/gcp/getting-started-gcp">
|
||||
Configure GCP authentication with Service Account or Application Default Credentials.
|
||||
</Card>
|
||||
<Card title="Kubernetes" icon="cloud" href="/user-guide/providers/kubernetes/getting-started-k8s">
|
||||
Set up Kubernetes authentication using kubeconfig files for cluster access.
|
||||
</Card>
|
||||
<Card title="Microsoft 365" icon="microsoft" href="/user-guide/providers/microsoft365/getting-started-m365">
|
||||
Configure M365 authentication with Application Certificate or Client Secret.
|
||||
</Card>
|
||||
<Card title="GitHub" icon="github" href="/user-guide/providers/github/getting-started-github">
|
||||
Set up GitHub authentication using Personal Access Token, OAuth App, or GitHub App.
|
||||
</Card>
|
||||
<Card title="Infrastructure as Code" icon="code" href="/user-guide/providers/iac/getting-started-iac">
|
||||
Scan IaC public or private repositories for security issues.
|
||||
</Card>
|
||||
</Columns>
|
||||
## **Step 5: Test Connection**
|
||||
|
||||
After adding your credentials of your cloud account, click the `Launch` button to verify that Prowler App can successfully connect to your provider:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: 'Prowler Cloud Public IPs'
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Prowler Cloud uses a dedicated egress IPv4 address for all outbound connections to customer infrastructure. This enables organizations to implement network-level security controls by whitelisting Prowler's IP address.
|
||||
|
||||
## Use Cases
|
||||
|
||||
Whitelisting Prowler's egress IP address enables:
|
||||
|
||||
- **Credential Usage Control**: Restrict where cloud provider credentials can be used from across AWS, Azure, GCP, and other providers
|
||||
- **Kubernetes Security**: Limit inbound HTTPS traffic to clusters by allowing only Prowler's IP address
|
||||
- **Compliance Requirements**: Meet security policies requiring allowlisting of external services
|
||||
|
||||
## Query the Egress IP Address
|
||||
|
||||
Retrieve Prowler Cloud's current egress IP address using the following command:
|
||||
|
||||
```bash
|
||||
dig egress.prowler.com +short
|
||||
```
|
||||
|
||||
This command returns the IPv4 address that Prowler Cloud uses for all outbound connections to customer infrastructure.
|
||||
|
||||
<Note>
|
||||
The egress IP address is stable, but it is recommended to periodically verify it remains current by querying `egress.prowler.com`.
|
||||
</Note>
|
||||
@@ -11,6 +11,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- `cloudstorage_bucket_versioning_enabled` check for GCP provider [(#9014)](https://github.com/prowler-cloud/prowler/pull/9014)
|
||||
- `cloudstorage_bucket_soft_delete_enabled` check for GCP provider [(#9028)](https://github.com/prowler-cloud/prowler/pull/9028)
|
||||
- `cloudstorage_bucket_logging_enabled` check for GCP provider [(#9091)](https://github.com/prowler-cloud/prowler/pull/9091)
|
||||
- `cloudstorage_bucket_sufficient_retention_period` check for GCP provider [(#9149)](https://github.com/prowler-cloud/prowler/pull/9149)
|
||||
- C5 compliance framework for Azure provider [(#9081)](https://github.com/prowler-cloud/prowler/pull/9081)
|
||||
- C5 compliance framework for the GCP provider [(#9097)](https://github.com/prowler-cloud/prowler/pull/9097)
|
||||
- `organization_repository_creation_limited` check for GitHub provider [(#8844)](https://github.com/prowler-cloud/prowler/pull/8844)
|
||||
@@ -19,6 +20,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- Add multiple compliance improvements [(#9145)](https://github.com/prowler-cloud/prowler/pull/9145)
|
||||
- Added validation for invalid checks, services, and categories in `load_checks_to_execute` function [(#8971)](https://github.com/prowler-cloud/prowler/pull/8971)
|
||||
- NIST CSF 2.0 compliance framework for the AWS provider [(#9185)](https://github.com/prowler-cloud/prowler/pull/9185)
|
||||
- Add FedRAMP 20x KSI Low for AWS, Azure and GCP [(#9198)](https://github.com/prowler-cloud/prowler/pull/9198)
|
||||
|
||||
### Changed
|
||||
- Update AWS Direct Connect service metadata to new format [(#8855)](https://github.com/prowler-cloud/prowler/pull/8855)
|
||||
@@ -29,6 +31,8 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- Update AWS EKS service metadata to new format [(#8890)](https://github.com/prowler-cloud/prowler/pull/8890)
|
||||
- Update AWS Elastic Beanstalk service metadata to new format [(#8934)](https://github.com/prowler-cloud/prowler/pull/8934)
|
||||
- Update AWS ElastiCache service metadata to new format [(#8933)](https://github.com/prowler-cloud/prowler/pull/8933)
|
||||
- Update MongoDB Atlas projects service metadata to new format [(#9093)](https://github.com/prowler-cloud/prowler/pull/9093)
|
||||
- Update GitHub Organization service metadata to new format [(#9094)](https://github.com/prowler-cloud/prowler/pull/9094)
|
||||
- Update AWS CodeBuild service metadata to new format [(#8851)](https://github.com/prowler-cloud/prowler/pull/8851)
|
||||
- Update GCP Artifact Registry service metadata to new format [(#9088)](https://github.com/prowler-cloud/prowler/pull/9088)
|
||||
- Update AWS EFS service metadata to new format [(#8889)](https://github.com/prowler-cloud/prowler/pull/8889)
|
||||
@@ -38,9 +42,9 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- Update AWS FSx service metadata to new format [(#9006)](https://github.com/prowler-cloud/prowler/pull/9006)
|
||||
- Update AWS Glacier service metadata to new format [(#9007)](https://github.com/prowler-cloud/prowler/pull/9007)
|
||||
- Update oraclecloud analytics service metadata to new format [(#9114)](https://github.com/prowler-cloud/prowler/pull/9114)
|
||||
|
||||
- Update AWS CodeArtifact service metadata to new format [(#8850)](https://github.com/prowler-cloud/prowler/pull/8850)
|
||||
- Rename OCI provider to oraclecloud with oci alias [(#9126)](https://github.com/prowler-cloud/prowler/pull/9126)
|
||||
- Remove unnecessary tests for M365_PowerShell module [(#9204)](https://github.com/prowler-cloud/prowler/pull/9204)
|
||||
|
||||
---
|
||||
|
||||
@@ -49,6 +53,9 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
### Fixed
|
||||
- Check `check_name` has no `resource_name` error for GCP provider [(#9169)](https://github.com/prowler-cloud/prowler/pull/9169)
|
||||
- Depth Truncation and parsing error in PowerShell queries [(#9181)](https://github.com/prowler-cloud/prowler/pull/9181)
|
||||
- False negative in `iam_role_cross_service_confused_deputy_prevention` check [(#9213)](https://github.com/prowler-cloud/prowler/pull/9213)
|
||||
- Fix M365 Teams `--sp-env-auth` connection error and enhanced timeout logging [(#9191)](https://github.com/prowler-cloud/prowler/pull/9191)
|
||||
- Rename `get_oci_assessment_summary` to `get_oraclecloud_assessment_summary` in HTML output [(#9200)](https://github.com/prowler-cloud/prowler/pull/9200)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
{
|
||||
"Framework": "FedRAMP-20x-KSI-Low",
|
||||
"Name": "FedRAMP 20x Key Security Indicators (KSIs) - Low Impact Level v25.05C",
|
||||
"Version": "25.05C",
|
||||
"Provider": "AWS",
|
||||
"Description": "FedRAMP 20x Key Security Indicators (KSIs) Low Impact Level represent core security indicators for cloud service providers, focusing on automation, continuous monitoring, and cloud-native security principles per FedRAMP 20x Phase One pilot requirements for Low impact systems.",
|
||||
"Requirements": [
|
||||
{
|
||||
"Id": "ksi-cmt",
|
||||
"Name": "KSI-CMT: Change Management",
|
||||
"Description": "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-cmt",
|
||||
"Section": "Change Management",
|
||||
"Service": "aws"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"cloudtrail_multi_region_enabled",
|
||||
"cloudtrail_log_file_validation_enabled",
|
||||
"cloudtrail_s3_dataevents_read_enabled",
|
||||
"cloudtrail_s3_dataevents_write_enabled",
|
||||
"cloudwatch_changes_to_network_acls_alarm_configured",
|
||||
"cloudwatch_changes_to_network_gateways_alarm_configured",
|
||||
"cloudwatch_changes_to_network_route_tables_alarm_configured",
|
||||
"cloudwatch_changes_to_vpcs_alarm_configured",
|
||||
"cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled",
|
||||
"cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled",
|
||||
"cloudwatch_log_metric_filter_aws_organizations_changes",
|
||||
"cloudwatch_log_metric_filter_for_s3_bucket_policy_changes",
|
||||
"cloudwatch_log_metric_filter_policy_changes",
|
||||
"cloudwatch_log_metric_filter_security_group_changes",
|
||||
"config_recorder_all_regions_enabled",
|
||||
"ec2_instance_managed_by_ssm",
|
||||
"ec2_instance_older_than_specific_days",
|
||||
"ssm_managed_compliant_patching",
|
||||
"ssm_managed_instance_compliance_association_compliant",
|
||||
"ssm_managed_instance_compliance_patch_compliant"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-cna",
|
||||
"Name": "KSI-CNA: Cloud Native Architecture",
|
||||
"Description": "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-cna",
|
||||
"Section": "Cloud Native Architecture",
|
||||
"Service": "aws"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"autoscaling_group_multiple_az",
|
||||
"autoscaling_group_multiple_instance_types",
|
||||
"autoscaling_group_capacity_rebalance_enabled",
|
||||
"dynamodb_tables_pitr_enabled",
|
||||
"dynamodb_tables_deletion_protection_enabled",
|
||||
"ec2_instance_imdsv2_enabled",
|
||||
"ec2_networkacl_allow_ingress_any_port",
|
||||
"ec2_securitygroup_default_restrict_traffic",
|
||||
"ec2_securitygroup_allow_ingress_from_internet_to_any_port",
|
||||
"eks_cluster_network_policy_enabled",
|
||||
"eks_cluster_not_publicly_accessible",
|
||||
"eks_cluster_private_nodes_enabled",
|
||||
"eks_cluster_uses_a_supported_version",
|
||||
"elb_cross_zone_load_balancing_enabled",
|
||||
"elbv2_alb_multi_az_scheme",
|
||||
"elbv2_waf_acl_attached",
|
||||
"rds_instance_multi_az",
|
||||
"rds_cluster_multi_az",
|
||||
"vpc_subnet_auto_assign_public_ip_disabled",
|
||||
"vpc_default_security_group_restricts_traffic",
|
||||
"vpc_peering_connection_routing_tables_with_least_privilege"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-iam",
|
||||
"Name": "KSI-IAM: Identity and Access Management",
|
||||
"Description": "A secure cloud service offering will protect user data, control access, and apply zero trust principles",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-iam",
|
||||
"Section": "Identity and Access Management",
|
||||
"Service": "aws"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"iam_administrator_access_with_mfa",
|
||||
"iam_aws_attached_policy_no_administrative_privileges",
|
||||
"iam_customer_attached_policy_no_administrative_privileges",
|
||||
"iam_inline_policy_no_administrative_privileges",
|
||||
"iam_no_custom_policy_permissive_role_assumption",
|
||||
"iam_no_root_access_key",
|
||||
"iam_password_policy_expires_passwords_within_90_days_or_less",
|
||||
"iam_password_policy_lowercase",
|
||||
"iam_password_policy_minimum_length_14",
|
||||
"iam_password_policy_number",
|
||||
"iam_password_policy_reuse_24",
|
||||
"iam_password_policy_symbol",
|
||||
"iam_password_policy_uppercase",
|
||||
"iam_policy_attached_only_to_group_or_roles",
|
||||
"iam_policy_no_full_access_to_cloudtrail",
|
||||
"iam_policy_no_full_access_to_kms",
|
||||
"iam_root_hardware_mfa_enabled",
|
||||
"iam_root_mfa_enabled",
|
||||
"iam_rotate_access_key_90_days",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"iam_user_hardware_mfa_enabled",
|
||||
"iam_user_mfa_enabled_console_access",
|
||||
"iam_user_two_active_access_key",
|
||||
"organizations_scp_check_deny_regions",
|
||||
"organizations_opt_out_ai_services_policy"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-inr",
|
||||
"Name": "KSI-INR: Incident Response",
|
||||
"Description": "A secure cloud service offering will respond to incidents according to FedRAMP requirements and cloud service provider policies",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-inr",
|
||||
"Section": "Incident Response",
|
||||
"Service": "aws"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"guardduty_centrally_managed",
|
||||
"guardduty_ec2_malware_protection_enabled",
|
||||
"guardduty_eks_audit_log_enabled",
|
||||
"guardduty_eks_protection_enabled",
|
||||
"guardduty_eks_runtime_monitoring_enabled",
|
||||
"guardduty_is_enabled",
|
||||
"guardduty_lambda_protection_enabled",
|
||||
"guardduty_malware_protection_enabled",
|
||||
"guardduty_no_high_severity_findings",
|
||||
"guardduty_rds_protection_enabled",
|
||||
"guardduty_s3_protection_enabled",
|
||||
"inspector2_is_enabled",
|
||||
"inspector2_active_findings_exist",
|
||||
"securityhub_enabled",
|
||||
"sns_topics_kms_encryption_at_rest_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-mla",
|
||||
"Name": "KSI-MLA: Monitoring, Logging, and Auditing",
|
||||
"Description": "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-mla",
|
||||
"Section": "Monitoring, Logging, and Auditing",
|
||||
"Service": "aws"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"apigateway_restapi_logging_enabled",
|
||||
"cloudtrail_cloudwatch_logging_enabled",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
"cloudtrail_log_file_validation_enabled",
|
||||
"cloudtrail_multi_region_enabled",
|
||||
"cloudtrail_s3_dataevents_read_enabled",
|
||||
"cloudtrail_s3_dataevents_write_enabled",
|
||||
"cloudwatch_log_group_kms_encryption_enabled",
|
||||
"cloudwatch_log_group_retention_policy_specific_days_enabled",
|
||||
"ecs_cluster_container_insights_enabled",
|
||||
"eks_cluster_control_plane_audit_logging_enabled",
|
||||
"elb_logging_enabled",
|
||||
"elbv2_logging_enabled",
|
||||
"inspector2_is_enabled",
|
||||
"opensearch_service_domains_cloudwatch_logging_enabled",
|
||||
"rds_instance_enhanced_monitoring_enabled",
|
||||
"rds_instance_integration_cloudwatch_logs",
|
||||
"redshift_cluster_audit_logging",
|
||||
"s3_bucket_server_access_logging_enabled",
|
||||
"vpc_flow_logs_enabled",
|
||||
"wafv2_webacl_logging_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-piy",
|
||||
"Name": "KSI-PIY: Policy and Inventory",
|
||||
"Description": "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-piy",
|
||||
"Section": "Policy and Inventory",
|
||||
"Service": "aws"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"config_recorder_all_regions_enabled",
|
||||
"config_recorder_using_aws_service_role",
|
||||
"ec2_instance_managed_by_ssm",
|
||||
"organizations_account_part_of_organizations",
|
||||
"organizations_delegated_administrators",
|
||||
"organizations_scp_check_deny_regions",
|
||||
"organizations_tags_policies_enabled_and_attached",
|
||||
"resourceexplorer_indexes_found",
|
||||
"ssm_managed_instance_compliance_association_compliant",
|
||||
"trustedadvisor_premium_support_plan_subscribed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-rpl",
|
||||
"Name": "KSI-RPL: Recovery Planning",
|
||||
"Description": "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-rpl",
|
||||
"Section": "Recovery Planning",
|
||||
"Service": "aws"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"backup_plans_exist",
|
||||
"backup_reportplans_exist",
|
||||
"backup_vaults_exist",
|
||||
"backup_vaults_encrypted",
|
||||
"backup_recovery_point_encrypted",
|
||||
"backup_recovery_point_manual_deletion_disabled",
|
||||
"backup_recovery_point_minimum_retention_days",
|
||||
"dlm_ebs_snapshot_lifecycle_policy_exists",
|
||||
"dynamodb_tables_pitr_enabled",
|
||||
"dynamodb_tables_deletion_protection_enabled",
|
||||
"efs_have_backup_enabled",
|
||||
"fsx_file_system_copy_tags_to_backups",
|
||||
"rds_instance_backup_enabled",
|
||||
"rds_instance_backup_retention_policy",
|
||||
"rds_instance_deletion_protection",
|
||||
"rds_cluster_deletion_protection",
|
||||
"rds_snapshots_encrypted",
|
||||
"redshift_cluster_automated_snapshot"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-svc",
|
||||
"Name": "KSI-SVC: Service Configuration",
|
||||
"Description": "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-svc",
|
||||
"Section": "Service Configuration",
|
||||
"Service": "aws"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"acm_certificates_expiration_check",
|
||||
"apigateway_restapi_cache_encrypted",
|
||||
"cloudtrail_kms_encryption_enabled",
|
||||
"dax_cluster_encryption_enabled",
|
||||
"dynamodb_table_encryption_enabled",
|
||||
"dynamodb_table_encryption_uses_cmks",
|
||||
"ebs_volume_encryption_enabled",
|
||||
"ec2_ebs_default_encryption",
|
||||
"ec2_instance_ebs_optimized",
|
||||
"efs_encryption_at_rest_enabled",
|
||||
"eks_cluster_envelope_encryption_enabled",
|
||||
"elasticache_redis_cluster_encryption_at_rest_enabled",
|
||||
"elasticache_redis_cluster_encryption_at_transit_enabled",
|
||||
"elbv2_ssl_listeners",
|
||||
"fsx_file_system_encryption_at_rest_enabled",
|
||||
"kinesis_stream_encrypted_at_rest",
|
||||
"kms_cmk_rotation_enabled",
|
||||
"kms_cmk_not_scheduled_for_deletion",
|
||||
"kms_key_not_publicly_accessible",
|
||||
"rds_instance_storage_encrypted",
|
||||
"rds_instance_storage_encrypted_with_cmk",
|
||||
"rds_cluster_storage_encrypted",
|
||||
"redshift_cluster_encryption_at_rest",
|
||||
"redshift_cluster_encryption_in_transit",
|
||||
"s3_bucket_server_side_encryption_enabled",
|
||||
"s3_bucket_default_encryption",
|
||||
"s3_bucket_secure_transport_policy",
|
||||
"sagemaker_notebook_instance_encryption_enabled",
|
||||
"sns_topics_kms_encryption_at_rest_enabled",
|
||||
"sqs_queue_server_side_encryption_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-tpr",
|
||||
"Name": "KSI-TPR: Third-Party Information Resources",
|
||||
"Description": "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-tpr",
|
||||
"Section": "Third-Party Information Resources",
|
||||
"Service": "aws"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"ecr_registry_scan_images_on_push_enabled",
|
||||
"ecr_repositories_lifecycle_policy_enabled",
|
||||
"ecr_repositories_not_publicly_accessible",
|
||||
"ecr_repositories_scan_on_push_enabled",
|
||||
"ecr_repositories_scan_vulnerabilities_in_latest_image",
|
||||
"ecr_repositories_tag_immutability",
|
||||
"inspector2_active_findings_exist",
|
||||
"inspector2_is_enabled",
|
||||
"awslambda_function_using_supported_runtimes",
|
||||
"ssm_managed_compliant_patching",
|
||||
"trustedadvisor_premium_support_plan_subscribed",
|
||||
"guardduty_no_high_severity_findings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-iam-07",
|
||||
"Name": "KSI-IAM-07: Account Lifecycle Management",
|
||||
"Description": "Securely manage the lifecycle and privileges of all accounts, roles, and groups",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-iam-07",
|
||||
"Section": "Identity and Access Management",
|
||||
"Service": "aws"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"iam_no_root_access_key",
|
||||
"iam_policy_attached_only_to_group_or_roles",
|
||||
"iam_rotate_access_key_90_days",
|
||||
"iam_user_accesskey_unused",
|
||||
"iam_user_console_access_unused",
|
||||
"organizations_delegated_administrators"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-mla-07",
|
||||
"Name": "KSI-MLA-07: Monitoring and Logging Inventory",
|
||||
"Description": "Maintain a list of information resources and event types that will be monitored, logged, and audited",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-mla-07",
|
||||
"Section": "Monitoring, Logging, and Auditing",
|
||||
"Service": "aws"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"cloudtrail_multi_region_enabled",
|
||||
"cloudwatch_log_group_retention_policy_specific_days_enabled",
|
||||
"config_recorder_all_regions_enabled",
|
||||
"inspector2_is_enabled",
|
||||
"resourceexplorer_indexes_found"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
{
|
||||
"Framework": "FedRAMP-20x-KSI-Low",
|
||||
"Name": "FedRAMP 20x Key Security Indicators (KSIs) - Low Impact Level v25.05C",
|
||||
"Version": "25.05C",
|
||||
"Provider": "Azure",
|
||||
"Description": "FedRAMP 20x Key Security Indicators (KSIs) Low Impact Level represent core security indicators for cloud service providers, focusing on automation, continuous monitoring, and cloud-native security principles per FedRAMP 20x Phase One pilot requirements for Low impact systems.",
|
||||
"Requirements": [
|
||||
{
|
||||
"Id": "ksi-cmt",
|
||||
"Name": "KSI-CMT: Change Management",
|
||||
"Description": "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-cmt",
|
||||
"Section": "Change Management",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"monitor_activity_log_alert_cmk_delete",
|
||||
"monitor_activity_log_alert_create_policy_assignment",
|
||||
"monitor_activity_log_alert_create_update_delete_network_sg",
|
||||
"monitor_activity_log_alert_create_update_delete_network_sg_rule",
|
||||
"monitor_activity_log_alert_create_update_delete_sql_server_fw_rule",
|
||||
"monitor_activity_log_alert_create_update_nsg",
|
||||
"monitor_activity_log_alert_create_update_public_ip_address",
|
||||
"monitor_activity_log_alert_create_update_security_solution",
|
||||
"monitor_activity_log_alert_delete_nsg",
|
||||
"monitor_activity_log_alert_delete_policy_assignment",
|
||||
"monitor_activity_log_alert_delete_public_ip_address",
|
||||
"monitor_activity_log_alert_delete_security_solution",
|
||||
"monitor_log_profile_all_categories",
|
||||
"monitor_log_profile_all_regions",
|
||||
"vm_agent_installed",
|
||||
"vm_antimalware_solution_installed",
|
||||
"vm_endpoint_protection_installed",
|
||||
"vm_guest_configuration_installed",
|
||||
"vm_guest_configuration_with_no_managed_identity",
|
||||
"vm_guest_configuration_with_user_identity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-cna",
|
||||
"Name": "KSI-CNA: Cloud Native Architecture",
|
||||
"Description": "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-cna",
|
||||
"Section": "Cloud Native Architecture",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"aks_clusters_created_with_private_nodes",
|
||||
"aks_clusters_public_access_disabled",
|
||||
"aks_network_policy_enabled",
|
||||
"app_function_vnet_integration_enabled",
|
||||
"app_function_not_publicly_accessible",
|
||||
"containerregistry_not_publicly_accessible",
|
||||
"containerregistry_uses_private_link",
|
||||
"cosmosdb_account_use_private_endpoints",
|
||||
"cosmosdb_account_firewall_use_selected_networks",
|
||||
"databricks_workspace_vnet_injection_enabled",
|
||||
"keyvault_access_only_through_private_endpoints",
|
||||
"keyvault_private_endpoints",
|
||||
"network_bastion_host_exists",
|
||||
"network_flow_logs_enabled",
|
||||
"network_security_group_not_empty",
|
||||
"network_sg_ssh_access_restricted",
|
||||
"network_sg_rdp_access_restricted",
|
||||
"network_sg_open_all_ports_to_any_source",
|
||||
"network_watcher_enabled",
|
||||
"postgresql_flexible_server_public_network_access_disabled",
|
||||
"sqlserver_public_network_access_disabled",
|
||||
"storage_default_network_access_rule_set_to_deny",
|
||||
"vm_availability_zones_enabled",
|
||||
"vm_availability_set_deployed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-iam",
|
||||
"Name": "KSI-IAM: Identity and Access Management",
|
||||
"Description": "A secure cloud service offering will protect user data, control access, and apply zero trust principles",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-iam",
|
||||
"Section": "Identity and Access Management",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"entra_conditional_access_policy_require_mfa_for_management_api",
|
||||
"entra_global_admin_in_less_than_five_users",
|
||||
"entra_non_privileged_user_has_mfa",
|
||||
"entra_policy_default_users_cannot_create_security_groups",
|
||||
"entra_policy_ensure_default_user_cannot_create_apps",
|
||||
"entra_policy_ensure_default_user_cannot_create_tenants",
|
||||
"entra_policy_guest_invite_only_for_admin_roles",
|
||||
"entra_policy_guest_users_access_restrictions",
|
||||
"entra_policy_restricts_user_consent_for_apps",
|
||||
"entra_policy_user_consent_for_verified_apps",
|
||||
"entra_privileged_user_has_mfa",
|
||||
"entra_security_defaults_enabled",
|
||||
"entra_trusted_named_locations_exists",
|
||||
"entra_user_with_vm_access_has_mfa",
|
||||
"entra_users_cannot_create_microsoft_365_groups",
|
||||
"iam_custom_role_has_permissions_to_administer_resource_locks",
|
||||
"iam_role_user_access_admin_restricted",
|
||||
"iam_subscription_roles_owner_custom_not_created",
|
||||
"keyvault_rbac_enabled",
|
||||
"app_function_identity_is_configured",
|
||||
"app_function_identity_without_admin_privileges",
|
||||
"app_ensure_auth_is_set_up",
|
||||
"app_register_with_identity",
|
||||
"vm_managed_identity_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-inr",
|
||||
"Name": "KSI-INR: Incident Response",
|
||||
"Description": "A secure cloud service offering will respond to incidents according to FedRAMP requirements and cloud service provider policies",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-inr",
|
||||
"Section": "Incident Response",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"defender_attack_path_notifications_properly_configured",
|
||||
"defender_ensure_notify_alerts_severity_is_high",
|
||||
"defender_ensure_notify_emails_to_owners",
|
||||
"defender_additional_email_configured_with_a_security_contact",
|
||||
"defender_container_images_resolved_vulnerabilities",
|
||||
"defender_container_images_scan_enabled",
|
||||
"defender_ensure_defender_for_app_services_is_on",
|
||||
"defender_ensure_defender_for_arm_is_on",
|
||||
"defender_ensure_defender_for_azure_sql_databases_is_on",
|
||||
"defender_ensure_defender_for_containers_is_on",
|
||||
"defender_ensure_defender_for_cosmosdb_is_on",
|
||||
"defender_ensure_defender_for_databases_is_on",
|
||||
"defender_ensure_defender_for_dns_is_on",
|
||||
"defender_ensure_defender_for_keyvault_is_on",
|
||||
"defender_ensure_defender_for_os_relational_databases_is_on",
|
||||
"defender_ensure_defender_for_server_is_on",
|
||||
"defender_ensure_defender_for_sql_servers_is_on",
|
||||
"defender_ensure_defender_for_storage_is_on",
|
||||
"defender_ensure_iot_hub_defender_is_on",
|
||||
"defender_ensure_wdatp_is_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-mla",
|
||||
"Name": "KSI-MLA: Monitoring, Logging, and Auditing",
|
||||
"Description": "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-mla",
|
||||
"Section": "Monitoring, Logging, and Auditing",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"app_function_application_insights_enabled",
|
||||
"app_http_logs_enabled",
|
||||
"appinsights_ensure_is_configured",
|
||||
"defender_auto_provisioning_log_analytics_agent_vms_on",
|
||||
"defender_auto_provisioning_vulnerabilty_assessments_machines_on",
|
||||
"keyvault_logging_enabled",
|
||||
"monitor_activity_log_retention_policy_set",
|
||||
"monitor_diagnostic_logs_categories",
|
||||
"monitor_diagnostic_setting_deployed_for_all_resources",
|
||||
"monitor_diagnostic_settings_captures_proper_categories",
|
||||
"monitor_log_profile_all_categories",
|
||||
"monitor_log_profile_all_regions",
|
||||
"monitor_log_profile_captures_all_activities",
|
||||
"monitor_log_profile_retention_policy_at_least_365",
|
||||
"network_flow_logs_enabled",
|
||||
"network_flow_log_retention_policy_at_least_90",
|
||||
"network_watcher_enabled",
|
||||
"postgresql_flexible_server_audit_logs_enabled",
|
||||
"postgresql_flexible_server_log_checkpoints_enabled",
|
||||
"postgresql_flexible_server_log_connections_enabled",
|
||||
"postgresql_flexible_server_log_disconnections_enabled",
|
||||
"sqlserver_auditing_on",
|
||||
"sqlserver_auditing_retention_90_days",
|
||||
"storage_storage_account_logging_queue_read_write_delete_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-piy",
|
||||
"Name": "KSI-PIY: Policy and Inventory",
|
||||
"Description": "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-piy",
|
||||
"Section": "Policy and Inventory",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"policy_ensure_asc_for_aks_is_enabled",
|
||||
"policy_ensure_asc_for_app_services_is_enabled",
|
||||
"policy_ensure_asc_for_azure_sql_is_enabled",
|
||||
"policy_ensure_asc_for_key_vault_is_enabled",
|
||||
"policy_ensure_asc_for_servers_is_enabled",
|
||||
"policy_ensure_asc_for_sql_servers_is_enabled",
|
||||
"policy_ensure_asc_for_storage_is_enabled",
|
||||
"policy_ensure_allowed_extensions_are_installed",
|
||||
"policy_ensure_allowed_locations_is_enabled",
|
||||
"policy_ensure_allowed_resource_types_is_enabled",
|
||||
"policy_ensure_audit_diagnostic_log_enabled_for_all_services",
|
||||
"policy_ensure_not_allowed_resource_types_is_enabled",
|
||||
"vm_guest_configuration_installed",
|
||||
"vm_guest_configuration_with_no_managed_identity",
|
||||
"vm_guest_configuration_with_user_identity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-rpl",
|
||||
"Name": "KSI-RPL: Recovery Planning",
|
||||
"Description": "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-rpl",
|
||||
"Section": "Recovery Planning",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"mysql_flexible_server_geo_redundant_backup_enabled",
|
||||
"mysql_flexible_server_retain_backup_35_days",
|
||||
"postgresql_flexible_server_geo_redundant_backup_enabled",
|
||||
"postgresql_flexible_server_backup_retention_period_35_days",
|
||||
"recovery_services_vault_uses_private_link",
|
||||
"recovery_services_vault_uses_private_link_for_backup",
|
||||
"sqlserver_database_long_term_geo_redundant_backup",
|
||||
"sqlserver_database_retention_policy_exceeds_90_days",
|
||||
"storage_default_storage_account_encrypted_with_cmk_not_stored_in_storage_account",
|
||||
"storage_geo_redundant_enabled",
|
||||
"storage_infrastructure_encryption_is_enabled",
|
||||
"storage_soft_delete_containers_enabled",
|
||||
"storage_soft_delete_enabled",
|
||||
"vm_backup_enabled",
|
||||
"vm_sufficient_daily_backup_retention_period"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-svc",
|
||||
"Name": "KSI-SVC: Service Configuration",
|
||||
"Description": "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-svc",
|
||||
"Section": "Service Configuration",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"app_client_certificates_on",
|
||||
"app_ensure_http_is_redirected_to_https",
|
||||
"app_minimum_tls_version_12",
|
||||
"containerregistry_admin_user_disabled",
|
||||
"cosmosdb_account_use_aad_and_rbac",
|
||||
"databricks_workspace_cmk_encryption_enabled",
|
||||
"keyvault_key_expiration_set_in_non_rbac",
|
||||
"keyvault_key_rotation_enabled",
|
||||
"keyvault_non_rbac_secret_expiration_set",
|
||||
"mysql_flexible_server_encrypted_at_rest_using_cmk",
|
||||
"mysql_flexible_server_encrypted_in_transit",
|
||||
"mysql_flexible_server_minimum_tls_version_tls12",
|
||||
"postgresql_flexible_server_encrypted_at_rest_using_cmk",
|
||||
"postgresql_flexible_server_encrypted_in_transit",
|
||||
"postgresql_flexible_server_minimum_tls_version_tls12",
|
||||
"sqlserver_advanced_data_security_enabled",
|
||||
"sqlserver_database_encryption_with_cmk",
|
||||
"sqlserver_database_tde_encryption_enabled",
|
||||
"sqlserver_minimum_tls_version_12",
|
||||
"storage_secure_transfer_required_enabled",
|
||||
"storage_default_storage_account_encrypted_with_cmk",
|
||||
"storage_infrastructure_encryption_is_enabled",
|
||||
"storage_storage_account_encrypted_with_cmk",
|
||||
"storage_storage_account_minimum_tls_version_tls12",
|
||||
"vm_encrypted_at_host",
|
||||
"vm_data_disks_encrypted_with_cmk",
|
||||
"vm_managed_disks_encrypted_with_cmk",
|
||||
"vm_os_disk_are_encrypted_with_cmk",
|
||||
"vm_temporary_disks_and_cache_encrypted"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-tpr",
|
||||
"Name": "KSI-TPR: Third-Party Information Resources",
|
||||
"Description": "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-tpr",
|
||||
"Section": "Third-Party Information Resources",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"app_ensure_java_version_is_latest",
|
||||
"app_ensure_php_version_is_latest",
|
||||
"app_ensure_python_version_is_latest",
|
||||
"app_function_latest_runtime_version",
|
||||
"defender_container_images_resolved_vulnerabilities",
|
||||
"defender_container_images_scan_enabled",
|
||||
"defender_ensure_system_updates_are_applied",
|
||||
"vm_agent_installed",
|
||||
"vm_antimalware_solution_installed",
|
||||
"vm_endpoint_protection_installed",
|
||||
"vm_os_update_system_updates",
|
||||
"vm_security_patch_assessment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-iam-07",
|
||||
"Name": "KSI-IAM-07: Account Lifecycle Management",
|
||||
"Description": "Securely manage the lifecycle and privileges of all accounts, roles, and groups",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-iam-07",
|
||||
"Section": "Identity and Access Management",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"entra_non_privileged_user_has_mfa",
|
||||
"entra_privileged_user_has_mfa",
|
||||
"entra_user_with_vm_access_has_mfa",
|
||||
"iam_custom_role_has_permissions_to_administer_resource_locks",
|
||||
"iam_role_user_access_admin_restricted",
|
||||
"app_function_identity_is_configured",
|
||||
"vm_managed_identity_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-mla-07",
|
||||
"Name": "KSI-MLA-07: Monitoring and Logging Inventory",
|
||||
"Description": "Maintain a list of information resources and event types that will be monitored, logged, and audited",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-mla-07",
|
||||
"Section": "Monitoring, Logging, and Auditing",
|
||||
"Service": "azure"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"monitor_log_profile_all_categories",
|
||||
"monitor_log_profile_all_regions",
|
||||
"monitor_log_profile_captures_all_activities",
|
||||
"monitor_diagnostic_setting_deployed_for_all_resources",
|
||||
"network_watcher_enabled"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
{
|
||||
"Framework": "FedRAMP-20x-KSI-Low",
|
||||
"Name": "FedRAMP 20x Key Security Indicators (KSIs) - Low Impact Level v25.05C",
|
||||
"Version": "25.05C",
|
||||
"Provider": "GCP",
|
||||
"Description": "FedRAMP 20x Key Security Indicators (KSIs) Low Impact Level represent core security indicators for cloud service providers, focusing on automation, continuous monitoring, and cloud-native security principles per FedRAMP 20x Phase One pilot requirements for Low impact systems.",
|
||||
"Requirements": [
|
||||
{
|
||||
"Id": "ksi-cmt",
|
||||
"Name": "KSI-CMT: Change Management",
|
||||
"Description": "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-cmt",
|
||||
"Section": "Change Management",
|
||||
"Service": "gcp"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"iam_audit_logs_enabled",
|
||||
"iam_cloud_asset_inventory_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_custom_role_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled",
|
||||
"compute_instance_serial_ports_in_use",
|
||||
"compute_project_os_login_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-cna",
|
||||
"Name": "KSI-CNA: Cloud Native Architecture",
|
||||
"Description": "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-cna",
|
||||
"Section": "Cloud Native Architecture",
|
||||
"Service": "gcp"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"cloudsql_instance_private_ip_assignment",
|
||||
"cloudsql_instance_public_access",
|
||||
"cloudsql_instance_public_ip",
|
||||
"cloudstorage_bucket_uniform_bucket_level_access",
|
||||
"compute_firewall_rdp_access_from_the_internet_allowed",
|
||||
"compute_firewall_ssh_access_from_the_internet_allowed",
|
||||
"compute_instance_block_project_wide_ssh_keys_disabled",
|
||||
"compute_instance_confidential_computing_enabled",
|
||||
"compute_instance_ip_forwarding_is_enabled",
|
||||
"compute_instance_public_ip",
|
||||
"compute_instance_shielded_vm_enabled",
|
||||
"compute_loadbalancer_logging_enabled",
|
||||
"compute_network_default_in_use",
|
||||
"compute_network_dns_logging_enabled",
|
||||
"compute_network_not_legacy",
|
||||
"compute_subnet_flow_logs_enabled",
|
||||
"gke_cluster_no_default_service_account"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-iam",
|
||||
"Name": "KSI-IAM: Identity and Access Management",
|
||||
"Description": "A secure cloud service offering will protect user data, control access, and apply zero trust principles",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-iam",
|
||||
"Section": "Identity and Access Management",
|
||||
"Service": "gcp"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"apikeys_api_restrictions_configured",
|
||||
"apikeys_key_exists",
|
||||
"apikeys_key_rotated_in_90_days",
|
||||
"compute_instance_default_service_account_in_use",
|
||||
"compute_instance_default_service_account_in_use_with_full_api_access",
|
||||
"iam_no_service_roles_at_project_level",
|
||||
"iam_role_kms_enforce_separation_of_duties",
|
||||
"iam_role_sa_enforce_separation_of_duties",
|
||||
"iam_sa_no_administrative_privileges",
|
||||
"iam_sa_no_user_managed_keys",
|
||||
"iam_sa_user_managed_key_rotate_90_days",
|
||||
"iam_sa_user_managed_key_unused",
|
||||
"iam_service_account_unused"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-inr",
|
||||
"Name": "KSI-INR: Incident Response",
|
||||
"Description": "A secure cloud service offering will respond to incidents according to FedRAMP requirements and cloud service provider policies",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-inr",
|
||||
"Section": "Incident Response",
|
||||
"Service": "gcp"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"iam_organization_essential_contacts_configured",
|
||||
"iam_account_access_approval_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_custom_role_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-mla",
|
||||
"Name": "KSI-MLA: Monitoring, Logging, and Auditing",
|
||||
"Description": "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-mla",
|
||||
"Section": "Monitoring, Logging, and Auditing",
|
||||
"Service": "gcp"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"cloudsql_instance_postgres_enable_pgaudit_flag",
|
||||
"cloudsql_instance_postgres_log_connections_flag",
|
||||
"cloudsql_instance_postgres_log_disconnections_flag",
|
||||
"cloudsql_instance_postgres_log_error_verbosity_flag",
|
||||
"cloudsql_instance_postgres_log_min_duration_statement_flag",
|
||||
"cloudsql_instance_postgres_log_min_error_statement_flag",
|
||||
"cloudsql_instance_postgres_log_min_messages_flag",
|
||||
"cloudsql_instance_postgres_log_statement_flag",
|
||||
"cloudsql_instance_sqlserver_trace_flag",
|
||||
"cloudstorage_bucket_log_retention_policy_lock",
|
||||
"compute_loadbalancer_logging_enabled",
|
||||
"compute_network_dns_logging_enabled",
|
||||
"compute_subnet_flow_logs_enabled",
|
||||
"iam_audit_logs_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_custom_role_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled",
|
||||
"logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled",
|
||||
"logging_sink_created"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-piy",
|
||||
"Name": "KSI-PIY: Policy and Inventory",
|
||||
"Description": "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-piy",
|
||||
"Section": "Policy and Inventory",
|
||||
"Service": "gcp"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"iam_cloud_asset_inventory_enabled",
|
||||
"iam_organization_essential_contacts_configured",
|
||||
"iam_audit_logs_enabled",
|
||||
"compute_project_os_login_enabled",
|
||||
"compute_instance_serial_ports_in_use",
|
||||
"compute_instance_block_project_wide_ssh_keys_disabled",
|
||||
"logging_sink_created"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-rpl",
|
||||
"Name": "KSI-RPL: Recovery Planning",
|
||||
"Description": "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-rpl",
|
||||
"Section": "Recovery Planning",
|
||||
"Service": "gcp"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"cloudsql_instance_automated_backups",
|
||||
"cloudstorage_bucket_log_retention_policy_lock",
|
||||
"cloudstorage_bucket_versioning_enabled",
|
||||
"cloudstorage_bucket_lifecycle_management_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-svc",
|
||||
"Name": "KSI-SVC: Service Configuration",
|
||||
"Description": "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-svc",
|
||||
"Section": "Service Configuration",
|
||||
"Service": "gcp"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"bigquery_dataset_cmk_encryption",
|
||||
"bigquery_table_cmk_encryption",
|
||||
"cloudsql_instance_mysql_local_infile_flag",
|
||||
"cloudsql_instance_mysql_skip_show_database_flag",
|
||||
"cloudsql_instance_postgres_enable_pgaudit_flag",
|
||||
"cloudsql_instance_postgres_log_connections_flag",
|
||||
"cloudsql_instance_postgres_log_disconnections_flag",
|
||||
"cloudsql_instance_postgres_log_error_verbosity_flag",
|
||||
"cloudsql_instance_postgres_log_min_duration_statement_flag",
|
||||
"cloudsql_instance_postgres_log_min_error_statement_flag",
|
||||
"cloudsql_instance_postgres_log_min_messages_flag",
|
||||
"cloudsql_instance_postgres_log_statement_flag",
|
||||
"cloudsql_instance_sqlserver_contained_database_authentication_flag",
|
||||
"cloudsql_instance_sqlserver_cross_db_ownership_chaining_flag",
|
||||
"cloudsql_instance_sqlserver_external_scripts_enabled_flag",
|
||||
"cloudsql_instance_sqlserver_remote_access_flag",
|
||||
"cloudsql_instance_sqlserver_trace_flag",
|
||||
"cloudsql_instance_sqlserver_user_connections_flag",
|
||||
"cloudsql_instance_sqlserver_user_options_flag",
|
||||
"cloudsql_instance_ssl_connections",
|
||||
"compute_instance_encryption_with_csek_enabled",
|
||||
"compute_instance_shielded_vm_enabled",
|
||||
"dataproc_encrypted_with_cmks_disabled",
|
||||
"dns_dnssec_disabled",
|
||||
"dns_rsasha1_in_use_to_key_sign_in_dnssec",
|
||||
"dns_rsasha1_in_use_to_zone_sign_in_dnssec",
|
||||
"kms_key_not_publicly_accessible",
|
||||
"kms_key_rotation_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-tpr",
|
||||
"Name": "KSI-TPR: Third-Party Information Resources",
|
||||
"Description": "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-tpr",
|
||||
"Section": "Third-Party Information Resources",
|
||||
"Service": "gcp"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"artifacts_container_analysis_enabled",
|
||||
"gcr_container_scanning_enabled",
|
||||
"compute_public_address_shodan",
|
||||
"cloudsql_instance_automated_backups",
|
||||
"iam_sa_user_managed_key_rotate_90_days",
|
||||
"iam_service_account_unused"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-iam-07",
|
||||
"Name": "KSI-IAM-07: Account Lifecycle Management",
|
||||
"Description": "Securely manage the lifecycle and privileges of all accounts, roles, and groups",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-iam-07",
|
||||
"Section": "Identity and Access Management",
|
||||
"Service": "gcp"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"apikeys_key_rotated_in_90_days",
|
||||
"iam_sa_user_managed_key_rotate_90_days",
|
||||
"iam_sa_user_managed_key_unused",
|
||||
"iam_service_account_unused",
|
||||
"compute_instance_default_service_account_in_use"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "ksi-mla-07",
|
||||
"Name": "KSI-MLA-07: Monitoring and Logging Inventory",
|
||||
"Description": "Maintain a list of information resources and event types that will be monitored, logged, and audited",
|
||||
"Attributes": [
|
||||
{
|
||||
"ItemId": "ksi-mla-07",
|
||||
"Section": "Monitoring, Logging, and Auditing",
|
||||
"Service": "gcp"
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"iam_audit_logs_enabled",
|
||||
"iam_cloud_asset_inventory_enabled",
|
||||
"logging_sink_created",
|
||||
"compute_subnet_flow_logs_enabled",
|
||||
"compute_network_dns_logging_enabled"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -511,6 +511,9 @@ gcp:
|
||||
# gcp.iam_service_account_unused
|
||||
# gcp.iam_sa_user_managed_key_unused
|
||||
max_unused_account_days: 180
|
||||
# GCP Storage Sufficient Retention Period
|
||||
# gcp.cloudstorage_bucket_sufficient_retention_period
|
||||
storage_min_retention_days: 90
|
||||
|
||||
# Kubernetes Configuration
|
||||
kubernetes:
|
||||
|
||||
@@ -153,8 +153,10 @@ class Mutelist(ABC):
|
||||
Check if the provided finding is muted for the audited account, check, region, resource and tags.
|
||||
|
||||
The Mutelist works in a way that each field is ANDed, so if a check is muted for an account, region, resource and tags, it will be muted.
|
||||
The exceptions are ORed, so if a check is excepted for an account, region, resource or tags, it will not be muted.
|
||||
The only particularity is the tags, which are ORed.
|
||||
|
||||
Exceptions use AND logic across specified fields, with unspecified fields treated as wildcards (matching all values).
|
||||
|
||||
Tag matching uses AND logic when multiple tags are listed (all must match). OR logic is achieved using regex alternation (|) within a single tag pattern.
|
||||
|
||||
So, for the following Mutelist:
|
||||
```
|
||||
@@ -167,11 +169,16 @@ class Mutelist(ABC):
|
||||
Resources:
|
||||
- 'i-123456789'
|
||||
Tags:
|
||||
- 'Name=AdminInstance | Environment=Prod'
|
||||
- 'Name=AdminInstance|Environment=Prod'
|
||||
Description: 'Field to describe why the findings associated with these values are muted'
|
||||
```
|
||||
The check `ec2_instance_detailed_monitoring_enabled` will be muted for all accounts and regions and for the resource_id 'i-123456789' with at least one of the tags 'Name=AdminInstance' or 'Environment=Prod'.
|
||||
|
||||
Note: The pipe (|) in the tag pattern provides OR logic via regex alternation. To require BOTH tags, use two separate tag entries:
|
||||
Tags:
|
||||
- 'Name=AdminInstance'
|
||||
- 'Environment=Prod'
|
||||
|
||||
Args:
|
||||
mutelist (dict): Dictionary containing information about muted checks for different accounts.
|
||||
audited_account (str): The account being audited.
|
||||
@@ -408,12 +415,13 @@ class Mutelist(ABC):
|
||||
Args:
|
||||
matched_items (list): List of items to be matched.
|
||||
finding_items (str): String to search for matched items.
|
||||
tag (bool): If True the search will have a different logic due to the tags being ANDed or ORed:
|
||||
- Check of AND logic -> True if all the tags are present in the finding.
|
||||
- Check of OR logic -> True if any of the tags is present in the finding.
|
||||
tag (bool): If True, uses AND logic across multiple tags in the list.
|
||||
- Multiple tags: ALL tags in matched_items must be present in finding_items (AND logic).
|
||||
- Single tag with regex alternation (|): Matches if pattern is found (enables OR within pattern).
|
||||
- For non-tags: Uses OR logic - returns True if ANY item matches.
|
||||
|
||||
Returns:
|
||||
bool: True if any of the matched_items are present in finding_items, otherwise False.
|
||||
bool: For tags - True if ALL patterns match. For non-tags - True if ANY pattern matches.
|
||||
"""
|
||||
try:
|
||||
is_item_matched = False
|
||||
|
||||
@@ -974,18 +974,20 @@ class HTML(Output):
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def get_oci_assessment_summary(provider: Provider) -> str:
|
||||
def get_oraclecloud_assessment_summary(provider: Provider) -> str:
|
||||
"""
|
||||
get_oci_assessment_summary gets the HTML assessment summary for the OCI provider
|
||||
get_oraclecloud_assessment_summary gets the HTML assessment summary for the OracleCloud provider
|
||||
|
||||
Args:
|
||||
provider (Provider): the OCI provider object
|
||||
provider (Provider): the OracleCloud provider object
|
||||
|
||||
Returns:
|
||||
str: HTML assessment summary for the OCI provider
|
||||
str: HTML assessment summary for the OracleCloud provider
|
||||
"""
|
||||
try:
|
||||
profile = getattr(provider.session, "profile", "default")
|
||||
if profile is None:
|
||||
profile = "instance-principal"
|
||||
tenancy_name = getattr(provider.identity, "tenancy_name", "unknown")
|
||||
tenancy_id = getattr(provider.identity, "tenancy_id", "unknown")
|
||||
|
||||
@@ -993,11 +995,11 @@ class HTML(Output):
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
OCI Assessment Summary
|
||||
OracleCloud Assessment Summary
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>OCI Tenancy:</b> {tenancy_name if tenancy_name != "unknown" else tenancy_id}
|
||||
<b>OracleCloud Tenancy:</b> {tenancy_name if tenancy_name != "unknown" else tenancy_id}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1005,7 +1007,7 @@ class HTML(Output):
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
OCI Credentials
|
||||
OracleCloud Credentials
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
|
||||
@@ -1555,6 +1555,7 @@
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-east-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
@@ -1565,6 +1566,8 @@
|
||||
"ap-southeast-3",
|
||||
"ap-southeast-4",
|
||||
"ap-southeast-5",
|
||||
"ap-southeast-6",
|
||||
"ap-southeast-7",
|
||||
"ca-central-1",
|
||||
"ca-west-1",
|
||||
"eu-central-1",
|
||||
@@ -1578,6 +1581,7 @@
|
||||
"il-central-1",
|
||||
"me-central-1",
|
||||
"me-south-1",
|
||||
"mx-central-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
@@ -4584,8 +4588,10 @@
|
||||
"ap-southeast-2",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-south-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-2"
|
||||
@@ -7261,6 +7267,7 @@
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"me-central-1",
|
||||
"me-south-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
@@ -7953,6 +7960,7 @@
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-east-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
@@ -7963,6 +7971,8 @@
|
||||
"ap-southeast-3",
|
||||
"ap-southeast-4",
|
||||
"ap-southeast-5",
|
||||
"ap-southeast-6",
|
||||
"ap-southeast-7",
|
||||
"ca-central-1",
|
||||
"ca-west-1",
|
||||
"eu-central-1",
|
||||
@@ -7976,6 +7986,7 @@
|
||||
"il-central-1",
|
||||
"me-central-1",
|
||||
"me-south-1",
|
||||
"mx-central-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
@@ -9799,6 +9810,20 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"rtbfabric": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
"ap-northeast-1",
|
||||
"ap-southeast-1",
|
||||
"eu-central-1",
|
||||
"eu-west-1",
|
||||
"us-east-1",
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [],
|
||||
"aws-us-gov": []
|
||||
}
|
||||
},
|
||||
"rum": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
|
||||
@@ -427,25 +427,33 @@ def is_policy_public(
|
||||
has_public_access = True
|
||||
|
||||
# Check for cross-service confused deputy
|
||||
if check_cross_service_confused_deputy and (
|
||||
if check_cross_service_confused_deputy:
|
||||
# Check if function can be invoked by other AWS services if check_cross_service_confused_deputy is True
|
||||
(
|
||||
".amazonaws.com" in principal.get("Service", "")
|
||||
or ".amazon.com" in principal.get("Service", "")
|
||||
or "*" in principal.get("Service", "")
|
||||
|
||||
svc = principal.get("Service", [])
|
||||
if isinstance(svc, str):
|
||||
services = [svc]
|
||||
elif isinstance(svc, list):
|
||||
services = [s for s in svc if isinstance(s, str)]
|
||||
else:
|
||||
services = []
|
||||
|
||||
is_cross_service = any(
|
||||
s == "*"
|
||||
or s.endswith(".amazonaws.com")
|
||||
or s.endswith(".amazon.com")
|
||||
for s in services
|
||||
)
|
||||
and (
|
||||
"secretsmanager.amazonaws.com"
|
||||
not in principal.get(
|
||||
"Service", ""
|
||||
) # AWS ensures that resources called by SecretsManager are executed in the same AWS account
|
||||
or "eks.amazonaws.com"
|
||||
not in principal.get(
|
||||
"Service", ""
|
||||
) # AWS ensures that resources called by EKS are executed in the same AWS account
|
||||
|
||||
# AWS ensures that resources called by SecretsManager are executed in the same AWS account
|
||||
# AWS ensures that resources called by EKS are executed in the same AWS account
|
||||
is_exempt = any(
|
||||
s in {"secretsmanager.amazonaws.com", "eks.amazonaws.com"}
|
||||
for s in services
|
||||
)
|
||||
):
|
||||
has_public_access = True
|
||||
|
||||
if is_cross_service and not is_exempt:
|
||||
has_public_access = True
|
||||
|
||||
if has_public_access and (
|
||||
not not_allowed_actions # If not_allowed_actions is empty, the function will not consider the actions in the policy
|
||||
|
||||
+12
-9
@@ -1,26 +1,29 @@
|
||||
{
|
||||
"Provider": "gcp",
|
||||
"CheckID": "cloudstorage_bucket_log_retention_policy_lock",
|
||||
"CheckTitle": "Ensure That Retention Policies on Cloud Storage Buckets Used for Exporting Logs Are Configured Using Bucket Lock",
|
||||
"CheckTitle": "Cloud Storage log bucket has a Retention Policy with Bucket Lock enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "cloudstorage",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "Bucket",
|
||||
"Description": "Enabling retention policies on log buckets will protect logs stored in cloud storage buckets from being overwritten or accidentally deleted.",
|
||||
"Risk": "Sinks can be configured to export logs in storage buckets. It is recommended to configure a data retention policy for these cloud storage buckets and to lock the data retention policy, thus permanently preventing the policy from being reduced or removed. This way, if the system is ever compromised by an attacker or a malicious insider who wants to cover their tracks, the activity logs are definitely preserved for forensics and security investigations.",
|
||||
"ResourceType": "storage.googleapis.com/Bucket",
|
||||
"Description": "**Google Cloud Storage buckets** used as **log sinks** are evaluated to ensure that a **Retention Policy** is configured and **Bucket Lock** is enabled. Enabling Bucket Lock permanently prevents the retention policy from being reduced or removed, protecting logs from modification or deletion.",
|
||||
"Risk": "Log sink buckets without a locked retention policy are at risk of log tampering or accidental deletion. Without Bucket Lock, an attacker or user could remove or shorten the retention policy, compromising the integrity of audit logs required for forensics and compliance investigations.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/CloudStorage/retention-policies-with-bucket-lock.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"CLI": "gcloud storage buckets lock-retention-policy gs://<LOG_BUCKET_NAME>",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/CloudStorage/retention-policies-with-bucket-lock.html",
|
||||
"Terraform": "https://docs.prowler.com/checks/gcp/logging-policies-1/ensure-that-retention-policies-on-log-buckets-are-configured-using-bucket-lock#terraform"
|
||||
"Other": "1) Open Google Cloud Console → Storage → Buckets → <LOG_BUCKET_NAME>\n2) Go to the **Configuration** tab\n3) Under **Retention policy**, ensure a retention duration is set\n4) Click **Lock** to enable Bucket Lock and confirm the operation",
|
||||
"Terraform": "```hcl\nresource \"google_storage_bucket\" \"log_bucket\" {\n name = var.log_bucket_name\n location = var.location\n\n retention_policy {\n retention_period = 31536000 # 365 days in seconds\n is_locked = true\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "It is recommended to set up retention policies and configure Bucket Lock on all storage buckets that are used as log sinks.",
|
||||
"Url": "https://cloud.google.com/storage/docs/using-uniform-bucket-level-access"
|
||||
"Text": "Configure a retention policy and enable Bucket Lock on all Cloud Storage buckets used as log sinks to ensure log integrity and immutability.",
|
||||
"Url": "https://hub.prowler.com/check/cloudstorage_bucket_log_retention_policy_lock"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
|
||||
+10
-3
@@ -6,7 +6,14 @@ from prowler.providers.gcp.services.logging.logging_client import logging_client
|
||||
|
||||
|
||||
class cloudstorage_bucket_log_retention_policy_lock(Check):
|
||||
def execute(self) -> Check_Report_GCP:
|
||||
"""
|
||||
Ensure Log Sink buckets have a Retention Policy with Bucket Lock enabled.
|
||||
|
||||
- PASS: Log sink bucket has a retention policy and is locked.
|
||||
- FAIL: Log sink bucket has no retention policy, or it has one but is not locked.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_GCP]:
|
||||
findings = []
|
||||
# Get Log Sink Buckets
|
||||
log_buckets = []
|
||||
@@ -22,8 +29,8 @@ class cloudstorage_bucket_log_retention_policy_lock(Check):
|
||||
)
|
||||
if bucket.retention_policy:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"Log Sink Bucket {bucket.name} has no Retention Policy but without Bucket Lock."
|
||||
if bucket.retention_policy.get("isLocked", False):
|
||||
report.status_extended = f"Log Sink Bucket {bucket.name} has a Retention Policy but without Bucket Lock."
|
||||
if bucket.retention_policy.is_locked:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Log Sink Bucket {bucket.name} has a Retention Policy with Bucket Lock."
|
||||
findings.append(report)
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"Provider": "gcp",
|
||||
"CheckID": "cloudstorage_bucket_sufficient_retention_period",
|
||||
"CheckTitle": "Cloud Storage bucket has a sufficient Retention Policy period",
|
||||
"CheckType": [],
|
||||
"ServiceName": "cloudstorage",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "storage.googleapis.com/Bucket",
|
||||
"Description": "Cloud Storage bucket has a bucket-level Retention Policy with a retentionPeriod that meets or exceeds the organization-defined minimum, preventing deletion or modification of objects before the required time.",
|
||||
"Risk": "Insufficient or missing retention allows premature deletion or modification of objects, weakening data recovery and compliance with retention requirements.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/CloudStorage/sufficient-retention-period.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "gcloud storage buckets update gs://<BUCKET_NAME> --retention-period=<SECONDS>",
|
||||
"NativeIaC": "",
|
||||
"Other": "1) Console → Storage → Buckets → <BUCKET_NAME>\n2) Tab 'Configuration' → 'Retention policy'\n3) Set the required retention period (e.g., 90 or 365 days) and save\n4) (Optional) Lock the policy if required by compliance",
|
||||
"Terraform": "```hcl\nresource \"google_storage_bucket\" \"example\" {\n name = var.bucket_name\n location = var.location\n\n retention_policy {\n retention_period = 7776000 # 90 days in seconds\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Define and apply a bucket-level Retention Policy that meets your minimum retention requirement (e.g., 90 or 365 days) to enforce data recoverability and compliance.",
|
||||
"Url": "https://hub.prowler.com/check/cloudstorage_bucket_sufficient_retention_period"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"resilience"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_GCP
|
||||
from prowler.providers.gcp.services.cloudstorage.cloudstorage_client import (
|
||||
cloudstorage_client,
|
||||
)
|
||||
|
||||
|
||||
class cloudstorage_bucket_sufficient_retention_period(Check):
|
||||
"""
|
||||
Ensure there is a sufficient bucket-level retention period configured for GCS buckets.
|
||||
|
||||
PASS: retentionPolicy.retentionPeriod >= min threshold (days)
|
||||
FAIL: no retention policy or period < threshold
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_GCP]:
|
||||
findings = []
|
||||
|
||||
min_retention_days = int(
|
||||
getattr(cloudstorage_client, "audit_config", {}).get(
|
||||
"storage_min_retention_days", 90
|
||||
)
|
||||
)
|
||||
|
||||
for bucket in cloudstorage_client.buckets:
|
||||
report = Check_Report_GCP(metadata=self.metadata(), resource=bucket)
|
||||
|
||||
retention_policy = bucket.retention_policy
|
||||
|
||||
if retention_policy is None:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Bucket {bucket.name} does not have a retention policy "
|
||||
f"(minimum required: {min_retention_days} days)."
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
days = retention_policy.retention_period // 86400 # seconds to days
|
||||
|
||||
if days >= min_retention_days:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Bucket {bucket.name} has a sufficient retention policy of {days} days "
|
||||
f"(minimum required: {min_retention_days})."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Bucket {bucket.name} has an insufficient retention policy of {days} days "
|
||||
f"(minimum required: {min_retention_days})."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -56,6 +56,21 @@ class CloudStorage(GCPService):
|
||||
logging_bucket = logging_info.get("logBucket")
|
||||
logging_prefix = logging_info.get("logObjectPrefix")
|
||||
|
||||
retention_policy_raw = bucket.get("retentionPolicy")
|
||||
retention_policy = None
|
||||
if isinstance(retention_policy_raw, dict):
|
||||
rp_seconds = retention_policy_raw.get("retentionPeriod")
|
||||
if rp_seconds:
|
||||
retention_policy = RetentionPolicy(
|
||||
retention_period=int(rp_seconds),
|
||||
is_locked=bool(
|
||||
retention_policy_raw.get("isLocked", False)
|
||||
),
|
||||
effective_time=retention_policy_raw.get(
|
||||
"effectiveTime"
|
||||
),
|
||||
)
|
||||
|
||||
self.buckets.append(
|
||||
Bucket(
|
||||
name=bucket["name"],
|
||||
@@ -65,7 +80,7 @@ class CloudStorage(GCPService):
|
||||
"uniformBucketLevelAccess"
|
||||
]["enabled"],
|
||||
public=public,
|
||||
retention_policy=bucket.get("retentionPolicy"),
|
||||
retention_policy=retention_policy,
|
||||
project_id=project_id,
|
||||
lifecycle_rules=lifecycle_rules,
|
||||
versioning_enabled=versioning_enabled,
|
||||
@@ -84,6 +99,12 @@ class CloudStorage(GCPService):
|
||||
)
|
||||
|
||||
|
||||
class RetentionPolicy(BaseModel):
|
||||
retention_period: int
|
||||
is_locked: bool
|
||||
effective_time: Optional[str] = None
|
||||
|
||||
|
||||
class Bucket(BaseModel):
|
||||
name: str
|
||||
id: str
|
||||
@@ -91,7 +112,7 @@ class Bucket(BaseModel):
|
||||
uniform_bucket_level_access: bool
|
||||
public: bool
|
||||
project_id: str
|
||||
retention_policy: Optional[dict] = None
|
||||
retention_policy: Optional[RetentionPolicy] = None
|
||||
lifecycle_rules: Optional[list[dict]] = None
|
||||
versioning_enabled: Optional[bool] = False
|
||||
soft_delete_enabled: Optional[bool] = False
|
||||
|
||||
+14
-11
@@ -1,32 +1,35 @@
|
||||
{
|
||||
"Provider": "github",
|
||||
"CheckID": "organization_default_repository_permission_strict",
|
||||
"CheckTitle": "Ensure strict base repository permissions are set for the organization",
|
||||
"CheckTitle": "Organization base repository permission is read or none",
|
||||
"CheckType": [],
|
||||
"ServiceName": "organization",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "GitHubOrganization",
|
||||
"Description": "Ensure the organization's base repository permission for members is set to 'read' or 'none' to minimize risk.",
|
||||
"Risk": "If base repository permissions allow 'write' or 'admin' by default, organization members may unintentionally gain excessive privileges across repositories, increasing the risk of unauthorized changes or accidental modifications.",
|
||||
"RelatedUrl": "https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-repository-roles/setting-base-permissions-for-an-organization",
|
||||
"Description": "**GitHub organization** base repository permission for members uses a **strict setting** such as `read` or `none` rather than permissive options like `write` or `admin`. *Applies to members, not outside collaborators.*",
|
||||
"Risk": "**Excessive default permissions** (`write`/`admin`) erode code **integrity** and **availability**.\n\nAny member-or a compromised account-can alter many repos, inject malicious commits, change tags/releases, or delete branches, enabling supply-chain compromise and large-scale disruptions.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-repository-roles/setting-base-permissions-for-an-organization"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
"Other": "1. Sign in to GitHub as an organization owner\n2. Go to the organization > Settings\n3. Under \"Access\" in the sidebar, click \"Member privileges\"\n4. Under \"Base permissions\", select \"Read\" (or \"None\")\n5. Click \"Change default permission\" to confirm",
|
||||
"Terraform": "```hcl\nresource \"github_organization_settings\" \"<example_resource_name>\" {\n default_repository_permission = \"read\" # Critical: sets the org's base repository permission to a strict level (read/none passes)\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Set the organization's base repository permission to 'read' or 'none' for members, unless stricter requirements are needed.",
|
||||
"Url": "https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-repository-roles/setting-base-permissions-for-an-organization"
|
||||
"Text": "Apply **least privilege**: set base permission to `none` or `read`.\n\nGrant higher access explicitly via teams per repo and enforce **separation of duties** with required reviews and **branch protection**. Regularly audit memberships and access to limit blast radius and maintain **defense in depth**.",
|
||||
"Url": "https://hub.prowler.com/check/organization_default_repository_permission_strict"
|
||||
}
|
||||
},
|
||||
"AdditionalURLs": [],
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
|
||||
|
||||
+14
-8
@@ -1,29 +1,35 @@
|
||||
{
|
||||
"Provider": "github",
|
||||
"CheckID": "organization_members_mfa_required",
|
||||
"CheckTitle": "Check if organization members are required to have MFA enabled.",
|
||||
"CheckTitle": "Organization requires members to have two-factor authentication enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "organization",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "critical",
|
||||
"ResourceType": "GitHubOrganization",
|
||||
"Description": "Ensure that all organization members are required to have multi-factor authentication (MFA) enabled. Enforcing MFA for all organization members helps protect the organization's resources and data from unauthorized access and security breaches.",
|
||||
"Risk": "Without Multi-Factor Authentication (MFA), user accounts are vulnerable to unauthorized access if their passwords are compromised. This can lead to unauthorized actions such as data theft, malicious code commits, and repository manipulation, potentially compromising the organization's source code and intellectual property.",
|
||||
"RelatedUrl": "https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-two-factor-authentication-for-your-organization/requiring-two-factor-authentication-in-your-organization",
|
||||
"Description": "GitHub organization settings require all members to use **two-factor authentication** (2FA).\n\nThe evaluation determines whether access to organization resources is conditioned on members having 2FA enabled.",
|
||||
"Risk": "Without enforced **2FA**, stolen or reused passwords enable account takeover, leading to:\n- Loss of code integrity via unauthorized commits\n- Confidential data exposure from repos and secrets\n- Availability impact from settings changes, token revocation, or deletions",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-two-factor-authentication-for-your-organization/preparing-to-require-two-factor-authentication-in-your-organization",
|
||||
"https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-two-factor-authentication-for-your-organization/requiring-two-factor-authentication-in-your-organization"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Other": "1. Sign in to GitHub as an organization owner with 2FA enabled\n2. Go to your organization > Settings\n3. In the left sidebar, click Security > Authentication security\n4. Under Two-factor authentication, select Require two-factor authentication for everyone in your organization\n5. Click Save, then Confirm",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Mandate the use of MFA for all organization members. This significantly enhances account security by adding an additional layer of protection beyond a username and password. MFA ensures that even if a password is compromised, unauthorized access to user accounts and repositories is prevented, safeguarding sensitive data and critical assets.",
|
||||
"Url": "https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-two-factor-authentication-for-your-organization/preparing-to-require-two-factor-authentication-in-your-organization"
|
||||
"Text": "Enforce org-wide **2FA** for all members and collaborators, preferring **secure methods** (passkeys, security keys, authenticator apps, GitHub Mobile) over SMS.\n\nApply **least privilege**, integrate with **SSO**, restrict token scopes, and use **branch protection** for defense-in-depth. Include bots/service accounts and define recovery options.",
|
||||
"Url": "https://hub.prowler.com/check/organization_members_mfa_required"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.powershell.powershell import PowerShellSession
|
||||
from prowler.providers.m365.exceptions.exceptions import (
|
||||
M365CertificateCreationError,
|
||||
M365GraphConnectionError,
|
||||
)
|
||||
from prowler.providers.m365.lib.jwt.jwt_decoder import decode_jwt, decode_msal_token
|
||||
from prowler.providers.m365.exceptions.exceptions import M365CertificateCreationError
|
||||
from prowler.providers.m365.lib.jwt.jwt_decoder import decode_msal_token
|
||||
from prowler.providers.m365.models import M365Credentials, M365IdentityInfo
|
||||
|
||||
|
||||
class M365PowerShell(PowerShellSession):
|
||||
CONNECT_TIMEOUT = 15
|
||||
"""
|
||||
Microsoft 365 specific PowerShell session management implementation.
|
||||
|
||||
@@ -125,9 +120,7 @@ class M365PowerShell(PowerShellSession):
|
||||
'$graphToken = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token" -Method POST -Body $graphtokenBody | Select-Object -ExpandProperty Access_Token'
|
||||
)
|
||||
|
||||
def _execute_connect_command(
|
||||
self, command: str, timeout: Optional[int] = None
|
||||
) -> str:
|
||||
def execute_connect(self, command: str) -> str:
|
||||
"""
|
||||
Execute a PowerShell connect command ensuring empty responses surface as timeouts.
|
||||
|
||||
@@ -138,82 +131,9 @@ class M365PowerShell(PowerShellSession):
|
||||
Returns:
|
||||
str: Command output or 'Timeout' if the command produced no output.
|
||||
"""
|
||||
effective_timeout = timeout or self.CONNECT_TIMEOUT
|
||||
result = self.execute(command, timeout=effective_timeout)
|
||||
return result or "Timeout"
|
||||
|
||||
def test_credentials(self, credentials: M365Credentials) -> bool:
|
||||
"""
|
||||
Test Microsoft 365 credentials by attempting to authenticate against Entra ID.
|
||||
|
||||
Supports testing two authentication methods:
|
||||
1. Application authentication (client_id/client_secret)
|
||||
2. Certificate authentication (certificate_content in base64/client_id)
|
||||
|
||||
Args:
|
||||
credentials (M365Credentials): The credentials object containing
|
||||
authentication information to test.
|
||||
|
||||
Returns:
|
||||
bool: True if credentials are valid and authentication succeeds, False otherwise.
|
||||
"""
|
||||
# Test Certificate Auth
|
||||
if credentials.certificate_content and credentials.client_id:
|
||||
try:
|
||||
logger.info("Testing Microsoft Graph Certificate connection...")
|
||||
self.test_graph_certificate_connection()
|
||||
logger.info("Microsoft Graph Certificate connection successful")
|
||||
teams_connection_successful = self.test_teams_certificate_connection()
|
||||
if not teams_connection_successful:
|
||||
self.test_exchange_certificate_connection()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Microsoft Graph Cer connection failed: {e}")
|
||||
raise M365GraphConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=e,
|
||||
message="Check your Microsoft Application Certificate and ensure the app has proper permissions",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
logger.info("Testing Microsoft Graph Client Secret connection...")
|
||||
self.test_graph_connection()
|
||||
logger.info("Microsoft Graph Client Secret connection successful")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Microsoft Graph Client Secret connection failed: {e}")
|
||||
raise M365GraphConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=e,
|
||||
message="Check your Microsoft Application Client Secret and ensure the app has proper permissions",
|
||||
)
|
||||
|
||||
def test_graph_connection(self) -> bool:
|
||||
"""Test Microsoft Graph API connection and raise exception if it fails."""
|
||||
try:
|
||||
if self.execute("Write-Output $graphToken") == "":
|
||||
raise M365GraphConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
message="Microsoft Graph token is empty or invalid.",
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Microsoft Graph connection failed: {e}")
|
||||
raise M365GraphConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=e,
|
||||
message=f"Failed to connect to Microsoft Graph API: {str(e)}",
|
||||
)
|
||||
|
||||
def test_graph_certificate_connection(self) -> bool:
|
||||
"""Test Microsoft Graph API connection using certificate and raise exception if it fails."""
|
||||
result = self._execute_connect_command(
|
||||
"Connect-Graph -Certificate $certificate -AppId $clientID -TenantId $tenantID"
|
||||
)
|
||||
if "Welcome to Microsoft Graph!" not in result:
|
||||
logger.error(f"Microsoft Graph Certificate connection failed: {result}")
|
||||
return False
|
||||
return True
|
||||
connect_timeout = 15
|
||||
result = self.execute(command, timeout=connect_timeout)
|
||||
return result or "'execute_connect' command timeout reached"
|
||||
|
||||
def test_teams_connection(self) -> bool:
|
||||
"""Test Microsoft Teams API connection and raise exception if it fails."""
|
||||
@@ -221,18 +141,13 @@ class M365PowerShell(PowerShellSession):
|
||||
self.execute(
|
||||
'$teamstokenBody = @{ Grant_Type = "client_credentials"; Scope = "48ac35b8-9aa8-4d74-927d-1f4a14a0b239/.default"; Client_Id = $clientID; Client_Secret = $clientSecret }'
|
||||
)
|
||||
self.execute(
|
||||
result = self.execute(
|
||||
'$teamsToken = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token" -Method POST -Body $teamstokenBody | Select-Object -ExpandProperty Access_Token'
|
||||
)
|
||||
permissions = decode_jwt(self.execute("Write-Output $teamsToken")).get(
|
||||
"roles", []
|
||||
)
|
||||
if "application_access" not in permissions:
|
||||
logger.error(
|
||||
"Microsoft Teams connection failed: Please check your permissions and try again."
|
||||
)
|
||||
if result != "":
|
||||
logger.error(f"Microsoft Teams connection failed: {result}")
|
||||
return False
|
||||
self._execute_connect_command(
|
||||
self.execute_connect(
|
||||
'Connect-MicrosoftTeams -AccessTokens @("$graphToken","$teamsToken")'
|
||||
)
|
||||
return True
|
||||
@@ -244,7 +159,7 @@ class M365PowerShell(PowerShellSession):
|
||||
|
||||
def test_teams_certificate_connection(self) -> bool:
|
||||
"""Test Microsoft Teams API connection using certificate and raise exception if it fails."""
|
||||
result = self._execute_connect_command(
|
||||
result = self.execute_connect(
|
||||
"Connect-MicrosoftTeams -Certificate $certificate -ApplicationId $clientID -TenantId $tenantID"
|
||||
)
|
||||
if self.tenant_identity.identity_id not in result:
|
||||
@@ -268,9 +183,8 @@ class M365PowerShell(PowerShellSession):
|
||||
"Exchange Online connection failed: Please check your permissions and try again."
|
||||
)
|
||||
return False
|
||||
self._execute_connect_command(
|
||||
'Connect-ExchangeOnline -AccessToken $exchangeToken.AccessToken -Organization "$tenantID"',
|
||||
timeout=self.CONNECT_TIMEOUT,
|
||||
self.execute_connect(
|
||||
'Connect-ExchangeOnline -AccessToken $exchangeToken.AccessToken -Organization "$tenantID"'
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -281,9 +195,8 @@ class M365PowerShell(PowerShellSession):
|
||||
|
||||
def test_exchange_certificate_connection(self) -> bool:
|
||||
"""Test Exchange Online API connection using certificate and raise exception if it fails."""
|
||||
result = self._execute_connect_command(
|
||||
"Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain",
|
||||
timeout=self.CONNECT_TIMEOUT,
|
||||
result = self.execute_connect(
|
||||
"Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain"
|
||||
)
|
||||
if "https://aka.ms/exov3-module" not in result:
|
||||
logger.error(f"Exchange Online Certificate connection failed: {result}")
|
||||
@@ -937,7 +850,11 @@ def initialize_m365_powershell_modules():
|
||||
bool: True if all modules were successfully initialized, False otherwise
|
||||
"""
|
||||
|
||||
REQUIRED_MODULES = ["ExchangeOnlineManagement", "MicrosoftTeams", "MSAL.PS"]
|
||||
REQUIRED_MODULES = [
|
||||
"ExchangeOnlineManagement",
|
||||
"MicrosoftTeams",
|
||||
"MSAL.PS",
|
||||
]
|
||||
|
||||
pwsh = PowerShellSession()
|
||||
try:
|
||||
@@ -949,7 +866,7 @@ def initialize_m365_powershell_modules():
|
||||
# Install module if not installed
|
||||
if not result:
|
||||
install_result = pwsh.execute(
|
||||
f'Install-Module "{module}" -Force -AllowClobber -Scope CurrentUser',
|
||||
f"Install-Module {module} -Force -AllowClobber -Scope CurrentUser",
|
||||
timeout=60,
|
||||
)
|
||||
if install_result:
|
||||
|
||||
@@ -444,12 +444,7 @@ class M365Provider(Provider):
|
||||
try:
|
||||
if init_modules:
|
||||
initialize_m365_powershell_modules()
|
||||
if test_session.test_credentials(credentials):
|
||||
return credentials
|
||||
raise M365ConfigCredentialsError(
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided credentials are not valid.",
|
||||
)
|
||||
return credentials
|
||||
finally:
|
||||
test_session.close()
|
||||
|
||||
|
||||
+20
-9
@@ -1,29 +1,40 @@
|
||||
{
|
||||
"Provider": "mongodbatlas",
|
||||
"CheckID": "projects_auditing_enabled",
|
||||
"CheckTitle": "Ensure database auditing is enabled",
|
||||
"CheckTitle": "MongoDB Atlas project has database auditing enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "projects",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "MongoDBAtlasProject",
|
||||
"Description": "Ensure database auditing is enabled to track database operations and security events",
|
||||
"Risk": "Without auditing enabled, security events and database operations are not logged, making it difficult to detect unauthorized access or troubleshoot issues",
|
||||
"Description": "**MongoDB Atlas projects** with **database auditing** capture database operations and administrative events. The evaluation looks for an active audit configuration and, *when present*, notes any configured `audit_filter` that scopes which events are recorded.",
|
||||
"Risk": "Without auditing, critical actions lack traceability, reducing **detectability** and impeding **forensics**. Attackers can mask unauthorized reads/writes and privilege changes, threatening data **confidentiality** and **integrity**, and weakening non-repudiation and incident response.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.mongodb.com/docs/manual/tutorial/configure-auditing/",
|
||||
"https://www.mongodb.com/docs/atlas/architecture/current/auditing/",
|
||||
"https://www.mongodb.com/docs/atlas/architecture/current/auditing-logging/?msockid=0878cc3dfa4e66a707beda0efb5a67b5",
|
||||
"https://www.mongodb.com/docs/atlas/operator/current/ak8so-configure-audit-logs/",
|
||||
"https://www.mongodb.com/docs/manual/core/auditing/",
|
||||
"https://www.mongodb.com/docs/atlas/database-auditing/"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"CLI": "atlas auditing update --projectId <example_resource_id> --enabled",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
"Other": "1. Sign in to MongoDB Atlas and open the target project\n2. In the left sidebar, click Security > Database & Network Access, then click Advanced\n3. Toggle Database Auditing to On\n4. Click Save",
|
||||
"Terraform": "```hcl\nresource \"mongodbatlas_auditing\" \"example\" {\n project_id = \"<example_resource_id>\"\n enabled = true # Critical: turns on project-level database auditing to pass the check\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable database auditing for the MongoDB Atlas project by configuring audit filters and destinations.",
|
||||
"Url": "https://www.mongodb.com/docs/atlas/database-auditing/"
|
||||
"Text": "Enable **auditing** and apply least-privilege filters to capture high-risk events:\n- authentication and session activity\n- DDL/config changes\n- user/role modifications and privilege grants\n\nCentralize logs in a SIEM, enforce retention/immutability with separation of duties, restrict access, and tune `auditAuthorizationSuccess` to balance coverage vs performance.",
|
||||
"Url": "https://hub.prowler.com/check/projects_auditing_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"logging",
|
||||
"forensics-ready"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+14
-9
@@ -1,29 +1,34 @@
|
||||
{
|
||||
"Provider": "mongodbatlas",
|
||||
"CheckID": "projects_network_access_list_exposed_to_internet",
|
||||
"CheckTitle": "Ensure MongoDB Atlas project network access list is not exposed to the internet",
|
||||
"CheckTitle": "MongoDB Atlas project network access list has entries and excludes 0.0.0.0/0, ::/0, 0.0.0.0, and ::",
|
||||
"CheckType": [],
|
||||
"ServiceName": "projects",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "MongoDBAtlasProject",
|
||||
"Description": "Ensure that MongoDB Atlas projects have properly configured network access lists that don't allow unrestricted access from anywhere on the internet. Network access lists should be configured to allow access only from specific IP addresses, CIDR blocks, or AWS security groups to minimize the attack surface.",
|
||||
"Risk": "If a MongoDB Atlas project has network access entries that allow unrestricted access (0.0.0.0/0 or ::/0), it exposes the database to potential attacks from anywhere on the internet. This significantly increases the risk of unauthorized access, data breaches, and malicious activities.",
|
||||
"RelatedUrl": "https://docs.atlas.mongodb.com/security/ip-access-list/",
|
||||
"Description": "**MongoDB Atlas project network access list** configuration is evaluated for entries that allow access from anywhere (`0.0.0.0/0`, `::/0`, `0.0.0.0`, `::`) or for missing access lists, instead of restricting connections to specific IPs or CIDRs.",
|
||||
"Risk": "Internet-wide access enables scanning, brute force, and credential stuffing against database endpoints. A successful compromise can cause data exfiltration (**confidentiality**), unauthorized writes or drops (**integrity**), and service disruption or lockout (**availability**).",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.atlas.mongodb.com/security/ip-access-list/"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
"Other": "1. In MongoDB Atlas, open your project and go to Security > Database & Network Access > IP Access List\n2. Delete any entries equal to 0.0.0.0/0, ::/0, 0.0.0.0, or ::\n3. If the list becomes empty, click Add IP Address and add a specific IP/CIDR or an AWS Security Group (for a peered VPC)\n4. Click Save",
|
||||
"Terraform": "```hcl\nresource \"mongodbatlas_project_ip_access_list\" \"<example_resource_name>\" {\n project_id = \"<example_resource_id>\"\n cidr_block = \"<ALLOWED_CIDR>\" # Critical: add a restricted CIDR (not 0.0.0.0/0 or ::/0) to ensure the list isn't empty and not open to the world\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Configure network access lists to allow access only from specific IP addresses, CIDR blocks, or AWS security groups. Remove any entries that allow unrestricted access (0.0.0.0/0 or ::/0) and replace them with more restrictive rules based on your application's requirements.",
|
||||
"Url": "https://docs.atlas.mongodb.com/security/ip-access-list/"
|
||||
"Text": "Apply **least privilege**: permit only required IPs/CIDRs or approved security groups; avoid `0.0.0.0/0` and `::/0`. Prefer **private connectivity** (VPC peering or private endpoints) over public access. Use temporary entries for short-lived admin needs and review lists regularly.",
|
||||
"Url": "https://hub.prowler.com/check/projects_network_access_list_exposed_to_internet"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+52
@@ -338,3 +338,55 @@ class Test_iam_role_cross_service_confused_deputy_prevention:
|
||||
)
|
||||
assert result[0].resource_id == "test"
|
||||
assert result[0].resource_arn == response["Role"]["Arn"]
|
||||
|
||||
@mock_aws
|
||||
def test_iam_service_role_with_cross_service_confused_deputy_prevention_service_list(
|
||||
self,
|
||||
):
|
||||
iam_client = client("iam", region_name=AWS_REGION)
|
||||
policy_document = {
|
||||
"Version": "2008-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Service": ["scheduler.amazonaws.com", "events.amazonaws.com"]
|
||||
},
|
||||
"Action": "sts:AssumeRole",
|
||||
}
|
||||
],
|
||||
}
|
||||
response = iam_client.create_role(
|
||||
RoleName="test",
|
||||
AssumeRolePolicyDocument=dumps(policy_document),
|
||||
)
|
||||
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
aws_provider.identity.account = AWS_ACCOUNT_ID
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.iam.iam_role_cross_service_confused_deputy_prevention.iam_role_cross_service_confused_deputy_prevention.iam_client",
|
||||
new=IAM(aws_provider),
|
||||
),
|
||||
):
|
||||
# Test Check
|
||||
from prowler.providers.aws.services.iam.iam_role_cross_service_confused_deputy_prevention.iam_role_cross_service_confused_deputy_prevention import (
|
||||
iam_role_cross_service_confused_deputy_prevention,
|
||||
)
|
||||
|
||||
check = iam_role_cross_service_confused_deputy_prevention()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "IAM Service Role test does not prevent against a cross-service confused deputy attack."
|
||||
)
|
||||
assert result[0].resource_id == "test"
|
||||
assert result[0].resource_arn == response["Role"]["Arn"]
|
||||
|
||||
@@ -90,6 +90,7 @@ class TestGCPProvider:
|
||||
assert gcp_provider.audit_config == {
|
||||
"shodan_api_key": None,
|
||||
"max_unused_account_days": 180,
|
||||
"storage_min_retention_days": 90,
|
||||
}
|
||||
|
||||
@freeze_time(datetime.today())
|
||||
|
||||
+13
-3
@@ -31,6 +31,7 @@ class TestCloudStorageBucketLogRetentionPolicyLock:
|
||||
)
|
||||
from prowler.providers.gcp.services.cloudstorage.cloudstorage_service import (
|
||||
Bucket,
|
||||
RetentionPolicy,
|
||||
)
|
||||
from prowler.providers.gcp.services.logging.logging_service import Sink
|
||||
|
||||
@@ -53,7 +54,11 @@ class TestCloudStorageBucketLogRetentionPolicyLock:
|
||||
region=GCP_US_CENTER1_LOCATION,
|
||||
uniform_bucket_level_access=True,
|
||||
public=True,
|
||||
retention_policy={"isLocked": True},
|
||||
retention_policy=RetentionPolicy(
|
||||
retention_period=31536000,
|
||||
is_locked=True,
|
||||
effective_time=None,
|
||||
),
|
||||
project_id=GCP_PROJECT_ID,
|
||||
)
|
||||
]
|
||||
@@ -95,6 +100,7 @@ class TestCloudStorageBucketLogRetentionPolicyLock:
|
||||
)
|
||||
from prowler.providers.gcp.services.cloudstorage.cloudstorage_service import (
|
||||
Bucket,
|
||||
RetentionPolicy,
|
||||
)
|
||||
from prowler.providers.gcp.services.logging.logging_service import Sink
|
||||
|
||||
@@ -117,7 +123,11 @@ class TestCloudStorageBucketLogRetentionPolicyLock:
|
||||
region=GCP_US_CENTER1_LOCATION,
|
||||
uniform_bucket_level_access=True,
|
||||
public=True,
|
||||
retention_policy={"isLocked": False},
|
||||
retention_policy=RetentionPolicy(
|
||||
retention_period=31536000,
|
||||
is_locked=False,
|
||||
effective_time=None,
|
||||
),
|
||||
project_id=GCP_PROJECT_ID,
|
||||
)
|
||||
]
|
||||
@@ -129,7 +139,7 @@ class TestCloudStorageBucketLogRetentionPolicyLock:
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"Log Sink Bucket {cloudstorage_client.buckets[0].name} has no Retention Policy but without Bucket Lock."
|
||||
== f"Log Sink Bucket {cloudstorage_client.buckets[0].name} has a Retention Policy but without Bucket Lock."
|
||||
)
|
||||
assert result[0].resource_id == "example-bucket"
|
||||
assert result[0].resource_name == "example-bucket"
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
from unittest import mock
|
||||
|
||||
from tests.providers.gcp.gcp_fixtures import (
|
||||
GCP_PROJECT_ID,
|
||||
GCP_US_CENTER1_LOCATION,
|
||||
set_mocked_gcp_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestCloudStorageBucketSufficientRetentionPeriod:
|
||||
def test_no_buckets(self):
|
||||
cloudstorage_client = mock.MagicMock()
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_gcp_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.gcp.services.cloudstorage.cloudstorage_bucket_sufficient_retention_period.cloudstorage_bucket_sufficient_retention_period.cloudstorage_client",
|
||||
new=cloudstorage_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.gcp.services.cloudstorage.cloudstorage_bucket_sufficient_retention_period.cloudstorage_bucket_sufficient_retention_period import (
|
||||
cloudstorage_bucket_sufficient_retention_period,
|
||||
)
|
||||
|
||||
cloudstorage_client.project_ids = [GCP_PROJECT_ID]
|
||||
cloudstorage_client.region = GCP_US_CENTER1_LOCATION
|
||||
cloudstorage_client.buckets = []
|
||||
cloudstorage_client.audit_config = {"storage_min_retention_days": 90}
|
||||
|
||||
check = cloudstorage_bucket_sufficient_retention_period()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
def test_bucket_without_retention_policy(self):
|
||||
cloudstorage_client = mock.MagicMock()
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_gcp_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.gcp.services.cloudstorage.cloudstorage_bucket_sufficient_retention_period.cloudstorage_bucket_sufficient_retention_period.cloudstorage_client",
|
||||
new=cloudstorage_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.gcp.services.cloudstorage.cloudstorage_bucket_sufficient_retention_period.cloudstorage_bucket_sufficient_retention_period import (
|
||||
cloudstorage_bucket_sufficient_retention_period,
|
||||
)
|
||||
from prowler.providers.gcp.services.cloudstorage.cloudstorage_service import (
|
||||
Bucket,
|
||||
)
|
||||
|
||||
cloudstorage_client.project_ids = [GCP_PROJECT_ID]
|
||||
cloudstorage_client.region = GCP_US_CENTER1_LOCATION
|
||||
cloudstorage_client.audit_config = {"storage_min_retention_days": 90}
|
||||
|
||||
cloudstorage_client.buckets = [
|
||||
Bucket(
|
||||
name="no-retention-policy",
|
||||
id="no-retention-policy",
|
||||
region=GCP_US_CENTER1_LOCATION,
|
||||
uniform_bucket_level_access=True,
|
||||
public=False,
|
||||
retention_policy=None,
|
||||
project_id=GCP_PROJECT_ID,
|
||||
lifecycle_rules=[],
|
||||
versioning_enabled=True,
|
||||
)
|
||||
]
|
||||
|
||||
check = cloudstorage_bucket_sufficient_retention_period()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "Bucket no-retention-policy does not have a retention policy (minimum required: 90 days)."
|
||||
)
|
||||
assert result[0].resource_id == "no-retention-policy"
|
||||
assert result[0].resource_name == "no-retention-policy"
|
||||
assert result[0].location == GCP_US_CENTER1_LOCATION
|
||||
assert result[0].project_id == GCP_PROJECT_ID
|
||||
|
||||
def test_bucket_with_sufficient_retention_policy(self):
|
||||
cloudstorage_client = mock.MagicMock()
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_gcp_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.gcp.services.cloudstorage.cloudstorage_bucket_sufficient_retention_period.cloudstorage_bucket_sufficient_retention_period.cloudstorage_client",
|
||||
new=cloudstorage_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.gcp.services.cloudstorage.cloudstorage_bucket_sufficient_retention_period.cloudstorage_bucket_sufficient_retention_period import (
|
||||
cloudstorage_bucket_sufficient_retention_period,
|
||||
)
|
||||
from prowler.providers.gcp.services.cloudstorage.cloudstorage_service import (
|
||||
Bucket,
|
||||
RetentionPolicy,
|
||||
)
|
||||
|
||||
cloudstorage_client.project_ids = [GCP_PROJECT_ID]
|
||||
cloudstorage_client.region = GCP_US_CENTER1_LOCATION
|
||||
cloudstorage_client.audit_config = {"storage_min_retention_days": 90}
|
||||
|
||||
cloudstorage_client.buckets = [
|
||||
Bucket(
|
||||
name="sufficient-retention-policy",
|
||||
id="sufficient-retention-policy",
|
||||
region=GCP_US_CENTER1_LOCATION,
|
||||
uniform_bucket_level_access=True,
|
||||
public=False,
|
||||
retention_policy=RetentionPolicy(
|
||||
retention_period=12096000, # 140 days
|
||||
is_locked=False,
|
||||
effective_time=None,
|
||||
),
|
||||
project_id=GCP_PROJECT_ID,
|
||||
lifecycle_rules=[],
|
||||
versioning_enabled=True,
|
||||
)
|
||||
]
|
||||
|
||||
check = cloudstorage_bucket_sufficient_retention_period()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "Bucket sufficient-retention-policy has a sufficient retention policy of 140 days (minimum required: 90)."
|
||||
)
|
||||
assert result[0].resource_id == "sufficient-retention-policy"
|
||||
assert result[0].resource_name == "sufficient-retention-policy"
|
||||
assert result[0].location == GCP_US_CENTER1_LOCATION
|
||||
assert result[0].project_id == GCP_PROJECT_ID
|
||||
|
||||
def test_bucket_with_insufficient_retention_policy(self):
|
||||
cloudstorage_client = mock.MagicMock()
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_gcp_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.gcp.services.cloudstorage.cloudstorage_bucket_sufficient_retention_period.cloudstorage_bucket_sufficient_retention_period.cloudstorage_client",
|
||||
new=cloudstorage_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.gcp.services.cloudstorage.cloudstorage_bucket_sufficient_retention_period.cloudstorage_bucket_sufficient_retention_period import (
|
||||
cloudstorage_bucket_sufficient_retention_period,
|
||||
)
|
||||
from prowler.providers.gcp.services.cloudstorage.cloudstorage_service import (
|
||||
Bucket,
|
||||
RetentionPolicy,
|
||||
)
|
||||
|
||||
cloudstorage_client.project_ids = [GCP_PROJECT_ID]
|
||||
cloudstorage_client.region = GCP_US_CENTER1_LOCATION
|
||||
cloudstorage_client.audit_config = {"storage_min_retention_days": 90}
|
||||
|
||||
cloudstorage_client.buckets = [
|
||||
Bucket(
|
||||
name="insufficient-retention-policy",
|
||||
id="insufficient-retention-policy",
|
||||
region=GCP_US_CENTER1_LOCATION,
|
||||
uniform_bucket_level_access=True,
|
||||
public=False,
|
||||
retention_policy=RetentionPolicy(
|
||||
retention_period=604800, # 7 days
|
||||
is_locked=False,
|
||||
effective_time=None,
|
||||
),
|
||||
project_id=GCP_PROJECT_ID,
|
||||
lifecycle_rules=[],
|
||||
versioning_enabled=True,
|
||||
)
|
||||
]
|
||||
|
||||
check = cloudstorage_bucket_sufficient_retention_period()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "Bucket insufficient-retention-policy has an insufficient retention policy of 7 days (minimum required: 90)."
|
||||
)
|
||||
assert result[0].resource_id == "insufficient-retention-policy"
|
||||
assert result[0].resource_name == "insufficient-retention-policy"
|
||||
assert result[0].location == GCP_US_CENTER1_LOCATION
|
||||
assert result[0].project_id == GCP_PROJECT_ID
|
||||
@@ -2,6 +2,7 @@ from unittest.mock import patch
|
||||
|
||||
from prowler.providers.gcp.services.cloudstorage.cloudstorage_service import (
|
||||
CloudStorage,
|
||||
RetentionPolicy,
|
||||
)
|
||||
from tests.providers.gcp.gcp_fixtures import (
|
||||
GCP_PROJECT_ID,
|
||||
@@ -35,9 +36,17 @@ class TestCloudStorageService:
|
||||
assert cloudstorage_client.buckets[0].region == "US"
|
||||
assert cloudstorage_client.buckets[0].uniform_bucket_level_access
|
||||
assert cloudstorage_client.buckets[0].public
|
||||
assert cloudstorage_client.buckets[0].retention_policy == {
|
||||
"retentionPeriod": 10
|
||||
}
|
||||
|
||||
assert isinstance(
|
||||
cloudstorage_client.buckets[0].retention_policy, RetentionPolicy
|
||||
)
|
||||
assert (
|
||||
cloudstorage_client.buckets[0].retention_policy.retention_period == 10
|
||||
)
|
||||
assert cloudstorage_client.buckets[0].retention_policy.is_locked is False
|
||||
assert (
|
||||
cloudstorage_client.buckets[0].retention_policy.effective_time is None
|
||||
)
|
||||
assert cloudstorage_client.buckets[0].project_id == GCP_PROJECT_ID
|
||||
|
||||
assert cloudstorage_client.buckets[1].name == "bucket2"
|
||||
|
||||
@@ -4,10 +4,7 @@ from unittest.mock import MagicMock, call, patch
|
||||
import pytest
|
||||
|
||||
from prowler.lib.powershell.powershell import PowerShellSession
|
||||
from prowler.providers.m365.exceptions.exceptions import (
|
||||
M365CertificateCreationError,
|
||||
M365GraphConnectionError,
|
||||
)
|
||||
from prowler.providers.m365.exceptions.exceptions import M365CertificateCreationError
|
||||
from prowler.providers.m365.lib.powershell.m365_powershell import M365PowerShell
|
||||
from prowler.providers.m365.models import M365Credentials, M365IdentityInfo
|
||||
|
||||
@@ -115,31 +112,6 @@ class Testm365PowerShell:
|
||||
)
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_credentials_application_auth(self, mock_popen):
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Service Principal",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="contoso.onmicrosoft.com",
|
||||
tenant_domains=["contoso.onmicrosoft.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
session.execute = MagicMock(return_value="sometoken")
|
||||
|
||||
result = session.test_credentials(credentials)
|
||||
assert result is True
|
||||
session.execute.assert_any_call("Write-Output $graphToken")
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_remove_ansi(self, mock_popen):
|
||||
credentials = M365Credentials(
|
||||
@@ -339,13 +311,14 @@ class Testm365PowerShell:
|
||||
# Verify successful initialization
|
||||
assert result is True
|
||||
# Verify that execute was called for each module
|
||||
assert mock_execute_obj.call_count == 9 # 3 modules * 3 commands each
|
||||
assert (
|
||||
mock_execute_obj.call_count == 3 * 3
|
||||
) # number of modules * 3 commands each
|
||||
# Verify success messages were logged
|
||||
mock_info.assert_any_call(
|
||||
"Successfully installed module ExchangeOnlineManagement"
|
||||
)
|
||||
mock_info.assert_any_call("Successfully installed module MicrosoftTeams")
|
||||
mock_info.assert_any_call("Successfully installed module MSAL.PS")
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_initialize_m365_powershell_modules_failure(self, mock_popen):
|
||||
@@ -457,98 +430,7 @@ class Testm365PowerShell:
|
||||
mock_info.assert_not_called()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_graph_connection_success(self, mock_popen):
|
||||
"""Test test_graph_connection when token is valid"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="example.com",
|
||||
tenant_domains=["example.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock execute to return a valid token
|
||||
session.execute = MagicMock(return_value="valid_token")
|
||||
|
||||
result = session.test_graph_connection()
|
||||
|
||||
assert result is True
|
||||
session.execute.assert_called_once_with("Write-Output $graphToken")
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_graph_connection_empty_token(self, mock_popen):
|
||||
"""Test test_graph_connection when token is empty"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="example.com",
|
||||
tenant_domains=["example.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock execute to return empty token
|
||||
session.execute = MagicMock(return_value="")
|
||||
|
||||
with pytest.raises(M365GraphConnectionError) as exc_info:
|
||||
session.test_graph_connection()
|
||||
|
||||
assert "Microsoft Graph token is empty or invalid" in str(exc_info.value)
|
||||
session.execute.assert_called_once_with("Write-Output $graphToken")
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_graph_connection_exception(self, mock_popen):
|
||||
"""Test test_graph_connection when an exception occurs"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
)
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="example.com",
|
||||
tenant_domains=["example.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock execute to raise an exception
|
||||
session.execute = MagicMock(side_effect=Exception("PowerShell error"))
|
||||
|
||||
with pytest.raises(M365GraphConnectionError) as exc_info:
|
||||
session.test_graph_connection()
|
||||
|
||||
assert "Failed to connect to Microsoft Graph API: PowerShell error" in str(
|
||||
exc_info.value
|
||||
)
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
@patch("prowler.providers.m365.lib.powershell.m365_powershell.decode_jwt")
|
||||
def test_test_teams_connection_success(self, mock_decode_jwt, mock_popen):
|
||||
def test_test_teams_connection_success(self, mock_popen):
|
||||
"""Test test_teams_connection when token is valid"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
@@ -567,30 +449,20 @@ class Testm365PowerShell:
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock execute to return valid responses
|
||||
def mock_execute(command, *args, **kwargs):
|
||||
if "Write-Output $teamsToken" in command:
|
||||
return "valid_teams_token"
|
||||
return None
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute)
|
||||
# Mock JWT decode to return proper permissions
|
||||
mock_decode_jwt.return_value = {"roles": ["application_access"]}
|
||||
session.execute = MagicMock(side_effect=[None, ""])
|
||||
session.execute_connect = MagicMock(return_value="")
|
||||
|
||||
result = session.test_teams_connection()
|
||||
|
||||
assert result is True
|
||||
# Verify all expected PowerShell commands were called
|
||||
# 4 calls: teamstokenBody, teamsToken, Write-Output $teamsToken, Connect-MicrosoftTeams
|
||||
assert session.execute.call_count == 4
|
||||
mock_decode_jwt.assert_called_once_with("valid_teams_token")
|
||||
assert session.execute.call_count == 2
|
||||
session.execute_connect.assert_called_once_with(
|
||||
'Connect-MicrosoftTeams -AccessTokens @("$graphToken","$teamsToken")'
|
||||
)
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
@patch("prowler.providers.m365.lib.powershell.m365_powershell.decode_jwt")
|
||||
def test_test_teams_connection_missing_permissions(
|
||||
self, mock_decode_jwt, mock_popen
|
||||
):
|
||||
def test_test_teams_connection_missing_permissions(self, mock_popen):
|
||||
"""Test test_teams_connection when token lacks required permissions"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
@@ -609,23 +481,17 @@ class Testm365PowerShell:
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock execute to return valid token but decode returns no permissions
|
||||
def mock_execute(command, *args, **kwargs):
|
||||
if "Write-Output $teamsToken" in command:
|
||||
return "valid_teams_token"
|
||||
return None
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute)
|
||||
# Mock JWT decode to return missing required permission
|
||||
mock_decode_jwt.return_value = {"roles": ["other_permission"]}
|
||||
session.execute = MagicMock(side_effect=[None, "Permission denied"])
|
||||
session.execute_connect = MagicMock()
|
||||
|
||||
with patch("prowler.lib.logger.logger.error") as mock_error:
|
||||
result = session.test_teams_connection()
|
||||
|
||||
assert result is False
|
||||
mock_error.assert_called_once_with(
|
||||
"Microsoft Teams connection failed: Please check your permissions and try again."
|
||||
"Microsoft Teams connection failed: Permission denied"
|
||||
)
|
||||
session.execute_connect.assert_not_called()
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
@@ -688,15 +554,17 @@ class Testm365PowerShell:
|
||||
return None
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute)
|
||||
session.execute_connect = MagicMock(return_value=None)
|
||||
# Mock MSAL token decode to return proper permissions
|
||||
mock_decode_msal_token.return_value = {"roles": ["Exchange.ManageAsApp"]}
|
||||
|
||||
result = session.test_exchange_connection()
|
||||
|
||||
assert result is True
|
||||
# Verify all expected PowerShell commands were called
|
||||
# 4 calls: SecureSecret, exchangeToken, Write-Output $exchangeToken, Connect-ExchangeOnline
|
||||
assert session.execute.call_count == 4
|
||||
assert session.execute.call_count == 3
|
||||
session.execute_connect.assert_called_once_with(
|
||||
'Connect-ExchangeOnline -AccessToken $exchangeToken.AccessToken -Organization "$tenantID"'
|
||||
)
|
||||
mock_decode_msal_token.assert_called_once_with("valid_exchange_token")
|
||||
session.close()
|
||||
|
||||
@@ -730,6 +598,7 @@ class Testm365PowerShell:
|
||||
return None
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute)
|
||||
session.execute_connect = MagicMock(return_value=None)
|
||||
# Mock MSAL token decode to return missing required permission
|
||||
mock_decode_msal_token.return_value = {"roles": ["other_permission"]}
|
||||
|
||||
@@ -737,6 +606,7 @@ class Testm365PowerShell:
|
||||
result = session.test_exchange_connection()
|
||||
|
||||
assert result is False
|
||||
session.execute_connect.assert_not_called()
|
||||
mock_error.assert_called_once_with(
|
||||
"Exchange Online connection failed: Please check your permissions and try again."
|
||||
)
|
||||
@@ -781,7 +651,7 @@ class Testm365PowerShell:
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
credentials = M365Credentials()
|
||||
identity = M365IdentityInfo()
|
||||
identity = M365IdentityInfo(identity_id="expected-id")
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Test with clean base64 content
|
||||
@@ -924,20 +794,18 @@ class Testm365PowerShell:
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
credentials = M365Credentials()
|
||||
identity = M365IdentityInfo()
|
||||
identity = M365IdentityInfo(identity_id="expected-id")
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock successful Exchange connection
|
||||
session.execute = MagicMock(
|
||||
session.execute_connect = MagicMock(
|
||||
return_value="Connected successfully https://aka.ms/exov3-module"
|
||||
)
|
||||
|
||||
result = session.test_exchange_certificate_connection()
|
||||
assert result is True
|
||||
|
||||
session.execute.assert_called_once_with(
|
||||
"Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain",
|
||||
timeout=M365PowerShell.CONNECT_TIMEOUT,
|
||||
session.execute_connect.assert_called_once_with(
|
||||
"Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain"
|
||||
)
|
||||
|
||||
session.close()
|
||||
@@ -949,20 +817,23 @@ class Testm365PowerShell:
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
credentials = M365Credentials()
|
||||
identity = M365IdentityInfo()
|
||||
identity = M365IdentityInfo(identity_id="expected-id")
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock failed Exchange connection
|
||||
session.execute = MagicMock(
|
||||
session.execute_connect = MagicMock(
|
||||
return_value="Connection failed: Authentication error"
|
||||
)
|
||||
|
||||
result = session.test_exchange_certificate_connection()
|
||||
with patch("prowler.lib.logger.logger.error") as mock_error:
|
||||
result = session.test_exchange_certificate_connection()
|
||||
|
||||
assert result is False
|
||||
|
||||
session.execute.assert_called_once_with(
|
||||
"Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain",
|
||||
timeout=M365PowerShell.CONNECT_TIMEOUT,
|
||||
session.execute_connect.assert_called_once_with(
|
||||
"Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain"
|
||||
)
|
||||
mock_error.assert_called_once_with(
|
||||
"Exchange Online Certificate connection failed: Connection failed: Authentication error"
|
||||
)
|
||||
|
||||
session.close()
|
||||
@@ -981,20 +852,15 @@ class Testm365PowerShell:
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock successful Teams connection - the method returns bool
|
||||
def mock_execute_side_effect(command, *_, **__):
|
||||
if "Connect-MicrosoftTeams" in command:
|
||||
# Return result that contains the identity_id for success
|
||||
return "Connected successfully test_identity_id"
|
||||
return ""
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute_side_effect)
|
||||
session.execute_connect = MagicMock(
|
||||
return_value="Connected successfully test_identity_id"
|
||||
)
|
||||
|
||||
result = session.test_teams_certificate_connection()
|
||||
assert result is True
|
||||
|
||||
session.execute.assert_called_once_with(
|
||||
"Connect-MicrosoftTeams -Certificate $certificate -ApplicationId $clientID -TenantId $tenantID",
|
||||
timeout=M365PowerShell.CONNECT_TIMEOUT,
|
||||
session.execute_connect.assert_called_once_with(
|
||||
"Connect-MicrosoftTeams -Certificate $certificate -ApplicationId $clientID -TenantId $tenantID"
|
||||
)
|
||||
|
||||
session.close()
|
||||
@@ -1006,52 +872,21 @@ class Testm365PowerShell:
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
credentials = M365Credentials()
|
||||
identity = M365IdentityInfo()
|
||||
identity = M365IdentityInfo(identity_id="expected-id")
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock failed Teams connection
|
||||
def mock_execute_side_effect(command, **kwargs):
|
||||
if "Connect-MicrosoftTeams" in command:
|
||||
raise Exception("Connection failed: Authentication error")
|
||||
return ""
|
||||
session.execute_connect = MagicMock(return_value="Connection failed")
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute_side_effect)
|
||||
with patch("prowler.lib.logger.logger.error") as mock_error:
|
||||
result = session.test_teams_certificate_connection()
|
||||
|
||||
# Should raise exception on connection failure
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
session.test_teams_certificate_connection()
|
||||
|
||||
assert "Connection failed: Authentication error" in str(exc_info.value)
|
||||
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_credentials_certificate_auth_success(self, mock_popen):
|
||||
"""Test test_credentials method with certificate authentication - successful"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
|
||||
credentials = M365Credentials(
|
||||
client_id="test_client_id", certificate_content=certificate_content
|
||||
assert result is False
|
||||
session.execute_connect.assert_called_once_with(
|
||||
"Connect-MicrosoftTeams -Certificate $certificate -ApplicationId $clientID -TenantId $tenantID"
|
||||
)
|
||||
mock_error.assert_called_once_with(
|
||||
"Microsoft Teams Certificate connection failed: Connection failed"
|
||||
)
|
||||
identity = M365IdentityInfo()
|
||||
|
||||
# Create session without calling init_credential
|
||||
with patch.object(M365PowerShell, "init_credential"):
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock successful certificate connections
|
||||
# Note: The actual implementation uses "or" so if teams succeeds, exchange won't be called
|
||||
session.test_teams_certificate_connection = MagicMock(return_value=True)
|
||||
session.test_exchange_certificate_connection = MagicMock(return_value=True)
|
||||
|
||||
result = session.test_credentials(credentials)
|
||||
assert result is True
|
||||
|
||||
session.test_teams_certificate_connection.assert_called_once()
|
||||
# Exchange connection should NOT be called if teams connection succeeds (due to "or" logic)
|
||||
session.test_exchange_certificate_connection.assert_not_called()
|
||||
|
||||
session.close()
|
||||
|
||||
@@ -1075,9 +910,6 @@ class Testm365PowerShell:
|
||||
session.test_teams_certificate_connection = MagicMock(return_value=False)
|
||||
session.test_exchange_certificate_connection = MagicMock(return_value=False)
|
||||
|
||||
result = session.test_credentials(credentials)
|
||||
assert result is True # Method always returns True after the try block
|
||||
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
@@ -1305,94 +1137,3 @@ class Testm365PowerShell:
|
||||
assert any('$tenantDomain = "contoso.com"' in cmd for cmd in executed_commands)
|
||||
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_credentials_certificate_auth_with_or_logic(self, mock_popen):
|
||||
"""Test test_credentials method with certificate auth using OR logic between Teams and Exchange"""
|
||||
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
mock_process.returncode = 0
|
||||
|
||||
# Create session with non-certificate credentials first
|
||||
session = M365PowerShell(
|
||||
M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
tenant_domains=["contoso.com"],
|
||||
),
|
||||
M365IdentityInfo(
|
||||
tenant_id="test_tenant_id",
|
||||
tenant_domain="contoso.com",
|
||||
tenant_domains=["contoso.com"],
|
||||
identity_id="test_identity_id",
|
||||
identity_type="Service Principal with Certificate",
|
||||
),
|
||||
)
|
||||
|
||||
# Mock that Teams connection fails but Exchange succeeds
|
||||
session.test_teams_certificate_connection = MagicMock(return_value=False)
|
||||
session.test_exchange_certificate_connection = MagicMock(return_value=True)
|
||||
|
||||
result = session.test_credentials(
|
||||
M365Credentials(
|
||||
client_id="test_client_id",
|
||||
tenant_id="test_tenant_id",
|
||||
certificate_content=certificate_content,
|
||||
tenant_domains=["contoso.com"],
|
||||
)
|
||||
)
|
||||
|
||||
assert result is True
|
||||
session.test_teams_certificate_connection.assert_called_once()
|
||||
session.test_exchange_certificate_connection.assert_called_once()
|
||||
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_credentials_certificate_auth_both_fail(self, mock_popen):
|
||||
"""Test test_credentials method with certificate auth when both Teams and Exchange fail"""
|
||||
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
mock_process.returncode = 0
|
||||
|
||||
# Create session with non-certificate credentials first
|
||||
session = M365PowerShell(
|
||||
M365Credentials(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_secret",
|
||||
tenant_id="test_tenant_id",
|
||||
tenant_domains=["contoso.com"],
|
||||
),
|
||||
M365IdentityInfo(
|
||||
tenant_id="test_tenant_id",
|
||||
tenant_domain="contoso.com",
|
||||
tenant_domains=["contoso.com"],
|
||||
identity_id="test_identity_id",
|
||||
identity_type="Service Principal with Certificate",
|
||||
),
|
||||
)
|
||||
|
||||
# Mock that both connections fail
|
||||
session.test_teams_certificate_connection = MagicMock(return_value=False)
|
||||
session.test_exchange_certificate_connection = MagicMock(return_value=False)
|
||||
|
||||
# Even when both fail, the method should return True (this is the intended logic)
|
||||
result = session.test_credentials(
|
||||
M365Credentials(
|
||||
client_id="test_client_id",
|
||||
tenant_id="test_tenant_id",
|
||||
certificate_content=certificate_content,
|
||||
tenant_domains=["contoso.com"],
|
||||
)
|
||||
)
|
||||
|
||||
assert result is True
|
||||
session.test_teams_certificate_connection.assert_called_once()
|
||||
session.test_exchange_certificate_connection.assert_called_once()
|
||||
|
||||
session.close()
|
||||
|
||||
@@ -932,37 +932,6 @@ class TestM365Provider:
|
||||
assert result.certificate_content == certificate_content
|
||||
assert identity.identity_type == "Service Principal with Certificate"
|
||||
|
||||
def test_setup_powershell_invalid_credentials(self):
|
||||
"""Test setup_powershell with invalid credentials"""
|
||||
credentials_dict = {
|
||||
"client_id": "test_client_id",
|
||||
"tenant_id": "test_tenant_id",
|
||||
"client_secret": "test_client_secret",
|
||||
}
|
||||
|
||||
with (
|
||||
patch("prowler.providers.m365.m365_provider.M365PowerShell") as mock_ps,
|
||||
pytest.raises(M365ConfigCredentialsError) as exception,
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session.test_credentials.return_value = False
|
||||
mock_session.close = MagicMock()
|
||||
mock_ps.return_value = mock_session
|
||||
|
||||
M365Provider.setup_powershell(
|
||||
m365_credentials=credentials_dict,
|
||||
identity=M365IdentityInfo(
|
||||
identity_id=IDENTITY_ID,
|
||||
identity_type="User",
|
||||
tenant_id=TENANT_ID,
|
||||
tenant_domain=DOMAIN,
|
||||
tenant_domains=["test.onmicrosoft.com"],
|
||||
location=LOCATION,
|
||||
),
|
||||
)
|
||||
assert exception.type == M365ConfigCredentialsError
|
||||
assert "The provided credentials are not valid." in str(exception.value)
|
||||
|
||||
def test_validate_arguments_browser_auth_without_tenant_id(self):
|
||||
"""Test validate_arguments with browser_auth but missing tenant_id"""
|
||||
with pytest.raises(M365BrowserAuthNoTenantIDError) as exception:
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AuthError } from "next-auth";
|
||||
|
||||
import { signIn, signOut } from "@/auth.config";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { addAuthEvent } from "@/lib/sentry-breadcrumbs";
|
||||
import type { SignInFormData, SignUpFormData } from "@/types";
|
||||
|
||||
export async function authenticate(
|
||||
@@ -11,6 +12,7 @@ export async function authenticate(
|
||||
formData: SignInFormData,
|
||||
) {
|
||||
try {
|
||||
addAuthEvent("login", { email: formData.email });
|
||||
await signIn("credentials", {
|
||||
...formData,
|
||||
redirect: false,
|
||||
@@ -20,6 +22,7 @@ export async function authenticate(
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
addAuthEvent("error", { type: error.type });
|
||||
switch (error.type) {
|
||||
case "CredentialsSignin":
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib";
|
||||
import { addScanOperation } from "@/lib/sentry-breadcrumbs";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
export const getScans = async ({
|
||||
@@ -89,6 +90,11 @@ export const scanOnDemand = async (formData: FormData) => {
|
||||
return { error: "Provider ID is required" };
|
||||
}
|
||||
|
||||
addScanOperation("create", undefined, {
|
||||
provider_id: String(providerId),
|
||||
scan_name: scanName ? String(scanName) : undefined,
|
||||
});
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/scans`);
|
||||
|
||||
try {
|
||||
@@ -113,8 +119,13 @@ export const scanOnDemand = async (formData: FormData) => {
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
return handleApiResponse(response, "/scans");
|
||||
const result = await handleApiResponse(response, "/scans");
|
||||
if (result?.data?.id) {
|
||||
addScanOperation("start", result.data.id);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
addScanOperation("create");
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getTask } from "@/actions/task/tasks";
|
||||
import { addTaskEvent } from "@/lib/sentry-breadcrumbs";
|
||||
import type {
|
||||
GetTaskResponse,
|
||||
PollOptions,
|
||||
@@ -14,10 +15,12 @@ export async function pollTaskUntilSettled<R = unknown>(
|
||||
taskId: string,
|
||||
{ maxAttempts = 10, delayMs = 2000 }: PollOptions = {},
|
||||
): Promise<PollSettledResult<R>> {
|
||||
addTaskEvent("started", taskId, { max_attempts: maxAttempts });
|
||||
let attempts = 0;
|
||||
while (attempts < maxAttempts) {
|
||||
const resp = (await getTask(taskId)) as GetTaskResponse<R>;
|
||||
if ("error" in resp) {
|
||||
addTaskEvent("failed", taskId, { error: resp.error });
|
||||
return { ok: false, error: resp.error };
|
||||
}
|
||||
const task = resp.data;
|
||||
@@ -25,15 +28,18 @@ export async function pollTaskUntilSettled<R = unknown>(
|
||||
const result = task?.attributes?.result;
|
||||
|
||||
if (!state) {
|
||||
addTaskEvent("failed", taskId, { error: "Task state unavailable" });
|
||||
return { ok: false, error: "Task state unavailable", task };
|
||||
}
|
||||
|
||||
if (state !== "executing" && state !== "available") {
|
||||
addTaskEvent("completed", taskId, { state });
|
||||
return { ok: true, state, task, result };
|
||||
}
|
||||
|
||||
attempts++;
|
||||
await sleep(delayMs);
|
||||
}
|
||||
addTaskEvent("timeout", taskId, { attempts: attempts });
|
||||
return { ok: false, error: "Task timeout" };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui";
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { SentryErrorSource, SentryErrorType } from "@/sentry";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
@@ -29,9 +31,39 @@ export default function Error({
|
||||
digest: error.digest,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
// TODO: sent to sentry
|
||||
|
||||
// Send to Sentry with high priority
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
error_boundary: "app",
|
||||
error_type: SentryErrorType.SERVER_ERROR,
|
||||
error_source: SentryErrorSource.ERROR_BOUNDARY,
|
||||
status_code: "500",
|
||||
digest: error.digest,
|
||||
},
|
||||
level: "error",
|
||||
fingerprint: ["server-error", error.message],
|
||||
contexts: {
|
||||
error_details: {
|
||||
is_server_error: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error("Application error:", error);
|
||||
|
||||
// Send other errors to Sentry with normal priority
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
error_boundary: "app",
|
||||
error_type: SentryErrorType.APPLICATION_ERROR,
|
||||
error_source: SentryErrorSource.ERROR_BOUNDARY,
|
||||
digest: error.digest,
|
||||
},
|
||||
level: "warning",
|
||||
fingerprint: ["app-error", error.message],
|
||||
});
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "@/styles/globals.css";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Metadata, Viewport } from "next";
|
||||
import React from "react";
|
||||
|
||||
@@ -22,6 +23,9 @@ export const metadata: Metadata = {
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
other: {
|
||||
...Sentry.getTraceData(),
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
|
||||
import { BarDataPoint } from "@/components/graphs/types";
|
||||
import {
|
||||
BaseCard,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Skeleton,
|
||||
} from "@/components/shadcn";
|
||||
import { calculatePercentage } from "@/lib/utils";
|
||||
|
||||
@@ -58,7 +59,10 @@ export const RiskSeverityChart = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<BaseCard className="flex min-h-[372px] min-w-[312px] flex-1 flex-col md:min-w-[380px]">
|
||||
<Card
|
||||
variant="base"
|
||||
className="flex min-h-[372px] min-w-[312px] flex-1 flex-col md:min-w-[380px]"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Risk Severity</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -66,6 +70,31 @@ export const RiskSeverityChart = ({
|
||||
<CardContent className="flex flex-1 items-center justify-start px-6">
|
||||
<HorizontalBarChart data={chartData} />
|
||||
</CardContent>
|
||||
</BaseCard>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export function RiskSeverityChartSkeleton() {
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
className="flex min-h-[372px] min-w-[312px] flex-1 flex-col md:min-w-[380px]"
|
||||
>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-7 w-[260px] rounded-xl" />
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-1 items-center justify-start px-6">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* 5 horizontal bar skeletons */}
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="flex h-7 w-full gap-6">
|
||||
<Skeleton className="h-full w-28 shrink-0 rounded-xl" />
|
||||
<Skeleton className="h-full flex-1 rounded-xl" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import { Bell, BellOff, ShieldCheck, TriangleAlert } from "lucide-react";
|
||||
import { DonutChart } from "@/components/graphs/donut-chart";
|
||||
import { DonutDataPoint } from "@/components/graphs/types";
|
||||
import {
|
||||
BaseCard,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardVariant,
|
||||
ResourceStatsCard,
|
||||
ResourceStatsCardContainer,
|
||||
Skeleton,
|
||||
} from "@/components/shadcn";
|
||||
import { calculatePercentage } from "@/lib/utils";
|
||||
|
||||
@@ -60,27 +60,30 @@ export const StatusChart = ({
|
||||
{
|
||||
name: "Fail Findings",
|
||||
value: failFindingsData.total,
|
||||
color: "#f43f5e", // Rose-500
|
||||
color: "var(--bg-fail-primary)",
|
||||
percentage: Number(failPercentage),
|
||||
change: Number(failChange),
|
||||
},
|
||||
{
|
||||
name: "Pass Findings",
|
||||
value: passFindingsData.total,
|
||||
color: "#4ade80", // Green-400
|
||||
color: "var(--bg-pass-primary)",
|
||||
percentage: Number(passPercentage),
|
||||
change: Number(passChange),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<BaseCard className="flex min-h-[372px] min-w-[312px] flex-1 flex-col justify-between md:min-w-[380px]">
|
||||
<Card
|
||||
variant="base"
|
||||
className="flex min-h-[372px] min-w-[312px] flex-1 flex-col justify-between md:min-w-[380px]"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Check Findings</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2">
|
||||
<div className="mx-auto max-h-[200px] max-w-[200px]">
|
||||
<CardContent className="flex flex-1 flex-col justify-between space-y-4">
|
||||
<div className="mx-auto h-[172px] w-[172px]">
|
||||
<DonutChart
|
||||
data={donutData}
|
||||
showLegend={false}
|
||||
@@ -93,7 +96,11 @@ export const StatusChart = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ResourceStatsCardContainer className="flex w-full flex-col items-start justify-center gap-4 lg:flex-row lg:justify-between">
|
||||
<Card
|
||||
variant="inner"
|
||||
padding="md"
|
||||
className="flex w-full flex-col items-start justify-center gap-4 lg:flex-row lg:justify-between"
|
||||
>
|
||||
<ResourceStatsCard
|
||||
containerless
|
||||
badge={{
|
||||
@@ -115,7 +122,7 @@ export const StatusChart = ({
|
||||
/>
|
||||
|
||||
<div className="flex w-full items-center justify-center lg:w-auto lg:self-stretch">
|
||||
<div className="h-px w-full bg-slate-300 lg:h-full lg:w-px dark:bg-[rgba(39,39,42,1)]" />
|
||||
<div className="bg-border-neutral-primary h-px w-full lg:h-full lg:w-px" />
|
||||
</div>
|
||||
|
||||
<ResourceStatsCard
|
||||
@@ -137,8 +144,31 @@ export const StatusChart = ({
|
||||
}
|
||||
className="w-full lg:min-w-0 lg:flex-1"
|
||||
/>
|
||||
</ResourceStatsCardContainer>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</BaseCard>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export function StatusChartSkeleton() {
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
className="flex min-h-[372px] min-w-[312px] flex-1 flex-col justify-between md:min-w-[380px]"
|
||||
>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-7 w-[260px] rounded-xl" />
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-1 flex-col justify-between space-y-4">
|
||||
{/* Circular skeleton for donut chart */}
|
||||
<div className="mx-auto h-[172px] w-[172px]">
|
||||
<Skeleton className="size-[172px] rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Bottom info box skeleton */}
|
||||
<Skeleton className="h-[97px] w-full shrink-0 rounded-xl" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MessageCircleWarning, ThumbsUp } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import type {
|
||||
CriticalRequirement,
|
||||
@@ -9,50 +8,33 @@ import type {
|
||||
} from "@/actions/overview/types";
|
||||
import { RadialChart } from "@/components/graphs/radial-chart";
|
||||
import {
|
||||
SEVERITY_COLORS,
|
||||
STATUS_COLORS,
|
||||
} from "@/components/graphs/shared/constants";
|
||||
import {
|
||||
BaseCard,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Skeleton,
|
||||
} from "@/components/shadcn";
|
||||
|
||||
const THREAT_LEVEL_CONFIG = {
|
||||
CRITICAL: {
|
||||
DANGER: {
|
||||
label: "Critical Risk",
|
||||
color: "text-red-500",
|
||||
chartColor: SEVERITY_COLORS.Critical,
|
||||
color: "var(--bg-fail-primary)",
|
||||
chartColor: "var(--bg-fail-primary)",
|
||||
minScore: 0,
|
||||
maxScore: 20,
|
||||
maxScore: 30,
|
||||
},
|
||||
HIGH: {
|
||||
label: "High Risk",
|
||||
color: "text-orange-500",
|
||||
chartColor: SEVERITY_COLORS.High,
|
||||
minScore: 21,
|
||||
maxScore: 40,
|
||||
},
|
||||
MODERATE: {
|
||||
label: "Moderately Secure",
|
||||
color: "text-yellow-500",
|
||||
chartColor: SEVERITY_COLORS.Medium,
|
||||
minScore: 41,
|
||||
WARNING: {
|
||||
label: "Moderate Risk",
|
||||
color: "var(--bg-warning-primary)",
|
||||
chartColor: "var(--bg-warning-primary)",
|
||||
minScore: 31,
|
||||
maxScore: 60,
|
||||
},
|
||||
LOW: {
|
||||
label: "Low Risk",
|
||||
color: "text-blue-500",
|
||||
chartColor: SEVERITY_COLORS.Low,
|
||||
SUCCESS: {
|
||||
label: "Secure",
|
||||
color: "var(--bg-pass-primary)",
|
||||
chartColor: "var(--bg-pass-primary)",
|
||||
minScore: 61,
|
||||
maxScore: 80,
|
||||
},
|
||||
SECURE: {
|
||||
label: "Highly Secure",
|
||||
color: "text-green-500",
|
||||
chartColor: STATUS_COLORS.Success,
|
||||
minScore: 81,
|
||||
maxScore: 100,
|
||||
},
|
||||
} as const;
|
||||
@@ -74,7 +56,7 @@ function getThreatLevel(score: number): ThreatLevelKey {
|
||||
return key as ThreatLevelKey;
|
||||
}
|
||||
}
|
||||
return "MODERATE";
|
||||
return "WARNING";
|
||||
}
|
||||
|
||||
// Convert section scores to tooltip data for the radial chart
|
||||
@@ -84,16 +66,13 @@ function convertSectionScoresToTooltipData(
|
||||
if (!sectionScores) return [];
|
||||
|
||||
return Object.entries(sectionScores).map(([name, value]) => {
|
||||
// Determine color based on score value
|
||||
let color: string = SEVERITY_COLORS.Critical;
|
||||
if (value >= 80) color = STATUS_COLORS.Success;
|
||||
else if (value >= 60) color = SEVERITY_COLORS.Low;
|
||||
else if (value >= 40) color = SEVERITY_COLORS.Medium;
|
||||
else if (value >= 20) color = SEVERITY_COLORS.High;
|
||||
|
||||
// Round to nearest integer
|
||||
const roundedValue = Math.round(value);
|
||||
|
||||
// Determine color based on the same ranges as THREAT_LEVEL_CONFIG
|
||||
const threatLevel = getThreatLevel(roundedValue);
|
||||
const color = THREAT_LEVEL_CONFIG[threatLevel].chartColor;
|
||||
|
||||
return { name, value: roundedValue, color };
|
||||
});
|
||||
}
|
||||
@@ -122,22 +101,10 @@ export function ThreatScore({
|
||||
sectionScores,
|
||||
criticalRequirements,
|
||||
}: ThreatScoreProps) {
|
||||
if (score === null || score === undefined) {
|
||||
return (
|
||||
<BaseCard className="flex min-h-[372px] min-w-[312px] flex-col justify-between md:max-w-[312px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Prowler Threat Score</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 items-center justify-center">
|
||||
<p className="text-chart-text-primary">
|
||||
No ThreatScore data available
|
||||
</p>
|
||||
</CardContent>
|
||||
</BaseCard>
|
||||
);
|
||||
}
|
||||
const hasData = score !== null && score !== undefined;
|
||||
const displayScore = hasData ? score : 0;
|
||||
|
||||
const threatLevel = getThreatLevel(score);
|
||||
const threatLevel = getThreatLevel(displayScore);
|
||||
const config = THREAT_LEVEL_CONFIG[threatLevel];
|
||||
|
||||
// Convert section scores to tooltip data
|
||||
@@ -147,20 +114,23 @@ export function ThreatScore({
|
||||
const gaps = extractTopGaps(criticalRequirements, 2);
|
||||
|
||||
return (
|
||||
<BaseCard className="flex min-h-[372px] min-w-[328px] flex-col justify-between md:max-w-[312px]">
|
||||
<Card
|
||||
variant="base"
|
||||
className="flex min-h-[372px] min-w-[328px] flex-col justify-between md:max-w-[312px]"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Prowler Threat Score</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2">
|
||||
<CardContent className="flex flex-1 flex-col justify-between space-y-4">
|
||||
{/* Radial Chart */}
|
||||
<div className="relative mx-auto h-[150px] w-full max-w-[250px]">
|
||||
<div className="absolute top-0 left-1/2 z-10 h-full w-full -translate-x-1/2">
|
||||
<div className="relative mx-auto h-[172px] w-full max-w-[250px]">
|
||||
<div className="absolute top-0 left-1/2 z-1 w-full -translate-x-1/2">
|
||||
<RadialChart
|
||||
percentage={score}
|
||||
percentage={displayScore}
|
||||
label="Score"
|
||||
color={config.chartColor}
|
||||
backgroundColor="rgba(100, 100, 100, 0.2)"
|
||||
backgroundColor="var(--bg-neutral-tertiary)"
|
||||
height={206}
|
||||
innerRadius={90}
|
||||
outerRadius={115}
|
||||
@@ -171,54 +141,87 @@ export function ThreatScore({
|
||||
/>
|
||||
</div>
|
||||
{/* Overlaid Text (centered) */}
|
||||
<div className="pointer-events-none absolute top-[75%] left-1/2 z-0 -translate-x-1/2 -translate-y-1/2 text-center">
|
||||
<p className="text-sm text-nowrap text-slate-900 dark:text-zinc-300">
|
||||
{config.label}
|
||||
</p>
|
||||
</div>
|
||||
{hasData && (
|
||||
<div className="pointer-events-none absolute top-[65%] left-1/2 z-0 -translate-x-1/2 -translate-y-1/2 text-center">
|
||||
<p className="text-text-neutral-secondary text-sm text-nowrap">
|
||||
{config.label}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="flex-1 rounded-xl border border-slate-300 bg-[#F8FAFC80] px-3 py-[9px] backdrop-blur-[46px] dark:border-[rgba(38,38,38,0.70)] dark:bg-[rgba(23,23,23,0.50)]">
|
||||
<div className="flex flex-col gap-1.5 text-sm leading-6 text-zinc-800 dark:text-zinc-300">
|
||||
{/* Improvement Message */}
|
||||
{scoreDelta !== undefined &&
|
||||
scoreDelta !== null &&
|
||||
scoreDelta !== 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<ThumbsUp size={14} className="flex-shrink-0" />
|
||||
{/* Info Box or Empty State */}
|
||||
{hasData ? (
|
||||
<Card
|
||||
variant="inner"
|
||||
padding="md"
|
||||
className="items-center justify-center"
|
||||
>
|
||||
<div className="text-text-neutral-secondary flex flex-col gap-1.5 text-sm leading-6">
|
||||
{/* Improvement Message */}
|
||||
{scoreDelta !== undefined &&
|
||||
scoreDelta !== null &&
|
||||
scoreDelta !== 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<ThumbsUp size={14} className="flex-shrink-0" />
|
||||
<p>
|
||||
Threat score has{" "}
|
||||
{scoreDelta > 0 ? "improved" : "decreased"} by{" "}
|
||||
{Math.abs(scoreDelta)}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gaps Message */}
|
||||
{gaps.length > 0 && (
|
||||
<div className="flex items-start gap-1">
|
||||
<MessageCircleWarning
|
||||
size={14}
|
||||
className="mt-1 flex-shrink-0"
|
||||
/>
|
||||
<p>
|
||||
Threat score has {scoreDelta > 0 ? "improved" : "decreased"}{" "}
|
||||
by {Math.abs(scoreDelta)}%
|
||||
Major gaps include {gaps.slice(0, 2).join(", ")}
|
||||
{gaps.length > 2 && ` & ${gaps.length - 2} more...`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gaps Message */}
|
||||
{gaps.length > 0 && (
|
||||
<div className="flex items-start gap-1">
|
||||
<MessageCircleWarning
|
||||
size={14}
|
||||
className="mt-1 flex-shrink-0"
|
||||
/>
|
||||
<p>
|
||||
Major gaps include {gaps.slice(0, 2).join(", ")}
|
||||
{gaps.length > 2 && ` & ${gaps.length - 2} more...`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Remediation Plan Button */}
|
||||
<div className="flex justify-center">
|
||||
<Link href="/compliance">
|
||||
<span className="text-sm font-medium text-blue-600 hover:underline dark:text-blue-300">
|
||||
View Remediation Plan
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
variant="inner"
|
||||
padding="md"
|
||||
className="items-center justify-center"
|
||||
>
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Threat Score Data Unavailable
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</CardContent>
|
||||
</BaseCard>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreatScoreSkeleton() {
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
className="flex min-h-[372px] min-w-[328px] flex-col justify-between md:max-w-[312px]"
|
||||
>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-7 w-36 rounded-xl" />
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-1 flex-col justify-between space-y-4">
|
||||
{/* Circular skeleton for radial chart */}
|
||||
<div className="relative mx-auto h-[172px] w-full max-w-[250px]">
|
||||
<Skeleton className="mx-auto size-[170px] rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Bottom info box skeleton */}
|
||||
<Skeleton className="h-[97px] w-full shrink-0 rounded-xl" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,12 @@ import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { AccountsSelector } from "./components/accounts-selector";
|
||||
import { ProviderTypeSelector } from "./components/provider-type-selector";
|
||||
import { RiskSeverityChart } from "./components/risk-severity-chart";
|
||||
import { StatusChart } from "./components/status-chart";
|
||||
import { ThreatScore } from "./components/threat-score";
|
||||
import {
|
||||
RiskSeverityChart,
|
||||
RiskSeverityChartSkeleton,
|
||||
} from "./components/risk-severity-chart";
|
||||
import { StatusChart, StatusChartSkeleton } from "./components/status-chart";
|
||||
import { ThreatScore, ThreatScoreSkeleton } from "./components/threat-score";
|
||||
|
||||
const FILTER_PREFIX = "filter[";
|
||||
|
||||
@@ -42,33 +45,15 @@ export default async function NewOverviewPage({
|
||||
<AccountsSelector providers={providersData?.data ?? []} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 md:flex-row md:flex-wrap md:items-stretch">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-[400px] w-full items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
|
||||
<p className="text-zinc-400">Loading...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<ThreatScoreSkeleton />}>
|
||||
<SSRThreatScore searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-[400px] w-full items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
|
||||
<p className="text-zinc-400">Loading...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<StatusChartSkeleton />}>
|
||||
<SSRCheckFindings searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-[400px] w-full items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
|
||||
<p className="text-zinc-400">Loading...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<RiskSeverityChartSkeleton />}>
|
||||
<SSRRiskSeverityChart searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { toUIMessageStream } from "@ai-sdk/langchain";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { createUIMessageStreamResponse, UIMessage } from "ai";
|
||||
|
||||
import { getLighthouseConfig } from "@/actions/lighthouse/lighthouse";
|
||||
@@ -6,6 +7,7 @@ import { getErrorMessage } from "@/lib/helper";
|
||||
import { getCurrentDataSection } from "@/lib/lighthouse/data";
|
||||
import { convertVercelMessageToLangChainMessage } from "@/lib/lighthouse/utils";
|
||||
import { initLighthouseWorkflow } from "@/lib/lighthouse/workflow";
|
||||
import { SentryErrorSource, SentryErrorType } from "@/sentry";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -96,7 +98,23 @@ export async function POST(req: Request) {
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
// For errors, send a plain string that toUIMessageStream will convert to text chunks
|
||||
|
||||
// Capture stream processing errors
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
api_route: "lighthouse_analyst",
|
||||
error_type: SentryErrorType.STREAM_PROCESSING,
|
||||
error_source: SentryErrorSource.API_ROUTE,
|
||||
},
|
||||
level: "error",
|
||||
contexts: {
|
||||
lighthouse: {
|
||||
event_type: "stream_error",
|
||||
message_count: processedMessages.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
controller.enqueue(`[LIGHTHOUSE_ANALYST_ERROR]: ${errorMessage}`);
|
||||
controller.close();
|
||||
}
|
||||
@@ -109,6 +127,25 @@ export async function POST(req: Request) {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in POST request:", error);
|
||||
|
||||
// Capture API route errors
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
api_route: "lighthouse_analyst",
|
||||
error_type: SentryErrorType.REQUEST_PROCESSING,
|
||||
error_source: SentryErrorSource.API_ROUTE,
|
||||
method: "POST",
|
||||
},
|
||||
level: "error",
|
||||
contexts: {
|
||||
request: {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json(
|
||||
{ error: await getErrorMessage(error) },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import NextError from "next/error";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { SentryErrorSource, SentryErrorType } from "@/sentry";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset: _reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
error_boundary: "global",
|
||||
error_type: SentryErrorType.APPLICATION_ERROR,
|
||||
error_source: SentryErrorSource.ERROR_BOUNDARY,
|
||||
digest: error.digest,
|
||||
},
|
||||
level: "error",
|
||||
contexts: {
|
||||
react: {
|
||||
componentStack: error.stack,
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<NextError statusCode={500} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Client-side Sentry instrumentation
|
||||
*
|
||||
* This file is automatically loaded by Next.js in the browser via the instrumentation hook.
|
||||
* It configures Sentry for client-side error tracking and performance monitoring.
|
||||
*
|
||||
* For server-side configuration, see: instrumentation.ts
|
||||
* For runtime-specific configs, see: sentry/sentry.server.config.ts and sentry/sentry.edge.config.ts
|
||||
*/
|
||||
|
||||
import { browserTracingIntegration } from "@sentry/browser";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
const isDevelopment = process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT === "dev";
|
||||
|
||||
/**
|
||||
* Initialize Sentry error tracking and performance monitoring
|
||||
*
|
||||
* This setup includes:
|
||||
* - Performance monitoring with Web Vitals tracking (LCP, FID, CLS, INP)
|
||||
* - Long task detection for UI-blocking operations
|
||||
* - beforeSend hook to filter noise
|
||||
*/
|
||||
Sentry.init({
|
||||
// 📍 DSN - Data Source Name (identifies your Sentry project)
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// 🌍 Environment - Separate dev errors from production
|
||||
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "local",
|
||||
|
||||
// 📦 Release - Track which version has the error
|
||||
release: process.env.NEXT_PUBLIC_PROWLER_RELEASE_VERSION,
|
||||
|
||||
// 🐛 Debug - Detailed logs in development console
|
||||
debug: isDevelopment,
|
||||
|
||||
// 📊 Sample Rates - Performance monitoring
|
||||
// 100% in dev (test everything), 50% in production (balance visibility with costs)
|
||||
tracesSampleRate: isDevelopment ? 1.0 : 0.5,
|
||||
profilesSampleRate: isDevelopment ? 1.0 : 0.5,
|
||||
|
||||
// 🔌 Integrations
|
||||
integrations: [
|
||||
// 📊 Performance Monitoring: Core Web Vitals + RUM
|
||||
// Tracks LCP, FID, CLS, INP
|
||||
// Real User Monitoring captures actual user experience, not synthetic tests
|
||||
browserTracingIntegration({
|
||||
enableLongTask: true, // Detect tasks that block UI (>50ms)
|
||||
enableInp: true, // Interaction to Next Paint (Core Web Vital)
|
||||
}),
|
||||
],
|
||||
|
||||
// 🎣 beforeSend Hook - Filter or modify events before sending to Sentry
|
||||
ignoreErrors: [
|
||||
// Browser extensions
|
||||
"top.GLOBALS",
|
||||
// Random network errors
|
||||
"Network request failed",
|
||||
"NetworkError",
|
||||
"Failed to fetch",
|
||||
// User canceled actions
|
||||
"AbortError",
|
||||
"Non-Error promise rejection captured",
|
||||
// NextAuth expected errors
|
||||
"NEXT_REDIRECT",
|
||||
// ResizeObserver errors (common browser quirk, not real bugs)
|
||||
"ResizeObserver",
|
||||
],
|
||||
|
||||
beforeSend(event, hint) {
|
||||
// Filter out noise: ResizeObserver errors (common browser quirk, not real bugs)
|
||||
if (event.message?.includes("ResizeObserver")) {
|
||||
return null; // Don't send to Sentry
|
||||
}
|
||||
|
||||
// Filter out non-actionable errors
|
||||
if (event.exception) {
|
||||
const error = hint.originalException;
|
||||
|
||||
// Don't send cancelled requests
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"name" in error &&
|
||||
error.name === "AbortError"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add additional context for API errors
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"message" in error &&
|
||||
typeof error.message === "string" &&
|
||||
error.message.includes("Request failed")
|
||||
) {
|
||||
event.tags = {
|
||||
...event.tags,
|
||||
error_type: "api_error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return event; // Send to Sentry
|
||||
},
|
||||
});
|
||||
|
||||
// 👤 Set user context (identifies who experienced the error)
|
||||
// In production, this will be updated after authentication
|
||||
if (isDevelopment) {
|
||||
Sentry.setUser({
|
||||
id: "dev-user",
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
// Import Sentry client-side initialization
|
||||
import "@/app/instrumentation.client";
|
||||
|
||||
import { HeroUIProvider } from "@heroui/system";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
@@ -30,7 +30,7 @@ const CustomTooltip = ({ active, payload }: any) => {
|
||||
const change = entry.payload?.change;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-3 py-1.5 shadow-lg dark:border-[#202020] dark:bg-[#121110]">
|
||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary rounded-xl border px-3 py-1.5 shadow-lg">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* Title with color chip */}
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -38,7 +38,7 @@ const CustomTooltip = ({ active, payload }: any) => {
|
||||
className="size-3 shrink-0 rounded"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<p className="text-sm leading-5 font-medium text-slate-900 dark:text-[#f4f4f5]">
|
||||
<p className="text-text-neutral-primary text-xs leading-5 font-medium">
|
||||
{percentage}% {name}
|
||||
</p>
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@ const CustomTooltip = ({ active, payload }: any) => {
|
||||
{/* Change percentage row */}
|
||||
{change !== undefined && (
|
||||
<div className="flex items-start">
|
||||
<p className="text-sm leading-5 font-medium text-slate-600 dark:text-[#d4d4d8]">
|
||||
<p className="text-text-neutral-primary text-xs leading-5 font-medium">
|
||||
{change > 0 ? "+" : ""}
|
||||
{change}% Since last scan
|
||||
</p>
|
||||
@@ -171,7 +171,7 @@ export function DonutChart({
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) - 6}
|
||||
className="text-2xl font-bold text-zinc-800 dark:text-zinc-300"
|
||||
className="text-text-neutral-secondary text-2xl font-bold"
|
||||
style={{
|
||||
fill: "currentColor",
|
||||
}}
|
||||
@@ -181,7 +181,7 @@ export function DonutChart({
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 24}
|
||||
className="text-sm text-nowrap text-zinc-800 dark:text-zinc-300"
|
||||
className="text-text-neutral-secondary text-sm text-nowrap"
|
||||
style={{
|
||||
fill: "currentColor",
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Bell } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { CHART_COLORS, SEVERITY_ORDER } from "./shared/constants";
|
||||
import { SEVERITY_ORDER } from "./shared/constants";
|
||||
import { getSeverityColorByName } from "./shared/utils";
|
||||
import { BarDataPoint } from "./types";
|
||||
|
||||
@@ -24,6 +24,7 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
{ name: "High", value: 1, percentage: 100 },
|
||||
{ name: "Medium", value: 1, percentage: 100 },
|
||||
{ name: "Low", value: 1, percentage: 100 },
|
||||
{ name: "Informational", value: 1, percentage: 100 },
|
||||
];
|
||||
|
||||
const sortedData = (isEmpty ? emptyData : [...data]).sort((a, b) => {
|
||||
@@ -38,7 +39,7 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
<div>
|
||||
<h3
|
||||
className="text-lg font-semibold"
|
||||
style={{ color: "var(--chart-text-primary)" }}
|
||||
style={{ color: "var(--text-neutral-primary)" }}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
@@ -50,15 +51,15 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
const isHovered = !isEmpty && hoveredIndex === index;
|
||||
const isFaded = !isEmpty && hoveredIndex !== null && !isHovered;
|
||||
const barColor = isEmpty
|
||||
? CHART_COLORS.gridLine
|
||||
? "var(--bg-neutral-tertiary)"
|
||||
: item.color ||
|
||||
getSeverityColorByName(item.name) ||
|
||||
CHART_COLORS.defaultColor;
|
||||
"var(--bg-neutral-tertiary)";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-6"
|
||||
key={item.name}
|
||||
className="flex gap-6"
|
||||
onMouseEnter={() => !isEmpty && setHoveredIndex(index)}
|
||||
onMouseLeave={() => !isEmpty && setHoveredIndex(null)}
|
||||
>
|
||||
@@ -67,18 +68,18 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{
|
||||
color: "var(--chart-text-primary)",
|
||||
color: "var(--text-neutral-secondary)",
|
||||
opacity: isFaded ? 0.5 : 1,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
{item.name === "Informational" ? "Info" : item.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bar - flexible */}
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute inset-0 h-[22px] w-full rounded-sm bg-[#FAFAFA] dark:bg-black" />
|
||||
<div className="bg-bg-neutral-tertiary absolute inset-0 h-[22px] w-full rounded-sm" />
|
||||
{(item.value > 0 || isEmpty) && (
|
||||
<div
|
||||
className="relative h-[22px] rounded-sm border border-black/10 transition-all duration-300"
|
||||
@@ -93,7 +94,7 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
)}
|
||||
|
||||
{isHovered && (
|
||||
<div className="absolute top-10 left-0 z-10 rounded-xl border border-slate-200 bg-white px-3 py-1.5 shadow-lg dark:border-[#202020] dark:bg-[#121110]">
|
||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary absolute top-10 left-0 z-10 rounded-xl border px-3 py-1.5 shadow-lg">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* Title with color chip */}
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -101,8 +102,10 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
className="size-3 shrink-0 rounded"
|
||||
style={{ backgroundColor: barColor }}
|
||||
/>
|
||||
<p className="text-sm leading-5 font-medium text-slate-900 dark:text-[#f4f4f5]">
|
||||
{item.value.toLocaleString()} {item.name} Risk
|
||||
<p className="text-text-neutral-primary text-xs leading-5 font-medium">
|
||||
{item.value.toLocaleString()}{" "}
|
||||
{item.name === "Informational" ? "Info" : item.name}{" "}
|
||||
Risk
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -111,9 +114,9 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
<div className="flex items-center gap-1">
|
||||
<Bell
|
||||
size={12}
|
||||
className="shrink-0 text-slate-600 dark:text-[#d4d4d8]"
|
||||
className="text-text-neutral-secondary shrink-0"
|
||||
/>
|
||||
<p className="text-sm leading-5 font-medium text-slate-600 dark:text-[#d4d4d8]">
|
||||
<p className="text-text-neutral-secondary text-xs leading-5 font-medium">
|
||||
{item.newFindings} New Findings
|
||||
</p>
|
||||
</div>
|
||||
@@ -122,7 +125,7 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
{/* Change percentage row */}
|
||||
{item.change !== undefined && (
|
||||
<div className="flex items-start">
|
||||
<p className="text-sm leading-5 font-medium text-slate-600 dark:text-[#d4d4d8]">
|
||||
<p className="text-text-neutral-secondary text-xs leading-5 font-medium">
|
||||
{item.change > 0 ? "+" : ""}
|
||||
{item.change}% Since last scan
|
||||
</p>
|
||||
@@ -137,7 +140,7 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
<div
|
||||
className="flex w-[90px] shrink-0 items-center gap-2 text-sm"
|
||||
style={{
|
||||
color: "var(--chart-text-primary)",
|
||||
color: "var(--text-neutral-secondary)",
|
||||
opacity: isFaded ? 0.5 : 1,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
@@ -147,7 +150,7 @@ export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) {
|
||||
</span>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: "var(--chart-text-secondary)" }}
|
||||
style={{ color: "var(--text-neutral-secondary)" }}
|
||||
>
|
||||
•
|
||||
</span>
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
|
||||
import { CHART_COLORS } from "./shared/constants";
|
||||
|
||||
export interface TooltipItem {
|
||||
name: string;
|
||||
value: number;
|
||||
@@ -42,18 +40,18 @@ const CustomTooltip = ({ active, payload }: any) => {
|
||||
return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-3 py-1.5 shadow-lg dark:border-[#202020] dark:bg-[#121110]">
|
||||
<div className="bg-bg-neutral-tertiary border-border-neutral-tertiary rounded-xl border px-3 py-1.5 shadow-lg">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{tooltipItems.map((item: TooltipItem, index: number) => (
|
||||
<div key={index} className="flex items-end gap-1">
|
||||
<p className="text-xs leading-5 font-medium text-slate-900 dark:text-[#f4f4f5]">
|
||||
<p className="text-text-neutral-primary text-xs leading-5 font-medium">
|
||||
{item.name}
|
||||
</p>
|
||||
<div className="mb-[4px] flex-1 border-b border-dotted border-slate-400 dark:border-slate-600" />
|
||||
<div className="border-text-neutral-primary mb-[4px] flex-1 border-b border-dotted" />
|
||||
<p
|
||||
className="text-xs leading-5 font-medium"
|
||||
style={{
|
||||
color: item.color || "var(--chart-text-primary)",
|
||||
color: item.color || "var(--text-neutral-primary)",
|
||||
}}
|
||||
>
|
||||
{item.value}%
|
||||
@@ -67,8 +65,8 @@ const CustomTooltip = ({ active, payload }: any) => {
|
||||
|
||||
export function RadialChart({
|
||||
percentage,
|
||||
color = "var(--chart-success-color)",
|
||||
backgroundColor = CHART_COLORS.tooltipBackground,
|
||||
color = "var(--bg-pass-primary)",
|
||||
backgroundColor = "var(--bg-neutral-tertiary)",
|
||||
height = 250,
|
||||
innerRadius = 60,
|
||||
outerRadius = 100,
|
||||
@@ -154,24 +152,18 @@ export function RadialChart({
|
||||
const y = centerY - middleRadius * Math.sin(currentAngleRad);
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={2}
|
||||
fill="rgba(255, 255, 255, 0.3)"
|
||||
/>
|
||||
<circle key={i} cx={x} cy={y} r={2} fill="var(--chart-dots)" />
|
||||
);
|
||||
})}
|
||||
|
||||
<text
|
||||
x="50%"
|
||||
y="40%"
|
||||
y="38%"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="text-2xl font-bold"
|
||||
style={{
|
||||
fill: "var(--chart-text-primary)",
|
||||
fill: "var(--text-neutral-secondary)",
|
||||
}}
|
||||
>
|
||||
{percentage}%
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
export const SEVERITY_COLORS = {
|
||||
Informational: "var(--chart-info)",
|
||||
Info: "var(--chart-info)",
|
||||
Low: "var(--chart-warning)",
|
||||
Medium: "var(--chart-warning-emphasis)",
|
||||
High: "var(--chart-danger)",
|
||||
Critical: "var(--chart-danger-emphasis)",
|
||||
Informational: "var(--bg-data-info)",
|
||||
Low: "var(--bg-data-low)",
|
||||
Medium: "var(--bg-data-medium)",
|
||||
High: "var(--bg-data-high)",
|
||||
Critical: "var(--bg-data-critical)",
|
||||
} as const;
|
||||
|
||||
export const PROVIDER_COLORS = {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Card } from "../card";
|
||||
|
||||
const baseCardVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-slate-200 bg-white dark:border-zinc-900 dark:bg-stone-950",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
interface BaseCardProps
|
||||
extends React.ComponentProps<typeof Card>,
|
||||
VariantProps<typeof baseCardVariants> {}
|
||||
|
||||
const BaseCard = ({ className, variant, ...props }: BaseCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
baseCardVariants({ variant }),
|
||||
"gap-2 px-[18px] pt-3 pb-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { BaseCard };
|
||||
@@ -1,3 +1,5 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const CardVariant = {
|
||||
@@ -10,14 +12,44 @@ export const CardVariant = {
|
||||
|
||||
export type CardVariant = (typeof CardVariant)[keyof typeof CardVariant];
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
base: "border-border-neutral-secondary bg-bg-neutral-secondary px-[18px] pt-3 pb-4",
|
||||
inner:
|
||||
"rounded-[12px] backdrop-blur-[46px] border-border-neutral-tertiary bg-bg-neutral-tertiary",
|
||||
},
|
||||
padding: {
|
||||
default: "",
|
||||
sm: "px-3 py-2",
|
||||
md: "px-4 py-3",
|
||||
lg: "px-5 py-4",
|
||||
none: "p-0",
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: "inner",
|
||||
padding: "default",
|
||||
className: "px-4 py-3", // md padding by default for inner
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
padding: "default",
|
||||
},
|
||||
});
|
||||
|
||||
interface CardProps
|
||||
extends React.ComponentProps<"div">,
|
||||
VariantProps<typeof cardVariants> {}
|
||||
|
||||
function Card({ className, variant, padding, ...props }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6",
|
||||
className,
|
||||
)}
|
||||
className={cn(cardVariants({ variant, padding }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -28,7 +60,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
"@container/card-header mb-6 grid auto-rows-min grid-rows-[auto_auto] items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -40,10 +72,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"my-2 text-[18px] leading-none text-slate-900 dark:text-white",
|
||||
className,
|
||||
)}
|
||||
className={cn("mt-2 text-[18px] leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -96,4 +125,6 @@ export {
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
cardVariants,
|
||||
};
|
||||
export type { CardProps };
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const containerVariants = cva(
|
||||
[
|
||||
"flex",
|
||||
"rounded-[12px]",
|
||||
"border",
|
||||
"backdrop-blur-[46px]",
|
||||
"border-slate-300",
|
||||
"bg-[#F8FAFC80]",
|
||||
"dark:border-[rgba(38,38,38,0.70)]",
|
||||
"dark:bg-[rgba(23,23,23,0.50)]",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
padding: {
|
||||
sm: "px-3 py-2",
|
||||
md: "px-[19px] py-[9px]",
|
||||
lg: "px-6 py-3",
|
||||
none: "p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
padding: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ResourceStatsCardContainerProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof containerVariants> {
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ResourceStatsCardContainer = ({
|
||||
className,
|
||||
children,
|
||||
padding,
|
||||
ref,
|
||||
...props
|
||||
}: ResourceStatsCardContainerProps) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(containerVariants({ padding }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ResourceStatsCardContainer.displayName = "ResourceStatsCardContainer";
|
||||
@@ -11,11 +11,11 @@ export interface StatItem {
|
||||
}
|
||||
|
||||
const variantColors = {
|
||||
default: "#868994",
|
||||
fail: "#f54280",
|
||||
pass: "#4ade80",
|
||||
warning: "#fbbf24",
|
||||
info: "#60a5fa",
|
||||
default: "var(--bg-neutral-tertiary)",
|
||||
fail: "var(--bg-fail-primary)",
|
||||
pass: "var(--bg-pass-primary)",
|
||||
warning: "var(--bg-warning-primary)",
|
||||
info: "var(--bg-data-info)",
|
||||
} as const;
|
||||
|
||||
type BadgeVariant = keyof typeof variantColors;
|
||||
@@ -26,8 +26,8 @@ const badgeVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
[CardVariant.default]: "bg-slate-100 dark:bg-[#535359]",
|
||||
[CardVariant.fail]: "bg-red-100 dark:bg-[#432232]",
|
||||
[CardVariant.pass]: "bg-green-100 dark:bg-[#204237]",
|
||||
[CardVariant.fail]: "bg-bg-fail-secondary",
|
||||
[CardVariant.pass]: "bg-bg-pass-secondary",
|
||||
[CardVariant.warning]: "bg-amber-100 dark:bg-[#3d3520]",
|
||||
[CardVariant.info]: "bg-blue-100 dark:bg-[#1e3a5f]",
|
||||
},
|
||||
@@ -58,7 +58,7 @@ const badgeIconVariants = cva("", {
|
||||
});
|
||||
|
||||
const labelTextVariants = cva(
|
||||
"leading-6 font-semibold text-slate-900 dark:text-zinc-300 whitespace-nowrap",
|
||||
"leading-6 font-semibold text-text-neutral-secondary whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
@@ -73,7 +73,7 @@ const labelTextVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
const statIconVariants = cva("text-slate-600 dark:text-zinc-300", {
|
||||
const statIconVariants = cva("text-text-neutral-secondary", {
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-2.5 w-2.5",
|
||||
@@ -87,7 +87,7 @@ const statIconVariants = cva("text-slate-600 dark:text-zinc-300", {
|
||||
});
|
||||
|
||||
const statLabelVariants = cva(
|
||||
"leading-5 font-medium text-slate-700 dark:text-zinc-300",
|
||||
"leading-5 font-medium text-text-neutral-secondary",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const dividerVariants = cva("flex items-center justify-center", {
|
||||
variants: {
|
||||
spacing: {
|
||||
sm: "px-2",
|
||||
md: "px-[23px]",
|
||||
lg: "px-8",
|
||||
},
|
||||
orientation: {
|
||||
vertical: "h-full",
|
||||
horizontal: "w-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
spacing: "md",
|
||||
orientation: "vertical",
|
||||
},
|
||||
});
|
||||
|
||||
const lineVariants = cva("bg-[rgba(39,39,42,1)]", {
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "h-full w-px",
|
||||
horizontal: "w-full h-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
});
|
||||
|
||||
export interface ResourceStatsCardDividerProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof dividerVariants> {
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ResourceStatsCardDivider = ({
|
||||
className,
|
||||
spacing,
|
||||
orientation,
|
||||
ref,
|
||||
...props
|
||||
}: ResourceStatsCardDividerProps) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(dividerVariants({ spacing, orientation }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div className={lineVariants({ orientation })} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ResourceStatsCardDivider.displayName = "ResourceStatsCardDivider";
|
||||
@@ -16,7 +16,7 @@ const headerVariants = cva("flex w-full items-center gap-1", {
|
||||
},
|
||||
});
|
||||
|
||||
const iconVariants = cva("text-zinc-300 dark:text-zinc-300", {
|
||||
const iconVariants = cva("text-text-neutral-secondary", {
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-3.5 w-3.5",
|
||||
@@ -30,7 +30,7 @@ const iconVariants = cva("text-zinc-300 dark:text-zinc-300", {
|
||||
});
|
||||
|
||||
const titleVariants = cva(
|
||||
"leading-7 font-semibold text-zinc-300 dark:text-zinc-300",
|
||||
"leading-7 font-semibold text-text-neutral-secondary",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
@@ -45,21 +45,18 @@ const titleVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
const countVariants = cva(
|
||||
"leading-4 font-normal text-zinc-300 dark:text-zinc-300",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "text-[9px]",
|
||||
md: "text-[10px]",
|
||||
lg: "text-xs",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
const countVariants = cva("leading-4 font-normal text-text-neutral-secondary", {
|
||||
variants: {
|
||||
size: {
|
||||
sm: "text-[9px]",
|
||||
md: "text-[10px]",
|
||||
lg: "text-xs",
|
||||
},
|
||||
},
|
||||
);
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
});
|
||||
|
||||
export interface ResourceStatsCardHeaderProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
|
||||
@@ -3,17 +3,12 @@ import { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CardVariant } from "../card";
|
||||
import { ResourceStatsCardContainer } from "./resource-stats-card-container";
|
||||
import { Card, CardVariant } from "../card";
|
||||
import type { StatItem } from "./resource-stats-card-content";
|
||||
import { ResourceStatsCardContent } from "./resource-stats-card-content";
|
||||
import { ResourceStatsCardHeader } from "./resource-stats-card-header";
|
||||
|
||||
export type { StatItem };
|
||||
|
||||
// Todo: when the design system is ready, we must use the colors from the design system (semantic colors)
|
||||
// Variant styles using CVA for type safety and consistency
|
||||
// Colors are exact HEX values from Figma design system
|
||||
const cardVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -109,7 +104,7 @@ export const ResourceStatsCard = ({
|
||||
{header && <ResourceStatsCardHeader {...header} size={resolvedSize} />}
|
||||
{emptyState ? (
|
||||
<div className="flex h-[51px] w-full flex-col items-center justify-center">
|
||||
<p className="text-center text-sm leading-5 font-medium text-slate-600 dark:text-zinc-300">
|
||||
<p className="text-text-neutral-secondary text-center text-sm leading-5 font-medium">
|
||||
{emptyState.message}
|
||||
</p>
|
||||
</div>
|
||||
@@ -131,15 +126,16 @@ export const ResourceStatsCard = ({
|
||||
|
||||
// Otherwise, render with container
|
||||
return (
|
||||
<ResourceStatsCardContainer
|
||||
<Card
|
||||
ref={ref}
|
||||
variant="inner"
|
||||
className={cn(cardVariants({ variant, size }), "flex-col", className)}
|
||||
{...props}
|
||||
>
|
||||
{header && <ResourceStatsCardHeader {...header} size={resolvedSize} />}
|
||||
{emptyState ? (
|
||||
<div className="flex h-[51px] w-full flex-col items-center justify-center">
|
||||
<p className="text-center text-sm leading-5 font-medium text-slate-600 dark:text-zinc-300">
|
||||
<p className="text-text-neutral-secondary text-center text-sm leading-5 font-medium">
|
||||
{emptyState.message}
|
||||
</p>
|
||||
</div>
|
||||
@@ -155,7 +151,7 @@ export const ResourceStatsCard = ({
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</ResourceStatsCardContainer>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
export * from "./badge/badge";
|
||||
export * from "./button/button";
|
||||
export * from "./card/base-card/base-card";
|
||||
export * from "./card/card";
|
||||
export * from "./card/resource-stats-card/resource-stats-card";
|
||||
export * from "./card/resource-stats-card/resource-stats-card-container";
|
||||
export * from "./card/resource-stats-card/resource-stats-card-content";
|
||||
export * from "./card/resource-stats-card/resource-stats-card-divider";
|
||||
export * from "./card/resource-stats-card/resource-stats-card-header";
|
||||
export * from "./dropdown/dropdown";
|
||||
export * from "./select/select";
|
||||
export * from "./separator/separator";
|
||||
export * from "./skeleton/skeleton";
|
||||
export * from "./tabs/generic-tabs";
|
||||
export * from "./tabs/tabs";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn(
|
||||
"bg-border-neutral-tertiary animate-pulse rounded-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -159,6 +159,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@sentry/nextjs",
|
||||
"from": "10.11.0",
|
||||
"to": "10.11.0",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T15:52:15.849Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@tailwindcss/postcss",
|
||||
@@ -709,7 +717,7 @@
|
||||
"from": "3.4.1",
|
||||
"to": "3.4.1",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
"generatedAt": "2025-10-22T15:52:15.849Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Next.js Instrumentation Hook
|
||||
*
|
||||
* This file is automatically executed by Next.js at startup to initialize server-side SDKs.
|
||||
*
|
||||
* Configuration Flow:
|
||||
* 1. This file (instrumentation.ts) - Server-side initialization
|
||||
* 2. Runtime-specific configs:
|
||||
* - sentry/sentry.server.config.ts (Node.js runtime)
|
||||
* - sentry/sentry.edge.config.ts (Edge runtime)
|
||||
* 3. Client-side init:
|
||||
* - app/instrumentation.client.ts (Browser/Client)
|
||||
*
|
||||
* @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
|
||||
*/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export async function register() {
|
||||
// The Sentry SDK automatically loads the appropriate config based on runtime
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("./sentry/sentry.server.config");
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
await import("./sentry/sentry.edge.config");
|
||||
}
|
||||
}
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Sentry Breadcrumb Utilities
|
||||
*
|
||||
* Provides helper functions to add breadcrumbs for tracking critical paths
|
||||
* and user actions throughout the application.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* import { addUserAction, addApiCall, addTaskEvent } from '@/lib/sentry-breadcrumbs';
|
||||
*
|
||||
* addUserAction('clicked_create_scan', { provider: 'aws' });
|
||||
* addApiCall('POST /scans', 'success');
|
||||
* addTaskEvent('scan_started', 'scan-123');
|
||||
* ```
|
||||
*/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export interface BreadcrumbContext {
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add breadcrumb for user actions
|
||||
* @param action - User action identifier
|
||||
* @param context - Additional context data
|
||||
*/
|
||||
export function addUserAction(action: string, context?: BreadcrumbContext) {
|
||||
Sentry.addBreadcrumb({
|
||||
message: `User action: ${action}`,
|
||||
category: "user.action",
|
||||
level: "info",
|
||||
data: context,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add breadcrumb for API calls
|
||||
* @param endpoint - API endpoint (e.g., "GET /scans")
|
||||
* @param status - Status of the call (success, error, timeout)
|
||||
* @param context - Additional context data
|
||||
*/
|
||||
export function addApiCall(
|
||||
endpoint: string,
|
||||
status: "success" | "error" | "timeout",
|
||||
context?: BreadcrumbContext,
|
||||
) {
|
||||
Sentry.addBreadcrumb({
|
||||
message: `API ${endpoint}`,
|
||||
category: "api",
|
||||
level: status === "error" ? "warning" : "info",
|
||||
data: {
|
||||
status,
|
||||
...context,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add breadcrumb for task events
|
||||
* @param event - Task event (started, completed, failed)
|
||||
* @param taskId - Task identifier
|
||||
* @param context - Additional context data
|
||||
*/
|
||||
export function addTaskEvent(
|
||||
event: "started" | "completed" | "failed" | "timeout",
|
||||
taskId: string,
|
||||
context?: BreadcrumbContext,
|
||||
) {
|
||||
Sentry.addBreadcrumb({
|
||||
message: `Task ${event}: ${taskId}`,
|
||||
category: "task",
|
||||
level: event === "failed" ? "warning" : "info",
|
||||
data: {
|
||||
task_id: taskId,
|
||||
...context,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add breadcrumb for authentication events
|
||||
* @param event - Auth event (login, logout, signup)
|
||||
* @param context - Additional context data
|
||||
*/
|
||||
export function addAuthEvent(
|
||||
event: "login" | "logout" | "signup" | "error",
|
||||
context?: BreadcrumbContext,
|
||||
) {
|
||||
Sentry.addBreadcrumb({
|
||||
message: `Auth event: ${event}`,
|
||||
category: "auth",
|
||||
level: event === "error" ? "warning" : "info",
|
||||
data: context,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add breadcrumb for form submissions
|
||||
* @param formName - Name of the form
|
||||
* @param status - Status of submission
|
||||
* @param context - Additional context data
|
||||
*/
|
||||
export function addFormSubmission(
|
||||
formName: string,
|
||||
status: "started" | "success" | "error",
|
||||
context?: BreadcrumbContext,
|
||||
) {
|
||||
Sentry.addBreadcrumb({
|
||||
message: `Form submission: ${formName}`,
|
||||
category: "form",
|
||||
level: status === "error" ? "warning" : "info",
|
||||
data: {
|
||||
status,
|
||||
...context,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add breadcrumb for navigation
|
||||
* @param from - Source path
|
||||
* @param to - Destination path
|
||||
*/
|
||||
export function addNavigation(from: string, to: string) {
|
||||
Sentry.addBreadcrumb({
|
||||
message: `Navigation: ${from} → ${to}`,
|
||||
category: "navigation",
|
||||
level: "info",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add breadcrumb for scan operations
|
||||
* @param operation - Operation type (create, start, cancel, etc.)
|
||||
* @param scanId - Scan identifier
|
||||
* @param context - Additional context data
|
||||
*/
|
||||
export function addScanOperation(
|
||||
operation: "create" | "start" | "cancel" | "pause" | "resume",
|
||||
scanId?: string,
|
||||
context?: BreadcrumbContext,
|
||||
) {
|
||||
Sentry.addBreadcrumb({
|
||||
message: `Scan ${operation}${scanId ? `: ${scanId}` : ""}`,
|
||||
category: "scan",
|
||||
level: "info",
|
||||
data: {
|
||||
scan_id: scanId,
|
||||
...context,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add breadcrumb for data mutations
|
||||
* @param entity - Entity type (provider, scan, role, etc.)
|
||||
* @param action - Action type (create, update, delete)
|
||||
* @param entityId - Entity identifier
|
||||
* @param context - Additional context data
|
||||
*/
|
||||
export function addDataMutation(
|
||||
entity: string,
|
||||
action: "create" | "update" | "delete",
|
||||
entityId?: string,
|
||||
context?: BreadcrumbContext,
|
||||
) {
|
||||
Sentry.addBreadcrumb({
|
||||
message: `Data mutation: ${action} ${entity}${entityId ? ` (${entityId})` : ""}`,
|
||||
category: "data",
|
||||
level: "info",
|
||||
data: {
|
||||
entity,
|
||||
action,
|
||||
entity_id: entityId,
|
||||
...context,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { SentryErrorSource, SentryErrorType } from "@/sentry";
|
||||
|
||||
import { getErrorMessage, parseStringify } from "./helper";
|
||||
|
||||
// Helper function to handle API responses consistently
|
||||
/**
|
||||
* Helper function to handle API responses consistently
|
||||
* Includes Sentry error tracking for debugging
|
||||
*/
|
||||
export const handleApiResponse = async (
|
||||
response: Response,
|
||||
pathToRevalidate?: string,
|
||||
@@ -29,12 +35,67 @@ export const handleApiResponse = async (
|
||||
response.statusText ||
|
||||
"Oops! Something went wrong.";
|
||||
|
||||
//5XX errors
|
||||
// Capture error context for Sentry
|
||||
const errorContext = {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url: response.url,
|
||||
errorDetail,
|
||||
pathToRevalidate,
|
||||
};
|
||||
|
||||
// 5XX errors - Server errors (high priority)
|
||||
if (response.status >= 500) {
|
||||
throw new Error(
|
||||
const serverError = new Error(
|
||||
errorDetail ||
|
||||
`Server error (${response.status}): The server encountered an error. Please try again later.`,
|
||||
);
|
||||
|
||||
Sentry.captureException(serverError, {
|
||||
tags: {
|
||||
api_error: true,
|
||||
status_code: response.status.toString(),
|
||||
error_type: SentryErrorType.SERVER_ERROR,
|
||||
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
|
||||
},
|
||||
level: "error",
|
||||
contexts: {
|
||||
api_response: errorContext,
|
||||
},
|
||||
fingerprint: [
|
||||
"api-server-error",
|
||||
response.status.toString(),
|
||||
response.url,
|
||||
],
|
||||
});
|
||||
|
||||
throw serverError;
|
||||
}
|
||||
|
||||
// Client errors (4xx) - Only capture unexpected ones
|
||||
if (![401, 403, 404].includes(response.status)) {
|
||||
const clientError = new Error(
|
||||
errorDetail ||
|
||||
`Request failed (${response.status}): ${response.statusText}`,
|
||||
);
|
||||
|
||||
Sentry.captureException(clientError, {
|
||||
tags: {
|
||||
api_error: true,
|
||||
status_code: response.status.toString(),
|
||||
error_type: SentryErrorType.CLIENT_ERROR,
|
||||
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
|
||||
},
|
||||
level: "warning",
|
||||
contexts: {
|
||||
api_response: errorContext,
|
||||
},
|
||||
fingerprint: [
|
||||
"api-client-error",
|
||||
response.status.toString(),
|
||||
response.url,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return errorsArray
|
||||
@@ -76,9 +137,60 @@ export const handleApiResponse = async (
|
||||
return parse ? parseStringify(data) : data;
|
||||
};
|
||||
|
||||
// Helper function to handle API errors consistently
|
||||
/**
|
||||
* Helper function to handle API errors consistently
|
||||
* Includes Sentry error tracking
|
||||
*/
|
||||
export const handleApiError = (error: unknown): { error: string } => {
|
||||
console.error(error);
|
||||
|
||||
// Check if this error was already captured by handleApiResponse
|
||||
const isAlreadyCaptured =
|
||||
error instanceof Error &&
|
||||
(error.message.includes("Server error") ||
|
||||
error.message.includes("Request failed"));
|
||||
|
||||
// Only capture if not already captured by handleApiResponse
|
||||
if (!isAlreadyCaptured) {
|
||||
if (error instanceof Error) {
|
||||
// Don't capture expected errors
|
||||
if (
|
||||
!error.message.includes("401") &&
|
||||
!error.message.includes("403") &&
|
||||
!error.message.includes("404")
|
||||
) {
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
error_source: SentryErrorSource.HANDLE_API_ERROR,
|
||||
error_type: SentryErrorType.UNEXPECTED_ERROR,
|
||||
},
|
||||
level: "error",
|
||||
contexts: {
|
||||
error_details: {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Capture non-Error objects
|
||||
Sentry.captureMessage(
|
||||
`Non-Error object in handleApiError: ${String(error)}`,
|
||||
{
|
||||
level: "warning",
|
||||
tags: {
|
||||
error_source: SentryErrorSource.HANDLE_API_ERROR,
|
||||
error_type: SentryErrorType.NON_ERROR_OBJECT,
|
||||
},
|
||||
extra: {
|
||||
error: error,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error: getErrorMessage(error),
|
||||
};
|
||||
|
||||
+61
-17
@@ -1,19 +1,36 @@
|
||||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
// HTTP Security Headers
|
||||
// 'unsafe-eval' is configured under `script-src` because it is required by NextJS for development mode
|
||||
const cspHeader = `
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://www.googletagmanager.com;
|
||||
connect-src 'self' https://api.iconify.design https://api.simplesvg.com https://api.unisvg.com https://js.stripe.com https://www.googletagmanager.com;
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://www.googletagmanager.com https://browser.sentry-cdn.com;
|
||||
connect-src 'self' https://api.iconify.design https://api.simplesvg.com https://api.unisvg.com https://js.stripe.com https://www.googletagmanager.com https://*.sentry.io https://*.ingest.sentry.io;
|
||||
img-src 'self' https://www.google-analytics.com https://www.googletagmanager.com;
|
||||
font-src 'self';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
frame-src 'self' https://js.stripe.com https://www.googletagmanager.com;
|
||||
frame-ancestors 'none';
|
||||
report-to csp-endpoint;
|
||||
`;
|
||||
|
||||
module.exports = {
|
||||
// Get Sentry CSP report endpoint if DSN is configured
|
||||
const getSentryReportEndpoint = () => {
|
||||
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) return null;
|
||||
try {
|
||||
const sentryKey =
|
||||
process.env.NEXT_PUBLIC_SENTRY_DSN.split("@")[0]?.split("//")[1];
|
||||
return sentryKey
|
||||
? `https://o0.ingest.sentry.io/api/0/security/?sentry_key=${sentryKey}`
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const nextConfig = {
|
||||
poweredByHeader: false,
|
||||
// Use standalone only in production deployments, not for CI/testing
|
||||
...(process.env.NODE_ENV === "production" &&
|
||||
@@ -28,24 +45,51 @@ module.exports = {
|
||||
root: __dirname,
|
||||
},
|
||||
async headers() {
|
||||
const sentryEndpoint = getSentryReportEndpoint();
|
||||
const headers = [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: cspHeader.replace(/\n/g, ""),
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "strict-origin-when-cross-origin",
|
||||
},
|
||||
];
|
||||
|
||||
// Add Reporting-Endpoints header if Sentry is configured
|
||||
if (sentryEndpoint) {
|
||||
headers.push({
|
||||
key: "Reporting-Endpoints",
|
||||
value: `csp-endpoint="${sentryEndpoint}"`,
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: cspHeader.replace(/\n/g, ""),
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "strict-origin-when-cross-origin",
|
||||
},
|
||||
],
|
||||
headers,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
// Sentry configuration options
|
||||
const sentryWebpackPluginOptions = {
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT,
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
silent: true, // Suppresses all logs
|
||||
hideSourceMaps: true, // Hides source maps from generated client bundles
|
||||
disableLogger: true, // Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
widenClientFileUpload: true, // Upload a larger set of source maps for prettier stack traces
|
||||
};
|
||||
|
||||
// Export with Sentry only if configuration is available
|
||||
module.exports = process.env.SENTRY_DSN
|
||||
? withSentryConfig(nextConfig, sentryWebpackPluginOptions)
|
||||
: nextConfig;
|
||||
|
||||
Generated
+3045
-397
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,7 @@
|
||||
"@radix-ui/react-toast": "1.2.14",
|
||||
"@react-aria/ssr": "3.9.4",
|
||||
"@react-aria/visually-hidden": "3.8.12",
|
||||
"@sentry/nextjs": "10.11.0",
|
||||
"@tailwindcss/postcss": "4.1.13",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user