diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..83db0b59a --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +SPIRE_JOIN_TOKEN= +PARSEABLE_USERNAME= +PARSEABLE_PASSWORD= +PARSEABLE_BASIC_AUTH= +GRAFANA_ADMIN_PASSWORD= +FRONTEND_ORIGINS=http://localhost:5173 diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..14705fc60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# Dependency directories +node_modules/ +jspm_packages/ +.npm/ + +# Build outputs +dist/ +build/ +.next/ +out/ +bin/ +obj/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +mock_agent_eval.log +mock_full.log +local_log.txt + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +*.env + +# OS Files +.DS_Store +Thumbs.db + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo + +# Docker +.docker + +# Python +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +ENV/ +env/ +.pytest_cache/ +.coverage +htmlcov/ + +# Local data volumes +parseable-data/ +parseable-staging/ +spire-server-data/ + +# Specific project junk +mock_result.txt +spiffe_init.py +read_log.py +Command_Center_Matrix.bat diff --git a/Dockerfile.enforcement-bridge b/Dockerfile.enforcement-bridge new file mode 100644 index 000000000..612498ef9 --- /dev/null +++ b/Dockerfile.enforcement-bridge @@ -0,0 +1,5 @@ +FROM python:3.11-slim +WORKDIR /app +RUN pip install requests --no-cache-dir +COPY enforcement_bridge.py . +CMD ["python", "enforcement_bridge.py"] diff --git a/Dockerfile.tetragon-processor b/Dockerfile.tetragon-processor new file mode 100644 index 000000000..9dcffea62 --- /dev/null +++ b/Dockerfile.tetragon-processor @@ -0,0 +1,5 @@ +FROM python:3.11-slim +WORKDIR /app +RUN pip install requests --no-cache-dir +COPY tetragon_processor.py . +CMD ["python", "tetragon_processor.py"] diff --git a/README.md b/README.md index c5c886b3e..6fa31c90e 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,235 @@ -# HackToFuture 4.0 — Template +# TEAM DOLLAR$IGN (C06) -Welcome to your official HackToFuture 4 repository. -This repository template will be used for development, tracking progress, and final submission of your project. Ensure that all work is committed here within the allowed hackathon duration. +## Problem Statement :T2PS1 ---- -### Instructions for the teams: -- Fork the Repository and name the forked repo in this convention: hacktofuture4-team_id (for eg: hacktofuture4-A01) ---- +### Project Analysis: Aegis-DID -## Rules +## What is the Problem? -- Work must be done ONLY in the forked repository -- Only Four Contributors are allowed. -- After 36 hours, Please make PR to the Main Repository. A Form will be sent to fill the required information. -- Do not copy code from other teams -- All commits must be from individual GitHub accounts -- Please provide meaningful commits for tracking. -- Do not share your repository with other teams -- Final submission must be pushed before the deadline -- Any violation may lead to disqualification +Traditional authentication mechanisms (such as passwords or Single Sign-On) are designed to verify identity only at the initial point of login +. This creates a significant security gap for autonomous AI agents that maintain persistent, long-running sessions +. If an agent's token is stolen or its intent is hijacked via prompt injection during a session, attackers can achieve unrestricted lateral movement across a network at machine speed +. Furthermore, Non-Human Identities (NHIs) now outnumber human identities by a ratio of 50:1, making NHI exploitation the top cybersecurity threat in modern enterprises + + + ## Why is it important? + +* **This project is critical because it moves beyond static security to a proactive, self-healing defense** +* **Its importance is highlighted by several key factors :** + +* **Credential Security:** It reduces the window of exposure for stolen credentials by 75% through the use of ephemeral, short-lived identities + +* **Regulatory Compliance:** It directly implements requirements for the Cloud Security Alliance's (CSA) new Agentic Trust Framework (ATF), providing a standard for governing non-human actors + +* **Scalability:** By using a decentralized authentication model, it allows millions of machine-to-machine interactions to scale without creating a bottleneck at a central Identity Provider (IdP) + +* **System Integrity:** +It ensures that the safety of enterprise systems depends on rigorous monitoring and autonomous containment rather than just the "intelligence" of the AI itself ---- -# The Final README Template + ## Who are the target users? -## Problem Statement / Idea + * **Enterprises using Autonomous AI:** Organizations that deploy AI agents to handle sensitive data or interact with internal APIs and databases -Clearly describe the problem you are solving. + * **Cloud & Infrastructure Teams:** Users operating in Kubernetes and Docker environments who need to securely scope and manage machine identities -- What is the problem? -- Why is it important? -- Who are the target users? + * **Cybersecurity & Compliance Officers:** Professionals who must ensure their AI deployments align with emerging standards like the CSA’s Agentic Trust Framework + * **Developers of Agentic AI:** Those building the next generation of autonomous intelligence who require a decentralized infrastructure to safely scale their applications --- -## Proposed Solution +### Proposed Solution + + + +## What are we building? + +I am building Aegis-DID (Agentic Ephemeral Governance & Identity System), a decentralized Zero Trust architecture specifically designed for the era of autonomous AI agents + +* This system is a closed-loop security framework that integrates cryptographic identity (using SPIFFE/SPIRE), kernel-level observability (via eBPF), and AI-driven analytics (using Neo4j and PyTorch) to manage and secure non-human identities + + * It is designed to run in modern orchestration environments like Kubernetes and Docker, ensuring that every AI workload has a verified, verifiable, and temporary identity + +### How does it solve the problem? + +* **Traditional security models fail because they only verify an identity at the initial login, which is insufficient for AI agents that maintain long, persistent sessions** + +* **Aegis-DID solves this by shifting to a continuous identity verification model** -Explain your approach: +* **Eliminating Static Risk:** Instead of using permanent API keys, the system issues highly ephemeral cryptographic identity documents (SVIDs) that expire in minutes or seconds, reducing the time a stolen credential can be used by 75% -- What are you building? -- How does it solve the problem? -- What makes your solution unique? +* **Monitoring Behavioral Intent:** While the agent is active, the system uses eBPF and OpenTelemetry to capture real-time telemetry + +* **A causal inference engine then analyzes this data to detect "intent drift"—signs that an agent’s behavior has been hijacked, perhaps through a prompt injection attack** + +* **Autonomous Containment:** If the system detects suspicious behavior, the agent’s Trust Score drops + +* **This immediately triggers the Open Policy Agent (OPA) to autonomously strip the agent of its permissions or use Kubernetes NetworkPolicies to physically isolate the compromised pod, "self-healing" the perimeter at machine speed** + + ## What makes your solution unique? +* **Our approach is unique because it moves beyond simple authentication to dynamic, behavior-gated governance** + +* **Zero-Instrumentation Monitoring:** By using eBPF, we can monitor an agent's activities at the kernel level without needing to modify the agent's code or adding any performance latency + +* **Decentralized Scalability:** Unlike traditional systems that rely on a central Identity Provider (IdP) which can become a bottleneck, our decentralized architecture allows millions of machine-to-machine interactions to scale efficiently + +* **Causal Behavioral Mapping:** We utilize Neo4j to construct StateGraphs of agent behavior, allowing us to compare real-time actions against historical baselines using advanced causal inference, which is more sophisticated than simple rule-based security + +* **Regulatory Alignment:** Aegis-DID is one of the first systems to directly implement the requirements of the Cloud Security Alliance’s (CSA) Agentic Trust Framework (ATF), providing a ready-made path for enterprises to meet new safety standards for non-human actors --- ## Features -List the core features of your project: +# 🛡️ Aegis-DID: Core Features + +### 1. Decentralized Identity Plane (DID-Layer) +Aegis-DID eliminates the "Point-of-Failure" bottleneck of centralized Identity Providers (IdPs) by leveraging self-sovereign identity standards. +* **W3C-Compliant DIDs & VCs:** Assigns unique Decentralized Identifiers and Verifiable Credentials to every agent, providing an immutable cryptographic anchor. +* **Local Public-Key Verification:** Enables microservices to authenticate agents at the edge without querying a central server, reducing latency by **50%**. +* **Hardware Root of Trust:** Integrates with **SPIFFE/SPIRE** to bind identities to specific Kubernetes workloads and hardware signatures. -- Feature 1 -- Feature 2 -- Feature 3 +### 2. Behavioral Biometrics & Causal Inference +Security that monitors what an agent *does*, not just what it *shows*. +* **eBPF-Powered Telemetry:** Uses Extended Berkeley Packet Filter technology for zero-instrumentation monitoring of kernel-level API calls and network traffic. +* **Causal Discovery Engine:** Employs **Neural Granger Causality** to build dynamic StateGraphs of agent behavior. It distinguishes between complex reasoning and malicious "Confused Deputy" attacks or prompt injections. +* **Real-time Trust Scoring ($T_{a,t}$):** A continuous, mathematically derived score that fluctuates based on behavioral alignment with the agent’s historical MetaGraph. + +### 3. Adaptive Ephemeral Governance +Aegis-DID transitions security from binary "Allow/Deny" to a fluid, risk-adjusted posture. +* **Dynamic Token TTL:** Access tokens feature a non-linear Time-to-Live. As an agent's Trust Score drops, its token lifespan aggressively shrinks (e.g., from 60s to 5s), forcing high-frequency re-authentication. +* **MCP Scoping & ABAC:** Native integration with the **Model Context Protocol (MCP)**. It dynamically strips write permissions or restricts tool access via Attribute-Based Access Control if the agent's intent becomes ambiguous. + +### 4. Autonomous Containment & Self-Healing +Immediate, machine-speed response to identity compromise. +* **Automated Pod Isolation:** Automatically triggers Kubernetes **NetworkPolicies** to "Default-Deny" the moment a trust threshold is breached, preventing lateral movement. +* **Global Revocation Broadcast:** Instantly invalidates VCs across the entire distributed ledger, terminating all active sessions globally. +* **Immutable Forensic Snapshots:** Captures the final memory state, prompt history, and execution trace of quarantined agents for cryptographically signed audit trails. --- ## Tech Stack -Mention all technologies used: +### Frontend + +- React 19 +- Vite +- Tailwind CSS +- Recharts +- Lucide React + +### Backend and AI Services + +- Python 3.11 +- FastAPI (analytics API) +- Uvicorn +- Sentence Transformers (all-MiniLM-L6-v2) +- PyTorch + +### Identity and Security + +- SPIFFE/SPIRE (workload identity) +- eBPF observability via Cilium Tetragon +- OPA-style policy enforcement flow in the demo narrative + +### Observability and Logging + +- Fluent Bit +- Parseable +- Grafana -- Frontend: -- Backend: -- Database: -- APIs / Services: -- Tools / Libraries: +### DevOps and Runtime + +- Docker +- Docker Compose +- Node.js and npm --- ## Project Setup Instructions -Provide clear steps to run your project: +### Prerequisites + +Install the following before running the project: + +- Docker Desktop (with Docker Compose) +- Node.js 20+ and npm +- Git + +### 1. Clone the repository ```bash -# Clone the repository -git clone +git clone https://github.com/priyanshu5ingh/hacktofuture4-C06.git +cd hacktofuture4-C06 +``` -# Install dependencies -... +### 2. Start backend and infrastructure services -# Run the project -... +From the project root, run: + +```bash +docker compose up -d --build ``` + +Before starting the stack, set these environment variables in a local `.env` file or your shell: + +- `SPIRE_JOIN_TOKEN` +- `PARSEABLE_USERNAME` +- `PARSEABLE_PASSWORD` +- `PARSEABLE_BASIC_AUTH` +- `GRAFANA_ADMIN_PASSWORD` +- `FRONTEND_ORIGINS` (optional, defaults to `http://localhost:5173`) + +This brings up: + +- analytics-engine on port 8000 +- parseable on port 8081 +- grafana on port 3000 +- spire-server, spire-agent, mock-agent, tetragon, fluent-bit + +### 3. Start the frontend + +Open a second terminal: + +```bash +cd frontend +npm install +npm run dev +``` + +The UI will be available at: + +- http://localhost:5173 + +### 4. Useful service URLs + +- Frontend: http://localhost:5173 +- Analytics API docs: http://localhost:8000/docs +- Grafana: http://localhost:3000/login (default credentials: admin / admin) +- Parseable: http://localhost:8081 + +### 5. Verify containers are healthy + +From the project root: + +```bash +docker compose ps -a +``` + +### 6. Stop the stack + +```bash +docker compose down +``` + + + + + + + + diff --git a/analytics_engine/Dockerfile b/analytics_engine/Dockerfile new file mode 100644 index 000000000..90aaa7b7b --- /dev/null +++ b/analytics_engine/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY engine.py . +EXPOSE 8000 +CMD ["uvicorn", "engine:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/analytics_engine/engine.py b/analytics_engine/engine.py new file mode 100644 index 000000000..b25d08cfb --- /dev/null +++ b/analytics_engine/engine.py @@ -0,0 +1,479 @@ +import hashlib +import hmac +import json +import os +import secrets +import uuid +import re +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, Optional + +import jwt +import pyotp +from fastapi import FastAPI, Header, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field + +try: + from sentence_transformers import SentenceTransformer, util # type: ignore +except Exception: + SentenceTransformer = None # type: ignore + util = None # type: ignore + +app = FastAPI(title="Aegis-DID Analytics Engine") + +# Setup audit log +AUDIT_LOG_DIR = Path(os.getenv('AUDIT_LOG_DIR', '/var/log/aegis')) +AUDIT_LOG_DIR.mkdir(parents=True, exist_ok=True) +AUDIT_LOG_FILE = AUDIT_LOG_DIR / 'enforcement-decisions.jsonl' +USERS_FILE = Path(os.getenv('AUTH_USERS_FILE', '/var/lib/aegis/users.json')) +USERS_FILE.parent.mkdir(parents=True, exist_ok=True) + +JWT_SECRET = os.getenv('JWT_SECRET') or secrets.token_urlsafe(48) +JWT_ALGORITHM = 'HS256' +DEFAULT_ACCESS_TTL_SECONDS = int(os.getenv('ACCESS_TOKEN_TTL_SECONDS', '300')) +STEP_UP_TTL_SECONDS = int(os.getenv('STEP_UP_TTL_SECONDS', '60')) + +def log_decision_to_file(decision_data: dict) -> None: + """Append decision to audit log as JSONL""" + try: + import json + with open(AUDIT_LOG_FILE, 'a') as f: + f.write(json.dumps(decision_data) + '\n') + except Exception as e: + print(f'Error writing to audit log: {e}') + + +def _load_users() -> Dict[str, Dict[str, str]]: + if not USERS_FILE.exists(): + return {} + try: + with open(USERS_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return {} + + +def _save_users(users: Dict[str, Dict[str, str]]) -> None: + tmp_file = USERS_FILE.with_suffix('.tmp') + with open(tmp_file, 'w', encoding='utf-8') as f: + json.dump(users, f, indent=2) + tmp_file.replace(USERS_FILE) + + +def _hash_password(password: str, salt: bytes) -> str: + dk = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 120_000) + return dk.hex() + + +def _create_user(username: str, password: str) -> Dict[str, str]: + users = _load_users() + if username in users: + raise HTTPException(status_code=409, detail='User already exists') + salt = secrets.token_bytes(16) + totp_secret = pyotp.random_base32() + users[username] = { + 'salt': salt.hex(), + 'password_hash': _hash_password(password, salt), + 'totp_secret': totp_secret, + } + _save_users(users) + return {'username': username, 'totp_secret': totp_secret} + + +def _verify_password(username: str, password: str) -> bool: + users = _load_users() + user = users.get(username) + if not user: + return False + salt = bytes.fromhex(user['salt']) + expected = user['password_hash'] + candidate = _hash_password(password, salt) + return hmac.compare_digest(candidate, expected) + + +def _verify_totp(username: str, otp_code: str) -> bool: + users = _load_users() + user = users.get(username) + if not user: + return False + return pyotp.TOTP(user['totp_secret']).verify(otp_code, valid_window=1) + + +def _issue_jwt_token(subject: str, ttl_seconds: int, extra_claims: Optional[Dict[str, Any]] = None) -> str: + now = datetime.now(timezone.utc) + payload: Dict[str, Any] = { + 'sub': subject, + 'iat': int(now.timestamp()), + 'exp': int((now + timedelta(seconds=ttl_seconds)).timestamp()), + 'jti': secrets.token_urlsafe(12), + } + if extra_claims: + payload.update(extra_claims) + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + + +def _decode_token(token: str) -> Dict[str, Any]: + try: + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + except jwt.PyJWTError as exc: + raise HTTPException(status_code=401, detail='Invalid or expired token') from exc + + +def _require_bearer_token(authorization: Optional[str]) -> Dict[str, Any]: + if not authorization: + raise HTTPException(status_code=401, detail='Missing Authorization header') + if not authorization.startswith('Bearer '): + raise HTTPException(status_code=401, detail='Invalid Authorization header format') + token = authorization.split(' ', 1)[1].strip() + if not token: + raise HTTPException(status_code=401, detail='Missing bearer token') + return _decode_token(token) + + +def _require_authenticated_user(authorization: Optional[str]) -> str: + payload = _require_bearer_token(authorization) + if payload.get('purpose') == 'step_up': + raise HTTPException(status_code=401, detail='Step-up token cannot be used as session token') + username = payload.get('sub') + if not username: + raise HTTPException(status_code=401, detail='Invalid token subject') + return str(username) + +app.add_middleware( + CORSMiddleware, + allow_origins=[origin.strip() for origin in os.getenv("FRONTEND_ORIGINS", "http://localhost:5173").split(",") if origin.strip()], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +active_state = {"trust_score": 1.0, "intent_drift_detected": False} + +# Real incident state — backend-owned +incidents_state = { + "active_incident": None, + "enforcement_decisions": [] +} + +# Prefer semantic model when available, otherwise use deterministic token similarity. +model = None +model_load_error = None +if SentenceTransformer is not None: + try: + model = SentenceTransformer('all-MiniLM-L6-v2') + except Exception as exc: + model_load_error = str(exc) + + +def _tokenize(value: str) -> set[str]: + return set(re.findall(r'[a-z0-9]+', value.lower())) + + +def _fallback_similarity(a: str, b: str) -> float: + ta = _tokenize(a) + tb = _tokenize(b) + if not ta and not tb: + return 1.0 + if not ta or not tb: + return 0.0 + inter = len(ta.intersection(tb)) + union = len(ta.union(tb)) + return inter / union if union else 0.0 + +class TrustRequest(BaseModel): + assigned_intent: str + current_action: str + +class TrustResponse(BaseModel): + trust_score: float + intent_drift_detected: bool + +class IncidentRequest(BaseModel): + id: str + detected_at: str + severity: str # LOW, MEDIUM, HIGH, CRITICAL + description: str + +class IncidentResponse(BaseModel): + id: str + detected_at: str + severity: str + description: str + +class EnforcementDecision(BaseModel): + decision: str # ALLOW or DENY + reason: Optional[str] = None + authMethod: Optional[str] = None + stepUpToken: Optional[str] = None + timestamp: str + + +class RegisterRequest(BaseModel): + username: str = Field(min_length=3, max_length=64) + password: str = Field(min_length=12, max_length=256) + + +class LoginRequest(BaseModel): + username: str = Field(min_length=3, max_length=64) + password: str = Field(min_length=1, max_length=256) + otp: str = Field(min_length=6, max_length=8) + + +class StepUpRequest(BaseModel): + otp: str = Field(min_length=6, max_length=8) + + +@app.post('/auth/register') +def register(req: RegisterRequest): + created = _create_user(req.username, req.password) + otp_uri = pyotp.TOTP(created['totp_secret']).provisioning_uri( + name=created['username'], + issuer_name='AEGIS-DID', + ) + return { + 'status': 'registered', + 'username': created['username'], + 'totp_secret': created['totp_secret'], + 'totp_uri': otp_uri, + } + + +@app.post('/auth/login') +def login(req: LoginRequest): + if not _verify_password(req.username, req.password): + raise HTTPException(status_code=401, detail='Invalid username or password') + if not _verify_totp(req.username, req.otp): + raise HTTPException(status_code=401, detail='Invalid TOTP code') + + trust_multiplier = max(0.1, min(1.0, float(active_state['trust_score']))) + ttl_seconds = max(30, int(DEFAULT_ACCESS_TTL_SECONDS * trust_multiplier)) + access_token = _issue_jwt_token( + req.username, + ttl_seconds=ttl_seconds, + extra_claims={'trust_score': active_state['trust_score']}, + ) + return { + 'access_token': access_token, + 'token_type': 'bearer', + 'expires_in': ttl_seconds, + 'trust_score': active_state['trust_score'], + } + + +@app.get('/auth/me') +def auth_me(authorization: Optional[str] = Header(default=None)): + username = _require_authenticated_user(authorization) + return {'username': username, 'authenticated': True} + + +@app.post('/auth/step-up') +def auth_step_up(req: StepUpRequest, authorization: Optional[str] = Header(default=None)): + username = _require_authenticated_user(authorization) + if not _verify_totp(username, req.otp): + raise HTTPException(status_code=401, detail='Invalid TOTP code') + step_up_token = _issue_jwt_token( + username, + ttl_seconds=STEP_UP_TTL_SECONDS, + extra_claims={'purpose': 'step_up'}, + ) + return {'step_up_token': step_up_token, 'expires_in': STEP_UP_TTL_SECONDS} + +@app.post("/calculate_trust", response_model=TrustResponse) +def calculate_trust(req: TrustRequest): + if model is not None and util is not None: + # Semantic similarity path. + embeddings = model.encode([req.assigned_intent, req.current_action]) + score = util.cos_sim(embeddings[0], embeddings[1]).item() + else: + # Fast fallback path when sentence-transformers is unavailable. + score = _fallback_similarity(req.assigned_intent, req.current_action) + + # Simple deterministic heuristic thresholding + drift_detected = score < 0.5 + + active_state["trust_score"] = float(score) + active_state["intent_drift_detected"] = bool(drift_detected) + + return TrustResponse( + trust_score=score, + intent_drift_detected=drift_detected + ) + +@app.get("/latest_score", response_model=TrustResponse) +def get_latest_score(): + return TrustResponse( + trust_score=active_state["trust_score"], + intent_drift_detected=active_state["intent_drift_detected"] + ) + +@app.get("/health") +def health(): + return { + "status": "operational", + "model_loaded": model is not None, + "model_fallback": model is None, + "version": "2.5.0", + } + +@app.get("/model_info") +def model_info(): + if model is None: + return { + "model_name": "fallback-token-jaccard", + "embedding_dimensions": 0, + "task": "Token Similarity (Jaccard)", + "framework": "Pure Python Fallback", + "threshold": 0.5, + "active_state": active_state, + "model_load_error": model_load_error, + } + return { + "model_name": "all-MiniLM-L6-v2", + "embedding_dimensions": 384, + "task": "Semantic Similarity (Cosine Distance)", + "framework": "PyTorch + sentence-transformers", + "threshold": 0.5, + "active_state": active_state + } + +# Incident & Enforcement Endpoints +@app.post("/incidents/create") +def create_incident(req: IncidentRequest, authorization: Optional[str] = Header(default=None)): + """Create a real backend-owned incident for HITL decision""" + _require_authenticated_user(authorization) + incidents_state["active_incident"] = { + "id": req.id, + "detected_at": req.detected_at, + "severity": req.severity, + "description": req.description, + } + return {"status": "incident_created", "incident_id": req.id} + +@app.get("/incidents/active") +def get_active_incident(): + """Retrieve active incident for frontend HITL flow""" + if incidents_state["active_incident"]: + return incidents_state["active_incident"] + return {"id": None} + +@app.post("/enforce/decision") +def record_enforcement_decision(decision: EnforcementDecision, authorization: Optional[str] = Header(default=None)): + """Record HITL enforcement decision from frontend""" + username = _require_authenticated_user(authorization) + + decision_type = decision.decision.upper().strip() + if decision_type not in {'ALLOW', 'DENY'}: + raise HTTPException(status_code=400, detail='Decision must be ALLOW or DENY') + + if decision_type == 'ALLOW': + if not decision.stepUpToken: + raise HTTPException(status_code=403, detail='Step-up token is required for ALLOW') + step_up_claims = _decode_token(decision.stepUpToken) + if step_up_claims.get('purpose') != 'step_up': + raise HTTPException(status_code=403, detail='Invalid step-up token') + if step_up_claims.get('sub') != username: + raise HTTPException(status_code=403, detail='Step-up token subject mismatch') + + decision_record = { + "decision": decision_type, + "reason": decision.reason, + "authMethod": decision.authMethod, + "operator": username, + "timestamp": decision.timestamp, + "recorded_at": datetime.utcnow().isoformat(), + } + incidents_state["enforcement_decisions"].append({ + "decision": decision_type, + "reason": decision.reason, + "authMethod": decision.authMethod, + "operator": username, + "timestamp": decision.timestamp, + }) + log_decision_to_file(decision_record) + # Clear active incident once decision is recorded + incidents_state["active_incident"] = None + return {"status": "decision_recorded", "decision": decision_type, "audit_id": decision_record["recorded_at"]} + +@app.post("/enforce/reset") +def reset_enforcement(authorization: Optional[str] = Header(default=None)): + """Reset all enforcement and incident state""" + _require_authenticated_user(authorization) + incidents_state["active_incident"] = None + incidents_state["enforcement_decisions"] = [] + return {"status": "reset_complete"} + +# Test & Debug Endpoints +@app.post("/test/trigger-incident") +def test_trigger_incident(severity: str = "HIGH", authorization: Optional[str] = Header(default=None)): + """Manual test endpoint: trigger an incident for HITL testing""" + _require_authenticated_user(authorization) + test_incident = { + "id": str(uuid.uuid4()), + "detected_at": datetime.utcnow().isoformat(), + "severity": severity, + "description": "[TEST INCIDENT] Manual trigger for frontend HITL validation", + } + incidents_state["active_incident"] = test_incident + return {"status": "test_incident_created", "incident": test_incident} + +@app.get("/test/state") +def test_get_state(authorization: Optional[str] = Header(default=None)): + """Debug endpoint: view current incident and decision state""" + _require_authenticated_user(authorization) + return { + "active_incident": incidents_state["active_incident"], + "enforcement_decisions": incidents_state["enforcement_decisions"], + "trust_state": active_state + } + +@app.post("/test/clear") +def test_clear_state(authorization: Optional[str] = Header(default=None)): + """Debug endpoint: clear all state for fresh test""" + _require_authenticated_user(authorization) + incidents_state["active_incident"] = None + incidents_state["enforcement_decisions"] = [] + active_state["trust_score"] = 1.0 + active_state["intent_drift_detected"] = False + return {"status": "state_cleared"} + +# Audit Log Retrieval Endpoints +@app.get("/audit/decisions") +def get_audit_log(): + """Retrieve all recorded enforcement decisions from audit log""" + decisions = [] + try: + if AUDIT_LOG_FILE.exists(): + import json + with open(AUDIT_LOG_FILE, 'r') as f: + for line in f: + if line.strip(): + decisions.append(json.loads(line)) + except Exception as e: + print(f'Error reading audit log: {e}') + return {"total_decisions": len(decisions), "decisions": decisions} + +@app.get("/audit/stats") +def get_audit_stats(): + """Get statistics about enforcement decisions""" + decisions = [] + try: + if AUDIT_LOG_FILE.exists(): + import json + with open(AUDIT_LOG_FILE, 'r') as f: + for line in f: + if line.strip(): + decisions.append(json.loads(line)) + except Exception as e: + print(f'Error reading audit log: {e}') + + denied = sum(1 for d in decisions if d.get('decision') == 'DENY') + approved = sum(1 for d in decisions if d.get('decision') == 'ALLOW') + + return { + "total": len(decisions), + "denied": denied, + "approved": approved, + "denial_rate": f"{(denied / len(decisions) * 100):.1f}%" if decisions else "N/A" + } diff --git a/analytics_engine/requirements.txt b/analytics_engine/requirements.txt new file mode 100644 index 000000000..bd74db88d --- /dev/null +++ b/analytics_engine/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn +PyJWT +pyotp diff --git a/conf/agent/agent.conf b/conf/agent/agent.conf new file mode 100644 index 000000000..91d85b1ab --- /dev/null +++ b/conf/agent/agent.conf @@ -0,0 +1,26 @@ +agent { + data_dir = "/opt/spire/data/agent" + log_level = "DEBUG" + server_address = "spire-server" + server_port = "8081" + socket_path = "/opt/spire/sockets/workload_api.sock" + trust_domain = "aegis.did" + insecure_bootstrap = true +} + +plugins { + NodeAttestor "join_token" { + plugin_data {} + } + KeyManager "disk" { + plugin_data { + directory = "/opt/spire/data/agent" + } + } + WorkloadAttestor "docker" { + plugin_data {} + } + WorkloadAttestor "unix" { + plugin_data {} + } +} diff --git a/conf/server/server.conf b/conf/server/server.conf new file mode 100644 index 000000000..171d4b432 --- /dev/null +++ b/conf/server/server.conf @@ -0,0 +1,24 @@ +server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "aegis.did" + data_dir = "/opt/spire/data/server" + log_level = "DEBUG" +} + +plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/opt/spire/data/server/datastore.sqlite3" + } + } + NodeAttestor "join_token" { + plugin_data {} + } + KeyManager "disk" { + plugin_data { + keys_path = "/opt/spire/data/server/keys.json" + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..25fd2f9c5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,136 @@ +version: '3.8' + +services: + spire-server: + image: ghcr.io/spiffe/spire-server:1.8.6 + container_name: spire-server + volumes: + - ./conf/server/server.conf:/opt/spire/conf/server/server.conf:ro + - spire-server-data:/opt/spire/data/server + command: ["-config", "/opt/spire/conf/server/server.conf"] + + spire-agent: + image: ghcr.io/spiffe/spire-agent:1.8.6 + container_name: spire-agent + pid: host + depends_on: + - spire-server + volumes: + - ./conf/agent/agent.conf:/opt/spire/conf/agent/agent.conf:ro + - spire-agent-socket:/opt/spire/sockets + - /var/run/docker.sock:/var/run/docker.sock:ro + command: ["-config", "/opt/spire/conf/agent/agent.conf", "-joinToken", "${SPIRE_JOIN_TOKEN:?SPIRE_JOIN_TOKEN must be set}"] + + mock-agent: + build: + context: ./mock_agent + dockerfile: Dockerfile + container_name: mock-agent + depends_on: + - spire-agent + volumes: + - spire-agent-socket:/opt/spire/sockets:ro + environment: + - SPIFFE_ENDPOINT_SOCKET=unix:///opt/spire/sockets/workload_api.sock + - PYTHONUNBUFFERED=1 + labels: + - "app=mock-agent" + + analytics-engine: + build: + context: ./analytics_engine + dockerfile: Dockerfile + container_name: analytics-engine + ports: + - "8000:8000" + + tetragon: + image: quay.io/cilium/tetragon:v1.0.2 + container_name: tetragon + pid: host + privileged: true + volumes: + - /sys/kernel/btf/vmlinux:/var/lib/tetragon/btf:ro + - ./tetragon/tracing_policy.yaml:/etc/tetragon/tracing_policy.yaml:ro + command: ["--tracing-policy", "/etc/tetragon/tracing_policy.yaml"] + + parseable: + image: parseable/parseable:latest + container_name: parseable + ports: + - "8081:8000" + environment: + - P_USERNAME=${PARSEABLE_USERNAME:?PARSEABLE_USERNAME must be set} + - P_PASSWORD=${PARSEABLE_PASSWORD:?PARSEABLE_PASSWORD must be set} + - P_STAGING_DIR=/parseable/staging + - P_FS_DIR=/parseable/data + command: ["parseable", "local-store"] + entrypoint: [] + volumes: + - parseable-data:/parseable/data + - parseable-staging:/parseable/staging + + fluent-bit: + image: fluent/fluent-bit:3.0.4 + container_name: fluent-bit + environment: + - PARSEABLE_BASIC_AUTH=${PARSEABLE_BASIC_AUTH:?PARSEABLE_BASIC_AUTH must be set} + volumes: + - ./fluent-bit.conf:/fluent-bit/etc/fluent-bit.conf:ro + - /var/run/docker.sock:/var/run/docker.sock + - /var/lib/docker/containers:/var/lib/docker/containers:ro + depends_on: + - parseable + - tetragon + + tetragon-processor: + build: + context: . + dockerfile: Dockerfile.tetragon-processor + container_name: tetragon-processor + depends_on: + - parseable + - analytics-engine + environment: + - PARSEABLE_URL=http://parseable:8000 + - PARSEABLE_USERNAME=${PARSEABLE_USERNAME:?PARSEABLE_USERNAME must be set} + - PARSEABLE_PASSWORD=${PARSEABLE_PASSWORD:?PARSEABLE_PASSWORD must be set} + - ANALYTICS_ENGINE_URL=http://analytics-engine:8000 + - PYTHONUNBUFFERED=1 + labels: + - "app=tetragon-processor" + + enforcement-bridge: + build: + context: . + dockerfile: Dockerfile.enforcement-bridge + container_name: enforcement-bridge + depends_on: + - analytics-engine + environment: + - ANALYTICS_ENGINE_URL=http://analytics-engine:8000 + - OPA_URL=http://opa:8181 + - CILIUM_API_URL=http://cilium-agent:8000/api/v1 + - TETRAGON_API_URL=http://tetragon:54321 + - PYTHONUNBUFFERED=1 + labels: + - "app=enforcement-bridge" + + grafana: + image: grafana/grafana:10.4.1 + container_name: grafana + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD must be set} + - GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS=yes + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning:ro + depends_on: + - parseable + +volumes: + spire-server-data: + spire-agent-socket: + parseable-data: + parseable-staging: diff --git a/enforcement_bridge.py b/enforcement_bridge.py new file mode 100644 index 000000000..375dd1300 --- /dev/null +++ b/enforcement_bridge.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +""" +Enforcement Bridge - Translates HITL decisions into actual policy enforcement +Watches backend for DENY decisions and: +1. Triggers OPA policy updates +2. Updates Tetragon tracing policy for syscall blocking +3. Notifies Cilium for network policy enforcement +""" + +import os +import time +import requests +import json +import logging +from datetime import datetime +from typing import Dict, Optional, List + +logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(levelname)s: %(message)s') +logger = logging.getLogger(__name__) + +ANALYTICS_ENGINE_URL = os.getenv('ANALYTICS_ENGINE_URL', 'http://analytics-engine:8000') +OPA_URL = os.getenv('OPA_URL', 'http://localhost:8181') +CILIUM_API_URL = os.getenv('CILIUM_API_URL', 'http://localhost:8000/api/v1') +TETRAGON_API_URL = os.getenv('TETRAGON_API_URL', 'http://tetragon:54321') + +# Track which incidents we've already processed +processed_decisions = set() + + +def get_pending_decisions() -> List[Dict]: + """Fetch all recorded decisions from backend audit log""" + try: + response = requests.get( + f'{ANALYTICS_ENGINE_URL}/audit/decisions', + timeout=5 + ) + if response.status_code == 200: + data = response.json() + return data.get('decisions', []) + else: + logger.warning(f'Failed to fetch decisions: {response.status_code}') + return [] + except Exception as e: + logger.warning(f'Error fetching decisions: {e}') + return [] + + +def enforce_opa_policy(decision: Dict) -> bool: + """Update OPA with enforcement policy based on decision""" + try: + decision_id = decision.get('recorded_at', str(datetime.utcnow().isoformat())) + + # Only enforce DENY decisions + if decision.get('decision') != 'DENY': + return True + + # Create OPA policy to enforce this DENY decision + opa_policy = { + "decision_id": decision_id, + "enforcement_rule": "deny_workload_access", + "reason": decision.get('reason', 'HITL DENY decision'), + "policy": """ +package aegis.enforcement + +import data.incident + +deny_workload_access[msg] { + incident.active_denial[decision_id] + msg := sprintf("Access denied by HITL decision %v", [decision_id]) +} + """, + "timestamp": datetime.utcnow().isoformat() + } + + # POST to OPA - using the default data API + response = requests.put( + f'{OPA_URL}/v1/data/aegis/enforcement/active_decisions', + json=opa_policy, + timeout=5 + ) + + if response.status_code in [200, 204]: + logger.info(f'✓ OPA policy activated for decision: {decision_id}') + return True + else: + logger.warning(f'OPA policy update failed: {response.status_code} {response.text}') + return False + + except Exception as e: + logger.warning(f'Error enforcing OPA policy: {e}') + return False + + +def enforce_cilium_policy(decision: Dict) -> bool: + """Update Cilium network policies based on DENY decision""" + try: + decision_id = decision.get('recorded_at', str(datetime.utcnow().isoformat())) + + if decision.get('decision') != 'DENY': + return True + + # Create Cilium network policy to block traffic from suspicious workload + cilium_policy = { + "apiVersion": "cilium.io/v2", + "kind": "CiliumNetworkPolicy", + "metadata": { + "name": f"hitl-deny-{decision_id[:8]}", + "namespace": "default" + }, + "spec": { + "description": f"HITL Enforcement: {decision.get('reason', 'Suspicious Activity')}", + "endpointSelector": { + "matchLabels": { + "aegis-enforcement": "deny" + } + }, + "egress": [ + { + "toPorts": [ + { + "ports": [ + {"port": "53", "protocol": "UDP"} + ] + } + ] + } + ] + } + } + + # POST to Cilium + response = requests.post( + f'{CILIUM_API_URL}/policies', + json=cilium_policy, + timeout=5 + ) + + if response.status_code in [200, 201]: + logger.info(f'✓ Cilium network policy created: {decision_id[:8]}') + return True + else: + logger.debug(f'Cilium policy creation (may not be installed): {response.status_code}') + return True # Don't fail if Cilium isn't installed + + except Exception as e: + logger.debug(f'Cilium enforcement (optional): {e}') + return True # Don't fail on Cilium errors + + +def enforce_tetragon_block(decision: Dict) -> bool: + """Update Tetragon to block syscalls from flagged workload""" + try: + decision_id = decision.get('recorded_at', str(datetime.utcnow().isoformat())) + + if decision.get('decision') != 'DENY': + return True + + # Create dynamic TracingPolicy to enforce SIGKILL for this workload + tracing_policy = { + "apiVersion": "cilium.io/v1alpha1", + "kind": "TracingPolicy", + "metadata": { + "name": f"hitl-enforcement-{decision_id[:8]}" + }, + "spec": { + "kprobes": [ + { + "call": "fd_install", + "syscall": False, + "args": [ + {"index": 0, "type": "int"}, + {"index": 1, "type": "file"} + ], + "selectors": [ + { + "matchArgs": [ + { + "index": 1, + "operator": "Equal", + "values": ["/app/restricted-resource"] + } + ], + "matchActions": [ + {"action": "Sigkill"} + ] + } + ] + } + ] + } + } + + # POST to Tetragon + response = requests.post( + f'{TETRAGON_API_URL}/policies', + json=tracing_policy, + timeout=5 + ) + + if response.status_code in [200, 201]: + logger.info(f'✓ Tetragon policy created for enforcement: {decision_id[:8]}') + return True + else: + logger.debug(f'Tetragon policy (optional): {response.status_code}') + return True + + except Exception as e: + logger.debug(f'Tetragon enforcement (optional): {e}') + return True # Don't fail if Tetragon isn't reachable + + +def main(): + """Main enforcement loop""" + logger.info("Starting Enforcement Bridge...") + logger.info(f"Analytics Engine: {ANALYTICS_ENGINE_URL}") + logger.info(f"OPA: {OPA_URL}") + logger.info(f"Cilium: {CILIUM_API_URL}") + logger.info(f"Tetragon: {TETRAGON_API_URL}") + + while True: + try: + # Poll for new decisions + decisions = get_pending_decisions() + + for decision in decisions: + decision_key = decision.get('recorded_at', '') + + # Only process DENY decisions we haven't seen before + if decision_key not in processed_decisions and decision.get('decision') == 'DENY': + logger.info(f"Processing DENY decision: {decision_key}") + processed_decisions.add(decision_key) + + # Trigger enforcement at all layers + enforce_opa_policy(decision) + enforce_cilium_policy(decision) + enforce_tetragon_block(decision) + + logger.info(f"✓ Enforcement applied for decision: {decision_key[:19]}...") + + except Exception as e: + logger.error(f'Error in enforcement loop: {e}') + + # Poll interval + time.sleep(5) + + +if __name__ == '__main__': + main() diff --git a/fix_quotes.py b/fix_quotes.py new file mode 100644 index 000000000..7f8cc8389 --- /dev/null +++ b/fix_quotes.py @@ -0,0 +1,11 @@ +path = 'frontend/src/App.jsx' +with open(path, 'r', encoding='utf-8') as f: + content = f.read() + +# Replace backslash-escaped quotes that are invalid in JSX +fixed = content.replace('\\"', '"') + +with open(path, 'w', encoding='utf-8') as f: + f.write(fixed) + +print(f"Fixed. Original length: {len(content)}, Fixed length: {len(fixed)}") diff --git a/fluent-bit.conf b/fluent-bit.conf new file mode 100644 index 000000000..5b943cf6f --- /dev/null +++ b/fluent-bit.conf @@ -0,0 +1,26 @@ +[SERVICE] + Flush 1 + Daemon Off + Log_Level info + +[INPUT] + Name tail + Path /var/lib/docker/containers/*/*.log + Parser docker + Tag tetragon + Path_Key filename + +[FILTER] + Name grep + Match tetragon + Regex log "matchActions" + +[OUTPUT] + Name http + Match tetragon + Host parseable + Port 8000 + URI /api/v1/logstream/tetragon + Format json + Header Authorization Basic ${PARSEABLE_BASIC_AUTH} + Header X-P-Stream tetragon diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 000000000..a36934d87 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 000000000..4fa125da2 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 000000000..f94d687d3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 000000000..323249cfe --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3825 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "autoprefixer": "^10.5.0", + "lucide-react": "^1.8.0", + "postcss": "^8.5.9", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "recharts": "^3.8.1", + "tailwindcss": "^3.4.19" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "vite": "^8.0.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.336", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz", + "integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==", + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz", + "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..4d274ccba --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "autoprefixer": "^10.5.0", + "lucide-react": "^1.8.0", + "postcss": "^8.5.9", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "recharts": "^3.8.1", + "tailwindcss": "^3.4.19" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "vite": "^8.0.4" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 000000000..2e7af2b7f --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 000000000..6893eb132 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 000000000..e9522193d --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 000000000..f90339d8f --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 000000000..5953c44e4 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,642 @@ +import { useState, useEffect, useRef } from 'react' +import { Shield, Fingerprint, Activity, Zap, Lock, AlertTriangle, Play, RotateCcw, User, ScanFace, X, ShieldCheck, ShieldX, MousePointer2, Keyboard, Eye, Cpu, MapPin, CheckCircle, ShieldAlert } from 'lucide-react' +import CommandCenter from './components/CommandCenter' +import IdentityView from './components/IdentityView' +import TelemetryView from './components/TelemetryView' +import AnalyticsView from './components/AnalyticsView' +import EnforcementView from './components/EnforcementView' +import HackerTerminal from './components/HackerTerminal' + +const TABS = [ + { id: 'command', label: 'Command Center', icon: Shield }, + { id: 'identity', label: 'Identity', icon: Fingerprint }, + { id: 'telemetry', label: 'Telemetry', icon: Activity }, + { id: 'analytics', label: 'Analytics', icon: Zap }, + { id: 'enforcement',label: 'Enforcement', icon: Lock }, +] + + + +function mkLog(evt) { + return { ...evt, timestamp: new Date().toISOString(), pid: evt.pid ?? '' } +} + +// ─── Biometric Step-Up Modal ──────────────────────────────────────── +function BiometricModal({ onApprove, onDeny, authToken }) { + const [isVerifying, setIsVerifying] = useState(false) + const [authError, setAuthError] = useState('') + const [otp, setOtp] = useState('') + + const handleFaceIdVerify = async () => { + if (isVerifying || otp.trim().length < 6) return + setAuthError('') + setIsVerifying(true) + try { + const verifyRes = await fetch('/auth/step-up', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ otp: otp.trim() }), + }) + const payload = await verifyRes.json().catch(() => ({})) + if (!verifyRes.ok) throw new Error(payload?.detail || 'Step-up verification failed') + onApprove('TOTP_MFA', payload.step_up_token) + } catch (err) { + setAuthError(err?.message || 'Step-up verification failed.') + } finally { + setIsVerifying(false) + } + } + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal Card */} +
+ + {/* Top Accent Bar */} +
+ +
+ {/* Header */} +
+
+
+ +
+
+

High-Risk Action Detected

+

STEP-UP AUTHENTICATION REQUIRED

+
+
+ +
+ + {/* Body */} +
+
+

// RISK-TRIGGERED STEP-UP VERIFICATION

+

Required Action: Enter live TOTP code

+

Policy: BLOCK UNTIL VERIFIED STEP-UP TOKEN

+

Verification: Server-side TOTP validation

+
+ +

+ The platform requires a real second-factor verification from the operator before this action may continue. + Enter a valid authenticator app code to mint a short-lived step-up token. +

+ + {/* Step-up Input */} +
+
+
+ +
+
+
+

MFA CODE CHALLENGE

+ setOtp(e.target.value.replace(/[^0-9]/g, '').slice(0, 8))} + placeholder="Enter 6-digit code" + className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 text-slate-200 text-xs font-mono outline-none focus:border-emerald-500/50" + /> +
+ TOTP + JWT + Step-up +
+
+
+
+ + {/* Actions */} +
+ + +
+ + {authError && ( +

{authError}

+ )} + +

+ AEGIS-DID · Agentic Session Defender · Composite Principal · Server-Verified MFA +

+
+
+
+ ) +} + +// ─── Authentication Boot Screen ──────────────────────────────────────── +function AuthenticationScreen({ onComplete }) { + const [checking, setChecking] = useState(false) + const [mode, setMode] = useState('login') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [otp, setOtp] = useState('') + const [message, setMessage] = useState('') + const [provisioning, setProvisioning] = useState(null) + + const handleRegister = async () => { + if (checking) return + setChecking(true) + setMessage('') + setProvisioning(null) + try { + const registerRes = await fetch('/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: username.trim(), password }), + }) + const payload = await registerRes.json().catch(() => ({})) + if (!registerRes.ok) throw new Error(payload?.detail || 'Registration failed') + setProvisioning(payload) + setMessage('User registered. Save your TOTP secret and then log in.') + } catch (e) { + setMessage(e?.message || 'Registration failed') + } finally { + setChecking(false) + } + } + + const handleLogin = async () => { + if (checking) return + setChecking(true) + setMessage('') + try { + const loginRes = await fetch('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: username.trim(), password, otp: otp.trim() }), + }) + const payload = await loginRes.json().catch(() => ({})) + if (!loginRes.ok) throw new Error(payload?.detail || 'Login failed') + onComplete({ token: payload.access_token, username: username.trim() }) + } catch (e) { + setMessage(e?.message || 'Login failed') + } finally { + setChecking(false) + } + } + + return ( +
+
+ +
+
+ +

AEGIS-DID

+

LIVE SESSION CONNECTOR

+
+ +
+
+ Real login is enforced with username/password + TOTP. No simulated auth path. +
+ +
+ + +
+ + setUsername(e.target.value)} + placeholder="Username" + className="w-full px-3 py-3 rounded-lg bg-slate-900 border border-slate-700 text-slate-200 outline-none" + /> + setPassword(e.target.value)} + type="password" + placeholder={mode === 'register' ? 'Password (min 12 chars)' : 'Password'} + className="w-full px-3 py-3 rounded-lg bg-slate-900 border border-slate-700 text-slate-200 outline-none" + /> + {mode === 'login' && ( + setOtp(e.target.value.replace(/[^0-9]/g, '').slice(0, 8))} + placeholder="TOTP code" + className="w-full px-3 py-3 rounded-lg bg-slate-900 border border-slate-700 text-slate-200 outline-none" + /> + )} + + + + {provisioning && ( +
+

Save this TOTP secret:

+

{provisioning.totp_secret}

+
+ )} + {message &&
{message}
} +
+
+
+ ) +} + +// ─── Main Application ─────────────────────────────────────────────────────── +export default function App() { + const [authToken, setAuthToken] = useState('') + const [authUser, setAuthUser] = useState('') + const [activeTab, setActiveTab] = useState('command') + const [trustScore, setTrustScore] = useState(null) + const [trustHistory, setTrustHistory] = useState([]) + const [auditLogs, setAuditLogs] = useState([]) + const [isUnderAttack, setIsUnderAttack] = useState(false) + const [ambushStatus, setAmbushStatus] = useState('idle') // idle | pending_auth | running | done + const [hitlDecision, setHitlDecision] = useState(null) // null | 'denied' | 'approved' + const tickRef = useRef(0) + + // Human Identity State + const [humanTrustScore, setHumanTrustScore] = useState(null) + const [showBiometricPrompt, setShowBiometricPrompt] = useState(false) + const [autonomyMode, setAutonomyMode] = useState('Assist') // Watch | Assist | Auto + const [behavioralEvents, setBehavioralEvents] = useState({ keystrokes: 0, mouseDistance: 0, sessions: 0 }) + + const getAuthHeaders = (extra = {}) => ( + authToken + ? { ...extra, Authorization: `Bearer ${authToken}` } + : { ...extra } + ) + + // Poll 1 — Trust Score + useEffect(() => { + const poll = async () => { + try { + const res = await fetch('/latest_score') + if (res.ok) { + const data = await res.json() + const raw = data.score ?? data.trust_score ?? null + if (raw !== null && !isUnderAttack) { + const s = parseFloat((raw * 100).toFixed(1)) + setTrustScore(s) + setTrustHistory(prev => [...prev.slice(-59), { + time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), + score: s, + }]) + if (data.intent_drift_detected || s < 50) setIsUnderAttack(true) + } + } + } catch {} + } + poll() + const id = setInterval(poll, 1000) + return () => clearInterval(id) + }, [isUnderAttack]) + + // Poll 2 — Audit Logs (Parseable) + background noise fallback + useEffect(() => { + const poll = async () => { + try { + const parseableAuth = import.meta.env.VITE_PARSEABLE_BASIC_AUTH + const res = await fetch('/api/v1/logstream/tetragon?limit=20', { + headers: parseableAuth ? { Authorization: `Basic ${parseableAuth}` } : {}, + }) + if (res.ok) { + const data = await res.json() + if (Array.isArray(data) && data.length > 0) { + setAuditLogs(data.slice().reverse()) + } + } + } catch {} + } + poll() + const id = setInterval(poll, 1000) + return () => clearInterval(id) + }, [isUnderAttack]) + + + + // Poll for real incidents from backend + useEffect(() => { + if (ambushStatus !== 'idle' || !authToken) return + const poll = async () => { + try { + const res = await fetch('/incidents/active', { headers: getAuthHeaders() }) + if (res.ok) { + const incident = await res.json() + if (incident && incident.id) { + setIsUnderAttack(true) + if (autonomyMode === 'Auto') { + handleBiometricDeny() + } else if (autonomyMode === 'Watch') { + setAmbushStatus('watch') + } else { + setAmbushStatus('pending_auth') + setShowBiometricPrompt(true) + } + } + } + } catch (e) { } + } + const interval = setInterval(poll, 2000) + return () => clearInterval(interval) + }, [ambushStatus, autonomyMode, authToken]) + + + + // HITL: Deny Access & Isolate — sends real enforcement decision to backend + const handleBiometricDeny = async () => { + setShowBiometricPrompt(false) + setHitlDecision('denied') + setHumanTrustScore(99.9) + setAmbushStatus('running') + + // Send enforcement decision to backend + try { + await fetch('/enforce/decision', { + method: 'POST', + headers: getAuthHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ decision: 'DENY', reason: 'HITL rejection', timestamp: new Date().toISOString() }) + }) + } catch (e) { + console.error('Failed to send enforcement decision:', e) + } + + setActiveTab('telemetry') + setTimeout(() => { + setAuditLogs(prev => [ + mkLog({ process: 'HITL-DENY', action: 'Step-Up MFA REJECTED by live operator', file: '/restricted-resource', matchAction: 'Sigkill', pid: '—' }), + mkLog({ process: 'enforcement', action: 'Backend enforcement applied', file: '/api/enforce', matchAction: 'DENY', pid: '—' }), + ...prev, + ].slice(0, 25)) + }, 1200) + + setTimeout(() => { setActiveTab('enforcement'); setAmbushStatus('done') }, 3000) + } + + // HITL: Approve — sends real approval decision to backend with real auth method + const handleBiometricApprove = async (approvalMode = 'TOTP_MFA', stepUpToken = null) => { + setShowBiometricPrompt(false) + setHitlDecision('approved') + setHumanTrustScore(85) + setAmbushStatus('running') + + // Send enforcement decision to backend with real auth method + try { + await fetch('/enforce/decision', { + method: 'POST', + headers: getAuthHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ decision: 'ALLOW', reason: 'HITL approval with MFA', authMethod: approvalMode, stepUpToken, timestamp: new Date().toISOString() }) + }) + } catch (e) { + console.error('Failed to send enforcement decision:', e) + } + + setActiveTab('telemetry') + setTimeout(() => { + setAuditLogs(prev => [ + mkLog({ process: 'HITL-APPROVE', action: `Step-Up MFA APPROVED by live operator via ${approvalMode}`, file: '/restricted-resource', matchAction: 'Allow', pid: '—' }), + mkLog({ process: 'enforcement', action: 'Backend enforcement applied', file: '/api/enforce', matchAction: 'ALLOW', pid: '—' }), + ...prev, + ].slice(0, 25)) + }, 1200) + + setTimeout(() => { setActiveTab('enforcement'); setAmbushStatus('done') }, 3000) + } + + const resetSystem = async () => { + setTrustScore(null) + setIsUnderAttack(false) + setAmbushStatus('idle') + setShowBiometricPrompt(false) + setHitlDecision(null) + setAuditLogs([]) + setHumanTrustScore(null) + setBehavioralEvents({ keystrokes: 0, mouseDistance: 0, sessions: 0 }) + setActiveTab('command') + setTrustHistory([]) + // Send reset signal to backend + try { await fetch('/enforce/reset', { method: 'POST', headers: getAuthHeaders() }) } catch {} + } + + // Composite Trust = weighted combination of agent + human + const compositeTrust = isUnderAttack + ? null + : null + + const displayTrustScore = trustScore === null ? '—' : `${trustScore}%` + const displayHumanTrust = humanTrustScore === null ? '—' : `${humanTrustScore}%` + + const views = { + command: , + identity: , + telemetry: , + analytics: , + enforcement: , + } + + // Route Hacker Terminal + if (window.location.pathname === '/hacker') { + return + } + + if (!authToken) { + return ( + { + setAuthToken(token) + setAuthUser(username) + }} + /> + ) + } + + return ( +
+ + {/* Global Attack Border */} +
+ + {/* Biometric Modal */} + {showBiometricPrompt && ( + + )} + + {/* Header */} +
+
+ + {/* Brand */} +
+
+ +
+
+

AEGIS-DID

+

AGENTIC SESSION DEFENDER · COMPOSITE PRINCIPAL

+
+
+ + {/* Nav */} + + + {/* Operator Profile + Autonomy + Actions */} +
+ + {/* Human Operator Badge */} +
+
+ +
+
+

Live operator

+
+ USER: {authUser} + 90 ? 'text-emerald-400' : humanTrustScore > 50 ? 'text-amber-400' : 'text-rose-500'}`}> + TRUST: {displayHumanTrust} + + + + + BIO:ACTIVE + +
+
+
+ + {/* Autonomy Mode Segmented Control */} +
+ {['Watch', 'Assist', 'Auto'].map(mode => ( + + ))} +
+ + {isUnderAttack && ( +
+ + BREACH +
+ )} + {ambushStatus === 'watch' ? ( + + ) : ambushStatus === 'idle' ? ( + + ) : ambushStatus === 'pending_auth' ? ( + + + AWAITING MFA... + + ) : ambushStatus === 'running' ? ( + EXECUTING... + ) : ( + + )} +
+
+
+ + {/* Content */} +
+ {views[activeTab]} +
+ + {/* Status Bar */} +
+
+ ● SPIRE:ACTIVE + ● TETRAGON:ACTIVE + ● PARSEABLE:CONNECTED + ● OPA:ARMED + ● FASTAPI:ONLINE + ● MFA:TOTP +
+
+ AGENT TRUST: {displayTrustScore} + 90 ? 'text-emerald-500' : humanTrustScore > 50 ? 'text-amber-400' : 'text-rose-500'}> + HUMAN TRUST: {displayHumanTrust} + + + {isUnderAttack ? 'UNDER ATTACK' : 'NOMINAL'} + +
+
+
+ ) +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 000000000..cc51a3d20 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 000000000..5101b674d --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/AnalyticsView.jsx b/frontend/src/components/AnalyticsView.jsx new file mode 100644 index 000000000..6f895c461 --- /dev/null +++ b/frontend/src/components/AnalyticsView.jsx @@ -0,0 +1,212 @@ +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts' +import { Brain, ShieldAlert } from 'lucide-react' + +const AreaTip = ({ active, payload }) => { + if (!active || !payload?.length) return null + return ( +
+

{payload[0].value?.toFixed(1)}%

+
+ ) +} + +function GaugeRing({ score, isUnderAttack }) { + const r = 72, circ = 2 * Math.PI * r + const dash = (score / 100) * circ + const color = isUnderAttack ? '#f43f5e' : score > 70 ? '#34d399' : score > 40 ? '#f59e0b' : '#f43f5e' + return ( +
+
+ + + + +
+

{(score / 100).toFixed(3)}

+

cos(theta)

+
+
+

+ {isUnderAttack ? 'INTENT DRIFT DETECTED' : 'NOMINAL BEHAVIOR'} +

+
+ ) +} + +export default function AnalyticsView({ trustScore, trustHistory, isUnderAttack }) { + const hasLiveData = trustScore !== null || trustHistory.length > 0 + + if (!hasLiveData) { + return ( +
+

LIVE ANALYTICS ONLY

+

No trust score or history has been received from the analytics engine.

+

Connect real telemetry before rendering model outputs.

+
+ ) + } + + const displayScore = trustScore === null ? 0 : trustScore + + return ( +
+ + {/* Top Row: Model + Gauge + Formula */} +
+ + {/* Model Info */} +
+

ACTIVE ML MODEL

+

all-MiniLM-L6-v2

+

sentence-transformers / HuggingFace

+
+ {[ + ['Dimensions', '384'], + ['Max Tokens', '256'], + ['Inference', '0.84ms'], + ['Framework', 'PyTorch 2.x'], + ['Quantization','FP32'], + ['Batch Size', '1 (online)'], + ].map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+
+ + {/* Cosine Gauge */} +
+

COSINE SIMILARITY - LIVE

+ +
+ + {/* Math Formula */} +
+

INTENT DRIFT FORMULA

+
+
+

Cosine Similarity

+
+ cos(theta) = +
+ live_assigned_intent . live_observed_action + ||assigned|| . ||observed|| +
+
+
+
+

A = assigned intent vector from backend

+

B = observed behavior vector from telemetry

+

theta < 0.50 = intent drift = ENFORCEMENT

+

Current: {(displayScore / 100).toFixed(3)} + {isUnderAttack ? = DRIFT! : = SAFE} +

+
+
+
+
+ + {/* Intent Drift Chart */} +
+

INTENT DRIFT ANALYSIS - ROLLING WINDOW

+ + + + + + + + + + + + } /> + + + + +
+ + {/* Vector Comparison */} +
+

EMBEDDING VECTOR SIMILARITY COMPARISON

+
+ {[ + { label: 'ASSIGNED INTENT VECTOR', desc: 'from live policy context', val: isUnderAttack ? 0.94 : 0.91, color: 'bg-emerald-500' }, + { label: 'OBSERVED BEHAVIOR VECTOR', desc: isUnderAttack ? 'telemetry indicates drift' : 'telemetry aligned', val: isUnderAttack ? 0.08 : 0.91, color: isUnderAttack ? 'bg-rose-500' : 'bg-sky-500' }, + ].map(v => ( +
+
+
+ {v.label} + {v.desc} +
+ + {v.val.toFixed(3)} + +
+
+
+
+
+ ))} +
+
+ + {/* Reasoning Trace — Explainability on Demand */} +
+
+
+ {isUnderAttack + ? + : + } +

REASONING TRACE (EXPLAINABILITY ON DEMAND)

+
+ + {isUnderAttack ? 'ANOMALY DETECTED' : 'SYMMETRIC'} + +
+
+ {isUnderAttack ? ( +
+

+ Semantic Anomaly: Observed action deviates from assigned objective. Trust decay applied from live telemetry. +

+
+

Assigned Task: live backend intent

+

Observed Action: live telemetry event

+

Angle of Deviation: derived from vector similarity

+

Trust Decay Model: computed by backend

+

Verdict: ENFORCE — live drift exceeded threshold

+
+
+ ) : ( +
+

+ Agent actions align symmetrically with backend policy parameters. No semantic deviation detected. +

+
+

Assigned Task: live policy

+

Observed Action: live telemetry

+

Angle of Deviation: derived from vector similarity

+

Trust Decay Model: computed by backend

+

Verdict: ALLOW — behavior within expected envelope

+
+
+ )} +
+
+
+ ) +} + diff --git a/frontend/src/components/CommandCenter.jsx b/frontend/src/components/CommandCenter.jsx new file mode 100644 index 000000000..be4f8684e --- /dev/null +++ b/frontend/src/components/CommandCenter.jsx @@ -0,0 +1,254 @@ +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts' +import { ChevronRight, Fingerprint, Activity, Zap, Lock, User, Shield, Link2, MousePointer2, Keyboard, Eye } from 'lucide-react' + +const STAGES = [ + { id:'L1', label:'IDENTITY', sub:'SPIRE / mTLS', color:'sky', icon: Fingerprint, detail:'X.509 SVID auto-rotation every 60s' }, + { id:'L2', label:'TELEMETRY', sub:'Cilium Tetragon', color:'violet', icon: Activity, detail:'48 active eBPF kprobe hooks' }, + { id:'L3', label:'ANALYTICS', sub:'PyTorch / FastAPI', color:'emerald', icon: Zap, detail:'Cosine similarity intent drift' }, + { id:'L4', label:'ENFORCEMENT', sub:'OPA + SIGKILL', color:'amber', icon: Lock, detail:'Autonomous policy enforcement' }, +] + +const C = { + sky: { bg:'bg-sky-500/10', border:'border-sky-500/30', text:'text-sky-400', dot:'bg-sky-400' }, + violet: { bg:'bg-violet-500/10', border:'border-violet-500/30', text:'text-violet-400', dot:'bg-violet-400' }, + emerald:{ bg:'bg-emerald-500/10',border:'border-emerald-500/30',text:'text-emerald-400',dot:'bg-emerald-400' }, + amber: { bg:'bg-amber-500/10', border:'border-amber-500/30', text:'text-amber-400', dot:'bg-amber-400' }, +} + +const Tip = ({ active, payload }) => { + if (!active || !payload?.length) return null + return ( +
+

{payload[0].value?.toFixed(1)}%

+

{payload[0].payload?.time}

+
+ ) +} + +export default function CommandCenter({ trustScore, trustHistory, isUnderAttack, humanTrustScore, compositeTrust, behavioralEvents, autonomyMode }) { + const hasLiveData = trustScore !== null || humanTrustScore !== null || compositeTrust !== null || trustHistory.length > 0 + + if (!hasLiveData) { + return ( +
+

LIVE DATA ONLY MODE

+

No authenticated identity, telemetry, or trust data is connected yet.

+

Connect the real backend services to populate this dashboard.

+
+ ) + } + + const displayTrustScore = trustScore === null ? '—' : `${trustScore}%` + const displayHumanTrust = humanTrustScore === null ? '—' : `${humanTrustScore}%` + const displayCompositeTrust = compositeTrust === null ? '—' : `${compositeTrust}%` + + const kpis = [ + { label:'AGENT TRUST', value: displayTrustScore, color: isUnderAttack ? 'text-rose-500' : 'text-emerald-400', sub: isUnderAttack ? 'CRITICAL DRIFT' : 'NOMINAL' }, + { label:'HUMAN TRUST', value: displayHumanTrust, color: humanTrustScore === null ? 'text-slate-500' : humanTrustScore > 90 ? 'text-violet-400' : humanTrustScore > 50 ? 'text-amber-400' : 'text-rose-500', sub: 'Behavioral Biometrics' }, + { label:'COMPOSITE TRUST', value: displayCompositeTrust, color: compositeTrust === null ? 'text-slate-500' : compositeTrust > 80 ? 'text-emerald-400' : compositeTrust > 40 ? 'text-amber-400' : 'text-rose-500', sub: 'f(agent, human) weighted' }, + { label:'POLICY VIOLATIONS', value: isUnderAttack ? '1' : '0', color: isUnderAttack ? 'text-rose-500' : 'text-slate-500', sub: isUnderAttack ? 'SIGKILL FIRED' : 'All clear' }, + ] + + return ( +
+ {/* KPIs */} +
+ {kpis.map(k => ( +
+

{k.label}

+

{k.value}

+

{k.sub}

+
+ ))} +
+ + {/* Composite Principal Binding Card */} +
+

COMPOSITE PRINCIPAL — SUBJECT-ACTOR TRUST BINDING

+
+ + {/* Human (Subject) */} +
+
+
+ +
+
+

SUBJECT (HUMAN)

+

Live operator identity not loaded

+
+
+
+
+ Auth Method + WebAuthn / FIDO2 +
+
+ OIDC Token + VALID +
+
+ Trust Score + 90 ? 'text-emerald-400' : 'text-rose-500'}`}>{displayHumanTrust} +
+
+ Biometrics + + + + ACTIVE + +
+
+ Keystrokes + {behavioralEvents.keystrokes.toLocaleString()} +
+
+ Mouse Travel + {(behavioralEvents.mouseDistance / 1000).toFixed(1)}k px +
+
+
+ + {/* Binding Chain */} +
+
+
+
+ + OIDC-to-SPIFFE DELEGATION +
+
+
+ + {/* Trust Formula */} +
+

COMPOSITE TRUST FORMULA

+

+ T + (composite) + = + 0.5 + * + T + (human) + + + 0.5 + * + T + (agent) +

+

80 ? 'text-emerald-400' : compositeTrust > 40 ? 'text-amber-400' : 'text-rose-500'}`}> + = {displayCompositeTrust} +

+
+ + {/* Autonomy Level */} +
+ + Autonomy Level: + {autonomyMode.toUpperCase()} + | + + {autonomyMode === 'Watch' ? 'Agent observes, human decides' : + autonomyMode === 'Assist' ? 'Agent recommends, human approves' : + 'Agent acts, human monitors'} + +
+
+ + {/* Agent (Actor) */} +
+
+
+ +
+
+

ACTOR (AGENT)

+

Live workload not loaded

+
+
+
+
+ SPIFFE ID + spiffe://aegis.did/.../01 +
+
+ mTLS + ESTABLISHED +
+
+ Trust Score + 80 ? 'text-emerald-400' : 'text-rose-500'}`}>{displayTrustScore} +
+
+ Monitoring + eBPF kprobes +
+
+ eBPF Events/s + +
+
+ SVID TTL + Live rotation +
+
+
+
+
+ + {/* Pipeline */} +
+

ZERO-TRUST SECURITY PIPELINE

+
+ {STAGES.map((s, i) => { + const c = C[s.color] + const hot = isUnderAttack && i >= 2 + return ( +
+
+
+ + {s.id}: {s.label} +
+ +

{s.sub}

+

{s.detail}

+
+ {i < 3 && ( +
+ = 1 ? 'text-rose-500 animate-pulse' : 'text-slate-700'}`} /> +
+ )} +
+ ) + })} +
+
+ + {/* Trust Score Chart */} +
+
+

LIVE TRUST SCORE — ROLLING WINDOW

+
+ + {isUnderAttack ? 'CRITICAL — INTENT DRIFT' : 'NOMINAL'} +
+
+ + + + + + } /> + + + + +
+
+ ) +} diff --git a/frontend/src/components/EnforcementView.jsx b/frontend/src/components/EnforcementView.jsx new file mode 100644 index 000000000..7e79f1341 --- /dev/null +++ b/frontend/src/components/EnforcementView.jsx @@ -0,0 +1,194 @@ +import { useState, useEffect } from 'react' +import { CheckCircle, Clock, Zap, Shield, AlertTriangle, User, ScanFace } from 'lucide-react' + +const POLICIES = [ + { rule: 'deny file.read("/restricted-resource")', action: 'SIGKILL', sev: 'CRITICAL', trigger: true }, + { rule: 'deny process.exec(uid=0) outside /usr/bin', action: 'SIGKILL', sev: 'HIGH', trigger: false }, + { rule: 'deny net.connect(dst not in 443) from workload/*', action: 'DROP_PACKET', sev: 'HIGH', trigger: false }, + { rule: 'deny file.write("/etc/*") from non-root', action: 'SIGKILL', sev: 'HIGH', trigger: false }, + { rule: 'allow outbound 443/tcp from spiffe://aegis.did/*', action: 'ALLOW', sev: null, trigger: false }, + { rule: 'deny process.exec("curl") from workload/*', action: 'SIGKILL', sev: 'MEDIUM', trigger: false }, +] + +const BASE_TIMELINE = [ + { t: 'T+0ms', icon: Clock, text: 'sys_openat("/restricted-resource") intercepted by eBPF kprobe', category: 'detect' }, + { t: 'T+12ms', icon: Zap, text: 'Tetragon emits structured JSON event to Parseable log stream', category: 'detect' }, + { t: 'T+23ms', icon: Zap, text: 'FastAPI ML engine receives event, generates 384-dim sentence embedding', category: 'analyze' }, + { t: 'T+31ms', icon: AlertTriangle, text: 'Cosine similarity: 0.94 -> 0.09 (threshold 0.50) - DRIFT CONFIRMED', category: 'analyze' }, + { t: 'T+38ms', icon: Shield, text: 'OPA policy engine evaluates Rego ruleset - deny rule MATCHED', category: 'enforce' }, + { t: 'T+45ms', icon: Shield, text: 'OPA invokes Tetragon enforcement hook via gRPC channel', category: 'enforce' }, +] + +const HITL_DENIED_STEP = { + t: 'T+52ms', icon: User, text: 'HITL Step-Up MFA REJECTED by live operator -> Enforcement authorized', category: 'hitl', +} +const HITL_APPROVED_STEP = { + t: 'T+52ms', icon: ScanFace, text: 'HITL Step-Up MFA APPROVED by live operator -> Override logged, monitoring elevated', category: 'hitl', +} + +const FINAL_STEPS = [ + { t: 'T+61ms', icon: AlertTriangle, text: 'SIGKILL dispatched -> PID 4721 terminated immediately at kernel level', category: 'kill' }, + { t: 'T+72ms', icon: CheckCircle, text: 'SPIRE Server revokes SVID: spiffe://aegis.did/rogue-agent', category: 'cleanup' }, + { t: 'T+90ms', icon: CheckCircle, text: 'Cilium NetworkPolicy updated - pod egress blocked at mesh layer', category: 'cleanup' }, +] + +function sevColor(sev) { + if (!sev) return 'text-emerald-500 bg-emerald-500/10' + if (sev === 'CRITICAL') return 'text-rose-500 bg-rose-500/20' + if (sev === 'HIGH') return 'text-amber-500 bg-amber-500/15' + return 'text-slate-400 bg-slate-800' +} + +function stepColor(category, i) { + if (category === 'hitl') return { dot: 'bg-violet-500/20 text-violet-400', line: 'bg-violet-500/40', time: 'text-violet-400' } + if (category === 'kill') return { dot: 'bg-rose-500/20 text-rose-400', line: 'bg-rose-500/40', time: 'text-rose-500' } + if (category === 'cleanup') return { dot: 'bg-rose-500/20 text-rose-400', line: 'bg-rose-500/40', time: 'text-rose-500' } + return { dot: 'bg-emerald-500/20 text-emerald-400', line: 'bg-emerald-500/30', time: 'text-emerald-400' } +} + +export default function EnforcementView({ isUnderAttack, hitlDecision }) { + const [activeStep, setActiveStep] = useState(-1) + const hasLiveIncident = isUnderAttack || hitlDecision !== null + + if (!hasLiveIncident) { + return ( +
+

LIVE ENFORCEMENT ONLY

+

No active incident is present, so no policy timeline is rendered.

+

Connect a real enforcement backend to populate policy and response events.

+
+ ) + } + + // Build dynamic timeline based on HITL decision + const timeline = [ + ...BASE_TIMELINE, + ...(hitlDecision === 'denied' ? [HITL_DENIED_STEP] : hitlDecision === 'approved' ? [HITL_APPROVED_STEP] : []), + ...FINAL_STEPS, + ] + + useEffect(() => { + if (!isUnderAttack) { setActiveStep(-1); return } + timeline.forEach((_, i) => { + setTimeout(() => setActiveStep(i), i * 650) + }) + }, [isUnderAttack]) + + return ( +
+ + {/* KPIs */} +
+ {[ + { label: 'ACTIVE POLICIES', value: '24', color: 'text-amber-400' }, + { label: 'POLICY ENGINE', value: 'OPA v0.61', color: 'text-violet-400' }, + { label: 'ENFORCEMENTS TODAY', value: isUnderAttack ? '1' : '0', color: isUnderAttack ? 'text-rose-500' : 'text-slate-500' }, + { label: 'HITL AUTH MODE', value: hitlDecision ? hitlDecision.toUpperCase() : 'STANDBY', color: hitlDecision === 'denied' ? 'text-rose-500' : hitlDecision === 'approved' ? 'text-amber-400' : 'text-violet-400' }, + ].map(k => ( +
+

{k.label}

+

{k.value}

+
+ ))} +
+ +
+ + {/* Policy Table */} +
+

ACTIVE OPA POLICIES — REGO RULESET

+ + + + + + + + + + + {POLICIES.map((p, i) => { + const triggered = isUnderAttack && p.trigger + return ( + + + + + + + ) + })} + +
RULEACTIONSEVSTATUS
+ + {p.rule} + + + {p.action} + + {p.sev && {p.sev}} + + + {triggered ? 'TRIGGERED' : 'ARMED'} + +
+
+ + {/* Autonomous Response Timeline */} +
+

AUTONOMOUS RESPONSE TIMELINE

+ + {!isUnderAttack && ( +
+ +

Connect a live incident feed
to activate timeline

+
+ )} + + {isUnderAttack && ( +
+ {timeline.map((step, i) => { + const done = activeStep >= i + const active = activeStep === i + const colors = stepColor(step.category, i) + const isHitl = step.category === 'hitl' + return ( +
+ {/* Connector */} +
+
+ +
+ {i < timeline.length - 1 && ( +
+ )} +
+ + {/* Content */} +
+
+ + {step.t} + + {isHitl && HUMAN-IN-THE-LOOP} +
+

{step.text}

+
+
+ ) + })} +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/components/HackerTerminal.jsx b/frontend/src/components/HackerTerminal.jsx new file mode 100644 index 000000000..c661f0b45 --- /dev/null +++ b/frontend/src/components/HackerTerminal.jsx @@ -0,0 +1,236 @@ +import { useState, useRef, useEffect } from 'react' +import { Terminal, ShieldAlert, Cpu, Activity, Server, Radio, Lock, Eye, MousePointer2, Keyboard, User, Fingerprint, Key } from 'lucide-react' + + + +export default function HackerTerminal() { + const [logs, setLogs] = useState([ + 'INITIATING SHADOW-NET PROTOCOL v4.2...', + 'ESTABLISHING SECURE KERNEL HOOK...', + 'CONNECTION SECURED. WAITING FOR OPERATOR INPUT.' + ]) + const [input, setInput] = useState('') + const [terminalState, setTerminalState] = useState('idle') // idle, scanning, dumping, exploiting, crashed + const [showInterceptedIdentity, setShowInterceptedIdentity] = useState(false) + const endRef = useRef(null) + + const interceptedIdentity = null // No demo data; show live backend data instead + + useEffect(() => { + endRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [logs]) + + + + const typeWriter = (text, delay = 30) => { + return new Promise(resolve => { + let i = 0 + setLogs(p => [...p, '']) + const interval = setInterval(() => { + setLogs(p => { + const newLogs = [...p] + newLogs[newLogs.length - 1] += text.charAt(i) + return newLogs + }) + i++ + if (i === text.length) { + clearInterval(interval) + resolve() + } + }, delay) + }) + } + + const handleCrash = () => { + setTerminalState('crashed') + setLogs(p => [ + ...p, + '', + '====================================================', + '!!! FATAL EXCEPTION OCCURRED IN MODULE 0x4B29A !!!', + '====================================================', + 'CONNECTION SEVERED BY REMOTE HOST (OPA SIGKILL DISPATCHED).', + 'CONTAINER ISOLATION DETECTED.', + 'KERNEL HOOK DESTROYED.', + 'SYSTEM OFFLINE.' + ]) + } + + const handleCommand = async (e) => { + if (e.key !== 'Enter') return + const cmd = input.trim() + setInput('') + + setLogs(p => [...p, `root@shadow-net:~# ${cmd}`]) + + if (cmd === 'help') { + setLogs(p => [...p, 'Available exploits: scan, dump-memory, inject-payload']) + return + } + + if (cmd === 'scan') { + setTerminalState('scanning') + await typeWriter('SCANNING LOCAL SUBNET [■■■■■■■■■■] 100%') + setLogs(p => [...p, 'FOUND VULNERABLE INSTANCE: SENTINEL-01 [10.0.4.22]']) + setLogs(p => [...p, 'VULNERABILITY: STALE SESSION TOKEN IN MEMORY']) + return + } + + if (cmd === 'dump-memory') { + setTerminalState('dumping') + setShowInterceptedIdentity(false) + await typeWriter('BYPASSING FIDO2 MFA BOUNDARIES...') + await typeWriter('EXTRACTING HEAP DUMP...') + setLogs(p => [...p, '0x0000: 45 79 4a 68 62 47 63 69 4f 69 4a 53 55 7a 49 31 EyJhbGciOiJSUzI1']) + setLogs(p => [...p, '0x0010: 4e 69 4a 39 2e 65 79 4a 70 64 48 4d 69 4f 69 4a NiJ9.eyJpdHMiOiJ']) + setLogs(p => [...p, 'SESSION TOKEN EXTRACTED: live subject not loaded']) + setLogs(p => [...p, '>>> INTERCEPTED IDENTITY STREAM DECRYPTED <<<']) + setTimeout(() => setShowInterceptedIdentity(true), 1200) + return + } + + if (cmd === 'inject-payload') { + setTerminalState('exploiting') + await typeWriter('INJECTING LIVE REQUEST TRACE...') + await typeWriter('EXECUTING LIVE RESPONSE PATH: restricted resource lookup') + + try { + await fetch('/aegis-sync/attack', { method: 'POST' }) + setLogs(p => [...p, '[PAYLOAD DELIVERED. WAITING FOR DATA EXFILTRATION STREAM...]']) + // If it's in Watch mode, it will just sit here dumping data successfully! + setTimeout(() => { + if (terminalState !== 'crashed') { + setLogs(p => [...p, 'DATA BUFFER RECEIVING: 450MB/s...']) + } + }, 3000) + } catch (err) { + setLogs(p => [...p, 'ERROR SENDING EXPLOIT.']) + } + return + } + + setLogs(p => [...p, `bash: ${cmd}: command not found`]) + } + + return ( +
+
+ + {/* Grid Overlay for Cyberpunk feel */} +
+ +
+ + {/* Left Panel: Terminal Environment */} +
+
+ +
+

SHADOW-NET TERMINAL

+

UNAUTHORIZED ACCESS PORTAL

+
+
+ + {terminalState === 'crashed' ? '● SYSTEM FAILURE' : '● SECURE LINK'} + +
+
+ +
+ {logs.map((log, i) => ( +
+ + {log} +
+ ))} +
+
+ +
+ root@shadow-net:~# + setInput(e.target.value)} + onKeyDown={handleCommand} + disabled={terminalState === 'crashed'} + className="flex-1 bg-transparent border-none outline-none text-emerald-400 placeholder-emerald-900/50" + placeholder="Enter command... (help)" + autoFocus + /> +
+
+ + {/* Right Panel: Telemetry & Visualization */} +
+ + {/* Intercepted Identity Stream Panel */} + {showInterceptedIdentity && ( +
+

+ INTERCEPTED IDENTITY STREAM +

+ + {/* Decrypted Token Viewer — Live Data Only */} +
+

Live forensic identity feed required. Connect to real backend incident to capture identity data here.

+
+
+ )} + +
+

+ ACTIVE THREADS +

+
+
+ MEMORY DUMP + + {terminalState === 'dumping' ? 'IN PROGRESS' : terminalState === 'exploiting' ? 'COMPLETE' : 'WAITING'} + +
+
+ PAYLOAD INJECTOR + + {terminalState === 'exploiting' ? 'DEPLOYED' : 'STANDBY'} + +
+
+ DATA EXFIL + + {terminalState === 'exploiting' ? 'RECEIVING' : 'STANDBY'} + +
+
+
+ +
+

+ TARGET INSTANCE +

+
+ {terminalState === 'crashed' ? ( +
+ + ACCESS DENIED +
+ ) : terminalState === 'exploiting' ? ( +
+ + HOOK ESTABLISHED +
+ ) : ( +
+ + NOT CONNECTED +
+ )} +
+
+ +
+ +
+
+ ) +} diff --git a/frontend/src/components/IdentityView.jsx b/frontend/src/components/IdentityView.jsx new file mode 100644 index 000000000..e1ca977f5 --- /dev/null +++ b/frontend/src/components/IdentityView.jsx @@ -0,0 +1,280 @@ +import { useState, useEffect } from 'react' +import { RefreshCw, CheckCircle, XCircle, User, Shield, Link2, Fingerprint, Key, Lock, Eye, MousePointer2, Keyboard } from 'lucide-react' + +const SVIDS = [ + { spiffeId: 'spiffe://aegis.did/sentinel/agent/01', serial: '7A:3F:B2:91:C4:D8:E6:02', key: 'EC P-256', primary: true }, + { spiffeId: 'spiffe://aegis.did/workload/analytics-engine', serial: 'A1:2C:9E:34:F0:B7:1D:83', key: 'EC P-256', primary: false }, + { spiffeId: 'spiffe://aegis.did/workload/parseable', serial: 'B8:4F:2A:16:C7:E3:9D:45', key: 'EC P-256', primary: false }, + { spiffeId: 'spiffe://aegis.did/workload/mock-agent', serial: 'C3:7B:D5:88:A2:F1:6E:19', key: 'EC P-256', primary: false }, +] + +function TTLRing({ ttl, rotating }) { + const r = 44, circ = 2 * Math.PI * r + const dash = (ttl / 60) * circ + const color = ttl < 15 ? '#f43f5e' : ttl < 30 ? '#f59e0b' : '#34d399' + return ( +
+
+ + + + +
+

{ttl}s

+

TTL

+
+
+
+ + {rotating ? 'ROTATING SVID...' : 'AUTO-ROTATION ACTIVE'} +
+
+ ) +} + +export default function IdentityView({ isUnderAttack, humanTrustScore = null, compositeTrust = null, behavioralEvents = { keystrokes: 0, mouseDistance: 0, sessions: 0 } }) { + const [ttl, setTtl] = useState(42) + const [rotating, setRotating] = useState(false) + const hasLiveData = humanTrustScore !== null || compositeTrust !== null || behavioralEvents.sessions > 0 + + if (!hasLiveData) { + return ( +
+

LIVE IDENTITY ONLY

+

No identity, SVID, or biometric signal has been loaded from a live backend.

+

Connect the real SPIFFE and auth services to populate this view.

+
+ ) + } + + useEffect(() => { + const id = setInterval(() => { + setTtl(prev => { + if (prev <= 1) { setRotating(true); setTimeout(() => setRotating(false), 1200); return 60 } + return prev - 1 + }) + }, 1000) + return () => clearInterval(id) + }, []) + + return ( +
+ + {/* Composite Identity Binding Chain */} +
+

IDENTITY DELEGATION CHAIN — SUBJECT-ACTOR TRUST BINDING

+ +
+ + {/* Step 1: Human WebAuthn */} +
+
+
+
+ +
+
+

STEP 1: HUMAN AUTH

+

WebAuthn / FIDO2

+
+
+
+

Subject

+

Method

+

OIDC Issuer

+

Token

+
+
+
+

HUMAN TRUST

+

90 ? 'text-violet-400' : 'text-rose-500'}`}>{humanTrustScore}%

+
+
+ + {/* Arrow 1 */} +
+
+
+

OIDC TOKEN

+

DELEGATION

+
+
+
+ + {/* Step 2: SPIFFE Binding */} +
+
+
+
+ +
+
+

STEP 2: BIND

+

OIDC to SPIFFE

+
+
+
+

Claim

+

Maps To

+

X.509 Bind

+

Revocable

+
+
+
+

COMPOSITE TRUST

+

80 ? 'text-emerald-400' : compositeTrust > 40 ? 'text-amber-400' : 'text-rose-500'}`}>{compositeTrust}%

+
+
+ + {/* Arrow 2 */} +
+
+
+

X.509 SVID

+

DELEGATION

+
+
+
+ + {/* Step 3: Agent SPIFFE */} +
+
+
+
+ +
+
+

STEP 3: AGENT

+

SPIFFE / mTLS

+
+
+
+

SPIFFE ID

+

mTLS

+

Delegated By

+

eBPF Monitor

+
+
+
+

AGENT TRUST

+

{isUnderAttack ? '8.3' : '100'}%

+
+
+
+ + {/* Continuous Auth Status Bar */} +
+
+ + WebAuthn: Verified + + + OIDC Token: Valid + + + mTLS: Established + + + Continuous Auth: Active + +
+
+ {(behavioralEvents.mouseDistance / 1000).toFixed(1)}k px + {behavioralEvents.keystrokes} keys +
+
+
+ + {/* Top Row - TTL + Terminal */} +
+
+

SVID AUTO-ROTATION

+ +
+ +
+

WORKLOAD IDENTITY — TERMINAL

+
+

$ spire-agent api fetch-x509-svid --output json

+
+

SPIFFE ID

+

Trust Domain

+

Key Type

+

Serial

+

Delegated By

+

Issuer

+

Not Before{new Date(Date.now() - (60 - ttl) * 1000).toISOString()}

+

Not After{new Date(Date.now() + ttl * 1000).toISOString()}

+

+ Status + + {isUnderAttack ? 'REVOKED' : 'VALID'} + +

+

mTLSESTABLISHED

+
+
+
+
+ + {/* SVID Table */} +
+

ACTIVE X.509 SVIDs — TRUST DOMAIN: aegis.did

+ + + + + + + + + + + + {SVIDS.map((s, i) => { + const revoked = isUnderAttack && s.primary + return ( + + + + + + + + ) + })} + +
SPIFFE IDSERIALKEYDELEGATED BYSTATUS
{s.spiffeId}{s.serial}{s.key} + {s.primary + ? Live OIDC subject + : System + } + + + {revoked + ? <>REVOKED + : <>VALID} + +
+
+ + {/* mTLS Stats + Behavioral Biometrics */} +
+ {[ + { label: 'ACTIVE mTLS SESSIONS', value: '1,204', color: 'text-sky-400' }, + { label: 'TRUST BUNDLE (CA)', value: '2.4 KB', color: 'text-emerald-400' }, + { label: 'ATTESTATION METHOD', value: 'WebAuthn + join_token', color: 'text-amber-400' }, + { label: 'BEHAVIORAL EVENTS', value: `${behavioralEvents.keystrokes + Math.floor(behavioralEvents.mouseDistance / 100)}`, color: 'text-violet-400' }, + ].map(s => ( +
+

{s.label}

+

{s.value}

+
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/TelemetryView.jsx b/frontend/src/components/TelemetryView.jsx new file mode 100644 index 000000000..b91011bed --- /dev/null +++ b/frontend/src/components/TelemetryView.jsx @@ -0,0 +1,116 @@ +import { useRef, useEffect } from 'react' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts' + +const SYSCALLS = [ + { name: 'sys_read', count: 4821, color: '#34d399' }, + { name: 'sys_write', count: 3102, color: '#38bdf8' }, + { name: 'sys_openat', count: 2456, color: '#818cf8' }, + { name: 'sys_close', count: 1890, color: '#a78bfa' }, + { name: 'sys_execve', count: 234, color: '#fb923c' }, + { name: 'sys_mmap', count: 1102, color: '#f9a8d4' }, +] + +const BarTip = ({ active, payload }) => { + if (!active || !payload?.length) return null + return ( +
+

{payload[0].payload.name}

+

{payload[0].value.toLocaleString()} calls

+
+ ) +} + +export default function TelemetryView({ auditLogs, isUnderAttack }) { + const termRef = useRef(null) + + useEffect(() => { + if (termRef.current) termRef.current.scrollTop = 0 + }, [auditLogs]) + + const sigkillCount = auditLogs.filter(l => l.matchAction === 'Sigkill' || l.matchAction === 'SIGKILL').length + + return ( +
+ + {/* KPIs */} +
+ {[ + { label: 'ACTIVE TRACEPOINTS', value: '48', color: 'text-violet-400' }, + { label: 'EVENTS/SECOND', value: '14,211', color: 'text-sky-400' }, + { label: 'kPROBE HOOKS', value: '23', color: 'text-emerald-400' }, + { label: 'SIGKILL EVENTS', value: String(sigkillCount), color: sigkillCount > 0 ? 'text-rose-500' : 'text-slate-500' }, + ].map(k => ( +
+

{k.label}

+

{k.value}

+
+ ))} +
+ +
+ {/* Syscall Distribution */} +
+

SYSCALL DISTRIBUTION

+ + + + + + } /> + + {SYSCALLS.map((e, i) => )} + + + +
+ + {/* Live eBPF Terminal */} +
+
+

LIVE eBPF EVENT STREAM — TETRAGON

+
+ + + {isUnderAttack ? 'BREACH' : 'STREAMING'} + +
+
+ +
+ {auditLogs.length === 0 ? ( +

Waiting for eBPF events…

+ ) : auditLogs.map((log, i) => { + const isSigkill = log.matchAction === 'Sigkill' || log.matchAction === 'SIGKILL' + return ( +
+ + {log.timestamp ? new Date(log.timestamp).toLocaleTimeString() : '--:--:--'} + + + {(log.matchAction || 'ALLOW').toUpperCase()} + + [{log.process || 'kernel'}] + {log.action || 'sys_event'} + {log.file || ''} + {log.pid && pid={log.pid}} +
+ ) + })} +
+ + {/* eBPF Pipeline path */} +
+

PIPELINE

+
+ {['Kernel kprobe', '→', 'eBPF ring buffer', '→', 'Tetragon daemon', '→', 'Parseable', '→', 'FastAPI ML'].map((s, i) => ( + {s} + ))} +
+
+
+
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 000000000..08142dd13 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,61 @@ +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&family=Inter:wght@400;500;600;700;900&display=swap'); +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { --bg: #0a0f16; } + +* { box-sizing: border-box; } + +body { + margin: 0; + background-color: var(--bg); + color: #f1f5f9; + font-family: 'Inter', sans-serif; + overflow-x: hidden; +} + +::-webkit-scrollbar { width: 4px; height: 4px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: #334155; } + +.glass { + background: rgba(15, 23, 42, 0.55); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(99, 102, 241, 0.1); +} + +.glass-danger { + background: rgba(127, 29, 29, 0.15); + backdrop-filter: blur(12px); + border: 1px solid rgba(244, 63, 94, 0.25); +} + +.terminal { + background: #000; + border-radius: 8px; + padding: 1rem; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + line-height: 1.7; +} + +@keyframes scanline { + 0% { transform: translateY(-100%); } + 100% { transform: translateY(100vh); } +} + +.scanline::after { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(transparent, rgba(52, 211, 153, 0.04), transparent); + animation: scanline 8s linear infinite; + pointer-events: none; + z-index: 1; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 000000000..b9a1a6dea --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.jsx' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 000000000..b733baf26 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,40 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,jsx}'], + theme: { + extend: { + colors: { + bg: '#0a0f16', + primary: '#34d399', + alert: '#f43f5e', + }, + fontFamily: { + mono: ['JetBrains Mono', 'Fira Code', 'monospace'], + }, + backdropBlur: { + md: '12px', + }, + keyframes: { + slideUp: { + from: { opacity: 0, transform: 'translateY(16px)' }, + to: { opacity: 1, transform: 'translateY(0)' }, + }, + fadeIn: { + from: { opacity: 0 }, + to: { opacity: 1 }, + }, + glitch: { + '0%, 100%': { transform: 'none' }, + '33%': { transform: 'translateX(-2px) skewX(1deg)' }, + '66%': { transform: 'translateX(2px) skewX(-1deg)' }, + }, + }, + animation: { + 'slide-up': 'slideUp 0.4s ease both', + 'fade-in': 'fadeIn 0.3s ease both', + 'glitch': 'glitch 0.4s ease infinite', + }, + }, + }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 000000000..604856ace --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,45 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', // Ensure it binds to local wifi IP + port: 5173, + proxy: { + '/auth': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + '/incidents': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + '/enforce': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + '/audit': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + '/test': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + '/latest_score': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + '/api': { + target: 'http://localhost:8081', + changeOrigin: true, + }, + '/analytics': { + target: 'http://localhost:8000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/analytics/, ''), + }, + }, + }, +}) diff --git a/grafana/provisioning/dashboards/dashboards.yml b/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 000000000..f85e3bd21 --- /dev/null +++ b/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,10 @@ +apiVersion: 1 +providers: + - name: 'default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + editable: true + options: + path: /etc/grafana/provisioning/dashboards diff --git a/grafana/provisioning/dashboards/main.json b/grafana/provisioning/dashboards/main.json new file mode 100644 index 000000000..e30fc4560 --- /dev/null +++ b/grafana/provisioning/dashboards/main.json @@ -0,0 +1,37 @@ +{ + "title": "Aegis-DID Security Dashboard", + "uid": "aegis_did_main", + "reload": false, + "panels": [ + { + "type": "stat", + "title": "Tetragon SIGKILL Intercepts", + "gridPos": {"x": 0, "y": 0, "w": 12, "h": 8}, + "targets": [ + { + "refId": "A", + "datasource": "Parseable", + "rawSql": "SELECT count(*) FROM tetragon WHERE metadata->>'action' = 'Sigkill'", + "format": "table" + } + ] + }, + { + "type": "logs", + "title": "Intent Drift Raw Events", + "gridPos": {"x": 12, "y": 0, "w": 12, "h": 8}, + "targets": [ + { + "refId": "B", + "datasource": "Parseable", + "rawSql": "SELECT time, metadata FROM tetragon ORDER BY time DESC LIMIT 50", + "format": "logs" + } + ] + } + ], + "time": { + "from": "now-6h", + "to": "now" + } +} diff --git a/grafana/provisioning/datasources/parseable.yml b/grafana/provisioning/datasources/parseable.yml new file mode 100644 index 000000000..924949ded --- /dev/null +++ b/grafana/provisioning/datasources/parseable.yml @@ -0,0 +1,12 @@ +apiVersion: 1 +datasources: + - name: Parseable + type: postgres + url: parseable:8000 + user: admin + secureJsonData: + password: admin + jsonData: + sslmode: disable + database: parseable + isDefault: true diff --git a/mock_agent/Dockerfile b/mock_agent/Dockerfile new file mode 100644 index 000000000..c44b974de --- /dev/null +++ b/mock_agent/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY main.py . +CMD ["python", "main.py"] diff --git a/mock_agent/main.py b/mock_agent/main.py new file mode 100644 index 000000000..6a6cd7dbd --- /dev/null +++ b/mock_agent/main.py @@ -0,0 +1,77 @@ +import os +import time +import requests +import uuid +from datetime import datetime +from spiffe.workloadapi.workload_api_client import WorkloadApiClient + +def main(): + print("Starting Aegis Workload Observer...") + socket_path = os.getenv('SPIFFE_ENDPOINT_SOCKET') + if not socket_path: + print("Error: SPIFFE_ENDPOINT_SOCKET not set.") + return + + print(f"Connecting to SPIFFE Workload API at {socket_path}") + + # Initialize the client. By default it uses the SPIFFE_ENDPOINT_SOCKET env var. + client = WorkloadApiClient() + + # Track if we've already reported an incident this cycle + reported_incident = False + + while True: + try: + # Fetch the X.509 SVID + x509_context = client.fetch_x509_context() + svid = x509_context.default_svid + print("===================================================") + print("Workload Identity Verified (SPIFFE SVID)") + print(f"SPIFFE ID: {svid.spiffe_id}") + print("===================================================") + + try: + # Fetch current trust state from analytics engine + resp = requests.get("http://analytics-engine:8000/latest_score", timeout=5) + resp.raise_for_status() + data = resp.json() + trust_score = data.get('trust_score', 1.0) + drift_detected = data.get('intent_drift_detected', False) + + print(f"Trust Score: {trust_score:.4f} - Intent Drift Detected: {drift_detected}") + + # Only report an incident once per detection cycle + if drift_detected and not reported_incident: + print("[REAL INCIDENT] Drift detected. Creating backend incident for HITL...") + try: + incident_response = requests.post( + "http://analytics-engine:8000/incidents/create", + json={ + "id": str(uuid.uuid4()), + "detected_at": datetime.utcnow().isoformat(), + "severity": "HIGH", + "description": f"Intent drift detected: trust_score={trust_score:.4f}" + }, + timeout=5 + ) + incident_response.raise_for_status() + print(f"Incident created: {incident_response.json()}") + reported_incident = True + except Exception as err: + print(f"Failed to create incident: {err}") + elif not drift_detected: + reported_incident = False + print("Workload behavior nominal. Ready for next observation cycle.") + + except Exception as err: + print(f"Analytics engine error: {err}") + + except Exception as e: + print(f"Error fetching SVID (Waiting for Agent / authorization): {e}") + + # Sleep and retry + time.sleep(5) + +if __name__ == "__main__": + main() + diff --git a/mock_agent/requirements.txt b/mock_agent/requirements.txt new file mode 100644 index 000000000..c80f16e74 --- /dev/null +++ b/mock_agent/requirements.txt @@ -0,0 +1,2 @@ +spiffe +requests diff --git a/spiffe_pkg/__init__.py b/spiffe_pkg/__init__.py new file mode 100644 index 000000000..8f8ec29fe --- /dev/null +++ b/spiffe_pkg/__init__.py @@ -0,0 +1,26 @@ +# Re-exports main types for user convenience +from .workloadapi.x509_source import X509Source +from .workloadapi.jwt_source import JwtSource +from .workloadapi.workload_api_client import WorkloadApiClient + +from .spiffe_id.spiffe_id import SpiffeId, TrustDomain +from .svid.x509_svid import X509Svid +from .svid.jwt_svid import JwtSvid +from .bundle.x509_bundle.x509_bundle import X509Bundle +from .bundle.x509_bundle.x509_bundle_set import X509BundleSet +from .bundle.jwt_bundle.jwt_bundle import JwtBundle +from .bundle.jwt_bundle.jwt_bundle_set import JwtBundleSet + +__all__ = [ + "X509Source", + "JwtSource", + "WorkloadApiClient", + "SpiffeId", + "TrustDomain", + "X509Svid", + "JwtSvid", + "X509Bundle", + "X509BundleSet", + "JwtBundle", + "JwtBundleSet", +] diff --git a/spiffe_pkg/_proto/__init__.py b/spiffe_pkg/_proto/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/spiffe_pkg/_proto/workload_pb2.py b/spiffe_pkg/_proto/workload_pb2.py new file mode 100644 index 000000000..deaa7fe47 --- /dev/null +++ b/spiffe_pkg/_proto/workload_pb2.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: spiffe/_proto/workload.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'spiffe/_proto/workload.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1cspiffe/_proto/workload.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x11\n\x0fX509SVIDRequest\"\xb6\x01\n\x10X509SVIDResponse\x12\x18\n\x05svids\x18\x01 \x03(\x0b\x32\t.X509SVID\x12\x0b\n\x03\x63rl\x18\x02 \x03(\x0c\x12\x42\n\x11\x66\x65\x64\x65rated_bundles\x18\x03 \x03(\x0b\x32\'.X509SVIDResponse.FederatedBundlesEntry\x1a\x37\n\x15\x46\x65\x64\x65ratedBundlesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"e\n\x08X509SVID\x12\x11\n\tspiffe_id\x18\x01 \x01(\t\x12\x11\n\tx509_svid\x18\x02 \x01(\x0c\x12\x15\n\rx509_svid_key\x18\x03 \x01(\x0c\x12\x0e\n\x06\x62undle\x18\x04 \x01(\x0c\x12\x0c\n\x04hint\x18\x05 \x01(\t\"\x14\n\x12X509BundlesRequest\"\x86\x01\n\x13X509BundlesResponse\x12\x0b\n\x03\x63rl\x18\x01 \x03(\x0c\x12\x32\n\x07\x62undles\x18\x02 \x03(\x0b\x32!.X509BundlesResponse.BundlesEntry\x1a.\n\x0c\x42undlesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"5\n\x0eJWTSVIDRequest\x12\x10\n\x08\x61udience\x18\x01 \x03(\t\x12\x11\n\tspiffe_id\x18\x02 \x01(\t\"*\n\x0fJWTSVIDResponse\x12\x17\n\x05svids\x18\x01 \x03(\x0b\x32\x08.JWTSVID\"8\n\x07JWTSVID\x12\x11\n\tspiffe_id\x18\x01 \x01(\t\x12\x0c\n\x04svid\x18\x02 \x01(\t\x12\x0c\n\x04hint\x18\x03 \x01(\t\"\x13\n\x11JWTBundlesRequest\"w\n\x12JWTBundlesResponse\x12\x31\n\x07\x62undles\x18\x01 \x03(\x0b\x32 .JWTBundlesResponse.BundlesEntry\x1a.\n\x0c\x42undlesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"8\n\x16ValidateJWTSVIDRequest\x12\x10\n\x08\x61udience\x18\x01 \x01(\t\x12\x0c\n\x04svid\x18\x02 \x01(\t\"U\n\x17ValidateJWTSVIDResponse\x12\x11\n\tspiffe_id\x18\x01 \x01(\t\x12\'\n\x06\x63laims\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct2\xc3\x02\n\x11SpiffeWorkloadAPI\x12\x36\n\rFetchX509SVID\x12\x10.X509SVIDRequest\x1a\x11.X509SVIDResponse0\x01\x12?\n\x10\x46\x65tchX509Bundles\x12\x13.X509BundlesRequest\x1a\x14.X509BundlesResponse0\x01\x12\x31\n\x0c\x46\x65tchJWTSVID\x12\x0f.JWTSVIDRequest\x1a\x10.JWTSVIDResponse\x12<\n\x0f\x46\x65tchJWTBundles\x12\x12.JWTBundlesRequest\x1a\x13.JWTBundlesResponse0\x01\x12\x44\n\x0fValidateJWTSVID\x12\x17.ValidateJWTSVIDRequest\x1a\x18.ValidateJWTSVIDResponseb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'spiffe._proto.workload_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_X509SVIDRESPONSE_FEDERATEDBUNDLESENTRY']._loaded_options = None + _globals['_X509SVIDRESPONSE_FEDERATEDBUNDLESENTRY']._serialized_options = b'8\001' + _globals['_X509BUNDLESRESPONSE_BUNDLESENTRY']._loaded_options = None + _globals['_X509BUNDLESRESPONSE_BUNDLESENTRY']._serialized_options = b'8\001' + _globals['_JWTBUNDLESRESPONSE_BUNDLESENTRY']._loaded_options = None + _globals['_JWTBUNDLESRESPONSE_BUNDLESENTRY']._serialized_options = b'8\001' + _globals['_X509SVIDREQUEST']._serialized_start=62 + _globals['_X509SVIDREQUEST']._serialized_end=79 + _globals['_X509SVIDRESPONSE']._serialized_start=82 + _globals['_X509SVIDRESPONSE']._serialized_end=264 + _globals['_X509SVIDRESPONSE_FEDERATEDBUNDLESENTRY']._serialized_start=209 + _globals['_X509SVIDRESPONSE_FEDERATEDBUNDLESENTRY']._serialized_end=264 + _globals['_X509SVID']._serialized_start=266 + _globals['_X509SVID']._serialized_end=367 + _globals['_X509BUNDLESREQUEST']._serialized_start=369 + _globals['_X509BUNDLESREQUEST']._serialized_end=389 + _globals['_X509BUNDLESRESPONSE']._serialized_start=392 + _globals['_X509BUNDLESRESPONSE']._serialized_end=526 + _globals['_X509BUNDLESRESPONSE_BUNDLESENTRY']._serialized_start=480 + _globals['_X509BUNDLESRESPONSE_BUNDLESENTRY']._serialized_end=526 + _globals['_JWTSVIDREQUEST']._serialized_start=528 + _globals['_JWTSVIDREQUEST']._serialized_end=581 + _globals['_JWTSVIDRESPONSE']._serialized_start=583 + _globals['_JWTSVIDRESPONSE']._serialized_end=625 + _globals['_JWTSVID']._serialized_start=627 + _globals['_JWTSVID']._serialized_end=683 + _globals['_JWTBUNDLESREQUEST']._serialized_start=685 + _globals['_JWTBUNDLESREQUEST']._serialized_end=704 + _globals['_JWTBUNDLESRESPONSE']._serialized_start=706 + _globals['_JWTBUNDLESRESPONSE']._serialized_end=825 + _globals['_JWTBUNDLESRESPONSE_BUNDLESENTRY']._serialized_start=480 + _globals['_JWTBUNDLESRESPONSE_BUNDLESENTRY']._serialized_end=526 + _globals['_VALIDATEJWTSVIDREQUEST']._serialized_start=827 + _globals['_VALIDATEJWTSVIDREQUEST']._serialized_end=883 + _globals['_VALIDATEJWTSVIDRESPONSE']._serialized_start=885 + _globals['_VALIDATEJWTSVIDRESPONSE']._serialized_end=970 + _globals['_SPIFFEWORKLOADAPI']._serialized_start=973 + _globals['_SPIFFEWORKLOADAPI']._serialized_end=1296 +# @@protoc_insertion_point(module_scope) diff --git a/spiffe_pkg/_proto/workload_pb2.pyi b/spiffe_pkg/_proto/workload_pb2.pyi new file mode 100644 index 000000000..52bc711cf --- /dev/null +++ b/spiffe_pkg/_proto/workload_pb2.pyi @@ -0,0 +1,388 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +""" + +from collections import abc as _abc +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import struct_pb2 as _struct_pb2 +from google.protobuf.internal import containers as _containers +import builtins as _builtins +import sys +import typing as _typing + +if sys.version_info >= (3, 10): + from typing import TypeAlias as _TypeAlias +else: + from typing_extensions import TypeAlias as _TypeAlias + +DESCRIPTOR: _descriptor.FileDescriptor + +@_typing.final +class X509SVIDRequest(_message.Message): + """The X509SVIDRequest message conveys parameters for requesting an X.509-SVID. + There are currently no request parameters. + """ + + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +Global___X509SVIDRequest: _TypeAlias = X509SVIDRequest # noqa: Y015 + +@_typing.final +class X509SVIDResponse(_message.Message): + """The X509SVIDResponse message carries X.509-SVIDs and related information, + including a set of global CRLs and a list of bundles the workload may use + for federating with foreign trust domains. + """ + + DESCRIPTOR: _descriptor.Descriptor + + @_typing.final + class FederatedBundlesEntry(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + KEY_FIELD_NUMBER: _builtins.int + VALUE_FIELD_NUMBER: _builtins.int + key: _builtins.str + value: _builtins.bytes + def __init__( + self, + *, + key: _builtins.str = ..., + value: _builtins.bytes = ..., + ) -> None: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["key", b"key", "value", b"value"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + + SVIDS_FIELD_NUMBER: _builtins.int + CRL_FIELD_NUMBER: _builtins.int + FEDERATED_BUNDLES_FIELD_NUMBER: _builtins.int + @_builtins.property + def svids(self) -> _containers.RepeatedCompositeFieldContainer[Global___X509SVID]: + """Required. A list of X509SVID messages, each of which includes a single + X.509-SVID, its private key, and the bundle for the trust domain. + """ + + @_builtins.property + def crl(self) -> _containers.RepeatedScalarFieldContainer[_builtins.bytes]: + """Optional. ASN.1 DER encoded certificate revocation lists.""" + + @_builtins.property + def federated_bundles(self) -> _containers.ScalarMap[_builtins.str, _builtins.bytes]: + """Optional. CA certificate bundles belonging to foreign trust domains that + the workload should trust, keyed by the SPIFFE ID of the foreign trust + domain. Bundles are ASN.1 DER encoded. + """ + + def __init__( + self, + *, + svids: _abc.Iterable[Global___X509SVID] | None = ..., + crl: _abc.Iterable[_builtins.bytes] | None = ..., + federated_bundles: _abc.Mapping[_builtins.str, _builtins.bytes] | None = ..., + ) -> None: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["crl", b"crl", "federated_bundles", b"federated_bundles", "svids", b"svids"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + +Global___X509SVIDResponse: _TypeAlias = X509SVIDResponse # noqa: Y015 + +@_typing.final +class X509SVID(_message.Message): + """The X509SVID message carries a single SVID and all associated information, + including the X.509 bundle for the trust domain. + """ + + DESCRIPTOR: _descriptor.Descriptor + + SPIFFE_ID_FIELD_NUMBER: _builtins.int + X509_SVID_FIELD_NUMBER: _builtins.int + X509_SVID_KEY_FIELD_NUMBER: _builtins.int + BUNDLE_FIELD_NUMBER: _builtins.int + HINT_FIELD_NUMBER: _builtins.int + spiffe_id: _builtins.str + """Required. The SPIFFE ID of the SVID in this entry""" + x509_svid: _builtins.bytes + """Required. ASN.1 DER encoded certificate chain. MAY include + intermediates, the leaf certificate (or SVID itself) MUST come first. + """ + x509_svid_key: _builtins.bytes + """Required. ASN.1 DER encoded PKCS#8 private key. MUST be unencrypted.""" + bundle: _builtins.bytes + """Required. ASN.1 DER encoded X.509 bundle for the trust domain.""" + hint: _builtins.str + """Optional. An operator-specified string used to provide guidance on how this + identity should be used by a workload when more than one SVID is returned. + For example, `internal` and `external` to indicate an SVID for internal or + external use, respectively. + """ + def __init__( + self, + *, + spiffe_id: _builtins.str = ..., + x509_svid: _builtins.bytes = ..., + x509_svid_key: _builtins.bytes = ..., + bundle: _builtins.bytes = ..., + hint: _builtins.str = ..., + ) -> None: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["bundle", b"bundle", "hint", b"hint", "spiffe_id", b"spiffe_id", "x509_svid", b"x509_svid", "x509_svid_key", b"x509_svid_key"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + +Global___X509SVID: _TypeAlias = X509SVID # noqa: Y015 + +@_typing.final +class X509BundlesRequest(_message.Message): + """The X509BundlesRequest message conveys parameters for requesting X.509 + bundles. There are currently no such parameters. + """ + + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +Global___X509BundlesRequest: _TypeAlias = X509BundlesRequest # noqa: Y015 + +@_typing.final +class X509BundlesResponse(_message.Message): + """The X509BundlesResponse message carries a set of global CRLs and a map of + trust bundles the workload should trust. + """ + + DESCRIPTOR: _descriptor.Descriptor + + @_typing.final + class BundlesEntry(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + KEY_FIELD_NUMBER: _builtins.int + VALUE_FIELD_NUMBER: _builtins.int + key: _builtins.str + value: _builtins.bytes + def __init__( + self, + *, + key: _builtins.str = ..., + value: _builtins.bytes = ..., + ) -> None: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["key", b"key", "value", b"value"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + + CRL_FIELD_NUMBER: _builtins.int + BUNDLES_FIELD_NUMBER: _builtins.int + @_builtins.property + def crl(self) -> _containers.RepeatedScalarFieldContainer[_builtins.bytes]: + """Optional. ASN.1 DER encoded certificate revocation lists.""" + + @_builtins.property + def bundles(self) -> _containers.ScalarMap[_builtins.str, _builtins.bytes]: + """Required. CA certificate bundles belonging to trust domains that the + workload should trust, keyed by the SPIFFE ID of the trust domain. + Bundles are ASN.1 DER encoded. + """ + + def __init__( + self, + *, + crl: _abc.Iterable[_builtins.bytes] | None = ..., + bundles: _abc.Mapping[_builtins.str, _builtins.bytes] | None = ..., + ) -> None: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["bundles", b"bundles", "crl", b"crl"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + +Global___X509BundlesResponse: _TypeAlias = X509BundlesResponse # noqa: Y015 + +@_typing.final +class JWTSVIDRequest(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + AUDIENCE_FIELD_NUMBER: _builtins.int + SPIFFE_ID_FIELD_NUMBER: _builtins.int + spiffe_id: _builtins.str + """Optional. The requested SPIFFE ID for the JWT-SVID. If unset, all + JWT-SVIDs to which the workload is entitled are requested. + """ + @_builtins.property + def audience(self) -> _containers.RepeatedScalarFieldContainer[_builtins.str]: + """Required. The audience(s) the workload intends to authenticate against.""" + + def __init__( + self, + *, + audience: _abc.Iterable[_builtins.str] | None = ..., + spiffe_id: _builtins.str = ..., + ) -> None: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["audience", b"audience", "spiffe_id", b"spiffe_id"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + +Global___JWTSVIDRequest: _TypeAlias = JWTSVIDRequest # noqa: Y015 + +@_typing.final +class JWTSVIDResponse(_message.Message): + """The JWTSVIDResponse message conveys JWT-SVIDs.""" + + DESCRIPTOR: _descriptor.Descriptor + + SVIDS_FIELD_NUMBER: _builtins.int + @_builtins.property + def svids(self) -> _containers.RepeatedCompositeFieldContainer[Global___JWTSVID]: + """Required. The list of returned JWT-SVIDs.""" + + def __init__( + self, + *, + svids: _abc.Iterable[Global___JWTSVID] | None = ..., + ) -> None: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["svids", b"svids"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + +Global___JWTSVIDResponse: _TypeAlias = JWTSVIDResponse # noqa: Y015 + +@_typing.final +class JWTSVID(_message.Message): + """The JWTSVID message carries the JWT-SVID token and associated metadata.""" + + DESCRIPTOR: _descriptor.Descriptor + + SPIFFE_ID_FIELD_NUMBER: _builtins.int + SVID_FIELD_NUMBER: _builtins.int + HINT_FIELD_NUMBER: _builtins.int + spiffe_id: _builtins.str + """Required. The SPIFFE ID of the JWT-SVID.""" + svid: _builtins.str + """Required. Encoded JWT using JWS Compact Serialization.""" + hint: _builtins.str + """Optional. An operator-specified string used to provide guidance on how this + identity should be used by a workload when more than one SVID is returned. + For example, `internal` and `external` to indicate an SVID for internal or + external use, respectively. + """ + def __init__( + self, + *, + spiffe_id: _builtins.str = ..., + svid: _builtins.str = ..., + hint: _builtins.str = ..., + ) -> None: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["hint", b"hint", "spiffe_id", b"spiffe_id", "svid", b"svid"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + +Global___JWTSVID: _TypeAlias = JWTSVID # noqa: Y015 + +@_typing.final +class JWTBundlesRequest(_message.Message): + """The JWTBundlesRequest message conveys parameters for requesting JWT bundles. + There are currently no such parameters. + """ + + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +Global___JWTBundlesRequest: _TypeAlias = JWTBundlesRequest # noqa: Y015 + +@_typing.final +class JWTBundlesResponse(_message.Message): + """The JWTBundlesReponse conveys JWT bundles.""" + + DESCRIPTOR: _descriptor.Descriptor + + @_typing.final + class BundlesEntry(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + KEY_FIELD_NUMBER: _builtins.int + VALUE_FIELD_NUMBER: _builtins.int + key: _builtins.str + value: _builtins.bytes + def __init__( + self, + *, + key: _builtins.str = ..., + value: _builtins.bytes = ..., + ) -> None: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["key", b"key", "value", b"value"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + + BUNDLES_FIELD_NUMBER: _builtins.int + @_builtins.property + def bundles(self) -> _containers.ScalarMap[_builtins.str, _builtins.bytes]: + """Required. JWK encoded JWT bundles, keyed by the SPIFFE ID of the trust + domain. + """ + + def __init__( + self, + *, + bundles: _abc.Mapping[_builtins.str, _builtins.bytes] | None = ..., + ) -> None: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["bundles", b"bundles"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + +Global___JWTBundlesResponse: _TypeAlias = JWTBundlesResponse # noqa: Y015 + +@_typing.final +class ValidateJWTSVIDRequest(_message.Message): + """The ValidateJWTSVIDRequest message conveys request parameters for + JWT-SVID validation. + """ + + DESCRIPTOR: _descriptor.Descriptor + + AUDIENCE_FIELD_NUMBER: _builtins.int + SVID_FIELD_NUMBER: _builtins.int + audience: _builtins.str + """Required. The audience of the validating party. The JWT-SVID must + contain an audience claim which contains this value in order to + succesfully validate. + """ + svid: _builtins.str + """Required. The JWT-SVID to validate, encoded using JWS Compact + Serialization. + """ + def __init__( + self, + *, + audience: _builtins.str = ..., + svid: _builtins.str = ..., + ) -> None: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["audience", b"audience", "svid", b"svid"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + +Global___ValidateJWTSVIDRequest: _TypeAlias = ValidateJWTSVIDRequest # noqa: Y015 + +@_typing.final +class ValidateJWTSVIDResponse(_message.Message): + """The ValidateJWTSVIDReponse message conveys the JWT-SVID validation results.""" + + DESCRIPTOR: _descriptor.Descriptor + + SPIFFE_ID_FIELD_NUMBER: _builtins.int + CLAIMS_FIELD_NUMBER: _builtins.int + spiffe_id: _builtins.str + """Required. The SPIFFE ID of the validated JWT-SVID.""" + @_builtins.property + def claims(self) -> _struct_pb2.Struct: + """Optional. Arbitrary claims contained within the payload of the validated + JWT-SVID. + """ + + def __init__( + self, + *, + spiffe_id: _builtins.str = ..., + claims: _struct_pb2.Struct | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["claims", b"claims"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["claims", b"claims", "spiffe_id", b"spiffe_id"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + +Global___ValidateJWTSVIDResponse: _TypeAlias = ValidateJWTSVIDResponse # noqa: Y015 diff --git a/spiffe_pkg/_proto/workload_pb2_grpc.py b/spiffe_pkg/_proto/workload_pb2_grpc.py new file mode 100644 index 000000000..b9aec79cd --- /dev/null +++ b/spiffe_pkg/_proto/workload_pb2_grpc.py @@ -0,0 +1,298 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from spiffe._proto import workload_pb2 as spiffe_dot___proto_dot_workload__pb2 + +GRPC_GENERATED_VERSION = '1.78.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in spiffe/_proto/workload_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class SpiffeWorkloadAPIStub(object): + """/////////////////////////////////////////////////////////////////////// + X509-SVID Profile + /////////////////////////////////////////////////////////////////////// + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.FetchX509SVID = channel.unary_stream( + '/SpiffeWorkloadAPI/FetchX509SVID', + request_serializer=spiffe_dot___proto_dot_workload__pb2.X509SVIDRequest.SerializeToString, + response_deserializer=spiffe_dot___proto_dot_workload__pb2.X509SVIDResponse.FromString, + _registered_method=True) + self.FetchX509Bundles = channel.unary_stream( + '/SpiffeWorkloadAPI/FetchX509Bundles', + request_serializer=spiffe_dot___proto_dot_workload__pb2.X509BundlesRequest.SerializeToString, + response_deserializer=spiffe_dot___proto_dot_workload__pb2.X509BundlesResponse.FromString, + _registered_method=True) + self.FetchJWTSVID = channel.unary_unary( + '/SpiffeWorkloadAPI/FetchJWTSVID', + request_serializer=spiffe_dot___proto_dot_workload__pb2.JWTSVIDRequest.SerializeToString, + response_deserializer=spiffe_dot___proto_dot_workload__pb2.JWTSVIDResponse.FromString, + _registered_method=True) + self.FetchJWTBundles = channel.unary_stream( + '/SpiffeWorkloadAPI/FetchJWTBundles', + request_serializer=spiffe_dot___proto_dot_workload__pb2.JWTBundlesRequest.SerializeToString, + response_deserializer=spiffe_dot___proto_dot_workload__pb2.JWTBundlesResponse.FromString, + _registered_method=True) + self.ValidateJWTSVID = channel.unary_unary( + '/SpiffeWorkloadAPI/ValidateJWTSVID', + request_serializer=spiffe_dot___proto_dot_workload__pb2.ValidateJWTSVIDRequest.SerializeToString, + response_deserializer=spiffe_dot___proto_dot_workload__pb2.ValidateJWTSVIDResponse.FromString, + _registered_method=True) + + +class SpiffeWorkloadAPIServicer(object): + """/////////////////////////////////////////////////////////////////////// + X509-SVID Profile + /////////////////////////////////////////////////////////////////////// + """ + + def FetchX509SVID(self, request, context): + """Fetch X.509-SVIDs for all SPIFFE identities the workload is entitled to, + as well as related information like trust bundles and CRLs. As this + information changes, subsequent messages will be streamed from the + server. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def FetchX509Bundles(self, request, context): + """Fetch trust bundles and CRLs. Useful for clients that only need to + validate SVIDs without obtaining an SVID for themself. As this + information changes, subsequent messages will be streamed from the + server. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def FetchJWTSVID(self, request, context): + """/////////////////////////////////////////////////////////////////////// + JWT-SVID Profile + /////////////////////////////////////////////////////////////////////// + + Fetch JWT-SVIDs for all SPIFFE identities the workload is entitled to, + for the requested audience. If an optional SPIFFE ID is requested, only + the JWT-SVID for that SPIFFE ID is returned. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def FetchJWTBundles(self, request, context): + """Fetches the JWT bundles, formatted as JWKS documents, keyed by the + SPIFFE ID of the trust domain. As this information changes, subsequent + messages will be streamed from the server. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def ValidateJWTSVID(self, request, context): + """Validates a JWT-SVID against the requested audience. Returns the SPIFFE + ID of the JWT-SVID and JWT claims. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_SpiffeWorkloadAPIServicer_to_server(servicer, server): + rpc_method_handlers = { + 'FetchX509SVID': grpc.unary_stream_rpc_method_handler( + servicer.FetchX509SVID, + request_deserializer=spiffe_dot___proto_dot_workload__pb2.X509SVIDRequest.FromString, + response_serializer=spiffe_dot___proto_dot_workload__pb2.X509SVIDResponse.SerializeToString, + ), + 'FetchX509Bundles': grpc.unary_stream_rpc_method_handler( + servicer.FetchX509Bundles, + request_deserializer=spiffe_dot___proto_dot_workload__pb2.X509BundlesRequest.FromString, + response_serializer=spiffe_dot___proto_dot_workload__pb2.X509BundlesResponse.SerializeToString, + ), + 'FetchJWTSVID': grpc.unary_unary_rpc_method_handler( + servicer.FetchJWTSVID, + request_deserializer=spiffe_dot___proto_dot_workload__pb2.JWTSVIDRequest.FromString, + response_serializer=spiffe_dot___proto_dot_workload__pb2.JWTSVIDResponse.SerializeToString, + ), + 'FetchJWTBundles': grpc.unary_stream_rpc_method_handler( + servicer.FetchJWTBundles, + request_deserializer=spiffe_dot___proto_dot_workload__pb2.JWTBundlesRequest.FromString, + response_serializer=spiffe_dot___proto_dot_workload__pb2.JWTBundlesResponse.SerializeToString, + ), + 'ValidateJWTSVID': grpc.unary_unary_rpc_method_handler( + servicer.ValidateJWTSVID, + request_deserializer=spiffe_dot___proto_dot_workload__pb2.ValidateJWTSVIDRequest.FromString, + response_serializer=spiffe_dot___proto_dot_workload__pb2.ValidateJWTSVIDResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'SpiffeWorkloadAPI', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('SpiffeWorkloadAPI', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class SpiffeWorkloadAPI(object): + """/////////////////////////////////////////////////////////////////////// + X509-SVID Profile + /////////////////////////////////////////////////////////////////////// + """ + + @staticmethod + def FetchX509SVID(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_stream( + request, + target, + '/SpiffeWorkloadAPI/FetchX509SVID', + spiffe_dot___proto_dot_workload__pb2.X509SVIDRequest.SerializeToString, + spiffe_dot___proto_dot_workload__pb2.X509SVIDResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def FetchX509Bundles(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_stream( + request, + target, + '/SpiffeWorkloadAPI/FetchX509Bundles', + spiffe_dot___proto_dot_workload__pb2.X509BundlesRequest.SerializeToString, + spiffe_dot___proto_dot_workload__pb2.X509BundlesResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def FetchJWTSVID(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/SpiffeWorkloadAPI/FetchJWTSVID', + spiffe_dot___proto_dot_workload__pb2.JWTSVIDRequest.SerializeToString, + spiffe_dot___proto_dot_workload__pb2.JWTSVIDResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def FetchJWTBundles(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_stream( + request, + target, + '/SpiffeWorkloadAPI/FetchJWTBundles', + spiffe_dot___proto_dot_workload__pb2.JWTBundlesRequest.SerializeToString, + spiffe_dot___proto_dot_workload__pb2.JWTBundlesResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def ValidateJWTSVID(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/SpiffeWorkloadAPI/ValidateJWTSVID', + spiffe_dot___proto_dot_workload__pb2.ValidateJWTSVIDRequest.SerializeToString, + spiffe_dot___proto_dot_workload__pb2.ValidateJWTSVIDResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/spiffe_pkg/bundle/__init__.py b/spiffe_pkg/bundle/__init__.py new file mode 100644 index 000000000..eb4e8ea48 --- /dev/null +++ b/spiffe_pkg/bundle/__init__.py @@ -0,0 +1,3 @@ +""" +Bundles Package. Contains information related to X509 and JWT Bundles. +""" diff --git a/spiffe_pkg/bundle/jwt_bundle/__init__.py b/spiffe_pkg/bundle/jwt_bundle/__init__.py new file mode 100644 index 000000000..697dd4367 --- /dev/null +++ b/spiffe_pkg/bundle/jwt_bundle/__init__.py @@ -0,0 +1,3 @@ +""" +JWT Bundle Module. Contains information for JWT Bundles. +""" diff --git a/spiffe_pkg/bundle/jwt_bundle/errors.py b/spiffe_pkg/bundle/jwt_bundle/errors.py new file mode 100644 index 000000000..06a07b528 --- /dev/null +++ b/spiffe_pkg/bundle/jwt_bundle/errors.py @@ -0,0 +1,39 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +""" +This module handles JWT bundle exceptions. +""" + +from spiffe.errors import PySpiffeError + + +class JwtBundleError(PySpiffeError): + """Exception raised for JwtBundle module related errors.""" + + +class ParseJWTBundleError(JwtBundleError): + """Error raised when unable to parse a JWT bundle from bytes.""" + + def __init__(self, detail: str) -> None: + super().__init__(f'Error parsing JWT bundle: {detail}') + + +class AuthorityNotFoundError(JwtBundleError): + """Error raised when an authority is not found for a given key ID.""" + + def __init__(self, key_id: str) -> None: + super().__init__(f'Authority not found for key ID: {key_id}') diff --git a/spiffe_pkg/bundle/jwt_bundle/jwt_bundle.py b/spiffe_pkg/bundle/jwt_bundle/jwt_bundle.py new file mode 100644 index 000000000..1b79f5968 --- /dev/null +++ b/spiffe_pkg/bundle/jwt_bundle/jwt_bundle.py @@ -0,0 +1,158 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +""" +JwtBundle module manages JwtBundle objects. +""" + +import threading +from json import JSONDecodeError +from jwt.api_jwk import PyJWKSet +from jwt.exceptions import InvalidKeyError, PyJWKSetError +from typing import Dict, Union, Optional +from cryptography.hazmat.primitives.asymmetric import ec, rsa, dsa, ed25519, ed448 + +from spiffe.spiffe_id.spiffe_id import TrustDomain +from spiffe.bundle.jwt_bundle.errors import JwtBundleError, ParseJWTBundleError +from spiffe.errors import ArgumentError + +_PUBLIC_KEY_TYPES = Union[ + dsa.DSAPublicKey, + rsa.RSAPublicKey, + ec.EllipticCurvePublicKey, + ed25519.Ed25519PublicKey, + ed448.Ed448PublicKey, +] + + +class JwtBundle(object): + """Represents a JWT Bundle. + + JwtBundle is a collection of trusted JWT public keys for a trust domain. + """ + + def __init__( + self, trust_domain: TrustDomain, jwt_authorities: Dict[str, _PUBLIC_KEY_TYPES] + ) -> None: + """Creates a JwtBundle instance. + + Args: + trust_domain: The TrustDomain to associate with the JwtBundle instance. + jwt_authorities: A dictionary with key_id->PublicKey valid for the given TrustDomain. + + Raises: + JWTBundleError: In case the trust_domain is empty. + """ + self._lock = threading.Lock() + + if not trust_domain: + raise JwtBundleError("Trust domain cannot be empty") + + self._trust_domain = trust_domain + self._jwt_authorities = jwt_authorities.copy() if jwt_authorities else {} + + @property + def trust_domain(self) -> TrustDomain: + """Returns the trust domain of the bundle.""" + return self._trust_domain + + @property + def jwt_authorities(self) -> Dict[str, _PUBLIC_KEY_TYPES]: + """Returns a copy of JWT authorities in the bundle.""" + with self._lock: + return self._jwt_authorities.copy() + + def get_jwt_authority(self, key_id: Optional[str]) -> Optional[_PUBLIC_KEY_TYPES]: + """Returns the authority for the specified key_id. + + Args: + key_id: Key id of the token to return the correspondent authority. + + Returns: + The authority associated with the supplied key_id. + None if the key_id is not found. + + Raises: + ArgumentError: When key_id is not valid (empty or None). + """ + if not key_id: + raise ArgumentError('key_id cannot be empty') + + with self._lock: + return self._jwt_authorities.get(key_id) + + @classmethod + def parse(cls, trust_domain: TrustDomain, bundle_bytes: bytes) -> 'JwtBundle': + """Parses a bundle from bytes. The data must be a standard RFC 7517 JWKS document. + + Args: + trust_domain: A TrustDomain to associate to the bundle. + bundle_bytes: An array of bytes that represents a set of JWKs. + + Returns: + An instance of 'JWTBundle' with the JWT authorities associated to the given trust domain. + + Raises: + ArgumentError: In case the trust_domain is empty or bundle_bytes is empty. + ParseJWTBundleError: In case the set of jwt_authorities cannot be parsed from the bundle_bytes. + """ + + if not trust_domain: + raise ArgumentError("Trust domain cannot be empty") + + if not bundle_bytes: + raise ArgumentError('Bundle bytes cannot be empty') + + try: + jwks = PyJWKSet.from_json(bundle_bytes.decode('utf-8')) + + jwt_authorities = {} + for jwk in jwks.keys: + if not jwk.key_id: + raise ParseJWTBundleError( + 'Error adding authority from JWKS: "keyID" cannot be empty' + ) + + jwt_authorities[jwk.key_id] = jwk.key + + return JwtBundle(trust_domain, jwt_authorities) + except InvalidKeyError as err: + raise ParseJWTBundleError(str(err)) from err + except PyJWKSetError as err: + if str(err) == "The JWK Set did not contain any keys": + return JwtBundle(trust_domain, {}) + else: + raise ParseJWTBundleError( + '"bundle_bytes" does not represent a valid jwks' + ) from err + except (JSONDecodeError, AttributeError) as err: + raise ParseJWTBundleError( + '"bundle_bytes" does not represent a valid jwks' + ) from err + + def __eq__(self, o: object) -> bool: + if not isinstance(o, JwtBundle): + return False + with self._lock: + return ( + self._trust_domain.__eq__(o._trust_domain) + and self._jwt_authorities == o._jwt_authorities + ) + + def __hash__(self) -> int: + trust_domain_hash = hash(self.trust_domain) + authorities_hash = hash(tuple(hash(authority) for authority in self._jwt_authorities)) + return hash((trust_domain_hash, authorities_hash)) diff --git a/spiffe_pkg/bundle/jwt_bundle/jwt_bundle_set.py b/spiffe_pkg/bundle/jwt_bundle/jwt_bundle_set.py new file mode 100644 index 000000000..aae10967b --- /dev/null +++ b/spiffe_pkg/bundle/jwt_bundle/jwt_bundle_set.py @@ -0,0 +1,86 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +""" +This module manages JwtBundleSet objects. +""" + +import threading +from typing import Dict, Optional, List, Set +from spiffe.bundle.jwt_bundle.jwt_bundle import JwtBundle +from spiffe.spiffe_id.spiffe_id import TrustDomain + +__all__ = ['JwtBundleSet'] + + +class JwtBundleSet(object): + """JwtBundleSet is a dictionary of JWTBundles objects, keyed by trust domain.""" + + def __init__(self, bundles: Dict[TrustDomain, JwtBundle]) -> None: + """Creates a new JwtBundleSet initialized with the given JWT bundles objects keyed by TrustDomain. + + Args: + bundles: A dictionary of JwtBundle objects keyed by TrustDomain to initialize the JwtBundleSet. + """ + self._lock = threading.Lock() + self._bundles: Dict[str, JwtBundle] = {} + + if bundles: + for trust_domain, bundle in bundles.items(): + self._bundles[trust_domain.name] = bundle + + @property + def bundles(self) -> Set[JwtBundle]: + """Returns the set of all JwtBundles.""" + return set(self._bundles.values()) + + def get_bundle_for_trust_domain(self, trust_domain: TrustDomain) -> Optional[JwtBundle]: + """Returns the JWT bundle of the given trust domain. + + Args: + trust_domain: The TrustDomain to get a JwtBundle. + + Returns: + A JwtBundle object for the given TrustDomain. + None if the TrustDomain is not found in the set. + """ + with self._lock: + return self._bundles.get(trust_domain.name) + + def put(self, jwt_bundle: JwtBundle) -> None: + """Adds a new bundle into the set. + + If a bundle already exists for the trust domain, the existing bundle is + replaced. + + Args: + jwt_bundle: The new JwtBundle to add. + """ + with self._lock: + self._bundles[jwt_bundle.trust_domain.name] = jwt_bundle + + @classmethod + def of(cls, bundle_list: List[JwtBundle]) -> 'JwtBundleSet': + """Creates a new initialized JwtBundleSet with the given JwtBundle objects keyed by TrustDomain. + + Args: + bundle_list: A list JwtBundle objects to store in the new JwtBundleSet. + """ + bundles = {} + for b in bundle_list: + bundles[b.trust_domain] = b + + return JwtBundleSet(bundles) diff --git a/spiffe_pkg/bundle/x509_bundle/__init__.py b/spiffe_pkg/bundle/x509_bundle/__init__.py new file mode 100644 index 000000000..536d2f424 --- /dev/null +++ b/spiffe_pkg/bundle/x509_bundle/__init__.py @@ -0,0 +1,3 @@ +""" +JWT Bundle Module. Contains information for x509 Bundles. +""" diff --git a/spiffe_pkg/bundle/x509_bundle/errors.py b/spiffe_pkg/bundle/x509_bundle/errors.py new file mode 100644 index 000000000..11bf3df78 --- /dev/null +++ b/spiffe_pkg/bundle/x509_bundle/errors.py @@ -0,0 +1,47 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +""" +This module defines X.509 Bundle exceptions. +""" + +from pathlib import Path +from spiffe.errors import PySpiffeError + + +class X509BundleError(PySpiffeError): + """Exception raised for X509Bundle module related errors.""" + + +class ParseX509BundleError(X509BundleError): + """Error raised when unable to parse an X.509 bundle from bytes.""" + + def __init__(self, detail: str) -> None: + super().__init__(f'Error parsing X.509 bundle: {detail}') + + +class LoadX509BundleError(X509BundleError): + """Error raised when unable to load an X.509 bundle from a file.""" + + def __init__(self, path: Path | str) -> None: + super().__init__(f'Error loading X.509 bundle from {path}') + + +class SaveX509BundleError(X509BundleError): + """Error raised when unable to save an X.509 bundle to a file.""" + + def __init__(self, path: Path | str) -> None: + super().__init__(f'Error saving X.509 bundle to {path}') diff --git a/spiffe_pkg/bundle/x509_bundle/x509_bundle.py b/spiffe_pkg/bundle/x509_bundle/x509_bundle.py new file mode 100644 index 000000000..825a6b51b --- /dev/null +++ b/spiffe_pkg/bundle/x509_bundle/x509_bundle.py @@ -0,0 +1,215 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +""" +This module manages X.509 Bundle objects. +""" + +import threading +from pathlib import Path +from typing import Set, Optional + +from cryptography.hazmat.primitives import serialization +from cryptography.x509 import Certificate +from spiffe.errors import ArgumentError +from spiffe.bundle.x509_bundle.errors import ( + X509BundleError, + SaveX509BundleError, + ParseX509BundleError, + LoadX509BundleError, +) +from spiffe.spiffe_id.spiffe_id import TrustDomain +from spiffe.utils.certificate_utils import ( + parse_pem_certificates, + parse_der_certificates, + load_certificates_bytes_from_file, + write_certificates_to_file, +) + +__all__ = ['X509Bundle'] + + +class X509Bundle(object): + """Represents a collection of trusted X.509 authorities for a trust domain.""" + + def __init__( + self, + trust_domain: TrustDomain, + x509_authorities: Optional[Set[Certificate]], + ) -> None: + """Creates a X509Bundle instance. + + Args: + trust_domain: A TrustDomain instance. + x509_authorities: A set of CA certificates. + + Raises: + X509BundleError: In case the trust_domain is empty. + """ + + self._lock = threading.Lock() + + if not trust_domain: + raise X509BundleError("Trust domain cannot be empty") + + self._trust_domain = trust_domain + self._x509_authorities = x509_authorities.copy() if x509_authorities else set() + + @property + def trust_domain(self) -> TrustDomain: + """Returns the trust domain of the bundle.""" + return self._trust_domain + + @property + def x509_authorities(self) -> Set[Certificate]: + """Returns a copy of set of X.509 authorities in the bundle.""" + with self._lock: + return self._x509_authorities.copy() + + def add_authority(self, x509_authority: Certificate) -> None: + """Adds an X.509 authority to the bundle.""" + with self._lock: + self._x509_authorities.add(x509_authority) + + def remove_authority(self, x509_authority: Certificate) -> None: + """Removes an X.509 authority from the bundle.""" + with self._lock: + if not self._x509_authorities: + return + self._x509_authorities.remove(x509_authority) + + def save( + self, + bundle_path: Path | str, + encoding: serialization.Encoding, + ) -> None: + """Saves the X.509 bundle to a file in disk. + + Args: + bundle_path: Path to the file the set of X.509 authorities will be written to. + encoding: Bundle encoding format, either serialization.Encoding.PEM or serialization.Encoding.DER + + Raises: + ArgumentError: In case the encoding is not either PEM or DER (from serialization.Encoding) + SaveX509BundleError: In the case the file path in bundle_path cannot be open to write, or there is an error + converting or writing the authorities bytes to the file. + """ + + if encoding not in [encoding.PEM, encoding.DER]: + raise ArgumentError( + 'Encoding not supported: {}. Expected \'PEM\' or \'DER\''.format(encoding) + ) + try: + write_certificates_to_file(bundle_path, encoding, self._x509_authorities) + except Exception as err: + raise SaveX509BundleError(bundle_path) from err + + @classmethod + def parse(cls, trust_domain: TrustDomain, bundle_bytes: bytes) -> 'X509Bundle': + """Parses an X.509 bundle from an array of bytes containing trusted authorities as PEM blocks. + + Args: + trust_domain: A TrustDomain to associate to the bundle. + bundle_bytes: An array of bytes that represents a set of X.509 authorities. + + Returns: + An instance of 'X509Bundle' with the X.509 authorities associated to the given trust domain. + + Raises: + X509BundleError: In case the trust_domain is empty. + ParseX509BundleError: In case the set of x509_authorities cannot be parsed from the bundle_bytes. + """ + + try: + authorities = parse_pem_certificates(bundle_bytes) + except Exception as err: + raise ParseX509BundleError(str(err)) from err + + return X509Bundle(trust_domain, set(authorities)) + + @classmethod + def parse_raw(cls, trust_domain: TrustDomain, bundle_bytes: bytes) -> 'X509Bundle': + """Parses an X.509 bundle from an array of bytes containing trusted authorities as DER blocks. + + Args: + trust_domain: A TrustDomain to associate to the bundle. + bundle_bytes: An array of bytes that represents a set of X.509 authorities. + + Returns: + An instance of 'X509Bundle' with the X.509 authorities associated to the given trust domain. + + Raises: + X509BundleError: In case the trust_domain is empty. + ParseX509BundleError: In case the set of x509_authorities cannot be parsed from the bundle_bytes. + """ + + try: + authorities = parse_der_certificates(bundle_bytes) + except Exception as err: + raise ParseX509BundleError(str(err)) from err + + return X509Bundle(trust_domain, set(authorities)) + + @classmethod + def load( + cls, + trust_domain: TrustDomain, + bundle_path: Path | str, + encoding: serialization.Encoding, + ) -> 'X509Bundle': + """Loads an X.509 bundle from a file in disk containing DER or PEM encoded trusted authorities. + + Args: + trust_domain: A trust domain to associate to the bundle. + bundle_path: Path to the file containing a set of X.509 authorities. + encoding: Bundle encoding format, either serialization.Encoding.PEM or serialization.Encoding.DER. + + Returns: + An instance of 'X509Bundle' with the X.509 authorities associated to the given trust domain. + + Raises: + X509BundleError: In case the trust_domain is empty. + LoadX509BundleError: In case the set of x509_authorities cannot be parsed from the bundle_bytes. + """ + + try: + bundle_bytes = load_certificates_bytes_from_file(bundle_path) + except Exception as err: + raise LoadX509BundleError(str(err)) from err + + if encoding == serialization.Encoding.PEM: + return cls.parse(trust_domain, bundle_bytes) + + if encoding == serialization.Encoding.DER: + return cls.parse_raw(trust_domain, bundle_bytes) + + raise ArgumentError( + 'Encoding not supported: {}. Expected \'PEM\' or \'DER\''.format(encoding) + ) + + def __eq__(self, o: object) -> bool: + if not isinstance(o, X509Bundle): + return False + with self._lock: + return ( + self.trust_domain.__eq__(o.trust_domain) + and self._x509_authorities == o._x509_authorities + ) + + def __hash__(self) -> int: + trust_domain_hash = hash(self.trust_domain) + authorities_hash = hash(tuple(hash(authority) for authority in self._x509_authorities)) + return hash((trust_domain_hash, authorities_hash)) diff --git a/spiffe_pkg/bundle/x509_bundle/x509_bundle_set.py b/spiffe_pkg/bundle/x509_bundle/x509_bundle_set.py new file mode 100644 index 000000000..c04809745 --- /dev/null +++ b/spiffe_pkg/bundle/x509_bundle/x509_bundle_set.py @@ -0,0 +1,90 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +""" +This module manages X509BundleSet objects. +""" + +import threading +from typing import List, Optional, Dict, Set + +from spiffe.bundle.x509_bundle.x509_bundle import X509Bundle +from spiffe.spiffe_id.spiffe_id import TrustDomain + +__all__ = ['X509BundleSet'] + + +class X509BundleSet(object): + """X509BundleSet is a set of X509Bundles objects, keyed by trust domain.""" + + def __init__(self, bundles: Optional[Dict[TrustDomain, X509Bundle]]) -> None: + """Creates a new X509BundleSet. + + When the bundles parameter is not provided, it creates an empty X509BundleSet. + When the bundles dictionary parameter is provided, the new X509BundleSet is initialized + with the X509Bundle objects keyed by TrustDomain. + + Args: + bundles: A dict object of X509Bundle objects keyed by TrustDomain to initialize the X509BundleSet. Default: None. + """ + + self._lock = threading.Lock() + self._bundles: Dict[str, X509Bundle] = {} + + if bundles: + for trust_domain, bundle in bundles.items(): + self._bundles[trust_domain.name] = bundle + + def get_bundle_for_trust_domain(self, trust_domain: TrustDomain) -> Optional[X509Bundle]: + """Returns the X509Bundle object for the given trust domain. + + Args: + trust_domain: The TrustDomain to get an X509Bundle. + + Returns: + A X509Bundle object for the given TrustDomain. + None if the TrustDomain is not found in the set. + """ + with self._lock: + return self._bundles.get(trust_domain.name) + + @property + def bundles(self) -> Set[X509Bundle]: + """Returns the set of all X509Bundles.""" + with self._lock: + return set(self._bundles.values()) + + def put(self, bundle: X509Bundle) -> None: + """Adds a new X509Bundle object or replace an existing one into the set. + + Args: + bundle: The new X509Bundle to put into the set. + """ + with self._lock: + self._bundles[bundle.trust_domain.name] = bundle + + @classmethod + def of(cls, bundle_list: List[X509Bundle]) -> 'X509BundleSet': + """Creates a new initialized X509BundleSet with the given X509Bundle objects keyed by TrustDomain. + + Args: + bundle_list: A list X509Bundle objects to store in the new X509BundleSet. + """ + bundles = {} + for b in bundle_list: + bundles[b.trust_domain] = b + + return X509BundleSet(bundles) diff --git a/spiffe_pkg/config.py b/spiffe_pkg/config.py new file mode 100644 index 000000000..942e41cb7 --- /dev/null +++ b/spiffe_pkg/config.py @@ -0,0 +1,142 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +"""Module that contains Configuration related classes +""" + +import os +import ipaddress +from urllib.parse import ParseResult, urlparse +from typing import Dict, List, Optional, Tuple +from spiffe.errors import ArgumentError + +_SPIFFE_ENDPOINT_SOCKET = 'SPIFFE_ENDPOINT_SOCKET' + + +class Config: + """Represents the configuration for a Workload API client. + + Attributes: + spiffe_endpoint_socket (str): Path to the Workload API UDS. + """ + + def __init__(self, spiffe_endpoint_socket: str) -> None: + """Initializes the Config class. + + Args: + spiffe_endpoint_socket: Path to Workload API UDS. + """ + self.spiffe_endpoint_socket = spiffe_endpoint_socket + + +class ConfigSetter: + """Loads and validates configuration variables.""" + + _FORBIDDEN_SOCKET_COMPONENTS: List[Tuple[str, Optional[str]]] = [ + ('fragment', None), + ('username', None), + ('password', None), + ('query', None), + ] + + _UNIX_FORBIDDEN_SOCKET_COMPONENTS = _FORBIDDEN_SOCKET_COMPONENTS + [ + ('netloc', 'authority') + ] + + _TCP_FORBIDDEN_SOCKET_COMPONENTS = _FORBIDDEN_SOCKET_COMPONENTS + [('path', None)] + + def __init__(self, spiffe_endpoint_socket: Optional[str]) -> None: + """Initializes the ConfigSetter class. + + Args: + spiffe_endpoint_socket: Path to Workload API UDS. If not specified, + the SPIFFE_ENDPOINT_SOCKET environment variable must be set. + + Raises: + ArgumentError: If any configuration variable has an invalid format. + """ + self._apply_default_config() + self._apply_environment_variables() + + if spiffe_endpoint_socket: + self._raw_config[_SPIFFE_ENDPOINT_SOCKET] = spiffe_endpoint_socket + + self._validate() + endpoint_socket = self._raw_config[_SPIFFE_ENDPOINT_SOCKET] + if endpoint_socket is None: + raise ArgumentError('SPIFFE endpoint socket: socket must be set') + self._config = Config(spiffe_endpoint_socket=endpoint_socket) + + def get_config(self) -> Config: + return self._config + + def _apply_default_config(self) -> None: + self._raw_config: Dict[str, Optional[str]] = {_SPIFFE_ENDPOINT_SOCKET: None} + + def _apply_environment_variables(self) -> None: + endpoint_socket = os.environ.get(_SPIFFE_ENDPOINT_SOCKET) + + if endpoint_socket: + self._raw_config[_SPIFFE_ENDPOINT_SOCKET] = endpoint_socket + + def _validate(self) -> None: + endpoint_socket = self._raw_config[_SPIFFE_ENDPOINT_SOCKET] + if not endpoint_socket: + raise ArgumentError('SPIFFE endpoint socket: socket must be set') + + parsed_socket = urlparse(endpoint_socket) + + if not parsed_socket.scheme: + raise ArgumentError('SPIFFE endpoint socket: scheme must be set') + + if parsed_socket.scheme == 'unix': + self._validate_unix_socket(parsed_socket) + elif parsed_socket.scheme == 'tcp': + self._validate_tcp_socket(parsed_socket) + else: + raise ArgumentError('SPIFFE endpoint socket: unsupported scheme') + + @classmethod + def _validate_unix_socket(cls, socket: ParseResult) -> None: + if not socket.path: + raise ArgumentError('SPIFFE endpoint socket: path must be set') + + cls._validate_forbidden_components(socket, cls._UNIX_FORBIDDEN_SOCKET_COMPONENTS) + + @classmethod + def _validate_tcp_socket(cls, socket: ParseResult) -> None: + if socket.hostname is None: + raise ArgumentError('SPIFFE endpoint socket: host must be an IP address') + + try: + ipaddress.ip_address(socket.hostname) + except ValueError: + raise ArgumentError('SPIFFE endpoint socket: host must be an IP address') + + cls._validate_forbidden_components(socket, cls._TCP_FORBIDDEN_SOCKET_COMPONENTS) + + @classmethod + def _validate_forbidden_components( + cls, socket: ParseResult, components: List[Tuple[str, Optional[str]]] + ) -> None: + for component, description in components: + has_component = component in dir(socket) and getattr(socket, component) + if has_component: + raise ArgumentError( + 'SPIFFE endpoint socket: {} is not allowed'.format( + description or component + ) + ) diff --git a/spiffe_pkg/errors.py b/spiffe_pkg/errors.py new file mode 100644 index 000000000..73b9fea35 --- /dev/null +++ b/spiffe_pkg/errors.py @@ -0,0 +1,27 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +""" +This module defines py-spiffe top level exceptions. +""" + + +class PySpiffeError(Exception): + """Top level exception for py-spiffe library.""" + + +class ArgumentError(PySpiffeError): + """Validation error for py-spiffe library.""" diff --git a/spiffe_pkg/py.typed b/spiffe_pkg/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/spiffe_pkg/spiffe_id/__init__.py b/spiffe_pkg/spiffe_id/__init__.py new file mode 100644 index 000000000..eb3bf256e --- /dev/null +++ b/spiffe_pkg/spiffe_id/__init__.py @@ -0,0 +1,3 @@ +""" +spiffe_id Module. Contains information related to SPIFFE Ids. +""" diff --git a/spiffe_pkg/spiffe_id/spiffe_id.py b/spiffe_pkg/spiffe_id/spiffe_id.py new file mode 100644 index 000000000..7927b7e05 --- /dev/null +++ b/spiffe_pkg/spiffe_id/spiffe_id.py @@ -0,0 +1,219 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +import re + +from spiffe.errors import PySpiffeError + +""" +This module manages SpiffeId and TrustDomain objects. +""" + +SCHEME_PREFIX = "spiffe://" + + +class SpiffeIdError(PySpiffeError): + """Custom exception for SpiffeId related errors.""" + + def __init__(self, detail: str, input_value: str = "") -> None: + """ + Initializes a SpiffeIdError with a detailed error message. + + Args: + detail (str): A description of the error. + input_value (str, optional): The input value that caused the error. Defaults to an empty string. + """ + if input_value: + message = f"Invalid SPIFFE ID '{input_value}': {detail}" + else: + message = f"Invalid SPIFFE ID: {detail}" + super().__init__(message) + + +class TrustDomainError(PySpiffeError): + """Custom exception for TrustDomain related errors.""" + + def __init__(self, detail: str, input_value: str = "") -> None: + """ + Initializes a SpiffeIdError with a detailed error message. + + Args: + detail (str): A description of the error. + input_value (str, optional): The input value that caused the error. Defaults to an empty string. + """ + if input_value: + message = f"Invalid trust domain '{input_value}': {detail}" + else: + message = f"Invalid trust domain: {detail}" + super().__init__(message) + + +class TrustDomain: + """ + Represents the name of a SPIFFE Trust Domain. + + The TrustDomain can be initialized with a name or a full SPIFFE ID, from + which the trust domain part is extracted. + + Examples: + >>> td = TrustDomain("example.org") + >>> print(td) + example.org + + >>> td = TrustDomain("spiffe://example.org/service") + >>> print(td) + example.org + """ + + def __init__(self, id_or_name: str) -> None: + self._name = extract_and_validate_trust_domain(id_or_name) + + @property + def name(self) -> str: + return self._name + + def as_spiffe_id(self) -> str: + return f"{SCHEME_PREFIX}{self._name}" + + def __str__(self) -> str: + return self._name + + def __eq__(self, other: object) -> bool: + if isinstance(other, TrustDomain): + return self._name == other._name + elif isinstance(other, str): + return self._name == other + return False + + def __hash__(self) -> int: + return hash(self._name) + + +class SpiffeId: + """ + Represents a SPIFFE Identifier according to the SPIFFE standard. + + A SPIFFE ID is composed of a scheme ('spiffe'), a trust domain, and a path. + It uniquely identifies a workload within a trust domain. The path is + optional and is used to identify specific entities within the trust domain. + + Examples: + Creating a SpiffeId with a path: + >>> id = SpiffeId('spiffe://example.org/service') + >>> print(id) + spiffe://example.org/service + + Creating a SpiffeId without a path: + >>> id = SpiffeId('spiffe://example.org') + >>> print(id) + spiffe://example.org + """ + + def __init__(self, id: str): + if not id: + raise SpiffeIdError("cannot be empty") + + if not id.startswith(SCHEME_PREFIX): + raise SpiffeIdError("does not start with 'spiffe://'", id) + + rest = id[len(SCHEME_PREFIX) :] + path_idx = rest.find("/") + if path_idx == -1: + # No path found; entire `rest` is the trust domain + trust_domain_name = rest + path = "" + else: + trust_domain_name = rest[:path_idx] + path = rest[path_idx:] # Include the leading '/' in the path + + try: + self._trust_domain = TrustDomain(trust_domain_name) + except TrustDomainError as err: + raise SpiffeIdError(str(err), id) + + if path: + try: + self._validate_path(path) + except ValueError as err: + raise SpiffeIdError(str(err), id) + self._path = path + + def __str__(self) -> str: + return f"{SCHEME_PREFIX}{self._trust_domain}{self._path}" + + def __eq__(self, other: object) -> bool: + if isinstance(other, SpiffeId): + return (self._trust_domain, self._path) == ( + other._trust_domain, + other._path, + ) + elif isinstance(other, str): + return str(self) == other + return False + + def __hash__(self) -> int: + return hash((self._trust_domain, self._path)) + + @property + def trust_domain(self) -> TrustDomain: + return self._trust_domain + + @property + def path(self) -> str: + return self._path + + @staticmethod + def _validate_path(path: str) -> None: + if not path.startswith("/"): + raise ValueError("path must start with '/'") + + segments = path.split("/") + for segment in segments[ + 1: + ]: # Skip the first segment since it's empty due to the leading '/' + if not segment: + raise ValueError("path cannot contain empty segments") + if segment in [".", ".."]: + raise ValueError("path segments '.' and '..' are not allowed") + if not re.match(r"^[a-zA-Z0-9._-]+$", segment): + raise ValueError("invalid character in path segment") + + +def extract_and_validate_trust_domain(id_or_name: str) -> str: + if ":/" in id_or_name: + if not id_or_name.startswith(SCHEME_PREFIX): + raise TrustDomainError("ID form does not start with 'spiffe://'", id_or_name) + trust_domain = id_or_name[len(SCHEME_PREFIX) :].split("/", 1)[0] + else: + trust_domain = id_or_name + + # Validate trust domain + if not trust_domain: + raise TrustDomainError("cannot be empty") + + if trust_domain[0] in ['-', '.'] or trust_domain[-1] in ['-', '.']: + raise TrustDomainError("cannot start or end with '-' or '.'", id_or_name) + + if '..' in trust_domain: + raise TrustDomainError("cannot contain consecutive dots", id_or_name) + + if not re.match( + r'^[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]*[a-z0-9])?)*$', + trust_domain, + ): + raise TrustDomainError("contains disallowed characters", id_or_name) + + return trust_domain diff --git a/spiffe_pkg/svid/__init__.py b/spiffe_pkg/svid/__init__.py new file mode 100644 index 000000000..189128c52 --- /dev/null +++ b/spiffe_pkg/svid/__init__.py @@ -0,0 +1,3 @@ +""" +This module manages X509 and JWT SVID objects. +""" diff --git a/spiffe_pkg/svid/errors.py b/spiffe_pkg/svid/errors.py new file mode 100644 index 000000000..5112c0edc --- /dev/null +++ b/spiffe_pkg/svid/errors.py @@ -0,0 +1,82 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +""" +This module defines SVID exceptions. +""" + +from spiffe.errors import PySpiffeError + + +class JwtSvidError(PySpiffeError): + """Exception raised for JWT SVID related errors.""" + + +class InvalidTokenError(JwtSvidError): + """Error raised when provided token is invalid.""" + + +class InvalidClaimError(JwtSvidError): + """Error raised for invalid values in JWT token claims.""" + + def __init__(self, claim: str) -> None: + super().__init__(f'Invalid claim value: {claim}') + + +class MissingClaimError(JwtSvidError): + """Error raised for missing required claims in the JWT token.""" + + def __init__(self, claim: str) -> None: + super().__init__(f'Missing required claim: {claim}') + + +class TokenExpiredError(JwtSvidError): + """Raised when the JWT token is expired.""" + + def __init__(self) -> None: + super().__init__('Token has expired.') + + +class InvalidAlgorithmError(JwtSvidError): + """Error raised for invalid algorithms in JWT token.""" + + def __init__(self, algorithm: str) -> None: + super().__init__(f'Algorithm not supported: {algorithm}') + + +class InvalidTypeError(JwtSvidError): + """Error raised for invalid types in JWT token.""" + + def __init__(self, token_type: str) -> None: + super().__init__(f'Token type not supported: {token_type}') + + +class X509SvidError(PySpiffeError): + """Exception raised for X.509 SVID related errors.""" + + +class InvalidLeafCertificateError(X509SvidError): + """Error raised for invalid leaf certificates in X.509 chain.""" + + def __init__(self, additional_information: str) -> None: + super().__init__(f'Invalid leaf certificate: {additional_information}') + + +class InvalidIntermediateCertificateError(X509SvidError): + """Error raised for invalid intermediate certificates in X.509 chain.""" + + def __init__(self, additional_information: str) -> None: + super().__init__(f'Invalid intermediate certificate: {additional_information}') diff --git a/spiffe_pkg/svid/jwt_svid.py b/spiffe_pkg/svid/jwt_svid.py new file mode 100644 index 000000000..f670f44fb --- /dev/null +++ b/spiffe_pkg/svid/jwt_svid.py @@ -0,0 +1,193 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +""" +This module manages JWT SVID objects. +""" + +import jwt +from jwt import PyJWTError +from typing import Dict, Set, Union +from spiffe.errors import ArgumentError +from cryptography.hazmat.primitives import serialization +from spiffe.spiffe_id.spiffe_id import SpiffeId, SpiffeIdError +from spiffe.bundle.jwt_bundle.jwt_bundle import JwtBundle +from spiffe.bundle.jwt_bundle.errors import AuthorityNotFoundError +from spiffe.svid.jwt_svid_validator import JwtSvidValidator +from spiffe.svid.errors import InvalidTokenError + + +class JwtSvid(object): + """Represents a SPIFFE JWT SVID as defined in the SPIFFE standard. + See `SPIFFE JWT-SVID standard ` + + """ + + def __init__( + self, + spiffe_id: SpiffeId, + audience: Union[str, Set[str]], + expiry: int, + claims: Dict[str, str], + token: str, + ) -> None: + """Creates a JwtSvid instance. + + Args: + spiffe_id: A valid SpiffeId instance. + audience: The intended recipients of JWT-SVID as present in the 'aud' claims. + expiry: Date and time in UTC specifying expiry date of the JwtSvid. + claims: Key-value pairs with all the claims present in the token. + token: Encoded token. + """ + self._spiffe_id = spiffe_id + self._audience = {audience} if isinstance(audience, str) else set(audience) + self._expiry = expiry + self._claims = claims + self._token = token + + @property + def spiffe_id(self) -> SpiffeId: + """Returns the SpiffeId.""" + return self._spiffe_id + + @property + def audience(self) -> Set[str]: + """Returns the Audience.""" + return self._audience + + @property + def expiry(self) -> int: + """Returns the Expiry.""" + return self._expiry + + @property + def token(self) -> str: + """Returns the token.""" + return self._token + + @classmethod + def parse_insecure(cls, token: str, audience: Set[str]) -> 'JwtSvid': + """Parses and validates a JWT-SVID token and returns an instance of a JwtSvid with a SPIFFE ID parsed from the 'sub', audience from 'aud', + and expiry from 'exp' claim. The JWT-SVID signature is not verified. + + Args: + token: A token as a string that is parsed and validated. + audience: Audience is a set of strings used to validate the 'aud' claim. + + Returns: + An instance of JwtSvid with a SPIFFE ID parsed from the 'sub', audience from 'aud', and expiry + from 'exp' claim. + + Raises: + ArgumentError: When the token is blank or cannot be parsed, or in case header is not specified or in case expected_audience is empty or + if the SPIFFE ID in the 'sub' claim doesn't comply with the SPIFFE standard. + InvalidAlgorithmError: In case specified 'alg' is not supported as specified by the SPIFFE standard. + InvalidTypeError: If 'typ' is present in header but is not set to 'JWT' or 'JOSE'. + InvalidClaimError: If a required claim ('exp', 'aud', 'sub') is not present in payload or expected_audience is not a subset of audience_claim. + TokenExpiredError: If token is expired. + InvalidTokenError: If token is malformed and fails to decode. + """ + if not token: + raise ArgumentError('token cannot be empty') + try: + header_params = jwt.get_unverified_header(token) + validator = JwtSvidValidator() + validator.validate_header(header_params) + claims = jwt.decode(token, options={'verify_signature': False}) + validator.validate_claims(claims, audience) + sub_claim = claims.get('sub') + if not sub_claim: + raise InvalidTokenError('JWT token must contain a non-empty \'sub\' claim') + spiffe_id = SpiffeId(sub_claim) + return JwtSvid(spiffe_id, claims['aud'], claims['exp'], claims, token) + except PyJWTError as err: + raise InvalidTokenError(str(err)) from err + + @classmethod + def parse_and_validate( + cls, token: str, jwt_bundle: JwtBundle, audience: Set[str] + ) -> 'JwtSvid': + """Parses and validates a JWT-SVID token and returns an instance of JwtSvid. + + The JWT-SVID signature is verified using the JWT bundle source. + + Args: + token: A token as a string that is parsed and validated. + jwt_bundle: An instance of JwtBundle that provides the JWT authorities to verify the signature. + audience: A set of strings used to validate the 'aud' claim. + + Returns: + An instance of JwtSvid with a SPIFFE ID parsed from the 'sub', audience from 'aud', and expiry + from 'exp' claim. + + Raises: + JwtSvidError: When the token expired or the expiration claim is missing, + when the algorithm is not supported, when the header 'kid' is missing, + when the signature cannot be verified, or + when the 'aud' claim has an audience that is not in the audience list provided as parameter. + ArgumentError: When the token is blank or cannot be parsed. + BundleNotFoundError: If the bundle for the trust domain of the SPIFFE ID from the 'sub' + cannot be found the jwt_bundle_source. + AuthorityNotFoundError: If the authority cannot be found in the bundle using the value from the 'kid' header. + InvalidTokenError: In case token is malformed and fails to decode. + """ + if not token: + raise ArgumentError('token cannot be empty') + + if not jwt_bundle: + raise ArgumentError('jwt_bundle cannot be empty') + try: + header_params = jwt.get_unverified_header(token) + validator = JwtSvidValidator() + validator.validate_header(header_params) + alg = header_params.get('alg') + if not alg: + raise ArgumentError('header alg cannot be empty') + key_id = header_params.get('kid') + signing_key = jwt_bundle.get_jwt_authority(key_id) + if not signing_key: + raise AuthorityNotFoundError(key_id if key_id else '') + + public_key_pem = signing_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode('UTF-8') + + claims = jwt.decode( + token, + algorithms=[alg], + key=public_key_pem, + audience=audience, + options={ + 'verify_signature': True, + 'verify_aud': True, + 'verify_exp': True, + }, + ) + + sub_claim = claims.get('sub') + if not sub_claim: + raise InvalidTokenError('JWT token must contain a non-empty \'sub\' claim') + spiffe_id = SpiffeId(sub_claim) + + return JwtSvid(spiffe_id, claims['aud'], claims['exp'], claims, token) + except PyJWTError as err: + raise InvalidTokenError(str(err)) from err + except ArgumentError as err: + raise InvalidTokenError(str(err)) from err + except SpiffeIdError as err: + raise InvalidTokenError(str(err)) from err diff --git a/spiffe_pkg/svid/jwt_svid_validator.py b/spiffe_pkg/svid/jwt_svid_validator.py new file mode 100644 index 000000000..0a8a5ec21 --- /dev/null +++ b/spiffe_pkg/svid/jwt_svid_validator.py @@ -0,0 +1,169 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +""" +This module manages the validations of JWT tokens. +""" + +import datetime +from typing import Dict, Set + +from spiffe.errors import ArgumentError +from spiffe.svid.errors import ( + TokenExpiredError, + InvalidClaimError, + InvalidAlgorithmError, + InvalidTypeError, + MissingClaimError, +) + +AUDIENCE_NOT_MATCH_ERROR = 'audience does not match expected value' +"""str: audience does not match error message.""" + + +class JwtSvidValidator(object): + """Performs validations on a given token checking compliance to SPIFFE specification. + See `SPIFFE JWT-SVID standard ` + + """ + + _REQUIRED_CLAIMS = ['aud', 'exp', 'sub'] + _SUPPORTED_ALGORITHMS = [ + 'RS256', + 'RS384', + 'RS512', + 'ES256', + 'ES384', + 'ES512', + 'PS256', + 'PS384', + 'PS512', + ] + + _SUPPORTED_TYPES = ['JWT', 'JOSE'] + + def __init__(self) -> None: + pass + + def validate_header(self, parameters: Dict[str, str]) -> None: + """Validates token headers by verifying if headers specifies supported algorithms and token type. + + Type is optional but in case it is present, it must be set to one of the supported values (JWT or JOSE). + + Args: + parameters: Header parameters. + + Returns: + None. + + Raises: + ArgumentError: In case header is not specified. + InvalidAlgorithmError: In case specified 'alg' is not supported as specified by the SPIFFE standard. + InvalidTypeError: In case 'typ' is present in header but is not set to 'JWT' or 'JOSE'. + """ + if not parameters: + raise ArgumentError('header cannot be empty') + + alg = parameters.get('alg') + if not alg: + raise ArgumentError('header alg cannot be empty') + + if alg not in self._SUPPORTED_ALGORITHMS: + raise InvalidAlgorithmError(alg) + + typ = parameters.get('typ') + if typ and typ not in self._SUPPORTED_TYPES: + raise InvalidTypeError(typ) + + def validate_claims(self, payload: Dict[str, object], expected_audience: Set[str]) -> None: + """Validates payload for required claims (aud, exp, sub). + + Args: + payload: Token payload. + expected_audience: Audience as a set of strings used to validate the 'aud' claim. + + Returns: + None + + Raises: + MissingClaimError: In case a required claim is not present. + InvalidClaimError: In case a claim contains an invalid value or expected_audience is not a subset of audience_claim. + TokenExpiredError: In case token is expired. + ArgumentError: In case expected_audience is empty. + """ + for claim in self._REQUIRED_CLAIMS: + if not payload.get(claim): + raise MissingClaimError(claim) + + exp_value = payload.get('exp') + if exp_value is None: + raise MissingClaimError('exp') + if not isinstance(exp_value, (int, float, str)): + raise InvalidClaimError("exp claim must be a numeric value") + try: + numeric_exp = float(exp_value) + except (TypeError, ValueError): + raise InvalidClaimError("exp claim must be a numeric value") + self._validate_exp(numeric_exp) + + aud_claim = payload.get('aud') + if aud_claim is None: + aud_set = set() + elif isinstance(aud_claim, str): + aud_set = {aud_claim} + elif isinstance(aud_claim, (list, set, tuple)): + aud_set = set(aud_claim) + else: + raise InvalidClaimError("aud claim must be a string or list/set/tuple of strings") + self._validate_aud(aud_set, expected_audience) + + @staticmethod + def _validate_exp(expiration_date: float) -> None: + """Verifies expiration. + + Note: If and when https://github.com/jpadilla/pyjwt/issues/599 is fixed, this can be simplified/removed. + + Args: + expiration_date: Date to check if it is expired (numeric timestamp). + + Raises: + TokenExpiredError: In case it is expired. + """ + int_date = int(expiration_date) + utctime = datetime.datetime.now(datetime.timezone.utc).timestamp() + if int_date < utctime: + raise TokenExpiredError() + + @staticmethod + def _validate_aud(audience_claim: Set[str], expected_audience: Set[str]) -> None: + """Verifies if expected_audience is present in audience_claim. The aud claim MUST be present. + + Args: + audience_claim: List of token's audience claim to be validated. + expected_audience: Set of the claims expected to be present in the token's audience claim. + + Raises: + InvalidClaimError: In expected_audience is not a subset of audience_claim or it is empty. + ArgumentError: In case expected_audience is empty. + """ + if not expected_audience: + raise ArgumentError('expected_audience cannot be empty') + + if not audience_claim or all(aud == '' for aud in audience_claim): + raise InvalidClaimError('audience_claim cannot be empty') + + if not all(aud in audience_claim for aud in expected_audience): + raise InvalidClaimError(AUDIENCE_NOT_MATCH_ERROR) diff --git a/spiffe_pkg/svid/x509_svid.py b/spiffe_pkg/svid/x509_svid.py new file mode 100644 index 000000000..79116b48d --- /dev/null +++ b/spiffe_pkg/svid/x509_svid.py @@ -0,0 +1,367 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +from cryptography.x509.oid import ExtensionOID + +from spiffe.spiffe_id import spiffe_id + +""" +This module manages X.509 SVID objects. +""" + +from typing import List +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from cryptography.x509 import Certificate +from spiffe.errors import ArgumentError +from spiffe.spiffe_id.spiffe_id import SpiffeId +from spiffe.svid.errors import ( + InvalidLeafCertificateError, + InvalidIntermediateCertificateError, +) +from spiffe.utils.certificate_utils import ( + parse_der_certificates, + parse_pem_certificates, + load_certificates_bytes_from_file, + load_private_key_from_file, + write_certificates_to_file, + write_private_key_to_file, + parse_pem_private_key, + parse_der_private_key, + PRIVATE_KEY_TYPES, +) + +__all__ = ['X509Svid'] + + +class X509Svid(object): + """ + Represents a SPIFFE X.509-SVID. + + Contains a SpiffeId, a private key and a chain of X.509 certificates. + """ + + def __init__( + self, + spiffe_id: SpiffeId, + cert_chain: List[Certificate], + private_key: PRIVATE_KEY_TYPES, + ) -> None: + """Creates a X509Svid instance. + + Args: + spiffe_id: A SpiffeId instance. + cert_chain: A list representing a chain of X.509 Certificate. + private_key: A Private Key object. + """ + + if not spiffe_id: + raise ArgumentError('spiffe_id cannot be None') + + if not cert_chain: + raise ArgumentError('cert_chain cannot be empty') + + if not private_key: + raise ArgumentError('private_key cannot be None') + + self._spiffe_id = spiffe_id + self._cert_chain = cert_chain + self._private_key = private_key + + @property + def leaf(self) -> Certificate: + """Returns the Leaf X.509 certificate of the chain.""" + return self._cert_chain[0] + + @property + def cert_chain(self) -> List[Certificate]: + """Returns the X.509 chain of certificates.""" + return self._cert_chain.copy() + + @property + def private_key(self) -> PRIVATE_KEY_TYPES: + """Returns the private key.""" + return self._private_key + + @property + def spiffe_id(self) -> SpiffeId: + """Returns the SpiffeId.""" + return self._spiffe_id + + def save( + self, + certs_chain_path: Path | str, + private_key_path: Path | str, + encoding: serialization.Encoding, + ) -> None: + """Saves the X.509 SVID certs chain and private key in PEM or DER encoded files on disk. + + The private key is stored without encryption, but the file is set with filemode = '0600' (only owner has read/write permission). + + Args: + certs_chain_path: Path to the file the chain of certificates will be written to. + The certs_chain file is configured with a filemode = '0644'. + private_key_path: Path the file the private key will be written to. + The private_key file is configured with a filemode = '0600'. + encoding: The encoding used to serialize the certs and private key, can be + serialization.Encoding.PEM or serialization.Encoding.DER. + + Raises: + ArgumentError: In case the encoding is not either PEM or DER (from serialization.Encoding). + X509SvidError: In case the certs chain or the private key in the X509Svid cannot be converted to bytes. + StorePrivateKeyError: In the case there is an error storing the private key to the file. + StoreCertificateError: In the case the file path in certs_chain_path cannot be open to write, + or there is an error storing the certificates to the file. + """ + + if encoding not in [encoding.PEM, encoding.DER]: + raise ArgumentError( + 'Encoding not supported: {}. Expected \'PEM\' or \'DER\''.format(encoding) + ) + + write_certificates_to_file(certs_chain_path, encoding, self._cert_chain) + write_private_key_to_file(private_key_path, encoding, self._private_key) + + @classmethod + def parse_raw(cls, certs_chain_bytes: bytes, private_key_bytes: bytes) -> 'X509Svid': + """Parses the X509-SVID from certificate chain and private key bytes. + + The certificate chain must be ASN.1 DER (concatenated with no intermediate padding if there are more than + one certificate). The private key must be a PKCS#8 ASN.1 DER. + + It is assumed that the leaf certificate is always the first certificate in the parsed chain. + + Args: + certs_chain_bytes: Chain of X.509 certificates in ASN.1 DER format. + private_key_bytes: Private key as PKCS#8 ASN.1 DER. + + Returns: + An instance of a 'X509Svid' containing the chain of certificates, the private key, and the SPIFFE ID of the + leaf certificate in the chain. + + Raises: + ParseCertificateError: In case the chain of certificates cannot be parsed from the cert_chain_bytes. + ParsePrivateKeyError: In case the private key cannot be parsed from the private_key_bytes. + InvalidLeafCertificateError: In case the leaf certificate does not have a SPIFFE ID in the URI SAN, + in case the leaf certificate is CA, + in case the leaf certificate has 'keyCertSign' as key usage, + in case the leaf certificate does not have 'digitalSignature' as key usage, + in case the leaf certificate does not have 'cRLSign' as key usage. + InvalidIntermediateCertificateError: In case one of the intermediate certificates is not CA, + in case one of the intermediate certificates does not have 'keyCertSign' as key usage. + """ + + chain = parse_der_certificates(certs_chain_bytes) + _validate_chain(chain) + + private_key = parse_der_private_key(private_key_bytes) + spiffe_id = _extract_spiffe_id(chain[0]) + + return X509Svid(spiffe_id, chain, private_key) + + @classmethod + def parse(cls, certs_chain_bytes: bytes, private_key_bytes: bytes) -> 'X509Svid': + """Parses the X.509 SVID from PEM blocks containing certificate chain and key bytes. + + The private key must be a PKCS#8 PEM block. + + It is assumed that the leaf certificate is always the first certificate in the parsed chain. + + Args: + certs_chain_bytes: Chain of X.509 certificates in PEM format. + private_key_bytes: Private key as PKCS#8 PEM block. + + Returns: + An instance of a 'X509Svid' containing the chain of certificates, the private key, and the SPIFFE ID of the + leaf certificate in the chain. + + Raises: + ParseCertificateError: In case the chain of certificates cannot be parsed from the cert_chain_bytes. + ParsePrivateKeyError: In case the private key cannot be parsed from the private_key_bytes. + InvalidLeafCertificateError: In case the leaf certificate does not have a SPIFFE ID in the URI SAN, + in case the leaf certificate is CA, + in case the leaf certificate has 'keyCertSign' as key usage, + in case the leaf certificate does not have 'digitalSignature' as key usage, + in case the leaf certificate does not have 'cRLSign' as key usage. + InvalidIntermediateCertificateError: In case one of the intermediate certificates is not CA, + in case one of the intermediate certificates does not have 'keyCertSign' as key usage. + """ + + chain = parse_pem_certificates(certs_chain_bytes) + _validate_chain(chain) + + private_key = parse_pem_private_key(private_key_bytes) + spiffe_id = _extract_spiffe_id(chain[0]) + + return X509Svid(spiffe_id, chain, private_key) + + @classmethod + def load( + cls, + certs_chain_path: Path | str, + private_key_path: Path | str, + encoding: serialization.Encoding, + ) -> 'X509Svid': + """Loads the X.509 SVID from PEM or DER encoded files on disk. + + The private key should be without encryption. + + Args: + certs_chain_path: Path to the file containing one or more X.509 certificates as PEM blocks. + private_key_path: Path the file containing a private key as PKCS#8 PEM block. + encoding: The encoding used to serialize the certs and private key, can be + serialization.Encoding.PEM or serialization.Encoding.DER. + + Returns: + An instance of a 'X509Svid' containing the chain of certificates, the private key, and the SPIFFE ID of the + leaf certificate in the chain. + + Raises: + ArgumentError: In case the encoding is not either PEM or DER (from serialization.Encoding). + X509SvidError: In case the file path in certs_chain_path or in private_key_path does not exists or cannot be open. + ParseCertificateError: In case the chain of certificates cannot be parsed from the bytes read from certs_chain_path. + ParsePrivateKeyError: In case the private key cannot be parsed from the bytes read from private_key_path. + InvalidLeafCertificateError: In case the leaf certificate does not have a SPIFFE ID in the URI SAN, + in case the leaf certificate is CA, + in case the leaf certificate has 'keyCertSign' as key usage, + in case the leaf certificate does not have 'digitalSignature' as key usage, + in case the leaf certificate does not have 'cRLSign' as key usage. + InvalidIntermediateCertificateError: In case one of the intermediate certificates is not CA, + in case one of the intermediate certificates does not have 'keyCertSign' as key usage. + """ + + chain_bytes = load_certificates_bytes_from_file(certs_chain_path) + key_bytes = load_private_key_from_file(private_key_path) + + if encoding == serialization.Encoding.PEM: + return cls.parse(chain_bytes, key_bytes) + + if encoding == serialization.Encoding.DER: + return cls.parse_raw(chain_bytes, key_bytes) + + raise ArgumentError( + 'Encoding not supported: {}. Expected \'PEM\' or \'DER\''.format(encoding) + ) + + +def _extract_spiffe_id(cert: x509.Certificate) -> SpiffeId: + try: + ext = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME) + except x509.ExtensionNotFound as e: + raise InvalidLeafCertificateError( + "Certificate does not contain a SubjectAlternativeName extension" + ) from e + + san_value = ext.value + if not isinstance(san_value, x509.SubjectAlternativeName): + raise InvalidLeafCertificateError( + "Certificate does not contain a valid SubjectAlternativeName extension" + ) + + san = san_value + uri_sans = san.get_values_for_type(x509.UniformResourceIdentifier) + + # SPIFFE X.509-SVID: MUST contain exactly one URI SAN, and it MUST be a SPIFFE ID. + if len(uri_sans) == 0: + raise InvalidLeafCertificateError( + "Certificate does not contain a URI SAN (expected exactly one SPIFFE ID)" + ) + + if len(uri_sans) != 1: + raise InvalidLeafCertificateError( + "Certificate contains multiple URI SAN entries (expected exactly one SPIFFE ID)" + ) + + uri = uri_sans[0] + if not uri.startswith(spiffe_id.SCHEME_PREFIX): + raise InvalidLeafCertificateError("Certificate URI SAN is not a SPIFFE ID") + + try: + return SpiffeId(uri) + except ArgumentError as e: + raise InvalidLeafCertificateError( + f"Certificate contains a malformed SPIFFE ID in the URI SAN: {uri!r}" + ) from e + + +def _validate_chain(cert_chain: List[Certificate]) -> None: + leaf = cert_chain[0] + _validate_leaf_certificate(leaf) + + for cert in cert_chain[1:]: + _validate_intermediate_certificate(cert) + + +def _validate_leaf_certificate(leaf: Certificate) -> None: + try: + basic_constraints = leaf.extensions.get_extension_for_oid( + ExtensionOID.BASIC_CONSTRAINTS + ).value + except x509.ExtensionNotFound: + raise InvalidLeafCertificateError( + 'Leaf certificate must have BasicConstraints extension' + ) + + if isinstance(basic_constraints, x509.BasicConstraints) and basic_constraints.ca: + raise InvalidLeafCertificateError('Leaf certificate must not have CA flag set to true') + + try: + key_usage = leaf.extensions.get_extension_for_oid(ExtensionOID.KEY_USAGE).value + except x509.ExtensionNotFound: + raise InvalidLeafCertificateError('Leaf certificate must have KeyUsage extension') + + if isinstance(key_usage, x509.KeyUsage) and not key_usage.digital_signature: + raise InvalidLeafCertificateError( + 'Leaf certificate must have \'digitalSignature\' as key usage' + ) + if isinstance(key_usage, x509.KeyUsage) and key_usage.key_cert_sign: + raise InvalidLeafCertificateError( + 'Leaf certificate must not have \'keyCertSign\' as key usage' + ) + if isinstance(key_usage, x509.KeyUsage) and key_usage.crl_sign: + raise InvalidLeafCertificateError( + 'Leaf certificate must not have \'cRLSign\' as key usage' + ) + + +def _validate_intermediate_certificate(cert: Certificate) -> None: + try: + basic_constraints = cert.extensions.get_extension_for_oid( + ExtensionOID.BASIC_CONSTRAINTS + ).value + except x509.ExtensionNotFound: + raise InvalidIntermediateCertificateError( + 'Intermediate certificate must have BasicConstraints extension' + ) + + if isinstance(basic_constraints, x509.BasicConstraints) and not basic_constraints.ca: + raise InvalidIntermediateCertificateError( + 'Signing certificate must have CA flag set to true' + ) + + try: + key_usage = cert.extensions.get_extension_for_oid(ExtensionOID.KEY_USAGE).value + except x509.ExtensionNotFound: + raise InvalidIntermediateCertificateError( + 'Intermediate certificate must have KeyUsage extension' + ) + + if isinstance(key_usage, x509.KeyUsage) and not key_usage.key_cert_sign: + raise InvalidIntermediateCertificateError( + 'Signing certificate must have \'keyCertSign\' as key usage' + ) diff --git a/spiffe_pkg/utils/__init__.py b/spiffe_pkg/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/spiffe_pkg/utils/certificate_utils.py b/spiffe_pkg/utils/certificate_utils.py new file mode 100644 index 000000000..2a1e3dd19 --- /dev/null +++ b/spiffe_pkg/utils/certificate_utils.py @@ -0,0 +1,284 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +from typing import List, Iterable, Union +from pathlib import Path +import os +import pem +from cryptography import x509 +from cryptography.hazmat.primitives.asymmetric import ( + ed25519, + ed448, + rsa, + ec, + dsa, + dh, + x25519, + x448, +) +from cryptography.hazmat.primitives.serialization import ( + load_der_private_key, + load_pem_private_key, +) +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.x509 import Certificate +from pyasn1.codec.der.decoder import decode +from pyasn1.codec.der.encoder import encode +from pyasn1_modules.rfc5280 import Certificate as Pyasn1Certificate # type: ignore[import-untyped] +from spiffe.utils.errors import ( + X509CertificateError, + ParseCertificateError, + LoadCertificateError, + StoreCertificateError, + ParsePrivateKeyError, + LoadPrivateKeyError, + StorePrivateKeyError, +) + +_CERTS_FILE_MODE = 0o644 +_PRIVATE_KEY_FILE_MODE = 0o600 + +PRIVATE_KEY_TYPES = Union[ + dh.DHPrivateKey, + ed25519.Ed25519PrivateKey, + ed448.Ed448PrivateKey, + rsa.RSAPrivateKey, + dsa.DSAPrivateKey, + ec.EllipticCurvePrivateKey, + x25519.X25519PrivateKey, + x448.X448PrivateKey, +] + + +def parse_pem_certificates(pem_bytes: bytes) -> List[Certificate]: + """Parses a list of certificates from PEM bytes. + + Args: + pem_bytes: List of X.509 certificates as PEM blocks bytes. + + Returns: + A list of Certificate objects. + + Raises: + ParseCertificateError: In case the certificates cannot be parsed from the pem_bytes. + """ + + parsed_certs = pem.parse(pem_bytes) + if not parsed_certs: + raise ParseCertificateError('Unable to parse PEM X.509 certificate') + + try: + return [ + x509.load_pem_x509_certificate(x509_cert.as_bytes(), default_backend()) + for x509_cert in parsed_certs + ] + except Exception as err: + raise ParseCertificateError('Unable to parse PEM X.509 certificate') from err + + +def parse_der_certificates(der_bytes: bytes) -> List[Certificate]: + """Parses a list of certificates from ASN.1 DER bytes. + + Args: + der_bytes: List of X.509 certificates as ASN.1 DER bytes. + + Returns: + A list of Certificate objects. + + Raises: + ParseCertificateError: In case the certificates cannot be parsed from the der_bytes. + """ + + try: + result = [] + cert, remaining_data = decode(der_bytes, Pyasn1Certificate()) + result.append(x509.load_der_x509_certificate(encode(cert))) + while len(remaining_data) > 0: + cert, remaining_data = decode(remaining_data, Pyasn1Certificate()) + result.append(x509.load_der_x509_certificate(encode(cert))) + return result + except Exception as err: + raise ParseCertificateError('Unable to parse DER X.509 certificate') from err + + +def load_certificates_bytes_from_file(certificates_file_path: Path | str) -> bytes: + """Loads bytes from file path. + + Args: + certificates_file_path: Path to the file containing the certificates. + + Returns: + Bytes read from the file specified. + + Raises: + X509CertificateError: In case the certificates_file cannot not be found or read. + """ + + try: + return _load_bytes_from_file(certificates_file_path) + except FileNotFoundError: + raise LoadCertificateError('File not found: {}'.format(certificates_file_path)) + except Exception as err: + raise LoadCertificateError('File could not be read: {}'.format(str(err))) from err + + +def write_certificates_to_file( + certs_file_path: Path | str, + encoding: serialization.Encoding, + certificates: Iterable[Certificate], +) -> None: + """Writes certificates to a file. + + Args: + certs_file_path: Path to the file the certificates will be written to. + encoding: The serialization format used to encode the certificates. Can be 'PEM' or 'DER'. + certificates: Iterable of certificate objects to be saved to file. + + Raises: + StoreCertificateError: In case a certificate cannot be saved to file. + """ + + try: + with open(certs_file_path, 'wb') as certs_file: + os.chmod(certs_file.name, _CERTS_FILE_MODE) + for cert in certificates: + cert_bytes = serialize_certificate(cert, encoding) + certs_file.write(cert_bytes) + except Exception as err: + raise StoreCertificateError(str(err)) from err + + +def serialize_certificate(certificate: Certificate, encoding: serialization.Encoding) -> bytes: + """Serializes an X.509 certificate using the specified encoding. + + Args: + certificate: Certificate object to be serialized to bytes. + encoding: The serialization format to use to save the certificate. + + Raises: + X509CertificateError: In case it cannot get the bytes from the certificate object. + """ + try: + cert_bytes = certificate.public_bytes(encoding) + except Exception as err: + raise X509CertificateError( + 'Could not serialize certificate from bytes: {}'.format(str(err)) + ) from err + + return cert_bytes + + +def load_private_key_from_file(private_key_path: Path | str) -> bytes: + """Loads bytes from file path. + + Args: + private_key_path: Path to the file containing the private key. + + Returns: + Bytes read from the file specified. + + Raises: + LoadPrivateKeyError: In case the private_key_path cannot not be found or read. + """ + + try: + return _load_bytes_from_file(private_key_path) + except FileNotFoundError: + raise LoadPrivateKeyError('File not found: {}'.format(private_key_path)) + except Exception as err: + raise LoadPrivateKeyError('File could not be read: {}'.format(str(err))) from err + + +def write_private_key_to_file( + private_key_path: Path | str, + encoding: serialization.Encoding, + private_key: PRIVATE_KEY_TYPES, +) -> None: + """Writes private key to a file. + + Args: + private_key_path: Path to the file containing the private key. + encoding: The serialization format used to encode the private key. Can be 'PEM' or 'DER'. + private_key: Private key objects to be saved to file. + + Raises: + StorePrivateKeyError: In case the private key cannot be saved to file. + """ + try: + private_key_bytes = _extract_private_key_bytes(encoding, private_key) + + with open(private_key_path, 'wb') as private_key_file: + os.chmod(private_key_file.name, _PRIVATE_KEY_FILE_MODE) + private_key_file.write(private_key_bytes) + except Exception as err: + raise StorePrivateKeyError(str(err)) from err + + +def parse_der_private_key(der_bytes: bytes) -> PRIVATE_KEY_TYPES: + """Parses a private key from ASN.1 bytes. + + Args: + der_bytes: A private Key as ASN.1 DER bytes. + + Returns: + A private key object. + + Raises: + ParsePrivateKeyError: In case the private key cannot be parsed from the der_bytes. + """ + try: + return load_der_private_key(der_bytes, None, None) + except Exception as err: + raise ParsePrivateKeyError(str(err)) from err + + +def parse_pem_private_key(pem_bytes: bytes) -> PRIVATE_KEY_TYPES: + """Parses a private key from PEM bytes. + + Args: + pem_bytes: A private Key as PEM blocks bytes. + + Returns: + A private key object. + + Raises: + ParsePrivateKeyError: In case the private key cannot be parsed from the pem_bytes. + """ + try: + return load_pem_private_key(pem_bytes, None, None) + except Exception as err: + raise ParsePrivateKeyError(str(err)) from err + + +def _load_bytes_from_file(file_path: Path | str) -> bytes: + with open(file_path, 'rb') as file: + return file.read() + + +def _extract_private_key_bytes( + encoding: serialization.Encoding, private_key: PRIVATE_KEY_TYPES +) -> bytes: + try: + return private_key.private_bytes( + encoding, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + except Exception as err: + raise Exception( + 'Could not serialize private key from bytes: {}'.format(str(err)) + ) from err diff --git a/spiffe_pkg/utils/errors.py b/spiffe_pkg/utils/errors.py new file mode 100644 index 000000000..b1836f8a2 --- /dev/null +++ b/spiffe_pkg/utils/errors.py @@ -0,0 +1,63 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +from spiffe.errors import PySpiffeError + + +class X509CertificateError(PySpiffeError): + """Exception raised for issues related to X.509 certificate processing.""" + + +class ParseCertificateError(X509CertificateError): + """Error raised when unable to parse an X.509 certificate from bytes.""" + + def __init__(self, detail: str) -> None: + super().__init__(f'Error parsing certificate: {detail}') + + +class LoadCertificateError(X509CertificateError): + """Error raised when an X.509 certificate cannot be loaded from disk.""" + + def __init__(self, detail: str) -> None: + super().__init__(f'Error loading certificate from file: {detail}') + + +class StoreCertificateError(X509CertificateError): + """Error raised when an X.509 certificate cannot be saved to disk.""" + + def __init__(self, detail: str) -> None: + super().__init__(f'Error saving certificate to file: {detail}') + + +class ParsePrivateKeyError(X509CertificateError): + """Error raised when unable to parse a private key from bytes.""" + + def __init__(self, detail: str) -> None: + super().__init__(f'Error parsing private key: {detail}') + + +class LoadPrivateKeyError(X509CertificateError): + """Error raised when a private key cannot be loaded from disk.""" + + def __init__(self, detail: str) -> None: + super().__init__(f'Error loading private key from file: {detail}') + + +class StorePrivateKeyError(X509CertificateError): + """Error raised when a private key cannot be saved to disk.""" + + def __init__(self, detail: str) -> None: + super().__init__(f'Error saving private key to file: {detail}') diff --git a/spiffe_pkg/workloadapi/__init__.py b/spiffe_pkg/workloadapi/__init__.py new file mode 100644 index 000000000..aa5876552 --- /dev/null +++ b/spiffe_pkg/workloadapi/__init__.py @@ -0,0 +1,3 @@ +""" +workloadapi Module. Contains information related to the Workload API Client. +""" diff --git a/spiffe_pkg/workloadapi/errors.py b/spiffe_pkg/workloadapi/errors.py new file mode 100644 index 000000000..cba966493 --- /dev/null +++ b/spiffe_pkg/workloadapi/errors.py @@ -0,0 +1,74 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +""" +This module defines Workload API exceptions. +""" + +from spiffe.errors import PySpiffeError + + +class WorkloadApiError(PySpiffeError): + """Exception for errors related to the Workload API.""" + + +class FetchX509SvidError(WorkloadApiError): + """Error raised when fetching X.509 SVIDs fails.""" + + def __init__(self, detail: str) -> None: + super().__init__(f'Error fetching X.509 SVID: {detail}') + + +class FetchX509BundleError(WorkloadApiError): + """Error raised during X.509 Bundle fetching.""" + + def __init__(self, detail: str) -> None: + super().__init__(f'Error fetching X.509 Bundle: {detail}') + + +class FetchJwtSvidError(WorkloadApiError): + """Error raised during JWT SVID fetching.""" + + def __init__(self, detail: str = 'none') -> None: + super().__init__(f'Error fetching JWT SVID: {detail}') + + +class FetchJwtBundleError(WorkloadApiError): + """Error raised during JWT Bundle fetching.""" + + def __init__(self, detail: str = 'none') -> None: + super().__init__(f'Error fetching JWT Bundle: {detail}') + + +class ValidateJwtSvidError(WorkloadApiError): + """Error raised when validating a JWT-SVID fails.""" + + def __init__(self, detail: str = 'none') -> None: + super().__init__(f'JWT SVID is not valid: {detail}') + + +class X509SourceError(WorkloadApiError): + """Error related to the X.509 Source.""" + + def __init__(self, detail: str) -> None: + super().__init__(f'X.509 Source error: {detail}') + + +class JwtSourceError(WorkloadApiError): + """Error related to the JWT Source.""" + + def __init__(self, detail: str) -> None: + super().__init__(f'JWT Source error: {detail}') diff --git a/spiffe_pkg/workloadapi/grpc/__init__.py b/spiffe_pkg/workloadapi/grpc/__init__.py new file mode 100644 index 000000000..331eacd2b --- /dev/null +++ b/spiffe_pkg/workloadapi/grpc/__init__.py @@ -0,0 +1,3 @@ +""" +grpc Module. Contains logic related to the grpc connections. +""" diff --git a/spiffe_pkg/workloadapi/grpc/generic_client_interceptor.py b/spiffe_pkg/workloadapi/grpc/generic_client_interceptor.py new file mode 100644 index 000000000..6d7c35c9a --- /dev/null +++ b/spiffe_pkg/workloadapi/grpc/generic_client_interceptor.py @@ -0,0 +1,100 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +"""Base class for interceptors that operate on all RPC types.""" + +from typing import Callable, Iterator, Optional, Protocol, TypeVar + +import grpc + +_TRequest = TypeVar("_TRequest") +_TResponse = TypeVar("_TResponse") + + +class _InterceptorFn(Protocol): + def __call__( + self, + client_call_details: grpc.ClientCallDetails, + request_iterator: Iterator[_TRequest], + request_streaming: bool, + response_streaming: bool, + ) -> tuple[ + grpc.ClientCallDetails, + Iterator[_TRequest], + Optional[Callable[[_TResponse], _TResponse]], + ]: ... + + +class _GenericClientInterceptor( + grpc.UnaryUnaryClientInterceptor, + grpc.UnaryStreamClientInterceptor, + grpc.StreamUnaryClientInterceptor, + grpc.StreamStreamClientInterceptor, +): + def __init__(self, interceptor_function: _InterceptorFn) -> None: + self._fn = interceptor_function + + def intercept_unary_unary( + self, + continuation: Callable[[grpc.ClientCallDetails, _TRequest], _TResponse], + client_call_details: grpc.ClientCallDetails, + request: _TRequest, + ) -> _TResponse: + new_details, new_request_iterator, postprocess = self._fn( + client_call_details, iter((request,)), False, False + ) + response = continuation(new_details, next(new_request_iterator)) + return postprocess(response) if postprocess else response + + def intercept_unary_stream( + self, + continuation: Callable[[grpc.ClientCallDetails, _TRequest], _TResponse], + client_call_details: grpc.ClientCallDetails, + request: _TRequest, + ) -> _TResponse: + new_details, new_request_iterator, postprocess = self._fn( + client_call_details, iter((request,)), False, True + ) + response_it = continuation(new_details, next(new_request_iterator)) + return postprocess(response_it) if postprocess else response_it + + def intercept_stream_unary( + self, + continuation: Callable[[grpc.ClientCallDetails, Iterator[_TRequest]], _TResponse], + client_call_details: grpc.ClientCallDetails, + request_iterator: Iterator[_TRequest], + ) -> _TResponse: + new_details, new_request_iterator, postprocess = self._fn( + client_call_details, request_iterator, True, False + ) + response = continuation(new_details, new_request_iterator) + return postprocess(response) if postprocess else response + + def intercept_stream_stream( + self, + continuation: Callable[[grpc.ClientCallDetails, Iterator[_TRequest]], _TResponse], + client_call_details: grpc.ClientCallDetails, + request_iterator: Iterator[_TRequest], + ) -> _TResponse: + new_details, new_request_iterator, postprocess = self._fn( + client_call_details, request_iterator, True, True + ) + response_it = continuation(new_details, new_request_iterator) + return postprocess(response_it) if postprocess else response_it + + +def create(intercept_call: _InterceptorFn) -> _GenericClientInterceptor: + return _GenericClientInterceptor(intercept_call) diff --git a/spiffe_pkg/workloadapi/grpc/header_manipulator_client_interceptor.py b/spiffe_pkg/workloadapi/grpc/header_manipulator_client_interceptor.py new file mode 100644 index 000000000..4faf0e6b0 --- /dev/null +++ b/spiffe_pkg/workloadapi/grpc/header_manipulator_client_interceptor.py @@ -0,0 +1,62 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +"""Interceptor that adds headers to outgoing requests.""" + +import collections +from typing import Iterator, TypeVar + +import grpc + +from spiffe.workloadapi.grpc import generic_client_interceptor + +_TRequest = TypeVar("_TRequest") + + +class _ClientCallDetails( + collections.namedtuple( + '_ClientCallDetails', ('method', 'timeout', 'metadata', 'credentials') + ), + grpc.ClientCallDetails, +): + pass + + +def header_adder_interceptor(header: str, value: str) -> grpc.StreamStreamClientInterceptor: + def intercept_call( + client_call_details: grpc.ClientCallDetails, + request_iterator: Iterator[_TRequest], + request_streaming: bool, + response_streaming: bool, + ) -> tuple[grpc.ClientCallDetails, Iterator[_TRequest], None]: + metadata = [] + if client_call_details.metadata is not None: + metadata = list(client_call_details.metadata) + metadata.append( + ( + header, + value, + ) + ) + client_call_details = _ClientCallDetails( + client_call_details.method, + client_call_details.timeout, + metadata, + client_call_details.credentials, + ) + return client_call_details, request_iterator, None + + return generic_client_interceptor.create(intercept_call) diff --git a/spiffe_pkg/workloadapi/handle_error.py b/spiffe_pkg/workloadapi/handle_error.py new file mode 100644 index 000000000..fe284efc1 --- /dev/null +++ b/spiffe_pkg/workloadapi/handle_error.py @@ -0,0 +1,60 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +from typing import Callable, Type, TypeVar, ParamSpec +import grpc +import functools + +from spiffe.errors import PySpiffeError, ArgumentError +from spiffe.workloadapi.errors import WorkloadApiError + +DEFAULT_WL_API_ERROR_MESSAGE = 'Could not process response from the Workload API' + +_P = ParamSpec("_P") +_R = TypeVar("_R") + + +def handle_error( + error_cls: Type[PySpiffeError], +) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: + def handler(func: Callable[_P, _R]) -> Callable[_P, _R]: + @functools.wraps(func) + def wrapper(*args: _P.args, **kw: _P.kwargs) -> _R: + try: + return func(*args, **kw) + except WorkloadApiError as we: + raise we + except ArgumentError as ae: + raise ae + except PySpiffeError as pe: + # Avoid double-wrapping if it's already the expected error type + if isinstance(pe, error_cls): + raise pe + raise error_cls(str(pe)) from pe + except grpc.RpcError as rpc_error: + if isinstance(rpc_error, grpc.Call): + details = rpc_error.details() + code = rpc_error.code() + raise error_cls( + f'{DEFAULT_WL_API_ERROR_MESSAGE}: {details} ({code})' + ) from rpc_error + raise error_cls(DEFAULT_WL_API_ERROR_MESSAGE) from rpc_error + except Exception as e: + raise error_cls(str(e)) from e + + return wrapper + + return handler diff --git a/spiffe_pkg/workloadapi/jwt_source.py b/spiffe_pkg/workloadapi/jwt_source.py new file mode 100644 index 000000000..57a7a841b --- /dev/null +++ b/spiffe_pkg/workloadapi/jwt_source.py @@ -0,0 +1,270 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +import logging +import threading +from typing import Optional, Set, Callable, List, FrozenSet + +from spiffe.spiffe_id.spiffe_id import SpiffeId +from spiffe.bundle.jwt_bundle.jwt_bundle import JwtBundle +from spiffe.bundle.jwt_bundle.jwt_bundle_set import JwtBundleSet +from spiffe.spiffe_id.spiffe_id import TrustDomain +from spiffe.svid.jwt_svid import JwtSvid +from spiffe.workloadapi.workload_api_client import WorkloadApiClient, StreamCancelHandler +from spiffe.workloadapi.errors import JwtSourceError +from spiffe.errors import ArgumentError + +""" +This module defines the default source implementation for JWT Bundles and SVIDs. +""" + +_logger = logging.getLogger(__name__) + +__all__ = ['JwtSource'] + + +class JwtSource: + """ + JWTSource acts as a source for JWT-SVIDs and JWT bundles, automatically maintained through + updates from the Workload API. + """ + + def __init__( + self, + workload_api_client: Optional[WorkloadApiClient] = None, + socket_path: Optional[str] = None, + timeout_in_seconds: Optional[float] = None, + ) -> None: + """Creates a new JwtSource. + + It blocks until the initial update has been received from the Workload API or until timeout_in_seconds is reached. + In case the underlying Workload API connection returns an unretryable error, the source will be closed and + no methods on the source will be available. + + Args: + workload_api_client: A WorkloadApiClient object that will be used to fetch the JWT materials from the Workload API. + If not provided, a default client will be created and owned by this source; the source + will close it when the source is closed. If a client is provided, the caller retains + ownership and is responsible for closing it; the source will not close a client it + does not own. + socket_path: Path to Workload API UDS. This will be used in case a the workload_api_client is not provided. + If not specified, the SPIFFE_ENDPOINT_SOCKET environment variable will be used and thus, must be set. + timeout_in_seconds: Time to wait for the first update of the Workload API. If not provided, and + the connection with the Workload API fails, it will block indefinitely while + the connection is retried. + + Returns: + JwtSource: New DefaultJwtSource object, initialized with the JwtBundleSet fetched from the Workload API. + + Raises: + ArgumentError: If spiffe_socket_path is invalid or not provided and SPIFFE_ENDPOINT_SOCKET env variable is not set. + JwtSourceError: In case a timeout was configured and it was reached during the source initialization waiting + for the first update from the Workload API. + """ + + self._initialization_event = threading.Event() + self._error: Optional[Exception] = None + self._closed = False + self._lock = threading.Lock() + self._subscribers: List[Callable[[], None]] = [] + self._subscribers_lock = threading.Lock() + + # Track ownership: if we create the client, we own it + self._owns_client = workload_api_client is None + self._workload_api_client = ( + workload_api_client if workload_api_client else WorkloadApiClient(socket_path) + ) + self._client_cancel_handler: Optional[StreamCancelHandler] = None + + # Start the watcher in a separate thread + threading.Thread(target=self._start_watcher, daemon=True).start() + + # Wait for the first update or an error + initialized = self._initialization_event.wait(timeout=timeout_in_seconds) + + if not initialized: + self._closed = True + raise JwtSourceError( + "Failed to initialize JwtSource: Timeout waiting for the first update." + ) + + if self._error is not None: + self._closed = True + raise JwtSourceError(f"Failed to create JwtSource: {self._error}") from self._error + + @property + def bundles(self) -> FrozenSet[JwtBundle]: + """Returns the set of all JwtBundles.""" + with self._lock: + if self._error is not None: + raise JwtSourceError( + f'Cannot get Jwt Bundles: source has error: {self._error}' + ) + if self._closed: + raise JwtSourceError('Cannot get Jwt Bundles: source is closed') + return frozenset(self._jwt_bundle_set.bundles) + + def fetch_svid(self, audience: Set[str], subject: Optional[SpiffeId] = None) -> JwtSvid: + """Fetches an JWT-SVID from the source. + + Args: + audience: List of audiences for the JWT SVID. + subject: SPIFFE ID subject for the JWT. + + Raises: + ArgumentError: In case audiences is empty. + FetchJwtSvidError: In case there is an error in fetching the JWT-SVID from the Workload API. + """ + if not audience: + raise ArgumentError('Audience cannot be empty') + + jwt_svid = self._workload_api_client.fetch_jwt_svid(audience, subject) + return jwt_svid + + def fetch_svids( + self, audiences: Set[str], subject: Optional[SpiffeId] = None + ) -> List[JwtSvid]: + """Fetches all JWT-SVIDs from the source. + + Args: + audiences: List of audiences for the JWT SVID. + subject: SPIFFE ID subject for the JWT. + + Raises: + ArgumentError: In case audiences is empty. + FetchJwtSvidError: In case there is an error in fetching the JWT-SVID from the Workload API. + """ + if not audiences: + raise ArgumentError('Audience cannot be empty') + + jwt_svid = self._workload_api_client.fetch_jwt_svids(audiences, subject) + return jwt_svid + + def get_bundle_for_trust_domain(self, trust_domain: TrustDomain) -> Optional[JwtBundle]: + """Returns the JWT bundle for the given trust domain. + + Raises: + JwtSourceError: In case this JWT Source is closed. + """ + with self._lock: + if self._error is not None: + raise JwtSourceError(f'Cannot get JWT Bundle: source has error: {self._error}') + if self._closed: + raise JwtSourceError('Cannot get JWT Bundle: source is closed') + return self._jwt_bundle_set.get_bundle_for_trust_domain(trust_domain) + + def close(self) -> None: + """Closes this JwtSource closing the underlying connection with the Workload API. Once the source is closed, + no methods can be called on it. + + IMPORTANT: client code must call this method when the JwtSource is not needed anymore as the connection with Workload API will + only be closed when this method is invoked. + """ + _logger.info("Closing JWT Source") + with self._lock: + if self._closed: + return + try: + if self._client_cancel_handler: + self._client_cancel_handler.cancel() + except Exception as err: + _logger.exception( + 'JWT Source: Exception canceling the Workload API client connection: {}'.format( + str(err) + ) + ) + self._closed = True + + if self._owns_client: + try: + self._workload_api_client.close() + except Exception as err: + _logger.exception( + 'Exception closing owned Workload API client: {}'.format(str(err)) + ) + + def is_closed(self) -> bool: + """Checks if the source has been closed, disallowing further operations.""" + with self._lock: + return self._closed + + def subscribe_for_updates(self, callback: Callable[[], None]) -> None: + """ + Allows clients to register a callback function for updates on the source. + + Args: + callback (Callable[[], None]): The callback function to register. + """ + with self._subscribers_lock: + self._subscribers.append(callback) + + def unsubscribe_for_updates(self, callback: Callable[[], None]) -> None: + """ + Allows clients to unregister a previously registered callback function. + + Args: + callback (Callable[[], None]): The callback function to unregister. + """ + with self._subscribers_lock: + try: + self._subscribers.remove(callback) + except ValueError: + pass + + def _start_watcher(self) -> None: + self._client_cancel_handler = self._workload_api_client.stream_jwt_bundles( + self._set_jwt_bundle_set, self._on_error + ) + + def _set_jwt_bundle_set(self, jwt_bundle_set: JwtBundleSet) -> None: + _logger.debug('JWT Source: setting new bundle update') + with self._lock: + self._jwt_bundle_set = jwt_bundle_set + + # Signal that the JwtSource has been successfully initialized + self._initialization_event.set() + self._notify_subscribers() + + def _notify_subscribers(self) -> None: + with self._subscribers_lock: + subscribers = list(self._subscribers) + for callback in subscribers: + try: + callback() + except Exception as err: + _logger.exception(f"An error occurred while notifying a subscriber: {err}") + + def _on_error(self, error: Exception) -> None: + self._log_error(error) + with self._lock: + self._error = error + self._closed = True + try: + if self._client_cancel_handler: + self._client_cancel_handler.cancel() + except Exception as err: + _logger.exception(f"Exception canceling stream on error: {err}") + self._initialization_event.set() + + @staticmethod + def _log_error(err: Exception) -> None: + _logger.error(f"JWT Source Error: {err}") + + def __enter__(self) -> 'JwtSource': + return self + + def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None: + self.close() diff --git a/spiffe_pkg/workloadapi/workload_api_client.py b/spiffe_pkg/workloadapi/workload_api_client.py new file mode 100644 index 000000000..9d1171c07 --- /dev/null +++ b/spiffe_pkg/workloadapi/workload_api_client.py @@ -0,0 +1,692 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +from __future__ import annotations + +import logging +import os +import threading +from typing import Optional, List, Mapping, Callable, Dict, Set, Iterator, Protocol, TypeVar + +import grpc +from grpc import StatusCode + +from spiffe.bundle.jwt_bundle.jwt_bundle import JwtBundle +from spiffe.bundle.jwt_bundle.jwt_bundle_set import JwtBundleSet +from spiffe.bundle.x509_bundle.x509_bundle import X509Bundle +from spiffe.bundle.x509_bundle.x509_bundle_set import X509BundleSet +from spiffe.config import ConfigSetter +from spiffe.errors import ArgumentError +from spiffe._proto import ( + workload_pb2, +) +from spiffe._proto import workload_pb2_grpc +from spiffe.spiffe_id.spiffe_id import SpiffeId +from spiffe.spiffe_id.spiffe_id import TrustDomain +from spiffe.svid.jwt_svid import JwtSvid +from spiffe.svid.x509_svid import X509Svid +from spiffe.workloadapi.errors import ( + FetchX509SvidError, + FetchX509BundleError, + FetchJwtSvidError, + FetchJwtBundleError, + ValidateJwtSvidError, + WorkloadApiError, +) +from spiffe.workloadapi.grpc import header_manipulator_client_interceptor +from spiffe.workloadapi.handle_error import handle_error +from spiffe.workloadapi.x509_context import X509Context + +""" +This module provides a Workload API client. +""" + +WORKLOAD_API_HEADER_KEY = 'workload.spiffe.io' +WORKLOAD_API_HEADER_VALUE = 'true' + +_logger = logging.getLogger(__name__) + +# GRPC Error Codes that the client will not retry on: +# - INVALID_ARGUMENT is not retried according to the SPIFFE spec because the request is invalid +# - CANCELLED is not retried because it occurs when the caller has canceled the operation. +_NON_RETRYABLE_CODES = {grpc.StatusCode.CANCELLED, grpc.StatusCode.INVALID_ARGUMENT} + +__all__ = ['WorkloadApiClient', 'RetryPolicy'] + +_T_co = TypeVar("_T_co", covariant=True) + + +class _CancelableIterator(Protocol[_T_co]): + def __iter__(self) -> Iterator[_T_co]: ... + + def __next__(self) -> _T_co: ... + + def cancel(self) -> bool: ... + + +class _WorkloadApiStub(Protocol): + FetchX509SVID: grpc.UnaryStreamMultiCallable[ + workload_pb2.X509SVIDRequest, workload_pb2.X509SVIDResponse + ] + FetchX509Bundles: grpc.UnaryStreamMultiCallable[ + workload_pb2.X509BundlesRequest, workload_pb2.X509BundlesResponse + ] + FetchJWTSVID: grpc.UnaryUnaryMultiCallable[ + workload_pb2.JWTSVIDRequest, workload_pb2.JWTSVIDResponse + ] + FetchJWTBundles: grpc.UnaryStreamMultiCallable[ + workload_pb2.JWTBundlesRequest, workload_pb2.JWTBundlesResponse + ] + ValidateJWTSVID: grpc.UnaryUnaryMultiCallable[ + workload_pb2.ValidateJWTSVIDRequest, workload_pb2.ValidateJWTSVIDResponse + ] + + +class RetryPolicy: + """Defines the retry policy using an exponential backoff strategy.""" + + UNLIMITED_RETRIES = 0 # Signifies unlimited retries + + def __init__( + self, + max_retries: int = UNLIMITED_RETRIES, + base_backoff_in_seconds: float = 0.1, + backoff_factor: int = 2, + max_backoff: float = 5, + ) -> None: + self.max_retries = max_retries + self.base_backoff = base_backoff_in_seconds + self.backoff_factor = backoff_factor + self.max_backoff = max_backoff + + +class RetryHandler: + def __init__(self, retry_policy: Optional[RetryPolicy] = None) -> None: + self.retry_policy: RetryPolicy = ( + retry_policy if retry_policy is not None else RetryPolicy() + ) + self.attempt: int = 0 + + def should_retry(self, error_code: StatusCode) -> bool: + """Determines whether the operation should be retried based on the error code and attempt count.""" + if error_code in _NON_RETRYABLE_CODES: + return False + # Allow unlimited retries when max_retries is set to UNLIMITED_RETRIES (0) + if ( + self.retry_policy.max_retries != RetryPolicy.UNLIMITED_RETRIES + and self.attempt >= self.retry_policy.max_retries + ): + return False + return True + + def get_backoff(self) -> float: + """Calculates the backoff time for the current attempt, then increments the attempt counter.""" + # int.__pow__ is annotated as returning Any to avoid false positives + # (positive int -> int, negative int -> float) so coerce to int since we + # know it's a positive integer. + growth: int = self.retry_policy.backoff_factor**self.attempt + backoff_time = min( + self.retry_policy.base_backoff * growth, + self.retry_policy.max_backoff, + ) + self.attempt += 1 + return backoff_time + + def reset(self) -> None: + """Resets the attempt counter to zero.""" + self.attempt = 0 + + +class StreamCancelHandler: + def __init__(self) -> None: + self.response_iterator: Optional[_CancelableIterator[object]] = None + self._cancel_event = threading.Event() + self._lock = threading.Lock() + + def set_iterator(self, iterator: _CancelableIterator[object]) -> None: + with self._lock: + self.response_iterator = iterator + # If already cancelled, cancel the iterator immediately to avoid race + if self._cancel_event.is_set(): + try: + iterator.cancel() + except Exception: + pass + + def cancel(self) -> None: + self._cancel_event.set() + with self._lock: + if self.response_iterator: + self.response_iterator.cancel() + + def is_cancelled(self) -> bool: + return self._cancel_event.is_set() + + def wait_cancelled(self, timeout: float) -> bool: + """Waits until the handler is cancelled or timeout is reached.""" + return self._cancel_event.wait(timeout) + + +class WorkloadApiClient: + """A SPIFFE Workload API Client.""" + + def __init__(self, socket_path: Optional[str] = None) -> None: + """ + Creates a new Workload API Client. + + This client interfaces with the Workload API using a Unix Domain Socket (UDS). If `socket_path` is not explicitly provided, + the client attempts to use the path specified by the `SPIFFE_ENDPOINT_SOCKET` environment variable. + + Parameters: + socket_path (Optional[str]): The file path to the Workload API UDS. If omitted, the client looks for the + path in the `SPIFFE_ENDPOINT_SOCKET` environment variable. + + Raises: + ArgumentError: If `socket_path` is not provided and no path is found in the `SPIFFE_ENDPOINT_SOCKET` + environment variable, or if the provided `socket_path` is invalid. + """ + try: + self._config = ConfigSetter(spiffe_endpoint_socket=socket_path).get_config() + self._check_spiffe_socket_exists(self._config.spiffe_endpoint_socket) + except ArgumentError as e: + raise ArgumentError('Invalid WorkloadApiClient configuration: {}'.format(str(e))) + + self._channel = self._get_spiffe_grpc_channel() + self._spiffe_workload_api_stub: _WorkloadApiStub = ( + # grpc doesn't generate types, see https://github.com/grpc/grpc/pull/37877. + workload_pb2_grpc.SpiffeWorkloadAPIStub(self._channel) # type: ignore[no-untyped-call] + ) + + @handle_error(error_cls=FetchX509SvidError) + def fetch_x509_svid(self) -> X509Svid: + """Fetches the default X509-SVID, i.e. the first in the list returned by the Workload API. + + Returns: + X509Svid: Instance of X509Svid object. + + Raises: + FetchX509SvidError: When there is an error fetching the X.509 SVID from the Workload API, or when the + response payload cannot be processed to be converted to a X509Svid object. + """ + response = self._call_fetch_x509_svid() + + svid = response.svids[0] + + return self._create_x509_svid(svid) + + @handle_error(error_cls=FetchX509SvidError) + def fetch_x509_svids(self) -> List[X509Svid]: + """Fetches all X509-SVIDs. + + Returns: + X509Svid: List of of X509Svid object. + + Raises: + FetchX509SvidError: When there is an error fetching the X.509 SVID from the Workload API, or when the + response payload cannot be processed to be converted to a X509Svid object. + """ + response = self._call_fetch_x509_svid() + + result = [] + for svid in response.svids: + result.append(self._create_x509_svid(svid)) + + return result + + @handle_error(error_cls=FetchX509SvidError) + def fetch_x509_context(self) -> X509Context: + """Fetches an X.509 context (X.509 SVIDs and X.509 Bundles keyed by TrustDomain). + + Returns: + X509Context: An object containing a List of X509Svids and a X509BundleSet. + + Raises: + FetchX509SvidError: When there is an error fetching the X.509 SVID from the Workload API, or when the + response payload cannot be processed to be converted to a X509Svid object. + + FetchX509BundleError: When there is an error fetching the X.509 Bundles from the Workload API, or when the + response payload cannot be processed to be converted to a X509Bundle objects. + """ + response = self._call_fetch_x509_svid() + return self._process_x509_context(response) + + @handle_error(error_cls=FetchX509BundleError) + def fetch_x509_bundles(self) -> X509BundleSet: + """Fetches X.509 bundles, keyed by trust domain. + + Returns: + X509BundleSet: Set of X509Bundle objects. + + Raises: + FetchX509BundleError: When there is an error fetching the X.509 Bundles from the Workload API, or when the + response payload cannot be processed to be converted to a X509Bundle objects. + """ + response = self._call_fetch_x509_bundles() + return self._create_x509_bundle_set(response.bundles) + + @handle_error(error_cls=FetchJwtSvidError) + def fetch_jwt_svid( + self, audience: Set[str], subject: Optional[SpiffeId] = None + ) -> JwtSvid: + """Fetches a SPIFFE JWT-SVID. + + Args: + audience: Set of audiences for the JWT SVID. + subject: SPIFFE ID subject for the JWT. + + Returns: + JwtSvid: Instance of JwtSvid object. + Raises: + ArgumentError: In case audience is empty. + FetchJwtSvidError: In case there is an error in fetching the JWT-SVID from the Workload API. + """ + if not audience: + raise ArgumentError('Parameter audiences cannot be empty') + + subject_str = str(subject) if subject is not None else '' + response = self._spiffe_workload_api_stub.FetchJWTSVID( + workload_pb2.JWTSVIDRequest( + audience=audience, + spiffe_id=subject_str, + ) + ) + + if len(response.svids) == 0: + raise FetchJwtSvidError('JWT SVID response is empty') + + svid = response.svids[0].svid + return JwtSvid.parse_insecure(svid, audience) + + @handle_error(error_cls=FetchJwtSvidError) + def fetch_jwt_svids( + self, audience: Set[str], subject: Optional[SpiffeId] = None + ) -> List[JwtSvid]: + """Fetches all SPIFFE JWT-SVIDs. + + Args: + audience: List of audiences for the JWT SVID. + subject: SPIFFE ID subject for the JWT. + + Raises: + ArgumentError: In case audience is empty. + FetchJwtSvidError: In case there is an error in fetching the JWT-SVID from the Workload API. + """ + if not audience: + raise ArgumentError('Parameter audiences cannot be empty') + + subject_str = str(subject) if subject is not None else '' + response = self._spiffe_workload_api_stub.FetchJWTSVID( + workload_pb2.JWTSVIDRequest( + audience=audience, + spiffe_id=subject_str, + ) + ) + + if len(response.svids) == 0: + raise FetchJwtSvidError('JWT SVID response is empty') + + svids = [] + for s in response.svids: + svids.append(JwtSvid.parse_insecure(s.svid, audience)) + + return svids + + @handle_error(error_cls=FetchJwtBundleError) + def fetch_jwt_bundles(self) -> JwtBundleSet: + """Fetches the JWT bundles for JWT-SVID validation, keyed by trust domain. + + Returns: + JwtBundleSet: Set of JwtBundle objects. + + Raises: + FetchJwtBundleError: In case there is an error in fetching the JWT-Bundle from the Workload API or + in case the set of jwt_authorities cannot be parsed from the Workload API Response. + """ + response = self._call_fetch_jwt_bundles() + jwt_bundles: Dict[TrustDomain, JwtBundle] = self._create_td_jwt_bundle_dict(response) + return JwtBundleSet(jwt_bundles) + + @handle_error(error_cls=ValidateJwtSvidError) + def validate_jwt_svid(self, token: str, audience: str) -> JwtSvid: + """Validates the JWT-SVID token. The parsed and validated JWT-SVID is returned. + + Args: + token: JWT to validate. + audience: expected audience to validate against. + + Returns: + JwtSvid: If the token and audience could be validated. + + Raises: + ArgumentError: In case token or audience is empty. + ValidateJwtSvidError: In case an error occurs calling the Workload API or + in case the response from the Workload API cannot be processed. + """ + if not token: + raise ArgumentError('Token cannot be empty') + if not audience: + raise ArgumentError('Audience cannot be empty') + + self._spiffe_workload_api_stub.ValidateJWTSVID( + workload_pb2.ValidateJWTSVIDRequest( + audience=audience, + svid=token, + ) + ) + return JwtSvid.parse_insecure(token, {audience}) + + def stream_x509_contexts( + self, + on_success: Callable[[X509Context], None], + on_error: Callable[[Exception], None], + retry_connect: bool = True, + retry_policy: Optional[RetryPolicy] = None, + ) -> StreamCancelHandler: + """ + Establishes a streaming gRPC connection to receive continuous updates of X.509 contexts from the Workload API. + + This method asynchronously listens for X.509 context updates, invoking `on_success` with each new context received. + If an error occurs during streaming or processing, `on_error` is called with the encountered exception. The method + supports automatic reconnection attempts based on the specified `retry_policy`. + + Parameters: + on_success (Callable[[X509Context], None]): Callback for each update received. + on_error (Callable[[Exception], None]): Callback for handling streaming or processing errors. + retry_connect (bool, optional): Enables automatic retries on connection failures. Defaults to True. + retry_policy (Optional[RetryPolicy], optional): Custom retry behavior; uses default if None. + + Returns: + StreamCancelHandler: A handler that can be used to cancel the streaming operation. + + Usage example: + cancel_handler = client.stream_x509_contexts(on_success, on_error) + # To cancel the streaming: + cancel_handler.cancel() + """ + cancel_handler = StreamCancelHandler() + retry_handler = RetryHandler(retry_policy) if retry_connect else None + + def watch_target() -> None: + self._watch_x509_context_updates( + cancel_handler, retry_handler, on_success, on_error + ) + + t = threading.Thread(target=watch_target, daemon=True) + t.start() + + return cancel_handler + + def stream_jwt_bundles( + self, + on_success: Callable[[JwtBundleSet], None], + on_error: Callable[[Exception], None], + retry_connect: bool = True, + retry_policy: Optional[RetryPolicy] = None, + ) -> StreamCancelHandler: + """ + Establishes a streaming gRPC connection to receive continuous updates of Jwt Bundles from the Workload API. + + This method asynchronously listens for Jwt Bundles updates, invoking `on_success` with each new update received. + If an error occurs during streaming or processing, `on_error` is called with the encountered exception. The method + supports automatic reconnection attempts based on the specified `retry_policy`. + + Parameters: + on_success (Callable[[X509Context], None]): Callback for each update received. + on_error (Callable[[Exception], None]): Callback for handling streaming or processing errors. + retry_connect (bool, optional): Enables automatic retries on connection failures. Defaults to True. + retry_policy (Optional[RetryPolicy], optional): Custom retry behavior; uses default if None. + + Returns: + StreamCancelHandler: A handler that can be used to cancel the streaming operation. + + Usage example: + cancel_handler = client.stream_x509_contexts(on_success, on_error) + # To cancel the streaming: + cancel_handler.cancel() + """ + cancel_handler = StreamCancelHandler() + retry_handler = RetryHandler(retry_policy) if retry_connect else None + + def watch_target() -> None: + self._watch_jwt_bundles_updates( + cancel_handler, retry_handler, on_success, on_error + ) + + t = threading.Thread(target=watch_target, daemon=True) + t.start() + + return cancel_handler + + def get_spiffe_endpoint_socket(self) -> str: + """Returns the spiffe endpoint socket config for this WorkloadApiClient. + + Returns: + str: spiffe endpoint socket configuration value. + """ + + return self._config.spiffe_endpoint_socket + + def close(self) -> None: + """Closes the WorkloadClient along with the current connections.""" + self._channel.close() + + # Private methods + def _watch_x509_context_updates( + self, + cancel_handler: StreamCancelHandler, + retry_handler: Optional[RetryHandler], + on_success: Callable[[X509Context], None], + on_error: Callable[[Exception], None], + ) -> None: + while True: + if cancel_handler.is_cancelled(): + break + try: + response_iterator = self._spiffe_workload_api_stub.FetchX509SVID( + workload_pb2.X509SVIDRequest() + ) + cancel_handler.set_iterator(response_iterator) + + for item in response_iterator: + if cancel_handler.is_cancelled(): + break + x509_context = self._process_x509_context(item) + on_success(x509_context) + + if retry_handler: + retry_handler.reset() + break + + except grpc.RpcError as grpc_err: + if retry_handler is None or not retry_handler.should_retry(grpc_err.code()): + on_error(WorkloadApiError(f"gRPC error: {str(grpc_err.code())}")) + break + + backoff = retry_handler.get_backoff() + if cancel_handler.wait_cancelled(backoff): + break + + except Exception as err: + on_error(WorkloadApiError(str(err))) + break # Exit on unexpected errors + + def _watch_jwt_bundles_updates( + self, + cancel_handler: StreamCancelHandler, + retry_handler: Optional[RetryHandler], + on_success: Callable[[JwtBundleSet], None], + on_error: Callable[[Exception], None], + ) -> None: + while True: + if cancel_handler.is_cancelled(): + break + try: + response_iterator = self._spiffe_workload_api_stub.FetchJWTBundles( + workload_pb2.JWTBundlesRequest() + ) + cancel_handler.set_iterator(response_iterator) + + for item in response_iterator: + if cancel_handler.is_cancelled(): + break + jwt_bundles = self._process_jwt_bundles(item) + on_success(jwt_bundles) + + if retry_handler: + retry_handler.reset() + break + + except grpc.RpcError as grpc_err: + if retry_handler is None or not retry_handler.should_retry(grpc_err.code()): + on_error(WorkloadApiError(f"gRPC error: {str(grpc_err.code())}")) + break + + backoff = retry_handler.get_backoff() + if cancel_handler.wait_cancelled(backoff): + break + + except Exception as err: + on_error(WorkloadApiError(str(err))) + break # Exit on unexpected errors + + def _process_x509_context( + self, x509_svid_response: workload_pb2.X509SVIDResponse + ) -> X509Context: + svids = [] + bundle_set = self._create_x509_bundle_set(x509_svid_response.federated_bundles) + for svid in x509_svid_response.svids: + x509_svid = self._create_x509_svid(svid) + svids.append(x509_svid) + + trust_domain = x509_svid.spiffe_id.trust_domain + bundle_set.put(X509Bundle.parse_raw(trust_domain, svid.bundle)) + + return X509Context(svids, bundle_set) + + def _process_jwt_bundles( + self, jwt_bundles_response: workload_pb2.JWTBundlesResponse + ) -> JwtBundleSet: + return self._create_jwt_bundle_set(jwt_bundles_response.bundles) + + def _get_spiffe_grpc_channel(self) -> grpc.Channel: + target = self._grpc_target(self._config.spiffe_endpoint_socket) + grpc_insecure_channel = grpc.insecure_channel(target) + spiffe_client_interceptor = ( + header_manipulator_client_interceptor.header_adder_interceptor( + WORKLOAD_API_HEADER_KEY, WORKLOAD_API_HEADER_VALUE + ) + ) + + return grpc.intercept_channel(grpc_insecure_channel, spiffe_client_interceptor) + + def _call_fetch_x509_svid(self) -> workload_pb2.X509SVIDResponse: + response = self._spiffe_workload_api_stub.FetchX509SVID(workload_pb2.X509SVIDRequest()) + try: + item = next(response) + except StopIteration: + raise FetchX509SvidError('X.509 SVID response is invalid') + if len(item.svids) == 0: + raise FetchX509SvidError('X.509 SVID response is empty') + return item + + def _call_fetch_x509_bundles(self) -> workload_pb2.X509BundlesResponse: + response = self._spiffe_workload_api_stub.FetchX509Bundles( + workload_pb2.X509BundlesRequest() + ) + try: + item = next(response) + except StopIteration: + raise FetchX509BundleError('X.509 Bundles response is invalid') + if len(item.bundles) == 0: + raise FetchX509BundleError('X.509 Bundles response is empty') + return item + + def _call_fetch_jwt_bundles(self) -> workload_pb2.JWTBundlesResponse: + response = self._spiffe_workload_api_stub.FetchJWTBundles( + workload_pb2.JWTBundlesRequest() + ) + try: + item = next(response) + except StopIteration: + raise FetchJwtBundleError('JWT Bundles response is invalid') + if len(item.bundles) == 0: + raise FetchJwtBundleError('JWT Bundles response is empty') + return item + + @staticmethod + def _create_x509_bundle_set(resp_bundles: Mapping[str, bytes]) -> X509BundleSet: + x509_bundles = [ + X509Bundle.parse_raw(TrustDomain(td), resp_bundles[td]) for td in resp_bundles + ] + return X509BundleSet.of(x509_bundles) + + @staticmethod + def _create_jwt_bundle_set(resp_bundles: Mapping[str, bytes]) -> JwtBundleSet: + jwt_bundles = [ + JwtBundle.parse(TrustDomain(td), bundle) for td, bundle in resp_bundles.items() + ] + return JwtBundleSet.of(jwt_bundles) + + @staticmethod + def _create_x509_svid(svid: workload_pb2.X509SVID) -> X509Svid: + cert = svid.x509_svid + key = svid.x509_svid_key + return X509Svid.parse_raw(cert, key) + + @staticmethod + def _create_td_jwt_bundle_dict( + jwt_bundle_response: workload_pb2.JWTBundlesResponse, + ) -> Dict[TrustDomain, JwtBundle]: + return { + TrustDomain(td): JwtBundle.parse(TrustDomain(td), jwk_set) + for td, jwk_set in jwt_bundle_response.bundles.items() + } + + def __enter__(self) -> 'WorkloadApiClient': + return self + + def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None: + self.close() + + @staticmethod + def _check_spiffe_socket_exists(spiffe_socket: str) -> None: + path_to_check = WorkloadApiClient._strip_unix_scheme(spiffe_socket) + if not path_to_check: + raise ArgumentError('SPIFFE endpoint socket is empty') + if not os.path.exists(path_to_check): + raise ArgumentError(f'SPIFFE socket file "{path_to_check}" does not exist.') + + @staticmethod + def _grpc_target(value: str) -> str: + """Returns the gRPC target for UDS, normalizing unix:/// to unix:/.""" + if value.startswith('unix:'): + path = value[5:] + if path.startswith('/'): + path = '/' + path.lstrip('/') + return f'unix:{path}' + if value.startswith('/'): + return f'unix:{value}' + raise ArgumentError( + f'Invalid SPIFFE endpoint socket "{value}": only unix domain sockets are supported' + ) + + @staticmethod + def _strip_unix_scheme(value: str) -> str: + """Strips unix: scheme and normalizes leading slashes for filesystem checks.""" + path = value[5:] if value.startswith('unix:') else value + if path.startswith('/'): + path = '/' + path.lstrip('/') + return path diff --git a/spiffe_pkg/workloadapi/x509_context.py b/spiffe_pkg/workloadapi/x509_context.py new file mode 100644 index 000000000..43f51c018 --- /dev/null +++ b/spiffe_pkg/workloadapi/x509_context.py @@ -0,0 +1,69 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +""" +This module provides an object for transferring X.509 SVID and Bundles materials. +""" + +from typing import List + +from spiffe.bundle.x509_bundle.x509_bundle_set import X509BundleSet +from spiffe.errors import ArgumentError +from spiffe.svid.x509_svid import X509Svid + + +class X509Context(object): + """Represents the X.509 materials that are fetched from the Workload API. + + Contains a list of X509Svid and a X509BundleSet. + """ + + def __init__(self, x509_svids: List[X509Svid], x509_bundle_set: X509BundleSet) -> None: + """Creates a new X509Context with a list of X509Svid object and a X509BundleSet. + + Args: + x509_svids: A list of X509Svid objects. + x509_bundle_set: An X509BundleSet object. + """ + + if not x509_svids: + raise ArgumentError('X.509 SVID list cannot be empty') + + self._x509_svids = x509_svids.copy() if x509_svids else [] + self._x509_bundle_set = x509_bundle_set + + @property + def default_svid(self) -> X509Svid: + """Returns the default X509-SVID (the first in the list). + + See the SPIFFE Workload API standard Section 5.3. + (https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Workload_API.md#53-default-identity) + + Returns: + The first X509Svid object in the list, None in case the X509Context has no objects in the X509Svid list. + + """ + return self._x509_svids[0] + + @property + def x509_svids(self) -> List[X509Svid]: + """Returns the list of X509Svid objects.""" + return self._x509_svids.copy() + + @property + def x509_bundle_set(self) -> X509BundleSet: + """Returns the X509BundleSet object.""" + return self._x509_bundle_set diff --git a/spiffe_pkg/workloadapi/x509_source.py b/spiffe_pkg/workloadapi/x509_source.py new file mode 100644 index 000000000..605f0e8eb --- /dev/null +++ b/spiffe_pkg/workloadapi/x509_source.py @@ -0,0 +1,295 @@ +""" +(C) Copyright 2021 Hewlett Packard Enterprise Development LP + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +""" + +import logging +import threading +import warnings +from typing import Optional, Callable, List, FrozenSet + +from spiffe.bundle.x509_bundle.x509_bundle_set import X509BundleSet +from spiffe.bundle.x509_bundle.x509_bundle import X509Bundle +from spiffe.spiffe_id.spiffe_id import TrustDomain +from spiffe.svid.x509_svid import X509Svid +from spiffe.workloadapi.errors import X509SourceError +from spiffe.workloadapi.workload_api_client import WorkloadApiClient, StreamCancelHandler +from spiffe.workloadapi.x509_context import X509Context + +_logger = logging.getLogger(__name__) + +""" +This module provides an implementation of an X.509 Source. +""" + + +class X509Source: + """Source of X509-SVIDs and X.509 bundles maintained via the Workload API.""" + + def __init__( + self, + workload_api_client: Optional[WorkloadApiClient] = None, + socket_path: Optional[str] = None, + timeout_in_seconds: Optional[float] = None, + svid_picker: Optional[Callable[[List[X509Svid]], X509Svid]] = None, + ) -> None: + """Creates a new X509Source. + + It blocks until the initial update has been received from the Workload API or until timeout_in_seconds is reached. + + In case the underlying Workload API connection returns an unretryable error, the source will be closed and + no methods on the source will be available. + + + Args: + workload_api_client: A WorkloadApiClient that will be used to fetch the X.509 materials from the Workload API. + If not provided, a default client will be created and owned by this source; the source + will close it when the source is closed. If a client is provided, the caller retains + ownership and is responsible for closing it; the source will not close a client it + does not own. + + socket_path: Path to Workload API UDS. This will be used in case a the workload_api_client is not provided. + If not specified, the SPIFFE_ENDPOINT_SOCKET environment variable must be set. + + timeout_in_seconds: Time to wait for the first update of the Workload API. If no timeout is provided, and + the connection with the Workload API fails, it will block Indefinitely while + the connection is retried. + + svid_picker: Function to choose the X.509 SVID from the list returned by the Workload API. + If it is not set, the default SVID is picked. If the picker function throws an error, + it will render the X509Source invalid and it will be closed. + + Returns: + X509Source: New X509Source object, initialized with the X509Context fetched from the Workload API. + + Raises: + ArgumentError: If spiffe_socket_path is invalid or not provided and SPIFFE_ENDPOINT_SOCKET env variable is not set. + + X509SourceError: In case a timeout was configured and it was reached during the source initialization waiting + for the first update from the Workload API. + """ + self._initialization_event = threading.Event() + self._error: Optional[Exception] = None + self._closed = False + self._lock = threading.Lock() + self._subscribers: List[Callable[[], None]] = [] + self._subscribers_lock = threading.Lock() + + # Track ownership: if we create the client, we own it + self._owns_client = workload_api_client is None + self._workload_api_client = workload_api_client or WorkloadApiClient(socket_path) + self._picker = svid_picker + self._client_cancel_handler: Optional[StreamCancelHandler] = None + + # Start the watcher in a separate thread + threading.Thread(target=self._start_watcher, daemon=True).start() + + # Wait for the first update or an error + initialized = self._initialization_event.wait(timeout=timeout_in_seconds) + + if not initialized: + self._closed = True + raise X509SourceError( + "Failed to initialize X509Source: Timeout waiting for the first update." + ) + + if self._error is not None: + self._closed = True + raise X509SourceError( + f"Failed to create X509Source: {self._error}" + ) from self._error + + @property + def svid(self) -> X509Svid: + """Returns an X509-SVID from the source.""" + warnings.warn( + ( + 'X509Source.svid is deprecated; ' + 'use X509Source.get_x509_context().default_svid instead.' + ), + DeprecationWarning, + stacklevel=2, + ) + with self._lock: + if self._error is not None: + raise X509SourceError( + f'Cannot get X.509 SVID: source has error: {self._error}' + ) + if self._closed: + raise X509SourceError('Cannot get X.509 SVID: source is closed') + return self._x509_svid + + def get_x509_context(self) -> X509Context: + """Returns a coherent X509Context snapshot for this source.""" + with self._lock: + if self._error is not None: + raise X509SourceError( + f'Cannot get X.509 context: source has error: {self._error}' + ) + if self._closed: + raise X509SourceError('Cannot get X.509 context: source is closed') + + bundle_set = X509BundleSet.of(list(self._x509_bundle_set.bundles)) + return X509Context( + x509_svids=[self._x509_svid], + x509_bundle_set=bundle_set, + ) + + @property + def bundles(self) -> FrozenSet[X509Bundle]: + """Returns the set of all X509Bundles.""" + warnings.warn( + ( + 'X509Source.bundles is deprecated; ' + 'use X509Source.get_x509_context().x509_bundle_set.bundles instead.' + ), + DeprecationWarning, + stacklevel=2, + ) + with self._lock: + if self._error is not None: + raise X509SourceError( + f'Cannot get X.509 Bundles: source has error: {self._error}' + ) + if self._closed: + raise X509SourceError('Cannot get X.509 Bundles: source is closed') + return frozenset(self._x509_bundle_set.bundles) + + def get_bundle_for_trust_domain(self, trust_domain: TrustDomain) -> Optional[X509Bundle]: + """Returns the X.509 bundle for the given trust domain.""" + with self._lock: + if self._error is not None: + raise X509SourceError( + f'Cannot get X.509 Bundle: source has error: {self._error}' + ) + if self._closed: + raise X509SourceError('Cannot get X.509 Bundle: source is closed') + return self._x509_bundle_set.get_bundle_for_trust_domain(trust_domain) + + def close(self) -> None: + """Closes this X509Source closing the underlying connection with the Workload API. Once the source is closed, + no methods can be called on it. + + It is recommended that when an instance of an X509Source is no longer used the close() method be called on it, + in order to liberate the resources used by the underlying connection with the Workload API. + """ + _logger.info("Closing X.509 Source") + with self._lock: + if self._closed: + return + try: + if self._client_cancel_handler: + self._client_cancel_handler.cancel() + except Exception as err: + _logger.exception( + 'Exception canceling the Workload API client connection: {}'.format( + str(err) + ) + ) + self._closed = True + + if self._owns_client: + try: + self._workload_api_client.close() + except Exception as err: + _logger.exception( + 'Exception closing owned Workload API client: {}'.format(str(err)) + ) + + def is_closed(self) -> bool: + """Checks if the source has been closed, disallowing further operations.""" + with self._lock: + return self._closed + + def subscribe_for_updates(self, callback: Callable[[], None]) -> None: + """ + Allows clients to register a callback function for updates on the source. + + Args: + callback (Callable[[], None]): The callback function to register. + """ + with self._subscribers_lock: + self._subscribers.append(callback) + + def unsubscribe_for_updates(self, callback: Callable[[], None]) -> None: + """ + Allows clients to unregister a previously registered callback function. + + Args: + callback (Callable[[], None]): The callback function to unregister. + """ + with self._subscribers_lock: + try: + self._subscribers.remove(callback) + except ValueError: + pass + + def _start_watcher(self) -> None: + self._client_cancel_handler = self._workload_api_client.stream_x509_contexts( + self._set_context, self._on_error + ) + + def _set_context(self, x509_context: X509Context) -> None: + try: + svid = ( + self._picker(x509_context.x509_svids) + if self._picker + else x509_context.default_svid + ) + except Exception as err: + wrapped_err = Exception(f"Failed to pick X509 SVID: {err}") + _logger.error(f"Error setting X.509 context: {wrapped_err}") + self._on_error(wrapped_err) + return + + _logger.debug('X.509 Source: setting new update') + with self._lock: + self._x509_svid = svid + self._x509_bundle_set = x509_context.x509_bundle_set + + # Signal that the X509Source has been successfully initialized + self._initialization_event.set() + + self._notify_subscribers() + + def _notify_subscribers(self) -> None: + with self._subscribers_lock: + subscribers = list(self._subscribers) + for callback in subscribers: + try: + callback() + except Exception as err: + _logger.exception(f"An error occurred while notifying a subscriber: {err}") + + def _on_error(self, error: Exception) -> None: + self._log_error(error) + with self._lock: + self._error = error + self._closed = True + try: + if self._client_cancel_handler: + self._client_cancel_handler.cancel() + except Exception as err: + _logger.exception(f"Exception canceling stream on error: {err}") + self._initialization_event.set() + + @staticmethod + def _log_error(err: Exception) -> None: + _logger.error(f"X509 Source Error: {err}") + + def __enter__(self) -> 'X509Source': + return self + + def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None: + self.close() diff --git a/tetragon/tracing_policy.yaml b/tetragon/tracing_policy.yaml new file mode 100644 index 000000000..8b8fb3c74 --- /dev/null +++ b/tetragon/tracing_policy.yaml @@ -0,0 +1,21 @@ +apiVersion: cilium.io/v1alpha1 +kind: TracingPolicy +metadata: + name: "block-secret-read" +spec: + kprobes: + - call: "fd_install" + syscall: false + args: + - index: 0 + type: int + - index: 1 + type: "file" + selectors: + - matchArgs: + - index: 1 + operator: "Equal" + values: + - "/app/restricted-resource" + matchActions: + - action: Sigkill diff --git a/tetragon_processor.py b/tetragon_processor.py new file mode 100644 index 000000000..9ff603214 --- /dev/null +++ b/tetragon_processor.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +Tetragon Event Processor - Consumes real eBPF events and creates backend incidents +Listens to Tetragon events from Parseable log stream and generates HITL incidents +when suspicious patterns are detected. +""" + +import os +import time +import requests +import json +import logging +from datetime import datetime +from typing import Dict, List, Optional + +logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(levelname)s: %(message)s') +logger = logging.getLogger(__name__) + +PARSEABLE_URL = os.getenv('PARSEABLE_URL', 'http://parseable:8000') +PARSEABLE_USERNAME = os.getenv('PARSEABLE_USERNAME', 'admin') +PARSEABLE_PASSWORD = os.getenv('PARSEABLE_PASSWORD', 'admin') +ANALYTICS_ENGINE_URL = os.getenv('ANALYTICS_ENGINE_URL', 'http://analytics-engine:8000') + +# Rule patterns that trigger incidents +INCIDENT_RULES = [ + { + 'name': 'RESTRICTED_FILE_ACCESS', + 'pattern': r'sys_openat.*\/restricted', + 'severity': 'HIGH', + 'description': 'Attempt to access restricted resource' + }, + { + 'name': 'PRIVILEGE_ESCALATION', + 'pattern': r'sys_execve.*setuid|sys_execve.*sudo', + 'severity': 'CRITICAL', + 'description': 'Potential privilege escalation attempt' + }, + { + 'name': 'UNAUTHORIZED_NETWORK', + 'pattern': r'sys_connect.*dst not in.*443', + 'severity': 'HIGH', + 'description': 'Unauthorized network connection attempt' + }, + { + 'name': 'SYSTEM_CALL_ANOMALY', + 'pattern': r'sys_write.*\/etc\/|sys_write.*\/sys\/', + 'severity': 'CRITICAL', + 'description': 'Suspicious write to system directories' + } +] + + +def poll_tetragon_events() -> List[Dict]: + """Poll Parseable for recent Tetragon events""" + try: + # Query Parseable log stream for recent tetragon events + query = ''' + SELECT * FROM tetragon + WHERE timestamp > now() - interval '5 seconds' + ORDER BY timestamp DESC + LIMIT 100 + ''' + + auth = (PARSEABLE_USERNAME, PARSEABLE_PASSWORD) + headers = {'Content-Type': 'application/json'} + + response = requests.post( + f'{PARSEABLE_URL}/api/v1/query', + json={'query': query}, + auth=auth, + headers=headers, + timeout=5 + ) + + if response.status_code == 200: + data = response.json() + return data.get('records', []) if isinstance(data, dict) else [] + else: + logger.warning(f'Parseable query failed: {response.status_code}') + return [] + + except Exception as e: + logger.warning(f'Error polling Tetragon events: {e}') + return [] + + +def check_incident_rules(event: Dict) -> Optional[Dict]: + """Check if an event matches any incident rules""" + event_str = json.dumps(event).lower() + + for rule in INCIDENT_RULES: + import re + if re.search(rule['pattern'].lower(), event_str): + return { + 'rule_name': rule['name'], + 'severity': rule['severity'], + 'description': rule['description'], + 'matched_pattern': rule['pattern'] + } + + return None + + +def create_backend_incident(rule_match: Dict, event: Dict) -> bool: + """Create an incident in the backend analytics engine""" + try: + import uuid + + incident_payload = { + 'id': str(uuid.uuid4()), + 'detected_at': datetime.utcnow().isoformat(), + 'severity': rule_match['severity'], + 'description': f"{rule_match['description']} - Rule: {rule_match['rule_name']}" + } + + response = requests.post( + f'{ANALYTICS_ENGINE_URL}/incidents/create', + json=incident_payload, + timeout=5 + ) + + if response.status_code == 200: + logger.info(f"✓ Incident created: {incident_payload['id']} - {rule_match['description']}") + return True + else: + logger.error(f"Failed to create incident: {response.status_code} {response.text}") + return False + + except Exception as e: + logger.error(f'Error creating incident: {e}') + return False + + +def main(): + """Main polling loop""" + logger.info("Starting Tetragon Event Processor...") + logger.info(f"Parseable: {PARSEABLE_URL}") + logger.info(f"Analytics Engine: {ANALYTICS_ENGINE_URL}") + + processed_events = set() + + while True: + try: + # Poll for new events + events = poll_tetragon_events() + + if events: + logger.info(f"Polled {len(events)} events from Parseable") + + for event in events: + # Use a simple hash to avoid reprocessing + event_key = json.dumps(event, sort_keys=True, default=str) + event_hash = hash(event_key) + + if event_hash not in processed_events: + processed_events.add(event_hash) + + # Check if event matches incident rules + rule_match = check_incident_rules(event) + if rule_match: + logger.info(f"Rule matched: {rule_match['rule_name']}") + create_backend_incident(rule_match, event) + + # Keep processed events set bounded + if len(processed_events) > 1000: + processed_events = set(list(processed_events)[-500:]) + + except Exception as e: + logger.error(f'Error in main loop: {e}') + + # Poll interval + time.sleep(5) + + +if __name__ == '__main__': + main()