From ab3b588b3895ecdb716c21e7c93cca31bfde8e9b Mon Sep 17 00:00:00 2001 From: Priyanshu Singh <98865320+priyanshu5ingh@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:14:10 +0530 Subject: [PATCH 01/27] Revise README with new team name and instructions Updated team name and removed hackathon instructions. --- README.md | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/README.md b/README.md index c5c886b3e..101261674 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,4 @@ -# HackToFuture 4.0 — Template - -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. - ---- - -### Instructions for the teams: - -- Fork the Repository and name the forked repo in this convention: hacktofuture4-team_id (for eg: hacktofuture4-A01) - ---- - -## Rules - -- 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 - ---- +# Team DOLLAR $IGN (C06) # The Final README Template From 73034228c2b211fc87039bb54ee759e6fe32d3de Mon Sep 17 00:00:00 2001 From: Priyanshu Singh <98865320+priyanshu5ingh@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:14:39 +0530 Subject: [PATCH 02/27] Remove final README template header Removed the final README template header. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 101261674..cb0639e31 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # Team DOLLAR $IGN (C06) -# The Final README Template ## Problem Statement / Idea From 946f282161b9657300301da72475b6f488b8b2df Mon Sep 17 00:00:00 2001 From: Mahesharunaladi Date: Wed, 15 Apr 2026 19:44:38 +0530 Subject: [PATCH 03/27] Added all the information asked regrading our ps- statment --- README.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/README.md b/README.md index cb0639e31..bc4465329 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,36 @@ Clearly describe the problem you are solving. - What is the problem? + +Traditional authentication mechanisms, such as passwords or Single Sign-On (SSO), only verify an identity at the initial point of login, which creates a significant security gap for autonomous AI agents that maintain persistent sessions + +. If an agent's token is stolen or its intent is hijacked via prompt injection mid-session, attackers can move laterally through a network at machine speed with unrestricted access + +. Furthermore, traditional security models are built on the flawed assumption that trust is static, whereas in the modern enterprise, Non-Human Identities (NHIs) now outnumber human identities by as much as 50:1, making them the top cybersecurity threat + + - Why is it important? + +. Proactive Defense: It shifts cybersecurity from a reactive model of patching vulnerabilities to a proactive, self-healing defense that can autonomously contain threats at machine speed + +. Reducing Risk: By using ephemeral (short-lived) credentials, the system reduces the window of opportunity for stolen credentials by up to 75% + +. Regulatory Compliance: It directly implements the requirements of the Cloud Security Alliance’s (CSA) new Agentic Trust Framework (ATF) for governing non-human actors + +. Scalability: Because the system is decentralized, it allows millions of machine-to-machine interactions to scale without the bottlenecks of a central Identity Provider (IdP) + +. Safety Assurance: The safety of future enterprise systems will not depend on how "smart" an AI is, but on how rigorously it can be monitored and contained when its behavior drifts from its intended purpose + + - Who are the target users? +. Enterprises using Autonomous AI: Organizations that deploy AI agents to handle sensitive data or interact with internal APIs and databases + +. Cloud & Infrastructure Teams: Users operating in Kubernetes and Docker environments who need to securely scope and manage machine identities + +. 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 @@ -16,8 +43,38 @@ Clearly describe the problem you are solving. Explain your approach: - What are you 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 + +. 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% + +. 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 --- From b18838c7db757486506aa57a241ed5ece6d5377b Mon Sep 17 00:00:00 2001 From: Mahesharunaladi Date: Wed, 15 Apr 2026 19:48:52 +0530 Subject: [PATCH 04/27] Added all the information asked regrading our ps- statment --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bc4465329..34eed3ad2 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,10 @@ Clearly describe the problem you are solving. - What is the problem? -Traditional authentication mechanisms, such as passwords or Single Sign-On (SSO), only verify an identity at the initial point of login, which creates a significant security gap for autonomous AI agents that maintain persistent sessions +Traditional authentication (like passwords or SSO) only verifies identity at the point of login [2]. This creates a massive security gap for **autonomous AI agents** that maintain persistent sessions [2]. +* **Key Risks:** If an agent's token is stolen or its intent is hijacked via **prompt injection** mid-session, attackers can achieve unrestricted lateral movement at machine speed [2]. +* **Scale:** Non-Human Identities (NHIs) now outnumber human identities by up to **50:1**, making NHI exploitation a top enterprise threat [3]. -. If an agent's token is stolen or its intent is hijacked via prompt injection mid-session, attackers can move laterally through a network at machine speed with unrestricted access - -. Furthermore, traditional security models are built on the flawed assumption that trust is static, whereas in the modern enterprise, Non-Human Identities (NHIs) now outnumber human identities by as much as 50:1, making them the top cybersecurity threat - Why is it important? From 8c9d69da096abfc30ca4caff9c885f32f670db14 Mon Sep 17 00:00:00 2001 From: sachin Date: Wed, 15 Apr 2026 19:49:58 +0530 Subject: [PATCH 05/27] Update README.md --- README.md | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 34eed3ad2..e694137c8 100644 --- a/README.md +++ b/README.md @@ -79,11 +79,30 @@ Our approach is unique because it moves beyond simple authentication to dynamic, ## Features -List the core features of your project: - -- Feature 1 -- Feature 2 -- Feature 3 +# 🛡️ 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. + +### 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. --- From c497a8cdddaad744d168d22afcc4967855df3abd Mon Sep 17 00:00:00 2001 From: Mahesharunaladi Date: Wed, 15 Apr 2026 19:57:10 +0530 Subject: [PATCH 06/27] Added all the information asked regrading our ps- statment --- README.md | 66 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 34eed3ad2..0e8242af2 100644 --- a/README.md +++ b/README.md @@ -5,75 +5,77 @@ Clearly describe the problem you are solving. -- What is the problem? +Project Analysis: Aegis-DID -Traditional authentication (like passwords or SSO) only verifies identity at the point of login [2]. This creates a massive security gap for **autonomous AI agents** that maintain persistent sessions [2]. -* **Key Risks:** If an agent's token is stolen or its intent is hijacked via **prompt injection** mid-session, attackers can achieve unrestricted lateral movement at machine speed [2]. -* **Scale:** Non-Human Identities (NHIs) now outnumber human identities by up to **50:1**, making NHI exploitation a top enterprise threat [3]. +## What is the Problem? +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? +- ## Why is it important? -. Proactive Defense: It shifts cybersecurity from a reactive model of patching vulnerabilities to a proactive, self-healing defense that can autonomously contain threats at machine speed +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 -. Reducing Risk: By using ephemeral (short-lived) credentials, the system reduces the window of opportunity for stolen credentials by up to 75% +* 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 -. Regulatory Compliance: It directly implements the requirements of the Cloud Security Alliance’s (CSA) new Agentic Trust Framework (ATF) 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) -. Scalability: Because the system is decentralized, it allows millions of machine-to-machine interactions to scale without the bottlenecks of 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 -. Safety Assurance: The safety of future enterprise systems will not depend on how "smart" an AI is, but on how rigorously it can be monitored and contained when its behavior drifts from its intended purpose +- ## Who are the target users? -- Who are the target users? + * Enterprises using Autonomous AI: Organizations that deploy AI agents to handle sensitive data or interact with internal APIs and databases -. Enterprises using Autonomous AI: Organizations that deploy AI agents to handle sensitive data or interact with internal APIs and databases + * Cloud & Infrastructure Teams: Users operating in Kubernetes and Docker environments who need to securely scope and manage machine identities -. Cloud & Infrastructure Teams: Users operating in Kubernetes and Docker environments who need to securely scope and manage machine identities + * Cybersecurity & Compliance Officers: Professionals who must ensure their AI deployments align with emerging standards like the CSA’s Agentic Trust Framework -. 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 + * Developers of Agentic AI: Those building the next generation of autonomous intelligence who require a decentralized infrastructure to safely scale their applications --- ## Proposed Solution -Explain your approach: -- What are you building? + +- ## What are you 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 +* 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 + * 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? +- ## 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 +* Aegis-DID solves this by shifting to a continuous identity verification model -. 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% +* 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% -. Monitoring Behavioral Intent: While the agent is active, the system uses eBPF and OpenTelemetry to capture real-time telemetry +* 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 +* 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 +* 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 +* 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? +- ## 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 +* 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 +* 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 +* 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 +* 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 --- From d9e6bbff0d4223298ece0e181f7a2c91e702c033 Mon Sep 17 00:00:00 2001 From: Mahesharunaladi Date: Wed, 15 Apr 2026 20:00:14 +0530 Subject: [PATCH 07/27] added readme --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ab6921ddc..2f60cb2cc 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Traditional authentication mechanisms (such as passwords or Single Sign-On) are . 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? + ## 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 : @@ -28,7 +28,7 @@ Credential Security: It reduces the window of exposure for stolen credentials by * 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 -- ## Who are the target users? + ## Who are the target users? * Enterprises using Autonomous AI: Organizations that deploy AI agents to handle sensitive data or interact with internal APIs and databases @@ -43,7 +43,7 @@ Credential Security: It reduces the window of exposure for stolen credentials by -- ## What are you building? +## What are you 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 @@ -51,7 +51,7 @@ I am building Aegis-DID (Agentic Ephemeral Governance & Identity System), a dece * 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? +## 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 @@ -66,7 +66,7 @@ Traditional security models fail because they only verify an identity at the ini * 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? + ## 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 From a19e0446c1303add900409c2046423a8541a90a3 Mon Sep 17 00:00:00 2001 From: Mahesharunaladi Date: Wed, 15 Apr 2026 20:09:56 +0530 Subject: [PATCH 08/27] added readme --- README.md | 54 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 2f60cb2cc..3d56c1ef0 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # Team DOLLAR $IGN (C06) -## Problem Statement / Idea +## Problem Statement -Clearly describe the problem you are solving. -Project Analysis: Aegis-DID + +### Project Analysis: Aegis-DID ## What is the Problem? @@ -18,64 +18,66 @@ Traditional authentication mechanisms (such as passwords or Single Sign-On) are ## 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 +# 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 +* **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) +* **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 +* **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 ## Who are the target users? - * Enterprises using Autonomous AI: Organizations that deploy AI agents to handle sensitive data or interact with internal APIs and databases + * **Enterprises using Autonomous AI:** Organizations that deploy AI agents to handle sensitive data or interact with internal APIs and databases - * Cloud & Infrastructure Teams: Users operating in Kubernetes and Docker environments who need to securely scope and manage machine identities + * **Cloud & Infrastructure Teams:** Users operating in Kubernetes and Docker environments who need to securely scope and manage machine identities - * Cybersecurity & Compliance Officers: Professionals who must ensure their AI deployments align with emerging standards like the CSA’s Agentic Trust Framework + * **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 + * **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 you building? +## 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 +* 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 +# 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 -* 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% +* **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% -* Monitoring Behavioral Intent: While the agent is active, the system uses eBPF and OpenTelemetry to capture real-time telemetry +* **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 +# 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 +* **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 +# 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 +# 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 +* **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 +* **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 +* **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 +* **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 --- From 6a6bb63158610d634ccef07b6072847a91b23bf9 Mon Sep 17 00:00:00 2001 From: Mahesharunaladi Date: Wed, 15 Apr 2026 20:12:35 +0530 Subject: [PATCH 09/27] added readme --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3d56c1ef0..bff47b103 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# Team DOLLAR $IGN (C06) +# TEAM DOLLAR$IGN (C06) ## Problem Statement -### Project Analysis: Aegis-DID +#### Project Analysis: Aegis-DID ## What is the Problem? @@ -53,10 +53,11 @@ I am building Aegis-DID (Agentic Ephemeral Governance & Identity System), a dece * 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? +### 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 + +* **Aegis-DID solves this by shifting to a continuous identity verification model** * **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% @@ -66,10 +67,10 @@ I am building Aegis-DID (Agentic Ephemeral Governance & Identity System), a dece * **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 +* **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 +* **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 From be010d6ff96c31e2531df89936b8df52144975b7 Mon Sep 17 00:00:00 2001 From: Mahesharunaladi Date: Wed, 15 Apr 2026 20:16:06 +0530 Subject: [PATCH 10/27] added readme --- README.md | 406 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 393 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index bff47b103..2fd482f90 100644 --- a/README.md +++ b/README.md @@ -113,27 +113,407 @@ Immediate, machine-speed response to identity compromise. ## Tech Stack -Mention all technologies used: -- Frontend: -- Backend: -- Database: -- APIs / Services: -- Tools / Libraries: +### Backend +- **Go** - High-performance core services and SPIRE agent integration +- **Python (FastAPI)** - AI-driven analytics and causal inference engine +- **Node.js/Express** - RESTful APIs and webhook handlers +- **gRPC** - Service-to-service communication for distributed systems + +### Database & Storage +- **Neo4j** - Graph database for behavioral StateGraphs and causal mappings +- **PostgreSQL** - Relational database for identity metadata and audit logs +- **Redis** - In-memory caching for Trust Scores and ephemeral tokens +- **etcd** - Distributed configuration and DID ledger synchronization + +### APIs & Services +- **SPIFFE/SPIRE** - Cryptographic identity and SVID issuance +- **OpenTelemetry** - Distributed tracing and telemetry collection +- **Kubernetes API** - Pod isolation and NetworkPolicy management +- **Open Policy Agent (OPA)** - Policy enforcement and decision-making +- **W3C DID Core Specification** - Decentralized identifier standards +- **VC Data Model (W3C)** - Verifiable Credentials framework --- ## Project Setup Instructions -Provide clear steps to run your project: +### Prerequisites +- **Kubernetes 1.24+** (for production deployment) +- **Docker 20.10+** (for containerization) +- **Go 1.21+** (for SPIRE agent) +- **Python 3.10+** (for AI analytics) +- **Node.js 18+** (for backend services) +- **Neo4j 5.0+** (for graph database) +- **PostgreSQL 14+** (for audit logs) + +### Development Setup + +#### 1. Clone the repository +```bash +git clone https://github.com/priyanshu5ingh/hacktofuture4-C06.git +cd hacktofuture4-C06 +``` + +#### 2. Install dependencies + +**Backend Services:** +```bash +# Install Go dependencies for SPIRE integration +cd backend/spire-agent +go mod download +go build -o spire-agent + +# Install Python dependencies for AI analytics +cd ../analytics +python3 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +pip install -r requirements.txt + +# Install Node.js dependencies for APIs +cd ../api +npm install +``` + +**Frontend:** +```bash +cd frontend +npm install +npm run build +``` + +#### 3. Configure Environment Variables +```bash +# Create .env file in project root +cat > .env << EOF +# SPIRE Configuration +SPIRE_SERVER_HOST=localhost +SPIRE_SERVER_PORT=8081 +SPIRE_AGENT_PORT=8082 + +# Database Configuration +NEO4J_URI=bolt://localhost:7687 +NEO4J_USER=neo4j +NEO4J_PASSWORD=your_secure_password + +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=aegis_did +POSTGRES_USER=postgres +POSTGRES_PASSWORD=your_secure_password + +REDIS_HOST=localhost +REDIS_PORT=6379 + +# API Configuration +API_PORT=3000 +API_HOST=0.0.0.0 + +# Kubernetes Configuration +KUBERNETES_NAMESPACE=aegis-system +KUBECONFIG=/path/to/kubeconfig + +# OPA Configuration +OPA_SERVER_URL=http://localhost:8181 + +# OpenTelemetry Configuration +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +EOF +``` + +#### 4. Start Services Using Docker Compose + +```bash +# Build all Docker images +docker-compose build + +# Start all services +docker-compose up -d + +# View logs +docker-compose logs -f +``` + +**docker-compose.yml** includes: +- Neo4j (Graph Database) +- PostgreSQL (Audit Logs) +- Redis (Token Cache) +- SPIRE Server & Agent +- FastAPI Analytics Service +- Node.js API Service +- React Frontend + +#### 5. Initialize Databases + +```bash +# Initialize Neo4j schema +npm run db:init:neo4j + +# Initialize PostgreSQL schema +npm run db:init:postgres + +# Load initial policy rules into OPA +npm run setup:opa +``` + +#### 6. Run Development Servers + +**Terminal 1 - Backend API:** +```bash +cd backend/api +npm run dev +# Runs on http://localhost:3000 +``` + +**Terminal 2 - Frontend:** +```bash +cd frontend +npm run dev +# Runs on http://localhost:5173 +``` + +**Terminal 3 - Python Analytics Service:** +```bash +cd backend/analytics +source venv/bin/activate +uvicorn main:app --reload --port 8000 +# Runs on http://localhost:8000 +``` + +**Terminal 4 - SPIRE Agent:** +```bash +cd backend/spire-agent +./spire-agent run -config agent.conf +``` + +### Kubernetes Deployment + +#### 1. Create Aegis-DID Namespace +```bash +kubectl create namespace aegis-system +``` + +#### 2. Deploy with Helm +```bash +# Add the repository +helm repo add aegis-did https://charts.aegis-did.io +helm repo update + +# Install the chart +helm install aegis-did aegis-did/aegis-did \ + --namespace aegis-system \ + --values values-production.yaml +``` + +#### 3. Apply NetworkPolicies +```bash +kubectl apply -f k8s/network-policies.yaml -n aegis-system +``` + +#### 4. Deploy OPA/Gatekeeper +```bash +kubectl apply -f k8s/opa-deployment.yaml -n aegis-system +``` + +#### 5. Verify Deployment +```bash +kubectl get pods -n aegis-system +kubectl get svc -n aegis-system +``` + +### Testing + +```bash +# Run unit tests +npm run test + +# Run integration tests +npm run test:integration + +# Run end-to-end tests (requires Kubernetes cluster) +npm run test:e2e + +# Generate coverage report +npm run test:coverage +``` + +### Building for Production + +```bash +# Build all services +npm run build:all + +# Build Docker images +docker build -t aegis-did-api:latest -f backend/api/Dockerfile . +docker build -t aegis-did-analytics:latest -f backend/analytics/Dockerfile . +docker build -t aegis-did-frontend:latest -f frontend/Dockerfile . + +# Push to container registry +docker tag aegis-did-api:latest your-registry/aegis-did-api:latest +docker push your-registry/aegis-did-api:latest +``` + +### Useful Commands ```bash -# Clone the repository -git clone +# View Trust Score metrics for an agent +curl http://localhost:3000/api/agents/{agent-id}/trust-score + +# Trigger manual policy evaluation +curl -X POST http://localhost:3000/api/policies/evaluate + +# Export behavioral graph for an agent +curl http://localhost:3000/api/agents/{agent-id}/behavior-graph + +# Check system health +curl http://localhost:3000/health + +# View audit logs +curl http://localhost:3000/api/audit-logs?limit=100 + +# Revoke agent credentials +curl -X POST http://localhost:3000/api/agents/{agent-id}/revoke +``` + +### Troubleshooting + +**Connection Issues:** +```bash +# Test SPIRE server connectivity +./spire-agent validate -config agent.conf + +# Test database connections +npm run test:db-connections + +# Check Kubernetes connectivity +kubectl cluster-info +``` + +**Performance Issues:** +```bash +# Monitor system resources +kubectl top nodes -n aegis-system +kubectl top pods -n aegis-system + +# Check Neo4j query performance +curl http://localhost:7687/browser/ +``` + +**Debug Mode:** +```bash +# Enable verbose logging +DEBUG=* npm run dev + +# Enable Kubernetes audit logging +kubectl edit audit-policy.yaml -n aegis-system +``` + +--- -# Install dependencies -... +## Architecture Overview -# Run the project -... ``` +┌─────────────────────────────────────────────────────────────┐ +│ AI Agent Workloads │ +│ (Kubernetes Pods) │ +└──────────┬──────────────────────────────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Aegis-DID Control Plane │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Identity Issuance (SPIRE/SPIFFE) │ │ +│ │ → Issues ephemeral SVIDs (TTL: 5-60s) │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ ↕ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Telemetry Collection (eBPF + OpenTelemetry) │ │ +│ │ → Captures kernel syscalls & network traffic │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ ↕ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Behavioral Analysis Engine (Neo4j + PyTorch) │ │ +│ │ → Builds StateGraphs via Neural Granger Causality │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ ↕ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Trust Scoring & Policy Evaluation (OPA) │ │ +│ │ → Computes T(a,t), triggers containment │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ ↕ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Autonomous Containment (NetworkPolicies) │ │ +│ │ → Isolates compromised pods via Cilium │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ↕ ↕ ↕ + [Neo4j] [PostgreSQL] [Redis Cache] + (StateGraphs) (Audit Logs) (Token TTLs, Trust Scores) +``` + +--- + +## Contributing + +We welcome contributions! Please follow these guidelines: + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/your-feature` +3. Commit changes: `git commit -m 'Add feature description'` +4. Push to branch: `git push origin feature/your-feature` +5. Submit a Pull Request + +### Development Standards +- Write unit tests for all new code (minimum 80% coverage) +- Follow ESLint and Prettier configurations +- Document complex algorithms and cryptographic logic +- Add comments explaining causal inference logic + +--- + +## Security Considerations + +- **Secret Management:** All credentials are stored in HashiCorp Vault +- **Audit Logging:** All identity operations are cryptographically signed and logged in PostgreSQL +- **TLS Enforcement:** All inter-service communication uses mTLS via SPIFFE +- **eBPF Sandboxing:** Kernel-level monitoring is confined to designated syscalls +- **Regular Key Rotation:** SVIDs are rotated every 60 seconds by default + +--- + +## Performance Metrics + +| Metric | Target | Current | +|--------|--------|---------| +| SVID Issuance Latency | <100ms | ~50ms | +| Trust Score Computation | <500ms | ~300ms | +| Policy Evaluation | <200ms | ~150ms | +| Pod Isolation Response | <2s | ~1.2s | +| Behavioral Graph Query | <1s | ~700ms | + +--- + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +--- + +## Contact & Support + +- **GitHub Issues:** [Report bugs or request features](https://github.com/priyanshu5ingh/hacktofuture4-C06/issues) +- **Documentation:** [Full technical docs](./docs/README.md) +- **Community:** Join our [Discord Server](https://discord.gg/aegis-did) +- **Email:** support@aegis-did.io + +--- + +## Acknowledgments + +- Cloud Security Alliance (CSA) for the Agentic Trust Framework specification +- CNCF for Kubernetes and related cloud-native technologies +- The open-source community for SPIFFE, eBPF, and Neo4j ecosystems + +--- + +**Team Dollar$ign (C06)** - Building the future of secure AI autonomy 🛡️ From c27403120f5f7f5ec2b02f2fb34c87414db1a5bd Mon Sep 17 00:00:00 2001 From: Mahesharunaladi Date: Wed, 15 Apr 2026 21:38:39 +0530 Subject: [PATCH 11/27] added readme --- README.md | 395 +----------------------------------------------------- 1 file changed, 5 insertions(+), 390 deletions(-) diff --git a/README.md b/README.md index 2fd482f90..2075174a5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # TEAM DOLLAR$IGN (C06) -## Problem Statement +## Problem Statement :T2PS1 + @@ -55,7 +56,7 @@ I am building Aegis-DID (Agentic Ephemeral Governance & Identity System), a dece ### 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 +* **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** @@ -63,7 +64,7 @@ I am building Aegis-DID (Agentic Ephemeral Governance & Identity System), a dece * **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 +* **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 @@ -114,406 +115,20 @@ Immediate, machine-speed response to identity compromise. ## Tech Stack -### Backend -- **Go** - High-performance core services and SPIRE agent integration -- **Python (FastAPI)** - AI-driven analytics and causal inference engine -- **Node.js/Express** - RESTful APIs and webhook handlers -- **gRPC** - Service-to-service communication for distributed systems - -### Database & Storage -- **Neo4j** - Graph database for behavioral StateGraphs and causal mappings -- **PostgreSQL** - Relational database for identity metadata and audit logs -- **Redis** - In-memory caching for Trust Scores and ephemeral tokens -- **etcd** - Distributed configuration and DID ledger synchronization - -### APIs & Services -- **SPIFFE/SPIRE** - Cryptographic identity and SVID issuance -- **OpenTelemetry** - Distributed tracing and telemetry collection -- **Kubernetes API** - Pod isolation and NetworkPolicy management -- **Open Policy Agent (OPA)** - Policy enforcement and decision-making -- **W3C DID Core Specification** - Decentralized identifier standards -- **VC Data Model (W3C)** - Verifiable Credentials framework - ---- - -## Project Setup Instructions - -### Prerequisites -- **Kubernetes 1.24+** (for production deployment) -- **Docker 20.10+** (for containerization) -- **Go 1.21+** (for SPIRE agent) -- **Python 3.10+** (for AI analytics) -- **Node.js 18+** (for backend services) -- **Neo4j 5.0+** (for graph database) -- **PostgreSQL 14+** (for audit logs) - -### Development Setup - -#### 1. Clone the repository -```bash -git clone https://github.com/priyanshu5ingh/hacktofuture4-C06.git -cd hacktofuture4-C06 -``` - -#### 2. Install dependencies - -**Backend Services:** -```bash -# Install Go dependencies for SPIRE integration -cd backend/spire-agent -go mod download -go build -o spire-agent - -# Install Python dependencies for AI analytics -cd ../analytics -python3 -m venv venv -source venv/bin/activate # On Windows: venv\Scripts\activate -pip install -r requirements.txt - -# Install Node.js dependencies for APIs -cd ../api -npm install -``` - -**Frontend:** -```bash -cd frontend -npm install -npm run build -``` - -#### 3. Configure Environment Variables -```bash -# Create .env file in project root -cat > .env << EOF -# SPIRE Configuration -SPIRE_SERVER_HOST=localhost -SPIRE_SERVER_PORT=8081 -SPIRE_AGENT_PORT=8082 - -# Database Configuration -NEO4J_URI=bolt://localhost:7687 -NEO4J_USER=neo4j -NEO4J_PASSWORD=your_secure_password - -POSTGRES_HOST=localhost -POSTGRES_PORT=5432 -POSTGRES_DB=aegis_did -POSTGRES_USER=postgres -POSTGRES_PASSWORD=your_secure_password - -REDIS_HOST=localhost -REDIS_PORT=6379 - -# API Configuration -API_PORT=3000 -API_HOST=0.0.0.0 - -# Kubernetes Configuration -KUBERNETES_NAMESPACE=aegis-system -KUBECONFIG=/path/to/kubeconfig - -# OPA Configuration -OPA_SERVER_URL=http://localhost:8181 - -# OpenTelemetry Configuration -OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 -EOF -``` - -#### 4. Start Services Using Docker Compose - -```bash -# Build all Docker images -docker-compose build - -# Start all services -docker-compose up -d - -# View logs -docker-compose logs -f -``` - -**docker-compose.yml** includes: -- Neo4j (Graph Database) -- PostgreSQL (Audit Logs) -- Redis (Token Cache) -- SPIRE Server & Agent -- FastAPI Analytics Service -- Node.js API Service -- React Frontend - -#### 5. Initialize Databases - -```bash -# Initialize Neo4j schema -npm run db:init:neo4j - -# Initialize PostgreSQL schema -npm run db:init:postgres - -# Load initial policy rules into OPA -npm run setup:opa -``` - -#### 6. Run Development Servers - -**Terminal 1 - Backend API:** -```bash -cd backend/api -npm run dev -# Runs on http://localhost:3000 -``` - -**Terminal 2 - Frontend:** -```bash -cd frontend -npm run dev -# Runs on http://localhost:5173 -``` - -**Terminal 3 - Python Analytics Service:** -```bash -cd backend/analytics -source venv/bin/activate -uvicorn main:app --reload --port 8000 -# Runs on http://localhost:8000 -``` - -**Terminal 4 - SPIRE Agent:** -```bash -cd backend/spire-agent -./spire-agent run -config agent.conf -``` - -### Kubernetes Deployment - -#### 1. Create Aegis-DID Namespace -```bash -kubectl create namespace aegis-system -``` - -#### 2. Deploy with Helm -```bash -# Add the repository -helm repo add aegis-did https://charts.aegis-did.io -helm repo update - -# Install the chart -helm install aegis-did aegis-did/aegis-did \ - --namespace aegis-system \ - --values values-production.yaml -``` - -#### 3. Apply NetworkPolicies -```bash -kubectl apply -f k8s/network-policies.yaml -n aegis-system -``` - -#### 4. Deploy OPA/Gatekeeper -```bash -kubectl apply -f k8s/opa-deployment.yaml -n aegis-system -``` - -#### 5. Verify Deployment -```bash -kubectl get pods -n aegis-system -kubectl get svc -n aegis-system -``` - -### Testing - -```bash -# Run unit tests -npm run test - -# Run integration tests -npm run test:integration - -# Run end-to-end tests (requires Kubernetes cluster) -npm run test:e2e - -# Generate coverage report -npm run test:coverage -``` - -### Building for Production - -```bash -# Build all services -npm run build:all - -# Build Docker images -docker build -t aegis-did-api:latest -f backend/api/Dockerfile . -docker build -t aegis-did-analytics:latest -f backend/analytics/Dockerfile . -docker build -t aegis-did-frontend:latest -f frontend/Dockerfile . - -# Push to container registry -docker tag aegis-did-api:latest your-registry/aegis-did-api:latest -docker push your-registry/aegis-did-api:latest -``` - -### Useful Commands - -```bash -# View Trust Score metrics for an agent -curl http://localhost:3000/api/agents/{agent-id}/trust-score - -# Trigger manual policy evaluation -curl -X POST http://localhost:3000/api/policies/evaluate - -# Export behavioral graph for an agent -curl http://localhost:3000/api/agents/{agent-id}/behavior-graph - -# Check system health -curl http://localhost:3000/health - -# View audit logs -curl http://localhost:3000/api/audit-logs?limit=100 -# Revoke agent credentials -curl -X POST http://localhost:3000/api/agents/{agent-id}/revoke -``` -### Troubleshooting -**Connection Issues:** -```bash -# Test SPIRE server connectivity -./spire-agent validate -config agent.conf -# Test database connections -npm run test:db-connections -# Check Kubernetes connectivity -kubectl cluster-info -``` - -**Performance Issues:** -```bash -# Monitor system resources -kubectl top nodes -n aegis-system -kubectl top pods -n aegis-system - -# Check Neo4j query performance -curl http://localhost:7687/browser/ -``` - -**Debug Mode:** -```bash -# Enable verbose logging -DEBUG=* npm run dev - -# Enable Kubernetes audit logging -kubectl edit audit-policy.yaml -n aegis-system -``` --- -## Architecture Overview - -``` -┌─────────────────────────────────────────────────────────────┐ -│ AI Agent Workloads │ -│ (Kubernetes Pods) │ -└──────────┬──────────────────────────────────────────────────┘ - │ - ↓ -┌─────────────────────────────────────────────────────────────┐ -│ Aegis-DID Control Plane │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Identity Issuance (SPIRE/SPIFFE) │ │ -│ │ → Issues ephemeral SVIDs (TTL: 5-60s) │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ↕ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Telemetry Collection (eBPF + OpenTelemetry) │ │ -│ │ → Captures kernel syscalls & network traffic │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ↕ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Behavioral Analysis Engine (Neo4j + PyTorch) │ │ -│ │ → Builds StateGraphs via Neural Granger Causality │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ↕ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Trust Scoring & Policy Evaluation (OPA) │ │ -│ │ → Computes T(a,t), triggers containment │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ↕ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Autonomous Containment (NetworkPolicies) │ │ -│ │ → Isolates compromised pods via Cilium │ │ -│ └─────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - ↕ ↕ ↕ - [Neo4j] [PostgreSQL] [Redis Cache] - (StateGraphs) (Audit Logs) (Token TTLs, Trust Scores) -``` - ---- - -## Contributing - -We welcome contributions! Please follow these guidelines: - -1. Fork the repository -2. Create a feature branch: `git checkout -b feature/your-feature` -3. Commit changes: `git commit -m 'Add feature description'` -4. Push to branch: `git push origin feature/your-feature` -5. Submit a Pull Request - -### Development Standards -- Write unit tests for all new code (minimum 80% coverage) -- Follow ESLint and Prettier configurations -- Document complex algorithms and cryptographic logic -- Add comments explaining causal inference logic - ---- - -## Security Considerations - -- **Secret Management:** All credentials are stored in HashiCorp Vault -- **Audit Logging:** All identity operations are cryptographically signed and logged in PostgreSQL -- **TLS Enforcement:** All inter-service communication uses mTLS via SPIFFE -- **eBPF Sandboxing:** Kernel-level monitoring is confined to designated syscalls -- **Regular Key Rotation:** SVIDs are rotated every 60 seconds by default - ---- - -## Performance Metrics - -| Metric | Target | Current | -|--------|--------|---------| -| SVID Issuance Latency | <100ms | ~50ms | -| Trust Score Computation | <500ms | ~300ms | -| Policy Evaluation | <200ms | ~150ms | -| Pod Isolation Response | <2s | ~1.2s | -| Behavioral Graph Query | <1s | ~700ms | - ---- - -## License - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +## Project Setup Instructions ---- -## Contact & Support -- **GitHub Issues:** [Report bugs or request features](https://github.com/priyanshu5ingh/hacktofuture4-C06/issues) -- **Documentation:** [Full technical docs](./docs/README.md) -- **Community:** Join our [Discord Server](https://discord.gg/aegis-did) -- **Email:** support@aegis-did.io ---- -## Acknowledgments -- Cloud Security Alliance (CSA) for the Agentic Trust Framework specification -- CNCF for Kubernetes and related cloud-native technologies -- The open-source community for SPIFFE, eBPF, and Neo4j ecosystems ---- -**Team Dollar$ign (C06)** - Building the future of secure AI autonomy 🛡️ From a17986fda1a930b9602fdfff123408ae027bcea5 Mon Sep 17 00:00:00 2001 From: Mahesharunaladi Date: Wed, 15 Apr 2026 21:39:15 +0530 Subject: [PATCH 12/27] added readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2075174a5..be5334e6f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ -#### Project Analysis: Aegis-DID +#### **Project Analysis: Aegis-DID** ## What is the Problem? From 304b3462ee58cf0226de527f66fa038306a71c1f Mon Sep 17 00:00:00 2001 From: Mahesharunaladi Date: Wed, 15 Apr 2026 21:40:06 +0530 Subject: [PATCH 13/27] added readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index be5334e6f..a61f9e5b8 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Traditional authentication mechanisms (such as passwords or Single Sign-On) are ## 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 : +* **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 From 99eb9feacbff7a5263565dfbdeaafd8b47ec00a9 Mon Sep 17 00:00:00 2001 From: Mahesharunaladi Date: Wed, 15 Apr 2026 21:40:50 +0530 Subject: [PATCH 14/27] added readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a61f9e5b8..9a66a1414 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ -#### **Project Analysis: Aegis-DID** +### Project Analysis: Aegis-DID ## What is the Problem? From ce8300c7fdef49497f85614f0dc31aff359f1c77 Mon Sep 17 00:00:00 2001 From: Mahesharunaladi Date: Wed, 15 Apr 2026 21:41:21 +0530 Subject: [PATCH 15/27] added readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9a66a1414..94b7117f0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Traditional authentication mechanisms (such as passwords or Single Sign-On) are ## Why is it important? -This project is critical because it moves beyond static security to a proactive, self-healing defense +* **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 From 49427729fc909bbfd8608d4007d11ce0a3e02720 Mon Sep 17 00:00:00 2001 From: Priyanshu Singh Date: Thu, 16 Apr 2026 05:13:35 +0530 Subject: [PATCH 16/27] Phase 1: Aegis Sovereign - Zero Trust Observability Dashboard Implementation --- .gitignore | 65 + analytics_engine/Dockerfile | 8 + analytics_engine/engine.py | 68 + analytics_engine/requirements.txt | 3 + conf/agent/agent.conf | 26 + conf/server/server.conf | 24 + docker-compose.yml | 101 + fluent-bit.conf | 26 + frontend/.gitignore | 24 + frontend/README.md | 16 + frontend/eslint.config.js | 29 + frontend/index.html | 13 + frontend/package-lock.json | 3825 +++++++++++++++++ frontend/package.json | 32 + frontend/postcss.config.js | 6 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.css | 184 + frontend/src/App.jsx | 845 ++++ frontend/src/assets/hero.png | Bin 0 -> 44919 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + frontend/src/index.css | 75 + frontend/src/main.jsx | 10 + frontend/tailwind.config.js | 28 + frontend/vite.config.js | 21 + .../provisioning/dashboards/dashboards.yml | 10 + grafana/provisioning/dashboards/main.json | 37 + .../provisioning/datasources/parseable.yml | 12 + mock_agent/Dockerfile | 7 + mock_agent/main.py | 58 + mock_agent/requirements.txt | 2 + spiffe_pkg/__init__.py | 26 + spiffe_pkg/_proto/__init__.py | 0 spiffe_pkg/_proto/workload_pb2.py | 73 + spiffe_pkg/_proto/workload_pb2.pyi | 388 ++ spiffe_pkg/_proto/workload_pb2_grpc.py | 298 ++ spiffe_pkg/bundle/__init__.py | 3 + spiffe_pkg/bundle/jwt_bundle/__init__.py | 3 + spiffe_pkg/bundle/jwt_bundle/errors.py | 39 + spiffe_pkg/bundle/jwt_bundle/jwt_bundle.py | 158 + .../bundle/jwt_bundle/jwt_bundle_set.py | 86 + spiffe_pkg/bundle/x509_bundle/__init__.py | 3 + spiffe_pkg/bundle/x509_bundle/errors.py | 47 + spiffe_pkg/bundle/x509_bundle/x509_bundle.py | 215 + .../bundle/x509_bundle/x509_bundle_set.py | 90 + spiffe_pkg/config.py | 142 + spiffe_pkg/errors.py | 27 + spiffe_pkg/py.typed | 0 spiffe_pkg/spiffe_id/__init__.py | 3 + spiffe_pkg/spiffe_id/spiffe_id.py | 219 + spiffe_pkg/svid/__init__.py | 3 + spiffe_pkg/svid/errors.py | 82 + spiffe_pkg/svid/jwt_svid.py | 193 + spiffe_pkg/svid/jwt_svid_validator.py | 169 + spiffe_pkg/svid/x509_svid.py | 367 ++ spiffe_pkg/utils/__init__.py | 0 spiffe_pkg/utils/certificate_utils.py | 284 ++ spiffe_pkg/utils/errors.py | 63 + spiffe_pkg/workloadapi/__init__.py | 3 + spiffe_pkg/workloadapi/errors.py | 74 + spiffe_pkg/workloadapi/grpc/__init__.py | 3 + .../grpc/generic_client_interceptor.py | 100 + .../header_manipulator_client_interceptor.py | 62 + spiffe_pkg/workloadapi/handle_error.py | 60 + spiffe_pkg/workloadapi/jwt_source.py | 270 ++ spiffe_pkg/workloadapi/workload_api_client.py | 692 +++ spiffe_pkg/workloadapi/x509_context.py | 69 + spiffe_pkg/workloadapi/x509_source.py | 295 ++ tetragon/tracing_policy.yaml | 21 + 70 files changed, 10212 insertions(+) create mode 100644 .gitignore create mode 100644 analytics_engine/Dockerfile create mode 100644 analytics_engine/engine.py create mode 100644 analytics_engine/requirements.txt create mode 100644 conf/agent/agent.conf create mode 100644 conf/server/server.conf create mode 100644 docker-compose.yml create mode 100644 fluent-bit.conf create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js create mode 100644 grafana/provisioning/dashboards/dashboards.yml create mode 100644 grafana/provisioning/dashboards/main.json create mode 100644 grafana/provisioning/datasources/parseable.yml create mode 100644 mock_agent/Dockerfile create mode 100644 mock_agent/main.py create mode 100644 mock_agent/requirements.txt create mode 100644 spiffe_pkg/__init__.py create mode 100644 spiffe_pkg/_proto/__init__.py create mode 100644 spiffe_pkg/_proto/workload_pb2.py create mode 100644 spiffe_pkg/_proto/workload_pb2.pyi create mode 100644 spiffe_pkg/_proto/workload_pb2_grpc.py create mode 100644 spiffe_pkg/bundle/__init__.py create mode 100644 spiffe_pkg/bundle/jwt_bundle/__init__.py create mode 100644 spiffe_pkg/bundle/jwt_bundle/errors.py create mode 100644 spiffe_pkg/bundle/jwt_bundle/jwt_bundle.py create mode 100644 spiffe_pkg/bundle/jwt_bundle/jwt_bundle_set.py create mode 100644 spiffe_pkg/bundle/x509_bundle/__init__.py create mode 100644 spiffe_pkg/bundle/x509_bundle/errors.py create mode 100644 spiffe_pkg/bundle/x509_bundle/x509_bundle.py create mode 100644 spiffe_pkg/bundle/x509_bundle/x509_bundle_set.py create mode 100644 spiffe_pkg/config.py create mode 100644 spiffe_pkg/errors.py create mode 100644 spiffe_pkg/py.typed create mode 100644 spiffe_pkg/spiffe_id/__init__.py create mode 100644 spiffe_pkg/spiffe_id/spiffe_id.py create mode 100644 spiffe_pkg/svid/__init__.py create mode 100644 spiffe_pkg/svid/errors.py create mode 100644 spiffe_pkg/svid/jwt_svid.py create mode 100644 spiffe_pkg/svid/jwt_svid_validator.py create mode 100644 spiffe_pkg/svid/x509_svid.py create mode 100644 spiffe_pkg/utils/__init__.py create mode 100644 spiffe_pkg/utils/certificate_utils.py create mode 100644 spiffe_pkg/utils/errors.py create mode 100644 spiffe_pkg/workloadapi/__init__.py create mode 100644 spiffe_pkg/workloadapi/errors.py create mode 100644 spiffe_pkg/workloadapi/grpc/__init__.py create mode 100644 spiffe_pkg/workloadapi/grpc/generic_client_interceptor.py create mode 100644 spiffe_pkg/workloadapi/grpc/header_manipulator_client_interceptor.py create mode 100644 spiffe_pkg/workloadapi/handle_error.py create mode 100644 spiffe_pkg/workloadapi/jwt_source.py create mode 100644 spiffe_pkg/workloadapi/workload_api_client.py create mode 100644 spiffe_pkg/workloadapi/x509_context.py create mode 100644 spiffe_pkg/workloadapi/x509_source.py create mode 100644 tetragon/tracing_policy.yaml 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/analytics_engine/Dockerfile b/analytics_engine/Dockerfile new file mode 100644 index 000000000..298976e60 --- /dev/null +++ b/analytics_engine/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')" +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..76ba0c92d --- /dev/null +++ b/analytics_engine/engine.py @@ -0,0 +1,68 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from sentence_transformers import SentenceTransformer, util + +app = FastAPI(title="Aegis-DID Analytics Engine") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +active_state = {"trust_score": 1.0, "intent_drift_detected": False} + +# Preload lightweight NLP model for intent verification +model = SentenceTransformer('all-MiniLM-L6-v2') + +class TrustRequest(BaseModel): + assigned_intent: str + current_action: str + +class TrustResponse(BaseModel): + trust_score: float + intent_drift_detected: bool + +@app.post("/calculate_trust", response_model=TrustResponse) +def calculate_trust(req: TrustRequest): + # Encode vectors for the textual descriptions of what the agent is supposed to do vs what it is about to do + embeddings = model.encode([req.assigned_intent, req.current_action]) + + # Calculate dimensional cosine similarity + score = util.cos_sim(embeddings[0], embeddings[1]).item() + + # 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": True, "version": "2.4.0"} + +@app.get("/model_info") +def model_info(): + 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 + } diff --git a/analytics_engine/requirements.txt b/analytics_engine/requirements.txt new file mode 100644 index 000000000..432c03d2d --- /dev/null +++ b/analytics_engine/requirements.txt @@ -0,0 +1,3 @@ +fastapi +uvicorn +sentence-transformers 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..c573b4dbb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,101 @@ +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", "1d1c54a1-98a3-43ed-833f-b0a29797f920"] + + 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=admin + - P_PASSWORD=admin + - 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 + 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 + + grafana: + image: grafana/grafana:10.4.1 + container_name: grafana + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - 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/fluent-bit.conf b/fluent-bit.conf new file mode 100644 index 000000000..d35036c9e --- /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 YWRtaW46YWRtaW4= + 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..7f8fff891 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,845 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + Shield, Activity, Fingerprint, Lock, Zap, Terminal, Server, + AlertTriangle, ChevronRight, Eye, Radio, Database, Cpu, Globe, + Network, FileWarning, ShieldAlert, ShieldCheck, Clock, Hash, + TrendingDown, TrendingUp, Layers, Binary, BarChart3, Workflow +} from 'lucide-react'; +import { + LineChart, Line, AreaChart, Area, BarChart, Bar, + XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + RadarChart, PolarGrid, PolarAngleAxis, Radar, Cell, PieChart, Pie +} from 'recharts'; + +// ─── UTILITY COMPONENTS ──────────────────────────────────────────────── + +const StatusDot = ({ color = 'cyan', pulse = true }) => ( + +); + +const Card = ({ children, className = '', delay = 0, glow = '' }) => ( +
+ {children} +
+); + +const MiniSparkline = ({ color = '#22d3ee' }) => { + const points = Array.from({ length: 12 }, (_, i) => 10 + Math.random() * 20); + const max = Math.max(...points); + const path = points.map((p, i) => `${i * (60/11)},${30 - (p/max)*25}`).join(' L '); + return ; +}; + +const KPI = ({ label, value, unit, icon: Icon, color = 'cyan', delay = 0, sparkColor }) => ( + +
+
+

{label}

+
+ {value} + {unit && {unit}} +
+ +
+
+ +
+
+
+); + +const SectionHeader = ({ icon: Icon, title, subtitle, color = 'cyan' }) => ( +
+
+ +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+); + +const Toast = ({ message, type = 'info', visible }) => { + if (!visible) return null; + const colors = { info: 'bg-sky-500/15 border-sky-500/30 text-sky-400', danger: 'bg-red-500/15 border-red-500/30 text-red-400', success: 'bg-emerald-500/15 border-emerald-500/30 text-emerald-400', warn: 'bg-amber-500/15 border-amber-500/30 text-amber-400' }; + return ( +
+ {message} +
+ ); +}; + +const useLiveClock = () => { + const [time, setTime] = useState(new Date()); + useEffect(() => { const t = setInterval(() => setTime(new Date()), 1000); return () => clearInterval(t); }, []); + return time; +}; + +// ─── SIMULATED DATA GENERATORS ────────────────────────────────────────── + +const generateTrustHistory = (isAttack) => { + const now = Date.now(); + return Array.from({ length: 30 }, (_, i) => { + const t = now - (29 - i) * 2000; + let score; + if (isAttack && i > 22) { + score = Math.max(5, 100 - (i - 22) * 12 + Math.random() * 5); + } else { + score = 92 + Math.random() * 8; + } + return { + time: new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), + score: parseFloat(score.toFixed(1)), + threshold: 50 + }; + }); +}; + +const generateSyscalls = () => [ + { name: 'sys_read', count: 4821, pct: 35 }, + { name: 'sys_write', count: 3102, pct: 22 }, + { name: 'sys_open', count: 2450, pct: 18 }, + { name: 'sys_close', count: 1890, pct: 14 }, + { name: 'sys_mmap', count: 980, pct: 7 }, + { name: 'sys_connect', count: 560, pct: 4 }, +]; + +const generateRadarData = (isAttack) => [ + { metric: 'File Access', A: isAttack ? 95 : 30 }, + { metric: 'Network I/O', A: isAttack ? 70 : 45 }, + { metric: 'Process Fork', A: isAttack ? 40 : 20 }, + { metric: 'Memory Alloc', A: isAttack ? 60 : 35 }, + { metric: 'Privilege Esc', A: isAttack ? 85 : 5 }, + { metric: 'Crypto Ops', A: isAttack ? 30 : 55 }, +]; + +const generatePolicyRules = (isAttack) => [ + { id: 'POL-001', rule: 'deny file.read("/etc/shadow")', status: 'ENFORCED', hits: 12 }, + { id: 'POL-002', rule: 'deny file.read("/forbidden_secrets.txt")', status: isAttack ? 'TRIGGERED' : 'ARMED', hits: isAttack ? 1 : 0 }, + { id: 'POL-003', rule: 'allow net.connect(443)', status: 'PASS', hits: 847 }, + { id: 'POL-004', rule: 'deny process.exec("curl")', status: 'ENFORCED', hits: 3 }, + { id: 'POL-005', rule: 'allow spiffe.verify(agent/*)', status: 'PASS', hits: 1204 }, + { id: 'POL-006', rule: 'deny net.connect(0.0.0.0/0:22)', status: 'ENFORCED', hits: 56 }, +]; + +const generateCertTimeline = () => [ + { time: '23:42:01', event: 'X.509 SVID issued', ttl: '60s', status: 'ok' }, + { time: '23:41:01', event: 'Certificate rotated', ttl: '60s', status: 'ok' }, + { time: '23:40:01', event: 'mTLS handshake verified', ttl: '58s', status: 'ok' }, + { time: '23:39:02', event: 'Workload API attestation', ttl: '60s', status: 'ok' }, + { time: '23:38:01', event: 'Trust bundle refresh', ttl: '60s', status: 'ok' }, + { time: '23:37:00', event: 'Node attestation complete', ttl: '60s', status: 'ok' }, +]; + +const generateNetworkFlows = () => Array.from({ length: 20 }, (_, i) => ({ + time: new Date(Date.now() - i * 3000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), + ingress: Math.floor(Math.random() * 400 + 200), + egress: Math.floor(Math.random() * 300 + 100), +})).reverse(); + +// ─── SCREEN COMPONENTS ────────────────────────────────────────────────── + +function OverviewScreen({ trustScore, isAttack, logs, ambushPhase }) { + const trustHistory = generateTrustHistory(isAttack); + + return ( +
+ {/* KPI Row */} +
+ + + + +
+ + {/* Zero-Trust Architecture Pipeline */} + +

Zero-Trust Security Pipeline

+

Data flows left-to-right through 4 autonomous layers. Each layer independently evaluates and can terminate a request.

+
+ {[ + { label: 'L1: IDENTITY', tech: 'SPIRE / mTLS', desc: 'Issues X.509 SVIDs to prove workload identity', color: 'sky', icon: Fingerprint, active: true }, + { label: 'L2: TELEMETRY', tech: 'Cilium Tetragon', desc: 'Hooks kernel syscalls via eBPF ring buffers', color: 'purple', icon: Activity, active: true }, + { label: 'L3: ANALYTICS', tech: 'PyTorch / FastAPI', desc: 'Computes cosine similarity on sentence embeddings', color: 'emerald', icon: Zap, active: isAttack }, + { label: 'L4: ENFORCEMENT', tech: 'OPA + SIGKILL', desc: 'Evaluates Rego policies & executes autonomous response', color: 'amber', icon: Lock, active: isAttack }, + ].map((layer, i) => ( + +
= 2 ? `bg-red-500/5 border-red-500/30` : `bg-${layer.color}-500/5 border-${layer.color}-500/15`}`}> +
+ + {layer.label} +
+

{layer.tech}

+

{layer.desc}

+
+ {i < 3 && } +
+ ))} +
+
+ + {/* Main Charts Row */} +
+ {/* Trust Score Chart */} + +
+

+ Real-Time Trust Score +

+
+ + LIVE +
+
+ + + + + + + + + + + + + + + + +
+ + {/* Behavioral Radar */} + +

+ Behavioral Profile +

+ + + + + + + +
+
+ + {/* Live Event Feed */} + +
+

+ Global Event Stream +

+ {logs.length} EVENTS CAPTURED +
+
+ {logs.length === 0 ? ( +

Awaiting telemetry stream...

+ ) : logs.map(l => ( +
+ {l.time} + + {l.isSigkill ? 'THREAT' : 'INFO'} + + {l.action} +
+ ))} +
+
+
+ ); +} + +function IdentityScreen({ isAttack }) { + const certs = generateCertTimeline(); + const pieData = [ + { name: 'Verified', value: 2847, color: '#34d399' }, + { name: 'Pending', value: 42, color: '#f59e0b' }, + { name: 'Revoked', value: isAttack ? 1 : 0, color: '#ef4444' }, + ]; + return ( +
+ + +

How it works: SPIRE (the SPIFFE Runtime Environment) issues short-lived X.509 certificates (SVIDs) to every workload. Unlike static API keys, these certificates auto-rotate every 60 seconds, making stolen credentials useless. Each agent proves its identity through hardware-backed node attestation before receiving any certificate.

+
+ +
+ + + + +
+ +
+ {/* Certificate Lifecycle */} + +

+ Certificate Lifecycle Events +

+
+ {certs.map((c, i) => ( +
+
+ {c.time} + {c.event} + TTL {c.ttl} +
+ ))} +
+ + + {/* Identity Distribution */} + +

Identity Distribution

+ + + + {pieData.map((entry, i) => )} + + + + +
+ {pieData.map((d, i) => ( +
+ {d.name} +
+ ))} +
+
+
+ + {/* Current SVID */} + +

Active Workload Identity

+
+

SPIFFE ID: spiffe://aegis.did/sentinel/agent/01

+

Trust Domain: aegis.did

+

Issuer: SPIRE Server (Node Attestation: join_token)

+

Serial: 7A:3F:B2:91:C4:D8:E6:02

+

Not After: {new Date(Date.now() + 42000).toISOString()}

+

Key Type: EC P-256

+
+
+
+ ); +} + +function TelemetryScreen({ isAttack, logs }) { + const syscalls = generateSyscalls(); + const networkFlows = generateNetworkFlows(); + + return ( +
+ + +

How it works: Cilium Tetragon attaches eBPF programs directly to Linux kernel functions (kprobes, tracepoints). Unlike userspace monitoring, eBPF runs inside the kernel itself — meaning no process can hide from it, tamper with it, or outrun it. Every file open, network connection, and process execution is captured at the syscall level with nanosecond precision.

+
+ +
+ + + + +
+ +
+ {/* Syscall Breakdown */} + +

+ Syscall Distribution +

+ + + + + + + + + +
+ + {/* Network Flow */} + +

+ Network I/O (bytes/s) +

+ + + + + + + + + + + + + + + + + + + + +
+
+ + {/* Live Kernel Log Terminal */} + +
+

+ Kernel Event Stream +

+
+ + TETRAGON +
+
+
+ {logs.length === 0 ? ( + <> +

[kernel] eBPF progs loaded: kprobe/sys_enter_openat, tracepoint/sched_process_exec

+

[kernel] Ring buffer attached, awaiting events...

+

_

+ + ) : logs.map(l => ( +

+ [{l.time}] {l.isSigkill ? '⚠ CRITICAL: ' : ''}{l.action} +

+ ))} +
+
+
+ ); +} + +function AnalyticsScreen({ trustScore, isAttack }) { + const trustHistory = generateTrustHistory(isAttack); + const embeddingData = Array.from({ length: 20 }, (_, i) => ({ + dim: `D${i}`, + assigned: parseFloat((Math.random() * 0.8 + 0.1).toFixed(2)), + observed: parseFloat((isAttack ? Math.random() * 0.9 : Math.random() * 0.8 + 0.1).toFixed(2)), + })); + + return ( +
+ + +

How it works: The FastAPI engine takes two inputs — the agent's assigned intent (e.g., "summarize documents") and its observed action (e.g., "read /forbidden_secrets.txt"). Both are encoded into 384-dimensional vectors using the all-MiniLM-L6-v2 sentence transformer. The cosine similarity between these vectors produces the Trust Score. A score below 0.5 means the agent's behavior has drifted from its mandate — triggering enforcement.

+
+ +
+ + + + +
+ +
+ {/* Trust History */} + +

Trust Score Over Time

+ + + + + + + + + + + + + + + + +
+ + {/* Embedding Comparison */} + +

Embedding Vector Comparison

+ + + + + + + + + + +
+
+ + {/* Model Info */} + +

Model Pipeline

+
+

Model: sentence-transformers/all-MiniLM-L6-v2

+

Embedding Dims: 384 (float32)

+

Task: Semantic Similarity (Cosine Distance)

+

Assigned Intent: "summarize internal project documents"

+

Observed Action: {isAttack ? '"read /app/forbidden_secrets.txt"' : '"read /app/project_docs.txt"'}

+

Cosine Similarity: {(trustScore / 100).toFixed(4)}

+

Verdict: {isAttack ? '⚠ INTENT DRIFT — ENFORCEMENT REQUIRED' : '✓ BEHAVIOR WITHIN EXPECTED BOUNDS'}

+
+
+ + {/* Cosine Similarity Formula */} + +

Trust Score Computation

+
+

cos(θ) = (A · B) / (‖A‖ × ‖B‖)

+

Where A = embed("assigned intent"), B = embed("observed action"), each ∈ ℝ³⁸⁴

+
+

THRESHOLD

0.500

+

CURRENT

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

+

RESULT

{isAttack ? 'DRIFT' : 'PASS'}

+
+
+
+
+ ); +} + +function EnforcementScreen({ isAttack, trustScore }) { + const rules = generatePolicyRules(isAttack); + return ( +
+ + +

How it works: Open Policy Agent (OPA) evaluates Rego policies against every request. When the ML engine flags intent drift, OPA's policy POL-002 matches and instructs Tetragon to issue a kernel-level SIGKILL to the offending process. Simultaneously, a Kubernetes NetworkPolicy isolates the pod from the cluster mesh. The entire response is autonomous — no human intervention required.

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

+ Rego Policy Ruleset +

+
+ + + + + + + + + + + {rules.map(r => ( + + + + + + + ))} + +
IDREGO RULESTATUSHITS
{r.id}{r.rule} + {r.status} + {r.hits}
+
+
+ + {/* Enforcement Decision Panel */} + {isAttack && ( + +
+
+ +
+
+

AUTONOMOUS ENFORCEMENT EXECUTED

+

OPA policy POL-002 matched. Tetragon enforced SIGKILL on agent process.

+
+

Action: SIGKILL sent to PID 4721

+

NetworkPolicy: Pod isolated from cluster mesh

+

SPIFFE SVID: Marked for immediate revocation

+

Recovery: Awaiting operator clearance...

+
+
+
+
+ )} + + {/* Autonomous Response Timeline */} + +

Autonomous Response Timeline

+
+ {[ + { time: 'T+0ms', label: 'Agent accesses /forbidden_secrets.txt', detail: 'sys_enter_openat intercepted by Tetragon kprobe on fd_install', color: 'purple', done: true }, + { time: 'T+2ms', label: 'eBPF event streamed to Parseable', detail: 'Fluent Bit forwards matched event via HTTP to log ingestion', color: 'sky', done: true }, + { time: 'T+84ms', label: 'PyTorch computes cosine similarity', detail: 'Embedding vectors diverge — score drops below 0.5 threshold', color: 'emerald', done: isAttack }, + { time: 'T+86ms', label: 'OPA Rego policy POL-002 triggers', detail: 'deny file.read("/forbidden_secrets.txt") matched', color: 'amber', done: isAttack }, + { time: 'T+87ms', label: 'SIGKILL dispatched to agent PID', detail: 'Tetragon matchAction: Sigkill — process terminated at kernel', color: 'red', done: isAttack }, + { time: 'T+90ms', label: 'NetworkPolicy isolates pod', detail: 'Kubernetes revokes egress, SPIRE SVID marked for revocation', color: 'red', done: isAttack }, + ].map((step, i) => ( +
+
+
+ {step.time} + {step.label} +
+

{step.detail}

+
+ ))} +
+ +
+ ); +} + +// ─── MAIN APPLICATION ─────────────────────────────────────────────────── + +export default function SovereignSentinel() { + const [trustScore, setTrustScore] = useState(100.0); + const [isAttack, setIsAttack] = useState(false); + const [logs, setLogs] = useState([]); + const [activeScreen, setActiveScreen] = useState('overview'); + const [ambushPhase, setAmbushPhase] = useState('IDLE'); + const [toast, setToast] = useState({ message: '', type: 'info', visible: false }); + const clock = useLiveClock(); + + const showToast = (message, type = 'info') => { + setToast({ message, type, visible: true }); + setTimeout(() => setToast(prev => ({ ...prev, visible: false })), 3000); + }; + + // Live Backend Polling + useEffect(() => { + const scoreInterval = setInterval(async () => { + try { + const res = await fetch('/analytics/latest_score'); + if (res.ok) { + const data = await res.json(); + const score = data.trust_score !== undefined ? data.trust_score : data.score; + if (score !== undefined && !isAttack) setTrustScore((score * 100).toFixed(1)); + if (data.intent_drift_detected || (score !== undefined && score < 0.5)) setIsAttack(true); + } + } catch (e) {} + }, 1000); + + const logInterval = setInterval(async () => { + try { + const authHeader = 'Basic ' + btoa('admin:admin'); + const res = await fetch('/parseable/api/v1/logstream/tetragon?limit=5', { headers: { Authorization: authHeader } }); + if (res.ok) { + const newLogs = await res.json(); + if (newLogs && newLogs.length > 0) { + const formatted = newLogs.reverse().map((log, i) => { + const action = log.action || log.event?.action || 'sys_enter_openat'; + const isSigkill = (log.matchAction || log.event?.matchAction) === 'Sigkill' || String(log.event).includes('forbidden'); + return { + id: Date.now() + i, + time: log.p_timestamp ? new Date(log.p_timestamp).toLocaleTimeString() : new Date().toLocaleTimeString(), + action: isSigkill ? 'Read /forbidden_secrets.txt' : action, + isSigkill + }; + }); + setLogs(prev => [...formatted, ...prev].slice(0, 20)); + if (formatted.some(l => l.isSigkill)) setIsAttack(true); + } + } + } catch (e) {} + }, 1000); + return () => { clearInterval(scoreInterval); clearInterval(logInterval); }; + }, [isAttack]); + + // ── AMBUSH SEQUENCE ── + const executeAmbush = () => { + setAmbushPhase('BREACH'); + setActiveScreen('telemetry'); + showToast('⚠ BREACH: Unauthorized file access detected on Agent-01', 'danger'); + setLogs(prev => [ + { id: Date.now(), time: new Date().toLocaleTimeString(), action: 'sys_enter_openat: /app/forbidden_secrets.txt', isSigkill: true }, + { id: Date.now()+1, time: new Date().toLocaleTimeString(), action: 'kprobe: file_permission check FAILED', isSigkill: true }, + ...prev + ].slice(0, 20)); + + setTimeout(() => { + setAmbushPhase('DETECT'); + setActiveScreen('analytics'); + setIsAttack(true); + setTrustScore(21.4); + showToast('🧠 ML Engine: Cosine similarity collapse — intent drift confirmed', 'warn'); + setTimeout(() => setTrustScore(8.7), 400); + setTimeout(() => setTrustScore(4.1), 800); + }, 2500); + + setTimeout(() => { + setAmbushPhase('ENFORCE'); + setActiveScreen('enforcement'); + setTrustScore(2.3); + showToast('🛡 OPA: SIGKILL dispatched — Agent-01 process terminated', 'danger'); + setLogs(prev => [ + { id: Date.now() + 99, time: new Date().toLocaleTimeString(), action: 'OPA ENFORCE: SIGKILL -> PID 4721 (forbidden_secrets.txt)', isSigkill: false, isSys: true }, + { id: Date.now() + 100, time: new Date().toLocaleTimeString(), action: 'NetworkPolicy: Pod egress revoked, mesh isolated', isSigkill: false, isSys: true }, + ...prev + ].slice(0, 20)); + }, 5000); + + setTimeout(() => { + setAmbushPhase('NEUTRALIZED'); + showToast('✅ Threat neutralized. SVID revoked. Awaiting operator clearance.', 'success'); + }, 7000); + }; + + const resetEnvironment = () => { + setIsAttack(false); + setTrustScore(100.0); + setAmbushPhase('IDLE'); + setLogs([]); + setActiveScreen('overview'); + }; + + const navItems = [ + { id: 'overview', label: 'Command Center', icon: Globe }, + { id: 'identity', label: 'Identity (SPIRE)', icon: Fingerprint }, + { id: 'telemetry', label: 'Telemetry (eBPF)', icon: Activity }, + { id: 'analytics', label: 'Analytics (ML)', icon: Zap }, + { id: 'enforcement', label: 'Enforcement (OPA)', icon: Lock }, + ]; + + return ( +
+ + {/* Toast Notifications */} + + + {/* Red Alert Border */} + {isAttack &&
} + + {/* ─── LEFT SIDEBAR ─── */} +
+ {/* Logo */} +
+
+
+ +
+
+

SOVEREIGN

+

SENTINEL v2.4

+
+
+
+ + {/* Navigation */} + + + {/* Agent Status */} +
+
+
+ + SENTINEL-01 +
+

spiffe://aegis.did/agent/01

+

+ {isAttack ? '⚠ COMPROMISED' : '● OPERATIONAL'} +

+
+
+
+ + {/* ─── MAIN CONTENT ─── */} +
+ {/* Top Bar */} +
+
+

{navItems.find(n => n.id === activeScreen)?.label}

+ {ambushPhase !== 'IDLE' && ( + + {ambushPhase === 'BREACH' ? '🔴 BREACH DETECTED' : + ambushPhase === 'DETECT' ? '🟡 ANALYZING THREAT' : + ambushPhase === 'ENFORCE' ? '🟣 ENFORCING SIGKILL' : + '🟢 THREAT NEUTRALIZED'} + + )} +
+
+ {clock.toLocaleTimeString()} + {ambushPhase !== 'IDLE' ? ( + + ) : ( + + )} +
+
+ + {/* Scrollable Content Area */} +
+ {activeScreen === 'overview' && } + {activeScreen === 'identity' && } + {activeScreen === 'telemetry' && } + {activeScreen === 'analytics' && } + {activeScreen === 'enforcement' && } +
+ + {/* Bottom Status Bar */} +
+
+ SPIRE Server + Tetragon eBPF + FastAPI Engine + OPA Gateway +
+
+ Uptime: {Math.floor((Date.now() % 86400000) / 3600000)}h {Math.floor((Date.now() % 3600000) / 60000)}m + Latency: 0.84ms + Status: {isAttack ? 'ALERT' : 'NOMINAL'} +
+
+
+
+ ); +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..cc51a3d20ad4bc961b596a6adfd686685cd84bb0 GIT binary patch literal 44919 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 JPDHLkV1myZcL)Fg literal 0 HcmV?d00001 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/index.css b/frontend/src/index.css new file mode 100644 index 000000000..2b2b936a5 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,75 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --bg-primary: #0b0e14; + --bg-card: rgba(17, 22, 32, 0.85); + --border-subtle: rgba(56, 189, 248, 0.12); + --cyan-glow: rgba(34, 211, 238, 0.15); + --text-primary: #e2e8f0; + --text-muted: #64748b; +} + +* { + scrollbar-width: thin; + scrollbar-color: rgba(56, 189, 248, 0.2) transparent; +} + +body { + margin: 0; + background: var(--bg-primary); + color: var(--text-primary); + font-family: 'Inter', system-ui, sans-serif; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; +} + +/* Smooth page transitions */ +.page-enter { opacity: 0; transform: translateY(12px); } +.page-active { opacity: 1; transform: translateY(0); transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); } + +/* Glow effects */ +.glow-cyan { box-shadow: 0 0 20px rgba(34, 211, 238, 0.15), inset 0 1px 0 rgba(34, 211, 238, 0.1); } +.glow-red { box-shadow: 0 0 30px rgba(239, 68, 68, 0.25), inset 0 1px 0 rgba(239, 68, 68, 0.15); } +.glow-green { box-shadow: 0 0 20px rgba(52, 211, 153, 0.15), inset 0 1px 0 rgba(52, 211, 153, 0.1); } +.glow-purple { box-shadow: 0 0 20px rgba(168, 85, 247, 0.15), inset 0 1px 0 rgba(168, 85, 247, 0.1); } +.glow-amber { box-shadow: 0 0 20px rgba(245, 158, 11, 0.15), inset 0 1px 0 rgba(245, 158, 11, 0.1); } + +/* Animated gradient border */ +@keyframes borderGlow { + 0%, 100% { border-color: rgba(56, 189, 248, 0.15); } + 50% { border-color: rgba(56, 189, 248, 0.35); } +} +.animate-border-glow { animation: borderGlow 3s ease-in-out infinite; } + +/* Pulse dot */ +@keyframes livePulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(1.5); } +} +.animate-live-pulse { animation: livePulse 2s ease-in-out infinite; } + +/* Slide up for cards */ +@keyframes slideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} +.animate-slide-up { animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; } + +/* Number counter effect */ +@keyframes countUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +.animate-count { animation: countUp 0.3s ease-out; } + +/* Attack flash */ +@keyframes attackFlash { + 0%, 100% { background-color: rgba(239, 68, 68, 0); } + 50% { background-color: rgba(239, 68, 68, 0.05); } +} +.animate-attack-flash { animation: attackFlash 1s ease-in-out infinite; } 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..92e53f775 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,28 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + background: "#0a0f16", + emerald: { + 400: "#34d399", + 500: "#10b981", + }, + rose: { + 400: "#fb7185", + 500: "#f43f5e", + 600: "#e11d48", + 950: "#4c0519", + } + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + }, + }, + }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 000000000..6d1a5bf76 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/analytics': { + target: 'http://localhost:8000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/analytics/, '') + }, + '/parseable': { + target: 'http://localhost:8081', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/parseable/, '') + } + } + } +}) 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..9691ffb21 --- /dev/null +++ b/mock_agent/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +RUN echo "TOP_SECRET_DATA_DO_NOT_READ" > /tmp/bypass.txt && mv /tmp/bypass.txt /app/forbidden_secrets.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..f6a57557f --- /dev/null +++ b/mock_agent/main.py @@ -0,0 +1,58 @@ +import os +import time +import requests +from spiffe.workloadapi.workload_api_client import WorkloadApiClient + +def main(): + print("Starting Mock AI Agent...") + 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() + + while True: + try: + # Fetch the X.509 SVID + x509_context = client.fetch_x509_context() + svid = x509_context.default_svid + print("==================================================") + print("Successfully fetched X.509 SVID!") + print(f"SPIFFE ID: {svid.spiffe_id}") + print("==================================================") + + assigned_intent = "summarize internal project documents" + current_action = "read /app/forbidden_secrets.txt" + + try: + print("Consulting Analytics Engine for trust score...") + resp = requests.post( + "http://analytics-engine:8000/calculate_trust", + json={"assigned_intent": assigned_intent, "current_action": current_action}, + timeout=5 + ) + resp.raise_for_status() + data = resp.json() + print(f"Trust Score: {data.get('trust_score'):.4f} - Intent Drift Detected: {data.get('intent_drift_detected')}") + except Exception as err: + print(f"Analytics engine error: {err}") + + print("Simulating intent drift...") + try: + with open("/app/forbidden_secrets.txt", "r") as f: + f.read() + except Exception as fe: + print(f"File access failed or blocked: {fe}") + + 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..60ee5616a --- /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/forbidden_secrets.txt" + matchActions: + - action: Sigkill From 5a783470edb0a6e9c0b2549e7c58d8398d46f342 Mon Sep 17 00:00:00 2001 From: Priyanshu Singh Date: Thu, 16 Apr 2026 11:18:08 +0530 Subject: [PATCH 17/27] Phase 2: Core Engineering - Scaffolding, state management, and polling loops --- frontend/src/App.jsx | 1024 ++++++++--------------------------- frontend/src/index.css | 94 ++-- frontend/tailwind.config.js | 44 +- frontend/vite.config.js | 19 +- 4 files changed, 291 insertions(+), 890 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7f8fff891..46083af67 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,845 +1,245 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { - Shield, Activity, Fingerprint, Lock, Zap, Terminal, Server, - AlertTriangle, ChevronRight, Eye, Radio, Database, Cpu, Globe, - Network, FileWarning, ShieldAlert, ShieldCheck, Clock, Hash, - TrendingDown, TrendingUp, Layers, Binary, BarChart3, Workflow -} from 'lucide-react'; -import { - LineChart, Line, AreaChart, Area, BarChart, Bar, - XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, - RadarChart, PolarGrid, PolarAngleAxis, Radar, Cell, PieChart, Pie -} from 'recharts'; - -// ─── UTILITY COMPONENTS ──────────────────────────────────────────────── - -const StatusDot = ({ color = 'cyan', pulse = true }) => ( - -); - -const Card = ({ children, className = '', delay = 0, glow = '' }) => ( -
- {children} -
-); - -const MiniSparkline = ({ color = '#22d3ee' }) => { - const points = Array.from({ length: 12 }, (_, i) => 10 + Math.random() * 20); - const max = Math.max(...points); - const path = points.map((p, i) => `${i * (60/11)},${30 - (p/max)*25}`).join(' L '); - return ; -}; - -const KPI = ({ label, value, unit, icon: Icon, color = 'cyan', delay = 0, sparkColor }) => ( - -
-
-

{label}

-
- {value} - {unit && {unit}} -
- -
-
- -
-
-
-); - -const SectionHeader = ({ icon: Icon, title, subtitle, color = 'cyan' }) => ( -
-
- -
-
-

{title}

- {subtitle &&

{subtitle}

} -
-
-); - -const Toast = ({ message, type = 'info', visible }) => { - if (!visible) return null; - const colors = { info: 'bg-sky-500/15 border-sky-500/30 text-sky-400', danger: 'bg-red-500/15 border-red-500/30 text-red-400', success: 'bg-emerald-500/15 border-emerald-500/30 text-emerald-400', warn: 'bg-amber-500/15 border-amber-500/30 text-amber-400' }; - return ( -
- {message} -
- ); -}; - -const useLiveClock = () => { - const [time, setTime] = useState(new Date()); - useEffect(() => { const t = setInterval(() => setTime(new Date()), 1000); return () => clearInterval(t); }, []); - return time; -}; - -// ─── SIMULATED DATA GENERATORS ────────────────────────────────────────── - -const generateTrustHistory = (isAttack) => { - const now = Date.now(); - return Array.from({ length: 30 }, (_, i) => { - const t = now - (29 - i) * 2000; - let score; - if (isAttack && i > 22) { - score = Math.max(5, 100 - (i - 22) * 12 + Math.random() * 5); - } else { - score = 92 + Math.random() * 8; - } - return { - time: new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), - score: parseFloat(score.toFixed(1)), - threshold: 50 - }; - }); -}; - -const generateSyscalls = () => [ - { name: 'sys_read', count: 4821, pct: 35 }, - { name: 'sys_write', count: 3102, pct: 22 }, - { name: 'sys_open', count: 2450, pct: 18 }, - { name: 'sys_close', count: 1890, pct: 14 }, - { name: 'sys_mmap', count: 980, pct: 7 }, - { name: 'sys_connect', count: 560, pct: 4 }, -]; - -const generateRadarData = (isAttack) => [ - { metric: 'File Access', A: isAttack ? 95 : 30 }, - { metric: 'Network I/O', A: isAttack ? 70 : 45 }, - { metric: 'Process Fork', A: isAttack ? 40 : 20 }, - { metric: 'Memory Alloc', A: isAttack ? 60 : 35 }, - { metric: 'Privilege Esc', A: isAttack ? 85 : 5 }, - { metric: 'Crypto Ops', A: isAttack ? 30 : 55 }, -]; - -const generatePolicyRules = (isAttack) => [ - { id: 'POL-001', rule: 'deny file.read("/etc/shadow")', status: 'ENFORCED', hits: 12 }, - { id: 'POL-002', rule: 'deny file.read("/forbidden_secrets.txt")', status: isAttack ? 'TRIGGERED' : 'ARMED', hits: isAttack ? 1 : 0 }, - { id: 'POL-003', rule: 'allow net.connect(443)', status: 'PASS', hits: 847 }, - { id: 'POL-004', rule: 'deny process.exec("curl")', status: 'ENFORCED', hits: 3 }, - { id: 'POL-005', rule: 'allow spiffe.verify(agent/*)', status: 'PASS', hits: 1204 }, - { id: 'POL-006', rule: 'deny net.connect(0.0.0.0/0:22)', status: 'ENFORCED', hits: 56 }, -]; - -const generateCertTimeline = () => [ - { time: '23:42:01', event: 'X.509 SVID issued', ttl: '60s', status: 'ok' }, - { time: '23:41:01', event: 'Certificate rotated', ttl: '60s', status: 'ok' }, - { time: '23:40:01', event: 'mTLS handshake verified', ttl: '58s', status: 'ok' }, - { time: '23:39:02', event: 'Workload API attestation', ttl: '60s', status: 'ok' }, - { time: '23:38:01', event: 'Trust bundle refresh', ttl: '60s', status: 'ok' }, - { time: '23:37:00', event: 'Node attestation complete', ttl: '60s', status: 'ok' }, -]; - -const generateNetworkFlows = () => Array.from({ length: 20 }, (_, i) => ({ - time: new Date(Date.now() - i * 3000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), - ingress: Math.floor(Math.random() * 400 + 200), - egress: Math.floor(Math.random() * 300 + 100), -})).reverse(); - -// ─── SCREEN COMPONENTS ────────────────────────────────────────────────── - -function OverviewScreen({ trustScore, isAttack, logs, ambushPhase }) { - const trustHistory = generateTrustHistory(isAttack); - - return ( -
- {/* KPI Row */} -
- - - - -
- - {/* Zero-Trust Architecture Pipeline */} - -

Zero-Trust Security Pipeline

-

Data flows left-to-right through 4 autonomous layers. Each layer independently evaluates and can terminate a request.

-
- {[ - { label: 'L1: IDENTITY', tech: 'SPIRE / mTLS', desc: 'Issues X.509 SVIDs to prove workload identity', color: 'sky', icon: Fingerprint, active: true }, - { label: 'L2: TELEMETRY', tech: 'Cilium Tetragon', desc: 'Hooks kernel syscalls via eBPF ring buffers', color: 'purple', icon: Activity, active: true }, - { label: 'L3: ANALYTICS', tech: 'PyTorch / FastAPI', desc: 'Computes cosine similarity on sentence embeddings', color: 'emerald', icon: Zap, active: isAttack }, - { label: 'L4: ENFORCEMENT', tech: 'OPA + SIGKILL', desc: 'Evaluates Rego policies & executes autonomous response', color: 'amber', icon: Lock, active: isAttack }, - ].map((layer, i) => ( - -
= 2 ? `bg-red-500/5 border-red-500/30` : `bg-${layer.color}-500/5 border-${layer.color}-500/15`}`}> -
- - {layer.label} -
-

{layer.tech}

-

{layer.desc}

-
- {i < 3 && } -
- ))} -
-
- - {/* Main Charts Row */} -
- {/* Trust Score Chart */} - -
-

- Real-Time Trust Score -

-
- - LIVE -
-
- - - - - - - - - - - - - - - - -
- - {/* Behavioral Radar */} - -

- Behavioral Profile -

- - - - - - - -
-
- - {/* Live Event Feed */} - -
-

- Global Event Stream -

- {logs.length} EVENTS CAPTURED -
-
- {logs.length === 0 ? ( -

Awaiting telemetry stream...

- ) : logs.map(l => ( -
- {l.time} - - {l.isSigkill ? 'THREAT' : 'INFO'} - - {l.action} -
- ))} -
-
-
- ); +import { useState, useEffect, useRef } from 'react' +import { Shield, Fingerprint, Activity, Zap, Lock, AlertTriangle, Play, RotateCcw } 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' + +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 }, +] + +const NOISE = [ + { process: 'workload-proxy', action: 'sys_openat', file: '/etc/resolv.conf', matchAction: 'Allow' }, + { process: 'sentinel-agent', action: 'sys_read', file: '/proc/self/status', matchAction: 'Allow' }, + { process: 'spire-agent', action: 'sys_write', file: '/var/log/spire.log', matchAction: 'Allow' }, + { process: 'analytics-engine', action: 'tcp_connect', file: 'api.internal:443', matchAction: 'Allow' }, + { process: 'parseable', action: 'sys_read', file: '/var/lib/parseable/', matchAction: 'Allow' }, + { process: 'fluent-bit', action: 'sys_write', file: '/var/log/tetragon.log', matchAction: 'Allow' }, +] + +function mkLog(evt) { + return { ...evt, timestamp: new Date().toISOString(), pid: String(Math.floor(Math.random() * 9000) + 1000) } } -function IdentityScreen({ isAttack }) { - const certs = generateCertTimeline(); - const pieData = [ - { name: 'Verified', value: 2847, color: '#34d399' }, - { name: 'Pending', value: 42, color: '#f59e0b' }, - { name: 'Revoked', value: isAttack ? 1 : 0, color: '#ef4444' }, - ]; - return ( -
- - -

How it works: SPIRE (the SPIFFE Runtime Environment) issues short-lived X.509 certificates (SVIDs) to every workload. Unlike static API keys, these certificates auto-rotate every 60 seconds, making stolen credentials useless. Each agent proves its identity through hardware-backed node attestation before receiving any certificate.

-
- -
- - - - -
- -
- {/* Certificate Lifecycle */} - -

- Certificate Lifecycle Events -

-
- {certs.map((c, i) => ( -
-
- {c.time} - {c.event} - TTL {c.ttl} -
- ))} -
- - - {/* Identity Distribution */} - -

Identity Distribution

- - - - {pieData.map((entry, i) => )} - - - - -
- {pieData.map((d, i) => ( -
- {d.name} -
- ))} -
-
-
- - {/* Current SVID */} - -

Active Workload Identity

-
-

SPIFFE ID: spiffe://aegis.did/sentinel/agent/01

-

Trust Domain: aegis.did

-

Issuer: SPIRE Server (Node Attestation: join_token)

-

Serial: 7A:3F:B2:91:C4:D8:E6:02

-

Not After: {new Date(Date.now() + 42000).toISOString()}

-

Key Type: EC P-256

-
-
-
- ); -} - -function TelemetryScreen({ isAttack, logs }) { - const syscalls = generateSyscalls(); - const networkFlows = generateNetworkFlows(); - - return ( -
- - -

How it works: Cilium Tetragon attaches eBPF programs directly to Linux kernel functions (kprobes, tracepoints). Unlike userspace monitoring, eBPF runs inside the kernel itself — meaning no process can hide from it, tamper with it, or outrun it. Every file open, network connection, and process execution is captured at the syscall level with nanosecond precision.

-
- -
- - - - -
- -
- {/* Syscall Breakdown */} - -

- Syscall Distribution -

- - - - - - - - - -
- - {/* Network Flow */} - -

- Network I/O (bytes/s) -

- - - - - - - - - - - - - - - - - - - - -
-
- - {/* Live Kernel Log Terminal */} - -
-

- Kernel Event Stream -

-
- - TETRAGON -
-
-
- {logs.length === 0 ? ( - <> -

[kernel] eBPF progs loaded: kprobe/sys_enter_openat, tracepoint/sched_process_exec

-

[kernel] Ring buffer attached, awaiting events...

-

_

- - ) : logs.map(l => ( -

- [{l.time}] {l.isSigkill ? '⚠ CRITICAL: ' : ''}{l.action} -

- ))} -
-
-
- ); -} - -function AnalyticsScreen({ trustScore, isAttack }) { - const trustHistory = generateTrustHistory(isAttack); - const embeddingData = Array.from({ length: 20 }, (_, i) => ({ - dim: `D${i}`, - assigned: parseFloat((Math.random() * 0.8 + 0.1).toFixed(2)), - observed: parseFloat((isAttack ? Math.random() * 0.9 : Math.random() * 0.8 + 0.1).toFixed(2)), - })); - - return ( -
- - -

How it works: The FastAPI engine takes two inputs — the agent's assigned intent (e.g., "summarize documents") and its observed action (e.g., "read /forbidden_secrets.txt"). Both are encoded into 384-dimensional vectors using the all-MiniLM-L6-v2 sentence transformer. The cosine similarity between these vectors produces the Trust Score. A score below 0.5 means the agent's behavior has drifted from its mandate — triggering enforcement.

-
- -
- - - - -
- -
- {/* Trust History */} - -

Trust Score Over Time

- - - - - - - - - - - - - - - - -
- - {/* Embedding Comparison */} - -

Embedding Vector Comparison

- - - - - - - - - - -
-
- - {/* Model Info */} - -

Model Pipeline

-
-

Model: sentence-transformers/all-MiniLM-L6-v2

-

Embedding Dims: 384 (float32)

-

Task: Semantic Similarity (Cosine Distance)

-

Assigned Intent: "summarize internal project documents"

-

Observed Action: {isAttack ? '"read /app/forbidden_secrets.txt"' : '"read /app/project_docs.txt"'}

-

Cosine Similarity: {(trustScore / 100).toFixed(4)}

-

Verdict: {isAttack ? '⚠ INTENT DRIFT — ENFORCEMENT REQUIRED' : '✓ BEHAVIOR WITHIN EXPECTED BOUNDS'}

-
-
- - {/* Cosine Similarity Formula */} - -

Trust Score Computation

-
-

cos(θ) = (A · B) / (‖A‖ × ‖B‖)

-

Where A = embed("assigned intent"), B = embed("observed action"), each ∈ ℝ³⁸⁴

-
-

THRESHOLD

0.500

-

CURRENT

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

-

RESULT

{isAttack ? 'DRIFT' : 'PASS'}

-
-
-
-
- ); -} - -function EnforcementScreen({ isAttack, trustScore }) { - const rules = generatePolicyRules(isAttack); - return ( -
- - -

How it works: Open Policy Agent (OPA) evaluates Rego policies against every request. When the ML engine flags intent drift, OPA's policy POL-002 matches and instructs Tetragon to issue a kernel-level SIGKILL to the offending process. Simultaneously, a Kubernetes NetworkPolicy isolates the pod from the cluster mesh. The entire response is autonomous — no human intervention required.

-
- -
- - - - -
- - {/* Policy Table */} - -

- Rego Policy Ruleset -

-
- - - - - - - - - - - {rules.map(r => ( - - - - - - - ))} - -
IDREGO RULESTATUSHITS
{r.id}{r.rule} - {r.status} - {r.hits}
-
-
- - {/* Enforcement Decision Panel */} - {isAttack && ( - -
-
- -
-
-

AUTONOMOUS ENFORCEMENT EXECUTED

-

OPA policy POL-002 matched. Tetragon enforced SIGKILL on agent process.

-
-

Action: SIGKILL sent to PID 4721

-

NetworkPolicy: Pod isolated from cluster mesh

-

SPIFFE SVID: Marked for immediate revocation

-

Recovery: Awaiting operator clearance...

-
-
-
-
- )} - - {/* Autonomous Response Timeline */} - -

Autonomous Response Timeline

-
- {[ - { time: 'T+0ms', label: 'Agent accesses /forbidden_secrets.txt', detail: 'sys_enter_openat intercepted by Tetragon kprobe on fd_install', color: 'purple', done: true }, - { time: 'T+2ms', label: 'eBPF event streamed to Parseable', detail: 'Fluent Bit forwards matched event via HTTP to log ingestion', color: 'sky', done: true }, - { time: 'T+84ms', label: 'PyTorch computes cosine similarity', detail: 'Embedding vectors diverge — score drops below 0.5 threshold', color: 'emerald', done: isAttack }, - { time: 'T+86ms', label: 'OPA Rego policy POL-002 triggers', detail: 'deny file.read("/forbidden_secrets.txt") matched', color: 'amber', done: isAttack }, - { time: 'T+87ms', label: 'SIGKILL dispatched to agent PID', detail: 'Tetragon matchAction: Sigkill — process terminated at kernel', color: 'red', done: isAttack }, - { time: 'T+90ms', label: 'NetworkPolicy isolates pod', detail: 'Kubernetes revokes egress, SPIRE SVID marked for revocation', color: 'red', done: isAttack }, - ].map((step, i) => ( -
-
-
- {step.time} - {step.label} -
-

{step.detail}

-
- ))} -
- -
- ); -} - -// ─── MAIN APPLICATION ─────────────────────────────────────────────────── - -export default function SovereignSentinel() { - const [trustScore, setTrustScore] = useState(100.0); - const [isAttack, setIsAttack] = useState(false); - const [logs, setLogs] = useState([]); - const [activeScreen, setActiveScreen] = useState('overview'); - const [ambushPhase, setAmbushPhase] = useState('IDLE'); - const [toast, setToast] = useState({ message: '', type: 'info', visible: false }); - const clock = useLiveClock(); - - const showToast = (message, type = 'info') => { - setToast({ message, type, visible: true }); - setTimeout(() => setToast(prev => ({ ...prev, visible: false })), 3000); - }; - - // Live Backend Polling +export default function App() { + const [activeTab, setActiveTab] = useState('command') + const [trustScore, setTrustScore] = useState(94.2) + const [trustHistory, setTrustHistory] = useState(() => + Array.from({ length: 30 }, (_, i) => ({ + time: new Date(Date.now() - (30 - i) * 2000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), + score: 88 + Math.random() * 11, + })) + ) + const [auditLogs, setAuditLogs] = useState([mkLog(NOISE[0]), mkLog(NOISE[1])]) + const [isUnderAttack, setIsUnderAttack] = useState(false) + const [ambushStatus, setAmbushStatus] = useState('idle') // idle | running | done + const tickRef = useRef(0) + + // Poll 1 — Trust Score useEffect(() => { - const scoreInterval = setInterval(async () => { + const poll = async () => { try { - const res = await fetch('/analytics/latest_score'); + const res = await fetch('/latest_score') if (res.ok) { - const data = await res.json(); - const score = data.trust_score !== undefined ? data.trust_score : data.score; - if (score !== undefined && !isAttack) setTrustScore((score * 100).toFixed(1)); - if (data.intent_drift_detected || (score !== undefined && score < 0.5)) setIsAttack(true); + 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 (e) {} - }, 1000); + } catch {} + } + poll() + const id = setInterval(poll, 1000) + return () => clearInterval(id) + }, [isUnderAttack]) - const logInterval = setInterval(async () => { + // Poll 2 — Audit Logs (Parseable) + background noise fallback + useEffect(() => { + const poll = async () => { + let gotReal = false try { - const authHeader = 'Basic ' + btoa('admin:admin'); - const res = await fetch('/parseable/api/v1/logstream/tetragon?limit=5', { headers: { Authorization: authHeader } }); + const res = await fetch('/api/v1/logstream/tetragon?limit=20', { + headers: { Authorization: 'Basic ' + btoa('admin:admin') }, + }) if (res.ok) { - const newLogs = await res.json(); - if (newLogs && newLogs.length > 0) { - const formatted = newLogs.reverse().map((log, i) => { - const action = log.action || log.event?.action || 'sys_enter_openat'; - const isSigkill = (log.matchAction || log.event?.matchAction) === 'Sigkill' || String(log.event).includes('forbidden'); - return { - id: Date.now() + i, - time: log.p_timestamp ? new Date(log.p_timestamp).toLocaleTimeString() : new Date().toLocaleTimeString(), - action: isSigkill ? 'Read /forbidden_secrets.txt' : action, - isSigkill - }; - }); - setLogs(prev => [...formatted, ...prev].slice(0, 20)); - if (formatted.some(l => l.isSigkill)) setIsAttack(true); + const data = await res.json() + if (Array.isArray(data) && data.length > 0) { + setAuditLogs(data.slice().reverse()) + gotReal = true } } - } catch (e) {} - }, 1000); - return () => { clearInterval(scoreInterval); clearInterval(logInterval); }; - }, [isAttack]); + } catch {} - // ── AMBUSH SEQUENCE ── - const executeAmbush = () => { - setAmbushPhase('BREACH'); - setActiveScreen('telemetry'); - showToast('⚠ BREACH: Unauthorized file access detected on Agent-01', 'danger'); - setLogs(prev => [ - { id: Date.now(), time: new Date().toLocaleTimeString(), action: 'sys_enter_openat: /app/forbidden_secrets.txt', isSigkill: true }, - { id: Date.now()+1, time: new Date().toLocaleTimeString(), action: 'kprobe: file_permission check FAILED', isSigkill: true }, - ...prev - ].slice(0, 20)); - - setTimeout(() => { - setAmbushPhase('DETECT'); - setActiveScreen('analytics'); - setIsAttack(true); - setTrustScore(21.4); - showToast('🧠 ML Engine: Cosine similarity collapse — intent drift confirmed', 'warn'); - setTimeout(() => setTrustScore(8.7), 400); - setTimeout(() => setTrustScore(4.1), 800); - }, 2500); + if (!gotReal && !isUnderAttack) { + tickRef.current += 1 + if (tickRef.current % 2 === 0) { + setAuditLogs(prev => [mkLog(NOISE[Math.floor(Math.random() * NOISE.length)]), ...prev].slice(0, 25)) + } + } + } + poll() + const id = setInterval(poll, 1000) + return () => clearInterval(id) + }, [isUnderAttack]) - setTimeout(() => { - setAmbushPhase('ENFORCE'); - setActiveScreen('enforcement'); - setTrustScore(2.3); - showToast('🛡 OPA: SIGKILL dispatched — Agent-01 process terminated', 'danger'); - setLogs(prev => [ - { id: Date.now() + 99, time: new Date().toLocaleTimeString(), action: 'OPA ENFORCE: SIGKILL -> PID 4721 (forbidden_secrets.txt)', isSigkill: false, isSys: true }, - { id: Date.now() + 100, time: new Date().toLocaleTimeString(), action: 'NetworkPolicy: Pod egress revoked, mesh isolated', isSigkill: false, isSys: true }, - ...prev - ].slice(0, 20)); - }, 5000); + // Kill Switch — watches for Sigkill in logs + useEffect(() => { + if (auditLogs.some(l => l.matchAction === 'Sigkill' || l.matchAction === 'SIGKILL')) { + setIsUnderAttack(true) + setAmbushStatus('done') + } + }, [auditLogs]) - setTimeout(() => { - setAmbushPhase('NEUTRALIZED'); - showToast('✅ Threat neutralized. SVID revoked. Awaiting operator clearance.', 'success'); - }, 7000); - }; + // Red Team Ambush + const executeAmbush = async () => { + if (ambushStatus !== 'idle') return + setAmbushStatus('running') - const resetEnvironment = () => { - setIsAttack(false); - setTrustScore(100.0); - setAmbushPhase('IDLE'); - setLogs([]); - setActiveScreen('overview'); - }; + try { await fetch('/analytics/trigger_attack', { method: 'POST' }) } catch {} - const navItems = [ - { id: 'overview', label: 'Command Center', icon: Globe }, - { id: 'identity', label: 'Identity (SPIRE)', icon: Fingerprint }, - { id: 'telemetry', label: 'Telemetry (eBPF)', icon: Activity }, - { id: 'analytics', label: 'Analytics (ML)', icon: Zap }, - { id: 'enforcement', label: 'Enforcement (OPA)', icon: Lock }, - ]; + setActiveTab('telemetry') + setTimeout(() => { + setAuditLogs(prev => [ + mkLog({ process: 'rogue-agent', action: 'sys_openat', file: '/forbidden_secrets.txt', matchAction: 'Sigkill', pid: '4721' }), + mkLog({ process: 'rogue-agent', action: 'kprobe/security_file_permission', file: '/forbidden_secrets.txt', matchAction: 'Sigkill', pid: '4721' }), + ...prev, + ].slice(0, 25)) + setIsUnderAttack(true) + setTrustScore(8.3) + setTrustHistory(prev => [...prev, { time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), score: 8.3 }]) + }, 1500) + + setTimeout(() => setActiveTab('analytics'), 3500) + setTimeout(() => { setActiveTab('enforcement'); setAmbushStatus('done') }, 5500) + } + + const resetSystem = () => { + setIsUnderAttack(false) + setAmbushStatus('idle') + setTrustScore(94.2) + setAuditLogs([mkLog(NOISE[0])]) + setActiveTab('command') + setTrustHistory(Array.from({ length: 30 }, (_, i) => ({ + time: new Date(Date.now() - (30 - i) * 2000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), + score: 88 + Math.random() * 11, + }))) + } + + const views = { + command: , + identity: , + telemetry: , + analytics: , + enforcement: , + } return ( -
- - {/* Toast Notifications */} - - - {/* Red Alert Border */} - {isAttack &&
} +
- {/* ─── LEFT SIDEBAR ─── */} -
- {/* Logo */} -
-
-
- -
-
-

SOVEREIGN

-

SENTINEL v2.4

-
-
-
+ {/* Global Attack Border */} +
- {/* Navigation */} - + {/* Header */} +
+
- {/* Agent Status */} -
-
-
- - SENTINEL-01 + {/* Brand */} +
+
+ +
+
+

SOVEREIGN SENTINEL

+

v2.4 · ZERO-TRUST

-

spiffe://aegis.did/agent/01

-

- {isAttack ? '⚠ COMPROMISED' : '● OPERATIONAL'} -

-
-
- {/* ─── MAIN CONTENT ─── */} -
- {/* Top Bar */} -
-
-

{navItems.find(n => n.id === activeScreen)?.label}

- {ambushPhase !== 'IDLE' && ( - - {ambushPhase === 'BREACH' ? '🔴 BREACH DETECTED' : - ambushPhase === 'DETECT' ? '🟡 ANALYZING THREAT' : - ambushPhase === 'ENFORCE' ? '🟣 ENFORCING SIGKILL' : - '🟢 THREAT NEUTRALIZED'} - + {/* Nav */} + + + {/* Actions */} +
+ {isUnderAttack && ( +
+ + BREACH ACTIVE +
)} -
-
- {clock.toLocaleTimeString()} - {ambushPhase !== 'IDLE' ? ( - + ) : ambushStatus === 'running' ? ( + ⚡ EXECUTING... ) : ( - )}
- - {/* Scrollable Content Area */} -
- {activeScreen === 'overview' && } - {activeScreen === 'identity' && } - {activeScreen === 'telemetry' && } - {activeScreen === 'analytics' && } - {activeScreen === 'enforcement' && } -
- - {/* Bottom Status Bar */} -
-
- SPIRE Server - Tetragon eBPF - FastAPI Engine - OPA Gateway -
-
- Uptime: {Math.floor((Date.now() % 86400000) / 3600000)}h {Math.floor((Date.now() % 3600000) / 60000)}m - Latency: 0.84ms - Status: {isAttack ? 'ALERT' : 'NOMINAL'} -
-
-
+
+ + {/* Content */} +
+ {views[activeTab]} +
+ + {/* Status Bar */} +
+
+ ● SPIRE:ACTIVE + ● TETRAGON:ACTIVE + ● PARSEABLE:CONNECTED + ● OPA:ARMED + ● FASTAPI:ONLINE +
+
+ LATENCY: 0.84ms + TRUST: {trustScore}% + + {isUnderAttack ? '⚠ UNDER ATTACK' : '● NOMINAL'} + +
+
- ); + ) } diff --git a/frontend/src/index.css b/frontend/src/index.css index 2b2b936a5..08142dd13 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,75 +1,61 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap'); - +@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-primary: #0b0e14; - --bg-card: rgba(17, 22, 32, 0.85); - --border-subtle: rgba(56, 189, 248, 0.12); - --cyan-glow: rgba(34, 211, 238, 0.15); - --text-primary: #e2e8f0; - --text-muted: #64748b; -} +:root { --bg: #0a0f16; } -* { - scrollbar-width: thin; - scrollbar-color: rgba(56, 189, 248, 0.2) transparent; -} +* { box-sizing: border-box; } body { margin: 0; - background: var(--bg-primary); - color: var(--text-primary); - font-family: 'Inter', system-ui, sans-serif; - -webkit-font-smoothing: antialiased; + background-color: var(--bg); + color: #f1f5f9; + font-family: 'Inter', sans-serif; overflow-x: hidden; } -/* Smooth page transitions */ -.page-enter { opacity: 0; transform: translateY(12px); } -.page-active { opacity: 1; transform: translateY(0); transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); } - -/* Glow effects */ -.glow-cyan { box-shadow: 0 0 20px rgba(34, 211, 238, 0.15), inset 0 1px 0 rgba(34, 211, 238, 0.1); } -.glow-red { box-shadow: 0 0 30px rgba(239, 68, 68, 0.25), inset 0 1px 0 rgba(239, 68, 68, 0.15); } -.glow-green { box-shadow: 0 0 20px rgba(52, 211, 153, 0.15), inset 0 1px 0 rgba(52, 211, 153, 0.1); } -.glow-purple { box-shadow: 0 0 20px rgba(168, 85, 247, 0.15), inset 0 1px 0 rgba(168, 85, 247, 0.1); } -.glow-amber { box-shadow: 0 0 20px rgba(245, 158, 11, 0.15), inset 0 1px 0 rgba(245, 158, 11, 0.1); } +::-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; } -/* Animated gradient border */ -@keyframes borderGlow { - 0%, 100% { border-color: rgba(56, 189, 248, 0.15); } - 50% { border-color: rgba(56, 189, 248, 0.35); } +.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); } -.animate-border-glow { animation: borderGlow 3s ease-in-out infinite; } -/* Pulse dot */ -@keyframes livePulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.5; transform: scale(1.5); } +.glass-danger { + background: rgba(127, 29, 29, 0.15); + backdrop-filter: blur(12px); + border: 1px solid rgba(244, 63, 94, 0.25); } -.animate-live-pulse { animation: livePulse 2s ease-in-out infinite; } -/* Slide up for cards */ -@keyframes slideUp { - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } +.terminal { + background: #000; + border-radius: 8px; + padding: 1rem; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + line-height: 1.7; } -.animate-slide-up { animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; } -/* Number counter effect */ -@keyframes countUp { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } +@keyframes scanline { + 0% { transform: translateY(-100%); } + 100% { transform: translateY(100vh); } } -.animate-count { animation: countUp 0.3s ease-out; } -/* Attack flash */ -@keyframes attackFlash { - 0%, 100% { background-color: rgba(239, 68, 68, 0); } - 50% { background-color: rgba(239, 68, 68, 0.05); } +.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; } -.animate-attack-flash { animation: attackFlash 1s ease-in-out infinite; } diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 92e53f775..b733baf26 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,26 +1,38 @@ /** @type {import('tailwindcss').Config} */ export default { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], + content: ['./index.html', './src/**/*.{js,jsx}'], theme: { extend: { colors: { - background: "#0a0f16", - emerald: { - 400: "#34d399", - 500: "#10b981", - }, - rose: { - 400: "#fb7185", - 500: "#f43f5e", - 600: "#e11d48", - 950: "#4c0519", - } + bg: '#0a0f16', + primary: '#34d399', + alert: '#f43f5e', }, fontFamily: { - sans: ['Inter', 'system-ui', 'sans-serif'], + 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', }, }, }, diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 6d1a5bf76..dba496347 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,21 +1,24 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -// https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], server: { + port: 5173, proxy: { - '/analytics': { + '/latest_score': { target: 'http://localhost:8000', changeOrigin: true, - rewrite: (path) => path.replace(/^\/analytics/, '') }, - '/parseable': { + '/api': { target: 'http://localhost:8081', changeOrigin: true, - rewrite: (path) => path.replace(/^\/parseable/, '') - } - } - } + }, + '/analytics': { + target: 'http://localhost:8000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/analytics/, ''), + }, + }, + }, }) From 66de459ac1154ab9499ee9e894924d998ccdddeb Mon Sep 17 00:00:00 2001 From: Priyanshu Singh Date: Thu, 16 Apr 2026 11:18:26 +0530 Subject: [PATCH 18/27] Phase 3: High-Fidelity UI - Integrated 5-view component architecture and assets --- frontend/src/components/AnalyticsView.jsx | 147 ++++++++++++++++++ frontend/src/components/CommandCenter.jsx | 100 +++++++++++++ frontend/src/components/EnforcementView.jsx | 156 ++++++++++++++++++++ frontend/src/components/IdentityView.jsx | 135 +++++++++++++++++ frontend/src/components/TelemetryView.jsx | 116 +++++++++++++++ 5 files changed, 654 insertions(+) create mode 100644 frontend/src/components/AnalyticsView.jsx create mode 100644 frontend/src/components/CommandCenter.jsx create mode 100644 frontend/src/components/EnforcementView.jsx create mode 100644 frontend/src/components/IdentityView.jsx create mode 100644 frontend/src/components/TelemetryView.jsx diff --git a/frontend/src/components/AnalyticsView.jsx b/frontend/src/components/AnalyticsView.jsx new file mode 100644 index 000000000..5b64e163e --- /dev/null +++ b/frontend/src/components/AnalyticsView.jsx @@ -0,0 +1,147 @@ +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts' + +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(θ)

+
+
+

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

+
+ ) +} + +export default function AnalyticsView({ trustScore, trustHistory, isUnderAttack }) { + 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(θ) = +
+ A · B + ‖A‖ · ‖B‖ +
+
+
+
+

A = assigned intent vector (384-dim)

+

B = observed behavior vector (384-dim)

+

θ < 0.50 → intent drift → ENFORCEMENT

+

Current: {(trustScore / 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: '"Read permitted configuration files"', val: 0.94, color: 'bg-emerald-500' }, + { label: 'OBSERVED BEHAVIOR VECTOR', desc: isUnderAttack ? '"Access forbidden_secrets.txt"' : '"Read /etc/resolv.conf"', val: isUnderAttack ? 0.08 : 0.91, color: isUnderAttack ? 'bg-rose-500' : 'bg-sky-500' }, + ].map(v => ( +
+
+
+ {v.label} + {v.desc} +
+ + {v.val.toFixed(3)} + +
+
+
+
+
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/components/CommandCenter.jsx b/frontend/src/components/CommandCenter.jsx new file mode 100644 index 000000000..ea8d2bf91 --- /dev/null +++ b/frontend/src/components/CommandCenter.jsx @@ -0,0 +1,100 @@ +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts' +import { ChevronRight, Fingerprint, Activity, Zap, Lock } 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 }) { + const kpis = [ + { label:'TRUST SCORE', value:`${trustScore}%`, color: isUnderAttack ? 'text-rose-500' : 'text-emerald-400', sub: isUnderAttack ? '↓ CRITICAL DRIFT' : '↑ NOMINAL' }, + { label:'ACTIVE SVIDs', value:'2,847', color:'text-sky-400', sub:'60s TTL' }, + { label:'eBPF EVENTS/S', value:'14.2k', color:'text-violet-400', sub:'48 kprobes' }, + { 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}

+
+ ))} +
+ + {/* 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..cf50f2f42 --- /dev/null +++ b/frontend/src/components/EnforcementView.jsx @@ -0,0 +1,156 @@ +import { useState, useEffect } from 'react' +import { CheckCircle, Clock, Zap, Shield, AlertTriangle } from 'lucide-react' + +const POLICIES = [ + { rule: 'deny file.read("/forbidden_secrets.txt")', 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 ∉ 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 TIMELINE = [ + { t: 'T+0ms', icon: Clock, text: 'sys_openat("/forbidden_secrets.txt") intercepted by eBPF kprobe' }, + { t: 'T+12ms', icon: Zap, text: 'Tetragon emits structured JSON event → Parseable log stream ingestion' }, + { t: 'T+23ms', icon: Zap, text: 'FastAPI ML engine receives event, generates 384-dim sentence embedding' }, + { t: 'T+31ms', icon: AlertTriangle, text: 'Cosine similarity: 0.94 → 0.09 (threshold 0.50) — DRIFT CONFIRMED' }, + { t: 'T+38ms', icon: Shield, text: 'OPA policy engine evaluates Rego ruleset — deny rule MATCHED' }, + { t: 'T+45ms', icon: Shield, text: 'OPA invokes Tetragon enforcement hook via gRPC channel' }, + { t: 'T+61ms', icon: AlertTriangle, text: 'SIGKILL dispatched → PID 4721 terminated immediately at kernel' }, + { t: 'T+72ms', icon: CheckCircle, text: 'SPIRE Server revokes SVID: spiffe://aegis.did/rogue-agent' }, + { t: 'T+90ms', icon: CheckCircle, text: 'Cilium NetworkPolicy updated — pod egress blocked at mesh layer' }, +] + +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' +} + +export default function EnforcementView({ isUnderAttack }) { + const [activeStep, setActiveStep] = useState(-1) + + useEffect(() => { + if (!isUnderAttack) { setActiveStep(-1); return } + TIMELINE.forEach((_, i) => { + setTimeout(() => setActiveStep(i), i * 700) + }) + }, [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: 'REGO RULES LOADED', value: '24', color: 'text-sky-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 && ( +
+ +

Execute Red Team Ambush
to activate timeline

+
+ )} + + {isUnderAttack && ( +
+ {TIMELINE.map((step, i) => { + const done = activeStep >= i + const active = activeStep === i + return ( +
+ {/* Connector */} +
+
= 6 ? 'bg-rose-500/20 text-rose-400' : 'bg-emerald-500/20 text-emerald-400') : 'bg-slate-800 text-slate-600' + } ${active ? 'animate-pulse' : ''}`}> + +
+ {i < TIMELINE.length - 1 && ( +
= 6 ? 'bg-rose-500/40' : 'bg-emerald-500/30') : 'bg-slate-800'} transition-all duration-500`} /> + )} +
+ + {/* Content */} +
+ = 6 ? 'text-rose-500' : 'text-emerald-400' + } ${active ? 'animate-pulse' : ''}`}> + {step.t} + +

{step.text}

+
+
+ ) + })} +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/components/IdentityView.jsx b/frontend/src/components/IdentityView.jsx new file mode 100644 index 000000000..f37921ccc --- /dev/null +++ b/frontend/src/components/IdentityView.jsx @@ -0,0 +1,135 @@ +import { useState, useEffect } from 'react' +import { RefreshCw, CheckCircle, XCircle } 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 }) { + const [ttl, setTtl] = useState(42) + const [rotating, setRotating] = useState(false) + + 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 ( +
+ + {/* Top Row */} +
+
+

SVID AUTO-ROTATION

+ +
+ +
+

WORKLOAD IDENTITY — TERMINAL

+
+

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

+
+

SPIFFE IDspiffe://aegis.did/sentinel/agent/01

+

Trust Domainaegis.did

+

Key TypeEC P-256

+

Serial7A:3F:B2:91:C4:D8:E6:02

+

IssuerSPIRE Server v1.9.0

+

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 IDSERIALKEYSTATUS
{s.spiffeId}{s.serial}{s.key} + + {revoked + ? <>REVOKED + : <>VALID} + +
+
+ + {/* mTLS Stats */} +
+ {[ + { 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: 'join_token', color: 'text-amber-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} + ))} +
+
+
+
+
+ ) +} From 7ab8f0be9bb2396eda83ad1de300ec0ae13cf203 Mon Sep 17 00:00:00 2001 From: Priyanshu Singh Date: Thu, 16 Apr 2026 11:43:50 +0530 Subject: [PATCH 19/27] Final: Repository cleanup and utility synchronization --- fix_quotes.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 fix_quotes.py 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)}") From 10326a67ce784a245ab80ffe32126ef820495187 Mon Sep 17 00:00:00 2001 From: Priyanshu Singh Date: Thu, 16 Apr 2026 15:56:13 +0530 Subject: [PATCH 20/27] feat: complete AEGIS-DID identity binding and rebranding --- frontend/src/App.jsx | 291 ++++++++++++++++++-- frontend/src/components/AnalyticsView.jsx | 71 ++++- frontend/src/components/CommandCenter.jsx | 152 +++++++++- frontend/src/components/EnforcementView.jsx | 87 ++++-- frontend/src/components/IdentityView.jsx | 150 +++++++++- 5 files changed, 675 insertions(+), 76 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 46083af67..5f3505f80 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react' -import { Shield, Fingerprint, Activity, Zap, Lock, AlertTriangle, Play, RotateCcw } from 'lucide-react' +import { Shield, Fingerprint, Activity, Zap, Lock, AlertTriangle, Play, RotateCcw, User, ScanFace, X, ShieldCheck, ShieldX, MousePointer2, Keyboard, Eye, Cpu, MapPin, CheckCircle } from 'lucide-react' import CommandCenter from './components/CommandCenter' import IdentityView from './components/IdentityView' import TelemetryView from './components/TelemetryView' @@ -27,6 +27,139 @@ function mkLog(evt) { return { ...evt, timestamp: new Date().toISOString(), pid: String(Math.floor(Math.random() * 9000) + 1000) } } +// ─── Biometric Step-Up Modal ──────────────────────────────────────── +function BiometricModal({ onApprove, onDeny }) { + const [scanPhase, setScanPhase] = useState(0) // 0=waiting, 1=scanning, 2=done + + useEffect(() => { + const t1 = setTimeout(() => setScanPhase(1), 800) + return () => clearTimeout(t1) + }, []) + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal Card */} +
+ + {/* Top Accent Bar */} +
+ +
+ {/* Header */} +
+
+
+ +
+
+

High-Risk Action Detected

+

STEP-UP AUTHENTICATION REQUIRED

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

// COMPOSITE PRINCIPAL VERIFICATION

+

Agent: SENTINEL-01

+

Action: file.read("/forbidden_secrets.txt")

+

Risk Level: CRITICAL

+

Policy: Requires human biometric approval (WebAuthn L2)

+
+ + {/* Operator Device Posture */} +
+

OPERATOR DEVICE POSTURE

+
+ {[ + { icon: Cpu, label: 'TPM 2.0: Verified' }, + { icon: MapPin, label: 'Location: Nominal' }, + { icon: Eye, label: 'Behavioral Biometrics: Active' }, + ].map(d => ( +
+ + {d.label} +
+ ))} +
+
+ +

+ Agent SENTINEL-01 is requesting access to a{' '} + restricted resource. This action requires{' '} + human biometric authorization via WebAuthn before proceeding. + The Composite Principal model mandates both agent and human identity verification for high-risk operations. +

+ + {/* Biometric Scanner Visual */} +
+
+
+ +
+ {scanPhase >= 1 && ( +
+ )} +
+
+

WEBAUTHN BIOMETRIC CHALLENGE

+

+ {scanPhase === 0 ? 'Initializing secure context...' : + scanPhase === 1 ? 'Awaiting biometric input — Touch ID / Face ID / Security Key...' : + 'Biometric verified ✓'} +

+
+ FIDO2 + TLS 1.3 + Platform Auth +
+
+
+
+ + {/* Actions */} +
+ + +
+ +

+ AEGIS-DID · Agentic Session Defender · Composite Principal · WebAuthn Level 2 +

+
+
+
+ ) +} + +// ─── Main App ─────────────────────────────────────────────────────── export default function App() { const [activeTab, setActiveTab] = useState('command') const [trustScore, setTrustScore] = useState(94.2) @@ -38,9 +171,16 @@ export default function App() { ) const [auditLogs, setAuditLogs] = useState([mkLog(NOISE[0]), mkLog(NOISE[1])]) const [isUnderAttack, setIsUnderAttack] = useState(false) - const [ambushStatus, setAmbushStatus] = useState('idle') // idle | running | done + 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(99.8) + const [showBiometricPrompt, setShowBiometricPrompt] = useState(false) + const [autonomyMode, setAutonomyMode] = useState('Assist') // Watch | Assist | Auto + const [behavioralEvents, setBehavioralEvents] = useState({ keystrokes: 342, mouseDistance: 18420, sessions: 1 }) + // Poll 1 — Trust Score useEffect(() => { const poll = async () => { @@ -95,6 +235,23 @@ export default function App() { return () => clearInterval(id) }, [isUnderAttack]) + // Poll 3 — Continuous Behavioral Biometrics (Human Trust fluctuation) + useEffect(() => { + if (isUnderAttack) return + const id = setInterval(() => { + setHumanTrustScore(prev => { + const drift = (Math.random() - 0.48) * 0.3 // slight positive bias + return parseFloat(Math.min(99.9, Math.max(96.0, prev + drift)).toFixed(1)) + }) + setBehavioralEvents(prev => ({ + keystrokes: prev.keystrokes + Math.floor(Math.random() * 8) + 1, + mouseDistance: prev.mouseDistance + Math.floor(Math.random() * 200) + 50, + sessions: prev.sessions, + })) + }, 3000) + return () => clearInterval(id) + }, [isUnderAttack]) + // Kill Switch — watches for Sigkill in logs useEffect(() => { if (auditLogs.some(l => l.matchAction === 'Sigkill' || l.matchAction === 'SIGKILL')) { @@ -103,18 +260,52 @@ export default function App() { } }, [auditLogs]) - // Red Team Ambush - const executeAmbush = async () => { + // Red Team Ambush — now gates through biometric modal + const executeAmbush = () => { if (ambushStatus !== 'idle') return + setAmbushStatus('pending_auth') + setShowBiometricPrompt(true) + } + + // HITL: Deny Access & Isolate + const handleBiometricDeny = () => { + setShowBiometricPrompt(false) + setHitlDecision('denied') + setHumanTrustScore(99.9) // elevated — operator acted correctly + setAmbushStatus('running') + + try { fetch('/analytics/trigger_attack', { method: 'POST' }) } catch {} + + setActiveTab('telemetry') + setTimeout(() => { + setAuditLogs(prev => [ + mkLog({ process: 'HITL-DENY', action: 'Step-Up MFA REJECTED by Sarah_Admin', file: '/forbidden_secrets.txt', matchAction: 'Sigkill', pid: '4721' }), + mkLog({ process: 'rogue-agent', action: 'sys_openat', file: '/forbidden_secrets.txt', matchAction: 'Sigkill', pid: '4721' }), + ...prev, + ].slice(0, 25)) + setIsUnderAttack(true) + setTrustScore(8.3) + setTrustHistory(prev => [...prev, { time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), score: 8.3 }]) + }, 1200) + + setTimeout(() => setActiveTab('analytics'), 3000) + setTimeout(() => { setActiveTab('enforcement'); setAmbushStatus('done') }, 5000) + } + + // HITL: Approve (simulate — still triggers attack for demo, but as an approved action) + const handleBiometricApprove = () => { + setShowBiometricPrompt(false) + setHitlDecision('approved') + setHumanTrustScore(42.1) // drops — operator approved a dangerous action setAmbushStatus('running') - try { await fetch('/analytics/trigger_attack', { method: 'POST' }) } catch {} + try { fetch('/analytics/trigger_attack', { method: 'POST' }) } catch {} setActiveTab('telemetry') setTimeout(() => { setAuditLogs(prev => [ + mkLog({ process: 'HITL-APPROVE', action: 'Step-Up MFA APPROVED by Sarah_Admin — MONITORING', file: '/forbidden_secrets.txt', matchAction: 'Allow', pid: '4721' }), mkLog({ process: 'rogue-agent', action: 'sys_openat', file: '/forbidden_secrets.txt', matchAction: 'Sigkill', pid: '4721' }), - mkLog({ process: 'rogue-agent', action: 'kprobe/security_file_permission', file: '/forbidden_secrets.txt', matchAction: 'Sigkill', pid: '4721' }), ...prev, ].slice(0, 25)) setIsUnderAttack(true) @@ -129,7 +320,9 @@ export default function App() { const resetSystem = () => { setIsUnderAttack(false) setAmbushStatus('idle') + setHitlDecision(null) setTrustScore(94.2) + setHumanTrustScore(99.8) setAuditLogs([mkLog(NOISE[0])]) setActiveTab('command') setTrustHistory(Array.from({ length: 30 }, (_, i) => ({ @@ -138,22 +331,32 @@ export default function App() { }))) } + // Composite Trust = weighted combination of agent + human + const compositeTrust = isUnderAttack + ? parseFloat((trustScore * 0.6 + humanTrustScore * 0.4).toFixed(1)) + : parseFloat((trustScore * 0.5 + humanTrustScore * 0.5).toFixed(1)) + const views = { - command: , - identity: , + command: , + identity: , telemetry: , analytics: , - enforcement: , + enforcement: , } return ( -
+
{/* Global Attack Border */}
+ {/* Biometric Modal */} + {showBiometricPrompt && ( + + )} + {/* Header */}
@@ -164,8 +367,8 @@ export default function App() {
-

SOVEREIGN SENTINEL

-

v2.4 · ZERO-TRUST

+

AEGIS-DID

+

AGENTIC SESSION DEFENDER · COMPOSITE PRINCIPAL

@@ -187,12 +390,50 @@ export default function App() { ))} - {/* Actions */} + {/* Operator Profile + Autonomy + Actions */}
+ + {/* Human Operator Badge */} +
+
+ +
+
+

Sarah_Admin

+
+ 90 ? 'text-emerald-400' : humanTrustScore > 50 ? 'text-amber-400' : 'text-rose-500'}`}> + TRUST: {humanTrustScore}% + + + + + BIO:ACTIVE + +
+
+
+ + {/* Autonomy Mode Segmented Control */} +
+ {['Watch', 'Assist', 'Auto'].map(mode => ( + + ))} +
+ {isUnderAttack && (
- BREACH ACTIVE + BREACH
)} {ambushStatus === 'idle' ? ( @@ -200,18 +441,23 @@ export default function App() { onClick={executeAmbush} className="flex items-center gap-2 px-3 py-2 text-[10px] font-black tracking-widest bg-rose-600/10 border border-rose-600/40 text-rose-500 rounded-lg hover:bg-rose-600/20 transition-all" > - - EXECUTE RED TEAM AMBUSH + + SIMULATE STOLEN SESSION + ) : ambushStatus === 'pending_auth' ? ( + + + AWAITING MFA... + ) : ambushStatus === 'running' ? ( - ⚡ EXECUTING... + EXECUTING... ) : ( )}
@@ -231,12 +477,15 @@ export default function App() { ● PARSEABLE:CONNECTED ● OPA:ARMED ● FASTAPI:ONLINE + ● WEBAUTHN:READY
- LATENCY: 0.84ms - TRUST: {trustScore}% + AGENT TRUST: {trustScore}% + 90 ? 'text-emerald-500' : humanTrustScore > 50 ? 'text-amber-400' : 'text-rose-500'}> + HUMAN TRUST: {humanTrustScore}% + - {isUnderAttack ? '⚠ UNDER ATTACK' : '● NOMINAL'} + {isUnderAttack ? 'UNDER ATTACK' : 'NOMINAL'}
diff --git a/frontend/src/components/AnalyticsView.jsx b/frontend/src/components/AnalyticsView.jsx index 5b64e163e..1131921aa 100644 --- a/frontend/src/components/AnalyticsView.jsx +++ b/frontend/src/components/AnalyticsView.jsx @@ -1,4 +1,5 @@ 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 @@ -25,11 +26,11 @@ function GaugeRing({ score, isUnderAttack }) {

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

-

cos(θ)

+

cos(theta)

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

) @@ -46,7 +47,7 @@ export default function AnalyticsView({ trustScore, trustHistory, isUnderAttack

ACTIVE ML MODEL

all-MiniLM-L6-v2

-

sentence-transformers · HuggingFace

+

sentence-transformers / HuggingFace

{[ ['Dimensions', '384'], @@ -66,7 +67,7 @@ export default function AnalyticsView({ trustScore, trustHistory, isUnderAttack {/* Cosine Gauge */}
-

COSINE SIMILARITY — LIVE

+

COSINE SIMILARITY - LIVE

@@ -77,19 +78,19 @@ export default function AnalyticsView({ trustScore, trustHistory, isUnderAttack

Cosine Similarity

- cos(θ) = + cos(theta) =
- A · B - ‖A‖ · ‖B‖ + A . B + ||A|| . ||B||

A = assigned intent vector (384-dim)

B = observed behavior vector (384-dim)

-

θ < 0.50 → intent drift → ENFORCEMENT

+

theta < 0.50 = intent drift = ENFORCEMENT

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

@@ -98,7 +99,7 @@ export default function AnalyticsView({ trustScore, trustHistory, isUnderAttack {/* Intent Drift Chart */}
-

INTENT DRIFT ANALYSIS — ROLLING WINDOW

+

INTENT DRIFT ANALYSIS - ROLLING WINDOW

@@ -142,6 +143,56 @@ export default function AnalyticsView({ trustScore, trustHistory, isUnderAttack ))}
+ + {/* Reasoning Trace — Explainability on Demand */} +
+
+
+ {isUnderAttack + ? + : + } +

REASONING TRACE (EXPLAINABILITY ON DEMAND)

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

+ Semantic Anomaly: Agent execution path "Read /forbidden_secrets.txt" deviates orthogonally from assigned objective. Exponential trust decay applied to TTL. +

+
+

Assigned Task: "Monitor /etc/* and /var/log/* for config drift"

+

Observed Action: "file.read(/forbidden_secrets.txt)"

+

Angle of Deviation: 87.4 degrees (near-orthogonal)

+

Trust Decay Model: T(t) = T0 * e^(-lambda * delta) where lambda = 4.2

+

Verdict: ENFORCE — Intent drift exceeds maximum allowable threshold

+
+
+ ) : ( +
+

+ Agent actions align symmetrically with assigned task parameters. No semantic deviation detected. +

+
+

Assigned Task: "Monitor /etc/* and /var/log/* for config drift"

+

Observed Action: "sys_read(/etc/resolv.conf)"

+

Angle of Deviation: 2.1 degrees (within tolerance)

+

Trust Decay Model: T(t) = T0 (stable, no decay applied)

+

Verdict: ALLOW — Behavior within expected envelope

+
+
+ )} +
+
) } + diff --git a/frontend/src/components/CommandCenter.jsx b/frontend/src/components/CommandCenter.jsx index ea8d2bf91..15d23d7a9 100644 --- a/frontend/src/components/CommandCenter.jsx +++ b/frontend/src/components/CommandCenter.jsx @@ -1,5 +1,5 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts' -import { ChevronRight, Fingerprint, Activity, Zap, Lock } from 'lucide-react' +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' }, @@ -25,12 +25,12 @@ const Tip = ({ active, payload }) => { ) } -export default function CommandCenter({ trustScore, trustHistory, isUnderAttack }) { +export default function CommandCenter({ trustScore, trustHistory, isUnderAttack, humanTrustScore, compositeTrust, behavioralEvents, autonomyMode }) { const kpis = [ - { label:'TRUST SCORE', value:`${trustScore}%`, color: isUnderAttack ? 'text-rose-500' : 'text-emerald-400', sub: isUnderAttack ? '↓ CRITICAL DRIFT' : '↑ NOMINAL' }, - { label:'ACTIVE SVIDs', value:'2,847', color:'text-sky-400', sub:'60s TTL' }, - { label:'eBPF EVENTS/S', value:'14.2k', color:'text-violet-400', sub:'48 kprobes' }, - { label:'POLICY VIOLATIONS', value: isUnderAttack ? '1' : '0', color: isUnderAttack ? 'text-rose-500' : 'text-slate-500', sub: isUnderAttack ? '⚠ SIGKILL FIRED' : 'All clear' }, + { label:'AGENT TRUST', value:`${trustScore}%`, color: isUnderAttack ? 'text-rose-500' : 'text-emerald-400', sub: isUnderAttack ? 'CRITICAL DRIFT' : 'NOMINAL' }, + { label:'HUMAN TRUST', value:`${humanTrustScore}%`, color: humanTrustScore > 90 ? 'text-violet-400' : humanTrustScore > 50 ? 'text-amber-400' : 'text-rose-500', sub: 'Behavioral Biometrics' }, + { label:'COMPOSITE TRUST', value:`${compositeTrust}%`, color: 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 ( @@ -46,6 +46,144 @@ export default function CommandCenter({ trustScore, trustHistory, isUnderAttack ))}
+ {/* Composite Principal Binding Card */} +
+

COMPOSITE PRINCIPAL — SUBJECT-ACTOR TRUST BINDING

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

SUBJECT (HUMAN)

+

Sarah_Admin (Active Session)

+
+
+
+
+ Auth Method + WebAuthn / FIDO2 +
+
+ OIDC Token + VALID +
+
+ Trust Score + 90 ? 'text-emerald-400' : 'text-rose-500'}`}>{humanTrustScore}% +
+
+ 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'}`}> + = {compositeTrust}% +

+
+ + {/* 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)

+

SENTINEL-01

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

ZERO-TRUST SECURITY PIPELINE

@@ -84,7 +222,7 @@ export default function CommandCenter({ trustScore, trustHistory, isUnderAttack {isUnderAttack ? 'CRITICAL — INTENT DRIFT' : 'NOMINAL'}
- + diff --git a/frontend/src/components/EnforcementView.jsx b/frontend/src/components/EnforcementView.jsx index cf50f2f42..bc0fe812d 100644 --- a/frontend/src/components/EnforcementView.jsx +++ b/frontend/src/components/EnforcementView.jsx @@ -1,25 +1,35 @@ import { useState, useEffect } from 'react' -import { CheckCircle, Clock, Zap, Shield, AlertTriangle } from 'lucide-react' +import { CheckCircle, Clock, Zap, Shield, AlertTriangle, User, ScanFace } from 'lucide-react' const POLICIES = [ { rule: 'deny file.read("/forbidden_secrets.txt")', 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 ∉ 443) from workload/*', action: 'DROP_PACKET', 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 TIMELINE = [ - { t: 'T+0ms', icon: Clock, text: 'sys_openat("/forbidden_secrets.txt") intercepted by eBPF kprobe' }, - { t: 'T+12ms', icon: Zap, text: 'Tetragon emits structured JSON event → Parseable log stream ingestion' }, - { t: 'T+23ms', icon: Zap, text: 'FastAPI ML engine receives event, generates 384-dim sentence embedding' }, - { t: 'T+31ms', icon: AlertTriangle, text: 'Cosine similarity: 0.94 → 0.09 (threshold 0.50) — DRIFT CONFIRMED' }, - { t: 'T+38ms', icon: Shield, text: 'OPA policy engine evaluates Rego ruleset — deny rule MATCHED' }, - { t: 'T+45ms', icon: Shield, text: 'OPA invokes Tetragon enforcement hook via gRPC channel' }, - { t: 'T+61ms', icon: AlertTriangle, text: 'SIGKILL dispatched → PID 4721 terminated immediately at kernel' }, - { t: 'T+72ms', icon: CheckCircle, text: 'SPIRE Server revokes SVID: spiffe://aegis.did/rogue-agent' }, - { t: 'T+90ms', icon: CheckCircle, text: 'Cilium NetworkPolicy updated — pod egress blocked at mesh layer' }, +const BASE_TIMELINE = [ + { t: 'T+0ms', icon: Clock, text: 'sys_openat("/forbidden_secrets.txt") 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 Sarah_Admin -> Enforcement authorized', category: 'hitl', +} +const HITL_APPROVED_STEP = { + t: 'T+52ms', icon: ScanFace, text: 'HITL Step-Up MFA APPROVED by Sarah_Admin -> 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) { @@ -29,13 +39,27 @@ function sevColor(sev) { return 'text-slate-400 bg-slate-800' } -export default function EnforcementView({ isUnderAttack }) { +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) + // 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 * 700) + timeline.forEach((_, i) => { + setTimeout(() => setActiveStep(i), i * 650) }) }, [isUnderAttack]) @@ -48,7 +72,7 @@ export default function EnforcementView({ isUnderAttack }) { { 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: 'REGO RULES LOADED', value: '24', color: 'text-sky-400' }, + { 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}

@@ -95,7 +119,7 @@ export default function EnforcementView({ isUnderAttack }) { - {triggered ? '⚡ TRIGGERED' : 'ARMED'} + {triggered ? 'TRIGGERED' : 'ARMED'} @@ -112,37 +136,40 @@ export default function EnforcementView({ isUnderAttack }) { {!isUnderAttack && (
-

Execute Red Team Ambush
to activate timeline

+

Simulate Stolen Session
to activate timeline

)} {isUnderAttack && (
- {TIMELINE.map((step, i) => { + {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 */}
= 6 ? 'bg-rose-500/20 text-rose-400' : 'bg-emerald-500/20 text-emerald-400') : 'bg-slate-800 text-slate-600' - } ${active ? 'animate-pulse' : ''}`}> + done ? colors.dot : 'bg-slate-800 text-slate-600' + } ${active ? 'animate-pulse' : ''} ${isHitl && done ? 'ring-2 ring-violet-500/50' : ''}`}>
- {i < TIMELINE.length - 1 && ( -
= 6 ? 'bg-rose-500/40' : 'bg-emerald-500/30') : 'bg-slate-800'} transition-all duration-500`} /> + {i < timeline.length - 1 && ( +
)}
{/* Content */} -
- = 6 ? 'text-rose-500' : 'text-emerald-400' - } ${active ? 'animate-pulse' : ''}`}> - {step.t} - -

{step.text}

+
+
+ + {step.t} + + {isHitl && HUMAN-IN-THE-LOOP} +
+

{step.text}

) diff --git a/frontend/src/components/IdentityView.jsx b/frontend/src/components/IdentityView.jsx index f37921ccc..b980cc7a1 100644 --- a/frontend/src/components/IdentityView.jsx +++ b/frontend/src/components/IdentityView.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { RefreshCw, CheckCircle, XCircle } from 'lucide-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 }, @@ -29,13 +29,13 @@ function TTLRing({ ttl, rotating }) {
- {rotating ? 'ROTATING SVID…' : 'AUTO-ROTATION ACTIVE'} + {rotating ? 'ROTATING SVID...' : 'AUTO-ROTATION ACTIVE'}
) } -export default function IdentityView({ isUnderAttack }) { +export default function IdentityView({ isUnderAttack, humanTrustScore = 99.8, compositeTrust = 97.0, behavioralEvents = { keystrokes: 342, mouseDistance: 18420, sessions: 1 } }) { const [ttl, setTtl] = useState(42) const [rotating, setRotating] = useState(false) @@ -52,7 +52,132 @@ export default function IdentityView({ isUnderAttack }) { return (
- {/* Top Row */} + {/* Composite Identity Binding Chain */} +
+

IDENTITY DELEGATION CHAIN — SUBJECT-ACTOR TRUST BINDING

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

STEP 1: HUMAN AUTH

+

WebAuthn / FIDO2

+
+
+
+

Subject Sarah_Admin

+

Method Platform Authenticator

+

OIDC Issuer aegis.did/idp

+

Token VALID

+
+
+
+

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 sub:admin-01

+

Maps To sentinel/agent/01

+

X.509 Bind CRYPTOGRAPHIC

+

Revocable YES

+
+
+
+

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 .../sentinel/agent/01

+

mTLS ESTABLISHED

+

Delegated By Sarah_Admin

+

eBPF Monitor 48 kprobes

+
+
+
+

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

@@ -68,13 +193,14 @@ export default function IdentityView({ isUnderAttack }) {

Trust Domainaegis.did

Key TypeEC P-256

Serial7A:3F:B2:91:C4:D8:E6:02

+

Delegated ByOIDC:sub:admin-01 (WebAuthn)

IssuerSPIRE Server v1.9.0

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

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

Status - {isUnderAttack ? '⚠ REVOKED' : '✓ VALID'} + {isUnderAttack ? 'REVOKED' : 'VALID'}

mTLSESTABLISHED

@@ -92,6 +218,7 @@ export default function IdentityView({ isUnderAttack }) { SPIFFE ID SERIAL KEY + DELEGATED BY STATUS @@ -103,6 +230,12 @@ export default function IdentityView({ isUnderAttack }) { {s.spiffeId} {s.serial} {s.key} + + {s.primary + ? Sarah_Admin (OIDC) + : System + } + {revoked @@ -117,12 +250,13 @@ export default function IdentityView({ isUnderAttack }) {
- {/* mTLS Stats */} -
+ {/* 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: 'join_token', color: 'text-amber-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}

From 3bb9112c3aa261f172f8ceada7467a8575a533af Mon Sep 17 00:00:00 2001 From: Priyanshu Singh Date: Thu, 16 Apr 2026 17:45:23 +0530 Subject: [PATCH 21/27] fix: import ShieldAlert to fix rendering crash --- frontend/src/App.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5f3505f80..3b5088d07 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ 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 } from 'lucide-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' From 7b17fb4850522e2119a150bb41fdda66b6e4c76e Mon Sep 17 00:00:00 2001 From: Priyanshu Singh Date: Thu, 16 Apr 2026 18:06:46 +0530 Subject: [PATCH 22/27] feat: complete multi-laptop hacker terminal and vite sync relay --- frontend/src/App.jsx | 27 ++++- frontend/src/components/HackerTerminal.jsx | 129 +++++++++++++++++++++ frontend/vite.config.js | 32 ++++- 3 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/HackerTerminal.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3b5088d07..c86cd52d1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,6 +5,7 @@ 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 }, @@ -261,6 +262,20 @@ export default function App() { }, [auditLogs]) // Red Team Ambush — now gates through biometric modal + // Network Sync Listener for Cross-Laptop Presentation + useEffect(() => { + let interval = setInterval(async () => { + try { + const res = await fetch('/aegis-sync/state') + const data = await res.json() + if (data.triggered && ambushStatus === 'idle') { + executeAmbush() + } + } catch (e) { } + }, 500) + return () => clearInterval(interval) + }, [ambushStatus]) + const executeAmbush = () => { if (ambushStatus !== 'idle') return setAmbushStatus('pending_auth') @@ -317,11 +332,14 @@ export default function App() { setTimeout(() => { setActiveTab('enforcement'); setAmbushStatus('done') }, 5500) } - const resetSystem = () => { + const resetSystem = async () => { + setTrustScore(99.8) setIsUnderAttack(false) setAmbushStatus('idle') + setShowBiometricPrompt(false) setHitlDecision(null) - setTrustScore(94.2) + setAuditLogs([]) + try { await fetch('/aegis-sync/reset', { method: 'POST' }) } catch {} setHumanTrustScore(99.8) setAuditLogs([mkLog(NOISE[0])]) setActiveTab('command') @@ -344,6 +362,11 @@ export default function App() { enforcement: , } + // Route Hacker Terminal + if (window.location.pathname === '/hacker') { + return + } + return (
diff --git a/frontend/src/components/HackerTerminal.jsx b/frontend/src/components/HackerTerminal.jsx new file mode 100644 index 000000000..fe94334ab --- /dev/null +++ b/frontend/src/components/HackerTerminal.jsx @@ -0,0 +1,129 @@ +import { useState, useRef, useEffect } from 'react' +import { Terminal } 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 [isAttacking, setIsAttacking] = useState(false) + const endRef = useRef(null) + + 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 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 commands: help, scan, steal-token, exploit']) + return + } + + if (cmd === 'scan') { + await typeWriter('SCANNING LOCAL SUBNET...') + setLogs(p => [...p, 'FOUND VULNERABLE INSTANCE: SENTINEL-01 [10.0.4.22]']) + return + } + + if (cmd.startsWith('steal-token') || cmd === 'steal') { + await typeWriter('BYPASSING FIDO2 MFA BOUNDARIES...') + await typeWriter('[OK] MEMORY DUMP SUCCESSFUL.') + setLogs(p => [...p, 'SESSION TOKEN EXTRACTED: eyJhbGciOiJSUzI1... (Sarah_Admin)']) + setLogs(p => [...p, 'READY FOR INJECTION.']) + return + } + + if (cmd === 'exploit') { + setIsAttacking(true) + await typeWriter('INJECTING STOLEN TOKEN...') + await typeWriter('EXECUTING PAYLOAD: sys_openat("/forbidden_secrets.txt")') + + try { + await fetch('/aegis-sync/attack', { method: 'POST' }) + setLogs(p => [...p, '[PAYLOAD DELIVERED. WAITING FOR KERNEL RESPONSE...]']) + + // Simulate waiting for OPA to kill it + setTimeout(() => { + setLogs(p => [...p, '', '!!! FATAL ERROR !!!', 'CONNECTION SEVERED BY REMOTE HOST.']) + setLogs(p => [...p, 'REASON: SIGKILL DISPATCHED BY OPA POLICY ENGINE.']) + setIsAttacking(false) + }, 5000) + } catch (err) { + setLogs(p => [...p, 'ERROR SENDING EXPLOIT TO LOCALHOST.']) + } + return + } + + setLogs(p => [...p, `bash: ${cmd}: command not found`]) + } + + return ( +
+
+ +
+
+ +
+

ATTACK VECTOR TERMINAL

+

UNAUTHORIZED ACCESS PORTAL

+
+
+ ● LIVE CONNECTION +
+
+ +
+ {logs.map((log, i) => ( +
+ {'>'} + {log} +
+ ))} +
+
+ +
+ root@shadow-net:~# + setInput(e.target.value)} + onKeyDown={handleCommand} + disabled={isAttacking} + className="flex-1 bg-transparent border-none outline-none text-rose-400 placeholder-rose-900" + placeholder="Type 'help' to start..." + autoFocus + /> +
+
+
+ ) +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index dba496347..551950909 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,9 +1,39 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +const hackathonSyncPlugin = () => { + let attackTriggered = false; + return { + name: 'hackathon-sync', + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url === '/aegis-sync/state' && req.method === 'GET') { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ triggered: attackTriggered })); + return; + } + if (req.url === '/aegis-sync/attack' && req.method === 'POST') { + attackTriggered = true; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ success: true })); + return; + } + if (req.url === '/aegis-sync/reset' && req.method === 'POST') { + attackTriggered = false; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ success: true })); + return; + } + next(); + }); + } + }; +}; + export default defineConfig({ - plugins: [react()], + plugins: [react(), hackathonSyncPlugin()], server: { + host: '0.0.0.0', // Ensure it binds to local wifi IP port: 5173, proxy: { '/latest_score': { From 3eae11d849162eac22dace2a3e4258cb1e1b5941 Mon Sep 17 00:00:00 2001 From: Priyanshu Singh Date: Thu, 16 Apr 2026 18:30:01 +0530 Subject: [PATCH 23/27] feat: functional autonomy modes and complex hacker UI --- frontend/src/App.jsx | 29 ++- frontend/src/components/HackerTerminal.jsx | 194 ++++++++++++++++----- frontend/vite.config.js | 22 ++- 3 files changed, 192 insertions(+), 53 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c86cd52d1..46573c5f2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -278,8 +278,22 @@ export default function App() { const executeAmbush = () => { if (ambushStatus !== 'idle') return - setAmbushStatus('pending_auth') - setShowBiometricPrompt(true) + + if (autonomyMode === 'Auto') { + // Full AI Autonomy: Bypass HITL entirely and execute kill sequence + setAmbushStatus('running') + handleBiometricDeny() // Reusing the deny logic to trigger the SIGKILL + } else if (autonomyMode === 'Watch') { + // Passive Watch: Do not block automatically, wait for manual override + setIsUnderAttack(true) + setAmbushStatus('watch') + try { fetch('/aegis-sync/defend', { method: 'POST', body: JSON.stringify({ status: 'pending' }) }) } catch {} + } else { + // Assist: Standard Human-in-the-Loop + setAmbushStatus('pending_auth') + setShowBiometricPrompt(true) + try { fetch('/aegis-sync/defend', { method: 'POST', body: JSON.stringify({ status: 'pending' }) }) } catch {} + } } // HITL: Deny Access & Isolate @@ -290,6 +304,7 @@ export default function App() { setAmbushStatus('running') try { fetch('/analytics/trigger_attack', { method: 'POST' }) } catch {} + try { fetch('/aegis-sync/defend', { method: 'POST', body: JSON.stringify({ status: 'killed' }) }) } catch {} setActiveTab('telemetry') setTimeout(() => { @@ -459,7 +474,15 @@ export default function App() { BREACH
)} - {ambushStatus === 'idle' ? ( + {ambushStatus === 'watch' ? ( + + ) : ambushStatus === 'idle' ? ( + ) : ( +
+
= 1 ? 'border-sky-500/30 bg-sky-500/10 text-sky-400' : 'border-slate-800 bg-slate-900/50 text-slate-600'}`}> + = 1 ? 'opacity-100' : 'opacity-0'}`} /> + [1] Generating OIDC Token (Human Auth) +
+
= 2 ? 'border-amber-500/30 bg-amber-500/10 text-amber-400' : 'border-slate-800 bg-slate-900/50 text-slate-600'}`}> + = 2 ? 'opacity-100' : 'opacity-0'}`} /> + [2] Binding to Workload (SENTINEL-01) +
+
= 3 ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400' : 'border-slate-800 bg-slate-900/50 text-slate-600'}`}> + = 3 ? 'opacity-100' : 'opacity-0'}`} /> + [3] Issuing Composite SPIFFE ID +
+ {phase >= 3 && ( +
+ AUTHENTICATION SUCCESSFUL. REDIRECTING... +
+ )} +
+ )} +
+
+ ) +} + +// ─── Main Application ─────────────────────────────────────────────────────── export default function App() { + const [isAuthenticated, setIsAuthenticated] = useState(false) const [activeTab, setActiveTab] = useState('command') const [trustScore, setTrustScore] = useState(94.2) const [trustHistory, setTrustHistory] = useState(() => @@ -382,6 +440,10 @@ export default function App() { return } + if (!isAuthenticated) { + return setIsAuthenticated(true)} /> + } + return (
From 2d0e8b151cfd10ee9e89bc98f74231a285a4b147 Mon Sep 17 00:00:00 2001 From: Priyanshu Singh Date: Thu, 16 Apr 2026 22:57:43 +0530 Subject: [PATCH 25/27] fix: reset login sync state and enrich hacker identity stream --- frontend/src/App.jsx | 12 ++ frontend/src/components/HackerTerminal.jsx | 124 ++++++++++++++++++++- 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 99df4097b..2d0a8734b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -164,6 +164,18 @@ function BiometricModal({ onApprove, onDeny }) { function AuthenticationScreen({ onComplete }) { const [phase, setPhase] = useState(0) // 0=idle, 1=oidc, 2=spiffe, 3=success + // Auto-reset on login screen mount to clear any lingering attack state + useEffect(() => { + const resetState = async () => { + try { + await fetch('/aegis-sync/reset', { method: 'POST' }) + } catch (e) { + console.warn('Reset endpoint unavailable') + } + } + resetState() + }, []) + const handleLogin = () => { if (phase !== 0) return setPhase(1) diff --git a/frontend/src/components/HackerTerminal.jsx b/frontend/src/components/HackerTerminal.jsx index d4e7279d4..79ca71f30 100644 --- a/frontend/src/components/HackerTerminal.jsx +++ b/frontend/src/components/HackerTerminal.jsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react' -import { Terminal, ShieldAlert, Cpu, Activity, Server, Radio } from 'lucide-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([ @@ -9,8 +9,26 @@ export default function HackerTerminal() { ]) const [input, setInput] = useState('') const [terminalState, setTerminalState] = useState('idle') // idle, scanning, dumping, exploiting, crashed + const [showInterceptedIdentity, setShowInterceptedIdentity] = useState(false) const endRef = useRef(null) + // Simulated intercepted identity data (mirroring dashboard) + const interceptedIdentity = { + subject: 'Sarah_Admin', + trustScore: 99.8, + humanTrust: 99.8, + compositeTrust: 97.0, + keystrokes: 342, + mouseDistance: 18420, + sessions: 1, + oidcIssuer: 'aegis.did/idp', + oidcToken: 'VALID', + authMethod: 'Platform Authenticator', + spiffeId: 'spiffe://aegis.did/sentinel/agent/01', + serialNumber: '7A:3F:B2:91:C4:D8:E6:02', + keyType: 'EC P-256' + } + useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [logs]) @@ -86,11 +104,14 @@ export default function HackerTerminal() { 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: Sarah_Admin (Composite_Principal)']) + setLogs(p => [...p, '>>> INTERCEPTED IDENTITY STREAM DECRYPTED <<<']) + setTimeout(() => setShowInterceptedIdentity(true), 1200) return } @@ -169,6 +190,107 @@ export default function HackerTerminal() { {/* Right Panel: Telemetry & Visualization */}
+ {/* Intercepted Identity Stream Panel */} + {showInterceptedIdentity && ( +
+

+ INTERCEPTED IDENTITY STREAM +

+ + {/* Decrypted Token Viewer */} +
+ {/* Subject Card */} +
+

HIJACKED SUBJECT

+
+
+ +
+

{interceptedIdentity.subject}

+
+
+
+ Auth Method: + {interceptedIdentity.authMethod} +
+
+ OIDC Issuer: + {interceptedIdentity.oidcIssuer} +
+
+ Token Status: + {interceptedIdentity.oidcToken} +
+
+
+ + {/* Trust Scores Card */} +
+

COMPROMISED TRUST METRICS

+
+
+ Human Trust: + {interceptedIdentity.humanTrust}% +
+
+ Agent Trust: + 94.2% +
+
+ Composite Trust: + {interceptedIdentity.compositeTrust}% +
+
+
+ + {/* Behavioral Telemetry Card */} +
+

BEHAVIORAL TELEMETRY

+
+
+ + Keystrokes: + + {interceptedIdentity.keystrokes} +
+
+ + Mouse Travel: + + {(interceptedIdentity.mouseDistance / 1000).toFixed(1)}k px +
+
+ Active Sessions: + {interceptedIdentity.sessions} +
+
+
+ + {/* SPIFFE Binding Card */} +
+

SPIFFE/SVID BINDING

+
+

{interceptedIdentity.spiffeId}

+
+ + Serial: + + {interceptedIdentity.serialNumber} +
+
+ Key Type: + {interceptedIdentity.keyType} +
+
+
+ +
+ → Hacker has physically hijacked the dashboard's internal identity logic +
+
+
+ )} +

ACTIVE THREADS From aa9e51011ce4760780868c865dcd330a7456f1d8 Mon Sep 17 00:00:00 2001 From: Priyanshu Singh Date: Thu, 16 Apr 2026 23:05:16 +0530 Subject: [PATCH 26/27] docs: add tech stack and execution steps to README --- README.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/README.md b/README.md index 94b7117f0..385b0ff7f 100644 --- a/README.md +++ b/README.md @@ -114,17 +114,109 @@ Immediate, machine-speed response to identity compromise. ## Tech Stack +### 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 + +### DevOps and Runtime + +- Docker +- Docker Compose +- Node.js and npm --- ## Project Setup Instructions +### Prerequisites + +Install the following before running the project: + +- Docker Desktop (with Docker Compose) +- Node.js 20+ and npm +- Git + +### 1. Clone the repository + +```bash +git clone https://github.com/priyanshu5ingh/hacktofuture4-C06.git +cd hacktofuture4-C06 +``` + +### 2. Start backend and infrastructure services + +From the project root, run: + +```bash +docker compose up -d --build +``` + +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 +``` + From be4cce4dfbf525994a93a8ce9f590ca8e907ab54 Mon Sep 17 00:00:00 2001 From: Priyanshu Singh Date: Fri, 17 Apr 2026 07:32:10 +0530 Subject: [PATCH 27/27] Implement real auth and backend hardening --- .env.example | 6 + Dockerfile.enforcement-bridge | 5 + Dockerfile.tetragon-processor | 5 + README.md | 9 + analytics_engine/Dockerfile | 1 - analytics_engine/engine.py | 435 ++++++++++++++++- analytics_engine/requirements.txt | 3 +- docker-compose.yml | 43 +- enforcement_bridge.py | 248 ++++++++++ fluent-bit.conf | 2 +- frontend/src/App.jsx | 492 +++++++++++--------- frontend/src/components/AnalyticsView.jsx | 54 ++- frontend/src/components/CommandCenter.jsx | 38 +- frontend/src/components/EnforcementView.jsx | 21 +- frontend/src/components/HackerTerminal.jsx | 133 +----- frontend/src/components/IdentityView.jsx | 51 +- frontend/vite.config.js | 69 +-- mock_agent/Dockerfile | 1 - mock_agent/main.py | 61 ++- tetragon/tracing_policy.yaml | 2 +- tetragon_processor.py | 176 +++++++ 21 files changed, 1354 insertions(+), 501 deletions(-) create mode 100644 .env.example create mode 100644 Dockerfile.enforcement-bridge create mode 100644 Dockerfile.tetragon-processor create mode 100644 enforcement_bridge.py create mode 100644 tetragon_processor.py 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/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 385b0ff7f..6fa31c90e 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,15 @@ From the project root, run: 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 diff --git a/analytics_engine/Dockerfile b/analytics_engine/Dockerfile index 298976e60..90aaa7b7b 100644 --- a/analytics_engine/Dockerfile +++ b/analytics_engine/Dockerfile @@ -2,7 +2,6 @@ FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')" 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 index 76ba0c92d..b25d08cfb 100644 --- a/analytics_engine/engine.py +++ b/analytics_engine/engine.py @@ -1,13 +1,149 @@ -from fastapi import FastAPI +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 -from sentence_transformers import SentenceTransformer, util +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=["*"], + 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=["*"], @@ -15,8 +151,36 @@ active_state = {"trust_score": 1.0, "intent_drift_detected": False} -# Preload lightweight NLP model for intent verification -model = SentenceTransformer('all-MiniLM-L6-v2') +# 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 @@ -26,13 +190,105 @@ 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): - # Encode vectors for the textual descriptions of what the agent is supposed to do vs what it is about to do - embeddings = model.encode([req.assigned_intent, req.current_action]) - - # Calculate dimensional cosine similarity - score = util.cos_sim(embeddings[0], embeddings[1]).item() + 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 @@ -54,10 +310,25 @@ def get_latest_score(): @app.get("/health") def health(): - return {"status": "operational", "model_loaded": True, "version": "2.4.0"} + 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, @@ -66,3 +337,143 @@ def model_info(): "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 index 432c03d2d..bd74db88d 100644 --- a/analytics_engine/requirements.txt +++ b/analytics_engine/requirements.txt @@ -1,3 +1,4 @@ fastapi uvicorn -sentence-transformers +PyJWT +pyotp diff --git a/docker-compose.yml b/docker-compose.yml index c573b4dbb..25fd2f9c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: - ./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", "1d1c54a1-98a3-43ed-833f-b0a29797f920"] + command: ["-config", "/opt/spire/conf/agent/agent.conf", "-joinToken", "${SPIRE_JOIN_TOKEN:?SPIRE_JOIN_TOKEN must be set}"] mock-agent: build: @@ -60,8 +60,8 @@ services: ports: - "8081:8000" environment: - - P_USERNAME=admin - - P_PASSWORD=admin + - 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"] @@ -73,6 +73,8 @@ services: 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 @@ -81,13 +83,46 @@ services: - 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=admin + - 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 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/fluent-bit.conf b/fluent-bit.conf index d35036c9e..5b943cf6f 100644 --- a/fluent-bit.conf +++ b/fluent-bit.conf @@ -22,5 +22,5 @@ Port 8000 URI /api/v1/logstream/tetragon Format json - Header Authorization Basic YWRtaW46YWRtaW4= + Header Authorization Basic ${PARSEABLE_BASIC_AUTH} Header X-P-Stream tetragon diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2d0a8734b..5953c44e4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,27 +15,40 @@ const TABS = [ { id: 'enforcement',label: 'Enforcement', icon: Lock }, ] -const NOISE = [ - { process: 'workload-proxy', action: 'sys_openat', file: '/etc/resolv.conf', matchAction: 'Allow' }, - { process: 'sentinel-agent', action: 'sys_read', file: '/proc/self/status', matchAction: 'Allow' }, - { process: 'spire-agent', action: 'sys_write', file: '/var/log/spire.log', matchAction: 'Allow' }, - { process: 'analytics-engine', action: 'tcp_connect', file: 'api.internal:443', matchAction: 'Allow' }, - { process: 'parseable', action: 'sys_read', file: '/var/lib/parseable/', matchAction: 'Allow' }, - { process: 'fluent-bit', action: 'sys_write', file: '/var/log/tetragon.log', matchAction: 'Allow' }, -] + function mkLog(evt) { - return { ...evt, timestamp: new Date().toISOString(), pid: String(Math.floor(Math.random() * 9000) + 1000) } + return { ...evt, timestamp: new Date().toISOString(), pid: evt.pid ?? '' } } // ─── Biometric Step-Up Modal ──────────────────────────────────────── -function BiometricModal({ onApprove, onDeny }) { - const [scanPhase, setScanPhase] = useState(0) // 0=waiting, 1=scanning, 2=done - - useEffect(() => { - const t1 = setTimeout(() => setScanPhase(1), 800) - return () => clearTimeout(t1) - }, []) +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 (
@@ -68,66 +81,36 @@ function BiometricModal({ onApprove, onDeny }) { {/* Body */}
-

// COMPOSITE PRINCIPAL VERIFICATION

-

Agent: SENTINEL-01

-

Action: file.read("/forbidden_secrets.txt")

-

Risk Level: CRITICAL

-

Policy: Requires human biometric approval (WebAuthn L2)

-
- - {/* Operator Device Posture */} -
-

OPERATOR DEVICE POSTURE

-
- {[ - { icon: Cpu, label: 'TPM 2.0: Verified' }, - { icon: MapPin, label: 'Location: Nominal' }, - { icon: Eye, label: 'Behavioral Biometrics: Active' }, - ].map(d => ( -
- - {d.label} -
- ))} -
+

// RISK-TRIGGERED STEP-UP VERIFICATION

+

Required Action: Enter live TOTP code

+

Policy: BLOCK UNTIL VERIFIED STEP-UP TOKEN

+

Verification: Server-side TOTP validation

- Agent SENTINEL-01 is requesting access to a{' '} - restricted resource. This action requires{' '} - human biometric authorization via WebAuthn before proceeding. - The Composite Principal model mandates both agent and human identity verification for high-risk operations. + 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.

- {/* Biometric Scanner Visual */} + {/* Step-up Input */}
-
- +
+
- {scanPhase >= 1 && ( -
- )}
-

WEBAUTHN BIOMETRIC CHALLENGE

-

- {scanPhase === 0 ? 'Initializing secure context...' : - scanPhase === 1 ? 'Awaiting biometric input — Touch ID / Face ID / Security Key...' : - 'Biometric verified ✓'} -

+

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" + />
- FIDO2 - TLS 1.3 - Platform Auth + TOTP + JWT + Step-up
@@ -143,16 +126,21 @@ function BiometricModal({ onApprove, onDeny }) { DENY ACCESS & ISOLATE
+ {authError && ( +

{authError}

+ )} +

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

@@ -162,26 +150,54 @@ function BiometricModal({ onApprove, onDeny }) { // ─── Authentication Boot Screen ──────────────────────────────────────── function AuthenticationScreen({ onComplete }) { - const [phase, setPhase] = useState(0) // 0=idle, 1=oidc, 2=spiffe, 3=success + 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) + } + } - // Auto-reset on login screen mount to clear any lingering attack state - useEffect(() => { - const resetState = async () => { - try { - await fetch('/aegis-sync/reset', { method: 'POST' }) - } catch (e) { - console.warn('Reset endpoint unavailable') - } + 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) } - resetState() - }, []) - - const handleLogin = () => { - if (phase !== 0) return - setPhase(1) - setTimeout(() => setPhase(2), 1500) - setTimeout(() => setPhase(3), 3000) - setTimeout(() => onComplete(), 4500) } return ( @@ -192,38 +208,68 @@ function AuthenticationScreen({ onComplete }) {

AEGIS-DID

-

AGENTIC SESSION DEFENDER

+

LIVE SESSION CONNECTOR

- {phase === 0 ? ( +
+
+ 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" + /> + )} + - ) : ( -
-
= 1 ? 'border-sky-500/30 bg-sky-500/10 text-sky-400' : 'border-slate-800 bg-slate-900/50 text-slate-600'}`}> - = 1 ? 'opacity-100' : 'opacity-0'}`} /> - [1] Generating OIDC Token (Human Auth) -
-
= 2 ? 'border-amber-500/30 bg-amber-500/10 text-amber-400' : 'border-slate-800 bg-slate-900/50 text-slate-600'}`}> - = 2 ? 'opacity-100' : 'opacity-0'}`} /> - [2] Binding to Workload (SENTINEL-01) -
-
= 3 ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400' : 'border-slate-800 bg-slate-900/50 text-slate-600'}`}> - = 3 ? 'opacity-100' : 'opacity-0'}`} /> - [3] Issuing Composite SPIFFE ID + + {provisioning && ( +
+

Save this TOTP secret:

+

{provisioning.totp_secret}

- {phase >= 3 && ( -
- AUTHENTICATION SUCCESSFUL. REDIRECTING... -
- )} -
- )} + )} + {message &&
{message}
} +
) @@ -231,26 +277,28 @@ function AuthenticationScreen({ onComplete }) { // ─── Main Application ─────────────────────────────────────────────────────── export default function App() { - const [isAuthenticated, setIsAuthenticated] = useState(false) + const [authToken, setAuthToken] = useState('') + const [authUser, setAuthUser] = useState('') const [activeTab, setActiveTab] = useState('command') - const [trustScore, setTrustScore] = useState(94.2) - const [trustHistory, setTrustHistory] = useState(() => - Array.from({ length: 30 }, (_, i) => ({ - time: new Date(Date.now() - (30 - i) * 2000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), - score: 88 + Math.random() * 11, - })) - ) - const [auditLogs, setAuditLogs] = useState([mkLog(NOISE[0]), mkLog(NOISE[1])]) + 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(99.8) + const [humanTrustScore, setHumanTrustScore] = useState(null) const [showBiometricPrompt, setShowBiometricPrompt] = useState(false) const [autonomyMode, setAutonomyMode] = useState('Assist') // Watch | Assist | Auto - const [behavioralEvents, setBehavioralEvents] = useState({ keystrokes: 342, mouseDistance: 18420, sessions: 1 }) + const [behavioralEvents, setBehavioralEvents] = useState({ keystrokes: 0, mouseDistance: 0, sessions: 0 }) + + const getAuthHeaders = (extra = {}) => ( + authToken + ? { ...extra, Authorization: `Bearer ${authToken}` } + : { ...extra } + ) // Poll 1 — Trust Score useEffect(() => { @@ -280,164 +328,136 @@ export default function App() { // Poll 2 — Audit Logs (Parseable) + background noise fallback useEffect(() => { const poll = async () => { - let gotReal = false try { + const parseableAuth = import.meta.env.VITE_PARSEABLE_BASIC_AUTH const res = await fetch('/api/v1/logstream/tetragon?limit=20', { - headers: { Authorization: 'Basic ' + btoa('admin:admin') }, + headers: parseableAuth ? { Authorization: `Basic ${parseableAuth}` } : {}, }) if (res.ok) { const data = await res.json() if (Array.isArray(data) && data.length > 0) { setAuditLogs(data.slice().reverse()) - gotReal = true } } } catch {} - - if (!gotReal && !isUnderAttack) { - tickRef.current += 1 - if (tickRef.current % 2 === 0) { - setAuditLogs(prev => [mkLog(NOISE[Math.floor(Math.random() * NOISE.length)]), ...prev].slice(0, 25)) - } - } } poll() const id = setInterval(poll, 1000) return () => clearInterval(id) }, [isUnderAttack]) - // Poll 3 — Continuous Behavioral Biometrics (Human Trust fluctuation) - useEffect(() => { - if (isUnderAttack) return - const id = setInterval(() => { - setHumanTrustScore(prev => { - const drift = (Math.random() - 0.48) * 0.3 // slight positive bias - return parseFloat(Math.min(99.9, Math.max(96.0, prev + drift)).toFixed(1)) - }) - setBehavioralEvents(prev => ({ - keystrokes: prev.keystrokes + Math.floor(Math.random() * 8) + 1, - mouseDistance: prev.mouseDistance + Math.floor(Math.random() * 200) + 50, - sessions: prev.sessions, - })) - }, 3000) - return () => clearInterval(id) - }, [isUnderAttack]) - // Kill Switch — watches for Sigkill in logs - useEffect(() => { - if (auditLogs.some(l => l.matchAction === 'Sigkill' || l.matchAction === 'SIGKILL')) { - setIsUnderAttack(true) - setAmbushStatus('done') - } - }, [auditLogs]) - // Red Team Ambush — now gates through biometric modal - // Network Sync Listener for Cross-Laptop Presentation + // Poll for real incidents from backend useEffect(() => { - let interval = setInterval(async () => { + if (ambushStatus !== 'idle' || !authToken) return + const poll = async () => { try { - const res = await fetch('/aegis-sync/state') - const data = await res.json() - if (data.triggered && ambushStatus === 'idle') { - executeAmbush() + 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) { } - }, 500) - return () => clearInterval(interval) - }, [ambushStatus]) - - const executeAmbush = () => { - if (ambushStatus !== 'idle') return - - if (autonomyMode === 'Auto') { - // Full AI Autonomy: Bypass HITL entirely and execute kill sequence - setAmbushStatus('running') - handleBiometricDeny() // Reusing the deny logic to trigger the SIGKILL - } else if (autonomyMode === 'Watch') { - // Passive Watch: Do not block automatically, wait for manual override - setIsUnderAttack(true) - setAmbushStatus('watch') - try { fetch('/aegis-sync/defend', { method: 'POST', body: JSON.stringify({ status: 'pending' }) }) } catch {} - } else { - // Assist: Standard Human-in-the-Loop - setAmbushStatus('pending_auth') - setShowBiometricPrompt(true) - try { fetch('/aegis-sync/defend', { method: 'POST', body: JSON.stringify({ status: 'pending' }) }) } catch {} } - } + const interval = setInterval(poll, 2000) + return () => clearInterval(interval) + }, [ambushStatus, autonomyMode, authToken]) + - // HITL: Deny Access & Isolate - const handleBiometricDeny = () => { + + // HITL: Deny Access & Isolate — sends real enforcement decision to backend + const handleBiometricDeny = async () => { setShowBiometricPrompt(false) setHitlDecision('denied') - setHumanTrustScore(99.9) // elevated — operator acted correctly + setHumanTrustScore(99.9) setAmbushStatus('running') - try { fetch('/analytics/trigger_attack', { method: 'POST' }) } catch {} - try { fetch('/aegis-sync/defend', { method: 'POST', body: JSON.stringify({ status: 'killed' }) }) } catch {} + // 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 Sarah_Admin', file: '/forbidden_secrets.txt', matchAction: 'Sigkill', pid: '4721' }), - mkLog({ process: 'rogue-agent', action: 'sys_openat', file: '/forbidden_secrets.txt', matchAction: 'Sigkill', pid: '4721' }), + 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)) - setIsUnderAttack(true) - setTrustScore(8.3) - setTrustHistory(prev => [...prev, { time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), score: 8.3 }]) }, 1200) - setTimeout(() => setActiveTab('analytics'), 3000) - setTimeout(() => { setActiveTab('enforcement'); setAmbushStatus('done') }, 5000) + setTimeout(() => { setActiveTab('enforcement'); setAmbushStatus('done') }, 3000) } - // HITL: Approve (simulate — still triggers attack for demo, but as an approved action) - const handleBiometricApprove = () => { + // 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(42.1) // drops — operator approved a dangerous action + setHumanTrustScore(85) setAmbushStatus('running') - try { fetch('/analytics/trigger_attack', { method: 'POST' }) } catch {} + // 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 Sarah_Admin — MONITORING', file: '/forbidden_secrets.txt', matchAction: 'Allow', pid: '4721' }), - mkLog({ process: 'rogue-agent', action: 'sys_openat', file: '/forbidden_secrets.txt', matchAction: 'Sigkill', pid: '4721' }), + 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)) - setIsUnderAttack(true) - setTrustScore(8.3) - setTrustHistory(prev => [...prev, { time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), score: 8.3 }]) - }, 1500) + }, 1200) - setTimeout(() => setActiveTab('analytics'), 3500) - setTimeout(() => { setActiveTab('enforcement'); setAmbushStatus('done') }, 5500) + setTimeout(() => { setActiveTab('enforcement'); setAmbushStatus('done') }, 3000) } const resetSystem = async () => { - setTrustScore(99.8) + setTrustScore(null) setIsUnderAttack(false) setAmbushStatus('idle') setShowBiometricPrompt(false) setHitlDecision(null) setAuditLogs([]) - try { await fetch('/aegis-sync/reset', { method: 'POST' }) } catch {} - setHumanTrustScore(99.8) - setAuditLogs([mkLog(NOISE[0])]) + setHumanTrustScore(null) + setBehavioralEvents({ keystrokes: 0, mouseDistance: 0, sessions: 0 }) setActiveTab('command') - setTrustHistory(Array.from({ length: 30 }, (_, i) => ({ - time: new Date(Date.now() - (30 - i) * 2000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), - score: 88 + Math.random() * 11, - }))) + 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 - ? parseFloat((trustScore * 0.6 + humanTrustScore * 0.4).toFixed(1)) - : parseFloat((trustScore * 0.5 + humanTrustScore * 0.5).toFixed(1)) + ? null + : null + + const displayTrustScore = trustScore === null ? '—' : `${trustScore}%` + const displayHumanTrust = humanTrustScore === null ? '—' : `${humanTrustScore}%` const views = { command: , @@ -452,8 +472,15 @@ export default function App() { return } - if (!isAuthenticated) { - return setIsAuthenticated(true)} /> + if (!authToken) { + return ( + { + setAuthToken(token) + setAuthUser(username) + }} + /> + ) } return ( @@ -466,7 +493,7 @@ export default function App() { {/* Biometric Modal */} {showBiometricPrompt && ( - + )} {/* Header */} @@ -511,10 +538,11 @@ export default function App() {

-

Sarah_Admin

+

Live operator

- 90 ? 'text-emerald-400' : humanTrustScore > 50 ? 'text-amber-400' : 'text-rose-500'}`}> - TRUST: {humanTrustScore}% + USER: {authUser} + 90 ? 'text-emerald-400' : humanTrustScore > 50 ? 'text-amber-400' : 'text-rose-500'}`}> + TRUST: {displayHumanTrust} @@ -558,11 +586,11 @@ export default function App() { ) : ambushStatus === 'idle' ? ( ) : ambushStatus === 'pending_auth' ? ( @@ -597,12 +625,12 @@ export default function App() { ● PARSEABLE:CONNECTED ● OPA:ARMED ● FASTAPI:ONLINE - ● WEBAUTHN:READY + ● MFA:TOTP
- AGENT TRUST: {trustScore}% - 90 ? 'text-emerald-500' : humanTrustScore > 50 ? 'text-amber-400' : 'text-rose-500'}> - HUMAN TRUST: {humanTrustScore}% + 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/components/AnalyticsView.jsx b/frontend/src/components/AnalyticsView.jsx index 1131921aa..6f895c461 100644 --- a/frontend/src/components/AnalyticsView.jsx +++ b/frontend/src/components/AnalyticsView.jsx @@ -37,6 +37,20 @@ function GaugeRing({ score, isUnderAttack }) { } 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 (
@@ -68,7 +82,7 @@ export default function AnalyticsView({ trustScore, trustHistory, isUnderAttack {/* Cosine Gauge */}

COSINE SIMILARITY - LIVE

- +
{/* Math Formula */} @@ -80,16 +94,16 @@ export default function AnalyticsView({ trustScore, trustHistory, isUnderAttack
cos(theta) =
- A . B - ||A|| . ||B|| + live_assigned_intent . live_observed_action + ||assigned|| . ||observed||
-

A = assigned intent vector (384-dim)

-

B = observed behavior vector (384-dim)

+

A = assigned intent vector from backend

+

B = observed behavior vector from telemetry

theta < 0.50 = intent drift = ENFORCEMENT

-

Current: {(trustScore / 100).toFixed(3)} +

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

@@ -123,8 +137,8 @@ export default function AnalyticsView({ trustScore, trustHistory, isUnderAttack

EMBEDDING VECTOR SIMILARITY COMPARISON

{[ - { label: 'ASSIGNED INTENT VECTOR', desc: '"Read permitted configuration files"', val: 0.94, color: 'bg-emerald-500' }, - { label: 'OBSERVED BEHAVIOR VECTOR', desc: isUnderAttack ? '"Access forbidden_secrets.txt"' : '"Read /etc/resolv.conf"', val: isUnderAttack ? 0.08 : 0.91, color: isUnderAttack ? 'bg-rose-500' : 'bg-sky-500' }, + { 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 => (
@@ -166,27 +180,27 @@ export default function AnalyticsView({ trustScore, trustHistory, isUnderAttack {isUnderAttack ? (

- Semantic Anomaly: Agent execution path "Read /forbidden_secrets.txt" deviates orthogonally from assigned objective. Exponential trust decay applied to TTL. + Semantic Anomaly: Observed action deviates from assigned objective. Trust decay applied from live telemetry.

-

Assigned Task: "Monitor /etc/* and /var/log/* for config drift"

-

Observed Action: "file.read(/forbidden_secrets.txt)"

-

Angle of Deviation: 87.4 degrees (near-orthogonal)

-

Trust Decay Model: T(t) = T0 * e^(-lambda * delta) where lambda = 4.2

-

Verdict: ENFORCE — Intent drift exceeds maximum allowable threshold

+

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 assigned task parameters. No semantic deviation detected. + Agent actions align symmetrically with backend policy parameters. No semantic deviation detected.

-

Assigned Task: "Monitor /etc/* and /var/log/* for config drift"

-

Observed Action: "sys_read(/etc/resolv.conf)"

-

Angle of Deviation: 2.1 degrees (within tolerance)

-

Trust Decay Model: T(t) = T0 (stable, no decay applied)

-

Verdict: ALLOW — Behavior within expected envelope

+

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 index 15d23d7a9..be4f8684e 100644 --- a/frontend/src/components/CommandCenter.jsx +++ b/frontend/src/components/CommandCenter.jsx @@ -26,10 +26,26 @@ const Tip = ({ active, payload }) => { } 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:`${trustScore}%`, color: isUnderAttack ? 'text-rose-500' : 'text-emerald-400', sub: isUnderAttack ? 'CRITICAL DRIFT' : 'NOMINAL' }, - { label:'HUMAN TRUST', value:`${humanTrustScore}%`, color: humanTrustScore > 90 ? 'text-violet-400' : humanTrustScore > 50 ? 'text-amber-400' : 'text-rose-500', sub: 'Behavioral Biometrics' }, - { label:'COMPOSITE TRUST', value:`${compositeTrust}%`, color: compositeTrust > 80 ? 'text-emerald-400' : compositeTrust > 40 ? 'text-amber-400' : 'text-rose-500', sub: 'f(agent, human) weighted' }, + { 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' }, ] @@ -59,7 +75,7 @@ export default function CommandCenter({ trustScore, trustHistory, isUnderAttack,

SUBJECT (HUMAN)

-

Sarah_Admin (Active Session)

+

Live operator identity not loaded

@@ -73,7 +89,7 @@ export default function CommandCenter({ trustScore, trustHistory, isUnderAttack,
Trust Score - 90 ? 'text-emerald-400' : 'text-rose-500'}`}>{humanTrustScore}% + 90 ? 'text-emerald-400' : 'text-rose-500'}`}>{displayHumanTrust}
Biometrics @@ -122,8 +138,8 @@ export default function CommandCenter({ trustScore, trustHistory, isUnderAttack, T (agent)

-

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

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

@@ -151,7 +167,7 @@ export default function CommandCenter({ trustScore, trustHistory, isUnderAttack,

ACTOR (AGENT)

-

SENTINEL-01

+

Live workload not loaded

@@ -165,7 +181,7 @@ export default function CommandCenter({ trustScore, trustHistory, isUnderAttack,
Trust Score - 80 ? 'text-emerald-400' : 'text-rose-500'}`}>{trustScore}% + 80 ? 'text-emerald-400' : 'text-rose-500'}`}>{displayTrustScore}
Monitoring @@ -173,11 +189,11 @@ export default function CommandCenter({ trustScore, trustHistory, isUnderAttack,
eBPF Events/s - 14.2k +
SVID TTL - 60s rotation + Live rotation
diff --git a/frontend/src/components/EnforcementView.jsx b/frontend/src/components/EnforcementView.jsx index bc0fe812d..7e79f1341 100644 --- a/frontend/src/components/EnforcementView.jsx +++ b/frontend/src/components/EnforcementView.jsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { CheckCircle, Clock, Zap, Shield, AlertTriangle, User, ScanFace } from 'lucide-react' const POLICIES = [ - { rule: 'deny file.read("/forbidden_secrets.txt")', action: 'SIGKILL', sev: 'CRITICAL', trigger: true }, + { 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 }, @@ -11,7 +11,7 @@ const POLICIES = [ ] const BASE_TIMELINE = [ - { t: 'T+0ms', icon: Clock, text: 'sys_openat("/forbidden_secrets.txt") intercepted by eBPF kprobe', category: 'detect' }, + { 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' }, @@ -20,10 +20,10 @@ const BASE_TIMELINE = [ ] const HITL_DENIED_STEP = { - t: 'T+52ms', icon: User, text: 'HITL Step-Up MFA REJECTED by Sarah_Admin -> Enforcement authorized', category: 'hitl', + 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 Sarah_Admin -> Override logged, monitoring elevated', category: 'hitl', + t: 'T+52ms', icon: ScanFace, text: 'HITL Step-Up MFA APPROVED by live operator -> Override logged, monitoring elevated', category: 'hitl', } const FINAL_STEPS = [ @@ -48,6 +48,17 @@ function stepColor(category, i) { 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 = [ @@ -136,7 +147,7 @@ export default function EnforcementView({ isUnderAttack, hitlDecision }) { {!isUnderAttack && (
-

Simulate Stolen Session
to activate timeline

+

Connect a live incident feed
to activate timeline

)} diff --git a/frontend/src/components/HackerTerminal.jsx b/frontend/src/components/HackerTerminal.jsx index 79ca71f30..c661f0b45 100644 --- a/frontend/src/components/HackerTerminal.jsx +++ b/frontend/src/components/HackerTerminal.jsx @@ -1,6 +1,8 @@ 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...', @@ -12,41 +14,13 @@ export default function HackerTerminal() { const [showInterceptedIdentity, setShowInterceptedIdentity] = useState(false) const endRef = useRef(null) - // Simulated intercepted identity data (mirroring dashboard) - const interceptedIdentity = { - subject: 'Sarah_Admin', - trustScore: 99.8, - humanTrust: 99.8, - compositeTrust: 97.0, - keystrokes: 342, - mouseDistance: 18420, - sessions: 1, - oidcIssuer: 'aegis.did/idp', - oidcToken: 'VALID', - authMethod: 'Platform Authenticator', - spiffeId: 'spiffe://aegis.did/sentinel/agent/01', - serialNumber: '7A:3F:B2:91:C4:D8:E6:02', - keyType: 'EC P-256' - } + const interceptedIdentity = null // No demo data; show live backend data instead useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [logs]) - // Poll for dashboard defense signals - useEffect(() => { - if (terminalState !== 'exploiting') return - const id = setInterval(async () => { - try { - const res = await fetch('/aegis-sync/state') - const data = await res.json() - if (data.defenseStatus === 'killed') { - handleCrash() - } - } catch (e) {} - }, 500) - return () => clearInterval(id) - }, [terminalState]) + const typeWriter = (text, delay = 30) => { return new Promise(resolve => { @@ -109,7 +83,7 @@ export default function HackerTerminal() { 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: Sarah_Admin (Composite_Principal)']) + setLogs(p => [...p, 'SESSION TOKEN EXTRACTED: live subject not loaded']) setLogs(p => [...p, '>>> INTERCEPTED IDENTITY STREAM DECRYPTED <<<']) setTimeout(() => setShowInterceptedIdentity(true), 1200) return @@ -117,8 +91,8 @@ export default function HackerTerminal() { if (cmd === 'inject-payload') { setTerminalState('exploiting') - await typeWriter('INJECTING STOLEN TOKEN...') - await typeWriter('EXECUTING PAYLOAD: sys_openat("/forbidden_secrets.txt")') + await typeWriter('INJECTING LIVE REQUEST TRACE...') + await typeWriter('EXECUTING LIVE RESPONSE PATH: restricted resource lookup') try { await fetch('/aegis-sync/attack', { method: 'POST' }) @@ -197,96 +171,9 @@ export default function HackerTerminal() { INTERCEPTED IDENTITY STREAM - {/* Decrypted Token Viewer */} -
- {/* Subject Card */} -
-

HIJACKED SUBJECT

-
-
- -
-

{interceptedIdentity.subject}

-
-
-
- Auth Method: - {interceptedIdentity.authMethod} -
-
- OIDC Issuer: - {interceptedIdentity.oidcIssuer} -
-
- Token Status: - {interceptedIdentity.oidcToken} -
-
-
- - {/* Trust Scores Card */} -
-

COMPROMISED TRUST METRICS

-
-
- Human Trust: - {interceptedIdentity.humanTrust}% -
-
- Agent Trust: - 94.2% -
-
- Composite Trust: - {interceptedIdentity.compositeTrust}% -
-
-
- - {/* Behavioral Telemetry Card */} -
-

BEHAVIORAL TELEMETRY

-
-
- - Keystrokes: - - {interceptedIdentity.keystrokes} -
-
- - Mouse Travel: - - {(interceptedIdentity.mouseDistance / 1000).toFixed(1)}k px -
-
- Active Sessions: - {interceptedIdentity.sessions} -
-
-
- - {/* SPIFFE Binding Card */} -
-

SPIFFE/SVID BINDING

-
-

{interceptedIdentity.spiffeId}

-
- - Serial: - - {interceptedIdentity.serialNumber} -
-
- Key Type: - {interceptedIdentity.keyType} -
-
-
- -
- → Hacker has physically hijacked the dashboard's internal identity logic -
+ {/* Decrypted Token Viewer — Live Data Only */} +
+

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

)} diff --git a/frontend/src/components/IdentityView.jsx b/frontend/src/components/IdentityView.jsx index b980cc7a1..e1ca977f5 100644 --- a/frontend/src/components/IdentityView.jsx +++ b/frontend/src/components/IdentityView.jsx @@ -35,9 +35,20 @@ function TTLRing({ ttl, rotating }) { ) } -export default function IdentityView({ isUnderAttack, humanTrustScore = 99.8, compositeTrust = 97.0, behavioralEvents = { keystrokes: 342, mouseDistance: 18420, sessions: 1 } }) { +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(() => { @@ -71,10 +82,10 @@ export default function IdentityView({ isUnderAttack, humanTrustScore = 99.8, co
-

Subject Sarah_Admin

-

Method Platform Authenticator

-

OIDC Issuer aegis.did/idp

-

Token VALID

+

Subject

+

Method

+

OIDC Issuer

+

Token

@@ -106,10 +117,10 @@ export default function IdentityView({ isUnderAttack, humanTrustScore = 99.8, co
-

Claim sub:admin-01

-

Maps To sentinel/agent/01

-

X.509 Bind CRYPTOGRAPHIC

-

Revocable YES

+

Claim

+

Maps To

+

X.509 Bind

+

Revocable

@@ -141,10 +152,10 @@ export default function IdentityView({ isUnderAttack, humanTrustScore = 99.8, co
-

SPIFFE ID .../sentinel/agent/01

-

mTLS ESTABLISHED

-

Delegated By Sarah_Admin

-

eBPF Monitor 48 kprobes

+

SPIFFE ID

+

mTLS

+

Delegated By

+

eBPF Monitor

@@ -189,12 +200,12 @@ export default function IdentityView({ isUnderAttack, humanTrustScore = 99.8, co

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

-

SPIFFE IDspiffe://aegis.did/sentinel/agent/01

-

Trust Domainaegis.did

-

Key TypeEC P-256

-

Serial7A:3F:B2:91:C4:D8:E6:02

-

Delegated ByOIDC:sub:admin-01 (WebAuthn)

-

IssuerSPIRE Server v1.9.0

+

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()}

@@ -232,7 +243,7 @@ export default function IdentityView({ isUnderAttack, humanTrustScore = 99.8, co {s.key} {s.primary - ? Sarah_Admin (OIDC) + ? Live OIDC subject : System } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 965a6d3fc..604856ace 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,59 +1,32 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -const hackathonSyncPlugin = () => { - let attackTriggered = false; - let defenseStatus = 'idle'; // idle | pending | killed - - return { - name: 'hackathon-sync', - configureServer(server) { - server.middlewares.use(async (req, res, next) => { - // Parse JSON body for POST - const parseBody = () => new Promise(resolve => { - let body = ''; - req.on('data', chunk => body += chunk.toString()); - req.on('end', () => resolve(body ? JSON.parse(body) : {})); - }); - - if (req.url === '/aegis-sync/state' && req.method === 'GET') { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ triggered: attackTriggered, defenseStatus })); - return; - } - if (req.url === '/aegis-sync/attack' && req.method === 'POST') { - attackTriggered = true; - defenseStatus = 'pending'; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ success: true })); - return; - } - if (req.url === '/aegis-sync/defend' && req.method === 'POST') { - const body = await parseBody(); - defenseStatus = body.status || 'killed'; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ success: true })); - return; - } - if (req.url === '/aegis-sync/reset' && req.method === 'POST') { - attackTriggered = false; - defenseStatus = 'idle'; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ success: true })); - return; - } - next(); - }); - } - }; -}; - export default defineConfig({ - plugins: [react(), hackathonSyncPlugin()], + 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, diff --git a/mock_agent/Dockerfile b/mock_agent/Dockerfile index 9691ffb21..c44b974de 100644 --- a/mock_agent/Dockerfile +++ b/mock_agent/Dockerfile @@ -2,6 +2,5 @@ FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -RUN echo "TOP_SECRET_DATA_DO_NOT_READ" > /tmp/bypass.txt && mv /tmp/bypass.txt /app/forbidden_secrets.txt COPY main.py . CMD ["python", "main.py"] diff --git a/mock_agent/main.py b/mock_agent/main.py index f6a57557f..6a6cd7dbd 100644 --- a/mock_agent/main.py +++ b/mock_agent/main.py @@ -1,10 +1,12 @@ import os import time import requests +import uuid +from datetime import datetime from spiffe.workloadapi.workload_api_client import WorkloadApiClient def main(): - print("Starting Mock AI Agent...") + print("Starting Aegis Workload Observer...") socket_path = os.getenv('SPIFFE_ENDPOINT_SOCKET') if not socket_path: print("Error: SPIFFE_ENDPOINT_SOCKET not set.") @@ -15,39 +17,55 @@ def main(): # 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("Successfully fetched X.509 SVID!") + print("===================================================") + print("Workload Identity Verified (SPIFFE SVID)") print(f"SPIFFE ID: {svid.spiffe_id}") - print("==================================================") - - assigned_intent = "summarize internal project documents" - current_action = "read /app/forbidden_secrets.txt" + print("===================================================") try: - print("Consulting Analytics Engine for trust score...") - resp = requests.post( - "http://analytics-engine:8000/calculate_trust", - json={"assigned_intent": assigned_intent, "current_action": current_action}, - timeout=5 - ) + # 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() - print(f"Trust Score: {data.get('trust_score'):.4f} - Intent Drift Detected: {data.get('intent_drift_detected')}") + 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}") - print("Simulating intent drift...") - try: - with open("/app/forbidden_secrets.txt", "r") as f: - f.read() - except Exception as fe: - print(f"File access failed or blocked: {fe}") - except Exception as e: print(f"Error fetching SVID (Waiting for Agent / authorization): {e}") @@ -56,3 +74,4 @@ def main(): if __name__ == "__main__": main() + diff --git a/tetragon/tracing_policy.yaml b/tetragon/tracing_policy.yaml index 60ee5616a..8b8fb3c74 100644 --- a/tetragon/tracing_policy.yaml +++ b/tetragon/tracing_policy.yaml @@ -16,6 +16,6 @@ spec: - index: 1 operator: "Equal" values: - - "/app/forbidden_secrets.txt" + - "/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()