diff --git a/backend-api/alembic/env.py b/backend-api/alembic/env.py index e7ab362f..b94188d8 100644 --- a/backend-api/alembic/env.py +++ b/backend-api/alembic/env.py @@ -19,6 +19,7 @@ from app.models.azure_connection import AzureConnection # noqa from app.models.gcp_connection import GCPConnection # noqa from app.models.aws_connection import AWSConnection # noqa +from app.models.user_settings import UserSettings # noqa from app.core.config import get_settings # this is the Alembic Config object, which provides diff --git a/backend-api/alembic/versions/j1k2l3m4n567_add_contact_us_tables.py b/backend-api/alembic/versions/j1k2l3m4n567_add_contact_us_tables.py index 2049f04d..1fcf3085 100644 --- a/backend-api/alembic/versions/j1k2l3m4n567_add_contact_us_tables.py +++ b/backend-api/alembic/versions/j1k2l3m4n567_add_contact_us_tables.py @@ -1,7 +1,7 @@ """add contact us tables Revision ID: j1k2l3m4n567 -Revises: h1i2j3k4l567 +Revises: j2k3l4m5n678 Create Date: 2026-01-20 """ @@ -14,7 +14,7 @@ # revision identifiers, used by Alembic. revision: str = "j1k2l3m4n567" -down_revision: Union[str, Sequence[str], None] = "h1i2j3k4l567" +down_revision: Union[str, Sequence[str], None] = "j2k3l4m5n678" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/backend-api/alembic/versions/j2k3l4m5n678_add_user_settings_table.py b/backend-api/alembic/versions/j2k3l4m5n678_add_user_settings_table.py new file mode 100644 index 00000000..b6133bd0 --- /dev/null +++ b/backend-api/alembic/versions/j2k3l4m5n678_add_user_settings_table.py @@ -0,0 +1,60 @@ +"""Add user_settings table. + +Revision ID: j2k3l4m5n678 +Revises: h1i2j3k4l567 +Create Date: 2026-01-18 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "j2k3l4m5n678" +down_revision: Union[str, Sequence[str], None] = "h1i2j3k4l567" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "user_settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column( + "confirm_delete_enabled", + sa.Boolean(), + nullable=False, + server_default=sa.text("true"), + ), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column( + "updated_at", + sa.DateTime(), + server_default=sa.text("now()"), + onupdate=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", name="uq_user_settings_user_id"), + ) + + op.create_index( + op.f("ix_user_settings_user_id"), + "user_settings", + ["user_id"], + unique=True, + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index(op.f("ix_user_settings_user_id"), table_name="user_settings") + op.drop_table("user_settings") + diff --git a/backend-api/app/Contact-ER-Diagram.jpg b/backend-api/app/Contact-ER-Diagram.jpg new file mode 100644 index 00000000..908e195d Binary files /dev/null and b/backend-api/app/Contact-ER-Diagram.jpg differ diff --git a/backend-api/app/api/_init_.py b/backend-api/app/api/_init_.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend-api/app/api/v1/contact.py b/backend-api/app/api/v1/contact.py index 4af0ae7c..5640041b 100644 --- a/backend-api/app/api/v1/contact.py +++ b/backend-api/app/api/v1/contact.py @@ -136,26 +136,38 @@ def track_change(field: str, old: str | None, new: str | None) -> None: if "status" in payload.model_fields_set: if payload.status is None: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="status cannot be null") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="status cannot be null", + ) track_change("status", submission.status, payload.status) new_status = payload.status.lower() submission.status = payload.status - if new_status == "resolved": + # If status is set to resolved and resolved_at wasn't explicitly provided, + # set it automatically. + if new_status == "resolved" and "resolved_at" not in payload.model_fields_set: submission.resolved_at = datetime.utcnow() - elif submission.resolved_at is not None: + # If status changes away from resolved, clear resolved_at unless caller + # is explicitly setting it. + elif ( + new_status != "resolved" + and submission.resolved_at is not None + and "resolved_at" not in payload.model_fields_set + ): submission.resolved_at = None if "priority" in payload.model_fields_set: if payload.priority is None: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="priority cannot be null") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="priority cannot be null", + ) track_change("priority", submission.priority, payload.priority) submission.priority = payload.priority if "assigned_to" in payload.model_fields_set: if payload.assigned_to is not None: - user_result = await db.execute( - select(User).where(User.id == payload.assigned_to) - ) + user_result = await db.execute(select(User).where(User.id == payload.assigned_to)) assigned_user = user_result.scalar_one_or_none() if not assigned_user: raise HTTPException( diff --git a/backend-api/app/api/v1/router.py b/backend-api/app/api/v1/router.py index 0a06f296..c648004a 100644 --- a/backend-api/app/api/v1/router.py +++ b/backend-api/app/api/v1/router.py @@ -1,5 +1,15 @@ from fastapi import APIRouter -from app.api.v1 import auth, test, evidence, m365_connections, scans, benchmarks, platforms, contact +from app.api.v1 import ( + auth, + benchmarks, + contact, + evidence, + m365_connections, + platforms, + scans, + settings, + test, +) api_router = APIRouter() @@ -26,3 +36,6 @@ # Contact routes api_router.include_router(contact.router) + +# User settings routes +api_router.include_router(settings.router) diff --git a/backend-api/app/api/v1/scans.py b/backend-api/app/api/v1/scans.py index 5282d064..f48c7933 100644 --- a/backend-api/app/api/v1/scans.py +++ b/backend-api/app/api/v1/scans.py @@ -1,7 +1,7 @@ """Scan API endpoints.""" from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -184,3 +184,26 @@ async def get_scan_results( results = await db.execute(query) return list(results.scalars().all()) + + +@router.delete("/{scan_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_scan( + scan_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_async_session), +) -> None: + """Delete a scan (hard delete) and its results.""" + result = await db.execute( + select(Scan).where(Scan.id == scan_id, Scan.user_id == current_user.id) + ) + scan = result.scalar_one_or_none() + if not scan: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Scan {scan_id} not found", + ) + + # Delete dependent results first (FK is not ON DELETE CASCADE). + await db.execute(delete(ScanResult).where(ScanResult.scan_id == scan_id)) + await db.delete(scan) + await db.commit() diff --git a/backend-api/app/api/v1/settings.py b/backend-api/app/api/v1/settings.py new file mode 100644 index 00000000..85c508cb --- /dev/null +++ b/backend-api/app/api/v1/settings.py @@ -0,0 +1,56 @@ +"""User settings API endpoints.""" + +from fastapi import APIRouter, Depends, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.auth import get_current_user +from app.db.session import get_async_session +from app.models.user import User +from app.models.user_settings import UserSettings +from app.schemas.user_settings import UserSettingsRead, UserSettingsUpdate + +router = APIRouter(prefix="/settings", tags=["Settings"]) + + +async def _get_or_create_settings( + db: AsyncSession, + user_id: int, +) -> UserSettings: + result = await db.execute(select(UserSettings).where(UserSettings.user_id == user_id)) + settings = result.scalar_one_or_none() + if settings: + return settings + + settings = UserSettings(user_id=user_id) + db.add(settings) + await db.commit() + await db.refresh(settings) + return settings + + +@router.get("/", response_model=UserSettingsRead) +async def get_my_settings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_async_session), +) -> UserSettings: + """Get settings for the current user (creates defaults on first access).""" + return await _get_or_create_settings(db, current_user.id) + + +@router.patch("/", response_model=UserSettingsRead) +async def update_my_settings( + update: UserSettingsUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_async_session), +) -> UserSettings: + """Update settings for the current user.""" + settings = await _get_or_create_settings(db, current_user.id) + + if update.confirm_delete_enabled is not None: + settings.confirm_delete_enabled = update.confirm_delete_enabled + + await db.commit() + await db.refresh(settings) + return settings + diff --git a/backend-api/app/contactus api flow AutoAudit.jpg b/backend-api/app/contactus api flow AutoAudit.jpg new file mode 100644 index 00000000..6ca9e293 Binary files /dev/null and b/backend-api/app/contactus api flow AutoAudit.jpg differ diff --git a/backend-api/app/models/__init__.py b/backend-api/app/models/__init__.py index 004d1684..27c476e4 100644 --- a/backend-api/app/models/__init__.py +++ b/backend-api/app/models/__init__.py @@ -11,6 +11,7 @@ from app.models.compliance import Scan from app.models.evidence_validation import EvidenceValidation from app.models.contact import ContactSubmission, SubmissionNote, SubmissionHistory +from app.models.user_settings import UserSettings __all__ = [ "User", @@ -27,4 +28,5 @@ "ContactSubmission", "SubmissionNote", "SubmissionHistory", + "UserSettings", ] diff --git a/backend-api/app/models/user.py b/backend-api/app/models/user.py index 211d3689..7db3b99f 100644 --- a/backend-api/app/models/user.py +++ b/backend-api/app/models/user.py @@ -13,6 +13,7 @@ from app.models.m365_connection import M365Connection from app.models.contact import ContactSubmission, SubmissionHistory, SubmissionNote from app.models.oauth_account import OAuthAccount + from app.models.user_settings import UserSettings class Role(str, Enum): @@ -49,6 +50,12 @@ class User(SQLAlchemyBaseUserTable[int], Base): cascade="all, delete-orphan", lazy="selectin", ) + settings: Mapped["UserSettings"] = relationship( + back_populates="user", + uselist=False, + cascade="all, delete-orphan", + lazy="selectin", + ) m365_connections: Mapped[list["M365Connection"]] = relationship( back_populates="user" ) diff --git a/backend-api/app/models/user_settings.py b/backend-api/app/models/user_settings.py new file mode 100644 index 00000000..acec6de4 --- /dev/null +++ b/backend-api/app/models/user_settings.py @@ -0,0 +1,42 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, text +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.db.base import Base + + +class UserSettings(Base): + """Per-user application settings. + + One row per user (enforced via unique constraint on user_id). + """ + + __tablename__ = "user_settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + unique=True, + index=True, + ) + + # UI preference: show confirmation dialog before destructive deletes. + confirm_delete_enabled: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + server_default=text("true"), + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now(), onupdate=func.now() + ) + + user = relationship("User", back_populates="settings") + diff --git a/backend-api/app/schemas/user_settings.py b/backend-api/app/schemas/user_settings.py new file mode 100644 index 00000000..6bcb3e0d --- /dev/null +++ b/backend-api/app/schemas/user_settings.py @@ -0,0 +1,25 @@ +"""Pydantic schemas for per-user settings.""" + +from datetime import datetime + +from pydantic import BaseModel + + +class UserSettingsRead(BaseModel): + """Schema for reading user settings.""" + + id: int + user_id: int + confirm_delete_enabled: bool + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class UserSettingsUpdate(BaseModel): + """Schema for updating user settings.""" + + confirm_delete_enabled: bool | None = None + diff --git a/docker-compose.yml b/docker-compose.yml index 67c0de76..c0edaff0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -174,6 +174,7 @@ services: powershell-service: profiles: ["powershell", "all"] + platform: linux/amd64 build: context: ./engine/powershell dockerfile: Dockerfile diff --git a/engine/collectors/entra/applications/apps_and_services_settings.py b/engine/collectors/entra/applications/apps_and_services_settings.py index 43b00837..fed92ba9 100644 --- a/engine/collectors/entra/applications/apps_and_services_settings.py +++ b/engine/collectors/entra/applications/apps_and_services_settings.py @@ -5,7 +5,7 @@ Connection Method: Microsoft Graph API Required Scopes: OrgSettings-AppsAndServices.Read.All -Graph Endpoint: /admin/serviceAnnouncement/messages (or organization settings) +Graph Endpoint: /admin/appsAndServices (beta) """ from typing import Any @@ -27,18 +27,60 @@ async def collect(self, client: GraphClient) -> dict[str, Any]: Returns: Dict containing: - apps_and_services_settings: Apps and services configuration - - user_owned_apps_enabled: Whether users can own apps + - is_office_store_enabled: Whether users can access the Office Store + - is_app_and_services_trial_enabled: Whether users can start trials + - user_owned_apps_enabled: Back-compat derived signal (best-effort) """ - # Get organization settings for apps and services - # This uses the admin/microsoft365Apps settings endpoint + # Prefer the documented Apps & Services admin settings endpoint. + # Keep a fallback to the older path for compatibility with older tenants. + collector_error: str | None = None try: - settings = await client.get("/admin/microsoft365Apps/settings", beta=True) - except Exception: - # Fallback if endpoint not available - settings = {} + resp = await client.get("/admin/appsAndServices", beta=True) + raw_settings: dict[str, Any] | None = None + + # Handle a few plausible response shapes defensively. + if isinstance(resp, dict): + if isinstance(resp.get("settings"), dict): + raw_settings = resp.get("settings") + else: + value = resp.get("value") + if isinstance(value, dict) and isinstance(value.get("settings"), dict): + raw_settings = value.get("settings") + elif isinstance(value, list) and value and isinstance(value[0], dict): + if isinstance(value[0].get("settings"), dict): + raw_settings = value[0].get("settings") + + settings = raw_settings if raw_settings is not None else (resp or {}) + except Exception as exc: + collector_error = str(exc) + try: + settings = await client.get("/admin/microsoft365Apps/settings", beta=True) + except Exception as exc2: + collector_error = f"{collector_error} | fallback_error={exc2}" + settings = {} + + office_store_enabled = ( + settings.get("isOfficeStoreEnabled") if isinstance(settings, dict) else None + ) + trial_enabled = ( + settings.get("isAppAndServicesTrialEnabled") if isinstance(settings, dict) else None + ) + + # Best-effort derived signal used by the old policy implementation. + derived_user_owned_apps_enabled: bool | None + if isinstance(settings, dict) and "isUserAppsAndServicesEnabled" in settings: + derived_user_owned_apps_enabled = settings.get("isUserAppsAndServicesEnabled") + elif office_store_enabled is False and trial_enabled is False: + derived_user_owned_apps_enabled = False + elif office_store_enabled is True or trial_enabled is True: + derived_user_owned_apps_enabled = True + else: + derived_user_owned_apps_enabled = None return { "apps_and_services_settings": settings, - "user_owned_apps_enabled": settings.get("isUserAppsAndServicesEnabled"), - "is_office_store_enabled": settings.get("isOfficeStoreEnabled"), + "is_office_store_enabled": office_store_enabled, + "is_app_and_services_trial_enabled": trial_enabled, + "user_owned_apps_enabled": derived_user_owned_apps_enabled, + "collector_error": collector_error, } diff --git a/engine/collectors/entra/applications/forms_settings.py b/engine/collectors/entra/applications/forms_settings.py index 56452289..0e8291d1 100644 --- a/engine/collectors/entra/applications/forms_settings.py +++ b/engine/collectors/entra/applications/forms_settings.py @@ -31,29 +31,114 @@ async def collect(self, client: GraphClient) -> dict[str, Any]: - external_sharing_enabled: External sharing status """ # Get Microsoft Forms admin settings + collector_error: str | None = None + resp: dict[str, Any] = {} + entry: dict[str, Any] | None = None try: - settings = await client.get("/admin/forms/settings", beta=True) - except Exception: + resp = await client.get("/admin/forms/settings", beta=True) + raw_settings: dict[str, Any] | None = None + + # Handle a few plausible response shapes defensively. + if isinstance(resp, dict): + if isinstance(resp.get("settings"), dict): + raw_settings = resp.get("settings") + elif isinstance(resp.get("formsSettings"), dict): + raw_settings = resp.get("formsSettings") + else: + value = resp.get("value") + if isinstance(value, dict): + entry = value + if isinstance(value.get("settings"), dict): + raw_settings = value.get("settings") + elif isinstance(value.get("formsSettings"), dict): + raw_settings = value.get("formsSettings") + else: + raw_settings = value + elif isinstance(value, list) and value and isinstance(value[0], dict): + entry = value[0] + if isinstance(entry.get("settings"), dict): + raw_settings = entry.get("settings") + elif isinstance(entry.get("formsSettings"), dict): + raw_settings = entry.get("formsSettings") + else: + raw_settings = entry + + settings = raw_settings if raw_settings is not None else (resp or {}) + except Exception as exc: + collector_error = str(exc) settings = {} + def _get_setting_value(*keys: str) -> Any: + for source in (settings, entry, resp): + if isinstance(source, dict): + for key in keys: + if key in source: + return source.get(key) + return None + + def _normalize_bool(value: Any) -> bool | None: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"on", "enabled", "true", "yes"}: + return True + if normalized in {"off", "disabled", "false", "no"}: + return False + return None + + internal_phishing_protection_enabled = _normalize_bool( + _get_setting_value( + "isInternalPhishingProtectionEnabled", + "internalPhishingProtectionEnabled", + "isInOrgFormsPhishingScanEnabled", + "inOrgFormsPhishingScanEnabled", + "FormsPhishingProtection", + "formsPhishingProtection", + "phishingProtection", + ) + ) + return { "forms_settings": settings, - "internal_phishing_protection_enabled": settings.get( - "isInternalPhishingProtectionEnabled" + "internal_phishing_protection_enabled": internal_phishing_protection_enabled, + "external_sharing_enabled": _normalize_bool( + _get_setting_value("isExternalSharingEnabled", "externalSharingEnabled") + ), + "external_send_form_enabled": _normalize_bool( + _get_setting_value("isExternalSendFormEnabled", "externalSendFormEnabled") + ), + "external_share_collaborating_enabled": _normalize_bool( + _get_setting_value( + "isExternalShareCollaborationEnabled", + "isExternalShareCollaboratingEnabled", + "externalShareCollaboratingEnabled", + ) ), - "external_sharing_enabled": settings.get("isExternalSharingEnabled"), - "external_send_form_enabled": settings.get("isExternalSendFormEnabled"), - "external_share_collaborating_enabled": settings.get( - "isExternalShareCollaborationEnabled" + "external_share_template_enabled": _normalize_bool( + _get_setting_value( + "isExternalShareTemplateEnabled", + "externalShareTemplateEnabled", + ) ), - "external_share_template_enabled": settings.get( - "isExternalShareTemplateEnabled" + "external_share_result_enabled": _normalize_bool( + _get_setting_value( + "isExternalShareResultEnabled", + "externalShareResultEnabled", + ) ), - "external_share_result_enabled": settings.get( - "isExternalShareResultEnabled" + "bing_search_enabled": _normalize_bool( + _get_setting_value( + "isBingSearchEnabled", + "bingSearchEnabled", + "isBingImageSearchEnabled", + ) ), - "bing_search_enabled": settings.get("isBingSearchEnabled"), - "record_identity_by_default_enabled": settings.get( - "isRecordIdentityByDefaultEnabled" + "record_identity_by_default_enabled": _normalize_bool( + _get_setting_value( + "isRecordIdentityByDefaultEnabled", + "recordIdentityByDefaultEnabled", + ) ), + "collector_error": collector_error, } diff --git a/engine/legacy/rules-gcp/CIS_GCP_2_10.rego b/engine/legacy/rules-gcp/CIS_GCP_2_10.rego index e36e0f88..af9504de 100644 --- a/engine/legacy/rules-gcp/CIS_GCP_2_10.rego +++ b/engine/legacy/rules-gcp/CIS_GCP_2_10.rego @@ -5,8 +5,6 @@ id := "CIS_GCP_2_10" title := "Ensure That the Log Metric Filter and Alerts Exist for Cloud Storage IAM Permission Changes" policy_group := "Logging and Monitoring" -<<<<<<< HEAD -======= verification := `1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: @@ -27,7 +25,6 @@ remediation := `Create the prescribed Log Metric: Create the prescribed alert policy: • Use the command: gcloud alpha monitoring policies create` ->>>>>>> 99b403da (finished final group 2 policy tagging with verification and remediaton) deny := { v | not gcs_iam_metric_exists v := sprintf("Project %q: Missing metric for Storage IAM permission changes", [input.project_id]) @@ -51,8 +48,4 @@ gcs_iam_alert_enabled_for_user_metric { contains(filt, "logging.googleapis.com/user/") } -<<<<<<< HEAD -report := H.build_report(deny, id, title, policy_group) -======= report := H.build_report(deny, id, title, policy_group, verification, remediation) ->>>>>>> 99b403da (finished final group 2 policy tagging with verification and remediaton) diff --git a/engine/legacy/rules-gcp/CIS_GCP_2_11.rego b/engine/legacy/rules-gcp/CIS_GCP_2_11.rego index a39c1d85..657c360b 100644 --- a/engine/legacy/rules-gcp/CIS_GCP_2_11.rego +++ b/engine/legacy/rules-gcp/CIS_GCP_2_11.rego @@ -5,8 +5,6 @@ id := "CIS_GCP_2_11" title := "Ensure That the Log Metric Filter and Alerts Exist for SQL Instance Configuration Changes" policy_group := "Logging and Monitoring" -<<<<<<< HEAD -======= verification := `1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to @@ -28,7 +26,6 @@ Create the prescribed alert policy: • Reference for command usage: https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create` ->>>>>>> 99b403da (finished final group 2 policy tagging with verification and remediaton) deny := { v | not cloudsql_metric_exists v := sprintf("Project %q: Missing metric for Cloud SQL instance updates", [input.project_id]) @@ -52,8 +49,4 @@ cloudsql_alert_enabled_for_user_metric { contains(filt, "logging.googleapis.com/user/") } -<<<<<<< HEAD -report := H.build_report(deny, id, title, policy_group) -======= report := H.build_report(deny, id, title, policy_group, verification, remediation) ->>>>>>> 99b403da (finished final group 2 policy tagging with verification and remediaton) diff --git a/engine/legacy/rules-gcp/CIS_GCP_2_4.rego b/engine/legacy/rules-gcp/CIS_GCP_2_4.rego index 11c6c65f..d94022a8 100644 --- a/engine/legacy/rules-gcp/CIS_GCP_2_4.rego +++ b/engine/legacy/rules-gcp/CIS_GCP_2_4.rego @@ -5,8 +5,6 @@ id := "CIS_GCP_2_4" title := "Ensure Log Metric Filter and Alerts Exist for Project Ownership Changes" policy_group := "Logging and Monitoring" -<<<<<<< HEAD -======= verification := `1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with filter set to: @@ -34,7 +32,6 @@ Create prescribed Alert Policy • Reference for Command Usage: https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create` ->>>>>>> 99b403da (finished final group 2 policy tagging with verification and remediaton) deny := { v | not project_owner_metric_exists v := sprintf("Project %q: Missing logs-based metric for project ownership changes", [input.project_id]) @@ -59,8 +56,4 @@ alert_policy_referencing_user_metric_enabled { contains(c.conditionThreshold.filter, "logging.googleapis.com/user/") } -<<<<<<< HEAD -report := H.build_report(deny, id, title, policy_group) -======= report := H.build_report(deny, id, title, policy_group, verification, remediation) ->>>>>>> 99b403da (finished final group 2 policy tagging with verification and remediaton) diff --git a/engine/legacy/rules-gcp/CIS_GCP_2_5.rego b/engine/legacy/rules-gcp/CIS_GCP_2_5.rego index bb86ced6..4879c8db 100644 --- a/engine/legacy/rules-gcp/CIS_GCP_2_5.rego +++ b/engine/legacy/rules-gcp/CIS_GCP_2_5.rego @@ -5,8 +5,6 @@ id := "CIS_GCP_2_5" title := "Ensure That the Log Metric Filter and Alerts Exist for Audit Configuration Changes" policy_group := "Logging and Monitoring" -<<<<<<< HEAD -======= verification := `1. List the log metrics: gcloud beta logging metrics list --format json @@ -32,7 +30,6 @@ Create prescribed Alert Policy • Reference for command usage: https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create` ->>>>>>> 99b403da (finished final group 2 policy tagging with verification and remediaton) deny := { v | not audit_config_metric_exists v := sprintf("Project %q: Missing logs-based metric for audit config changes (SetIamPolicy)", [input.project_id]) @@ -57,8 +54,4 @@ alert_policy_referencing_user_metric_enabled { contains(filt, "logging.googleapis.com/user/") } -<<<<<<< HEAD -report := H.build_report(deny, id, title, policy_group) -======= report := H.build_report(deny, id, title, policy_group, verification, remediation) ->>>>>>> 99b403da (finished final group 2 policy tagging with verification and remediaton) diff --git a/engine/legacy/rules-gcp/CIS_GCP_2_6.rego b/engine/legacy/rules-gcp/CIS_GCP_2_6.rego index 08f210cb..5d6e14b5 100644 --- a/engine/legacy/rules-gcp/CIS_GCP_2_6.rego +++ b/engine/legacy/rules-gcp/CIS_GCP_2_6.rego @@ -5,8 +5,6 @@ id := "CIS_GCP_2_6" title := "Ensure That the Log Metric Filter and Alerts Exist for Custom Role Changes" policy_group := "Logging and Monitoring" -<<<<<<< HEAD -======= verification := `Ensure that the prescribed log metric is present: 1. List the log metrics: gcloud logging metrics list --format json @@ -31,7 +29,6 @@ remediation := `Create the prescribed Log Metric: Create the prescribed Alert Policy: • Use the command: gcloud alpha monitoring policies create` ->>>>>>> 99b403da (finished final group 2 policy tagging with verification and remediaton) deny := { v | not custom_role_metric_exists v := sprintf("Project %q: Missing logs-based metric for IAM custom role changes", [input.project_id]) @@ -58,8 +55,4 @@ alert_policy_referencing_user_metric_enabled { contains(filt, "logging.googleapis.com/user/") } -<<<<<<< HEAD -report := H.build_report(deny, id, title, policy_group) -======= report := H.build_report(deny, id, title, policy_group, verification, remediation) ->>>>>>> 99b403da (finished final group 2 policy tagging with verification and remediaton) diff --git a/engine/legacy/rules-gcp/CIS_GCP_2_7.rego b/engine/legacy/rules-gcp/CIS_GCP_2_7.rego index 49a5452e..4df10b69 100644 --- a/engine/legacy/rules-gcp/CIS_GCP_2_7.rego +++ b/engine/legacy/rules-gcp/CIS_GCP_2_7.rego @@ -5,8 +5,6 @@ id := "CIS_GCP_2_7" title := "Ensure That the Log Metric Filter and Alerts Exist for VPC Network Firewall Rule Changes" policy_group := "Logging and Monitoring" -<<<<<<< HEAD -======= verification := `1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: @@ -29,7 +27,6 @@ remediation := `Create the prescribed Log Metric Create the prescribed alert policy: • Use the command: gcloud alpha monitoring policies create` ->>>>>>> 99b403da (finished final group 2 policy tagging with verification and remediaton) deny := { v | not firewall_metric_exists v := sprintf("Project %q: Missing metric for firewall rule changes", [input.project_id]) @@ -58,8 +55,4 @@ firewall_alert_enabled_for_user_metric { contains(filt, "logging.googleapis.com/user/") } -<<<<<<< HEAD -report := H.build_report(deny, id, title, policy_group) -======= report := H.build_report(deny, id, title, policy_group, verification, remediation) ->>>>>>> 99b403da (finished final group 2 policy tagging with verification and remediaton) diff --git a/engine/legacy/rules-gcp/CIS_GCP_2_8.rego b/engine/legacy/rules-gcp/CIS_GCP_2_8.rego index fc68b98c..3aae06d1 100644 --- a/engine/legacy/rules-gcp/CIS_GCP_2_8.rego +++ b/engine/legacy/rules-gcp/CIS_GCP_2_8.rego @@ -5,8 +5,6 @@ id := "CIS_GCP_2_8" title := "Ensure Log Metric Filter and Alerts Exist for VPC Route Changes" policy_group := "Logging and Monitoring" -<<<<<<< HEAD -======= verification := `1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: @@ -28,7 +26,6 @@ remediation := `Create the prescribed Log Metric: Create the prescribed the alert policy: • Use the command: gcloud alpha monitoring policies create` ->>>>>>> 99b403da (finished final group 2 policy tagging with verification and remediaton) deny := { v | not route_metric_exists v := sprintf("Project %q: Missing logs-based metric for VPC route/peering changes", [input.project_id]) @@ -55,8 +52,4 @@ alert_policy_referencing_user_metric_enabled { contains(c.conditionThreshold.filter, "logging.googleapis.com/user/") } -<<<<<<< HEAD -report := H.build_report(deny, id, title, policy_group) -======= report := H.build_report(deny, id, title, policy_group, verification, remediation) ->>>>>>> 99b403da (finished final group 2 policy tagging with verification and remediaton) diff --git a/engine/legacy/rules-gcp/CIS_GCP_2_9.rego b/engine/legacy/rules-gcp/CIS_GCP_2_9.rego index 05dc7939..2c9e381d 100644 --- a/engine/legacy/rules-gcp/CIS_GCP_2_9.rego +++ b/engine/legacy/rules-gcp/CIS_GCP_2_9.rego @@ -5,8 +5,6 @@ id := "CIS_GCP_2_9" title := "Ensure Log Metric Filter and Alerts Exist for VPC Network Changes" policy_group := "Logging and Monitoring" -<<<<<<< HEAD -======= verification := `1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with filter set to: @@ -31,7 +29,6 @@ remediation := `Create the prescribed Log Metric: Create the prescribed alert policy: • Use the command: gcloud alpha monitoring policies create` ->>>>>>> 99b403da (finished final group 2 policy tagging with verification and remediaton) deny := { v | not vpc_network_metric_exists v := sprintf("Project %q: Missing logs-based metric for VPC network create/update/delete", [input.project_id]) @@ -54,8 +51,4 @@ alert_policy_referencing_user_metric_enabled { contains(c.conditionThreshold.filter, "logging.googleapis.com/user/") } -<<<<<<< HEAD -report := H.build_report(deny, id, title, policy_group) -======= report := H.build_report(deny, id, title, policy_group, verification, remediation) ->>>>>>> 99b403da (finished final group 2 policy tagging with verification and remediaton) diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.3.4_user_owned_apps_restricted.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.3.4_user_owned_apps_restricted.rego index 0b0308a9..c55b2cb9 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.3.4_user_owned_apps_restricted.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.3.4_user_owned_apps_restricted.rego @@ -15,7 +15,7 @@ # severity: medium # service: EntraID # requires_permissions: -# - Policy.Read.All +# - OrgSettings-AppsAndServices.Read.All package cis.microsoft_365_foundations.v6_0_0.control_1_3_4 @@ -27,25 +27,45 @@ default result := { "details": {} } +has_modern := true if { + input.is_office_store_enabled != null + input.is_app_and_services_trial_enabled != null +} else := false if { true } + +unknown := true if { input.user_owned_apps_enabled == null; input.is_office_store_enabled == null } else := true if { input.user_owned_apps_enabled == null; input.is_app_and_services_trial_enabled == null } else := false if { true } + +compliant := true if { + has_modern + input.is_office_store_enabled == false + input.is_app_and_services_trial_enabled == false +} else := true if { + not has_modern + input.user_owned_apps_enabled == false +} else := false if { true } + result := output if { - enabled := input.user_owned_apps_enabled + office := input.is_office_store_enabled + trial := input.is_app_and_services_trial_enabled + legacy_enabled := input.user_owned_apps_enabled output := { # CIS intent: restricted => disabled - "compliant": enabled == false, - "message": generate_message(enabled), - "affected_resources": generate_affected(enabled), + "compliant": compliant, + "message": generate_message(compliant, unknown), + "affected_resources": generate_affected(compliant, unknown), "details": { - "user_owned_apps_enabled": enabled, - "is_office_store_enabled": input.is_office_store_enabled + "is_office_store_enabled": office, + "is_app_and_services_trial_enabled": trial, + "user_owned_apps_enabled": legacy_enabled, + "collector_error": input.collector_error } } } -generate_message(true) := "User owned apps and services are not restricted (enabled)" -generate_message(false) := "User owned apps and services are restricted (disabled)" -generate_message(null) := "Unable to determine whether user owned apps and services are restricted" +generate_message(true, false) := "User owned apps and services are restricted (disabled)" +generate_message(false, false) := "User owned apps and services are not restricted (enabled)" +generate_message(_, true) := "Unable to determine whether user owned apps and services are restricted" -generate_affected(false) := [] -generate_affected(true) := ["User owned apps and services are enabled"] -generate_affected(null) := ["User owned apps and services setting unknown"] +generate_affected(true, false) := [] +generate_affected(false, false) := ["User owned apps and services are enabled"] +generate_affected(_, true) := ["User owned apps and services setting unknown"] diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.3.5_forms_internal_phishing_protection.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.3.5_forms_internal_phishing_protection.rego index 17342aa2..09d2282a 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.3.5_forms_internal_phishing_protection.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.3.5_forms_internal_phishing_protection.rego @@ -42,7 +42,8 @@ result := output if { "external_share_template_enabled": input.external_share_template_enabled, "external_share_result_enabled": input.external_share_result_enabled, "bing_search_enabled": input.bing_search_enabled, - "record_identity_by_default_enabled": input.record_identity_by_default_enabled + "record_identity_by_default_enabled": input.record_identity_by_default_enabled, + "collector_error": input.collector_error } } } diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.11_Comprehensive_Attachment_Filtering_Applied.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.11_Comprehensive_Attachment_Filtering_Applied.rego index 0986dc9c..5514ac7f 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.11_Comprehensive_Attachment_Filtering_Applied.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.11_Comprehensive_Attachment_Filtering_Applied.rego @@ -22,6 +22,8 @@ package cis.microsoft_365_foundations.v6_0_0.control_2_1_11 import rego.v1 +default result := {"compliant": false, "message": "Evaluation failed"} + attach_exts := [ "7z","a3x","ace","ade","adp","ani","app","appinstaller","applescript","application", "appref-ms","appx","appxbundle","arj","asd","asx","bas","bat","bgi","bz2","cab", @@ -42,69 +44,38 @@ attach_exts := [ passing_value := 0.9 -missing_exts[policy_identity] = missing if { - policy := input.malware_filter_policies[_] - policy_identity := policy.Identity - - missing := [ext | - ext := attach_exts[_] - not ext in policy.FileTypes - ] -} - -is_compliant[policy_identity] if { - policy := input.malware_filter_policies[_] - policy_identity := policy.Identity - - missing := missing_exts[policy_identity] - fail_threshold := count(attach_exts) * (1 - passing_value) +policy := object.get(input, "default_policy", null) +has_policy := policy != null - count(missing) <= fail_threshold - policy.EnableFileFilter == true -} - -generate_message(true) := "Attachment filtering policy is correctly configured and enforced" -generate_message(false) := "Attachment filtering policy is misconfigured or not enforced" +enable_file_filter := object.get(policy, "EnableFileFilter", object.get(input, "enable_file_filter", null)) +file_types := object.get(policy, "FileTypes", object.get(input, "file_types", [])) -generate_affected_resources(true, _) := [] - -generate_affected_resources(false, data_input) := [ - pol.Identity | - pol := data_input.malware_filter_policies[_] -] +missing := [ext | ext := attach_exts[_]; not ext in file_types] +fail_threshold := count(attach_exts) * (1 - passing_value) +coverage_ok := count(missing) <= fail_threshold -policies := object.get(input, "malware_filter_policies", []) -has_policies := count(policies) > 0 - -non_compliant_policies := [ - pol.Identity | - pol := policies[_] - not is_compliant[pol.Identity] -] +compliant := true if { + has_policy + enable_file_filter == true + coverage_ok +} else := false if { true } result := { - "compliant": false, - "message": "No malware filter policies found", - "affected_resources": ["MalwareFilterPolicy"], + "compliant": compliant, + "message": generate_message(compliant, has_policy), + "affected_resources": generate_affected_resources(compliant, has_policy), "details": { - "malware_filter_policies_evaluated": 0, + "EnableFileFilter": enable_file_filter, + "missing_count": count(missing), + "missing_extensions": missing, "passing_threshold": passing_value, - "total_known_extensions": count(attach_exts) + "known_extensions_count": count(attach_exts) } -} if { - not has_policies } -result := { - "compliant": count(non_compliant_policies) == 0, - "message": generate_message(count(non_compliant_policies) == 0), - "affected_resources": generate_affected_resources(count(non_compliant_policies) == 0, input), - "details": { - "malware_filter_policies_evaluated": count(policies), - "non_compliant_policies": non_compliant_policies, - "passing_threshold": passing_value, - "total_known_extensions": count(attach_exts) - } -} if { - has_policies -} +generate_message(true, _) := "Comprehensive attachment filtering is applied and covers the expected set of extensions." +generate_message(false, false) := "No malware filter policy (default_policy) was found to evaluate attachment filtering." +generate_message(false, true) := "Comprehensive attachment filtering is not applied or does not cover enough extensions." + +generate_affected_resources(true, _) := [] +generate_affected_resources(false, _) := ["MalwareFilterPolicy"] diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.12_ConnectionFilter_IPAllowList_not_used.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.12_ConnectionFilter_IPAllowList_not_used.rego index 582482a6..923d357b 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.12_ConnectionFilter_IPAllowList_not_used.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.12_ConnectionFilter_IPAllowList_not_used.rego @@ -2,11 +2,12 @@ # title: Ensure the connection filter IP allow list is not used # description: | # In Microsoft 365 organizations with Exchange Online mailboxes or standalone -# Exchange Online Protection organizations without Exchange Online mailboxes, +# Exchange Online Protection organizations without Exchange Online mailboxes # connection filtering and the default connection filter policy identify good or # bad source email servers by IP addresses. The key components of the default connection # filter policy are IP Allow List, IP Block List and Safe list. # The recommended state is IP Allow List empty or undefined. +# # related_resources: # - ref: https://www.cisecurity.org/benchmark/microsoft_365 # description: CIS Microsoft 365 Foundations Benchmark @@ -22,39 +23,44 @@ package cis.microsoft_365_foundations.v6_0_0.control_2_1_12 -import rego.v1 - default result := {"compliant": false, "message": "Evaluation failed"} -policies := object.get(input, "connection_filter_policies", []) - -non_compliant_policies := [ - { - "identity": object.get(p, "Identity", object.get(p, "Name", null)), - "ip_allow_list": object.get(p, "IPAllowList", []) - } | - p := policies[_] - count(object.get(p, "IPAllowList", [])) > 0 -] - -compliant := count(non_compliant_policies) == 0 - -result := { - "compliant": compliant, - "message": messages[compliant], - "affected_resources": affected_resources[compliant], - "details": { - "policies_evaluated": count(policies), - "non_compliant_policies": non_compliant_policies - } +# Required IPAllowList setting +required_fields := { + "IPAllowList": [] } -messages := { - true: "IPAllowList is empty for all Exchange Online Hosted Connection Filter policies", - false: "IPAllowList is not empty for one or more Exchange Online Hosted Connection Filter policies" -} +ip_allow_list := object.get( + input, + "ip_allow_list", + object.get(object.get(input, "default_policy", {}), "IPAllowList", null) +) + +ip_allow_list_is_empty := null if { + ip_allow_list == null +} else := true if { + ip_allow_list == [] # empty array +} else := true if { + ip_allow_list == {} # empty object +} else := false -affected_resources := { - true: [], - false: ["HostedConnectionFilterPolicy"] +result := output if { + compliant := ip_allow_list_is_empty == true + + output := { + "compliant": compliant, + "message": generate_message(ip_allow_list_is_empty), + "affected_resources": generate_affected_resources(ip_allow_list_is_empty), + "details": { + "IPAllowList": ip_allow_list + } + } } + +generate_message(true) := "IPAllowList is empty or {} in Exchange Online Hosted Connection Filter" +generate_message(false) := "IPAllowList is not empty or is not {} in Exchange Online Hosted Connection Filter" +generate_message(null) := "Unable to determine the IPAllowList status in Exchange Online Hosted Connection Filter" + +generate_affected_resources(true) := [] +generate_affected_resources(false) := ["HostedConnectionFilterPolicy"] +generate_affected_resources(null) := ["HostedConnectionFilterPolicy status unknown"] diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.13_Connection_Filter_SafeList_Off.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.13_Connection_Filter_SafeList_Off.rego index 6718c79c..e4b3728e 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.13_Connection_Filter_SafeList_Off.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.13_Connection_Filter_SafeList_Off.rego @@ -24,22 +24,44 @@ package cis.microsoft_365_foundations.v6_0_0.control_2_1_13 -import rego.v1 - default result := {"compliant": false, "message": "Evaluation failed"} -enable_safe_list := object.get(input, "enable_safe_list", false) - -result := { - "compliant": true, - "message": "EnableSafeList is False for Exchange Online Hosted Connection Filter", - "affected_resources": [], - "details": {"EnableSafeList": enable_safe_list} -} if enable_safe_list == false - -result := { - "compliant": false, - "message": "EnableSafeList is not False for Exchange Online Hosted Connection Filter", - "affected_resources": ["HostedConnectionFilterPolicy"], - "details": {"EnableSafeList": enable_safe_list} -} if enable_safe_list == true +# Required EnableSafeList setting +required_fields := { + "EnableSafeList": false +} + +enable_safe_list := object.get( + input, + "enable_safe_list", + object.get(object.get(input, "default_policy", {}), "EnableSafeList", null) +) + +enable_safe_list_is_false := null if { + enable_safe_list == null +} else := true if { + enable_safe_list == false # Ensure EnableSafeList is False +} else := false if { + enable_safe_list == true # If EnableSafeList is True, it's non-compliant +} else := null + +result := output if { + compliant := enable_safe_list_is_false == true + + output := { + "compliant": compliant, + "message": generate_message(enable_safe_list_is_false), + "affected_resources": generate_affected_resources(enable_safe_list_is_false), + "details": { + "EnableSafeList": enable_safe_list + } + } +} + +generate_message(true) := "EnableSafeList is False for Exchange Online Hosted Connection Filter" +generate_message(false) := "EnableSafeList is not False for Exchange Online Hosted Connection Filter" +generate_message(null) := "Unable to determine the EnableSafeList status in Exchange Online Hosted Connection Filter" + +generate_affected_resources(true) := [] +generate_affected_resources(false) := ["HostedConnectionFilterPolicy"] +generate_affected_resources(null) := ["HostedConnectionFilterPolicy status unknown"] diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.14_Inbound_AntiSpam_Policies_DoNot_AllowedDomains.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.14_Inbound_AntiSpam_Policies_DoNot_AllowedDomains.rego index ecf75ec9..dc99b224 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.14_Inbound_AntiSpam_Policies_DoNot_AllowedDomains.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.14_Inbound_AntiSpam_Policies_DoNot_AllowedDomains.rego @@ -25,30 +25,46 @@ package cis.microsoft_365_foundations.v6_0_0.control_2_1_14 -import rego.v1 - default result := {"compliant": false, "message": "Evaluation failed"} -allowed_sender_domains := object.get(input, "allowed_sender_domains", []) +allowed_sender_domains := object.get( + input, + "allowed_sender_domains", + object.get(object.get(input, "default_policy", {}), "AllowedSenderDomains", null) +) -allowed_sender_domains_undefined := true if count(allowed_sender_domains) == 0 -allowed_sender_domains_undefined := false if count(allowed_sender_domains) > 0 +allowed_sender_domains_undefined := true if { + allowed_sender_domains != null + count(allowed_sender_domains) == 0 +} -result := { - "compliant": allowed_sender_domains_undefined, - "message": messages[allowed_sender_domains_undefined], - "affected_resources": affected_resources[allowed_sender_domains_undefined], - "details": { - "allowed_sender_domains": allowed_sender_domains - } +allowed_sender_domains_undefined := false if { + allowed_sender_domains != null + count(allowed_sender_domains) > 0 } -messages := { - true: "AllowedSenderDomains is undefined or empty for the policy", - false: "AllowedSenderDomains is defined for the policy" +allowed_sender_domains_undefined := null if { + allowed_sender_domains == null } -affected_resources := { - true: [], - false: ["HostedContentFilterPolicy"] +result := output if { + # Ensure all inbound policies pass + compliant := allowed_sender_domains_undefined == true + + output := { + "compliant": compliant, + "message": generate_message(allowed_sender_domains_undefined), + "affected_resources": generate_affected_resources(allowed_sender_domains_undefined), + "details": { + "AllowedSenderDomains": allowed_sender_domains + } + } } + +generate_message(true) := "AllowedSenderDomains is undefined for the policy" +generate_message(false) := "AllowedSenderDomains is defined for the policy" +generate_message(null) := "Unable to determine the AllowedSenderDomains status for the policy" + +generate_affected_resources(true) := [] +generate_affected_resources(false) := ["HostedContentFilterPolicy"] +generate_affected_resources(null) := ["HostedContentFilterPolicy status unknown"] diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.15_Outbound_AntiSpam_MessageLimits_InPlace.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.15_Outbound_AntiSpam_MessageLimits_InPlace.rego index 38b06b8d..b7037015 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.15_Outbound_AntiSpam_MessageLimits_InPlace.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.15_Outbound_AntiSpam_MessageLimits_InPlace.rego @@ -24,121 +24,63 @@ package cis.microsoft_365_foundations.v6_0_0.control_2_1_15 -import rego.v1 +default result := {"compliant": false, "message": "Evaluation failed"} -# Expected policy values -required_policy_fields := { - "RecipientLimitExternalPerHour": 500, - "RecipientLimitInternalPerHour": 1000, - "RecipientLimitPerDay": 1000, -} - -valid_actions := {"BlockUser", "RestrictUser", "Restrict"} -missing_sentinel := "__missing__" - -limits_compliant if { - policy != null - - all_keys_correct := {k | k in required_policy_fields; policy[k] == required_policy_fields[k]} - count(all_keys_correct) == count(required_policy_fields) - - # Action must match one of the valid options - policy.ActionWhenThresholdReached in valid_actions -} - -limits_compliant := false if { - policy != null - some k - required_policy_fields[k] - object.get(policy, k, missing_sentinel) == missing_sentinel -} +policy := object.get(input, "default_policy", {}) -limits_compliant := false if { - policy != null - object.get(policy, "ActionWhenThresholdReached", missing_sentinel) == missing_sentinel -} - -limits_compliant := false if { - policy != null - some k - required_policy_fields[k] - object.get(policy, k, missing_sentinel) != missing_sentinel - policy[k] != required_policy_fields[k] +required_policy_fields := { + "RecipientLimitExternalPerHour": 500, + "RecipientLimitInternalPerHour": 1000, + "RecipientLimitPerDay": 1000, + "ActionWhenThresholdReached": "BlockUser", + "NotifyOutboundSpamRecipients": {"monitored@example.com"} } -limits_compliant := false if { - policy != null - object.get(policy, "ActionWhenThresholdReached", missing_sentinel) != missing_sentinel - not policy.ActionWhenThresholdReached in valid_actions +# Function to validate individual policy settings +validate_policy_setting(setting_name, setting_value) if { + required_policy_fields[setting_name] == setting_value } -policy := value if { - input != null - value := object.get(input, "default_policy", null) -} else = null -has_policy := policy != null - -safe_get(obj, key) = value if { - obj != null - value := object.get(obj, key, null) -} else = null - -policy_detail_fields := { - "RecipientLimitExternalPerHour", - "RecipientLimitInternalPerHour", - "RecipientLimitPerDay", - "ActionWhenThresholdReached", +validate_notify_outbound_spam_recipients if { + count(policy.NotifyOutboundSpamRecipients) > 0 } -add_if_not_null(obj, key, value) = out if { - value != null - out := object.union(obj, {key: value}) -} else = obj - -details := out if { - base := { - "required_policy_settings": required_policy_fields, - "valid_actions": valid_actions, - } - - v_external := safe_get(policy, "RecipientLimitExternalPerHour") - d1 := add_if_not_null(base, "RecipientLimitExternalPerHour", v_external) - - v_internal := safe_get(policy, "RecipientLimitInternalPerHour") - d2 := add_if_not_null(d1, "RecipientLimitInternalPerHour", v_internal) - - v_day := safe_get(policy, "RecipientLimitPerDay") - d3 := add_if_not_null(d2, "RecipientLimitPerDay", v_day) - - v_action := safe_get(policy, "ActionWhenThresholdReached") - out := add_if_not_null(d3, "ActionWhenThresholdReached", v_action) +compliant if { + # Validate that all required policy fields match + count({ + k | + required_policy_fields[k] == policy[k] + }) == count(required_policy_fields) } -compliant := true if { - has_policy - limits_compliant -} +compliant_message := "Outbound spam filter policy is correctly configured and meets required standards" -compliant := false if { - has_policy - not limits_compliant -} +non_compliant_message := "Outbound spam filter policy settings are misconfigured or incomplete" -compliant := false if { - not has_policy -} +unknown_message := "Unable to determine outbound spam filter policy configuration" -compliant_message := "Outbound spam filter policy is correctly configured for message limits and over-limit action" -non_compliant_message := "Outbound spam filter policy settings for message limits or over-limit action are misconfigured" generate_message(true) := compliant_message generate_message(false) := non_compliant_message +generate_message(null) := unknown_message generate_affected_resources(true, _) := [] -generate_affected_resources(false, _) := ["Outbound Spam Filter Policy"] + +generate_affected_resources(false, data_input) := [ + "Outbound Spam Filter Policy" +] + +generate_affected_resources(null, _) := ["Outbound Spam Filter Policy configuration status unknown"] result := { - "compliant": compliant, - "message": generate_message(compliant), - "affected_resources": generate_affected_resources(compliant, input), - "details": details + "compliant": compliant == true, + "message": generate_message(compliant), + "affected_resources": generate_affected_resources(compliant, input), + "details": { + "RecipientLimitExternalPerHour": policy.RecipientLimitExternalPerHour, + "RecipientLimitInternalPerHour": policy.RecipientLimitInternalPerHour, + "RecipientLimitPerDay": policy.RecipientLimitPerDay, + "ActionWhenThresholdReached": policy.ActionWhenThresholdReached, + "NotifyOutboundSpamRecipients": policy.NotifyOutboundSpamRecipients, + "required_policy_settings": required_policy_fields + } } diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.1_SafeLinks_OfficeApplications_Enabled.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.1_SafeLinks_OfficeApplications_Enabled.rego index 930f55d0..1df9ea82 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.1_SafeLinks_OfficeApplications_Enabled.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.1_SafeLinks_OfficeApplications_Enabled.rego @@ -20,104 +20,48 @@ package cis.microsoft_365_foundations.v6_0_0.control_2_1_1 -import rego.v1 - default result := {"compliant": false, "message": "Evaluation failed"} -required_fields := { - "EnableSafeLinksForEmail": true, - "EnableSafeLinksForTeams": true, - "EnableSafeLinksForOffice": true, - "TrackClicks": true, - "AllowClickThrough": false, - "ScanUrls": true, - "EnableForInternalSenders": true, - "DeliverMessageAfterScan": true, - "DisableUrlRewrite": false -} - -missing_sentinel := "__missing__" - -field_non_compliant(p, f) if { - object.get(p, f, missing_sentinel) == missing_sentinel -} - -field_non_compliant(p, f) if { - object.get(p, f, missing_sentinel) != missing_sentinel - p[f] != required_fields[f] -} - -non_compliant_fields(p) := fields if { - fields := {f | - some f - required_fields[f] = _ # f is a key in required_fields (even if value is false) - field_non_compliant(p, f) - } -} - -policy_compliant(p) := true if { - count(non_compliant_fields(p)) == 0 -} - -policy_compliant(p) := false if { - count(non_compliant_fields(p)) > 0 -} +safe_links_policies := object.get(input, "safe_links_policies", []) -generate_message(true, _) := "All Safe Links policies for Office applications are compliant" -generate_message_no_policies := "No Safe Links policies found for Office applications" +office_enabled_policies := [p | + p := safe_links_policies[_] + p.EnableSafeLinksForOffice == true +] -generate_message(false, non_compliant) := sprintf( - "%d Safe Links policy(ies) for Office applications are not compliant", - [count(non_compliant)] -) +non_compliant_policies := [p | + p := safe_links_policies[_] + p.EnableSafeLinksForOffice != true +] -generate_affected_resources(true, _) := [] +policy_name(p) := name if { + name := object.get(p, "Name", null) + name != null +} else := identity if { + identity := object.get(p, "Identity", null) + identity != null +} else := "Unknown policy" -generate_affected_resources(false, non_compliant) := resources if { - resources := [ - { - "identity": object.get(p, "Identity", object.get(p, "Name", null)), - "non_compliant_fields": [f | f := non_compliant_fields(p)[_]] - } | - p := non_compliant[_] - ] -} +generate_message(true, _) := "Safe Links for Office applications is enabled in at least one policy." +generate_message(false, false) := "No Safe Links policies were found to evaluate." +generate_message(false, true) := "Safe Links for Office applications is not enabled in any policy." -no_policies_result := { - "compliant": false, - "message": generate_message_no_policies, - "affected_resources": [{"identity": "Safe Links policy", "non_compliant_fields": ["missing_policy"]}], - "details": { - "policies_checked": [], - "required_fields": required_fields - } -} - -with_policies_result(policies, non_compliant) := { - "compliant": count(non_compliant) == 0, - "message": generate_message(count(non_compliant) == 0, non_compliant), - "affected_resources": generate_affected_resources(count(non_compliant) == 0, non_compliant), - "details": { - "policies_checked": [object.get(p, "Identity", object.get(p, "Name", null)) | p := policies[_]], - "required_fields": required_fields - } -} +generate_affected_resources(true, _, _) := [] +generate_affected_resources(false, false, _) := ["SafeLinksPolicy"] +generate_affected_resources(false, true, non_compliant) := [policy_name(p) | p := non_compliant[_]] result := output if { - policies := object.get(input, "safe_links_policies", []) - count(policies) == 0 - output := no_policies_result -} - -result := output if { - policies := object.get(input, "safe_links_policies", []) - count(policies) > 0 - - non_compliant := [ - p | - p := policies[_] - not policy_compliant(p) - ] - - output := with_policies_result(policies, non_compliant) + has_policies := count(safe_links_policies) > 0 + compliant := count(office_enabled_policies) > 0 + + output := { + "compliant": compliant, + "message": generate_message(compliant, has_policies), + "affected_resources": generate_affected_resources(compliant, has_policies, non_compliant_policies), + "details": { + "total_policies": count(safe_links_policies), + "office_enabled_count": count(office_enabled_policies), + "office_enabled_policies": [policy_name(p) | p := office_enabled_policies[_]] + } + } } diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.2_Common_AttachmentTypes_Enabled.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.2_Common_AttachmentTypes_Filter_Enabled.rego similarity index 61% rename from engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.2_Common_AttachmentTypes_Enabled.rego rename to engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.2_Common_AttachmentTypes_Filter_Enabled.rego index a21a1916..52a5918d 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.2_Common_AttachmentTypes_Enabled.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.2_Common_AttachmentTypes_Filter_Enabled.rego @@ -19,12 +19,17 @@ package cis.microsoft_365_foundations.v6_0_0.control_2_1_2 -import rego.v1 - default result := {"compliant": false, "message": "Evaluation failed"} -file_filter_enabled := true if input.enable_file_filter == true -file_filter_enabled := false if input.enable_file_filter != true +enable_file_filter := object.get( + input, + "enable_file_filter", + object.get(object.get(input, "default_policy", {}), "EnableFileFilter", null) +) + +file_filter_enabled := true if enable_file_filter == true +file_filter_enabled := false if enable_file_filter != true +file_filter_enabled := null if enable_file_filter == null result := output if { compliant := file_filter_enabled == true @@ -32,9 +37,9 @@ result := output if { output := { "compliant": compliant, "message": generate_message(file_filter_enabled), - "affected_resources": generate_affected_resources(file_filter_enabled), + "affected_resources": generate_affected_resources(file_filter_enabled, input), "details": { - "enablefilefilter": file_filter_enabled + "enable_file_filter": file_filter_enabled } } } @@ -49,5 +54,11 @@ generate_message(file_filter_enabled) := msg if { msg := "The 'Enable the common attachments filter' is Off." } -generate_affected_resources(true) := [] -generate_affected_resources(false) := ["Common attachments filter is disabled"] +generate_message(file_filter_enabled) := msg if { + file_filter_enabled == null + msg := "Unable to determine the 'Enable the common attachments filter' status." +} + +generate_affected_resources(true, _) := [] +generate_affected_resources(false, data_input) := ["Common attachments filter is disabled"] +generate_affected_resources(null, _) := ["Common attachments filter status unknown"] diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.3_Notifications_InternalUsers_sendingMalware_Enabled.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.3_Notifications_InternalUsers_sendingMalware_Enabled.rego index 9c67cc3a..ae34171a 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.3_Notifications_InternalUsers_sendingMalware_Enabled.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.3_Notifications_InternalUsers_sendingMalware_Enabled.rego @@ -1,8 +1,10 @@ # METADATA -# title: Ensure Notifications for Internal Users Sending Malware is Enabled +# title: Ensure notifications for internal users sending malware is Enabled # description: | -# This control ensures that administrators are notified when internal users send malware. -# It helps security teams respond promptly to malware incidents. +# Exchange Online Protection is Microsoft's cloud-based filtering service +# that protects organizations against spam, malware, and other email threats. +# EOP is included in all Microsoft 365 organizations with Exchange Online mailboxes. +# # related_resources: # - ref: https://www.cisecurity.org/benchmark/microsoft_365 # description: CIS Microsoft 365 Foundations Benchmark @@ -11,74 +13,77 @@ # framework: cis # benchmark: microsoft-365-foundations # version: v6.0.0 -# severity: high +# severity: medium # service: Exchange # requires_permissions: # - Exchange.Manage package cis.microsoft_365_foundations.v6_0_0.control_2_1_3 -import rego.v1 +default result = {"compliant": false, "message": "Evaluation failed"} -default result := {"compliant": false, "message": "Evaluation failed"} +policies := object.get(input, "malware_filter_policies", []) -required_values := { - "EnableInternalSenderAdminNotifications": true, - "InternalSenderAdminAddress": "" # empty value considered compliant +policy_list := policies +policy_list := [p] if { + count(policies) == 0 + p := object.get(input, "default_policy", null) + p != null } -missing_sentinel := "__missing__" +has_policies := count(policy_list) > 0 -field_non_compliant(p, f) if { - object.get(p, f, missing_sentinel) == missing_sentinel -} +notifications_enabled := true if { has_policies } else := false if { true } -field_non_compliant(p, f) if { - object.get(p, f, missing_sentinel) != missing_sentinel - p[f] != required_values[f] +policy_name(policy) := name if { + name := object.get(policy, "Identity", null) + name != null +} else := name if { + name := object.get(policy, "Name", null) + name != null +} else := "Unknown policy" + +violating_policies[policy_name(policy)] if { + some i + policy := policy_list[i] + object.get(policy, "EnableInternalSenderAdminNotifications", null) != true } -policy_compliant(p) if { - invalid_fields := {f | - some f - required_values[f] - field_non_compliant(p, f) - } - count(invalid_fields) == 0 +violating_policies[policy_name(policy)] if { + some i + policy := policy_list[i] + object.get(policy, "EnableInternalSenderAdminNotifications", null) == true + object.get(policy, "InternalSenderAdminAddress", "") == "" } -policy := input.default_policy -policies := [policy] if policy != null -policies := [] if policy == null +notifications_configured := true if { + has_policies + count(violating_policies) == 0 +} else := false if { true } -non_compliant_policies := [ - {"policy": p.Name, "failed_fields": [f | - some f - required_values[f] - field_non_compliant(p, f) - ]} | - p := policies[_] - not policy_compliant(p) -] +compliant := true if { + notifications_enabled + notifications_configured +} else := false if { true } -result := { - "compliant": true, - "message": sprintf("All %d notification policies are compliant", [count(policies)]) -} if { - count(policies) > 0 - count(non_compliant_policies) == 0 +result = output if { + output := { + "compliant": compliant, + "message": generate_message(compliant, has_policies), + "affected_resources": generate_affected_resources(compliant, has_policies, violating_policies), + "details": { + "notifications_enabled": notifications_enabled, + "notifications_configured": notifications_configured, + "policies_count": count(policy_list), + "violations_count": count(violating_policies) + } + } } -result := { - "compliant": false, - "message": sprintf("Non-compliant policies detected: %v", [non_compliant_policies]) -} if { - count(non_compliant_policies) > 0 -} +generate_message(true, _) := "Internal sender admin notifications are enabled and configured correctly." +generate_message(false, false) := "No malware filter policies were found to evaluate internal sender admin notifications." +generate_message(false, true) := "Internal sender admin notifications are not properly configured." -result := { - "compliant": false, - "message": "No notification policies found" -} if { - count(policies) == 0 -} +generate_affected_resources(true, _, _) := [] +generate_affected_resources(false, false, _) := ["MalwareFilterPolicy"] +generate_affected_resources(false, true, violations) := [v | v := violations[_]] diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.4_Safe_AttachementsPolicy_Enabled.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.4_Safe_AttachementsPolicy_Enabled.rego index 48d5fc1c..773dba94 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.4_Safe_AttachementsPolicy_Enabled.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.4_Safe_AttachementsPolicy_Enabled.rego @@ -19,49 +19,36 @@ package cis.microsoft_365_foundations.v6_0_0.control_2_1_4 -import rego.v1 - default result := {"compliant": false, "message": "Evaluation failed"} -target_policy_candidate(p) if { - p.Identity == "Built-In Protection Policy" -} +safe_attachment_policies := object.get(input, "safe_attachment_policies", []) -target_policy_candidate(p) if { - p.Name == "Built-In Protection Policy" -} +policy_name(p) := name if { + name := object.get(p, "Name", null) + name != null +} else := identity if { + identity := object.get(p, "Identity", null) + identity != null +} else := "Unknown policy" -target_policies := [p | - p := input.safe_attachment_policies[_] - target_policy_candidate(p) +built_in_policies := [p | + p := safe_attachment_policies[_] + policy_name(p) == "Built-In Protection Policy" ] -target_policy := target_policies[0] if count(target_policies) > 0 - -policy_identity := target_policy.Identity if target_policy.Identity -policy_identity := target_policy.Name if target_policy.Name +has_policy := count(built_in_policies) > 0 +policy := built_in_policies[0] if { has_policy } -has_policy := target_policy != null +policy_identity := object.get(policy, "Identity", object.get(policy, "Name", "Unknown policy")) +policy_enable := object.get(policy, "Enable", null) +policy_action := object.get(policy, "Action", null) +policy_quarantine_tag := object.get(policy, "QuarantineTag", null) -policy_matches if { - target_policy.Enable == true - target_policy.Action == "Block" - target_policy.QuarantineTag == "AdminOnlyAccessPolicy" - policy_identity == "Built-In Protection Policy" -} - -compliant := true if { +compliant if { has_policy - policy_matches -} - -compliant := false if { - has_policy - not policy_matches -} - -compliant := null if { - not has_policy + policy_enable == true + policy_action == "Block" + policy_quarantine_tag == "AdminOnlyAccessPolicy" } result := { @@ -70,53 +57,44 @@ result := { "affected_resources": affected_resources, "details": { "identity": policy_identity, - "enable": target_policy.Enable, - "action": target_policy.Action, - "quarantine_tag": target_policy.QuarantineTag + "enable": policy_enable, + "action": policy_action, + "quarantine_tag": policy_quarantine_tag } } if { - has_policy + true } -message := "Safe Attachments Built-In Protection Policy is enabled, blocking threats, and correctly configured." if compliant == true -message := "Unable to determine Safe Attachments policy configuration." if compliant == null -message := "Safe Attachments Built-In Protection Policy is disabled." if { - compliant == false - target_policy.Enable == false -} -message := "Safe Attachments policy action is not set to 'Block'." if { - compliant == false - target_policy.Enable == true - target_policy.Action != "Block" -} -message := "Safe Attachments policy does not use the 'AdminOnlyAccessPolicy' quarantine tag." if { - compliant == false - target_policy.Enable == true - target_policy.Action == "Block" - target_policy.QuarantineTag != "AdminOnlyAccessPolicy" -} +message := "Safe Attachments Built-In Protection Policy is enabled, blocking threats, and correctly configured." if { + compliant +} else := "Safe Attachments Built-In Protection Policy is disabled." if { + has_policy + policy_enable == false +} else := "Safe Attachments policy action is not set to 'Block'." if { + has_policy + policy_enable == true + policy_action != "Block" +} else := "Safe Attachments policy does not use the 'AdminOnlyAccessPolicy' quarantine tag." if { + has_policy + policy_enable == true + policy_action == "Block" + policy_quarantine_tag != "AdminOnlyAccessPolicy" +} else := "Safe Attachments Built-In Protection Policy was not found." if { + not has_policy +} else := "Unable to determine Safe Attachments policy configuration." -affected_resources := [] if compliant == true -affected_resources := ["Safe Attachments policy status unknown"] if compliant == null -affected_resources := [sprintf("Non-compliant Safe Attachments policy: %v", [policy_identity])] if { - compliant == false - policy_identity +affected_resources := [] if { + compliant } -affected_resources := ["Non-compliant Safe Attachments policy: Built-In Protection Policy"] if { - compliant == false - not policy_identity + +affected_resources := [ + sprintf("Non-compliant Safe Attachments policy: %v", [policy_identity]) +] if { + not compliant + has_policy } -result := { - "compliant": false, - "message": "Unable to determine Safe Attachments policy configuration.", - "affected_resources": ["Safe Attachments policy status unknown"], - "details": { - "identity": null, - "enable": null, - "action": null, - "quarantine_tag": null - } -} if { +affected_resources := ["Safe Attachments Built-In Protection Policy not found"] if { + not compliant not has_policy } diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.5_Safe_Attachments_SharePoint_OneDrive_MSTeams_Enabled.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.5_Safe_Attachments_SharePoint_OneDrive_MSTeams_Enabled.rego index ff8aae1d..85d401ef 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.5_Safe_Attachments_SharePoint_OneDrive_MSTeams_Enabled.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.5_Safe_Attachments_SharePoint_OneDrive_MSTeams_Enabled.rego @@ -18,67 +18,43 @@ package cis.microsoft_365_foundations.v6_0_0.control_2_1_5 -import rego.v1 - default result := {"compliant": false, "message": "Evaluation failed"} -required_values := { - "EnableATPForSPOTeamsODB": true, - "EnableSafeDocs": true, - "AllowSafeDocsOpen": false -} +policies := [p | + p := object.get(input, "atp_policy", null) + p != null +] -missing_sentinel := "__missing__" +non_compliant_policies = [policy.Name | + policy := policies[_] + policy.EnableATPForSPOTeamsODB == false + policy.EnableSafeDocs == false + policy.AllowSafeDocsOpen == true +] -field_non_compliant(p, f) if { - object.get(p, f, missing_sentinel) == missing_sentinel -} +compliant := count(non_compliant_policies) == 0 -field_non_compliant(p, f) if { - object.get(p, f, missing_sentinel) != missing_sentinel - p[f] != required_values[f] +message_text := "Safe Attachments for SharePoint, OneDrive, and Teams is configured securely" if { + compliant == true } -policy_compliant(p) if { - invalid_fields := {f | - some f - required_values[f] = _ - field_non_compliant(p, f) - } - count(invalid_fields) == 0 +message_text := "Safe Attachments for SharePoint, OneDrive, or Teams is not configured securely" if { + compliant == false } -policy := input.atp_policy -policies := [policy] if policy != null -policies := [] if policy == null - -non_compliant_policies := [ {"policy": p.Name, "failed_fields": [f | - some f - required_values[f] = _ - field_non_compliant(p, f) -]} | - p := policies[_] - not policy_compliant(p) -] - -result := { - "compliant": true, - "message": sprintf("All %d Safe Attachments policies are compliant", [count(policies)]) -} if { - count(policies) > 0 - count(non_compliant_policies) == 0 +affected_list := [] if { + compliant == true } -result := { - "compliant": false, - "message": sprintf("Non-compliant policies detected: %v", [non_compliant_policies]) -} if { - count(non_compliant_policies) > 0 +affected_list := non_compliant_policies if { + compliant == false } result := { - "compliant": false, - "message": "No Safe Attachments policies found" -} if { - count(policies) == 0 + "compliant": compliant, + "message": message_text, + "affected_resources": affected_list, + "details": { + "policies_evaluated": policies + } } diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.6_Exchange_OnlineSpam_Policies_Notify_Administrators.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.6_Exchange_OnlineSpam_Policies_Notify_Administrators.rego index 250af297..22e7f1da 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.6_Exchange_OnlineSpam_Policies_Notify_Administrators.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.6_Exchange_OnlineSpam_Policies_Notify_Administrators.rego @@ -3,15 +3,16 @@ # description: | # In Microsoft 365 organizations with mailboxes in Exchange Online or # standalone Exchange Online Protection organizations without Exchange -# Online mailboxes, email messages are automatically protected against spam by EOP. +# Online mailboxes, email messages are automatically protected against spam (junk email) by EOP. +# # related_resources: # - ref: https://www.cisecurity.org/benchmark/microsoft_365 -# description: CIS Microsoft 365 Foundations benchmark +# description: CIS Microsoft 365 Foundations Benchmark # custom: # control_id: CIS-2.1.6 # framework: cis # benchmark: microsoft-365-foundations -# version: v6.0 +# version: v6.0.0 # severity: medium # service: Exchange # requires_permissions: @@ -19,61 +20,88 @@ package cis.microsoft_365_foundations.v6_0_0.control_2_1_6 -import rego.v1 +default result := {"compliant": false, "message": "Evaluation failed"} -bcc_suspicious_outbound_mail := object.get(input, "bcc_suspicious_outbound_mail", false) -notify_outbound_spam := object.get(input, "notify_outbound_spam", false) -auto_forwarding_mode := object.get(input, "auto_forwarding_mode", null) +bcc_suspicious_outbound_mail := object.get( + input, + "bcc_suspicious_outbound_mail", + object.get(object.get(input, "default_policy", {}), "BccSuspiciousOutboundMail", null) +) -missing_bcc if { - not bcc_suspicious_outbound_mail -} +notify_outbound_spam := object.get( + input, + "notify_outbound_spam", + object.get(object.get(input, "default_policy", {}), "NotifyOutboundSpam", null) +) -missing_notify if { - not notify_outbound_spam -} +bcc_additional_recipients := object.get( + object.get(input, "default_policy", {}), + "BccSuspiciousOutboundAdditionalRecipients", + null +) -missing_settings := array.concat( - [s | s := "bcc_suspicious_outbound_mail"; missing_bcc], - [s | s := "notify_outbound_spam"; missing_notify] +notify_outbound_spam_recipients := object.get( + object.get(input, "default_policy", {}), + "NotifyOutboundSpamRecipients", + null ) -outbound_spam_monitoring_enabled if { - count(missing_settings) == 0 +bcc_recipients_count := count(bcc_additional_recipients) if { + bcc_additional_recipients != null +} else := 0 + +notify_recipients_count := count(notify_outbound_spam_recipients) if { + notify_outbound_spam_recipients != null +} else := 0 + +outbound_spam_monitoring_enabled := true if { + bcc_suspicious_outbound_mail == true + notify_outbound_spam == true + bcc_additional_recipients != null + notify_outbound_spam_recipients != null + bcc_recipients_count > 0 + notify_recipients_count > 0 } -scan_result = { - "compliant": true, - "message": "Outbound spam BCC and notification settings are enabled", - "affected_resources": [], - "details": { - "bcc_suspicious_outbound_mail": bcc_suspicious_outbound_mail, - "notify_outbound_spam": notify_outbound_spam, - "auto_forwarding_mode": auto_forwarding_mode - } -} if { - outbound_spam_monitoring_enabled +outbound_spam_monitoring_enabled := false if { + bcc_suspicious_outbound_mail != true +} else := false if { + notify_outbound_spam != true +} else := false if { + bcc_additional_recipients != null + bcc_recipients_count == 0 +} else := false if { + notify_outbound_spam_recipients != null + notify_recipients_count == 0 } -scan_result = { - "compliant": false, - "message": generate_message_missing, - "affected_resources": ["HostedOutboundSpamFilterPolicy"], - "details": { - "bcc_suspicious_outbound_mail": bcc_suspicious_outbound_mail, - "notify_outbound_spam": notify_outbound_spam, - "auto_forwarding_mode": auto_forwarding_mode - } -} if { - not outbound_spam_monitoring_enabled +outbound_spam_monitoring_enabled := null if { + bcc_suspicious_outbound_mail == null + notify_outbound_spam == null + bcc_additional_recipients == null + notify_outbound_spam_recipients == null } -result := scan_result +result := output if { + compliant := outbound_spam_monitoring_enabled == true -generate_message_missing := msg if { - count(missing_settings) > 0 - msg := sprintf( - "Outbound spam settings disabled or misconfigured: %v", - missing_settings - ) + output := { + "compliant": compliant, + "message": generate_message(outbound_spam_monitoring_enabled), + "affected_resources": generate_affected_resources(outbound_spam_monitoring_enabled), + "details": { + "bcc_suspicious_outbound_mail": bcc_suspicious_outbound_mail, + "bcc_additional_recipients": bcc_additional_recipients, + "notify_outbound_spam": notify_outbound_spam, + "notify_outbound_spam_recipients": notify_outbound_spam_recipients + } + } } + +generate_message(true) := "Outbound spam Bcc and notification settings are enabled and recipients are configured" +generate_message(false) := "Outbound spam Bcc or notification settings are disabled or missing recipients" +generate_message(null) := "Unable to determine outbound spam Bcc or notification configuration" + +generate_affected_resources(true) := [] +generate_affected_resources(false) := ["HostedOutboundSpamFilterPolicy"] +generate_affected_resources(null) := ["HostedOutboundSpamFilterPolicy status unknown"] diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.7_AntiPhishing_Policy_is_created.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.7_AntiPhishing_Policy_is_created.rego index 69d4b4b1..535562d4 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.7_AntiPhishing_Policy_is_created.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.7_AntiPhishing_Policy_is_created.rego @@ -23,96 +23,91 @@ package cis.microsoft_365_foundations.v6_0_0.control_2_1_7 -import rego.v1 - -required_values := { - "Enabled": true, - "PhishThresholdLevel": 3, - "EnableTargetedUserProtection": true, - "EnableOrganizationDomainsProtection": true, - "EnableMailboxIntelligence": true, - "EnableMailboxIntelligenceProtection": true, - "EnableSpoofIntelligence": true, - "TargetedUserProtectionAction": "Quarantine", - "TargetedDomainProtectionAction": "Quarantine", - "MailboxIntelligenceProtectionAction": "Quarantine", - "EnableFirstContactSafetyTips": true, - "EnableSimilarUsersSafetyTips": true, - "EnableSimilarDomainsSafetyTips": true, - "EnableUnusualCharactersSafetyTips": true, - "HonorDmarcPolicy": true -} +default result := {"compliant": false, "message": "Evaluation failed"} -missing_sentinel := "__missing__" +policies := object.get(input, "anti_phish_policies", object.get(input, "antiPhishPolicies", [])) +rules := object.get(input, "anti_phish_rules", object.get(input, "antiPhishRules", [])) -field_non_compliant(p, f) if { - object.get(p, f, missing_sentinel) == missing_sentinel +required_policy_fields := { + "Enabled": true, + "PhishThresholdLevel": 3, + "EnableTargetedUserProtection": true, + "EnableOrganizationDomainsProtection": true, + "EnableMailboxIntelligence": true, + "EnableMailboxIntelligenceProtection": true, + "EnableSpoofIntelligence": true, + "TargetedUserProtectionAction": "Quarantine", + "TargetedDomainProtectionAction": "Quarantine", + "MailboxIntelligenceProtectionAction": "Quarantine", + "EnableFirstContactSafetyTips": true, + "EnableSimilarUsersSafetyTips": true, + "EnableSimilarDomainsSafetyTips": true, + "EnableUnusualCharactersSafetyTips": true, + "HonorDmarcPolicy": true } -field_non_compliant(p, f) if { - object.get(p, f, missing_sentinel) != missing_sentinel - p[f] != required_values[f] -} +matching_policy[policy_obj] if { + policy_obj := policies[_] -policy_compliant(p) if { - invalid_fields := {f | - some f - required_values[f] - field_non_compliant(p, f) - } - count(invalid_fields) == 0 + # All required fields must match + count({k | required_policy_fields[k] == policy_obj[k]}) == count(required_policy_fields) + + # Targeted users must be within limits + count(policy_obj.TargetedUsersToProtect) > 0 + count(policy_obj.TargetedUsersToProtect) <= 350 } -policies := object.get(input, "anti_phish_policies", []) +matching_rule[rule_obj] if { + rule_obj := rules[_] + rule_obj.State == "Enabled" -non_compliant_policies := [ - {"policy": p.Name, "failed_fields": [f | - some f - required_values[f] - field_non_compliant(p, f) - ]} | - p := policies[_] - not policy_compliant(p) -] + matching_policy[policy_obj] # ✅ unique variable name + rule_obj.AntiPhishPolicy == policy_obj.Name +} -targeted_users := [user | - p := policies[_] - policy_compliant(p) - users := object.get(p, "TargetedUsersToProtect", []) - user := users[_] -] +targets_majority(rule_obj) if { + count(rule_obj.RecipientDomainIs) > 0 + count(rule_obj.SentToMemberOf) > 0 +} -global_compliant_policies := [p | - p := policies[_] - policy_compliant(p) - count(object.get(p, "TargetedUsersToProtect", [])) == 0 -] +# Compliant: matching policy + enabled rule + targets majority +antiphish_configured if { + matching_rule[rule_obj] + targets_majority(rule_obj) +} -result := { - "compliant": false, - "message": "No anti-phishing policies found" -} if { - count(policies) == 0 +# Non-compliant: policies exist but none match requirements +antiphish_configured if { + count(policies) > 0 + count({p | matching_policy[p]}) == 0 } -result := { - "compliant": false, - "message": sprintf( - "Non-compliant policies detected: %v", - [non_compliant_policies] - ) -} if { - count(policies) > 0 - count(non_compliant_policies) > 0 +antiphish_configured = null if { + count(policies) == 0 + count(rules) == 0 } +generate_message(true) := "Anti-Phish policy and rules are correctly configured and enabled" +generate_message(false) := "Anti-Phish policy or rules are misconfigured or not enforced" +generate_message(null) := "Unable to determine Anti-Phish policy or rule configuration" + +generate_affected_resources(true, _) := [] + +generate_affected_resources(false, data_input) := [ + pol.Name | + pol := data_input[_] +] + +generate_affected_resources(null, _) := ["Anti-Phish configuration status unknown"] + result := { - "compliant": true, - "message": sprintf( - "Found %d user(s) protected by compliant anti-phishing policy (including global/default)", - [count(targeted_users) + count(global_compliant_policies)] - ) -} if { - count(policies) > 0 - count(non_compliant_policies) == 0 + "compliant": antiphish_configured == true, + "message": generate_message(antiphish_configured), + "affected_resources": generate_affected_resources(antiphish_configured, policies), + "details": { + "anti_phish_policies_evaluated": count(policies), + "anti_phish_rules_evaluated": count(rules), + "targeted_user_limit": 350, + "required_policy_settings": required_policy_fields + } } diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.9_DKIM_is_enabled.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.9_DKIM_is_enabled.rego index a4da6a8c..97cf4c63 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.9_DKIM_is_enabled.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.1.9_DKIM_is_enabled.rego @@ -1,9 +1,9 @@ # METADATA # title: Ensure that DKIM is enabled for all Exchange Online Domains # description: | -# DKIM is one of the trio of Authentication methods (SPF, DKIM and DMARC) that help -# prevent attackers from sending messages that look like they come from your domain. -# +# DKIM is one of the trio of authentication methods (SPF, DKIM, and DMARC) that help +# prevent attackers from sending messages that look like they come from your domain. +# # related_resources: # - ref: https://www.cisecurity.org/benchmark/microsoft_365 # description: CIS Microsoft 365 Foundations Benchmark diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.4.4_Zero_hour_AutoPurge_MSTeams_is_On.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.4.4_Zero_hour_AutoPurge_MSTeams_is_On.rego index b83d0f57..cf7c4774 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.4.4_Zero_hour_AutoPurge_MSTeams_is_On.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/2.4.4_Zero_hour_AutoPurge_MSTeams_is_On.rego @@ -21,13 +21,29 @@ package cis.microsoft_365_foundations.v6_0_0.control_2_4_4 -import rego.v1 +default result := {"compliant": false, "message": "Evaluation failed"} -zap_enabled := object.get(input, "zap_enabled", null) +required_fields := { + "ZeroHourAutoPurgeEnabled": true +} + +zap_enabled := object.get( + input, + "zap_enabled", + object.get(object.get(input, "teams_protection_policy", {}), "ZapEnabled", null) +) + +zero_hour_auto_purge_enabled := true if { + zap_enabled == true +} -zero_hour_auto_purge_enabled := true if zap_enabled == true -zero_hour_auto_purge_enabled := false if zap_enabled == false -zero_hour_auto_purge_enabled := null if zap_enabled == null +zero_hour_auto_purge_enabled := false if { + zap_enabled != true +} + +zero_hour_auto_purge_enabled := null if { + not zap_enabled +} result := output if { compliant := zero_hour_auto_purge_enabled == true @@ -37,14 +53,15 @@ result := output if { "message": generate_message(zero_hour_auto_purge_enabled), "affected_resources": generate_affected_resources(zero_hour_auto_purge_enabled), "details": { - "zap_enabled": zap_enabled + "ZeroHourAutoPurgeEnabled": zap_enabled } } } generate_message(true) := "Zero-hour auto purge is enabled for Microsoft Teams" generate_message(false) := "Zero-hour auto purge is not enabled for Microsoft Teams" -generate_message(null) := "Unable to determine Zero-hour auto purge status for Microsoft Teams" +generate_message(null) := "Unable to determine if Zero-hour auto purge is enabled for Microsoft Teams" + generate_affected_resources(true) := [] generate_affected_resources(false) := ["TeamsProtectionPolicy"] generate_affected_resources(null) := ["TeamsProtectionPolicy status unknown"] diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json b/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json index 6f611a82..e3d70a57 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json @@ -154,7 +154,7 @@ "automation_status": "ready", "data_collector_id": "entra.applications.apps_and_services_settings", "policy_file": "1.3.4_user_owned_apps_restricted.rego", - "requires_permissions": ["Policy.Read.All"], + "requires_permissions": ["OrgSettings-AppsAndServices.Read.All"], "notes": null }, { @@ -169,7 +169,7 @@ "automation_status": "ready", "data_collector_id": "entra.applications.forms_settings", "policy_file": "1.3.5_forms_internal_phishing_protection.rego", - "requires_permissions": ["Policy.Read.All"], + "requires_permissions": ["OrgSettings-Forms.Read.All"], "notes": null }, { @@ -258,7 +258,7 @@ "benchmark_audit_type": "Automated", "automation_status": "ready", "data_collector_id": "exchange.protection.malware_filter_policy", - "policy_file": "2.1.2_Common_AttachmentTypes_Enabled.rego", + "policy_file": "2.1.2_Common_AttachmentTypes_Filter_Enabled.rego", "requires_permissions": ["Exchange.Manage"], "notes": null }, diff --git a/engine/powershell/Dockerfile b/engine/powershell/Dockerfile index a7e06373..140ba52d 100644 --- a/engine/powershell/Dockerfile +++ b/engine/powershell/Dockerfile @@ -1,7 +1,8 @@ -FROM mcr.microsoft.com/powershell:7.5-mariner-2.0 +ARG TARGETPLATFORM +FROM --platform=$TARGETPLATFORM mcr.microsoft.com/powershell:7.5-mariner-2.0 -# Install Python, curl (for healthcheck), and tar (for uv installer) -RUN tdnf install -y python3 curl tar && tdnf clean all +# Install Python, curl (for healthcheck), tar (for uv installer), and awk (uv install script uses it) +RUN tdnf install -y python3 curl tar gawk && tdnf clean all # Install uv RUN curl -LsSf https://astral.sh/uv/install.sh | sh diff --git a/engine/worker/config.py b/engine/worker/config.py index b82ded1c..4ebe4927 100644 --- a/engine/worker/config.py +++ b/engine/worker/config.py @@ -26,6 +26,10 @@ class WorkerSettings(BaseSettings): # PowerShell service URL (optional - if set, uses HTTP instead of Docker) POWERSHELL_SERVICE_URL: str | None = None + # Performance mode: PowerShell-based controls (Exchange/Compliance/Teams) are much slower + # than Graph-based controls. Default is True to preserve full scan coverage. + ENABLE_POWERSHELL_CONTROLS: bool = True + class Config: env_file = ".env" diff --git a/engine/worker/tasks.py b/engine/worker/tasks.py index 421f0c2b..94811146 100644 --- a/engine/worker/tasks.py +++ b/engine/worker/tasks.py @@ -126,8 +126,28 @@ def run_scan(scan_id: int) -> dict: # Check automation_status before dispatching status = control.get("automation_status", "ready") + collector_id = control.get("data_collector_id") or "" if status == "ready": + # Optional fast-scan mode: allow skipping slow PowerShell-based controls only when + # explicitly disabled via ENABLE_POWERSHELL_CONTROLS=false. + if ( + settings.ENABLE_POWERSHELL_CONTROLS is False + and collector_id.startswith(("exchange.", "compliance.", "teams.")) + and not collector_id.startswith("exchange.dns.") + ): + with get_db_session() as session: + update_scan_result( + session, + result_id=result["id"], + status="skipped", + message="Skipped (fast scan): PowerShell-based controls disabled (ENABLE_POWERSHELL_CONTROLS=false).", + ) + increment_scan_skipped_count(session, scan_id) + session.commit() + skipped += 1 + continue + # Verify collector exists before dispatching if not control.get("data_collector_id"): with get_db_session() as session: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 64ee2005..29c0e5d2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,9 +12,10 @@ "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", - "chart.js": "^4.5.0", + "apexcharts": "^5.3.6", "lucide-react": "^0.542.0", "react": "^19.1.1", + "react-apexcharts": "^1.9.0", "react-dom": "^19.1.1", "react-router-dom": "^7.9.1" }, @@ -25,12 +26,10 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.27.1", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -39,9 +38,7 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "version": "7.28.5", "dev": true, "license": "MIT", "engines": { @@ -49,21 +46,19 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "version": "7.28.5", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -79,25 +74,13 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.28.5", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -107,13 +90,11 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.27.2", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", + "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -123,20 +104,8 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-globals": { "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "dev": true, "license": "MIT", "engines": { @@ -144,29 +113,25 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.27.1", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.28.3", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -177,8 +142,6 @@ }, "node_modules/@babel/helper-plugin-utils": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "dev": true, "license": "MIT", "engines": { @@ -187,8 +150,6 @@ }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", "engines": { @@ -197,8 +158,6 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -206,8 +165,6 @@ }, "node_modules/@babel/helper-validator-option": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", "engines": { @@ -215,27 +172,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.28.4", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.28.5", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -246,8 +199,6 @@ }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", "dev": true, "license": "MIT", "dependencies": { @@ -262,8 +213,6 @@ }, "node_modules/@babel/plugin-transform-react-jsx-source": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", "dev": true, "license": "MIT", "dependencies": { @@ -277,42 +226,36 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", - "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "version": "7.28.4", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.27.2", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.28.5", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", "debug": "^4.3.1" }, "engines": { @@ -320,9 +263,7 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.28.5", "dev": true, "license": "MIT", "dependencies": { @@ -403,8 +344,6 @@ }, "node_modules/@esbuild/darwin-arm64": { "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", "cpu": [ "arm64" ], @@ -776,9 +715,7 @@ } }, "node_modules/@fontsource/league-spartan": { - "version": "5.2.7", - "resolved": "https://registry.npmjs.org/@fontsource/league-spartan/-/league-spartan-5.2.7.tgz", - "integrity": "sha512-OwYjMcJOGAGB680+4+Z47c/G5Ky8f8mT9Md6e8DB8/6fi1RGd/Ct5pUe3KT/lhUeNGUT8m5R4PKww47qtU8NHw==", + "version": "5.2.8", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -786,8 +723,6 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { @@ -797,8 +732,6 @@ }, "node_modules/@jridgewell/remapping": { "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", "dependencies": { @@ -808,38 +741,19 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.30", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", - "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "version": "0.3.31", "dev": true, "license": "MIT", "dependencies": { @@ -847,23 +761,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@kurkle/color": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", - "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", - "license": "MIT" - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.2.tgz", - "integrity": "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", + "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", "cpu": [ "arm" ], @@ -875,9 +781,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.2.tgz", - "integrity": "sha512-eXBg7ibkNUZ+sTwbFiDKou0BAckeV6kIigK7y5Ko4mB/5A1KLhuzEKovsmfvsL8mQorkoincMFGnQuIT92SKqA==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz", + "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", "cpu": [ "arm64" ], @@ -889,9 +795,7 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.2.tgz", - "integrity": "sha512-UCbaTklREjrc5U47ypLulAgg4njaqfOVLU18VrCrI+6E5MQjuG0lSWaqLlAJwsD7NpFV249XgB0Bi37Zh5Sz4g==", + "version": "4.53.5", "cpu": [ "arm64" ], @@ -903,9 +807,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.2.tgz", - "integrity": "sha512-dP67MA0cCMHFT2g5XyjtpVOtp7y4UyUxN3dhLdt11at5cPKnSm4lY+EhwNvDXIMzAMIo2KU+mc9wxaAQJTn7sQ==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz", + "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", "cpu": [ "x64" ], @@ -917,9 +821,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.2.tgz", - "integrity": "sha512-WDUPLUwfYV9G1yxNRJdXcvISW15mpvod1Wv3ok+Ws93w1HjIVmCIFxsG2DquO+3usMNCpJQ0wqO+3GhFdl6Fow==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz", + "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", "cpu": [ "arm64" ], @@ -931,9 +835,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.2.tgz", - "integrity": "sha512-Ng95wtHVEulRwn7R0tMrlUuiLVL/HXA8Lt/MYVpy88+s5ikpntzZba1qEulTuPnPIZuOPcW9wNEiqvZxZmgmqQ==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz", + "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", "cpu": [ "x64" ], @@ -945,9 +849,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.2.tgz", - "integrity": "sha512-AEXMESUDWWGqD6LwO/HkqCZgUE1VCJ1OhbvYGsfqX2Y6w5quSXuyoy/Fg3nRqiwro+cJYFxiw5v4kB2ZDLhxrw==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz", + "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", "cpu": [ "arm" ], @@ -959,9 +863,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.2.tgz", - "integrity": "sha512-ZV7EljjBDwBBBSv570VWj0hiNTdHt9uGznDtznBB4Caj3ch5rgD4I2K1GQrtbvJ/QiB+663lLgOdcADMNVC29Q==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz", + "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", "cpu": [ "arm" ], @@ -973,9 +877,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.2.tgz", - "integrity": "sha512-uvjwc8NtQVPAJtq4Tt7Q49FOodjfbf6NpqXyW/rjXoV+iZ3EJAHLNAnKT5UJBc6ffQVgmXTUL2ifYiLABlGFqA==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz", + "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", "cpu": [ "arm64" ], @@ -987,9 +891,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.2.tgz", - "integrity": "sha512-s3KoWVNnye9mm/2WpOZ3JeUiediUVw6AvY/H7jNA6qgKA2V2aM25lMkVarTDfiicn/DLq3O0a81jncXszoyCFA==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz", + "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", "cpu": [ "arm64" ], @@ -1001,23 +905,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.2.tgz", - "integrity": "sha512-gi21faacK+J8aVSyAUptML9VQN26JRxe484IbF+h3hpG+sNVoMXPduhREz2CcYr5my0NE3MjVvQ5bMKX71pfVA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.2.tgz", - "integrity": "sha512-qSlWiXnVaS/ceqXNfnoFZh4IiCA0EwvCivivTGbEu1qv2o+WTHpn1zNmCTAoOG5QaVr2/yhCoLScQtc/7RxshA==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz", + "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", "cpu": [ "loong64" ], @@ -1029,23 +919,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.2.tgz", - "integrity": "sha512-rPyuLFNoF1B0+wolH277E780NUKf+KoEDb3OyoLbAO18BbeKi++YN6gC/zuJoPPDlQRL3fIxHxCxVEWiem2yXw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.2.tgz", - "integrity": "sha512-g+0ZLMook31iWV4PvqKU0i9E78gaZgYpSrYPed/4Bu+nGTgfOPtfs1h11tSSRPXSjC5EzLTjV/1A7L2Vr8pJoQ==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz", + "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", "cpu": [ "ppc64" ], @@ -1057,9 +933,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.2.tgz", - "integrity": "sha512-i+sGeRGsjKZcQRh3BRfpLsM3LX3bi4AoEVqmGDyc50L6KfYsN45wVCSz70iQMwPWr3E5opSiLOwsC9WB4/1pqg==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz", + "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", "cpu": [ "riscv64" ], @@ -1071,9 +947,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.2.tgz", - "integrity": "sha512-C1vLcKc4MfFV6I0aWsC7B2Y9QcsiEcvKkfxprwkPfLaN8hQf0/fKHwSF2lcYzA9g4imqnhic729VB9Fo70HO3Q==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz", + "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", "cpu": [ "riscv64" ], @@ -1085,9 +961,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.2.tgz", - "integrity": "sha512-68gHUK/howpQjh7g7hlD9DvTTt4sNLp1Bb+Yzw2Ki0xvscm2cOdCLZNJNhd2jW8lsTPrHAHuF751BygifW4bkQ==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz", + "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", "cpu": [ "s390x" ], @@ -1099,9 +975,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.2.tgz", - "integrity": "sha512-1e30XAuaBP1MAizaOBApsgeGZge2/Byd6wV4a8oa6jPdHELbRHBiw7wvo4dp7Ie2PE8TZT4pj9RLGZv9N4qwlw==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz", + "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", "cpu": [ "x64" ], @@ -1113,9 +989,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.2.tgz", - "integrity": "sha512-4BJucJBGbuGnH6q7kpPqGJGzZnYrpAzRd60HQSt3OpX/6/YVgSsJnNzR8Ot74io50SeVT4CtCWe/RYIAymFPwA==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz", + "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", "cpu": [ "x64" ], @@ -1126,24 +1002,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.2.tgz", - "integrity": "sha512-cT2MmXySMo58ENv8p6/O6wI/h/gLnD3D6JoajwXFZH6X9jz4hARqUhWpGuQhOgLNXscfZYRQMJvZDtWNzMAIDw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.2.tgz", - "integrity": "sha512-sZnyUgGkuzIXaK3jNMPmUIyJrxu/PjmATQrocpGA1WbCPX8H5tfGgRSuYtqBYAvLuIGp8SPRb1O4d1Fkb5fXaQ==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz", + "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", "cpu": [ "arm64" ], @@ -1155,9 +1017,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.2.tgz", - "integrity": "sha512-sDpFbenhmWjNcEbBcoTV0PWvW5rPJFvu+P7XoTY0YLGRupgLbFY0XPfwIbJOObzO7QgkRDANh65RjhPmgSaAjQ==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz", + "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", "cpu": [ "arm64" ], @@ -1169,9 +1031,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.2.tgz", - "integrity": "sha512-GvJ03TqqaweWCigtKQVBErw2bEhu1tyfNQbarwr94wCGnczA9HF8wqEe3U/Lfu6EdeNP0p6R+APeHVwEqVxpUQ==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz", + "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", "cpu": [ "ia32" ], @@ -1183,9 +1045,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.2.tgz", - "integrity": "sha512-KvXsBvp13oZz9JGe5NYS7FNizLe99Ny+W8ETsuCyjXiKdiGrcz2/J/N8qxZ/RSwivqjQguug07NLHqrIHrqfYw==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz", + "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", "cpu": [ "x64" ], @@ -1197,9 +1059,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.2.tgz", - "integrity": "sha512-xNO+fksQhsAckRtDSPWaMeT1uIM+JrDRXlerpnWNXhn1TdB3YZ6uKBMBTKP0eX9XtYEP978hHk1f8332i2AW8Q==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz", + "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", "cpu": [ "x64" ], @@ -1210,10 +1072,64 @@ "win32" ] }, + "node_modules/@svgdotjs/svg.draggable.js": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.6.tgz", + "integrity": "sha512-7iJFm9lL3C40HQcqzEfezK2l+dW2CpoVY3b77KQGqc8GXWa6LhhmX5Ckv7alQfUXBuZbjpICZ+Dvq1czlGx7gA==", + "license": "MIT", + "peerDependencies": { + "@svgdotjs/svg.js": "^3.2.4" + } + }, + "node_modules/@svgdotjs/svg.filter.js": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.filter.js/-/svg.filter.js-3.0.9.tgz", + "integrity": "sha512-/69XMRCDoam2HgC4ldHIaDgeQf1ViHIsa0Ld4uWgiXtZ+E24DWHe/9Ib6kbNiZ7WRIdlVokUDR1Fg0kjIpkfbw==", + "license": "MIT", + "dependencies": { + "@svgdotjs/svg.js": "^3.2.4" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@svgdotjs/svg.js": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.5.tgz", + "integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Fuzzyma" + } + }, + "node_modules/@svgdotjs/svg.resize.js": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.resize.js/-/svg.resize.js-2.0.5.tgz", + "integrity": "sha512-4heRW4B1QrJeENfi7326lUPYBCevj78FJs8kfeDxn5st0IYPIRXoTtOSYvTzFWgaWWXd3YCDE6ao4fmv91RthA==", + "license": "MIT", + "engines": { + "node": ">= 14.18" + }, + "peerDependencies": { + "@svgdotjs/svg.js": "^3.2.4", + "@svgdotjs/svg.select.js": "^4.0.1" + } + }, + "node_modules/@svgdotjs/svg.select.js": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.select.js/-/svg.select.js-4.0.3.tgz", + "integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==", + "license": "MIT", + "engines": { + "node": ">= 14.18" + }, + "peerDependencies": { + "@svgdotjs/svg.js": "^3.2.4" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", @@ -1229,19 +1145,8 @@ "node": ">=18" } }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/@testing-library/react": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", - "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "version": "16.3.1", "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5" @@ -1267,8 +1172,6 @@ }, "node_modules/@testing-library/user-event": { "version": "13.5.0", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", - "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5" @@ -1283,14 +1186,10 @@ }, "node_modules/@types/aria-query": { "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, "license": "MIT", "dependencies": { @@ -1303,8 +1202,6 @@ }, "node_modules/@types/babel__generator": { "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, "license": "MIT", "dependencies": { @@ -1313,8 +1210,6 @@ }, "node_modules/@types/babel__template": { "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "dev": true, "license": "MIT", "dependencies": { @@ -1324,8 +1219,6 @@ }, "node_modules/@types/babel__traverse": { "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1334,27 +1227,11 @@ }, "node_modules/@types/estree": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "24.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", - "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~7.10.0" - } - }, "node_modules/@vitejs/plugin-react": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", - "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1372,44 +1249,60 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/@vitejs/plugin-react/node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true, + "node_modules/@yr/monotone-cubic-spline": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", + "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, + "node_modules/ansi-styles": { + "version": "5.2.0", "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, "engines": { - "node": ">=0.4.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" + "node_modules/apexcharts": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.3.6.tgz", + "integrity": "sha512-sVEPw+J0Gp0IHQabKu8cfdsxlfME0e36Wid7RIaPclGM2OUt+O7O4+6mfAmTUYhy5bDk8cNHzEhPfVtLCIXEJA==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@svgdotjs/svg.draggable.js": "^3.0.4", + "@svgdotjs/svg.filter.js": "^3.0.8", + "@svgdotjs/svg.js": "^3.2.4", + "@svgdotjs/svg.resize.js": "^2.0.2", + "@svgdotjs/svg.select.js": "^4.0.1", + "@yr/monotone-cubic-spline": "^1.0.3" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.9", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" } }, "node_modules/browserslist": { - "version": "4.25.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", - "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", + "version": "4.28.1", "dev": true, "funding": [ { @@ -1427,10 +1320,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001737", - "electron-to-chromium": "^1.5.211", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -1439,19 +1333,8 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/caniuse-lite": { - "version": "1.0.30001739", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001739.tgz", - "integrity": "sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==", + "version": "1.0.30001760", "dev": true, "funding": [ { @@ -1469,29 +1352,24 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chart.js": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", - "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", - "license": "MIT", - "dependencies": { - "@kurkle/color": "^0.3.0" - }, - "engines": { - "pnpm": ">=8" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", "dev": true, "license": "MIT", "dependencies": { @@ -1508,8 +1386,6 @@ }, "node_modules/dequal": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "license": "MIT", "engines": { "node": ">=6" @@ -1517,21 +1393,15 @@ }, "node_modules/dom-accessibility-api": { "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.213", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.213.tgz", - "integrity": "sha512-xr9eRzSLNa4neDO0xVFrkXu3vyIzG4Ay08dApecw42Z1NbmCt+keEpXdvlYGVe0wtvY5dhW0Ay0lY0IOfsCg0Q==", + "version": "1.5.267", "dev": true, "license": "ISC" }, "node_modules/esbuild": { "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1572,20 +1442,31 @@ }, "node_modules/escalade": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -1597,36 +1478,18 @@ }, "node_modules/gensync": { "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, "node_modules/js-tokens": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, "node_modules/jsesc": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", "bin": { @@ -1638,8 +1501,6 @@ }, "node_modules/json5": { "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", "bin": { @@ -1649,10 +1510,20 @@ "node": ">=6" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", "dependencies": { @@ -1661,8 +1532,6 @@ }, "node_modules/lucide-react": { "version": "0.542.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz", - "integrity": "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1670,8 +1539,6 @@ }, "node_modules/lz-string": { "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "license": "MIT", "bin": { "lz-string": "bin/bin.js" @@ -1679,15 +1546,11 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -1704,22 +1567,36 @@ } }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.27", "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -1747,8 +1624,6 @@ }, "node_modules/pretty-format": { "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1", @@ -1759,49 +1634,67 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", - "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "version": "19.2.3", "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/react-apexcharts": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-1.9.0.tgz", + "integrity": "sha512-DDBzQFuKdwyCEZnji1yIcjlnV8hRr4VDabS5Y3iuem/WcTq6n4VbjWPzbPm3aOwW4I+rf/gA3zWqhws4z9CwLw==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "apexcharts": ">=4.0.0", + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", - "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "version": "19.2.3", "license": "MIT", "dependencies": { - "scheduler": "^0.26.0" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.1.1" + "react": "^19.2.3" } }, "node_modules/react-is": { "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "license": "MIT" }, + "node_modules/react-refresh": { + "version": "0.18.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-router": { - "version": "7.9.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.1.tgz", - "integrity": "sha512-pfAByjcTpX55mqSDGwGnY9vDCpxqBLASg0BMNAuMmpSGESo/TaOUG6BllhAtAkCGx8Rnohik/XtaqiYUJtgW2g==", + "version": "7.11.0", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -1821,12 +1714,10 @@ } }, "node_modules/react-router-dom": { - "version": "7.9.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.1.tgz", - "integrity": "sha512-U9WBQssBE9B1vmRjo9qTM7YRzfZ3lUxESIZnsf4VjR/lXYz9MHjvOxHzr/aUm4efpktbVOrF09rL/y4VHa8RMw==", + "version": "7.11.0", "license": "MIT", "dependencies": { - "react-router": "7.9.1" + "react-router": "7.11.0" }, "engines": { "node": ">=20.0.0" @@ -1836,103 +1727,77 @@ "react-dom": ">=18" } }, - "node_modules/react-router/node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "node_modules/rollup": { + "version": "4.53.5", + "dev": true, "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, "engines": { - "node": ">=18" + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.5", + "@rollup/rollup-android-arm64": "4.53.5", + "@rollup/rollup-darwin-arm64": "4.53.5", + "@rollup/rollup-darwin-x64": "4.53.5", + "@rollup/rollup-freebsd-arm64": "4.53.5", + "@rollup/rollup-freebsd-x64": "4.53.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.5", + "@rollup/rollup-linux-arm-musleabihf": "4.53.5", + "@rollup/rollup-linux-arm64-gnu": "4.53.5", + "@rollup/rollup-linux-arm64-musl": "4.53.5", + "@rollup/rollup-linux-loong64-gnu": "4.53.5", + "@rollup/rollup-linux-ppc64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-musl": "4.53.5", + "@rollup/rollup-linux-s390x-gnu": "4.53.5", + "@rollup/rollup-linux-x64-gnu": "4.53.5", + "@rollup/rollup-linux-x64-musl": "4.53.5", + "@rollup/rollup-openharmony-arm64": "4.53.5", + "@rollup/rollup-win32-arm64-msvc": "4.53.5", + "@rollup/rollup-win32-ia32-msvc": "4.53.5", + "@rollup/rollup-win32-x64-gnu": "4.53.5", + "@rollup/rollup-win32-x64-msvc": "4.53.5", + "fsevents": "~2.3.2" } }, "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "version": "0.27.0", "license": "MIT" }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "version": "2.7.2", "license": "MIT" }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/tailwindcss": { "version": "4.1.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "dev": true, "license": "MIT" }, - "node_modules/terser": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", - "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/tinyglobby": { "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1946,50 +1811,8 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/undici-types": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", "dev": true, "funding": [ { @@ -2018,9 +1841,7 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.0", "dev": true, "license": "MIT", "dependencies": { @@ -2092,106 +1913,10 @@ } } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vite/node_modules/rollup": { - "version": "4.55.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz", - "integrity": "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.2", - "@rollup/rollup-android-arm64": "4.55.2", - "@rollup/rollup-darwin-arm64": "4.55.2", - "@rollup/rollup-darwin-x64": "4.55.2", - "@rollup/rollup-freebsd-arm64": "4.55.2", - "@rollup/rollup-freebsd-x64": "4.55.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.2", - "@rollup/rollup-linux-arm-musleabihf": "4.55.2", - "@rollup/rollup-linux-arm64-gnu": "4.55.2", - "@rollup/rollup-linux-arm64-musl": "4.55.2", - "@rollup/rollup-linux-loong64-gnu": "4.55.2", - "@rollup/rollup-linux-loong64-musl": "4.55.2", - "@rollup/rollup-linux-ppc64-gnu": "4.55.2", - "@rollup/rollup-linux-ppc64-musl": "4.55.2", - "@rollup/rollup-linux-riscv64-gnu": "4.55.2", - "@rollup/rollup-linux-riscv64-musl": "4.55.2", - "@rollup/rollup-linux-s390x-gnu": "4.55.2", - "@rollup/rollup-linux-x64-gnu": "4.55.2", - "@rollup/rollup-linux-x64-musl": "4.55.2", - "@rollup/rollup-openbsd-x64": "4.55.2", - "@rollup/rollup-openharmony-arm64": "4.55.2", - "@rollup/rollup-win32-arm64-msvc": "4.55.2", - "@rollup/rollup-win32-ia32-msvc": "4.55.2", - "@rollup/rollup-win32-x64-gnu": "4.55.2", - "@rollup/rollup-win32-x64-msvc": "4.55.2", - "fsevents": "~2.3.2" - } - }, "node_modules/yallist": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } } } } diff --git a/frontend/package.json b/frontend/package.json index 5311fae3..84d63361 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,9 +7,10 @@ "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", - "chart.js": "^4.5.0", + "apexcharts": "^5.3.6", "lucide-react": "^0.542.0", "react": "^19.1.1", + "react-apexcharts": "^1.9.0", "react-dom": "^19.1.1", "react-router-dom": "^7.9.1" }, @@ -32,7 +33,7 @@ }, "devDependencies": { "@vitejs/plugin-react": "^5.1.2", - "vite": "^7.3.0", - "tailwindcss": "^4.1.18" + "tailwindcss": "^4.1.18", + "vite": "^7.3.0" } } diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 2f100907..8db31aa1 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -156,6 +156,18 @@ export async function getContactHistory(token, id) { return fetchWithAuth(`/v1/contact/submissions/${id}/history`, token); } +// Settings endpoints +export async function getSettings(token) { + return fetchWithAuth('/v1/settings', token); +} + +export async function updateSettings(token, data) { + return fetchWithAuth('/v1/settings', token, { + method: 'PATCH', + body: JSON.stringify(data), + }); +} + // Platform endpoints export async function getPlatforms(token) { return fetchWithAuth('/v1/platforms', token); @@ -224,6 +236,23 @@ export async function createScan(token, data) { }); } +export async function deleteScan(token, id) { + const response = await fetch(`${API_BASE_URL}/v1/scans/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: response.statusText })); + throw new Error(error.detail || 'Failed to delete scan'); + } + + // DELETE returns 204 No Content, so don't try to parse JSON + return; +} + // Evidence scanner endpoints export async function getEvidenceStrategies() { // Frontend -> Backend diff --git a/frontend/src/components/ComplianceChart.jsx b/frontend/src/components/ComplianceChart.jsx index 4cf73807..6fe1482a 100644 --- a/frontend/src/components/ComplianceChart.jsx +++ b/frontend/src/components/ComplianceChart.jsx @@ -1,174 +1,251 @@ -//This component establishes a chart to display the number of scan items which returned as passed, requiring attention, or failed -//Last updated 18 September 2025 -//Recomended next changes: -// -add a switch case for more chart types besides just pie and doughnot. Some of the chart types have slightly different syntax to each other so a little more work is needed on this. -// - the legend is functional but the text doesn't look amazing. Played around with font settings and layout but can't get it to look quite right. Worth reviewing! - -//Note: no css for this component as this is purely javascript (no JSX) and the libary in use (charts.js) has its own parameters for styling - -//To render this component: -//import ComplianceChart from './components/ComplianceChart'; -// -//'doughnut' and 'pie' are current options for selectedChartType - -import React, { useRef, useEffect } from 'react'; -import '@fontsource/league-spartan'; - -// Imports only the minimum required components below -import { - Chart as ChartJS, - ArcElement, - Tooltip, - Legend, - Title, - DoughnutController, -} from 'chart.js'; - - -ChartJS.register( - ArcElement, - Tooltip, - Legend, - DoughnutController, - Title -); - -//Accepts chartType and dataInput as parameters. ChartType currently supports doughnut and pie chart, more to be added later. -//Also needs to be provided with isDarkMode to copy theming from parent. -//dataInput should be an array of 3 numbers, being, in order, number of High Priority Items, Medium Priority Items, and Passed Items. -//This should be made more robust in future. For now, since the graph is only used for one very consistent thing, this is functional. -//Fallback to 1,1,1 as array if error in data. -const ComplianceChart = ({ chartType = 'doughnut', dataInput = [1, 1, 1] , isDarkMode = true}) => { - const chartRef = useRef(null); - const chartInstanceRef = useRef(null); - - const getTextColor = (darkMode) => { - return darkMode ? '#ffffff' : '#1e293b'; - }; - - useEffect(() => { - const ctx = chartRef.current.getContext('2d'); - const textColor = getTextColor(isDarkMode); - - if (chartInstanceRef.current) { - chartInstanceRef.current.destroy(); - } +import React, { useMemo } from 'react'; +import ReactApexChart from 'react-apexcharts'; + +const normalizeNumbers = (arr, fallback = []) => { + if (!Array.isArray(arr)) return fallback; + return arr.map((n) => { + const num = Number(n); + return Number.isFinite(num) ? num : 0; + }); +}; - // Data styling - const data = { - labels: ['High Priority Issues', 'Medium Priority Issues', 'Pass'], - datasets: [{ - data: dataInput, - backgroundColor: [ - '#ef4444', - '#f97316', - '#10b981', - ], - borderColor: [ - '#ef4444', - '#f97316', - '#10b981', - ], - borderWidth: 2, - hoverOffset: 20 - }] +const colorForLabel = (label, isDarkMode) => { + const key = String(label || '').toLowerCase(); + if (key.includes('pass')) return '#10b981'; + if (key.includes('fail')) return '#ef4444'; + if (key.includes('error') || key.includes('err')) return '#f59e0b'; + if (key.includes('skip')) return isDarkMode ? 'rgba(148,163,184,0.55)' : 'rgba(71,85,105,0.45)'; + return isDarkMode ? 'rgba(148,163,184,0.55)' : 'rgba(71,85,105,0.45)'; }; - //CONFIG AREA - //Main chart area config - const config = { - type: 'doughnut', - data: data, - options: { - responsive: true, - maintainAspectRatio: false, //Note: the graph will break the card system and expand the card even beyond !important limits if this is set to true! - - //This is a very placeholder chart switch system. - //If its a doughnut chart, cut out a 60% hole. If its a pie chart, don't cut a hole (so yes, technically the pie chart is a doughnut chart in disguise) - //This should definitely be replaced with a switch case in future to allow more diverse chart types like bar charts! - cutout: chartType === 'doughnut' ? '60%' : '0%', - - //This padding below is required so that the hovered section (which expands on hover) doesn't clip out of frame - //If you adjust the hover offset, increase this padding as well to give spare room - layout: { - padding: { - top: 25, - bottom: 25, - left: 25, - right: 25, - } - }, - - plugins: { - - /* Code for a title. Re-enable title here if you want to use this independently. Currently the card system provides a title so we don't need it. - title: { - display: true, - text: 'Compliance Scan Results', - color: 'white', - font: { - size: 18, - weight: 'bold', +// ApexCharts-backed replacement for the old Chart.js component. +// Keeps the same props API used by Dashboard.jsx: +// - chartType: 'doughnut' | 'pie' | 'bar' +// - labelsInput: string[] +// - dataInput: number[] +// - isDarkMode: boolean +const ComplianceChart = ({ chartType = 'doughnut', dataInput = [1, 1], labelsInput = [], isDarkMode = true }) => { + const isBar = chartType === 'bar'; + + const { + series, + options, + height, + } = useMemo(() => { + const textColor = isDarkMode ? '#ffffff' : '#1e293b'; + const mutedText = isDarkMode ? 'rgba(160,165,175,0.95)' : 'rgba(71,85,105,0.95)'; + const grid = isDarkMode ? 'rgba(148,163,184,0.12)' : 'rgba(15,23,42,0.08)'; + const tooltipTheme = isDarkMode ? 'dark' : 'light'; + const fontFamily = '"League Spartan", "Inter", system-ui, -apple-system, "Segoe UI", Arial'; + + const normalized = normalizeNumbers(dataInput, [1, 1]); + + if (isBar) { + const categories = Array.isArray(labelsInput) && labelsInput.length > 0 + ? labelsInput.map((x) => String(x)) + : normalized.map((_, i) => `#${i + 1}`); + + const values = normalized; + const avg = values.length > 0 ? (values.reduce((a, b) => a + b, 0) / values.length) : 0; + + return { + series: [ + { + name: 'Compliance %', + type: 'column', + data: values, + }, + { + name: 'Trend', + type: 'line', + data: values.map(() => Math.round(avg * 10) / 10), + }, + ], + height: '100%', + options: { + chart: { + type: 'line', + stacked: false, + toolbar: { show: false }, + zoom: { enabled: false }, + background: 'transparent', + fontFamily, + animations: { enabled: true, easing: 'easeinout', speed: 650 }, + foreColor: mutedText, + }, + theme: { mode: isDarkMode ? 'dark' : 'light' }, + grid: { + borderColor: grid, + strokeDashArray: 3, + padding: { top: 12, right: 14, bottom: 6, left: 12 }, + }, + xaxis: { + categories, + labels: { style: { colors: mutedText, fontWeight: 600 } }, + axisBorder: { show: false }, + axisTicks: { show: false }, + tooltip: { enabled: false }, + }, + yaxis: { + min: 0, + max: 100, + tickAmount: 5, + labels: { + formatter: (v) => `${Math.round(v)}`, + style: { colors: mutedText, fontWeight: 600 }, }, - padding: 20 }, - */ - - //Legend configuration legend: { + show: true, position: 'bottom', - labels: { - padding: 45, - color: textColor, - usePointStyle: true, - font: { - size: 16, - /*family: '"League Spartan", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',*/ - }, - } + fontSize: '12px', + labels: { colors: mutedText }, + markers: { radius: 12, width: 8, height: 8 }, + itemMargin: { horizontal: 10, vertical: 6 }, }, - - - //Tooltip configuration - //The tooltip will display when you hover over a section of the chart tooltip: { - position: 'nearest', - callbacks: { - label: function(context) { - const label = context.label || ''; - const value = context.parsed; - const total = context.dataset.data.reduce((a, b) => a + b, 0); - const percentage = ((value / total) * 100).toFixed(1); - return ` ${label}: ${value} items (${percentage}%)`; - } - } - } + theme: tooltipTheme, + fillSeriesColor: false, + marker: { show: false }, + style: { fontSize: '12px', fontFamily }, + x: { show: true }, + y: { formatter: (v) => `${Math.round(v)}%` }, + }, + stroke: { + width: [0, 2.5], + curve: 'straight', + }, + markers: { + size: 0, + strokeWidth: 0, + }, + colors: ['rgba(16,185,129,0.55)', 'rgba(16,185,129,0.95)'], + plotOptions: { + bar: { + borderRadius: 10, + columnWidth: '42%', + }, + }, + dataLabels: { enabled: false }, }, - animation: { - animateScale: true, - animateRotate: true - } - } - }; - - - - - //Instantiate the chart - chartInstanceRef.current = new ChartJS(ctx, config); + }; + } - //Destroy the existing chart (if any) - return () => { - if (chartInstanceRef.current) { - chartInstanceRef.current.destroy(); - } + // Pie / Donut + const defaultLabels = ['Pass', 'Fail']; + const rawLabels = Array.isArray(labelsInput) && labelsInput.length > 0 ? labelsInput : defaultLabels; + const labels = rawLabels.map((x) => String(x)); + const values = labels.map((_, i) => Number(normalized[i] || 0)); + + const sum = values.reduce((a, b) => a + b, 0); + const hasData = sum > 0; + + const safeLabels = hasData ? labels : ['No data']; + const safeValues = hasData ? values : [1]; + const colors = hasData + ? safeLabels.map((l) => colorForLabel(l, isDarkMode)) + : [isDarkMode ? 'rgba(148,163,184,0.25)' : 'rgba(71,85,105,0.25)']; + + const passIdx = safeLabels.findIndex((l) => String(l).toLowerCase().includes('pass')); + const failIdx = safeLabels.findIndex((l) => String(l).toLowerCase().includes('fail')); + const pass = passIdx >= 0 ? Number(safeValues[passIdx] || 0) : 0; + const fail = failIdx >= 0 ? Number(safeValues[failIdx] || 0) : 0; + const denom = pass + fail; + const compliancePct = denom > 0 ? Math.round((pass / denom) * 100) : null; + const centerText = hasData ? (compliancePct === null ? '—' : `${compliancePct}%`) : '—'; + const centerSub = hasData ? 'COMPLIANCE' : 'NO RESULTS'; + + const apexType = chartType === 'pie' ? 'pie' : 'donut'; + + return { + series: safeValues, + height: '100%', + options: { + chart: { + type: apexType, + toolbar: { show: false }, + background: 'transparent', + fontFamily, + animations: { enabled: true, easing: 'easeinout', speed: 650 }, + foreColor: mutedText, + }, + theme: { mode: isDarkMode ? 'dark' : 'light' }, + labels: safeLabels, + colors, + legend: { + show: true, + position: 'bottom', + fontSize: '12px', + labels: { colors: mutedText }, + markers: { radius: 12, width: 8, height: 8 }, + itemMargin: { horizontal: 10, vertical: 6 }, + }, + stroke: { + show: false, + width: 0, + }, + dataLabels: { + enabled: false, + }, + tooltip: { + theme: tooltipTheme, + fillSeriesColor: false, + marker: { show: false }, + style: { fontSize: '12px', fontFamily }, + y: { + formatter: (value) => { + if (!hasData) return `${value}`; + const total = safeValues.reduce((a, b) => a + b, 0); + const pct = total > 0 ? ((Number(value || 0) / total) * 100).toFixed(1) : '0.0'; + return `${value} (${pct}%)`; + }, + }, + }, + plotOptions: { + pie: { + expandOnClick: false, + donut: { + size: chartType === 'doughnut' ? '70%' : '0%', + labels: { + show: chartType === 'doughnut', + name: { + show: true, + offsetY: 22, + color: mutedText, + fontSize: '12px', + fontWeight: 600, + formatter: () => centerSub, + }, + value: { + show: true, + offsetY: -6, + color: textColor, + fontSize: '34px', + fontWeight: 700, + formatter: () => centerText, + }, + }, + }, + }, + }, + responsive: [ + { + breakpoint: 480, + options: { + legend: { fontSize: '11px' }, + }, + }, + ], + }, }; - }, [chartType, dataInput]); //Re-render if the chartType or data is changed + }, [chartType, dataInput, labelsInput, isDarkMode, isBar]); + + const type = isBar ? 'line' : (chartType === 'pie' ? 'pie' : 'donut'); - //Scale to fit the max size provided with the card + // Fill parent container (Dashboard controls height via CSS). return ( -
- +
+
); }; diff --git a/frontend/src/components/Dropdown.css b/frontend/src/components/Dropdown.css index 05f1ac74..6b786499 100644 --- a/frontend/src/components/Dropdown.css +++ b/frontend/src/components/Dropdown.css @@ -44,6 +44,7 @@ Updated with theme support while preserving original design */ display: inline-block; min-width: 0; flex-shrink: 1; + isolation: isolate; } .chart-dropdown-trigger { @@ -52,7 +53,7 @@ Updated with theme support while preserving original design */ border-radius: 8px; color: var(--dropdown-text-primary); padding: 8px 12px; - font-size: 18px; + font-size: 14px; font-weight: bold; cursor: pointer; display: flex; @@ -108,12 +109,13 @@ Updated with theme support while preserving original design */ right: 0; left: 0; margin-top: 4px; - background: var(--dropdown-bg-primary); + background-color: var(--dropdown-bg-primary); border: 1px solid var(--dropdown-border-color); border-radius: 8px; box-shadow: 0 10px 25px var(--dropdown-shadow); - z-index: 1000; + z-index: 1100; overflow: hidden; + opacity: 1; transition: all 0.3s ease; } @@ -147,4 +149,4 @@ Updated with theme support while preserving original design */ /* if user hovers over already selected option */ .chart-dropdown-option.selected:hover { background: var(--dropdown-selected-hover); -} \ No newline at end of file +} diff --git a/frontend/src/components/Dropdown.jsx b/frontend/src/components/Dropdown.jsx index 157843ef..0245e736 100644 --- a/frontend/src/components/Dropdown.jsx +++ b/frontend/src/components/Dropdown.jsx @@ -11,6 +11,8 @@ import './Dropdown.css'; const Dropdown = ({ value, onChange, options, isDarkMode = true }) => { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); + const safeOptions = Array.isArray(options) ? options : []; + const hasOptions = safeOptions.length > 0; useEffect(() => { // Closes dropdown if clicks anywhere outside of the dropdown @@ -20,8 +22,18 @@ const Dropdown = ({ value, onChange, options, isDarkMode = true }) => { } }; + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; }, []); const handleSelect = (option) => { @@ -30,28 +42,37 @@ const Dropdown = ({ value, onChange, options, isDarkMode = true }) => { }; //Set option state to provided option from function call. Fall back to first possible option if any errors. - const selectedOption = options.find(opt => opt.value === value) || options[0]; + const selectedOption = safeOptions.find(opt => opt.value === value) || safeOptions[0]; + const selectedLabel = selectedOption?.label ?? 'No options'; return (
{/* we use this ref to detect if we've clicked outside of the dropdown (to close it)*/} {/* render only if isOpen is true */} - {isOpen && ( -
- {options.map((option) => ( //loop through the array to map the options to the list + {isOpen && hasOptions && ( +
+ {safeOptions.map((option) => ( //loop through the array to map the options to the list @@ -62,4 +83,4 @@ const Dropdown = ({ value, onChange, options, isDarkMode = true }) => { ); }; -export default Dropdown; \ No newline at end of file +export default Dropdown; diff --git a/frontend/src/components/Sidebar.css b/frontend/src/components/Sidebar.css index 5db25f45..fbc41fad 100644 --- a/frontend/src/components/Sidebar.css +++ b/frontend/src/components/Sidebar.css @@ -12,7 +12,7 @@ overflow-y: hidden; z-index: 1000; box-shadow: 4px 0 10px rgba(0, 0, 0, 0.2); - outline: 1px solid #3F3F3F; + border-right: 0.5px solid rgba(148, 163, 184, 0.22); /* Dark theme (default) */ background-color: #0a1628; @@ -22,14 +22,14 @@ .sidebar.dark { background-color: #0a1628; box-shadow: 4px 0 10px rgba(0, 0, 0, 0.2); - outline: 1px solid #3F3F3F; + border-right: 0.5px solid rgba(148, 163, 184, 0.22); } /* Light theme explicit */ .sidebar.light { background-color: #ffffff; box-shadow: 4px 0 10px rgba(0, 0, 0, 0.1); - outline: 1px solid #e2e8f0; + border-right: 0.5px solid rgba(148, 163, 184, 0.35); } /* Sidebar content container */ diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 02949254..4f4d0c6f 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -3,6 +3,11 @@ import ReactDOM from 'react-dom/client'; import { BrowserRouter as Router } from 'react-router-dom'; import { AuthProvider } from './context/AuthContext'; +// Fonts (load once at app startup to reduce layout shifts) +import '@fontsource/league-spartan/400.css'; +import '@fontsource/league-spartan/600.css'; +import '@fontsource/league-spartan/700.css'; + // Tailwind + tokens + base (includes tokens.css & components.css) import './styles/global.css'; diff --git a/frontend/src/pages/Admin/ContactAdminPage.js b/frontend/src/pages/Admin/ContactAdminPage.js new file mode 100644 index 00000000..718b7297 --- /dev/null +++ b/frontend/src/pages/Admin/ContactAdminPage.js @@ -0,0 +1,363 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import "./ContactAdminPage.css"; +import { useAuth } from "../../context/AuthContext"; +import { + addContactNote, + deleteContactSubmission, + getContactHistory, + getContactNotes, + getContactSubmissions, + updateContactSubmission, +} from "../../api/client"; + +const statusOptions = ["new", "in_progress", "resolved", "closed"]; +const priorityOptions = ["low", "medium", "high", "urgent"]; + +const ContactAdminPage = () => { + const { token, user } = useAuth(); + const [submissions, setSubmissions] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [notes, setNotes] = useState([]); + const [history, setHistory] = useState([]); + const [noteText, setNoteText] = useState(""); + const [isInternal, setIsInternal] = useState(true); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + const [actionMessage, setActionMessage] = useState(""); + const latestSelectionRef = useRef(null); + + useEffect(() => { + if (!actionMessage) return; + const timeoutId = setTimeout(() => { + setActionMessage(""); + }, 5000); + return () => clearTimeout(timeoutId); + }, [actionMessage]); + + const selectedSubmission = useMemo( + () => submissions.find((item) => item.id === selectedId) || null, + [submissions, selectedId] + ); + + const loadSubmissions = async () => { + setError(""); + setIsLoading(true); + try { + const data = await getContactSubmissions(token); + setSubmissions(data); + if (data.length && !selectedId) { + setSelectedId(data[0].id); + } + } catch (err) { + setError(err?.message || "Unable to load submissions."); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (!user || user.role !== "admin") { + return; + } + loadSubmissions(); + }, [user, token]); + + useEffect(() => { + if (!selectedId) return; + + const loadDetail = async () => { + latestSelectionRef.current = selectedId; + try { + const [noteData, historyData] = await Promise.all([ + getContactNotes(token, selectedId), + getContactHistory(token, selectedId), + ]); + if (latestSelectionRef.current !== selectedId) { + return; + } + setNotes(noteData); + setHistory(historyData); + } catch (err) { + setError(err?.message || "Unable to load submission details."); + } + }; + + loadDetail(); + }, [selectedId, token]); + + const handleUpdate = async (updates) => { + if (!selectedSubmission) return; + const currentId = selectedSubmission.id; + setActionMessage(""); + try { + const updated = await updateContactSubmission(token, selectedSubmission.id, updates); + setSubmissions((prev) => + prev.map((item) => (item.id === updated.id ? updated : item)) + ); + const historyData = await getContactHistory(token, selectedSubmission.id); + if (latestSelectionRef.current === currentId) { + setHistory(historyData); + } + setActionMessage("Submission updated."); + } catch (err) { + setError(err?.message || "Unable to update submission."); + } + }; + + const handleAddNote = async () => { + if (!noteText.trim() || !selectedSubmission) return; + const currentId = selectedSubmission.id; + setActionMessage(""); + try { + const newNote = await addContactNote(token, selectedSubmission.id, { + note: noteText.trim(), + is_internal: isInternal, + }); + setNotes((prev) => [newNote, ...prev]); + const historyData = await getContactHistory(token, selectedSubmission.id); + if (latestSelectionRef.current === currentId) { + setHistory(historyData); + } + setNoteText(""); + setActionMessage("Note added."); + } catch (err) { + setError(err?.message || "Unable to add note."); + } + }; + + const handleDelete = async () => { + if (!selectedSubmission) return; + setActionMessage(""); + try { + await deleteContactSubmission(token, selectedSubmission.id); + setSubmissions((prev) => prev.filter((item) => item.id !== selectedSubmission.id)); + setSelectedId(null); + setNotes([]); + setHistory([]); + setActionMessage("Submission deleted."); + } catch (err) { + setError(err?.message || "Unable to delete submission."); + } + }; + + if (user?.role !== "admin") { + return ( +
+
+

Admin access required

+

You do not have permission to view this page.

+
+
+ ); + } + + const messageWordCount = selectedSubmission?.message + ? selectedSubmission.message.trim().split(/\s+/).filter(Boolean).length + : 0; + const isLongMessage = messageWordCount > 500; + + return ( +
+
+
+

Contact Submissions

+

Review and manage incoming Contact Us requests.

+
+
+ + {error &&
{error}
} + {actionMessage &&
{actionMessage}
} + +
+
+ {isLoading ? ( +

Loading submissions...

+ ) : submissions.length ? ( +
    + {submissions.map((submission) => { + const isActive = submission.id === selectedId; + + return ( +
  • setSelectedId(submission.id)} + > +
    +

    + {submission.first_name} {submission.last_name} +

    +

    {submission.subject}

    +
    + + {submission.status} + +
  • + ); + })} +
+ ) : ( +

No submissions yet.

+ )} +
+ +
+ {selectedSubmission ? ( +
+
+
+

{selectedSubmission.subject}

+ + {selectedSubmission.status} + +
+
+ +
+

Contact Information

+
+
+ Name + + {selectedSubmission.first_name} {selectedSubmission.last_name} + +
+
+ Email + + {selectedSubmission.email} + +
+
+ Phone + + {selectedSubmission.phone || "Not provided"} + +
+
+ Company + + {selectedSubmission.company || "Not provided"} + +
+
+
+ +
+

Message

+
+ {selectedSubmission.message} +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+

Notes

+